AppManager: Add API endpoint to install dodo app

Refactors cue definitions.

Next steps:
* Needs some cleanup, namespace is hard coded ...
* Maybe merge with regular install API
* Support exposing ports across clusters

Change-Id: Ibfc3c3f742b61f2c5874012fe6c77b958eae81d9
diff --git a/core/installer/Makefile b/core/installer/Makefile
index abdd656..0fc236c 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -1,4 +1,4 @@
-repo_name ?= dtabidze
+repo_name ?= giolekva
 podman ?= docker
 ifeq ($(podman), podman)
 manifest_dest=docker://docker.io/$(repo_name)/pcloud-installer:latest
diff --git a/core/installer/app.go b/core/installer/app.go
index 612f2e5..948e17e 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -476,6 +476,31 @@
 	return AppTypeEnv
 }
 
+func merge(d map[string]any, v map[string]any) map[string]any {
+	ret := map[string]any{}
+	for k, val := range d {
+		if vv, ok := v[k]; ok && vv != nil {
+			if mv, ok := val.(map[string]any); ok {
+				// TODO(gio): check that it is actually map
+				fmt.Println(vv)
+				ret[k] = merge(mv, vv.(map[string]any))
+			} else {
+				ret[k] = vv
+			}
+		} else {
+			ret[k] = val
+		}
+	}
+	for k, v := range v {
+		if v != nil {
+			if _, ok := d[k]; !ok {
+				d[k] = v
+			}
+		}
+	}
+	return ret
+}
+
 func (a cueEnvApp) Render(
 	release Release,
 	env EnvConfig,
@@ -485,7 +510,12 @@
 	charts map[string]helmv2.HelmChartTemplateSpec,
 	vpnKeyGen VPNAPIClient,
 ) (EnvAppRendered, error) {
-	derived, err := deriveValues(values, values, a.Schema(), networks, clusters, vpnKeyGen)
+	dv, err := ExtractDefaultValues(a.cueApp.cfg.LookupPath(cue.ParsePath("input")))
+	if err != nil {
+		return EnvAppRendered{}, err
+	}
+	mv := merge(dv.(map[string]any), values)
+	derived, err := deriveValues(mv, values, a.Schema(), networks, clusters, vpnKeyGen)
 	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 3ed300a..7698553 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -3,6 +3,7 @@
 	"encoding/yaml"
 	"list"
 	"net"
+	"strings"
 )
 
 input: {
@@ -62,15 +63,15 @@
 	fullNameWithTag: "\(fullName):\(tag)"
 }
 
-#Volume: {
+#VolumeInput: {
 	cluster?: #Cluster
+	name: string
 	size: string
 	accessMode: "ReadWriteOnce" | "ReadOnlyMany" | "ReadWriteMany" | "ReadWriteOncePod" | *"ReadWriteOnce"
 }
 
-#volume: {
-	#Volume
-	name: string
+#Volume: {
+	#VolumeInput
 }
 
 #Chart: #GitRepositoryRef | #HelmRepositoryRef
@@ -114,7 +115,7 @@
 	port: int
 	service: close({
 		name: string
-		port: int & > 0
+		port: #PortValue
 	})
 	protocol: "TCP" | "UDP" | *"TCP"
 
@@ -237,19 +238,17 @@
 					name: "ssh"
 					value: 22
 					protocol: "TCP"
-				}, {
-					if codeServerEnabled {
-						name: _codeServerPortName
-						value: _codeServerPort
-						protocol: "TCP"
-					}
 				},
-					for p in _ports {
-						name: p.name
-						value: p.value
-						protocol: p.protocol
-					}
-				]
+				if codeServerEnabled {
+					name: _codeServerPortName
+					value: _codeServerPort
+					protocol: "TCP"
+				}
+				for p in _ports {
+					name: p.name
+					value: p.value
+					protocol: p.protocol
+				}]
 				cloudInit: {
 					userData: base64.Encode(null, "#cloud-config\n\(yaml.Marshal(_cloudInitUserData))")
 					networkData: base64.Encode(null, yaml.Marshal({
@@ -319,17 +318,49 @@
 	}
 }
 
-#MongoDB: #WithOut & {
+#Domain: {
+	network: string
+	subdomain: string
+}
+
+#PortDomain: {
+	#Domain
+	port: { name: string } | { value: #PortValue }
+}
+
+#MongoDBInput: {
 	cluster?: #Cluster
-	_cluster: cluster
 	name: string
 	version: "8.0.1"
 	size: string | *"1Gi"
 	initdbScripts: {...}
-	_initdbScripts: initdbScripts
+	expose: [...#Domain] | *[]
+	...
+}
 
+#MongoDB: #WithOut & #MongoDBInput & {
+	cluster?: #Cluster
+	name: string
+	version: "8.0.1"
+	size: string | *"1Gi"
+	initdbScripts: {...}
+	expose: [...#Domain] | *[]
+
+	_cluster: cluster
+	_name: name
 	_size: size
 	_volumeClaimName: "\(name)-mongodb"
+	_initdbScripts: initdbScripts
+
+	openPort: [for i, e in expose {
+		network: networks[strings.ToLower(e.network)]
+		port: input["port_mongodb_\(_name)_\(i)"]
+		protocol: "TCP"
+		service: {
+			name: "mongodb-\(_name)"
+			port: 27017
+		}
+	}]
 
 	images: {
 		mongodb: {
@@ -348,7 +379,7 @@
 			path: "charts/mongodb"
 		}
 	}
-	volumes: {
+	volume: {
 		"\(_volumeClaimName)": {
 			size: _size
 			if _cluster != _|_ {
@@ -357,7 +388,7 @@
 		}
 	}
 	helm: {
-		mongodb: {
+		"mongodb-\(name)": {
 			chart: charts.mongodb
 			if _cluster != _|_ {
 				cluster: _cluster
@@ -422,6 +453,12 @@
 		}
 		...
 	}
+ 	openPortMap: {
+		for k, v in mongodb {
+			"mongodb-\(k)": v.openPort
+		}
+		...
+	}
 	images: {
 		for k, v in mongodb {
 			for x, y in v.images {
@@ -447,16 +484,38 @@
 	...
 }
 
-#PostgreSQL: #WithOut & {
+#PostgreSQLInput: {
 	cluster?: #Cluster
-	_cluster: cluster
 	name: string
 	version: "15.3"
 	initSQL: string | *""
 	size: string | *"1Gi"
+	expose: [...#Domain] | *[]
+	...
+}
 
+#PostgreSQL: #WithOut & #PostgreSQLInput & {
+	cluster?: #Cluster
+	name: string
+	version: "15.3"
+	initSQL: string | *""
+	size: string | *"1Gi"
+	expose: [...#Domain] | *[]
+
+	_cluster: cluster
 	_size: size
 	_volumeClaimName: "\(name)-postgresql"
+	_name: name
+
+	openPort: [for i, e in expose {
+		network: networks[strings.ToLower(e.network)]
+		port: input["port_postgresql_\(_name)_\(i)"]
+		protocol: "TCP"
+		service: {
+			name: "postgres-\(_name)"
+			port: 5432
+		}
+	}]
 
 	images: {
 		postgres: {
@@ -474,7 +533,7 @@
 			path: "charts/postgresql"
 		}
 	}
-	volumes: {
+	volume: {
 		"\(_volumeClaimName)": {
 			size: _size
 			if _cluster != _|_ {
@@ -670,13 +729,12 @@
 	images: {...}
 	charts: {...}
 	helm: {...}
-	openPort: [...#PortForward]
+	openPort: [...#PortForward] | *[]
 	openPortMap: {
 		"_self": openPort
 		...
 	}
 	clusterProxy: {...}
-	openPort: [...#PortForward] | *[]
 	images: {
 		for k, v in images {
 			"\(k)": #Image & v
@@ -765,10 +823,10 @@
 		}
 		...
 	}
-	volumes: {...}
-	volumes: {
-		for k, v in volumes {
-			"\(k)": #volume & v & {
+	volume: {...}
+	volume: {
+		for k, v in volume {
+			"\(k)": #Volume & v & {
 				name: k
 				if _cluster != _|_ {
 					cluster: _cluster
@@ -777,7 +835,7 @@
 		}
 	}
 	helm: {
-		for k, v in volumes {
+		for k, v in volume {
 			"\(k)-volume": {
 				chart: charts.volume
 				info: "Creating disk for \(k)"
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index 13fd0d4..3770ff0 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -5,104 +5,79 @@
 	"strings"
 )
 
+cluster?: string
+_cluster: cluster
+
 input: {
-	repoAddr: string
-	repoPublicAddr: string
+	// VM uses this endpoint to load env variables from dodo-app server.
+	// app-runner registers itself as worker, which later is used by dodo-app server
+	// to ping runners on git push.
 	managerAddr: string
 	appId: string
-	branch: string
 	sshPrivateKey: string
 	// TODO(gio): this should not be necessary as app.dev.username is autogenerated
 	username?: string
+	if _cluster != _|_ {
+		cluster: clusterMap[strings.ToLower(_cluster)]
+	}
+
+	for v in _postgresql {
+		for i, e in v.expose {
+			"port_postgresql_\(v.name)_\(i)": int @role(port)
+		}
+	}
+	for v in _mongodb {
+		for i, e in v.expose {
+			"port_mongodb_\(v.name)_\(i)": int @role(port)
+		}
+	}
+	for svc in service {
+		for i, e in svc.expose {
+			"port_service_\(svc.name)_\(i)": int @role(port)
+		}
+	}
+
+	if input.cluster != _|_ {
+		appVPNAuthKey: string @role(VPNAuthKey) @username(private-network-proxy)
+	}
+
+	for svc in service {
+		if svc.dev.enabled {
+			username: string | *svc.dev.username
+			vpnAuthKey: string  @role(VPNAuthKey) @usernameField(username)
+		}
+	}
 }
 
+_appIdSanitized: strings.Replace(input.appId, "/", "-", -1)
+
 _devVM: {}
 
-#PSQL: {
-	name: string
-	size: string | *"1Gi"
-	cluster?: string
-	expose: [...string] | *[]
-}
+volume: [...#VolumeInput] | *[]
+postgresql: [...#PostgreSQLInput] | *[]
+mongodb: [...#MongoDBInput] | *[]
 
-postgresql: [...#PSQL] | *[]
+_volume: volume
 _postgresql: postgresql
+_mongodb: mongodb
 
-input: {
-	for psql in _postgresql {
-		for i, e in psql.expose {
-			"port_\(psql.name)_\(i)": int @role(port)
-		}
+envVars: [
+	for v in _volume {
+		"DODO_VOLUME_\(strings.ToUpper(v.name))=/dodo/volume/\(v.name)"
 	}
-}
-
-out: {
-	postgresql: {
-		for psql in _postgresql {
-			"\(psql.name)": #PostgreSQL & {
-				name: psql.name
-				size: psql.size
-				if psql.cluster != _|_ {
-					cluster: clusterMap[strings.ToLower(app.cluster)]
-				}
-				openPort: [for i, e in psql.expose {
-					network: networks[strings.ToLower(e)]
-					port: input["port_\(psql.name)_\(i)"]
-					protocol: "TCP"
-					service: {
-						name: "postgres-\(psql.name)"
-						port: 5432
-					}
-				}]
-			}
-		}
+	for p in _postgresql {
+		"DODO_POSTGRESQL_\(strings.ToUpper(p.name))_URL=postgresql://postgres:postgres@postgres-\(p.name).\(release.namespace).svc.cluster.local/postgres"
 	}
-}
-if app.dev.enabled {
-	input: {
-		username?: string | *app.dev.username
-		vpnAuthKey: string  @role(VPNAuthKey) @usernameField(username)
+	for m in _mongodb {
+		"DODO_MONGODB_\(strings.ToUpper(m.name))_URL=mongodb://mongo:mongo@mongodb-\(m.name).\(release.namespace).svc.cluster.local/mongo"
 	}
-	_devVM: {
-		username: app.dev.username
-		domain: global.domain
-		vpn: {
-			enabled: true
-			loginServer: "https://headscale.\(global.domain)"
-			authKey: input.vpnAuthKey
-		}
-		codeServerEnabled: true
-		cpuCores: 2
-		memory: "3Gi"
-		ports: app.ports
-		cloudInit: {
-			_loadEnvFile: "/home/\(username)/.dodo_env.sh"
-			writeFiles: [{
-				path: _loadEnvFile
-				content: "source <(curl -fsSL \(input.managerAddr)/api/apps/\(input.appId)/branch/\(input.branch)/env-profile)"
-				owner: "\(username):\(username)"
-				permissions: "0700"
-			},
-			{
-				path: "/home/\(username)/.bash_profile"
-				content: "source \(_loadEnvFile)"
-				owner: "\(username):\(username)"
-				permissions: "0700"
-			}]
-			runCmd: list.Concat([[
-				["sh", "-c", "chown \(username):\(username) /home/\(username)/.cache"],
-				["sh", "-c", "GIT_SSH_COMMAND='ssh -i /home/\(username)/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new' git clone --branch \(input.branch) \(input.repoPublicAddr)/\(input.appId) /home/\(username)/code"],
-				["sh", "-c", "chown -R \(username):\(username) /home/\(username)/code"],
-				["sh", "-c", "chown -R \(username):\(username) /home/\(username)"],
-	        ], app.vm.cloudInit.runCmd])
-		}
-	}
-}
+]
 
 #AppIngress: {
 	network: string
 	subdomain: string
 	auth: #Auth
+	port: { name: string } | { value: number } | *{ name: "web" }
 
 	_network: networks[strings.ToLower(network)]
 	baseURL: "https://\(subdomain).\(_network.domain)"
@@ -131,52 +106,46 @@
 }
 
 #AppTmpl: {
+	name: string | *"app"
 	type: string
-	cluster?: string
-	ingress: #AppIngress
-	volumes: [...#volume]
-	postgresql: [...#PostgreSQL]
-	mongodb: [...#MongoDB]
+	ingress: #AppIngress // TODO(gio): make it a list
+	expose: [...#PortDomain] | *[]
 	rootDir: string
 	runConfiguration: [...#Command]
+	volume: [...string] | *[]
 	dev: #Dev | *{ enabled: false }
 	vm: #VMCustomization
 	// TODO(gio): check for duplicate values
 	apiPort: #PortValue | *3000
 	ports: [...#Port]
+	source: close({
+		repository: string
+		branch: string | *"master"
+		rootDir: string | *"/"
+	})
 
-	lastCmdEnv: [
-		for p in ports {
-			"DODO_PORT_\(strings.ToUpper(p.name))=\(p.value)"
-		}
-		for v in volumes {
-			"DODO_VOLUME_\(strings.ToUpper(v.name))=/dodo-volume/\(v.name)"
-		}
-		for p in postgresql {
-			"DODO_POSTGRESQL_\(strings.ToUpper(p.name))_URL=postgresql://postgres:postgres@postgres-\(p.name).\(release.namespace).svc.cluster.local/postgres"
-		}
-		for m in mongodb {
-			"DODO_MONGODB_\(strings.ToUpper(m.name))_URL=mongodb://mongo:mongo@mongodb-\(m.name).\(release.namespace).svc.cluster.local/mongo"
-		}
-    ]
+	lastCmdEnv: list.Concat([
+		envVars,
+		[
+			for p in ports {
+				"DODO_PORT_\(strings.ToUpper(p.name))=\(p.value)"
+			}
+	    ]
+    ])
 
 	...
 }
 
-envProfile: strings.Join(list.Concat([
-	app.vm.env,
-	[for e in app.lastCmdEnv { "export \(e)" }]
-]), "\n")
-
 // Go app
 
-_goVer1220: "golang:1.22.0"
 _goVer1200: "golang:1.20.0"
+_goVer1220: "golang:1.22.0"
+_goVer1240: "golang:1.24.0"
 
 #GoAppTmpl: #AppTmpl & {
-	type: _goVer1220 | _goVer1200
+	type: _goVer1200 | _goVer1220 | _goVer1240
 	run: string | *"main.go"
-	ports: [{
+	ports: [...#Port] | *[{
 		name: "web"
 		value: 8080
 	}]
@@ -212,9 +181,33 @@
 
 #GoApp1220: #GoAppTmpl & {
 	type: _goVer1220
+	vm: {
+		env: [
+			"export PATH=$PATH:/usr/local/go/bin"
+	    ]
+		cloudInit: runCmd: [
+			["sh", "-c", "wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz -O /tmp/go.tar.gz"],
+			["sh", "-c", "rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tar.gz"],
+			["sh", "-c", "rm /tmp/go.tar.gz"],
+        ]
+	}
 }
 
-#GoApp: #GoApp1200 | #GoApp1220
+#GoApp1240: #GoAppTmpl & {
+	type: _goVer1240
+	vm: {
+		env: [
+			"export PATH=$PATH:/usr/local/go/bin"
+	    ]
+		cloudInit: runCmd: [
+			["sh", "-c", "wget https://go.dev/dl/go1.24.0.linux-amd64.tar.gz -O /tmp/go.tar.gz"],
+			["sh", "-c", "rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tar.gz"],
+			["sh", "-c", "rm /tmp/go.tar.gz"],
+        ]
+	}
+}
+
+#GoApp: #GoApp1200 | #GoApp1220 | #GoApp1240
 
 // Hugo app
 
@@ -223,7 +216,7 @@
 #HugoAppTmpl: #AppTmpl & {
 	type: _hugoLatest
 	ingress: #AppIngress
-	ports: [{
+	ports: [...#Port] | *[{
 		name: "web"
 		value: 1313
 	}]
@@ -253,7 +246,7 @@
 
 #PHPAppTmpl: #AppTmpl & {
 	type: "php:8.2-apache"
-	ports: [{
+	ports: [...#Port] | *[{
 		name: "web"
 		value: 80
 	}]
@@ -274,7 +267,7 @@
 #NextjsDeno2AppTmpl: #AppTmpl & {
 	type: "nextjs:deno-2.0.0"
 	apiPort: 2000
-	ports: [{
+	ports: [...#Port] | *[{
 		name: "web"
 		value: 3000
 	}]
@@ -322,7 +315,7 @@
 #NodeJSAppTmpl: #AppTmpl & {
 	buildPath: string | *"dist"
 	apiPort: 2000
-	ports: [{
+	ports: [...#Port] | *[{
 		name: "web"
 		value: 3000
 	}]
@@ -371,177 +364,247 @@
 
 #App: #GoApp | #HugoApp | #PHPApp | #NextjsApp | #NodeJSApp
 
-app: #App
-_app: app
+service: [...#App]
 
-if !_app.dev.enabled {
-	{
-		if _app.cluster != _|_ {
-			input: {
-				appVPNAuthKey: string  @role(VPNAuthKey) @username(private-network-proxy)
-			}
-		}
-		out: {
-			ingress: {
-				app: {
-					label: "App"
-					network: networks[strings.ToLower(_app.ingress.network)]
-					subdomain: _app.ingress.subdomain
-					auth: _app.ingress.auth
-					service: {
-						name: "app-app"
-						port: name: "web"
-					}
-				}
-			}
-			images: {
-				app: {
-					repository: "giolekva"
-					name: "app-runner"
-					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"
-					branch: "main"
-					path: "charts/app-runner"
-				}
-			}
-			helm: {
-				if _app.cluster != _|_ {
-					{
-					"access-secrets": {
-						chart: charts["access-secrets"]
-						values: {
-							serviceAccountName: "default"
-						}
-					}
-					}
-				}
-				app: {
-					chart: charts.app
-					values: {
-						image: {
-							repository: images.app.fullName
-							tag: images.app.tag
-							pullPolicy: images.app.pullPolicy
-						}
-						// 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)"
-								}]
-						    }]
-						}
-						apiPort: _app.apiPort
-						appPorts: [for p in _app.ports {
-							name: p.name
-							containerPort: p.value
-							protocol: p.protocol
-						}]
-						appDir: _app.rootDir
-						appId: input.appId
-						repoAddr: "\(input.repoPublicAddr)/\(input.appId)"
-						branch: input.branch
-						sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
-						runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
-						managerAddr: input.managerAddr
-						volumes: [
-							for v in _app.volumes {
-								name: v.name
-								mountPath: "/dodo-volume/\(v.name)"
+outs: {
+	for svc in service {
+		"service_\(svc.name)": #WithOut & {
+			envProfile: strings.Join(list.Concat([
+														svc.vm.env,
+				[for e in svc.lastCmdEnv { "export \(e)" }]
+												]), "\n")
+			if !svc.dev.enabled {
+				{
+						// TODO(gio): support vm open ports
+						openPort: list.Concat([
+							[for i, e in svc.expose if e.port.name != _|_ {
+								for p in svc.ports if e.port.name == p.name {
+									network: networks[strings.ToLower(e.network)]
+									port: input["port_service_app_\(i)"] // TODO(gio): app name
+									protocol: "TCP"
+									service: {
+										name: "app-app"
+										port: p.value
+									}
+								}
+							}],
+							[for i, e in svc.expose if e.port.value != _|_ {
+								for p in svc.ports if e.port.value == p.value {
+									network: networks[strings.ToLower(e.network)]
+									port: input["port_service_app_\(i)"] // TODO(gio): app name
+									protocol: "TCP"
+									service: {
+										name: "app-app"
+										port: p.value
+									}
+								}
+							}]
+						])
+						ingress: {
+							"\(svc.name)": {
+								label: "App"
+								network: networks[strings.ToLower(svc.ingress.network)]
+								subdomain: svc.ingress.subdomain
+								auth: svc.ingress.auth
+								if input.cluster != _|_ {
+									cluster: input.cluster
+								}
+								service: {
+									name: "app-app"
+									if svc.ingress.port.name != _|_ {
+										port: name: svc.ingress.port.name
+									}
+									if svc.ingress.port.value != _|_ {
+										port: number: svc.ingress.port.value
+									}
+								}
 							}
-					    ]
+						}
+						images: {
+							app: {
+								repository: "giolekva"
+								name: "app-runner"
+								tag: strings.Replace(svc.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"
+								branch: "main"
+								path: "charts/app-runner"
+							}
+						}
+						helm: {
+							if input.cluster != _|_ {
+								{
+								"access-secrets": {
+									chart: charts["access-secrets"]
+									values: {
+										serviceAccountName: "default"
+									}
+								}
+								}
+							}
+							"\(svc.name)": {
+								chart: charts.app
+								values: {
+									image: {
+										repository: images.app.fullName
+										tag: images.app.tag
+										pullPolicy: images.app.pullPolicy
+									}
+									// TODO(gio): install gvisor runtime during new remote cluster init
+									if input.cluster == _|_ {
+										runtimeClassName: "untrusted-external" // TODO(gio): make this part of the infra config
+									}
+									if input.cluster != _|_ {
+										extraContainers: [{
+											name: "proxy"
+											image: images["tailscale-proxy"].fullNameWithTag
+											env: [{
+												name: "TS_AUTHKEY"
+												value: input.appVPNAuthKey
+											}, {
+												name: "TS_HOSTNAME"
+												value: "dodo-app-\(_appIdSanitized)-\(svc.name)"
+											}, {
+												name: "TS_EXTRA_ARGS"
+												value: "--login-server=https://headscale.\(global.domain)"
+											}]
+										}]
+									}
+									name: svc.name
+									apiPort: svc.apiPort
+									appPorts: [for p in svc.ports {
+										name: p.name
+										containerPort: p.value
+										protocol: p.protocol
+									}]
+									appDir: svc.rootDir
+									appId: input.appId
+									repoAddr: svc.source.repository
+									branch: svc.source.branch
+									rootDir: svc.source.rootDir
+									if input.sshPrivateKey != "" {
+										sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+									}
+									runCfg: base64.Encode(null, json.Marshal(svc.runConfiguration))
+									managerAddr: input.managerAddr
+									volumes: [
+										for v in svc.volume {
+											name: v.name
+											mountPath: "/dodo/volume/\(v)"
+										}
+									]
+								}
+							}
+						}
 					}
 				}
 			}
-		}
-	}
-}
 
-if _app.dev.enabled {
-	{
-		out: {
-			ingress: {
-				app: {
-					label: "App"
-					network: networks[strings.ToLower(_app.ingress.network)]
-					subdomain: _app.ingress.subdomain
-					auth: _app.ingress.auth
-					service: {
-						name: _vmName
-						port: name: "web"
-					}
-				}
-				code: {
-					label: "VS Code"
-					home: "/?folder=/home/\(_app.dev.username)/code"
-					network: networks[strings.ToLower(_app.ingress.network)]
-					subdomain: "code-\(_app.ingress.subdomain)"
-					auth: enabled: false
-					service: {
-						name: _vmName
-						port: name: _codeServerPortName
+			if svc.dev.enabled {
+				{
+					_vmName: "\(_appIdSanitized)-\(svc.name)-\(svc.source.branch)"
+					"\(svc.name)": #WithOut & {
+						ingress: {
+							"\(svc.name)": {
+								label: "App"
+								network: networks[strings.ToLower(svc.ingress.network)]
+								subdomain: svc.ingress.subdomain
+								auth: svc.ingress.auth
+								service: {
+									name: _vmName
+									port: name: "web"
+								}
+							}
+							code: {
+								label: "VS Code"
+								home: "/?folder=/home/\(svc.dev.username)/code"
+								network: networks[strings.ToLower(svc.ingress.network)]
+								subdomain: "code-\(svc.ingress.subdomain)"
+								auth: enabled: false
+								service: {
+									name: _vmName
+									port: name: _codeServerPortName
+								}
+							}
+						}
+						vm: {
+							"\(_vmName)": {
+								username: svc.dev.username
+								domain: global.domain
+								vpn: {
+									enabled: true
+									loginServer: "https://headscale.\(global.domain)"
+									authKey: input.vpnAuthKey
+								}
+								codeServerEnabled: true
+								cpuCores: 2
+								memory: "3Gi"
+								ports: svc.ports
+								cloudInit: {
+									_loadEnvFile: "/home/\(username)/.dodo_env.sh"
+									writeFiles: [{
+										path: _loadEnvFile
+										content: "source <(curl -fsSL \(input.managerAddr)/api/apps/\(input.appId)/branch/\(svc.source.branch)/env-profile)"
+										owner: "\(username):\(username)"
+										permissions: "0700"
+									},
+										{
+											path: "/home/\(username)/.bash_profile"
+											content: "source \(_loadEnvFile)"
+											owner: "\(username):\(username)"
+											permissions: "0700"
+										}]
+									runCmd: list.Concat([[
+																["sh", "-c", "chown \(username):\(username) /home/\(username)/.cache"],
+										["sh", "-c", "GIT_SSH_COMMAND='ssh -i /home/\(username)/.ssh/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new' git clone --branch \(svc.source.branch) \(svc.source.repository) /home/\(username)/code"],
+										["sh", "-c", "chown -R \(username):\(username) /home/\(username)/code"],
+										["sh", "-c", "chown -R \(username):\(username) /home/\(username)"],
+														], svc.vm.cloudInit.runCmd])
+								}
+							}
+						}
 					}
 				}
 			}
-		}
 	}
 }
 
-_vmName: "\(input.appId)-\(input.branch)"
-
 out: {
-	if app.cluster != _|_ {
-		cluster: clusterMap[strings.ToLower(app.cluster)]
+	if input.cluster != _|_ {
+		cluster: input.cluster
 	}
-	volumes: {
-		for v in app.volumes {
+	volume: {
+		for v in _volume {
 			"\(v.name)": v
 		}
 	}
-	// TODO(gio): remove
 	postgresql: {
-		for v in app.postgresql {
+		for v in _postgresql {
 			"\(v.name)": v
 		}
 	}
 	mongodb: {
-		for v in app.mongodb {
+		for v in _mongodb {
 			"\(v.name)": v
 		}
 	}
-	vm: {
-		"\(_vmName)": _devVM
-	}
 }
 
 _appDir: "/dodo-app"
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index a59571d..f0fe537 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -401,7 +401,8 @@
 }
 
 var dodoAppRemoteClusterCue = `
-app: {
+service: [{
+    name: "app"
 	type: "golang:1.22.0"
 	run: "main.go"
 	ingress: {
@@ -413,7 +414,8 @@
 		enabled: false
 	}
     cluster: "io"
-}`
+    source: repository: "ssh://foo.bar"
+}]`
 
 func TestDodoAppRemoteCluster(t *testing.T) {
 	app, err := NewDodoApp([]byte(dodoAppRemoteClusterCue))
@@ -432,12 +434,9 @@
 	}
 	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",
+		"managerAddr":   "3",
+		"appId":         "4",
+		"sshPrivateKey": "6",
 	}, nil, keyGen)
 	if err != nil {
 		for _, e := range errors.Errors(err) {
@@ -453,7 +452,8 @@
 }
 
 var dodoAppDevDisabledCue = `
-app: {
+service: [{
+    name: "app"
 	type: "golang:1.22.0"
 	run: "main.go"
 	ingress: {
@@ -464,10 +464,12 @@
 	dev: {
 		enabled: false
 	}
-}`
+    source: repository: "ssh://foo.bar"
+}]`
 
 var dodoAppDevEnabledCue = `
-app: {
+service: [{
+    name: "app"
 	type: "golang:1.20.0"
 	run: "main.go"
 	ingress: {
@@ -475,19 +477,24 @@
 		subdomain: "testapp"
 		auth: enabled: false
 	}
+	volume: ["data"]
 	dev: {
 		enabled: true
 		username: "gio"
 	}
-    volumes: [{
-      name: "data"
-      size: "5Gi"
-    }]
-    mongodb: [{
-      name: "db"
-      size: "10Gi"
-    }]
-}`
+    source: repository: "ssh://foo.bar"
+}]
+
+volume: [{
+  name: "data"
+  size: "5Gi"
+}]
+
+mongodb: [{
+  name: "db"
+  size: "10Gi"
+}]
+`
 
 func TestDodoAppDevDisabled(t *testing.T) {
 	app, err := NewDodoApp([]byte(dodoAppDevDisabledCue))
@@ -506,12 +513,9 @@
 	}
 	keyGen := testKeyGen{}
 	r, err := app.Render(release, env, networks, nil, map[string]any{
-		"repoAddr":       "",
-		"repoPublicAddr": "",
-		"managerAddr":    "",
-		"appId":          "",
-		"branch":         "",
-		"sshPrivateKey":  "",
+		"managerAddr":   "",
+		"appId":         "",
+		"sshPrivateKey": "",
 	}, nil, keyGen)
 	if err != nil {
 		for _, e := range errors.Errors(err) {
@@ -543,13 +547,10 @@
 	}
 	keyGen := testKeyGen{}
 	r, err := app.Render(release, env, networks, nil, map[string]any{
-		"repoAddr":       "",
-		"repoPublicAddr": "",
-		"managerAddr":    "",
-		"appId":          "",
-		"branch":         "",
-		"sshPrivateKey":  "",
-		"username":       "",
+		"managerAddr":   "",
+		"appId":         "",
+		"sshPrivateKey": "",
+		"username":      "",
 	}, nil, keyGen)
 	if err != nil {
 		for _, e := range errors.Errors(err) {
@@ -574,7 +575,6 @@
 	}
 	values := map[string]any{
 		"repoAddr":         "",
-		"repoPublicAddr":   "",
 		"repoHost":         "",
 		"branch":           "",
 		"gitRepoPublicKey": "",
diff --git a/core/installer/canvas-app.cue b/core/installer/canvas-app.cue
new file mode 100644
index 0000000..a8be305
--- /dev/null
+++ b/core/installer/canvas-app.cue
@@ -0,0 +1,37 @@
+POST http://localhost:8080/api/dodo-app
+Content-Type: application/json
+
+{
+  "id": "canvas",
+  "sshPrivateKey": "",
+  "config": {
+	"service": [{
+		"name": "app",
+		"type": "golang:1.24.0",
+		"source": {
+			"repository": "https://code.v1.dodo.cloud/pcloud",
+			"branch": "test-canvas",
+			"rootDir": "apps/canvas/server"
+		},
+		"ingress": {
+			"network": "private",
+			"subdomain": "canvas",
+			"auth": {
+				  "enabled": false
+	   	    }
+		},
+		"dev": {
+  		    "enabled": false
+		}
+	}],
+	  "mongodb": [{
+		  "name": "pgg",
+		  "size": "2Gi",
+		  "expose": [{
+		    "network": "private",
+			"subdomain": "mongoo"
+		  }]
+	  }],
+	  "cluster": "ct"
+  }
+}
diff --git a/core/installer/dodo_app_test.go b/core/installer/dodo_app_test.go
index e9d7737..5b0f3b1 100644
--- a/core/installer/dodo_app_test.go
+++ b/core/installer/dodo_app_test.go
@@ -6,8 +6,9 @@
 	"cuelang.org/go/cue/errors"
 )
 
-var exposedPostgreSQL = `
-app: {
+var exposed = `
+service: [{
+    name: "app"
 	type: "golang:1.20.0"
 	run: "main.go"
 	ingress: {
@@ -15,25 +16,93 @@
 		subdomain: "testapp"
 		auth: enabled: false
 	}
-}
+    source: repository: "ssh://foo.bar"
+    ports: [{
+        name: "a"
+        value: 100
+    }, {
+		name: "web"
+		value: 8080
+    }, {
+		name: "b"
+		value: 101
+    }]
+    expose: [{
+		network: "private"
+		subdomain: "1"
+        port: value: 100
+	}, {
+		network: "public"
+		subdomain:"2"
+        port: name: "web"
+	}, {
+		network: "private"
+		subdomain: "3"
+        port: value: 101
+    }]
+}]
 
 postgresql: [{
 	name: "db_1"
-	expose: ["private", "public", "private"]
+	expose: [{
+		network: "private"
+		subdomain: "1"
+	}, {
+		network: "public"
+		subdomain:"2"
+	}, {
+		network: "private"
+		subdomain: "3"
+	}]
 }, {
 	name: "db_2"
-	expose: ["public", "private", "public"]
-}]`
+	expose: [{
+		network: "public"
+		subdomain: "1"
+	}, {
+		network: "private"
+		subdomain:"2"
+	}, {
+		network: "public"
+		subdomain: "3"
+	}]
+}]
 
-func TestExposedPostgreSQL(t *testing.T) {
-	app, err := NewDodoApp([]byte(exposedPostgreSQL))
+mongodb: [{
+	name: "mdb_1"
+	expose: [{
+		network: "public"
+		subdomain: "1"
+	}, {
+		network: "private"
+		subdomain:"2"
+	}, {
+		network: "public"
+		subdomain: "3"
+	}]
+}, {
+	name: "mdb_2"
+	expose: [{
+		network: "private"
+		subdomain: "1"
+	}, {
+		network: "public"
+		subdomain:"2"
+	}, {
+		network: "private"
+		subdomain: "3"
+	}]
+}]
+`
+
+func TestExposedPorts(t *testing.T) {
+	app, err := NewDodoApp([]byte(exposed))
 	if err != nil {
 		for _, e := range errors.Errors(err) {
 			t.Log(e)
 		}
 		t.Fatal(err)
 	}
-
 	release := Release{
 		Namespace:     "foo",
 		AppInstanceId: "foo-bar",
@@ -42,28 +111,102 @@
 	}
 	keyGen := testKeyGen{}
 	r, err := app.Render(release, env, networks, nil, map[string]any{
-		"repoAddr":       "",
-		"repoPublicAddr": "",
-		"managerAddr":    "",
-		"appId":          "",
-		"branch":         "",
-		"sshPrivateKey":  "",
-		"port_db_1_0":    1,
-		"port_db_1_1":    2,
-		"port_db_1_2":    3,
-		"port_db_2_0":    4,
-		"port_db_2_1":    5,
-		"port_db_2_2":    6,
+		"managerAddr":            "",
+		"appId":                  "",
+		"sshPrivateKey":          "",
+		"port_postgresql_db_1_0": 1,
+		"port_postgresql_db_1_1": 2,
+		"port_postgresql_db_1_2": 3,
+		"port_postgresql_db_2_0": 4,
+		"port_postgresql_db_2_1": 5,
+		"port_postgresql_db_2_2": 6,
+		"port_mongodb_mdb_1_0":   1,
+		"port_mongodb_mdb_1_1":   2,
+		"port_mongodb_mdb_1_2":   3,
+		"port_mongodb_mdb_2_0":   4,
+		"port_mongodb_mdb_2_1":   5,
+		"port_mongodb_mdb_2_2":   6,
+		"port_service_app_0":     7,
+		"port_service_app_1":     8,
+		"port_service_app_2":     9,
 	}, nil, keyGen)
 	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(string(r.Raw))
+}
+
+const canvas = `
+{
+    "service": [
+        {
+            "type": "nextjs:deno-2.0.0",
+            "name": "app",
+            "source": {
+                "repository": "ssh://d.p.v1.dodo.cloud:62533/myblog",
+                "branch": "master",
+                "rootDir": "/"
+            },
+            "ports": [
+                {
+                    "name": "web",
+                    "value": 3000,
+                    "protocol": "TCP"
+                }
+            ],
+            "env": [
+                {
+                    "name": "DODO_POSTGRESQL_DB_CONNECTION_URL"
+                }
+            ],
+            "ingress": {
+                "network": "Private",
+                "subdomain": "foo",
+                "port": {
+                    "name": "web"
+                },
+                "auth": {
+                    "enabled": false
+                }
+            },
+            "expose": [],
+            "dev": { "enabled": true, "username": "gio" }
+        }
+    ],
+    "volume": [],
+    "postgresql": [
+        {
+            "name": "db",
+            "size": "1Gi",
+            "expose": []
+        }
+    ],
+    "mongodb": []
+}
+`
+
+func TestCanvas(t *testing.T) {
+	app, err := NewDodoApp([]byte(canvas))
+	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.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, nil, map[string]any{
+		"managerAddr":   "",
+		"appId":         "",
+		"sshPrivateKey": "",
+	}, nil, keyGen)
+	if err != nil {
+		t.Fatal(err)
+	}
 	t.Log(string(r.Raw))
 }
diff --git a/core/installer/reconcile.sh b/core/installer/reconcile.sh
new file mode 100755
index 0000000..f8b3295
--- /dev/null
+++ b/core/installer/reconcile.sh
@@ -0,0 +1,5 @@
+while true
+do
+	flux reconcile source git -n hgrz hgrz
+	flux reconcile kustomization -n hgrz hgrz
+done
diff --git a/core/installer/schema.go b/core/installer/schema.go
index 7f8410f..fb5d305 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -369,6 +369,56 @@
 	}
 }
 
+func ExtractDefaultValues(v cue.Value) (any, error) {
+	switch v.IncompleteKind() {
+	case cue.StringKind:
+		if d, ok := v.Default(); ok {
+			return d.String()
+		}
+	case cue.BoolKind:
+		if d, ok := v.Default(); ok {
+			return d.Bool()
+		}
+	case cue.NumberKind:
+		// TODO(gio): handle numbers
+		return nil, fmt.Errorf("implement: %s", v)
+	case cue.IntKind:
+		if d, ok := v.Default(); ok {
+			return d.Int64()
+		}
+	case cue.ListKind:
+		// TODO(gio): handle lists
+		return nil, nil
+	case cue.StructKind:
+		// TODO(gio): Such fields might have default values as well?
+		if isNetwork(v) {
+			return nil, nil
+		} else if isAuth(v) {
+			return nil, nil
+		} else if isSSHKey(v) {
+			return nil, nil
+		} else if isCluster(v) {
+			return nil, nil
+		}
+		ret := map[string]any{}
+		f, err := v.Fields(cue.All())
+		if err != nil {
+			return nil, err
+		}
+		for f.Next() {
+			fv, err := ExtractDefaultValues(f.Value())
+			if err != nil {
+				return nil, err
+			}
+			ret[f.Selector().String()] = fv
+		}
+		return ret, nil
+	default:
+		return nil, fmt.Errorf("SHOULD NOT REACH! value: %s", v)
+	}
+	return nil, nil
+}
+
 func cleanFieldName(name string) string {
 	return strings.ReplaceAll(strings.ReplaceAll(name, "?", ""), "!", "")
 }
diff --git a/core/installer/schema_test.go b/core/installer/schema_test.go
index 32baa40..0dee8e8 100644
--- a/core/installer/schema_test.go
+++ b/core/installer/schema_test.go
@@ -101,3 +101,21 @@
 		t.Fatal("not really network")
 	}
 }
+
+const inputIsCluster = `
+input: {
+	name: string
+	kubeconfig: string
+	ingressClassName: string
+}
+`
+
+func TestIsCluster(t *testing.T) {
+	v, err := ParseCueAppConfig(CueAppData{"/test.cue": []byte(inputIsCluster)})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if !isCluster(v.LookupPath(cue.ParsePath("input"))) {
+		t.Fatal("is cluster")
+	}
+}
diff --git a/core/installer/server/appmanager/server.go b/core/installer/server/appmanager/server.go
index 5e1e06b..b3a0883 100644
--- a/core/installer/server/appmanager/server.go
+++ b/core/installer/server/appmanager/server.go
@@ -1,6 +1,7 @@
 package appmanager
 
 import (
+	"bytes"
 	"context"
 	"embed"
 	"encoding/json"
@@ -9,6 +10,7 @@
 	"html/template"
 	"net"
 	"net/http"
+	"path/filepath"
 	"strconv"
 	"strings"
 	"sync"
@@ -138,6 +140,7 @@
 	r.HandleFunc("/api/instance/{slug}", s.handleInstance).Methods(http.MethodGet)
 	r.HandleFunc("/api/instance/{slug}/update", s.handleAppUpdate).Methods(http.MethodPost)
 	r.HandleFunc("/api/instance/{slug}/remove", s.handleAppRemove).Methods(http.MethodPost)
+	r.HandleFunc("/api/dodo-app", s.handleDodoAppInstall).Methods(http.MethodPost)
 	r.HandleFunc("/clusters/{cluster}/servers/{server}/remove", s.handleClusterRemoveServer).Methods(http.MethodPost)
 	r.HandleFunc("/clusters/{cluster}/servers", s.handleClusterAddServer).Methods(http.MethodPost)
 	r.HandleFunc("/clusters/{name}", s.handleCluster).Methods(http.MethodGet)
@@ -154,6 +157,47 @@
 	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 }
 
+type dodoAppInstallReq struct {
+	Id            string         `json:"id"`
+	SSHPrivateKey string         `json:"sshPrivateKey"`
+	Config        map[string]any `json:"config"`
+}
+
+func (s *Server) handleDodoAppInstall(w http.ResponseWriter, r *http.Request) {
+	var req dodoAppInstallReq
+	// TODO(gio): validate that no internal fields are overridden by request
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	clusters, err := s.m.GetClusters()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	req.Config["clusters"] = installer.ToAccessConfigs(clusters)
+	var cfg bytes.Buffer
+	if err := json.NewEncoder(&cfg).Encode(req.Config); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	app, err := installer.NewDodoApp(cfg.Bytes())
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	appDir := filepath.Join("/dodo-app", req.Id)
+	namespace := "dodo-app-test" // TODO(gio)
+	if _, err := s.m.Install(app, req.Id, appDir, namespace, map[string]any{
+		"managerAddr":   "", // TODO(gio)
+		"appId":         req.Id,
+		"sshPrivateKey": req.SSHPrivateKey,
+	}); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
 func (s *Server) handleNetworks(w http.ResponseWriter, r *http.Request) {
 	env, err := s.m.Config()
 	if err != nil {
diff --git a/core/installer/server/dodo-app/schemas/app.schema.json b/core/installer/server/dodo-app/schemas/app.schema.json
index 55b9941..f6f2171 100644
--- a/core/installer/server/dodo-app/schemas/app.schema.json
+++ b/core/installer/server/dodo-app/schemas/app.schema.json
@@ -22,7 +22,16 @@
           "$ref": "#/definitions/nodejs"
         }
       ]
-    }
+    },
+	"volume": {
+	  "$ref": "#/definitions/volumes"
+	},
+	"postgresql": {
+	  "$ref": "#/definitions/postgresql"
+	},
+	"mongodb": {
+	  "$ref": "#/definitions/mongodb"
+	}
   },
   "definitions": {
     "golang": {
@@ -45,15 +54,6 @@
         "ingress": {
           "$ref": "#/definitions/ingress"
         },
-        "volumes": {
-          "$ref": "#/definitions/volumes"
-        },
-        "postgresql": {
-          "$ref": "#/definitions/postgresql"
-        },
-        "mongodb": {
-          "$ref": "#/definitions/mongodb"
-        },
         "dev": {
           "$ref": "#/definitions/dev"
         }
@@ -100,15 +100,6 @@
         "ingress": {
           "$ref": "#/definitions/ingress"
         },
-        "volumes": {
-          "$ref": "#/definitions/volumes"
-        },
-        "postgresql": {
-          "$ref": "#/definitions/postgresql"
-        },
-        "mongodb": {
-          "$ref": "#/definitions/mongodb"
-        },
         "dev": {
           "$ref": "#/definitions/dev"
         }
@@ -132,15 +123,6 @@
         "ingress": {
           "$ref": "#/definitions/ingress"
         },
-        "volumes": {
-          "$ref": "#/definitions/volumes"
-        },
-        "postgresql": {
-          "$ref": "#/definitions/postgresql"
-        },
-        "mongodb": {
-          "$ref": "#/definitions/mongodb"
-        },
         "dev": {
           "$ref": "#/definitions/dev"
         },
@@ -167,15 +149,6 @@
         "ingress": {
           "$ref": "#/definitions/ingress"
         },
-        "volumes": {
-          "$ref": "#/definitions/volumes"
-        },
-        "postgresql": {
-          "$ref": "#/definitions/postgresql"
-        },
-        "mongodb": {
-          "$ref": "#/definitions/mongodb"
-        },
         "dev": {
           "$ref": "#/definitions/dev"
         },
diff --git a/core/installer/server/dodo-app/server.go b/core/installer/server/dodo-app/server.go
index 7643168..2bbbb7c 100644
--- a/core/installer/server/dodo-app/server.go
+++ b/core/installer/server/dodo-app/server.go
@@ -1439,7 +1439,6 @@
 			"/.dodo/app",
 			namespace,
 			map[string]any{
-				"repoAddr":       repo.FullAddress(),
 				"repoPublicAddr": s.repoPublicAddr,
 				"managerAddr":    fmt.Sprintf("http://%s", s.self),
 				"appId":          name,
diff --git a/core/installer/values-tmpl/config-repo.cue b/core/installer/values-tmpl/config-repo.cue
index 4fdb20a..384ee3f 100644
--- a/core/installer/values-tmpl/config-repo.cue
+++ b/core/installer/values-tmpl/config-repo.cue
@@ -26,7 +26,7 @@
 		}
 	}
 
-	volumes: data: size: "1Gi"
+	volume: data: size: "1Gi"
 
 	helm: {
 		softserve: {
@@ -43,7 +43,7 @@
 					tag: images.softserve.tag
 					pullPolicy: images.softserve.pullPolicy
 				}
-				persistentVolumeClaimName: volumes.data.name
+				persistentVolumeClaimName: volume.data.name
 			}
 		}
 	}
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index 349f0d6..5ad606e 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -83,7 +83,7 @@
 		}
 	}
 
-	volumes: {
+	volume: {
 		"config-repo": size: "10Gi"
 		db: size: "10Gi"
 	}
@@ -137,7 +137,7 @@
 					tag: images.softserve.tag
 					pullPolicy: images.softserve.pullPolicy
 				}
-				persistentVolumeClaimName: volumes["config-repo"].name
+				persistentVolumeClaimName: volume["config-repo"].name
 			}
 		}
 		"dodo-app": {
@@ -161,7 +161,7 @@
 				envAppManagerAddr: "http://appmanager.\(global.namespacePrefix)appmanager.svc.cluster.local"
 				envConfig: base64.Encode(null, json.Marshal(global))
 				gitRepoPublicKey: input.ssKeys.public
-				persistentVolumeClaimName: volumes.db.name
+				persistentVolumeClaimName: volume.db.name
 				allowedNetworks: strings.Join([for n in input.allowedNetworks { n.name }], ",")
 				external: input.external
 				fetchUsersAddr: "http://memberships-api.\(global.namespacePrefix)core-auth-memberships.svc.cluster.local/api/users"
diff --git a/core/installer/values-tmpl/env-dns.cue b/core/installer/values-tmpl/env-dns.cue
index 2b73ada..f5b8ac2 100644
--- a/core/installer/values-tmpl/env-dns.cue
+++ b/core/installer/values-tmpl/env-dns.cue
@@ -53,10 +53,11 @@
 		}
 	}
 
-	volumes: data: {
+	volume: data: {
 		accessMode: "ReadWriteMany"
 		size: "5Gi"
 	}
+	_volume: volume
 
 	helm: {
 		coredns: {
@@ -116,11 +117,11 @@
 				}]
 				extraConfig: import: parameters: "\(_mountPath)/coredns.conf"
 				extraVolumes: [{
-					name: volumes.data.name
-					persistentVolumeClaim: claimName: volumes.data.name
+					name: volume.data.name
+					persistentVolumeClaim: claimName: volume.data.name
 				}]
 				extraVolumeMounts: [{
-					name: volumes.data.name
+					name: volume.data.name
 					mountPath: _mountPath
 				}]
 				livenessProbe: {
@@ -161,7 +162,7 @@
 				nameserverIP: strings.Join(global.nameserverIP, ",")
 				service: type: "ClusterIP"
 				volume: {
-					claimName: volumes.data.name
+					claimName: _volume.data.name
 					mountPath: _mountPath
 				}
 			}
diff --git a/core/installer/values-tmpl/etherpad.cue b/core/installer/values-tmpl/etherpad.cue
index 980c5eb..a70b7cd 100644
--- a/core/installer/values-tmpl/etherpad.cue
+++ b/core/installer/values-tmpl/etherpad.cue
@@ -46,7 +46,7 @@
 		}
 	}
 
-	volumes: data: size: "1Gi"
+	volume: data: size: "1Gi"
 
 	postgresql: db: {
 		name: "db"
@@ -64,7 +64,7 @@
 					pullPolicy: images.etherpad.pullPolicy
 				}
 				portName: _httpPortName
-				persistentVolumeClaimName: volumes.data.name
+				persistentVolumeClaimName: volume.data.name
 				db: {
 					type: "postgres"
 					host: "postgres-db.\(release.namespace).svc.cluster.local"
diff --git a/core/installer/values-tmpl/gerrit.cue b/core/installer/values-tmpl/gerrit.cue
index 2c57ed5..c783817 100644
--- a/core/installer/values-tmpl/gerrit.cue
+++ b/core/installer/values-tmpl/gerrit.cue
@@ -104,7 +104,7 @@
 		}
 	}
 
-	volumes: {
+	volume: {
 		git: {
 			accessMode: "ReadWriteMany"
 			size: "50Gi"
@@ -246,14 +246,14 @@
 				gitRepositoryStorage: {
 					externalPVC: {
 						use: true
-						name: volumes.git.name
+						name: volume.git.name
 					}
 				}
 				logStorage: {
 					enabled: true
 					externalPVC: {
 						use: true
-						name: volumes.logs.name
+						name: volume.logs.name
 					}
 				}
 				ingress: enabled: false
diff --git a/core/installer/values-tmpl/jenkins.cue b/core/installer/values-tmpl/jenkins.cue
index 3169853..5d4110e 100644
--- a/core/installer/values-tmpl/jenkins.cue
+++ b/core/installer/values-tmpl/jenkins.cue
@@ -69,7 +69,7 @@
 		}
 	}
 
-	volumes: jenkins: size: "10Gi"
+	volume: jenkins: size: "10Gi"
 
 	helm: {
 		"oauth2-client": {
@@ -140,7 +140,7 @@
 				}
 				persistence: {
 					enabled: true
-					existingClaim: volumes.jenkins.name
+					existingClaim: volume.jenkins.name
 				}
 			}
 		}
diff --git a/core/installer/values-tmpl/memberships.cue b/core/installer/values-tmpl/memberships.cue
index 95a2b60..521c4d1 100644
--- a/core/installer/values-tmpl/memberships.cue
+++ b/core/installer/values-tmpl/memberships.cue
@@ -49,7 +49,7 @@
 		}
 	}
 
-	volumes: data: size: "1Gi"
+	volume: data: size: "1Gi"
 
 	helm: {
 		memberships: {
@@ -61,7 +61,7 @@
 					pullPolicy: images.memberships.pullPolicy
 				}
 				portName: _httpPortName
-				volumeClaimName: volumes.data.name
+				volumeClaimName: volume.data.name
 			}
 		}
 	}
diff --git a/core/installer/values-tmpl/open-project.cue b/core/installer/values-tmpl/open-project.cue
index 6598094..10234b4 100644
--- a/core/installer/values-tmpl/open-project.cue
+++ b/core/installer/values-tmpl/open-project.cue
@@ -63,7 +63,7 @@
 		}
 	}
 
-	volumes: {
+	volume: {
 		openProject: {
 			name: "open-project"
 			accessMode: "ReadWriteMany"
@@ -99,7 +99,7 @@
 				}
 				persistence: {
 					enabled: true
-					existingClaim: volumes.openProject.name
+					existingClaim: volume.openProject.name
 				}
 				postgresql: {
 					bundled: false
@@ -129,7 +129,7 @@
 		}
 		"open-project-volume": {
 			chart: charts.volume
-			values: volumes.openProject
+			values: volume.openProject
 		}
 		postgres: {
 			chart: charts.postgres
diff --git a/core/installer/values-tmpl/pihole.cue b/core/installer/values-tmpl/pihole.cue
index 01a3ca1..6607409 100644
--- a/core/installer/values-tmpl/pihole.cue
+++ b/core/installer/values-tmpl/pihole.cue
@@ -65,7 +65,7 @@
 		}
 	}
 
-	volumes: data: size: input.storageSize
+	volume: data: size: input.storageSize
 
 	helm: {
 		pihole: {
@@ -75,7 +75,7 @@
 				fullnameOverride: "pihole"
 				persistentVolumeClaim: {
 					enabled: true
-					existingClaim: volumes.data.name
+					existingClaim: volume.data.name
 				}
 				admin: {
 					enabled: false
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index dae0515..23e07bb 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -77,7 +77,7 @@
 		}
 	}]
 
-	volumes: data: size: "1Gi"
+	volume: data: size: "1Gi"
 
 	helm: {
 		softserve: {
@@ -88,7 +88,7 @@
 				adminKey: input.adminKey
 				host: _domain
 				sshPublicPort: input.sshPort
-				persistentVolumeClaimName: volumes.data.name
+				persistentVolumeClaimName: volume.data.name
 				image: {
 					repository: images.softserve.fullName
 					tag: images.softserve.tag
diff --git a/core/installer/values-tmpl/url-shortener.cue b/core/installer/values-tmpl/url-shortener.cue
index 1fa70c4..9f401fb 100644
--- a/core/installer/values-tmpl/url-shortener.cue
+++ b/core/installer/values-tmpl/url-shortener.cue
@@ -69,7 +69,7 @@
 		}
 	}
 
-		volumes: data: size: input.storageSize
+		volume: data: size: input.storageSize
 
 	helm: {
 		"url-shortener": {
@@ -82,7 +82,7 @@
 					pullPolicy: images.urlShortener.pullPolicy
 				}
 				portName: _httpPortName
-				persistentVolumeClaimNama: volumes.data.name
+				persistentVolumeClaimNama: volume.data.name
 				requireAuth: input.auth.enabled
 			}
 		}
diff --git a/core/installer/values-tmpl/vaultwarden.cue b/core/installer/values-tmpl/vaultwarden.cue
index 69ac75f..7b68190 100644
--- a/core/installer/values-tmpl/vaultwarden.cue
+++ b/core/installer/values-tmpl/vaultwarden.cue
@@ -62,7 +62,7 @@
 		}
 	}
 
-	volumes: data: size: input.storageSize
+	volume: data: size: input.storageSize
 
 	helm: {
 		vaultwarden: {
@@ -75,7 +75,7 @@
 					pullPolicy: images.vaultwarden.pullPolicy
 				}
 				domain: _domain
-				persistentVolumeClaimName: volumes.data.name
+				persistentVolumeClaimName: volume.data.name
 				httpPortName: _httpPortName
 			}
 		}
diff --git a/core/installer/values-tmpl/zot.cue b/core/installer/values-tmpl/zot.cue
index c5cf631..4f98388 100644
--- a/core/installer/values-tmpl/zot.cue
+++ b/core/installer/values-tmpl/zot.cue
@@ -84,7 +84,7 @@
 		}
 	}
 
-	volumes: zot: size: "100Gi"
+	volume: zot: size: "100Gi"
 
 	_httpPort: 80
 	_oauth2ClientSecretName: "oauth2-client"
@@ -176,7 +176,7 @@
 				persistence: true
 				pvc: {
 					create: false
-					name: volumes.zot.name
+					name: volume.zot.name
 				}
 				extraVolumes: [{
 					name: "config"