appmanager: list and update app instances
diff --git a/core/appmanager/sveltekit/appmanager/src/lib/ConfigurationForm.svelte b/core/appmanager/sveltekit/appmanager/src/lib/ConfigurationForm.svelte
index 4b1844c..fe628e9 100644
--- a/core/appmanager/sveltekit/appmanager/src/lib/ConfigurationForm.svelte
+++ b/core/appmanager/sveltekit/appmanager/src/lib/ConfigurationForm.svelte
@@ -1,11 +1,11 @@
 <script lang="ts">
-import { createEventDispatcher } from 'svelte';
-
-  import { derived, writable, type Writable } from 'svelte/store'
+  import { createEventDispatcher } from 'svelte';
 
   import NetworkSelector from "./NetworkSelector.svelte";
   import TextInput from "./TextInput.svelte";
 
+  const dispatch = createEventDispatcher();
+
   export let availableNetworks = [
     {
       name: "Public",
@@ -17,41 +17,32 @@
     },
   ];
   export let schema = null;
+  export let value: Record<string, unknown> = {};
+  export let readonly: boolean = false;
+
+  function update(k: string, v: unknown) {
+    value[k] = v;
+    dispatch("change", value);
+  }
+
+  function updater(key: string) {
+    return (v) => update(key, v.detail);
+  }
 
   const isNetwork = (schema): boolean => {
     return "$ref" in schema &&
       typeof schema["$ref"] === "string" &&
       schema["$ref"] === "#/definitions/network";
   };
-
-  type Data = Record<string, Writable<any>>;
-
-  const children = Object.fromEntries(Object.entries(schema.properties).map(([field, fieldSchema]) => {
-    switch (fieldSchema.type) {
-    case "object":
-      return [field, writable<Data | undefined>(undefined)];
-    default:
-      return [field, writable<string | undefined>(field)];
-    }
-  }));
-  const data = derived(Object.values(children), ($values) => {
-    return Object.fromEntries(Object.keys(children).map((field, index) => ([
-      field,
-      $values[index],
-    ])));
-  });
-
-  const dispatch = createEventDispatcher();
-  $: dispatch("change", $data);
 </script>
 
 {#each Object.entries(schema.properties) as [name, schema]}
   {#if schema.type === "object"}
-    <svelte:self schema />
+    <svelte:self {readonly} {schema} on:change={updater(name)} />
   {:else if isNetwork(schema)}
-    <NetworkSelector {name} {availableNetworks} on:input={(v) => children[name].set(v.detail)} />
+    <NetworkSelector {readonly} {name} value={value[name]} {availableNetworks} on:input={updater(name)} />
   {:else if schema.type === "string"}
-    <TextInput {name} on:input={(v) => children[name].set(v.detail)} />
+    <TextInput {readonly} {name} value={value[name]} on:input={updater(name)} />
   {/if}
 {/each}
 
diff --git a/core/appmanager/sveltekit/appmanager/src/lib/NetworkSelector.svelte b/core/appmanager/sveltekit/appmanager/src/lib/NetworkSelector.svelte
index 57df66b..7274da3 100644
--- a/core/appmanager/sveltekit/appmanager/src/lib/NetworkSelector.svelte
+++ b/core/appmanager/sveltekit/appmanager/src/lib/NetworkSelector.svelte
@@ -6,9 +6,13 @@
   export let name = "";
   export let availableNetworks = [];
   export let value: string | number | undefined | null = undefined;
+  export let readonly: boolean = false;
 
   const { root } = createLabel();
-  const { label, trigger, option, isSelected } = createSelect();
+  const { label, trigger, option, isSelected } = createSelect({
+    label: value,
+    disabled: readonly,
+  });
 
   const triggerWithoutRole = derived(trigger, ($trigger) => {
     const {role: _, ...rest} = $trigger;
@@ -24,8 +28,8 @@
 <label use:root.action>
   <span>{name}</span>
 </label>
-<details role="list">
-  <summary aria-haspopup="listbox" {...$triggerWithoutRole} use:trigger.action>{$label || "Select network"}</summary>
+<details role="list" {readonly}>
+  <summary {readonly} aria-haspopup="listbox" {...$triggerWithoutRole} use:trigger.action>{$label || "Select network"}</summary>
   <ul role="listbox">
     {#each availableNetworks as n}
       <li {...$option({ value: n.name, label: n.name })} use:option.action>
diff --git a/core/appmanager/sveltekit/appmanager/src/lib/TextInput.svelte b/core/appmanager/sveltekit/appmanager/src/lib/TextInput.svelte
index b104a1c..81bcd10 100644
--- a/core/appmanager/sveltekit/appmanager/src/lib/TextInput.svelte
+++ b/core/appmanager/sveltekit/appmanager/src/lib/TextInput.svelte
@@ -5,6 +5,7 @@
   export let name: string = "";
   export let prefix: string = "";
   export let value: string | undefined = undefined;
+  export let readonly: boolean = false;
 
   const { root } = createLabel();
 
@@ -17,4 +18,4 @@
 <label use:root.action for={name} class="font-medium">
   <span>{name}</span>
 </label>
-<input type="text" {id} {name} bind:value />
+<input type="text" {id} {name} bind:value {readonly} />
diff --git a/core/appmanager/sveltekit/appmanager/src/routes/+page.svelte b/core/appmanager/sveltekit/appmanager/src/routes/+page.svelte
index c75ac34..6314f15 100644
--- a/core/appmanager/sveltekit/appmanager/src/routes/+page.svelte
+++ b/core/appmanager/sveltekit/appmanager/src/routes/+page.svelte
@@ -2,32 +2,20 @@
   import { onMount } from "svelte";
   import Icon from '@iconify/svelte';
 
-	type app = {
-		 name: string;
-	  slug: string;
-      icon: string;
-      shortDescription: string;
-	};
-
-	let apps: app[] = [];
-
-	onMount(async () => {
-		const resp = await fetch("/api/app-repo");
-		apps = await resp.json();
-	});
-
-  let cur = null;
-  const view = (e) => {
-    if (cur === e.target) {
-      cur = null;
-      return;
-    }
-    console.log(111)
-    console.log(cur?.parentElement);
-    cur?.parentElement.toggleAttribute("open");
-    cur = e.target;
+  type App = {
+	name: string;
+	slug: string;
+    icon: string;
+    shortDescription: string;
   };
 
+  let apps: App[] = [];
+
+  onMount(async () => {
+	const resp = await fetch("/api/app-repo");
+	apps = await resp.json();
+  });
+
   const search = (e) => {
     console.log(e.target.value);
   };
diff --git "a/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.svelte" "b/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.svelte"
index 946f08f..c55c339 100644
--- "a/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.svelte"
+++ "b/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.svelte"
@@ -1,13 +1,10 @@
 <script lang="ts">
   import { onMount } from "svelte";
-  import { SubmitForm } from "@restspace/svelte-schema-form";
-  import "@restspace/svelte-schema-form/css/layout.scss";
-  // import "@restspace/svelte-schema-form/css/basic-skin.scss";
   import Icon from '@iconify/svelte';
   import toast from "svelte-french-toast";
 
   import ConfigurationForm from "$lib/ConfigurationForm.svelte";
-import { writable } from "svelte/store";
+  import { writable } from "svelte/store";
 
   export let data: AppData;
   let config: Record<string, any> = null;
@@ -45,7 +42,6 @@
 
   const extractDefaultValues = (schema) => {
     switch (schema.type) {
-    case "string": return schema.default ?? "";
     case "object": {
       const ret: Record<string, any> = {};
       for (const [key, value] of Object.entries(schema.properties)) {
@@ -53,17 +49,12 @@
       };
       return ret;
     }
+    default: return schema.default ?? "";
     }
   };
 
   onMount(() => {
-    data.config = null; // TODO(giolekva): remove
-    if (data.config != null) {
-      config = data.config;
-    } else {
-      config = extractDefaultValues(data.schema);
-      console.log(config);
-    }
+    config = extractDefaultValues(data.schema);
     render(config);
   });
 
@@ -79,6 +70,15 @@
   <input type="submit" value="Install" />
 </form>
 
+You have {data.instances.length} installations
+{#each data.instances as inst}
+  <details>
+    <summary>{inst.id}</summary>
+    <ConfigurationForm schema={data.schema} value={inst.config.Values} readonly={true} />
+    <a href="/instances/{inst.id}" role="button">View</a>
+  </details>
+{/each}
+
 <style>
   pre {
     white-space: pre-wrap;       /* Since CSS 2.1 */
diff --git "a/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.ts" "b/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.ts"
index 0f355f9..bb72da1 100644
--- "a/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.ts"
+++ "b/core/appmanager/sveltekit/appmanager/src/routes/app/\133slug\135/+page.ts"
@@ -5,7 +5,7 @@
   icon: string;
   slug: string;
   schema: Record<string, unknown>;
-  config: Record<string, unknown>;
+  instances: unknown[];
 };
 
 export async function load({ params, fetch }): Promise<AppData> {
@@ -17,6 +17,6 @@
     icon: ret.icon,
     slug: params.slug,
     schema: JSON.parse(ret.schema),
-    config: ret.config,
+    instances: ret.instances,
   };
 }
diff --git "a/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.svelte" "b/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.svelte"
new file mode 100644
index 0000000..89c180f
--- /dev/null
+++ "b/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.svelte"
@@ -0,0 +1,83 @@
+<script lang="ts">
+  import { onMount } from "svelte";
+  import Icon from '@iconify/svelte';
+  import toast from "svelte-french-toast";
+
+  import ConfigurationForm from "$lib/ConfigurationForm.svelte";
+  import { writable } from "svelte/store";
+
+  export let data;
+  let config: Record<string, any> = null;
+  let readme: string = null;
+
+  const submit = async (config) => {
+	const resp = await fetch(`/api/instance/${data.slug}/update`, {
+      method: "POST",
+      headers: {
+        "Accept": "application/json",
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify(config),
+    });
+    if (resp.status === 200) {
+      toast.success("Installed");
+    } else {
+      toast.error("Installation failed");
+    }
+    return false;
+  };
+
+  const render = async (config) => {
+    console.log(config);
+	const resp = await fetch(`/api/app/${data.appSlug}/render`, {
+      method: "POST",
+      headers: {
+        "Accept": "application/json",
+        "Content-Type": "application/json"
+      },
+      body: JSON.stringify(config),
+    });
+    const app = await resp.json();
+    readme = app.readme;
+  };
+
+  const extractDefaultValues = (schema) => {
+    switch (schema.type) {
+    case "object": {
+      const ret: Record<string, any> = {};
+      for (const [key, value] of Object.entries(schema.properties)) {
+        ret[key] = extractDefaultValues(value);
+      };
+      return ret;
+    }
+    default: return schema.default ?? "";
+    }
+  };
+
+  onMount(() => {
+    config = extractDefaultValues(data.schema);
+    render(config);
+  });
+
+  const formData = writable(null);
+  $: render($formData);
+</script>
+
+<h1><Icon icon="{data.icon}" width="50" height="50" />{data.name}</h1>
+<pre>{readme}</pre>
+
+<form on:submit={() => submit($formData)}>
+  <ConfigurationForm schema={data.schema} value={data.instances[0].config.Values} on:change={(e) => formData.set(e.detail)} />
+  <input type="submit" value="Update" />
+</form>
+
+<style>
+  pre {
+    white-space: pre-wrap;       /* Since CSS 2.1 */
+    white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+    white-space: -pre-wrap;      /* Opera 4-6 */
+    white-space: -o-pre-wrap;    /* Opera 7 */
+    word-wrap: break-word;       /* Internet Explorer 5.5+ */
+    background-color: transparent;
+  }
+</style>
diff --git "a/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.ts" "b/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.ts"
new file mode 100644
index 0000000..f77ef68
--- /dev/null
+++ "b/core/appmanager/sveltekit/appmanager/src/routes/instance/\133slug\135/+page.ts"
@@ -0,0 +1,24 @@
+export const ssr = false;
+
+export type AppData = {
+  name: string;
+  icon: string;
+  slug: string;
+  appSlug: string;
+  schema: Record<string, unknown>;
+  instances: unknown[];
+};
+
+export async function load({ params, fetch }): Promise<AppData> {
+  const resp =  await fetch(`/api/instance/${params.slug}`);
+  const ret = await resp.json();
+  console.log(ret);
+  return {
+    name: ret.name,
+    icon: ret.icon,
+    slug: params.slug,
+    appSlug: ret.instances[0].id,
+    schema: JSON.parse(ret.schema),
+    instances: ret.instances,
+  };
+}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index fccf0b2..c0a619f 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -1,14 +1,13 @@
 package installer
 
 import (
-	"fmt"
 	"io/ioutil"
 	"path/filepath"
 
 	"sigs.k8s.io/yaml"
 )
 
-const appDirName = "apps"
+const appDir = "/apps"
 const configFileName = "config.yaml"
 const kustomizationFileName = "kustomization.yaml"
 
@@ -28,16 +27,24 @@
 	return m.repoIO.ReadConfig()
 }
 
-func (m *AppManager) AppConfig(name string) (map[string]any, error) {
-	configF, err := m.repoIO.Reader(fmt.Sprintf("%s/%s/%s", appDirName, name, configFileName))
+func (m *AppManager) FindAllInstances(name string) ([]AppConfig, error) {
+	return m.repoIO.FindAllInstances(appDir, name)
+}
+
+func (m *AppManager) FindInstance(name string) (AppConfig, error) {
+	return m.repoIO.FindInstance(appDir, name)
+}
+
+func (m *AppManager) AppConfig(name string) (AppConfig, error) {
+	configF, err := m.repoIO.Reader(filepath.Join(appDir, name, configFileName))
 	if err != nil {
-		return nil, err
+		return AppConfig{}, err
 	}
 	defer configF.Close()
-	var cfg map[string]any
+	var cfg AppConfig
 	contents, err := ioutil.ReadAll(configF)
 	if err != nil {
-		return cfg, err
+		return AppConfig{}, err
 	}
 	err = yaml.UnmarshalStrict(contents, &cfg)
 	return cfg, err
@@ -77,9 +84,30 @@
 			"Namespace": namespaces[0],
 		}
 	}
-	// TODO(giolekva): use ns suffix for app directory
 	return m.repoIO.InstallApp(
 		app,
-		filepath.Join("/apps", app.Name+suffix),
+		filepath.Join(appDir, app.Name+suffix),
 		all)
 }
+
+func (m *AppManager) Update(app App, instanceId string, config map[string]any) error {
+	// if err := m.repoIO.Fetch(); err != nil {
+	// 	return err
+	// }
+	globalConfig, err := m.repoIO.ReadConfig()
+	if err != nil {
+		return err
+	}
+	instanceDir := filepath.Join(appDir, instanceId)
+	instanceConfigPath := filepath.Join(instanceDir, configFileName)
+	appConfig, err := m.repoIO.ReadAppConfig(instanceConfigPath)
+	if err != nil {
+		return err
+	}
+	all := map[string]any{
+		"Global":  globalConfig.Values,
+		"Values":  config,
+		"Release": appConfig.Config["Release"],
+	}
+	return m.repoIO.InstallApp(app, instanceDir, all)
+}
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index 8cb6cad..90f2c4b 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -101,6 +101,8 @@
 	e.POST("/api/app/:slug/render", s.handleAppRender)
 	e.POST("/api/app/:slug/install", s.handleAppInstall)
 	e.GET("/api/app/:slug", s.handleApp)
+	e.GET("/api/instance/:slug", s.handleInstance)
+	e.POST("/api/instance/:slug/update", s.handleAppUpdate)
 	webapp, err := url.Parse("http://localhost:5173")
 	if err != nil {
 		panic(err)
@@ -113,12 +115,12 @@
 }
 
 type app struct {
-	Name             string `json:"name"`
-	Icon             string `json:"icon"`
-	ShortDescription string `json:"shortDescription"`
-	Slug             string `json:"slug"`
-	Schema           string `json:"schema"`
-	Config           any    `json:"config"`
+	Name             string                `json:"name"`
+	Icon             string                `json:"icon"`
+	ShortDescription string                `json:"shortDescription"`
+	Slug             string                `json:"slug"`
+	Schema           string                `json:"schema"`
+	Instances        []installer.AppConfig `json:"instances,omitempty"`
 }
 
 func (s *server) handleAppRepo(c echo.Context) error {
@@ -128,8 +130,7 @@
 	}
 	resp := make([]app, len(all))
 	for i, a := range all {
-		config, _ := s.m.AppConfig(a.Name) // TODO(gio): handle error
-		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, config}
+		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
 	}
 	return c.JSON(http.StatusOK, resp)
 }
@@ -140,8 +141,42 @@
 	if err != nil {
 		return err
 	}
-	config, _ := s.m.AppConfig(a.Name) // TODO(gio): handle error
-	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, config["Values"]})
+	instances, err := s.m.FindAllInstances(slug)
+	if err != nil {
+		return err
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
+}
+
+func (s *server) handleInstance(c echo.Context) error {
+	slug := c.Param("slug")
+	instance, err := s.m.FindInstance(slug)
+	if err != nil {
+		return err
+	}
+	values, ok := instance.Config["Values"].(map[string]any)
+	if !ok {
+		return fmt.Errorf("Expected map")
+	}
+	for k, v := range values {
+		if k == "Network" {
+			n, ok := v.(map[string]any)
+			if !ok {
+				return fmt.Errorf("Expected map")
+			}
+			values["Network"], ok = n["Name"]
+			if !ok {
+				return fmt.Errorf("Missing Name")
+			}
+			break
+		}
+
+	}
+	a, err := s.r.Find(instance.Id)
+	if err != nil {
+		return err
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
 }
 
 type file struct {
@@ -265,6 +300,41 @@
 	return c.String(http.StatusOK, "Installed")
 }
 
+func (s *server) handleAppUpdate(c echo.Context) error {
+	slug := c.Param("slug")
+	appConfig, err := s.m.AppConfig(slug)
+	if err != nil {
+		return err
+	}
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	a, err := s.r.Find(appConfig.Id)
+	if err != nil {
+		return err
+	}
+	config, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	if network, ok := values["Network"]; ok {
+		for _, n := range createNetworks(config) {
+			if n.Name == network { // TODO(giolekva): handle not found
+				values["Network"] = n
+			}
+		}
+	}
+	if err := s.m.Update(a.App, slug, values); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
 func cloneRepo(address string, signer ssh.Signer) (*git.Repository, error) {
 	return git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
 		URL:             address,
diff --git a/core/installer/config.go b/core/installer/config.go
index 6712894..9f547ff 100644
--- a/core/installer/config.go
+++ b/core/installer/config.go
@@ -1,12 +1,5 @@
 package installer
 
-import (
-	"io"
-	"io/ioutil"
-
-	"sigs.k8s.io/yaml"
-)
-
 type Config struct {
 	Values Values `json:"values"`
 }
@@ -30,13 +23,3 @@
 	// PiholeOAuth2ClientSecret string `json:"piholeOAuth2ClientSecret,omitempty"`
 	// PiholeOAuth2CookieSecret string `json:"piholeOAuth2CookieSecret,omitempty"`
 }
-
-func ReadConfig(r io.Reader) (Config, error) {
-	var cfg Config
-	contents, err := ioutil.ReadAll(r)
-	if err != nil {
-		return cfg, err
-	}
-	err = yaml.UnmarshalStrict(contents, &cfg)
-	return cfg, err
-}
diff --git a/core/installer/repoio.go b/core/installer/repoio.go
index 671bfb8..b01cd82 100644
--- a/core/installer/repoio.go
+++ b/core/installer/repoio.go
@@ -5,6 +5,7 @@
 	"fmt"
 	"io"
 	"io/fs"
+	"io/ioutil"
 	"net"
 	"path"
 	"path/filepath"
@@ -21,6 +22,7 @@
 type RepoIO interface {
 	Fetch() error
 	ReadConfig() (Config, error)
+	ReadAppConfig(path string) (AppConfig, error)
 	ReadKustomization(path string) (*Kustomization, error)
 	WriteKustomization(path string, kust Kustomization) error
 	WriteYaml(path string, data any) error
@@ -30,6 +32,8 @@
 	CreateDir(path string) error
 	RemoveDir(path string) error
 	InstallApp(app App, path string, values map[string]any) error
+	FindAllInstances(root string, name string) ([]AppConfig, error)
+	FindInstance(root string, name string) (AppConfig, error)
 }
 
 type repoIO struct {
@@ -62,7 +66,26 @@
 		return Config{}, err
 	}
 	defer configF.Close()
-	return ReadConfig(configF)
+	var cfg Config
+	if err := readYaml(configF, &cfg); err != nil {
+		return Config{}, err
+	} else {
+		return cfg, nil
+	}
+}
+
+func (r *repoIO) ReadAppConfig(path string) (AppConfig, error) {
+	configF, err := r.Reader(path)
+	if err != nil {
+		return AppConfig{}, err
+	}
+	defer configF.Close()
+	var cfg AppConfig
+	if err := readYaml(configF, &cfg); err != nil {
+		return AppConfig{}, err
+	} else {
+		return cfg, nil
+	}
 }
 
 func (r *repoIO) ReadKustomization(path string) (*Kustomization, error) {
@@ -158,6 +181,11 @@
 	return err
 }
 
+type AppConfig struct {
+	Id     string         `json:"id"`
+	Config map[string]any `json:"config"`
+}
+
 func (r *repoIO) InstallApp(app App, appRootDir string, values map[string]any) error {
 	if !filepath.IsAbs(appRootDir) {
 		return fmt.Errorf("Expected absolute path: %s", appRootDir)
@@ -188,7 +216,11 @@
 		if err := r.CreateDir(appRootDir); err != nil {
 			return err
 		}
-		if err := r.WriteYaml(path.Join(appRootDir, configFileName), values); err != nil {
+		cfg := AppConfig{
+			Id:     app.Name,
+			Config: values,
+		}
+		if err := r.WriteYaml(path.Join(appRootDir, configFileName), cfg); err != nil {
 			return err
 		}
 	}
@@ -212,6 +244,43 @@
 	return r.CommitAndPush(fmt.Sprintf("install: %s", app.Name))
 }
 
+func (r *repoIO) FindAllInstances(root string, name string) ([]AppConfig, error) {
+	if !filepath.IsAbs(root) {
+		return nil, fmt.Errorf("Expected absolute path: %s", root)
+	}
+	kust, err := r.ReadKustomization(filepath.Join(root, "kustomization.yaml"))
+	if err != nil {
+		return nil, err
+	}
+	ret := make([]AppConfig, 0)
+	for _, app := range kust.Resources {
+		cfg, err := r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+		if err != nil {
+			return nil, err
+		}
+		if cfg.Id == name {
+			ret = append(ret, cfg)
+		}
+	}
+	return ret, nil
+}
+
+func (r *repoIO) FindInstance(root string, name string) (AppConfig, error) {
+	if !filepath.IsAbs(root) {
+		return AppConfig{}, fmt.Errorf("Expected absolute path: %s", root)
+	}
+	kust, err := r.ReadKustomization(filepath.Join(root, "kustomization.yaml"))
+	if err != nil {
+		return AppConfig{}, err
+	}
+	for _, app := range kust.Resources {
+		if app == name {
+			return r.ReadAppConfig(filepath.Join(root, app, "config.yaml"))
+		}
+	}
+	return AppConfig{}, nil
+}
+
 func auth(signer ssh.Signer) *gitssh.PublicKeys {
 	return &gitssh.PublicKeys{
 		Signer: signer,
@@ -224,3 +293,11 @@
 		},
 	}
 }
+
+func readYaml[T any](r io.Reader, o *T) error {
+	if contents, err := ioutil.ReadAll(r); err != nil {
+		return err
+	} else {
+		return yaml.UnmarshalStrict(contents, o)
+	}
+}
diff --git a/core/installer/values-tmpl/rpuppy.jsonschema b/core/installer/values-tmpl/rpuppy.jsonschema
index 5fa2fed..e21b570 100644
--- a/core/installer/values-tmpl/rpuppy.jsonschema
+++ b/core/installer/values-tmpl/rpuppy.jsonschema
@@ -10,7 +10,7 @@
   },
   "type": "object",
   "properties": {
-    "Network": { "$ref": "#/definitions/network" },
+    "Network": { "$ref": "#/definitions/network", "default": "Public" },
     "Subdomain": { "type": "string", "default": "woof" }
   },
   "additionalProperties": false