appmanager: list and update app 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