AppManager: monitor installed HelmRelease resources

Change-Id: Ia036e7dda8136ad696d8222e799c4d1b6a9018a9
diff --git a/charts/appmanager/templates/install.yaml b/charts/appmanager/templates/install.yaml
index c8d6b04..f84e7ba 100644
--- a/charts/appmanager/templates/install.yaml
+++ b/charts/appmanager/templates/install.yaml
@@ -9,6 +9,12 @@
   - namespaces
   verbs:
   - create
+- apiGroups:
+  - "helm.toolkit.fluxcd.io"
+  resources:
+  - helmreleases
+  verbs:
+  - get
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 871dae5..8ca42f2 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -12,11 +12,15 @@
 
 	"github.com/giolekva/pcloud/core/installer/io"
 	"github.com/giolekva/pcloud/core/installer/soft"
+
+	"sigs.k8s.io/yaml"
 )
 
 const configFileName = "config.yaml"
 const kustomizationFileName = "kustomization.yaml"
 
+var ErrorNotFound = errors.New("not found")
+
 type AppManager struct {
 	repoIO     soft.RepoIO
 	nsCreator  NamespaceCreator
@@ -85,22 +89,22 @@
 	return ret, nil
 }
 
-func (m *AppManager) FindInstance(id string) (AppInstanceConfig, error) {
+func (m *AppManager) FindInstance(id string) (*AppInstanceConfig, error) {
 	kust, err := soft.ReadKustomization(m.repoIO, filepath.Join(m.appDirRoot, "kustomization.yaml"))
 	if err != nil {
-		return AppInstanceConfig{}, err
+		return nil, err
 	}
 	for _, app := range kust.Resources {
 		if app == id {
 			cfg, err := m.appConfig(filepath.Join(m.appDirRoot, app, "config.json"))
 			if err != nil {
-				return AppInstanceConfig{}, err
+				return nil, err
 			}
 			cfg.Id = id
-			return cfg, nil
+			return &cfg, nil
 		}
 	}
-	return AppInstanceConfig{}, nil
+	return nil, ErrorNotFound
 }
 
 func (m *AppManager) AppConfig(name string) (AppInstanceConfig, error) {
@@ -163,6 +167,15 @@
 	return nil
 }
 
+type Resource struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
+type ReleaseResources struct {
+	Helm []Resource
+}
+
 // TODO(gio): rename to CommitApp
 func InstallApp(
 	repo soft.RepoIO,
@@ -172,11 +185,12 @@
 	ports []PortForward,
 	resources CueAppData,
 	data CueAppData,
-	opts ...soft.DoOption) error {
+	opts ...soft.DoOption,
+) (ReleaseResources, error) {
 	// if err := openPorts(rendered.Ports); err != nil {
 	// 	return err
 	// }
-	return repo.Do(func(r soft.RepoFS) (string, error) {
+	return ReleaseResources{}, repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := r.RemoveDir(appDir); err != nil {
 			return "", err
 		}
@@ -230,17 +244,17 @@
 }
 
 // TODO(gio): commit instanceId -> appDir mapping as well
-func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) error {
+func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) (ReleaseResources, error) {
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	if err := m.nsCreator.Create(namespace); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	env, err := m.Config()
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	release := Release{
 		AppInstanceId: instanceId,
@@ -250,24 +264,51 @@
 	}
 	rendered, err := app.Render(release, env, values)
 	if err != nil {
-		return err
+		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.Ports, rendered.Resources, rendered.Data); err != nil {
+		return ReleaseResources{}, err
+	}
+	return ReleaseResources{
+		Helm: extractHelm(rendered.Resources),
+	}, nil
 }
 
-func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any, opts ...soft.DoOption) error {
+type helmRelease struct {
+	Metadata Resource `json:"metadata"`
+	Status   struct {
+		Conditions []struct {
+			Type   string `json:"type"`
+			Status string `json:"status"`
+		} `json:"conditions"`
+	} `json:"status,omitempty"`
+}
+
+func extractHelm(resources CueAppData) []Resource {
+	ret := make([]Resource, 0, len(resources))
+	for _, contents := range resources {
+		var h helmRelease
+		if err := yaml.Unmarshal(contents, &h); err != nil {
+			panic(err) // TODO(gio): handle
+		}
+		ret = append(ret, h.Metadata)
+	}
+	return ret
+}
+
+func (m *AppManager) Update(app EnvApp, instanceId string, values map[string]any, opts ...soft.DoOption) (ReleaseResources, error) {
 	if err := m.repoIO.Pull(); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	env, err := m.Config()
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	instanceDir := filepath.Join(m.appDirRoot, instanceId)
 	instanceConfigPath := filepath.Join(instanceDir, "config.json")
 	config, err := m.appConfig(instanceConfigPath)
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	release := Release{
 		AppInstanceId: instanceId,
@@ -277,7 +318,7 @@
 	}
 	rendered, err := app.Render(release, env, values)
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
@@ -368,17 +409,17 @@
 	return InfraAppInstanceConfig{}, nil
 }
 
-func (m *InfraAppManager) Install(app InfraApp, appDir string, namespace string, values map[string]any) error {
+func (m *InfraAppManager) Install(app InfraApp, appDir string, namespace string, values map[string]any) (ReleaseResources, error) {
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	if err := m.nsCreator.Create(namespace); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	infra, err := m.Config()
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	release := Release{
 		Namespace: namespace,
@@ -387,24 +428,24 @@
 	}
 	rendered, err := app.Render(release, infra, values)
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
 }
 
-func (m *InfraAppManager) Update(app InfraApp, instanceId string, values map[string]any, opts ...soft.DoOption) error {
+func (m *InfraAppManager) Update(app InfraApp, instanceId string, values map[string]any, opts ...soft.DoOption) (ReleaseResources, error) {
 	if err := m.repoIO.Pull(); err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	env, err := m.Config()
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	instanceDir := filepath.Join("/infrastructure", instanceId)
 	instanceConfigPath := filepath.Join(instanceDir, "config.json")
 	config, err := m.appConfig(instanceConfigPath)
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	release := Release{
 		AppInstanceId: instanceId,
@@ -414,7 +455,7 @@
 	}
 	rendered, err := app.Render(release, env, values)
 	if err != nil {
-		return err
+		return ReleaseResources{}, err
 	}
 	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index e8b77ec..c581f42 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -418,7 +418,8 @@
 		}
 		namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 		appDir := filepath.Join("/infrastructure", app.Slug())
-		return mgr.Install(app, appDir, namespace, map[string]any{})
+		_, err = mgr.Install(app, appDir, namespace, map[string]any{})
+		return err
 	}
 	appsToInstall := []string{
 		"resource-renderer-controller",
@@ -512,12 +513,13 @@
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Slug())
-	return mgr.Install(app, appDir, namespace, map[string]any{
+	_, err = mgr.Install(app, appDir, namespace, map[string]any{
 		"repoIP":        env.ServiceIPs.ConfigRepo,
 		"repoPort":      22,
 		"repoName":      "config",
 		"sshPrivateKey": string(keys.RawPrivateKey()),
 	})
+	return err
 }
 
 func (b Bootstrapper) installIngressPublic(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
@@ -538,9 +540,10 @@
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Slug())
-	return mgr.Install(app, appDir, namespace, map[string]any{
+	_, err = mgr.Install(app, appDir, namespace, map[string]any{
 		"sshPrivateKey": string(keys.RawPrivateKey()),
 	})
+	return err
 }
 
 func (b Bootstrapper) installOryHydraMaester(mgr *InfraAppManager, env BootstrapConfig) error {
@@ -550,7 +553,8 @@
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Slug())
-	return mgr.Install(app, appDir, namespace, map[string]any{})
+	_, err = mgr.Install(app, appDir, namespace, map[string]any{})
+	return err
 }
 
 func (b Bootstrapper) installDNSZoneManager(mgr *InfraAppManager, env BootstrapConfig) error {
@@ -560,9 +564,10 @@
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Slug())
-	return mgr.Install(app, appDir, namespace, map[string]any{
+	_, err = mgr.Install(app, appDir, namespace, map[string]any{
 		"servers": []EnvDNS{},
 	})
+	return err
 }
 
 func (b Bootstrapper) installFluxcdReconciler(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
@@ -572,7 +577,8 @@
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
 	appDir := filepath.Join("/infrastructure", app.Slug())
-	return mgr.Install(app, appDir, namespace, map[string]any{})
+	_, err = mgr.Install(app, appDir, namespace, map[string]any{})
+	return err
 }
 
 type HelmActionConfigFactory interface {
diff --git a/core/installer/cmd/app_manager.go b/core/installer/cmd/app_manager.go
index ee21ba0..abdd475 100644
--- a/core/installer/cmd/app_manager.go
+++ b/core/installer/cmd/app_manager.go
@@ -104,6 +104,10 @@
 	} else {
 		r = installer.NewInMemoryAppRepository(installer.CreateStoreApps())
 	}
+	helmMon, err := newHelmReleaseMonitor()
+	if err != nil {
+		return err
+	}
 	s := welcome.NewAppManagerServer(
 		appManagerFlags.port,
 		m,
@@ -112,6 +116,7 @@
 			"http://fluxcd-reconciler.dodo-fluxcd-reconciler.svc.cluster.local",
 			env.Id,
 		),
+		helmMon,
 	)
 	return s.Start()
 }
diff --git a/core/installer/cmd/kube.go b/core/installer/cmd/kube.go
index d06ae59..4c6ab59 100644
--- a/core/installer/cmd/kube.go
+++ b/core/installer/cmd/kube.go
@@ -11,3 +11,7 @@
 func newZoneFetcher() (installer.ZoneStatusFetcher, error) {
 	return installer.NewZoneStatusFetcher(rootFlags.kubeConfig)
 }
+
+func newHelmReleaseMonitor() (installer.HelmReleaseMonitor, error) {
+	return installer.NewHelmReleaseMonitor(rootFlags.kubeConfig)
+}
diff --git a/core/installer/cmd/rewrite.go b/core/installer/cmd/rewrite.go
index 44f499a..8bb9173 100644
--- a/core/installer/cmd/rewrite.go
+++ b/core/installer/cmd/rewrite.go
@@ -84,7 +84,7 @@
 			return err
 		}
 		v := inst.InputToValues(app.Schema())
-		if err := mgr.Update(app, inst.Id, v, soft.WithNoCommit()); err != nil {
+		if _, err := mgr.Update(app, inst.Id, v, soft.WithNoCommit()); err != nil {
 			return err
 		}
 	}
diff --git a/core/installer/kube.go b/core/installer/kube.go
index db2fb85..a8ed275 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -3,6 +3,7 @@
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
@@ -10,6 +11,8 @@
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+	"k8s.io/client-go/dynamic"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/tools/clientcmd"
@@ -76,6 +79,45 @@
 	return &realZoneStatusFetcher{}, nil
 }
 
+type HelmReleaseMonitor interface {
+	IsReleased(namespace, name string) (bool, error)
+}
+
+type realHelmReleaseMonitor struct {
+	d dynamic.Interface
+}
+
+func (m *realHelmReleaseMonitor) IsReleased(namespace, name string) (bool, error) {
+	ctx := context.Background()
+	res, err := m.d.Resource(schema.GroupVersionResource{"helm.toolkit.fluxcd.io", "v2beta1", "helmreleases"}).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
+	if err != nil {
+		return false, err
+	}
+	b, err := res.MarshalJSON()
+	if err != nil {
+		return false, err
+	}
+	var hr helmRelease
+	if err := json.Unmarshal(b, &hr); err != nil {
+		return false, err
+	}
+	for _, c := range hr.Status.Conditions {
+		if c.Type == "Ready" && c.Status == "True" {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func NewHelmReleaseMonitor(kubeconfig string) (HelmReleaseMonitor, error) {
+	c, err := NewKubeConfig(kubeconfig)
+	if err != nil {
+		return nil, err
+	}
+	d := dynamic.New(c.RESTClient())
+	return &realHelmReleaseMonitor{d}, nil
+}
+
 func NewKubeConfig(kubeconfig string) (*kubernetes.Clientset, error) {
 	if kubeconfig == "" {
 		config, err := rest.InClusterConfig()
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 1316dc0..0424cfe 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -49,7 +49,7 @@
 			instanceId := app.Slug()
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+			if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 				"addressPool":  addressPool,
 				"inClusterIP":  env.Network.DNSInClusterIP.String(),
 				"publicIP":     join(env.PublicIP, ","),
@@ -84,7 +84,7 @@
 				env.Domain,
 				env.Network.DNSInClusterIP.String(),
 			})
-			if err := st.infraAppManager.Update(app, "dns-gateway", map[string]any{
+			if _, err := st.infraAppManager.Update(app, "dns-gateway", map[string]any{
 				"servers": servers,
 			}); err != nil {
 				return err
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 746b9cf..91d0fd9 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -125,7 +125,7 @@
 				instanceId := fmt.Sprintf("%s-ingress-private", app.Slug())
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+				if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       fmt.Sprintf("%s-ingress-private", env.Id),
 					"from":       env.Network.Ingress.String(),
 					"to":         env.Network.Ingress.String(),
@@ -139,7 +139,7 @@
 				instanceId := fmt.Sprintf("%s-headscale", app.Slug())
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+				if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       fmt.Sprintf("%s-headscale", env.Id),
 					"from":       env.Network.Headscale.String(),
 					"to":         env.Network.Headscale.String(),
@@ -153,7 +153,7 @@
 				instanceId := app.Slug()
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+				if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 					"name":       env.Id,
 					"from":       env.Network.ServicesFrom.String(),
 					"to":         env.Network.ServicesTo.String(),
@@ -183,7 +183,7 @@
 			instanceId := app.Slug()
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+			if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 				"privateNetwork": map[string]any{
 					"hostname": "private-network-proxy",
 					"username": "private-network-proxy",
@@ -208,7 +208,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
@@ -221,7 +221,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
 			return err
 		}
 		return nil
@@ -238,7 +238,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "test", // TODO(giolekva): make core-auth chart actually use this
 		}); err != nil {
 			return err
@@ -262,7 +262,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"authGroups": strings.Join(initGroups, ","),
 		}); err != nil {
 			return err
@@ -286,7 +286,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"subdomain": "headscale",
 			"ipSubnet":  fmt.Sprintf("%s/24", env.Network.DNS.String()),
 		}); err != nil {
@@ -322,7 +322,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 		}); err != nil {
@@ -358,7 +358,7 @@
 		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 			"authGroups":    strings.Join(initGroups, ","),
@@ -390,7 +390,7 @@
 		instanceId := app.Name()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+		if _, err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 			"repoAddr":      st.ssClient.GetRepoAddress("config"),
 			"sshPrivateKey": string(keys.RawPrivateKey()),
 		}); err != nil {
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index 20e428d..f55344d 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -53,11 +53,12 @@
 			return err
 		}
 		appDir := filepath.Join("/environments", env.Id, "config-repo")
-		return st.infraAppManager.Install(app, appDir, env.Id, map[string]any{
+		_, err = st.infraAppManager.Install(app, appDir, env.Id, map[string]any{
 			"privateKey": string(keys.RawPrivateKey()),
 			"publicKey":  string(keys.RawAuthorizedKey()),
 			"adminKey":   string(adminKeys.RawAuthorizedKey()),
 		})
+		return err
 	})
 	return &t
 }
diff --git a/core/installer/tasks/reconciler.go b/core/installer/tasks/reconciler.go
index 461ffc8..0e8133e 100644
--- a/core/installer/tasks/reconciler.go
+++ b/core/installer/tasks/reconciler.go
@@ -27,7 +27,7 @@
 func (r fluxcdReconciler) Reconcile(ctx context.Context) {
 	for {
 		select {
-		case <-time.After(30 * time.Second):
+		case <-time.After(3 * time.Second):
 			for _, res := range r.resources {
 				http.Get(res)
 			}
diff --git a/core/installer/tasks/release.go b/core/installer/tasks/release.go
new file mode 100644
index 0000000..53e5e74
--- /dev/null
+++ b/core/installer/tasks/release.go
@@ -0,0 +1,29 @@
+package tasks
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/giolekva/pcloud/core/installer"
+)
+
+func NewMonitorRelease(mon installer.HelmReleaseMonitor, rr installer.ReleaseResources) Task {
+	var t []Task
+	for _, h := range rr.Helm {
+		t = append(t, newMonitorHelm(mon, h))
+	}
+	return newConcurrentParentTask("Monitor", true, t...)
+}
+
+func newMonitorHelm(mon installer.HelmReleaseMonitor, h installer.Resource) Task {
+	t := newLeafTask(fmt.Sprintf("%s/%s", h.Namespace, h.Name), func() error {
+		for {
+			if ok, err := mon.IsReleased(h.Namespace, h.Name); err == nil && ok {
+				break
+			}
+			time.Sleep(5 * time.Second)
+		}
+		return nil
+	})
+	return &t
+}
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index cab78bf..64c1458 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -1,3 +1,16 @@
+{{ define "task" }}
+{{ range . }}
+<li aria-busy="{{ eq .Status 1 }}">
+	{{ if eq .Status 3 }}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none"><circle cx="12" cy="12" r="8" fill="green" fill-opacity="0.25"/><path stroke="green" stroke-width="1.2" d="m8.5 11l2.894 2.894a.15.15 0 0 0 .212 0L19.5 6"/><path stroke="green" stroke-linecap="round" d="M19.358 10.547a7.5 7.5 0 1 1-3.608-5.042"/></g></svg>{{ end }}{{ .Title }}{{ if .Err }} - {{ .Err.Error }} {{ end }}
+	{{ if .Subtasks }}
+	<ul>
+   		{{ template "task" .Subtasks }}
+	</ul>
+	{{ end }}
+</li>
+{{ end }}
+{{ end }}
+
 {{ define "schema-form" }}
   {{ $readonly := .ReadOnly }}
   {{ $networks := .AvailableNetworks }}
@@ -75,6 +88,20 @@
 {{ $schema := .App.Schema }}
 {{ $networks := .AvailableNetworks }}
 
+{{ $renderForm := true }}
+
+{{ if .Task }}
+  {{if or (eq .Task.Status 0) (eq .Task.Status 1) }}
+  {{ $renderForm = false }}
+  Waiting for resources:
+	<ul class="progress">
+		{{ template "task" .Task.Subtasks }}
+	</ul>
+	<script>setTimeout(() => location.reload(), 3000);</script>
+  {{ end }}
+{{ end }}
+
+{{ if $renderForm }}
 <form id="config-form">
     {{ if $instance }}
       {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" ($instance.InputToValues $schema)) }}
@@ -92,7 +119,13 @@
 </form>
 
 {{ range .Instances }}
-  {{ if or (not $instance) (ne $instance.Id .Id)}}
+  {{ $r := true}}
+  {{ if $instance }}
+	{{ if eq $instance.Id .Id }}
+  	  {{ $r = false}}
+	{{ end }}
+  {{ end }}
+  {{ if $r }}
     <details>
       <summary>{{ .Id }}</summary>
       {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" (.InputToValues $schema)) }}
@@ -100,20 +133,12 @@
     </details>
   {{ end }}
 {{ end }}
-
-
-<div id="toast-success" class="toast hidden">
-  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> {{ if $instance }}Update succeeded{{ else }}Install succeeded{{ end}}
-</div>
+{{ end }}
 
 <div id="toast-failure" class="toast hidden">
   <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> {{ if $instance }}Update failed{{ else}}Install failed{{ end }}
 </div>
 
-<div id="toast-uninstall-success" class="toast hidden">
-  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> Uninstalled application
-</div>
-
 <div id="toast-uninstall-failure" class="toast hidden">
   <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> Failed to uninstall application
 </div>
@@ -192,18 +217,10 @@
      );
  }
 
- function installSucceeded() {
-     actionFinished(document.getElementById("toast-success"));
- }
-
  function installFailed() {
      actionFinished(document.getElementById("toast-failure"));
  }
 
- function uninstallSucceeded() {
-     actionFinished(document.getElementById("toast-uninstall-success"));
- }
-
  function uninstallFailed() {
      actionFinished(document.getElementById("toast-uninstall-failure"));
  }
@@ -213,16 +230,16 @@
  async function install() {
      installStarted();
 	 const resp = await fetch(submitAddr, {
-         method: "POST",
-         headers: {
-             "Content-Type": "application/json",
-             "Accept": "application/json",
-         },
-         body: JSON.stringify(config),
-     });
+		 method: "POST",
+		 headers: {
+			 "Content-Type": "application/json",
+			 "Accept": "application/json",
+		 },
+		 body: JSON.stringify(config),
+	 });
      if (resp.status === 200) {
-         installSucceeded();
-     } else {
+		 window.location = await resp.text();
+	 } else {
          installFailed();
      }
  }
@@ -234,7 +251,7 @@
          method: "POST",
      });
      if (resp.status === 200) {
-         uninstallSucceeded();
+		 window.location = await resp.text();
      } else {
          uninstallFailed();
      }
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index a6eb243..e7322ab 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -32,6 +32,8 @@
 	m          *installer.AppManager
 	r          installer.AppRepository
 	reconciler tasks.Reconciler
+	h          installer.HelmReleaseMonitor
+	tasks      map[string]tasks.Task
 }
 
 func NewAppManagerServer(
@@ -39,12 +41,15 @@
 	m *installer.AppManager,
 	r installer.AppRepository,
 	reconciler tasks.Reconciler,
+	h installer.HelmReleaseMonitor,
 ) *AppManagerServer {
 	return &AppManagerServer{
 		port,
 		m,
 		r,
 		reconciler,
+		h,
+		map[string]tasks.Task{},
 	}
 }
 
@@ -107,7 +112,7 @@
 	if err != nil {
 		return err
 	}
-	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Slug(), []installer.AppInstanceConfig{instance}})
+	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Slug(), []installer.AppInstanceConfig{*instance}})
 }
 
 func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
@@ -139,13 +144,22 @@
 	instanceId := a.Slug() + suffix
 	appDir := fmt.Sprintf("/apps/%s", instanceId)
 	namespace := fmt.Sprintf("%s%s%s", env.NamespacePrefix, a.Namespace(), suffix)
-	if err := s.m.Install(a, instanceId, appDir, namespace, values); err != nil {
-		log.Printf("%s\n", err.Error())
+	rr, err := s.m.Install(a, instanceId, appDir, namespace, values)
+	if err != nil {
 		return err
 	}
 	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
 	go s.reconciler.Reconcile(ctx)
-	return c.String(http.StatusOK, "Installed")
+	if _, ok := s.tasks[instanceId]; ok {
+		panic("MUST NOT REACH!")
+	}
+	t := tasks.NewMonitorRelease(s.h, rr)
+	t.OnDone(func(err error) {
+		delete(s.tasks, instanceId)
+	})
+	s.tasks[instanceId] = t
+	go t.Start()
+	return c.String(http.StatusOK, fmt.Sprintf("/instance/%s", instanceId))
 }
 
 func (s *AppManagerServer) handleAppUpdate(c echo.Context) error {
@@ -166,13 +180,22 @@
 	if err != nil {
 		return err
 	}
-	if err := s.m.Update(a, slug, values); err != nil {
-		fmt.Println(err)
+	if _, ok := s.tasks[slug]; ok {
+		return fmt.Errorf("Update already in progress")
+	}
+	rr, err := s.m.Update(a, slug, values)
+	if err != nil {
 		return err
 	}
 	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
 	go s.reconciler.Reconcile(ctx)
-	return c.String(http.StatusOK, "Installed")
+	t := tasks.NewMonitorRelease(s.h, rr)
+	t.OnDone(func(err error) {
+		delete(s.tasks, slug)
+	})
+	s.tasks[slug] = t
+	go t.Start()
+	return c.String(http.StatusOK, fmt.Sprintf("/instance/%s", slug))
 }
 
 func (s *AppManagerServer) handleAppRemove(c echo.Context) error {
@@ -182,7 +205,7 @@
 	}
 	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
 	go s.reconciler.Reconcile(ctx)
-	return c.String(http.StatusOK, "Installed")
+	return c.String(http.StatusOK, "/")
 }
 
 func (s *AppManagerServer) handleIndex(c echo.Context) error {
@@ -206,6 +229,7 @@
 	Instance          *installer.AppInstanceConfig
 	Instances         []installer.AppInstanceConfig
 	AvailableNetworks []installer.Network
+	Task              tasks.Task
 }
 
 func (s *AppManagerServer) handleAppUI(c echo.Context) error {
@@ -265,11 +289,13 @@
 	if err != nil {
 		return err
 	}
+	t := s.tasks[slug]
 	err = appTmpl.Execute(c.Response(), appContext{
 		App:               a,
-		Instance:          &instance,
+		Instance:          instance,
 		Instances:         instances,
 		AvailableNetworks: installer.CreateNetworks(global),
+		Task:              t,
 	})
 	return err
 }
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index a689f54..ed7a2d3 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -205,7 +205,7 @@
 		if err != nil {
 			t.Fatal(err)
 		}
-		if err := infraMgr.Install(app, "/infrastructure/dns-gateway", "dns-gateway", map[string]any{
+		if _, err := infraMgr.Install(app, "/infrastructure/dns-gateway", "dns-gateway", map[string]any{
 			"servers": []installer.EnvDNS{},
 		}); err != nil {
 			t.Fatal(err)
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index d7bccdd..1592a5c 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -226,7 +226,7 @@
 			instanceId := fmt.Sprintf("%s-%s", app.Slug(), req.Username)
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
-			if err := appManager.Install(app, instanceId, appDir, namespace, map[string]any{
+			if _, err := appManager.Install(app, instanceId, appDir, namespace, map[string]any{
 				"username": req.Username,
 				"preAuthKey": map[string]any{
 					"enabled": false,