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/apps/canvas/config/src/config.test.ts b/apps/canvas/config/src/config.test.ts
index 5a927df..e8c0c38 100644
--- a/apps/canvas/config/src/config.test.ts
+++ b/apps/canvas/config/src/config.test.ts
@@ -17,6 +17,7 @@
 						rootDir: "/",
 					},
 					ports: [{ name: "http", value: 3000, protocol: "TCP" }],
+					dev: { enabled: false },
 				},
 			],
 		};
diff --git a/apps/canvas/config/src/config.ts b/apps/canvas/config/src/config.ts
index d91b535..f5957d4 100644
--- a/apps/canvas/config/src/config.ts
+++ b/apps/canvas/config/src/config.ts
@@ -114,6 +114,7 @@
 					dev: n.data.dev?.enabled
 						? {
 								enabled: true,
+								mode: "VM",
 								username: env.user.username,
 								codeServer:
 									n.data.dev.expose != null
diff --git a/apps/canvas/config/src/types.ts b/apps/canvas/config/src/types.ts
index bbf851b..a49c2d5 100644
--- a/apps/canvas/config/src/types.ts
+++ b/apps/canvas/config/src/types.ts
@@ -98,14 +98,16 @@
 	expose: z.array(PortDomainSchema).optional(),
 	volume: z.array(z.string()).optional(),
 	preBuildCommands: z.array(z.object({ bin: z.string() })).optional(),
-	dev: z
-		.object({
-			enabled: z.boolean(),
+	dev: z.discriminatedUnion("enabled", [
+		z.object({ enabled: z.literal(false) }),
+		z.object({
+			enabled: z.literal(true),
+			mode: z.string(),
 			username: z.string().optional(),
 			ssh: DomainSchema.optional(),
 			codeServer: DomainSchema.optional(),
-		})
-		.optional(),
+		}),
+	]),
 	model: ModelSchema.optional(),
 });
 
diff --git a/charts/proxy/.helmignore b/charts/proxy/.helmignore
new file mode 100644
index 0000000..0e8a0eb
--- /dev/null
+++ b/charts/proxy/.helmignore
@@ -0,0 +1,23 @@
+# Patterns to ignore when building packages.
+# This supports shell glob matching, relative path matching, and
+# negation (prefixed with !). Only one pattern per line.
+.DS_Store
+# Common VCS dirs
+.git/
+.gitignore
+.bzr/
+.bzrignore
+.hg/
+.hgignore
+.svn/
+# Common backup files
+*.swp
+*.bak
+*.tmp
+*.orig
+*~
+# Various IDEs
+.project
+.idea/
+*.tmproj
+.vscode/
diff --git a/charts/proxy/Chart.yaml b/charts/proxy/Chart.yaml
new file mode 100644
index 0000000..05aea82
--- /dev/null
+++ b/charts/proxy/Chart.yaml
@@ -0,0 +1,6 @@
+apiVersion: v2
+name: proxy
+description: A Helm chart for proxy
+type: application
+version: 0.0.1
+appVersion: "0.0.1"
diff --git a/charts/proxy/templates/install.yaml b/charts/proxy/templates/install.yaml
new file mode 100644
index 0000000..849cd75
--- /dev/null
+++ b/charts/proxy/templates/install.yaml
@@ -0,0 +1,127 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: {{ .Values.name }}
+  namespace: {{ .Release.Namespace }}
+data:
+  nginx.conf: |
+    {{ .Values.config | nindent 4 }}
+---
+{{- if .Values.vpn.enabled }}
+apiVersion: v1
+kind: Secret
+metadata:
+  name: {{ .Values.name }}-vpn-pre-auth-key
+  namespace: {{ .Release.Namespace }}
+stringData:
+  authkey: {{ .Values.vpn.preAuthKey }}
+{{- end }}
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: {{ .Values.name }}
+  namespace: {{ .Release.Namespace }}
+spec:
+  selector:
+    matchLabels:
+      app: proxy
+  replicas: 1
+  template:
+    metadata:
+      labels:
+        app: proxy
+    spec:
+      serviceAccountName: {{ .Values.name }}-proxy
+      volumes:
+      - name: config
+        configMap:
+          name: {{ .Values.name }}
+      containers:
+      - name: proxy
+        image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
+        imagePullPolicy: {{ .Values.image.pullPolicy }}
+        ports:
+          {{- range .Values.ports }}
+          - name: {{ .name }}
+            containerPort: {{ .value }}
+            protocol: {{ .protocol }}
+          {{- end }}
+        volumeMounts:
+        - name: config
+          mountPath: /etc/nginx
+      {{- if .Values.vpn.enabled }}
+      - name: tailscale
+        image: {{ .Values.vpn.image.repository }}:{{ .Values.vpn.image.tag }}
+        imagePullPolicy: {{ .Values.vpn.image.pullPolicy }}
+        securityContext:
+          privileged: true
+          capabilities:
+            add:
+            - NET_ADMIN
+        env:
+        - name: TS_KUBE_SECRET
+          value: {{ .Values.name }}-vpn-pre-auth-key
+        - name: TS_HOSTNAME
+          value: {{ .Values.vpn.hostname }}
+        - name: TS_USERSPACE
+          value: "false"
+        - name: TS_ACCEPT_DNS
+          value: "true"
+        - name: TS_EXTRA_ARGS
+          value: --login-server={{ .Values.vpn.loginServer }}
+      {{- end }}
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ .Values.name }}
+  namespace: {{ .Release.Namespace }}
+spec:
+  type: ClusterIP
+  selector:
+    app: proxy
+  ports:
+    {{- range .Values.ports }}
+    - name: {{ .name }}
+      port: {{ .value }}
+      targetPort: {{ .name }}
+      protocol: {{ .protocol }}
+    {{- end }}
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: {{ .Values.name }}-proxy
+  namespace: {{ .Release.Namespace }}
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  name: {{ .Values.name }}-proxy
+  namespace: {{ .Release.Namespace }}
+rules:
+- apiGroups: [""] # "" indicates the core API group
+  resources: ["secrets"]
+  # Create can not be restricted to a resource name.
+  verbs: ["create"]
+- apiGroups: [""] # "" indicates the core API group
+  resourceNames: ["{{ .Values.name }}-vpn-pre-auth-key"]
+  resources: ["secrets"]
+  verbs: ["get", "update", "patch"]
+- apiGroups: [""] # "" indicates the core API group
+  resources: ["events"]
+  verbs: ["get", "create", "patch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  name: {{ .Values.name }}-proxy
+  namespace: {{ .Release.Namespace }}
+subjects:
+- kind: ServiceAccount
+  name: {{ .Values.name }}-proxy
+roleRef:
+  kind: Role
+  name: {{ .Values.name }}-proxy
+  apiGroup: rbac.authorization.k8s.io
diff --git a/charts/proxy/values.yaml b/charts/proxy/values.yaml
new file mode 100644
index 0000000..a701c3e
--- /dev/null
+++ b/charts/proxy/values.yaml
@@ -0,0 +1,22 @@
+name: proxy
+image:
+  repository: library/nginx
+  tag: 1.27.1-alpine3.20-slim
+  pullPolicy: IfNotPresent
+config: "nginx configuration"
+ports:
+- name: web
+  value: 8080
+  protocol: TCP
+- name: API
+  value: 9090
+  protocol: UDP
+vpn:
+  enabled: false
+  image:
+    repository: tailscale/tailscale
+    tag: v1.42.0
+    pullPolicy: IfNotPresent
+  preAuthKey: ""
+  loginServer: ""
+  hostname: ""
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 {