DodoApp: Individually configure dev-vm code-server, ssh, vpn

Refactor openPortMap handling a bit.

Change-Id: I2ea4d4c9b090f2791700149dda6cc8dcd8ab6820
diff --git a/Jenkinsfile b/Jenkinsfile
index 9b5aae8..ff51d94 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -36,6 +36,7 @@
                 	dir('core/installer') {
                 		sh 'go mod tidy'
                 		sh 'go build cmd/*.go'
+						sh 'go run cuelang.org/go/cmd/cue fmt app_configs/*.cue --check'
                 		sh 'go test ./...'
 					}
                     dir('core/auth/memberships') {
diff --git a/core/installer/app.go b/core/installer/app.go
index f8e6d6f..f830455 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -13,6 +13,7 @@
 	"cuelang.org/go/cue"
 	"cuelang.org/go/cue/build"
 	"cuelang.org/go/cue/cuecontext"
+	"cuelang.org/go/cue/errors"
 	"cuelang.org/go/cue/load"
 	cueyaml "cuelang.org/go/encoding/yaml"
 	helmv2 "github.com/fluxcd/helm-controller/api/v2"
@@ -342,7 +343,7 @@
 	d := ctx.CompileBytes(buf.Bytes())
 	res := a.cfg.Unify(d).Eval()
 	if err := res.Err(); err != nil {
-		return rendered{}, err
+		return rendered{}, fmt.Errorf(errors.Details(err, nil))
 	}
 	if err := res.Validate(); err != nil {
 		return rendered{}, err
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index d61bfb3..d43cf0f 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -17,7 +17,7 @@
 	release,
 	global,
 	localCharts,
-					   ])
+])
 // TODO(gio): revisit this logic
 _uuidSorted: strings.Join(list.Sort([for x in strings.Runes(_uuidData) {strconv.QuoteRune(x)}], list.Ascending), "")
 uuid: base64.Encode(null, sha256.Sum256(_uuidSorted))
@@ -253,15 +253,45 @@
 
 #VPN: #VPNEnabled | #VPNDisabled
 
+#CodeServerDisabled: {
+	enabled: false
+}
+
+#CodeServerEnabled: {
+	enabled: true
+	expose: {
+		network:   #Network
+		subdomain: string
+	} | null | *null
+}
+
+#CodeServer: #CodeServerEnabled | #CodeServerDisabled
+
+#SSHServerDisabled: {
+	enabled: false
+}
+
+#SSHServerEnabled: {
+	enabled: true
+	expose: {
+		network:         #Network
+		subdomain:       string
+		input_port_name: string
+	} | null | *null
+}
+
+#SSHServer: #SSHServerEnabled | #SSHServerDisabled
+
 #VirtualMachine: #WithOut & {
 	cluster?: #Cluster
 	name:     string
 	username: string
 	domain:   string
 	vpn: #VPN | *{enabled: false}
-	codeServerEnabled: bool | *false
-	cpuCores:          int
-	memory:            string
+	ssh: #SSHServer | *{enabled: false}
+	codeServer: #CodeServer | *{enabled: false}
+	cpuCores: int
+	memory:   string
 	sshKnownHosts: [...string] | *[]
 	sshAuthorizedKeys: [...string] | *[]
 	cloudInit: #CloudInit
@@ -277,7 +307,7 @@
 	_configFiles: configFiles
 
 	_codeServerCmd: [...[...string]] | *[]
-	if codeServerEnabled {
+	if codeServer.enabled {
 		_codeServerCmd: [
 			["sh", "-c", "curl -fsSL https://code-server.dev/install.sh | HOME=/home/\(username) sh"],
 			["sh", "-c", "systemctl enable --now code-server@\(username)"],
@@ -287,6 +317,35 @@
 			["sh", "-c", "sed -i -e 's/auth: password/auth: none/g' /home/\(username)/.config/code-server/config.yaml"],
 			["sh", "-c", "systemctl restart --now code-server@\(username)"],
 		]
+		if codeServer.expose != null {
+			ingress: {
+				"\(name)-code": {
+					label:     "VS Code"
+					home:      "/?folder=/home/\(username)/code"
+					network:   codeServer.expose.network
+					subdomain: codeServer.expose.subdomain
+					auth: enabled: false
+					service: {
+						name: _name
+						port: _codeServerPort
+					}
+				}
+			}
+		}
+	}
+
+	if ssh.enabled {
+		if ssh.expose != null {
+			openPort: [{
+				name:    "\(_name)-ssh"
+				network: ssh.expose.network
+				port:    input[ssh.expose.input_port_name]
+				service: {
+					name: _name
+					port: _sshPort
+				}
+			}]
+		}
 	}
 
 	_vpnCmd: [...[...string]] | *[]
@@ -334,12 +393,13 @@
 					source: "https://cloud.debian.org/images/cloud/bookworm-backports/latest/debian-12-backports-generic-amd64.qcow2"
 					size:   "64Gi"
 				}
-				ports: [{
-					name:     "ssh"
-					value:    22
-					protocol: "TCP"
-				},
-					if codeServerEnabled {
+				ports: [
+					if ssh.enabled {
+						name:     _sshPortName
+						value:    _sshPort
+						protocol: "TCP"
+					},
+					if codeServer.enabled {
 						name:     _codeServerPortName
 						value:    _codeServerPort
 						protocol: "TCP"
@@ -561,7 +621,9 @@
 	}
 	openPortMap: {
 		for k, v in mongodb {
-			"mongodb-\(k)": v.openPortMap
+			for x, y in v.openPortMap {
+				"mongodb-\(k)-\(x)": y
+			}
 		}
 		...
 	}
@@ -783,7 +845,7 @@
 		}
 	}
 	openPort: [...#PortForward] | *[]
-	openPort: list.FlattenN([for i in _op {i}], -1)
+	openPort: list.FlattenN([for i in _op {i}], 1)
 	for _, out in outs {
 		images:       out.images
 		charts:       out.charts
@@ -1020,7 +1082,9 @@
 	}
 	openPortMap: {
 		for k, v in postgresql {
-			"postgresql-\(k)": v.openPortMap
+			for x, y in v.openPortMap {
+				"postgresql-\(k)-\(x)": y
+			}
 		}
 		...
 	}
@@ -1066,6 +1130,14 @@
 			}
 		}
 	}
+	openPortMap: {
+		for k, v in vm {
+			for x, y in v.openPortMap {
+				"vm-\(k)-\(x)": y
+			}
+		}
+		...
+	}
 	images: {
 		for k, v in vm {
 			for x, y in v.images {
@@ -1096,6 +1168,8 @@
 
 _codeServerPortName: "code-server"
 _codeServerPort:     9090
+_sshPortName:        "ssh"
+_sshPort:            22
 
 resources: {...}
 
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index 08049c4..a02ac1e 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -68,12 +68,13 @@
 		if svc.dev.enabled {
 			username:   string | *svc.dev.username
 			vpnAuthKey: string @role(VPNAuthKey) @usernameField(username)
+			if svc.dev.ssh != null {
+				"port_service_\(svc.name)_ssh": int @role(port)
+			}
 		}
 	}
 }
 
-_devVM: {}
-
 volume: [...#VolumeInput] | *[]
 postgresql: [...#PostgreSQLInput] | *[]
 mongodb: [...#MongoDBInput] | *[]
@@ -116,8 +117,11 @@
 }
 
 #DevEnabled: {
-	enabled:  true
-	username: string
+	enabled:    true
+	username:   string
+	vpn:        bool | *false
+	codeServer: #Domain | null | *null
+	ssh:        #Domain | null | *null
 }
 
 #Dev: #DevEnabled | #DevDisabled
@@ -141,8 +145,8 @@
 	rootDir: string
 	runConfiguration: [...#Command]
 	volume: [...string] | *[]
-	dev: #Dev | *{enabled: false}
-	vm: #VMCustomization
+	dev: #Dev
+	vm:  #VMCustomization
 	// TODO(gio): check for duplicate values
 	apiPort: #PortValue | *3000
 	ports: [...#Port]
@@ -653,18 +657,6 @@
 						}
 					}
 				}
-				// TODO(gio): code should work even without svc ingress
-				"\(svc.name)-\(i)-code": {
-					label:     "VS Code"
-					home:      "/?folder=/home/\(svc.dev.username)/code"
-					network:   networks[strings.ToLower(ingress.network)]
-					subdomain: "code-\(ingress.subdomain)"
-					auth: enabled: false
-					service: {
-						name: svc.name
-						port: _codeServerPort
-					}
-				}
 			}}
 		}
 	}
@@ -672,15 +664,36 @@
 		"\(svc.name)": {
 			username: svc.dev.username
 			domain:   global.domain
-			vpn: {
-				enabled:     true
-				loginServer: "https://headscale.\(global.domain)"
-				authKey:     input.vpnAuthKey
+			if svc.dev.vpn {
+				// TODO(gio): make VPN network configurable
+				vpn: {
+					enabled:     true
+					loginServer: "https://headscale.\(global.domain)"
+					authKey:     input.vpnAuthKey
+				}
 			}
-			codeServerEnabled: true
-			cpuCores:          2
-			memory:            "3Gi"
-			ports:             svc.ports
+			if svc.dev.codeServer != null {
+				codeServer: {
+					enabled: true
+					expose: {
+						network:   networks[strings.ToLower(svc.dev.codeServer.network)]
+						subdomain: svc.dev.codeServer.subdomain
+					}
+				}
+			}
+			if svc.dev.ssh != null {
+				ssh: {
+					enabled: true
+					expose: {
+						network:         networks[strings.ToLower(svc.dev.ssh.network)]
+						subdomain:       svc.dev.ssh.subdomain
+						input_port_name: "port_service_\(svc.name)_ssh"
+					}
+				}
+			}
+			cpuCores: 2
+			memory:   "3Gi"
+			ports:    svc.ports
 			configFiles: {
 				"env.sh": _envProfile
 			}
@@ -723,7 +736,9 @@
 	}
 	openPortMap: {
 		for k, v in services {
-			"service-\(k)": v.openPortMap
+			for x, y in v.openPortMap {
+				"service-\(k)-\(x)": y
+			}
 		}
 		...
 	}
@@ -775,7 +790,7 @@
 		for v in _service {
 			"\(v.name)": #Service & {
 				name: v.name
-				svc: v
+				svc:  v
 			}
 		}
 	}
diff --git a/core/installer/dodo_app_test.go b/core/installer/dodo_app_test.go
index 3501620..579d3b2 100644
--- a/core/installer/dodo_app_test.go
+++ b/core/installer/dodo_app_test.go
@@ -42,7 +42,8 @@
 		network: "private"
 		subdomain: "3"
         port: value: 101
-    }]
+    }],
+    dev: enabled: false
 }]
 
 postgresql: [{
@@ -343,25 +344,34 @@
 
 const exposeSVCRemoteCluster = `
 {
-    "cluster": "remote",
-    "service": [{
-		"name": "echo",
-		"type": "golang:1.20.0",
-		"source": {
-			"repository": "ssh://foo.bar"
-		},
-		"ports": [{
-			"name": "echo",
-			"value": 9090
-		}],
-		"expose": [{
-			"port": {
-				"name": "echo"
-			},
-			"network": "Private",
-			"subdomain": "echo"
-		}]
-	}]
+  "cluster": "remote",
+  "service": [
+    {
+      "name": "echo",
+      "type": "golang:1.20.0",
+      "source": {
+        "repository": "ssh://foo.bar"
+      },
+      "ports": [
+        {
+          "name": "echo",
+          "value": 9090
+        }
+      ],
+      "expose": [
+        {
+          "port": {
+            "name": "echo"
+          },
+          "network": "Private",
+          "subdomain": "echo"
+        }
+      ],
+      "dev": {
+        "enabled": false
+      }
+    }
+  ]
 }
 `
 
@@ -409,3 +419,109 @@
 	t.Log(string(r.Raw))
 	t.Log(fmt.Sprintf("%+v", r.Ports))
 }
+
+const sshCodeServer = `
+{
+  "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
+          }
+        },
+        {
+          "network": "Public",
+          "subdomain": "foo",
+          "port": {
+            "name": "web"
+          },
+          "auth": {
+            "enabled": false
+          }
+        }
+      ],
+      "expose": [{
+		"network": "Public",
+		"subdomain": "foo",
+		"port": {
+		  "name": "web"
+		},
+      }],
+      "dev": {
+        "enabled": true,
+        "username": "gio",
+        "codeServer": {
+          "network": "Private",
+          "subdomain": "code"
+        },
+        "ssh": {
+          "network": "Public",
+          "subdomain": "ssh"
+        }
+      }
+    }
+  ],
+  "volume": [],
+  "postgresql": [
+    {
+      "name": "db",
+      "size": "1Gi",
+      "expose": []
+    }
+  ],
+  "mongodb": []
+}
+`
+
+func TestSSHCodeServer(t *testing.T) {
+	app, err := NewDodoApp([]byte(sshCodeServer))
+	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, nil, map[string]any{
+		"managerAddr":          "",
+		"appId":                "",
+		"sshPrivateKey":        "",
+		"port_service_app_ssh": 12,
+		"port_service_app_0":   13,
+	}, nil, keyGen)
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(string(r.Raw))
+}
diff --git a/core/installer/values-tmpl/virtual-machine.cue b/core/installer/values-tmpl/virtual-machine.cue
index 63cacf0..7416e28 100644
--- a/core/installer/values-tmpl/virtual-machine.cue
+++ b/core/installer/values-tmpl/virtual-machine.cue
@@ -35,7 +35,9 @@
 				}
 			}
 			if input.codeServerEnabled != _|_ {
-				codeServerEnabled: input.codeServerEnabled
+				codeServer: {
+					enabled: input.codeServerEnabled
+				}
 			}
 		}
 	}