DodoApp: Support remote clusters

Change-Id: I6f4e6a0a32cc723b47c96518d83b1ffdb5169f14
diff --git a/charts/app-runner/templates/install.yaml b/charts/app-runner/templates/install.yaml
index 1e22086..f3b2a00 100644
--- a/charts/app-runner/templates/install.yaml
+++ b/charts/app-runner/templates/install.yaml
@@ -114,3 +114,6 @@
         - name: volume-{{ .name }}
           mountPath: {{ .mountPath }}
         {{- end }}
+      {{- if .Values.extraContainers }}
+        {{ toYaml .Values.extraContainers | nindent 6 }}
+      {{- end }}
diff --git a/charts/app-runner/values.yaml b/charts/app-runner/values.yaml
index afc9481..9145c47 100644
--- a/charts/app-runner/values.yaml
+++ b/charts/app-runner/values.yaml
@@ -11,3 +11,4 @@
 managerAddr: ""
 volumes: []
 runtimeClassName: ""
+extraContainers: []
diff --git a/core/installer/app.go b/core/installer/app.go
index f023b18..d7f6989 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -491,12 +491,16 @@
 	if charts == nil {
 		charts = make(map[string]helmv2.HelmChartTemplateSpec)
 	}
+	if clusters == nil {
+		clusters = []Cluster{}
+	}
 	ret, err := a.cueApp.render(map[string]any{
 		"global":      env,
 		"release":     release,
 		"input":       derived,
 		"localCharts": charts,
 		"networks":    NetworkMap(networks),
+		"clusters":    clusters,
 	})
 	if err != nil {
 		return EnvAppRendered{}, err
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index 192716f..0bb511a 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -449,31 +449,33 @@
 }
 
 output: {
-	images: out.images
-	charts: out.charts
-	clusterProxy: out.clusterProxy
-	_lc: _localCharts & {
-		for k, v in out.charts {
-			"\(k)": {
-				...
+	for _, out in outs {
+		images: out.images
+		charts: out.charts
+		clusterProxy: out.clusterProxy
+		_lc: _localCharts & {
+			for k, v in out.charts {
+				"\(k)": {
+					...
+				}
 			}
 		}
-	}
-	helm: {
-		for name, r in out.helmR {
-			"\(name)": #HelmRelease & {
-				_name: name
-				_chart: _lc[r.chart.name]
-				_values: r.values
-				_dependencies: r.dependsOn
-				_info: r.info
-				_annotations: r.annotations
-				_namespace: release.namespace
-				if r.cluster != _|_ {
-					_cluster: r.cluster
-				}
-				if r.targetNamespace != _|_ {
-					_targetNamespace: r.targetNamespace
+		helm: {
+			for name, r in out.helmR {
+				"\(name)": #HelmRelease & {
+					_name: name
+					_chart: _lc[r.chart.name]
+					_values: r.values
+					_dependencies: r.dependsOn
+					_info: r.info
+					_annotations: r.annotations
+					_namespace: release.namespace
+					if r.cluster != _|_ {
+						_cluster: r.cluster
+					}
+					if r.targetNamespace != _|_ {
+						_targetNamespace: r.targetNamespace
+					}
 				}
 			}
 		}
@@ -496,6 +498,11 @@
 url: string | *""
 
 #WithOut: {
+	cluster?: #Cluster
+	if input.cluster != _|_ {
+		cluster: #Cluster | *input.cluster
+	}
+	_cluster: cluster
 	images: {...}
 	charts: {...}
 	helm: {...}
@@ -527,8 +534,8 @@
 	helmR: {
 		for k, v in helm {
 			"\(k)": v & {
-				if v.cluster == _|_ && input.cluster != _|_ {
-					cluster: input.cluster
+				if v.cluster == _|_ && _cluster != _|_ {
+					cluster: _cluster
 				}
 			}
 		}
@@ -537,6 +544,39 @@
 	...
 }
 
+outs: {
+	"out": out
+}
+if out.cluster != _|_ {
+	outs: kout: #WithOut & {
+		cluster: out.cluster
+		clusterName: cluster.name
+		clusterKubeconfig: cluster.kubeconfig
+		charts: {
+			secret: {
+				name: "secret"
+				kind: "GitRepository"
+				address: "https://code.v1.dodo.cloud/helm-charts"
+				branch: "main"
+				path: "charts/secret"
+			}
+ 		}
+		helm: {
+			"cluster-kubeconfig": {
+				chart: charts.secret
+				cluster: null
+				info: "Connecting to \(clusterName) cluster"
+				values: {
+					name: "cluster-kubeconfig"
+					key: "kubeconfig"
+					value: base64.Encode(null, clusterKubeconfig)
+					keep: true
+				}
+			}
+		}
+	}
+}
+
 #WithOut: {
 	charts: {
 		volume: {
@@ -658,35 +698,10 @@
 	to: string
 }
 
-if input.cluster != _|_ {
-	{
-		out: {
-			charts: {
-				secret: {
-					kind: "GitRepository"
-					address: "https://code.v1.dodo.cloud/helm-charts"
-					branch: "main"
-					path: "charts/secret"
-				}
- 			}
-			helm: {
-				"cluster-kubeconfig": {
-					chart: charts.secret
-					cluster: null
-					info: "Connecting to \(input.cluster.name) cluster"
-					values: {
-						name: "cluster-kubeconfig"
-						key: "kubeconfig"
-						value: base64.Encode(null, input.cluster.kubeconfig)
-						keep: true
-					}
-				}
-			}
-		}
-	}
-
+// TODO(gio): Move this inside #WithOut definition
+if out.cluster != _|_ {
 	namespaces: [{
 		name: release.namespace
-		kubeconfig: input.cluster.kubeconfig
+		kubeconfig: out.cluster.kubeconfig
 	}]
 }
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
index 8d4a7db..e6e8de8 100644
--- a/core/installer/app_configs/app_global_env.cue
+++ b/core/installer/app_configs/app_global_env.cue
@@ -31,6 +31,13 @@
 
 networks: #Networks
 
+clusters: [...#Cluster] | *[]
+clusterMap: {
+	for c in clusters {
+		"\(strings.ToLower(c.name))": c
+	}
+}
+
 #Ingress: #WithOut & {
 	name: string
 	auth: #Auth
@@ -41,6 +48,8 @@
 		name: string
 		port: close({ name: string }) | close({ number: int & > 0 })
 	})
+	cluster?: #Cluster
+	_cluster: cluster
 	g?: #Global
 
 	_domain: "\(subdomain).\(network.domain)"
@@ -48,12 +57,12 @@
 	_authProxyName: "\(name)-auth-proxy"
     _authProxyHTTPPortName: "http"
 
-	if input.cluster != _|_ {
+	if _cluster != _|_ {
 		clusterProxy: {
 			"\(name)": {
 				from: _domain
 				_sanitizedDomain: strings.Replace(_domain, ".", "-", -1)
-				to: "\(_sanitizedDomain).\(input.cluster.name).cluster.\(global.privateDomain)"
+				to: "\(_sanitizedDomain).\(_cluster.name).cluster.\(global.privateDomain)"
 			}
 		}
 	}
@@ -109,14 +118,14 @@
 				}
 			}
 		}
-		if input.cluster != _|_ {
-			"\(name)-ingress-\(input.cluster.name)": {
+		if _cluster != _|_ {
+			"\(name)-ingress-\(_cluster.name)": {
 				chart: charts.ingress
-				cluster: input.cluster
+				cluster: _cluster
 				_service: service
 				_sanitizedDomain: strings.Replace(_domain, ".", "-", -1)
-				_clusterDomain: "\(_sanitizedDomain).\(input.cluster.name).cluster.\(global.privateDomain)"
-				info: "Configuring secure route to \(input.cluster.name) cluster"
+				_clusterDomain: "\(_sanitizedDomain).\(cluster.name).cluster.\(global.privateDomain)"
+				info: "Configuring secure route to \(cluster.name) cluster"
 				annotations: {
 					// TODO(gio): Change type to cluster-gateway or sth similar.
 					"dodo.cloud/resource-type": "ingress"
@@ -124,7 +133,7 @@
 				}
 				values: {
 					domain: _clusterDomain
-					ingressClassName: input.cluster.ingressClassName
+					ingressClassName: cluster.ingressClassName
 					certificateIssuer: ""
 					annotations: {
 						"nginx.ingress.kubernetes.io/force-ssl-redirect": "false"
@@ -172,7 +181,7 @@
 				}
 			}
 		}
-		if input.cluster == _|_ {
+		if _cluster == _|_ {
 			"\(name)-ingress": {
 				chart: charts.ingress
 				// NOTE(gio): Force to install in default cluster.
@@ -210,12 +219,17 @@
 }
 
 #WithOut: {
+	cluster?: #Cluster
+	_cluster: cluster
 	ingress: {...}
 	ingress: {
 		for k, v in ingress {
 			"\(k)": #Ingress & v & {
 				name: k
 				g: global
+				if _cluster != _|_ {
+					cluster: _cluster
+				}
 			}
 		}
 		...
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index b9357ec..a4bbf0c 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -21,9 +21,8 @@
 if app.dev.enabled {
 	input: {
 		username?: string | *app.dev.username
-		vpnAuthKey: string @role(VPNAuthKey) @usernameField(username)
+		vpnAuthKey: string  @role(VPNAuthKey) @usernameField(username)
 	}
-
 	_devVM: {
 		username: app.dev.username
 		domain: global.domain
@@ -92,6 +91,7 @@
 
 #AppTmpl: {
 	type: string
+	cluster?: string
 	ingress: #AppIngress
 	volumes: {
 		for k, v in volumes {
@@ -245,6 +245,11 @@
 
 if !_app.dev.enabled {
 	{
+		if _app.cluster != _|_ {
+			input: {
+				appVPNAuthKey: string  @role(VPNAuthKey) @username(private-network-proxy)
+			}
+		}
 		out: {
 			ingress: {
 				app: {
@@ -264,8 +269,20 @@
 					tag: strings.Replace(_app.type, ":", "-", -1)
 					pullPolicy: "Always"
 				}
+				"tailscale-proxy": {
+					repository: "tailscale"
+					name: "tailscale"
+					tag: "v1.42.0"
+					pullPolicy: "IfNotPresent"
+				}
 			}
 			charts: {
+				"access-secrets": {
+					kind: "GitRepository"
+					address: "https://code.v1.dodo.cloud/helm-charts"
+					branch: "main"
+					path: "charts/access-secrets"
+				}
 				app: {
 					kind: "GitRepository"
 					address: "https://code.v1.dodo.cloud/helm-charts"
@@ -274,6 +291,16 @@
 				}
 			}
 			helm: {
+				if _app.cluster != _|_ {
+					{
+					"access-secrets": {
+						chart: charts["access-secrets"]
+						values: {
+							serviceAccountName: "default"
+						}
+					}
+					}
+				}
 				app: {
 					chart: charts.app
 					values: {
@@ -282,11 +309,30 @@
 							tag: images.app.tag
 							pullPolicy: images.app.pullPolicy
 						}
-						runtimeClassName: "untrusted-external" // TODO(gio): make this part of the infra config
+						// TODO(gio): install gvisor runtime during new remote cluster init
+						if _app.cluster == _|_ {
+							runtimeClassName: "untrusted-external" // TODO(gio): make this part of the infra config
+						}
+						if _app.cluster != _|_ {
+							extraContainers: [{
+								name: "proxy"
+								image: images["tailscale-proxy"].fullNameWithTag
+								env: [{
+									name: "TS_AUTHKEY"
+									value: input.appVPNAuthKey
+								}, {
+									name: "TS_HOSTNAME"
+									value: "dodo-app-\(input.appId)"
+								}, {
+									name: "TS_EXTRA_ARGS"
+									value: "--login-server=https://headscale.\(global.domain)"
+								}]
+						    }]
+						}
 						appPort: _app.port
 						appDir: _app.rootDir
 						appId: input.appId
-						repoAddr: input.repoAddr
+						repoAddr: "\(input.repoPublicAddr)/\(input.appId)"
 						sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 						runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
 						managerAddr: input.managerAddr
@@ -333,6 +379,9 @@
 _vmName: "\(input.appId)-\(input.branch)"
 
 out: {
+	if app.cluster != _|_ {
+		cluster: clusterMap[strings.ToLower(app.cluster)]
+	}
 	volumes: app.volumes
 	postgresql: app.postgresql
 	vm: {
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 0bddf29..9b28004 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -449,9 +449,15 @@
 			return ReleaseResources{}, err
 		}
 	}
-	clusters, err := m.GetClusters()
-	if err != nil {
-		return ReleaseResources{}, err
+	var clusters []Cluster
+	if o.Clusters != nil {
+		clusters = o.Clusters
+	} else {
+		if cls, err := m.GetClusters(); err != nil {
+			return ReleaseResources{}, err
+		} else {
+			clusters = ToAccessConfigs(cls)
+		}
 	}
 	var lg LocalChartGenerator
 	if o.LG != nil {
@@ -465,7 +471,7 @@
 		RepoAddr:      m.repo.FullAddress(),
 		AppDir:        appDir,
 	}
-	rendered, err := app.Render(release, env, networks, ToAccessConfigs(clusters), values, nil, m.vpnAPIClient)
+	rendered, err := app.Render(release, env, networks, clusters, values, nil, m.vpnAPIClient)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -497,7 +503,7 @@
 	if o.FetchContainerImages {
 		release.ImageRegistry = imageRegistry
 	}
-	rendered, err = app.Render(release, env, networks, ToAccessConfigs(clusters), values, localCharts, m.vpnAPIClient)
+	rendered, err = app.Render(release, env, networks, clusters, values, localCharts, m.vpnAPIClient)
 	if err != nil {
 		return ReleaseResources{}, err
 	}
@@ -796,6 +802,7 @@
 	NoPublish            bool
 	Env                  *EnvConfig
 	Networks             []Network
+	Clusters             []Cluster
 	Branch               string
 	LG                   LocalChartGenerator
 	FetchContainerImages bool
@@ -821,6 +828,12 @@
 	return WithNetworks([]Network{})
 }
 
+func WithClusters(clusters []Cluster) InstallOption {
+	return func(o *installOptions) {
+		o.Clusters = clusters
+	}
+}
+
 func WithBranch(branch string) InstallOption {
 	return func(o *installOptions) {
 		o.Branch = branch
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 4f3f7dd..83cf701 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -59,6 +59,17 @@
 			DeallocatePortAddr: fmt.Sprintf("http://port-allocator.%s-ingress-private.svc.cluster.local/api/remove", env.Id),
 		},
 	}
+
+	clusters = []Cluster{
+		{
+			Name: "default",
+		},
+		{
+			Name:             "io",
+			IngressClassName: "io",
+			Kubeconfig:       "kubeconfig",
+		},
+	}
 )
 
 func TestAuthProxyEnabled(t *testing.T) {
@@ -297,6 +308,42 @@
 	}
 }
 
+func TestAppPackagesRemoteCluster(t *testing.T) {
+	contents, err := valuesTmpls.ReadFile("values-tmpl/rpuppy.cue")
+	if err != nil {
+		t.Fatal(err)
+	}
+	app, err := NewCueEnvApp(CueAppData{
+		"base.cue":   []byte(cueBaseConfig),
+		"app.cue":    []byte(contents),
+		"global.cue": []byte(cueEnvAppGlobal),
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	release := Release{
+		Namespace: "foo",
+	}
+	values := map[string]any{
+		"network":   "Public",
+		"subdomain": "woof",
+		"auth": map[string]any{
+			"enabled": false,
+		},
+		"cluster": "io",
+	}
+	rendered, err := app.Render(release, env, networks, clusters, values, nil, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, r := range rendered.Resources {
+		t.Log(string(r))
+	}
+	for _, r := range rendered.Data {
+		t.Log(string(r))
+	}
+}
+
 func TestDNSGateway(t *testing.T) {
 	contents, err := valuesTmpls.ReadFile("values-tmpl/dns-gateway.cue")
 	if err != nil {
@@ -337,6 +384,58 @@
 	}
 }
 
+var dodoAppRemoteClusterCue = `
+app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "private"
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+	dev: {
+		enabled: false
+	}
+    cluster: "io"
+}`
+
+func TestDodoAppRemoteCluster(t *testing.T) {
+	app, err := NewDodoApp([]byte(dodoAppRemoteClusterCue))
+	if err != nil {
+		for _, e := range errors.Errors(err) {
+			t.Log(e)
+		}
+		t.Fatal(err)
+	}
+
+	release := Release{
+		Namespace:     "foo",
+		AppInstanceId: "foo-bar",
+		RepoAddr:      "ssh://192.168.100.210:22/config",
+		AppDir:        "/foo/bar",
+	}
+	keyGen := testKeyGen{}
+	r, err := app.Render(release, env, networks, clusters, map[string]any{
+		"repoAddr":       "1",
+		"repoPublicAddr": "2",
+		"managerAddr":    "3",
+		"appId":          "4",
+		"branch":         "5",
+		"sshPrivateKey":  "6",
+	}, nil, keyGen)
+	if err != nil {
+		for _, e := range errors.Errors(err) {
+			for _, f := range errors.Errors(e) {
+				for _, g := range errors.Errors(f) {
+					t.Log(g)
+				}
+			}
+		}
+		t.Fatal(err)
+	}
+	t.Log(string(r.Raw))
+}
+
 var dodoAppDevDisabledCue = `
 app: {
 	type: "golang:1.22.0"
diff --git a/core/installer/cluster.go b/core/installer/cluster.go
index 9ebffcf..9ac89da 100644
--- a/core/installer/cluster.go
+++ b/core/installer/cluster.go
@@ -93,8 +93,8 @@
 		if err != nil {
 			return "", err
 		}
-		if v, ok := cfg.Proxies[src]; ok {
-			return "", fmt.Errorf("mapping from %s already exists (%s)", src, v)
+		if v, ok := cfg.Proxies[src]; ok && v != dst {
+			return "", fmt.Errorf("wrong mapping %s already exists (%s)", src, v)
 		}
 		cfg.Proxies[src] = dst
 		w, err := fs.Writer(c.NginxConfigPath)
@@ -167,8 +167,8 @@
 		if err != nil {
 			return "", err
 		}
-		if v, ok := cfg.Proxies[src]; !ok || v != dst {
-			return "", fmt.Errorf("mapping does not exist: %s %s", src, dst)
+		if v, ok := cfg.Proxies[src]; ok || v != dst {
+			return "", fmt.Errorf("wrong mapping %s already exists (%s)", src, v)
 		}
 		delete(cfg.Proxies, src)
 		w, err := fs.Writer(c.NginxConfigPath)
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index 8512691..bcd8a68 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -1,9 +1,14 @@
 package main
 
 import (
+	"bytes"
 	"database/sql"
 	"encoding/json"
+	"fmt"
+	"io"
 	"log"
+	"net"
+	"net/http"
 	"os"
 
 	"github.com/giolekva/pcloud/core/installer"
@@ -201,8 +206,7 @@
 		},
 	}
 	vpnKeyGen := installer.NewHeadscaleAPIClient(dodoAppFlags.headscaleAPIAddr)
-	// TOOD(gio): implement
-	var cnc installer.ClusterNetworkConfigurator
+	cnc := &proxyConfigurator{dodoAppFlags.envAppManagerAddr}
 	s, err := welcome.NewDodoAppServer(
 		st,
 		nf,
@@ -230,3 +234,54 @@
 	}
 	return s.Start()
 }
+
+type proxyConfigurator struct {
+	apiAddr string
+}
+
+func (pc *proxyConfigurator) AddCluster(name string, ingressIP net.IP) error {
+	return fmt.Errorf("NOT IMPLEMENTED")
+}
+
+func (pc *proxyConfigurator) RemoveCluster(name string, ingressIP net.IP) error {
+	return fmt.Errorf("NOT IMPLEMENTED")
+}
+
+type proxyPair struct {
+	From string `json:"from"`
+	To   string `json:"to"`
+}
+
+func (pc *proxyConfigurator) AddProxy(src, dst string) error {
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(proxyPair{src, dst}); err != nil {
+		return err
+	}
+	resp, err := http.Post(fmt.Sprintf("%s/api/proxy/add", pc.apiAddr), "application/json", &buf)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		return fmt.Errorf(buf.String())
+	}
+	return nil
+}
+
+func (pc *proxyConfigurator) RemoveProxy(src, dst string) error {
+	var buf bytes.Buffer
+	if err := json.NewEncoder(&buf).Encode(proxyPair{src, dst}); err != nil {
+		return err
+	}
+	resp, err := http.Post(fmt.Sprintf("%s/api/proxy/remove", pc.apiAddr), "application/json", &buf)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != http.StatusOK {
+		var buf bytes.Buffer
+		io.Copy(&buf, resp.Body)
+		return fmt.Errorf(buf.String())
+	}
+	return nil
+}
diff --git a/core/installer/go.mod b/core/installer/go.mod
index c58ffaf..2aa88b5 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -5,7 +5,7 @@
 go 1.22.0
 
 require (
-	cuelang.org/go v0.8.1
+	cuelang.org/go v0.10.0
 	github.com/Masterminds/sprig/v3 v3.2.3
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/charmbracelet/keygen v0.5.0
@@ -19,18 +19,19 @@
 	github.com/libdns/libdns v0.2.2
 	github.com/miekg/dns v1.1.58
 	github.com/ncruces/go-sqlite3 v0.17.0
-	github.com/spf13/cobra v1.8.0
-	golang.org/x/crypto v0.24.0
+	github.com/spf13/cobra v1.8.1
+	golang.org/x/crypto v0.26.0
 	golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8
 	helm.sh/helm/v3 v3.14.3
 	k8s.io/api v0.30.0
 	k8s.io/apimachinery v0.30.0
 	k8s.io/client-go v0.30.0
+	k8s.io/kubectl v0.29.3
 	sigs.k8s.io/yaml v1.4.0
 )
 
 require (
-	cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e // indirect
+	cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79 // indirect
 	dario.cat/mergo v1.0.0 // indirect
 	github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
@@ -60,7 +61,7 @@
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-metrics v0.0.1 // indirect
 	github.com/emicklei/go-restful/v3 v3.12.0 // indirect
-	github.com/emicklei/proto v1.10.0 // indirect
+	github.com/emicklei/proto v1.13.2 // indirect
 	github.com/emirpasic/gods v1.18.1 // indirect
 	github.com/evanphx/json-patch v5.9.0+incompatible // indirect
 	github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect
@@ -122,6 +123,7 @@
 	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.0 // indirect
+	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
@@ -132,7 +134,7 @@
 	github.com/prometheus/procfs v0.13.0 // indirect
 	github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
-	github.com/rogpeppe/go-internal v1.12.0 // indirect
+	github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 // indirect
 	github.com/rubenv/sql-migrate v1.6.1 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
@@ -152,16 +154,15 @@
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
 	go.starlark.net v0.0.0-20240329153429-e6e8e7ce1b7a // indirect
-	golang.org/x/mod v0.17.0 // indirect
-	golang.org/x/net v0.25.0 // indirect
-	golang.org/x/oauth2 v0.18.0 // indirect
-	golang.org/x/sync v0.7.0 // indirect
-	golang.org/x/sys v0.22.0 // indirect
-	golang.org/x/term v0.21.0 // indirect
-	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/mod v0.20.0 // indirect
+	golang.org/x/net v0.28.0 // indirect
+	golang.org/x/oauth2 v0.22.0 // indirect
+	golang.org/x/sync v0.8.0 // indirect
+	golang.org/x/sys v0.23.0 // indirect
+	golang.org/x/term v0.23.0 // indirect
+	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
-	google.golang.org/appengine v1.6.8 // indirect
+	golang.org/x/tools v0.24.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
 	google.golang.org/grpc v1.63.0 // indirect
 	google.golang.org/protobuf v1.33.0 // indirect
@@ -176,7 +177,6 @@
 	k8s.io/component-base v0.30.0 // indirect
 	k8s.io/klog/v2 v2.120.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20240403164606-bc84c2ddaf99 // indirect
-	k8s.io/kubectl v0.29.3 // indirect
 	k8s.io/utils v0.0.0-20240310230437-4693a0247e57 // indirect
 	oras.land/oras-go v1.2.5 // indirect
 	sigs.k8s.io/controller-runtime v0.18.1 // indirect
diff --git a/core/installer/go.sum b/core/installer/go.sum
index fc743fe..8c5107a 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -1,7 +1,7 @@
-cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e h1:GwCVItFUPxwdsEYnlUcJ6PJxOjTeFFCKOh6QWg4oAzQ=
-cuelabs.dev/go/oci/ociregistry v0.0.0-20240314152124-224736b49f2e/go.mod h1:ApHceQLLwcOkCEXM1+DyCXTHEJhNGDpJ2kmV6axsx24=
-cuelang.org/go v0.8.1 h1:VFYsxIFSPY5KgSaH1jQ2GxHOrbu6Ga3kEI70yCZwnOg=
-cuelang.org/go v0.8.1/go.mod h1:CoDbYolfMms4BhWUlhD+t5ORnihR7wvjcfgyO9lL5FI=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79 h1:EceZITBGET3qHneD5xowSTY/YHbNybvMWGh62K2fG/M=
+cuelabs.dev/go/oci/ociregistry v0.0.0-20240807094312-a32ad29eed79/go.mod h1:5A4xfTzHTXfeVJBU6RAUf+QrlfTCW+017q/QiW+sMLg=
+cuelang.org/go v0.10.0 h1:Y1Pu4wwga5HkXfLFK1sWAYaSWIBdcsr5Cb5AWj2pOuE=
+cuelang.org/go v0.10.0/go.mod h1:HzlaqqqInHNiqE6slTP6+UtxT9hN6DAzgJgdbNxXvX8=
 dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
 dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
@@ -77,7 +77,7 @@
 github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0=
 github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
-github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
 github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg=
@@ -110,8 +110,8 @@
 github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
 github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk=
 github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
-github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw=
-github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
+github.com/emicklei/proto v1.13.2 h1:z/etSFO3uyXeuEsVPzfl56WNgzcvIr42aQazXaQmFZY=
+github.com/emicklei/proto v1.13.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A=
 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
 github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
@@ -177,8 +177,6 @@
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
-github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
 github.com/gomarkdown/markdown v0.0.0-20240328165702-4d01890c35c0 h1:4gjrh/PN2MuWCCElk8/I4OCKRKWCCo2zEct3VKCbibU=
@@ -190,7 +188,6 @@
 github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49 h1:0VpGH+cDhbDtdcweoyCVsF3fhN8kejK6rFe/2FFX2nU=
 github.com/google/gnostic-models v0.6.9-0.20230804172637-c7be7c783f49/go.mod h1:BkkQ4L1KS1xMt2aWSPStnn55ChGC0DPOn2FQYj+f25M=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -330,6 +327,8 @@
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
 github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
 github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
 github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI=
@@ -367,8 +366,8 @@
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
-github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
-github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21 h1:igWZJluD8KtEtAgRyF4x6lqcxDry1ULztksMJh2mnQE=
+github.com/rogpeppe/go-internal v1.12.1-0.20240709150035-ccf4b4329d21/go.mod h1:RMRJLmBOqWacUkmJHRMiPKh1S1m3PA7Zh4W80/kWPpg=
 github.com/rubenv/sql-migrate v1.6.1 h1:bo6/sjsan9HaXAsNxYP/jCEDUGibHp8JmOBw7NTGRos=
 github.com/rubenv/sql-migrate v1.6.1/go.mod h1:tPzespupJS0jacLfhbwto/UjSX+8h2FdWB7ar+QlHa0=
 github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
@@ -387,20 +386,25 @@
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
 github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
-github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
-github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
+github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
@@ -448,16 +452,16 @@
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
-golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
+golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
-golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=
+golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -470,10 +474,10 @@
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
-golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
+golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
+golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
+golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
+golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -481,8 +485,8 @@
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
-golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
+golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -503,25 +507,24 @@
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
-golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
+golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
-golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
-golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
+golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
+golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
-golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
+golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -530,20 +533,16 @@
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
-google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
 google.golang.org/grpc v1.63.0 h1:WjKe+dnvABXyPJMD7KDNLxtoGk5tgk+YFWN6cBWjZE8=
 google.golang.org/grpc v1.63.0/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
-google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
-google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
 google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
diff --git a/core/installer/schema.go b/core/installer/schema.go
index 5a70519..3c9e237 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -6,6 +6,7 @@
 
 	"cuelang.org/go/cue"
 	"cuelang.org/go/cue/cuecontext"
+	"cuelang.org/go/cue/format"
 )
 
 type Kind int
@@ -64,6 +65,7 @@
     ingressClassName: string
 }
 
+value: #Cluster
 value: { %s }
 `
 
@@ -71,18 +73,23 @@
 	if v.Value().Kind() != cue.StructKind {
 		return false
 	}
-	s := fmt.Sprintf(clusterSchema, fmt.Sprintf("%#v", v))
+	vb, err := format.Node(v.Syntax(cue.All()), format.TabIndent(true))
+	if err != nil {
+		return false
+	}
+	s := fmt.Sprintf(clusterSchema, string(vb))
 	c := cuecontext.New()
 	u := c.CompileString(s)
+	if err := u.Err(); err != nil {
+		return false
+	}
 	if err := u.Validate(); err != nil {
 		return false
 	}
-	cluster := u.LookupPath(cue.ParsePath("#Cluster"))
-	vv := u.LookupPath(cue.ParsePath("value"))
-	if err := cluster.Subsume(vv); err == nil {
-		return true
+	if err := u.Eval().Err(); err != nil {
+		return false
 	}
-	return false
+	return true
 }
 
 const networkSchema = `
@@ -96,6 +103,7 @@
 	deallocatePortAddr: string
 }
 
+value: #Network
 value: { %s }
 `
 
@@ -103,15 +111,23 @@
 	if v.Value().Kind() != cue.StructKind {
 		return false
 	}
-	s := fmt.Sprintf(networkSchema, fmt.Sprintf("%#v", v))
+	vb, err := format.Node(v.Syntax(cue.All()), format.TabIndent(true))
+	if err != nil {
+		return false
+	}
+	s := fmt.Sprintf(networkSchema, string(vb))
 	c := cuecontext.New()
 	u := c.CompileString(s)
-	network := u.LookupPath(cue.ParsePath("#Network"))
-	vv := u.LookupPath(cue.ParsePath("value"))
-	if err := network.Subsume(vv); err == nil {
-		return true
+	if err := u.Err(); err != nil {
+		return false
 	}
-	return false
+	if err := u.Validate(); err != nil {
+		return false
+	}
+	if err := u.Eval().Err(); err != nil {
+		return false
+	}
+	return true
 }
 
 const multiNetworkSchema = `
@@ -127,6 +143,7 @@
 
 #Networks: [...#Network]
 
+value: #Networks
 value: %s
 `
 
@@ -134,15 +151,23 @@
 	if v.Value().IncompleteKind() != cue.ListKind {
 		return false
 	}
-	s := fmt.Sprintf(multiNetworkSchema, fmt.Sprintf("%#v", v))
+	vb, err := format.Node(v.Syntax(cue.All()), format.TabIndent(true))
+	if err != nil {
+		return false
+	}
+	s := fmt.Sprintf(multiNetworkSchema, string(vb))
 	c := cuecontext.New()
 	u := c.CompileString(s)
-	networks := u.LookupPath(cue.ParsePath("#Networks"))
-	vv := u.LookupPath(cue.ParsePath("value"))
-	if err := networks.Subsume(vv); err == nil {
-		return true
+	if err := u.Err(); err != nil {
+		return false
 	}
-	return false
+	if err := u.Validate(); err != nil {
+		return false
+	}
+	if err := u.Eval().Err(); err != nil {
+		return false
+	}
+	return true
 }
 
 const authSchema = `
@@ -151,6 +176,7 @@
     groups: string | *""
 }
 
+value: #Auth
 value: { %s }
 `
 
@@ -158,15 +184,23 @@
 	if v.Value().Kind() != cue.StructKind {
 		return false
 	}
-	s := fmt.Sprintf(authSchema, fmt.Sprintf("%#v", v))
+	vb, err := format.Node(v.Syntax(cue.All()), format.TabIndent(true))
+	if err != nil {
+		return false
+	}
+	s := fmt.Sprintf(authSchema, string(vb))
 	c := cuecontext.New()
 	u := c.CompileString(s)
-	auth := u.LookupPath(cue.ParsePath("#Auth"))
-	vv := u.LookupPath(cue.ParsePath("value"))
-	if err := auth.Subsume(vv); err == nil {
-		return true
+	if err := u.Err(); err != nil {
+		return false
 	}
-	return false
+	if err := u.Validate(); err != nil {
+		return false
+	}
+	if err := u.Eval().Err(); err != nil {
+		return false
+	}
+	return true
 }
 
 const sshKeySchema = `
@@ -175,6 +209,7 @@
     private: string
 }
 
+value: #SSHKey
 value: { %s }
 `
 
@@ -182,15 +217,23 @@
 	if v.Value().Kind() != cue.StructKind {
 		return false
 	}
-	s := fmt.Sprintf(sshKeySchema, fmt.Sprintf("%#v", v))
+	vb, err := format.Node(v.Syntax(cue.All()), format.TabIndent(true))
+	if err != nil {
+		return false
+	}
+	s := fmt.Sprintf(sshKeySchema, string(vb))
 	c := cuecontext.New()
 	u := c.CompileString(s)
-	sshKey := u.LookupPath(cue.ParsePath("#SSHKey"))
-	vv := u.LookupPath(cue.ParsePath("value"))
-	if err := sshKey.Subsume(vv); err == nil {
-		return true
+	if err := u.Err(); err != nil {
+		return false
 	}
-	return false
+	if err := u.Validate(); err != nil {
+		return false
+	}
+	if err := u.Eval().Err(); err != nil {
+		return false
+	}
+	return true
 }
 
 type basicSchema struct {
@@ -318,7 +361,7 @@
 		}
 		return s, nil
 	default:
-		return nil, fmt.Errorf("SHOULD NOT REACH!")
+		return nil, fmt.Errorf("SHOULD NOT REACH! field: %s, value: %s", name, v)
 	}
 }
 
diff --git a/core/installer/schema_test.go b/core/installer/schema_test.go
index 61739fb..04375bf 100644
--- a/core/installer/schema_test.go
+++ b/core/installer/schema_test.go
@@ -2,6 +2,8 @@
 
 import (
 	"testing"
+
+	"cuelang.org/go/cue"
 )
 
 func TestFindPortFields(t *testing.T) {
@@ -34,3 +36,56 @@
 		t.Fatalf("expected 'z' and 'w.z' port fields, %v", p)
 	}
 }
+
+const isNotNetwork = `
+input: {
+	repoAddr: string
+	repoPublicAddr: string
+	managerAddr: string
+	appId: string
+	branch: string
+	sshPrivateKey: string
+	username?: string
+	cluster: #Cluster
+	username?: string | *"test"
+	vpnAuthKey: string  @role(VPNAuthKey) @usernameField(username)
+}
+
+#Cluster: {
+	name: string
+	kubeconfig: string
+	ingressClassName: string
+}
+`
+
+func TestIsNotNetwork(t *testing.T) {
+	v, err := ParseCueAppConfig(CueAppData{"/test.cue": []byte(isNotNetwork)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if isNetwork(v.LookupPath(cue.ParsePath("input"))) {
+		t.Fatal("not really network")
+	}
+}
+
+const inputIsNetwork = `
+input: {
+	name: string
+	ingressClass: string
+	certificateIssuer: string | *""
+	domain: string
+	allocatePortAddr: string
+	reservePortAddr: string
+	deallocatePortAddr: string
+}
+`
+
+func TestIsNetwork(t *testing.T) {
+	v, err := ParseCueAppConfig(CueAppData{"/test.cue": []byte(inputIsNetwork)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !isNetwork(v.LookupPath(cue.ParsePath("input"))) {
+		t.Fatal("is network")
+	}
+}
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 3016190..808bf7e 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -137,6 +137,9 @@
 	r := mux.NewRouter()
 	r.PathPrefix("/stat/").Handler(cachingHandler{http.FileServer(http.FS(statAssets))})
 	r.HandleFunc("/api/networks", s.handleNetworks).Methods(http.MethodGet)
+	r.HandleFunc("/api/clusters", s.handleClusters).Methods(http.MethodGet)
+	r.HandleFunc("/api/proxy/add", s.handleProxyAdd).Methods(http.MethodPost)
+	r.HandleFunc("/api/proxy/remove", s.handleProxyRemove).Methods(http.MethodPost)
 	r.HandleFunc("/api/app-repo", s.handleAppRepo)
 	r.HandleFunc("/api/app/{slug}/install", s.handleAppInstall).Methods(http.MethodPost)
 	r.HandleFunc("/api/app/{slug}", s.handleApp).Methods(http.MethodGet)
@@ -158,14 +161,6 @@
 	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 }
 
-type app struct {
-	Name             string                        `json:"name"`
-	Icon             template.HTML                 `json:"icon"`
-	ShortDescription string                        `json:"shortDescription"`
-	Slug             string                        `json:"slug"`
-	Instances        []installer.AppInstanceConfig `json:"instances,omitempty"`
-}
-
 func (s *AppManagerServer) handleNetworks(w http.ResponseWriter, r *http.Request) {
 	env, err := s.m.Config()
 	if err != nil {
@@ -183,6 +178,55 @@
 	}
 }
 
+func (s *AppManagerServer) handleClusters(w http.ResponseWriter, r *http.Request) {
+	clusters, err := s.m.GetClusters()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := json.NewEncoder(w).Encode(installer.ToAccessConfigs(clusters)); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type proxyPair struct {
+	From string `json:"from"`
+	To   string `json:"to"`
+}
+
+func (s *AppManagerServer) handleProxyAdd(w http.ResponseWriter, r *http.Request) {
+	var req proxyPair
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if err := s.cnc.AddProxy(req.From, req.To); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *AppManagerServer) handleProxyRemove(w http.ResponseWriter, r *http.Request) {
+	var req proxyPair
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	if err := s.cnc.RemoveProxy(req.From, req.To); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type app struct {
+	Name             string                        `json:"name"`
+	Icon             template.HTML                 `json:"icon"`
+	ShortDescription string                        `json:"shortDescription"`
+	Slug             string                        `json:"slug"`
+	Instances        []installer.AppInstanceConfig `json:"instances,omitempty"`
+}
+
 func (s *AppManagerServer) handleAppRepo(w http.ResponseWriter, r *http.Request) {
 	all, err := s.r.GetAll()
 	if err != nil {
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 9a93afd..4307866 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -748,6 +748,11 @@
 		if err != nil {
 			return
 		}
+		// TODO(gio): get only available ones by owner
+		clusters, err := s.getClusters()
+		if err != nil {
+			return
+		}
 		apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 		instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
 		if err != nil {
@@ -768,7 +773,7 @@
 		}
 		s.l.Lock()
 		defer s.l.Unlock()
-		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, owner)
+		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, clusters, owner)
 		if err = s.createCommit(req.Repository.Name, branch, req.After, commitMsg, err, resources); err != nil {
 			fmt.Printf("Error: %s\n", err.Error())
 			return
@@ -1046,6 +1051,11 @@
 	if err != nil {
 		return err
 	}
+	// TODO(gio): get only available ones by owner
+	clusters, err := s.getClusters()
+	if err != nil {
+		return err
+	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
 	if err != nil {
@@ -1062,7 +1072,7 @@
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
 	s.setAppConfig(appName, branch, appConfig{namespace, network})
-	resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, user)
+	resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, clusters, user)
 	if err != nil {
 		fmt.Printf("Error: %s\n", err.Error())
 		return err
@@ -1216,6 +1226,7 @@
 	branch string,
 	namespace string,
 	networks []installer.Network,
+	clusters []installer.Cluster,
 	owner string,
 ) (installer.ReleaseResources, error) {
 	repo, err := s.client.GetRepoBranch(name, branch)
@@ -1256,6 +1267,7 @@
 			installer.WithNoPublish(),
 			installer.WithConfig(&s.env),
 			installer.WithNetworks(networks),
+			installer.WithClusters(clusters),
 			installer.WithLocalChartGenerator(lg),
 			installer.WithNoLock(),
 		)
@@ -1280,6 +1292,7 @@
 			installer.WithNoPublish(),
 			installer.WithConfig(&s.env),
 			installer.WithNetworks(networks),
+			installer.WithClusters(clusters),
 			installer.WithLocalChartGenerator(lg),
 			installer.WithNoLock(),
 		); err != nil {
@@ -1323,6 +1336,20 @@
 	return s.nf.Filter(user, networks)
 }
 
+func (s *DodoAppServer) getClusters() ([]installer.Cluster, error) {
+	addr := fmt.Sprintf("%s/api/clusters", s.envAppManagerAddr)
+	resp, err := http.Get(addr)
+	if err != nil {
+		return nil, err
+	}
+	clusters := []installer.Cluster{}
+	if json.NewDecoder(resp.Body).Decode(&clusters); err != nil {
+		return nil, err
+	}
+	fmt.Printf("CLUSTERS %+v\n", clusters)
+	return clusters, nil
+}
+
 type publicNetworkData struct {
 	Name   string `json:"name"`
 	Domain string `json:"domain"`