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 {