DodoApp: Deploy Ingress resource for status page

Change-Id: I0f102664d655d060d0ba37a63e3681816457f79b
diff --git a/charts/ingress/templates/install.yaml b/charts/ingress/templates/install.yaml
index f2c839b..c50a741 100644
--- a/charts/ingress/templates/install.yaml
+++ b/charts/ingress/templates/install.yaml
@@ -1,13 +1,18 @@
 apiVersion: networking.k8s.io/v1
 kind: Ingress
 metadata:
-  name: ingress
+  name: ingress-{{ .Values.domain }}
   namespace: {{ .Release.Namespace }}
-  {{- if .Values.certificateIssuer }}
+  {{- if or .Values.certificateIssuer .Values.appRoot }}
   annotations:
+    {{- if .Values.certificateIssuer }}
     acme.cert-manager.io/http01-edit-in-place: "true"
     cert-manager.io/cluster-issuer: {{ .Values.certificateIssuer }}
     # nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
+    {{- end }}
+    {{- if .Values.appRoot }}
+    nginx.ingress.kubernetes.io/app-root: {{ .Values.appRoot }}
+    {{- end }}
   {{- end }}
 spec:
   ingressClassName: {{ .Values.ingressClassName }}
@@ -15,7 +20,7 @@
   tls:
   - hosts:
     - {{ .Values.domain }}
-    secretName: cert-rpuppy
+    secretName: cert-{{ .Values.domain }}
   {{- end }}
   rules:
   - host: {{ .Values.domain }}
diff --git a/charts/ingress/values.yaml b/charts/ingress/values.yaml
index 18477aa..0640557 100644
--- a/charts/ingress/values.yaml
+++ b/charts/ingress/values.yaml
@@ -1,6 +1,7 @@
 ingressClassName: ingress-public
 certificateIssuer: example-public
 domain: woof.example.com
+appRoot: ""
 service:
   name: woof
   port:
diff --git a/core/installer/app.go b/core/installer/app.go
index d2c5d00..d86c9f3 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -41,6 +41,7 @@
 	URL             string
 	Help            []HelpDocument
 	Icon            string
+	Raw             []byte
 }
 
 type HelpDocument struct {
@@ -318,6 +319,7 @@
 	if err != nil {
 		return rendered{}, err
 	}
+	ret.Raw = full
 	ret.Data["rendered.json"] = full
 	readme, err := res.LookupPath(cue.ParsePath("readme")).String()
 	if err != nil {
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index 477a68d..34e0a72 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -189,11 +189,8 @@
 	}
 	for key, value in _ingressValidate {
 		for ing, ingValue in value.out.helm {
-            // TODO(gio): support multiple ingresses
-			// "\(key)-\(ing)": #Helm & ingValue & {
-			"\(ing)": #Helm & ingValue & {
-				// name: "\(key)-\(ing)"
-				name: ing
+			"\(key)-\(ing)": #Helm & ingValue & {
+				name: "\(key)-\(ing)"
 			}
 		}
 	}
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
index a9edbde..9a306f5 100644
--- a/core/installer/app_configs/app_global_env.cue
+++ b/core/installer/app_configs/app_global_env.cue
@@ -23,12 +23,14 @@
 	auth: #Auth
 	network: #Network
 	subdomain: string
+	appRoot: string | *""
 	service: close({
 		name: string
 		port: close({ name: string }) | close({ number: int & > 0 })
 	})
 
 	_domain: "\(subdomain).\(network.domain)"
+	_appRoot: appRoot
     _authProxyHTTPPortName: "http"
 
 	out: {
@@ -81,12 +83,13 @@
 					}
 				}
 			}
-			ingress: {
+			"\(_domain)": {
 				chart: charts.ingress
 				_service: service
                 info: "Generating TLS certificate for https://\(_domain)"
 				values: {
 					domain: _domain
+					appRoot: _appRoot
 					ingressClassName: network.ingressClass
 					certificateIssuer: network.certificateIssuer
 					service: {
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 4772076..7274e03 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -288,7 +288,9 @@
 }
 
 type ReleaseResources struct {
-	Helm []Resource
+	Release     Release
+	Helm        []Resource
+	RenderedRaw []byte
 }
 
 // TODO(gio): rename to CommitApp
@@ -297,21 +299,21 @@
 	appDir string,
 	name string,
 	config any,
-	ports []PortForward,
 	resources CueAppData,
 	data CueAppData,
 	opts ...InstallOption,
-) (ReleaseResources, error) {
+) error {
 	var o installOptions
 	for _, i := range opts {
 		i(&o)
 	}
 	dopts := []soft.DoOption{}
-	// NOTE(gio): Expects caller to have pulled already
-	dopts = append(dopts, soft.WithNoPull())
 	if o.Branch != "" {
 		dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
 	}
+	if o.NoPull {
+		dopts = append(dopts, soft.WithNoPull())
+	}
 	if o.NoPublish {
 		dopts = append(dopts, soft.WithNoCommit())
 	}
@@ -321,7 +323,7 @@
 	if o.NoLock {
 		dopts = append(dopts, soft.WithNoLock())
 	}
-	return ReleaseResources{}, repo.Do(func(r soft.RepoFS) (string, error) {
+	return repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := r.RemoveDir(appDir); err != nil {
 			return "", err
 		}
@@ -329,49 +331,55 @@
 		if err := r.CreateDir(resourcesDir); err != nil {
 			return "", err
 		}
-		{
+		if err := func() error {
 			if err := soft.WriteFile(r, path.Join(appDir, gitIgnoreFileName), includeEverything); err != nil {
-				return "", err
+				return err
 			}
 			if err := soft.WriteYaml(r, path.Join(appDir, configFileName), config); err != nil {
-				return "", err
+				return err
 			}
 			if err := soft.WriteJson(r, path.Join(appDir, "config.json"), config); err != nil {
-				return "", err
+				return err
 			}
 			for name, contents := range data {
 				if name == "config.json" || name == "kustomization.yaml" || name == "resources" {
-					return "", fmt.Errorf("%s is forbidden", name)
+					return fmt.Errorf("%s is forbidden", name)
 				}
 				w, err := r.Writer(path.Join(appDir, name))
 				if err != nil {
-					return "", err
+					return err
 				}
 				defer w.Close()
 				if _, err := w.Write(contents); err != nil {
-					return "", err
+					return err
 				}
 			}
+			return nil
+		}(); err != nil {
+			return "", err
 		}
-		{
+		if err := func() error {
 			if err := createKustomizationChain(r, resourcesDir); err != nil {
-				return "", err
+				return err
 			}
 			appKust := gio.NewKustomization()
 			for name, contents := range resources {
 				appKust.AddResources(name)
 				w, err := r.Writer(path.Join(resourcesDir, name))
 				if err != nil {
-					return "", err
+					return err
 				}
 				defer w.Close()
 				if _, err := w.Write(contents); err != nil {
-					return "", err
+					return err
 				}
 			}
 			if err := soft.WriteYaml(r, path.Join(resourcesDir, "kustomization.yaml"), appKust); err != nil {
-				return "", err
+				return err
 			}
+			return nil
+		}(); err != nil {
+			return "", err
 		}
 		return fmt.Sprintf("install: %s", name), nil
 	}, dopts...)
@@ -399,9 +407,12 @@
 		i(o)
 	}
 	appDir = filepath.Clean(appDir)
-	if err := m.repoIO.Pull(); err != nil {
-		return ReleaseResources{}, err
+	if !o.NoPull {
+		if err := m.repoIO.Pull(); err != nil {
+			return ReleaseResources{}, err
+		}
 	}
+	opts = append(opts, WithNoPull())
 	if err := m.nsc.Create(namespace); err != nil {
 		return ReleaseResources{}, err
 	}
@@ -472,7 +483,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...); err != nil {
+	if err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Resources, rendered.Data, opts...); err != nil {
 		return ReleaseResources{}, err
 	}
 	// TODO(gio): add ingress-nginx to release resources
@@ -480,7 +491,9 @@
 		return ReleaseResources{}, err
 	}
 	return ReleaseResources{
-		Helm: extractHelm(rendered.Resources),
+		Release:     rendered.Config.Release,
+		RenderedRaw: rendered.Raw,
+		Helm:        extractHelm(rendered.Resources),
 	}, nil
 }
 
@@ -559,7 +572,14 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	if err := installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Resources, rendered.Data, opts...); err != nil {
+		return ReleaseResources{}, err
+	}
+	return ReleaseResources{
+		Release:     rendered.Config.Release,
+		RenderedRaw: rendered.Raw,
+		Helm:        extractHelm(rendered.Resources),
+	}, nil
 }
 
 func (m *AppManager) Remove(instanceId string) error {
@@ -632,6 +652,7 @@
 }
 
 type installOptions struct {
+	NoPull               bool
 	NoPublish            bool
 	Env                  *EnvConfig
 	Networks             []Network
@@ -690,6 +711,12 @@
 	}
 }
 
+func WithNoPull() InstallOption {
+	return func(o *installOptions) {
+		o.NoPull = true
+	}
+}
+
 func WithNoLock() InstallOption {
 	return func(o *installOptions) {
 		o.NoLock = true
@@ -785,7 +812,14 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
+	if err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Resources, rendered.Data); err != nil {
+		return ReleaseResources{}, err
+	}
+	return ReleaseResources{
+		Release:     rendered.Config.Release,
+		RenderedRaw: rendered.Raw,
+		Helm:        extractHelm(rendered.Resources),
+	}, nil
 }
 
 // TODO(gio): take app configuration from the repo
@@ -823,7 +857,14 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	if err := installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Resources, rendered.Data, opts...); err != nil {
+		return ReleaseResources{}, err
+	}
+	return ReleaseResources{
+		Release:     rendered.Config.Release,
+		RenderedRaw: rendered.Raw,
+		Helm:        extractHelm(rendered.Resources),
+	}, nil
 }
 
 func pullHelmCharts(hf HelmFetcher, charts HelmCharts, rfs soft.RepoFS, root string) (map[string]string, error) {
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index df35ae8..62ca38a 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -38,6 +38,7 @@
 
 var envAppConfigs = []string{
 	"values-tmpl/dodo-app-instance.cue",
+	"values-tmpl/dodo-app-instance-status.cue",
 	"values-tmpl/certificate-issuer-private.cue",
 	"values-tmpl/certificate-issuer-public.cue",
 	"values-tmpl/appmanager.cue",
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index a0b0459..f4c05bb 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -295,6 +295,7 @@
 	if err != nil {
 		return err
 	}
+	defer out.Close()
 	serialized, err := yaml.Marshal(data)
 	if err != nil {
 		return err
diff --git a/core/installer/values-tmpl/dodo-app-instance-status.cue b/core/installer/values-tmpl/dodo-app-instance-status.cue
new file mode 100644
index 0000000..ad66153
--- /dev/null
+++ b/core/installer/values-tmpl/dodo-app-instance-status.cue
@@ -0,0 +1,22 @@
+input: {
+	appName: string
+	network: #Network
+	appSubdomain: string
+}
+
+name: "Dodo App Instance Status"
+
+_subdomain: "status.\(input.appSubdomain)"
+
+ingress: {
+	"status-\(input.appName)": {
+		auth: enabled: false
+		network: input.network
+		subdomain: _subdomain
+		appRoot: "/\(input.appName)"
+		service: {
+			name: "web"
+			port: name: "http"
+		}
+	}
+}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 462a95d..afcd627 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -1,6 +1,7 @@
 package welcome
 
 import (
+	"bytes"
 	"context"
 	"embed"
 	"encoding/json"
@@ -392,6 +393,25 @@
 		http.Error(w, "missing app-name", http.StatusBadRequest)
 		return
 	}
+	u := r.Context().Value(userCtx)
+	if u == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	user, ok := u.(string)
+	if !ok {
+		http.Error(w, "could not get user", http.StatusInternalServerError)
+		return
+	}
+	owner, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if owner != user {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
 	commits, err := s.st.GetCommitHistory(appName)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -440,7 +460,12 @@
 		if err != nil {
 			return
 		}
-		if err := s.updateDodoApp(req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks); err != nil {
+		apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
+		if err != nil {
+			return
+		}
+		if err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks); err != nil {
 			if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
 				fmt.Printf("Error: %s\n", err.Error())
 				return
@@ -652,7 +677,11 @@
 		return err
 	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-	app, err := installer.FindEnvApp(apps, "dodo-app-instance")
+	instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
+	if err != nil {
+		return err
+	}
+	instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
 	if err != nil {
 		return err
 	}
@@ -661,9 +690,9 @@
 	if err != nil {
 		return err
 	}
-	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
+	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
 	s.appConfigs[appName] = appConfig{namespace, network}
-	if err := s.updateDodoApp(appName, namespace, networks); err != nil {
+	if err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks); err != nil {
 		return err
 	}
 	configRepo, err := s.client.GetRepo(ConfigRepoName)
@@ -685,7 +714,7 @@
 			return "", err
 		}
 		if _, err := m.Install(
-			app,
+			instanceApp,
 			appName,
 			"/"+appName,
 			namespace,
@@ -756,7 +785,19 @@
 	}
 }
 
-func (s *DodoAppServer) updateDodoApp(name, namespace string, networks []installer.Network) error {
+type dodoAppRendered struct {
+	App struct {
+		Ingress struct {
+			Network   string `json:"network"`
+			Subdomain string `json:"subdomain"`
+		} `json:"ingress"`
+	} `json:"app"`
+	Input struct {
+		AppId string `json:"appId"`
+	} `json:"input"`
+}
+
+func (s *DodoAppServer) updateDodoApp(appStatus installer.EnvApp, name, namespace string, networks []installer.Network) error {
 	repo, err := s.client.GetRepo(name)
 	if err != nil {
 		return err
@@ -775,26 +816,56 @@
 		return err
 	}
 	lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
-	if _, err := m.Install(
-		app,
-		"app",
-		"/.dodo/app",
-		namespace,
-		map[string]any{
-			"repoAddr":      repo.FullAddress(),
-			"managerAddr":   fmt.Sprintf("http://%s", s.self),
-			"appId":         name,
-			"sshPrivateKey": s.sshKey,
-		},
-		installer.WithConfig(&s.env),
-		installer.WithNetworks(networks),
-		installer.WithLocalChartGenerator(lg),
-		installer.WithBranch("dodo"),
-		installer.WithForce(),
-	); err != nil {
-		return err
-	}
-	return nil
+	return repo.Do(func(r soft.RepoFS) (string, error) {
+		res, err := m.Install(
+			app,
+			"app",
+			"/.dodo/app",
+			namespace,
+			map[string]any{
+				"repoAddr":      repo.FullAddress(),
+				"managerAddr":   fmt.Sprintf("http://%s", s.self),
+				"appId":         name,
+				"sshPrivateKey": s.sshKey,
+			},
+			installer.WithNoPull(),
+			installer.WithNoPublish(),
+			installer.WithConfig(&s.env),
+			installer.WithNetworks(networks),
+			installer.WithLocalChartGenerator(lg),
+			installer.WithNoLock(),
+		)
+		if err != nil {
+			return "", err
+		}
+		var rendered dodoAppRendered
+		if err := json.NewDecoder(bytes.NewReader(res.RenderedRaw)).Decode(&rendered); err != nil {
+			return "", nil
+		}
+		if _, err := m.Install(
+			appStatus,
+			"status",
+			"/.dodo/status",
+			s.namespace,
+			map[string]any{
+				"appName":      rendered.Input.AppId,
+				"network":      rendered.App.Ingress.Network,
+				"appSubdomain": rendered.App.Ingress.Subdomain,
+			},
+			installer.WithNoPull(),
+			installer.WithNoPublish(),
+			installer.WithConfig(&s.env),
+			installer.WithNetworks(networks),
+			installer.WithLocalChartGenerator(lg),
+			installer.WithNoLock(),
+		); err != nil {
+			return "", err
+		}
+		return "install app", nil
+	},
+		soft.WithCommitToBranch("dodo"),
+		soft.WithForce(),
+	)
 }
 
 func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) error {