DodoApp: Implement dev proxy mode

With dev proxy user can substitute any service with their own local
machine. In which case dodo will run proxy server on the platform
which will forward all requests to the configured address.

When VPN is enabled, dodo will run tailscale sidecar in the proxy pod.

Change-Id: I12592ae77d2e88e0582c8fe1e0f82e5fd24e02cb
diff --git a/core/headscale/Dockerfile b/core/headscale/Dockerfile
index d53b665..c93ca62 100644
--- a/core/headscale/Dockerfile
+++ b/core/headscale/Dockerfile
@@ -2,4 +2,4 @@
 
 ARG TARGETARCH
 
-COPY server_${TARGETARCH} /usr/bin/headscale-api
+COPY server_${TARGETARCH} /usr/bin/eadscale-api
diff --git a/core/headscale/main.go b/core/headscale/main.go
index d4a129f..8a3a9ec 100644
--- a/core/headscale/main.go
+++ b/core/headscale/main.go
@@ -39,6 +39,11 @@
     },
   },
   "acls": [
+    {
+      "action": "accept",
+      "src": ["*"],
+      "dst": ["*:*"]
+    },
     // {
     //   "action": "accept",
     //   "src": ["10.42.0.0/16", "10.43.0.0/16", "135.181.48.180/32", "65.108.39.172/32"],
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index 0ce8baa..794f0fd 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -74,10 +74,18 @@
 
 	for svc in service {
 		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)
+			if svc.dev.mode == "VM" {
+				username:   string | *svc.dev.username
+				vpnAuthKey: string @role(VPNAuthKey) @usernameField(username)
+				if svc.dev.ssh != null {
+					"port_service_\(svc.name)_ssh": int @role(port)
+				}
+			}
+			if svc.dev.mode == "PROXY" {
+				if svc.dev.vpn.enabled == true {
+					username:   svc.dev.vpn.username
+					vpnAuthKey: string @role(VPNAuthKey) @usernameField(username)
+				}
 			}
 		}
 	}
@@ -126,14 +134,33 @@
 	enabled: false
 }
 
-#DevEnabled: {
+#DevEnabledVM: {
 	enabled:    true
+	mode:       "VM"
 	username:   string
 	vpn:        bool | *false
 	codeServer: #Domain | null | *null
 	ssh:        #Domain | null | *null
 }
 
+#PortMapping: {
+	src: int
+	dst: int
+}
+
+#DevEnabledProxy: {
+	enabled: true
+	mode:    "PROXY"
+	address: string
+	ports: [...#PortMapping]
+	vpn: {enabled: false} | {
+		enabled:  true
+		username: string
+	} | *{enabled: false}
+}
+
+#DevEnabled: *#DevEnabledVM | #DevEnabledProxy
+
 #Dev: #DevEnabled | #DevDisabled
 
 #VMCustomization: {
@@ -761,7 +788,7 @@
 
 // TODO(gio): Support remote clusters.
 // TODO(gio): pass all svc ports and not only ssh and code-server
-#ServiceDevEnabled: #WithOut & {
+#ServiceDevEnabledVM: #WithOut & {
 	cluster?: #Cluster
 	_cc:      #Cluster | null | *null
 	if cluster != _|_ {
@@ -770,6 +797,7 @@
 	svc: #App & {
 		dev: {
 			enabled: true
+			mode:    "VM"
 		}
 		externalEnv: envVars
 	}
@@ -870,6 +898,121 @@
 	}
 }
 
+#ServiceDevEnabledProxy: #WithOut & {
+	cluster?: #Cluster
+	_cc:      #Cluster | null | *null
+	if cluster != _|_ {
+		_cc: cluster
+	}
+	svc: #App & {
+		dev: {
+			enabled: true
+			mode:    "PROXY"
+		}
+		externalEnv: envVars
+	}
+
+	_namedPorts: {
+		for p in svc.ports {
+			"\(p.name)": p.value
+		}
+	}
+
+	// TODO(gio): reuse envProfile from above
+	_envProfile: strings.Join(list.Concat([
+		svc.vm.env,
+		[for e in svc.lastCmdEnv {"export \(e)"}],
+	]), "\n")
+	ingress: {
+		if svc.ingress != _|_ {
+			{for i, ingress in svc.ingress {
+				"\(svc.name)-\(i)": {
+					label:     svc.name
+					network:   networks[strings.ToLower(ingress.network)]
+					subdomain: ingress.subdomain
+					auth:      ingress.auth
+					if svc.agentPort == service.port {
+						agentName: svc.name
+					}
+					service: {
+						name: svc.name
+						if ingress.port.value != _|_ {
+							port: ingress.port.value
+						}
+						if ingress.port.name != _|_ {
+							port: _namedPorts[ingress.port.name]
+						}
+					}
+				}
+			}}
+		}
+	}
+	images: {
+		nginx: {
+			registry:   "docker.io"
+			repository: "library"
+			name:       "nginx"
+			tag:        "1.27.1-alpine3.20-slim"
+			pullPolicy: "IfNotPresent"
+		}
+		tailscale: {
+			repository: "tailscale"
+			name:       "tailscale"
+			tag:        "v1.82.0"
+			pullPolicy: "IfNotPresent"
+		}
+	}
+
+	charts: {
+		nginx: {
+			kind:    "GitRepository"
+			address: "https://code.v1.dodo.cloud/helm-charts"
+			branch:  "main"
+			path:    "charts/proxy"
+		}
+	}
+
+	_nginxCfg: #NginxProxyConfig & {
+		address: svc.dev.address
+		ports:   svc.dev.ports
+	}
+
+	helm: {
+		proxy: {
+			chart: charts.nginx
+			annotations: {
+				"dodo.cloud/resource-type":         "service"
+				"dodo.cloud/resource.service.name": svc.name
+			}
+			values: {
+				image: {
+					repository: images.nginx.fullName
+					tag:        images.nginx.tag
+					pullPolicy: images.nginx.pullPolicy
+				}
+				name:   svc.name
+				config: _nginxCfg.cfg
+				ports:  svc.ports
+				if svc.dev.vpn.enabled {
+					vpn: {
+						enabled: true
+						image: {
+							repository: images.tailscale.fullName
+							tag:        images.tailscale.tag
+							pullPolicy: images.tailscale.pullPolicy
+						}
+						preAuthKey:  input.vpnAuthKey
+						loginServer: "https://headscale.\(global.domain)"
+						hostname:    "proxy-\(release.appInstanceId)-\(svc.name)"
+					}
+				}
+			}
+		}
+	}
+}
+
+#ServiceDevEnabled: #ServiceDevEnabledVM | #ServiceDevEnabledProxy
+
 #Service: #ServiceDevEnabled | #ServiceDevDisabled
 #Service: {
 	svc: type: string
@@ -998,3 +1141,37 @@
 }
 
 _appDir: "/dodo-app"
+
+#NginxProxyConfig: {
+	address: string
+	ports: [...#PortMapping]
+
+	_upstreams: [for p in ports {
+		"""
+		upstream backend_\(p.dst) {
+			server \(address):\(p.dst);
+		}
+		"""
+	}]
+
+	_servers: [for p in ports {
+		"""
+		server {
+			listen \(p.src);
+			proxy_pass backend_\(p.dst);
+		}
+		"""
+	}]
+
+	cfg: """
+	worker_processes  1;
+	worker_rlimit_nofile 8192;
+	events {
+		worker_connections  1024;
+	}
+	stream {
+		\(strings.Join(_upstreams, "\n"))
+		\(strings.Join(_servers, "\n"))
+	}
+	"""
+}
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 6d982a8..f8b0758 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -492,6 +492,7 @@
 	volume: ["data"]
 	dev: {
 		enabled: true
+		mode: "VM"
 		username: "gio"
 	}
     source: repository: "ssh://foo.bar"
@@ -559,10 +560,10 @@
 	}
 	keyGen := testKeyGen{}
 	r, err := app.Render(release, env, networks, nil, map[string]any{
-		"managerAddr":   "",
-		"appId":         "",
-		"sshPrivateKey": "",
-		"username":      "",
+		"managerAddr":         "",
+		"appId":               "",
+		"sshPrivateKey":       "",
+		"username":            "",
 		"password_mongodb_db": "foo",
 	}, nil, keyGen)
 	if err != nil {
diff --git a/core/installer/dodo_app_test.go b/core/installer/dodo_app_test.go
index a733c11..73f7025 100644
--- a/core/installer/dodo_app_test.go
+++ b/core/installer/dodo_app_test.go
@@ -149,59 +149,66 @@
 
 const canvas = `
 {
-    "service": [
+  "service": [
+    {
+      "type": "nextjs:deno-2.0.0",
+      "name": "app",
+      "source": {
+        "repository": "ssh://d.p.v1.dodo.cloud:62533/myblog",
+        "branch": "master",
+        "rootDir": "/"
+      },
+      "ports": [
         {
-            "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": [],
-            "dev": { "enabled": true, "username": "gio" }
+          "name": "web",
+          "value": 3000,
+          "protocol": "TCP"
         }
-    ],
-    "volume": [],
-    "postgresql": [
+      ],
+      "env": [
         {
-            "name": "db",
-            "size": "1Gi",
-            "expose": []
+          "name": "DODO_POSTGRESQL_DB_CONNECTION_URL"
         }
-    ],
-    "mongodb": []
+      ],
+      "ingress": [
+        {
+          "network": "Private",
+          "subdomain": "foo",
+          "port": {
+            "name": "web"
+          },
+          "auth": {
+            "enabled": false
+          }
+        },
+        {
+          "network": "Public",
+          "subdomain": "foo",
+          "port": {
+            "name": "web"
+          },
+          "auth": {
+            "enabled": false
+          }
+        }
+      ],
+      "expose": [],
+      "dev": {
+        "enabled": true,
+        "mode": "VM",
+        "username": "gio"
+      }
+    }
+  ],
+  "volume": [],
+  "postgresql": [
+    {
+      "name": "db",
+      "size": "1Gi",
+      "expose": []
+    }
+  ],
+  "mongodb": []
 }
 `
 
@@ -481,6 +488,7 @@
       }],
       "dev": {
         "enabled": true,
+        "mode": "VM",
         "username": "gio",
         "codeServer": {
           "network": "Private",
@@ -535,78 +543,67 @@
 
 const foo = `
 {
-  "service": [
-    {
-      "type": "deno:2.2.0",
-      "name": "qwe",
-      "source": {
-        "repository": "git@github.com:giolekva/dodo-blog.git"
-      },
-      "ports": [
-        {
-          "name": "web",
-          "value": 8080,
-          "protocol": "TCP"
-        }
-      ],
-      "env": [
-        {
-          "name": "DODO_POSTGRESQL_DB_URL"
-        },
-        {
-          "name": "DODO_PORT_WEB"
-        }
-      ],
-      "ingress": [
-        {
-          "network": "Private",
-          "subdomain": "blog",
-          "port": {
-            "name": "web"
-          },
-          "auth": {
-            "enabled": false
-          }
-        }
-      ],
-      "expose": [
-        {
-          "network": "Private",
-          "subdomain": "blog",
-          "port": {
-            "name": "web"
-          }
-        }
-      ],
-      "preBuildCommands": [],
-      "dev": {
-        "enabled": true,
-        "username": "gio",
-        "codeServer": {
-          "network": "Private",
-          "subdomain": "code"
-        },
-        "ssh": {
-          "network": "Public",
-          "subdomain": "code"
-        }
-      }
-    }
-  ],
-  "volume": [],
-  "postgresql": [
-    {
-      "name": "db",
-      "size": "1Gi",
-      "expose": [
-        {
-          "network": "Private",
-          "subdomain": "pg"
-        }
-      ]
-    }
-  ],
-  "mongodb": []
+	"service": [
+		{
+			"type": "deno:2.2.0",
+			"name": "qwe",
+			"source": {
+				"repository": "git@github.com:giolekva/dodo-blog.git"
+			},
+			"ports": [
+				{
+					"name": "web",
+					"value": 8081,
+					"protocol": "TCP"
+				}
+			],
+			"env": [
+				{
+					"name": "DODO_POSTGRESQL_DB_URL"
+				},
+				{
+					"name": "DODO_PORT_WEB"
+				}
+			],
+			"ingress": [
+				{
+					"network": "Private",
+					"subdomain": "blog",
+					"port": {
+						"name": "web"
+					},
+					"auth": {
+						"enabled": false
+					}
+				}
+			],
+			"expose": [
+				{
+					"network": "Private",
+					"subdomain": "blog",
+					"port": {
+						"name": "web"
+					}
+				}
+			],
+			"preBuildCommands": [],
+			"dev": {
+				"enabled": true,
+				"mode": "PROXY",
+				"vpn": { "enabled": true, "username": "gio" },
+				"address": "65.108.39.172",
+				"ports": [
+					{
+						"src": 8081,
+						"dst": 80
+					}
+				]
+			}
+		}
+	],
+	"volume": [],
+	"postgresql": [],
+	"mongodb": []
 }
 `
 
@@ -636,11 +633,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	access, err := json.Marshal(r.Access)
-	if err != nil {
-		t.Fatal(err)
-	}
-	t.Log(string(access))
+	t.Log(string(r.Raw))
 }
 
 const sketch = `
diff --git a/core/installer/samples/canvas.rest b/core/installer/samples/canvas.rest
index 42bc95d..6cbbd8d 100644
--- a/core/installer/samples/canvas.rest
+++ b/core/installer/samples/canvas.rest
@@ -2,23 +2,70 @@
 Content-Type: application/json
 
 {
-  "config": {
-    "input": {
-	  "sketch_dev_gemini_api_key": "AIzaSyAx_vF0HJyT55A09iXtjPhf2JocNOGaWCo"
-	},
-    "agent": [
-      {
-        "name": "dev",
-        "ingress": [
-          {
-            "network": "private",
-            "port": {
-              "value": 2001
-            },
-            "subdomain": "sketch"
-          }
-        ]
-      }
-    ]
-  }
-}
+    "config": {
+        "service": [
+            {
+                "type": "deno:2.2.0",
+                "name": "qwe",
+                "source": {
+                    "repository": "git@github.com:giolekva/dodo-blog.git"
+                },
+                "ports": [
+                    {
+                        "name": "web",
+                        "value": 8081,
+                        "protocol": "TCP"
+                    }
+                ],
+                "env": [
+                    {
+                        "name": "DODO_POSTGRESQL_DB_URL"
+                    },
+                    {
+                        "name": "DODO_PORT_WEB"
+                    }
+                ],
+                "ingress": [
+                    {
+                        "network": "Private",
+                        "subdomain": "blog",
+                        "port": {
+                            "name": "web"
+                        },
+                        "auth": {
+                            "enabled": false
+                        }
+                    }
+                ],
+                "expose": [
+                    {
+                        "network": "Private",
+                        "subdomain": "blog",
+                        "port": {
+                            "name": "web"
+                        }
+                    }
+                ],
+                "preBuildCommands": [],
+                "dev": {
+                    "enabled": true,
+                    "mode": "PROXY",
+                    "vpn": {
+                      "enabled": true,
+                      "username": "gio"
+                    },
+                    "address": "gl-mbp-m1-max-2-vi8huz7s",
+                    "ports": [
+                        {
+                            "src": 8081,
+                            "dst": 8080
+                        }
+                    ]
+                }
+            }
+        ],
+        "volume": [],
+        "postgresql": [],
+        "mongodb": []
+    }
+}
\ No newline at end of file
diff --git a/core/installer/server/appmanager/server.go b/core/installer/server/appmanager/server.go
index 3b5d5b5..c3bd236 100644
--- a/core/installer/server/appmanager/server.go
+++ b/core/installer/server/appmanager/server.go
@@ -187,6 +187,10 @@
 func (s *Server) handleDodoAppInstall(w http.ResponseWriter, r *http.Request) {
 	s.l.Lock()
 	defer s.l.Unlock()
+	fmt.Println("INSTALLING DODO APP")
+	defer func() {
+		fmt.Println("DONE")
+	}()
 	var req dodoAppInstallReq
 	// TODO(gio): validate that no internal fields are overridden by request
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {