DodoApp: VMs load dodo specific env vars from the dodo app manager

Change-Id: I522619a3ba6cd6c78eb4fe1dd8c91ec490759fdf
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index f058c96..ac2d159 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -106,6 +106,7 @@
 	content: string
 	owner: string
 	permissions: string
+	defer: bool | *true
 }
 
 #CloudInit: {
@@ -234,7 +235,7 @@
 					content: """
 					[user]
 						name = \(username)
-						email = \(username)@.\(domain)
+						email = \(username)@\(domain)
 
 					"""
 					owner: "\(username):\(username)"
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index 84f236b..2456838 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -35,24 +35,29 @@
 		cpuCores: 1
 		memory: "1Gi"
 		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 \(username):\(username) /home/\(username)/.gitconfig"],
+				["sh", "-c", "chown -R \(username):\(username) /home/\(username)"],
 	        ], app.vm.cloudInit.runCmd])
 		}
 	}
 }
 
-_vmName: "\(input.appId)-\(input.branch)"
-
-out: {
-	vm: {
-		"\(_vmName)": _devVM
-	}
-}
-
 #AppIngress: {
 	network: string
 	subdomain: string
@@ -62,31 +67,6 @@
 	baseURL: "https://\(subdomain).\(_network.domain)"
 }
 
-#Volumes: {
-	...
-}
-
-#PostgreSQLs: {
-	...
-}
-
-app: {
-	volumes: {
-		for key, value in volumes {
-			"\(key)": #volume & value & {
-				name: key
-			}
-		}
-	}
-	postgresql: {
-		for key, value in postgresql {
-			"\(key)": #PostgreSQL & value & {
-				name: key
-			}
-		}
-	}
-}
-
 #Command: {
 	bin: string
 	args: [...string] | *[]
@@ -106,20 +86,59 @@
 
 #VMCustomization: {
 	cloudInit: #CloudInit
+	env: [...string] | *[]
 }
 
 #AppTmpl: {
 	type: string
 	ingress: #AppIngress
-	volumes: #Volumes
-	postgresql: #PostgreSQLs
+	volumes: {
+		for k, v in volumes {
+			"\(k)": #volume & v & {
+				name: k
+			}
+		}
+		...
+	}
+	postgresql: {
+		for k, v in postgresql {
+			"\(k)": #PostgreSQL & v & {
+				name: k
+			}
+		}
+		...
+	}
 	rootDir: string
 	runConfiguration: [...#Command]
 	dev: #Dev | *{ enabled: false }
 	vm: #VMCustomization
+
+	lastCmdEnv: [
+		for k, v in volumes {
+			"DODO_VOLUME_\(strings.ToUpper(k))=/dodo-volume/\(v.name)"
+		}
+		for k, v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(k))_ADDRESS=postgres-\(v.name).\(release.namespace).svc.cluster.local"
+		}
+		for k, v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(k))_USERNAME=postgres"
+		}
+		for k, v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(k))_PASSWORD=postgres"
+		}
+		for k, v in postgresql {
+			"DODO_POSTGRESQL_\(strings.ToUpper(k))_DATABASE=postgres"
+		}
+    ]
+
 	...
 }
 
+envProfile: strings.Join(list.Concat([
+	app.vm.env,
+	[for e in app.lastCmdEnv { "export \(e)" }]
+]), "\n")
+
 // Go app
 
 _goVer1220: "golang:1.22.0"
@@ -128,12 +147,13 @@
 #GoAppTmpl: #AppTmpl & {
 	type: _goVer1220 | _goVer1200
 	run: string | *"main.go"
-	ingress: #AppIngress
-	volumes: #Volumes
-	postgresql: #PostgreSQLs
 	port: int | *8080
 	rootDir: _appDir
 
+	volumes: {...}
+	postgresql: {...}
+	lastCmdEnv: [...string]
+
 	runConfiguration: [{
 		bin: "/usr/local/go/bin/go",
 		args: ["mod", "tidy"]
@@ -142,34 +162,22 @@
 		args: ["build", "-o", ".app", run]
 	}, {
 		bin: ".app",
-		env: [
-			for k, v in volumes {
-				"DODO_VOLUME_\(strings.ToUpper(k))=/dodo-volume/\(v.name)"
-			}
-			for k, v in postgresql {
-				"DODO_POSTGRESQL_\(strings.ToUpper(k))_ADDRESS=postgres-\(v.name).\(release.namespace).svc.cluster.local"
-			}
-			for k, v in postgresql {
-				"DODO_POSTGRESQL_\(strings.ToUpper(k))_USERNAME=postgres"
-			}
-			for k, v in postgresql {
-				"DODO_POSTGRESQL_\(strings.ToUpper(k))_PASSWORD=postgres"
-			}
-			for k, v in postgresql {
-				"DODO_POSTGRESQL_\(strings.ToUpper(k))_DATABASE=postgres"
-			}
-	    ]
+		env: lastCmdEnv
 	}]
 }
 
 #GoApp1200: #GoAppTmpl & {
 	type: _goVer1200
-	vm: cloudInit: runCmd: [
-		["sh", "-c", "wget https://go.dev/dl/go1.20.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", "echo \"export PATH=$PATH:/usr/local/go/bin\\n\" > /etc/environment"],
-		["sh", "-c", "rm /tmp/go.tar.gz"],
-    ]
+	vm: {
+		env: [
+			"export PATH=$PATH:/usr/local/go/bin"
+	    ]
+		cloudInit: runCmd: [
+			["sh", "-c", "wget https://go.dev/dl/go1.20.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"],
+        ]
+	}
 }
 
 #GoApp1220: #GoAppTmpl & {
@@ -185,11 +193,13 @@
 #HugoAppTmpl: #AppTmpl & {
 	type: _hugoLatest
 	ingress: #AppIngress
-	volumes: {}
-	postgresql: {}
 	port: int | *8080
 	rootDir: _appDir
 
+	volumes: {...}
+	postgresql: {...}
+	lastCmdEnv: [...string]
+
 	runConfiguration: [{
 		bin: "/usr/bin/hugo",
 	}, {
@@ -201,7 +211,8 @@
 			"--port=\(port)",
 			"--baseURL=\(ingress.baseURL)",
 			"--appendPort=false",
-    	]
+        ]
+		env: lastCmdEnv
 	}]
 }
 
@@ -211,19 +222,16 @@
 
 #PHPAppTmpl: #AppTmpl & {
 	type: "php:8.2-apache"
-	ingress: #AppIngress
-	volumes: {}
-	postgresql: {}
 	port: int | *80
 	rootDir: "/var/www/html"
 
+	volumes: {...}
+	postgresql: {...}
+	lastCmdEnv: [...string]
+
 	runConfiguration: [{
 		bin: "/usr/local/bin/apache2-foreground",
-		env: [
-			for k, v in volumes {
-				"DODO_VOLUME_\(strings.ToUpper(k))=/dodo-volume/\(v.name)"
-			}
-	    ]
+		env: lastCmdEnv
 	}]
 }
 
@@ -232,67 +240,66 @@
 #App: #GoApp | #HugoApp | #PHPApp
 
 app: #App
-
 _app: app
 
 if !_app.dev.enabled {
 	{
-	out: {
-		ingress: {
-			app: {
-				network: networks[strings.ToLower(_app.ingress.network)]
-				subdomain: _app.ingress.subdomain
-				auth: _app.ingress.auth
-				service: {
-					name: "app-app"
-					port: name: "app"
+		out: {
+			ingress: {
+				app: {
+					network: networks[strings.ToLower(_app.ingress.network)]
+					subdomain: _app.ingress.subdomain
+					auth: _app.ingress.auth
+					service: {
+						name: "app-app"
+						port: name: "app"
+					}
 				}
 			}
-		}
-		images: {
-			app: {
-				repository: "giolekva"
-				name: "app-runner"
-				tag: strings.Replace(_app.type, ":", "-", -1)
-				pullPolicy: "Always"
+			images: {
+				app: {
+					repository: "giolekva"
+					name: "app-runner"
+					tag: strings.Replace(_app.type, ":", "-", -1)
+					pullPolicy: "Always"
+				}
 			}
-		}
-		charts: {
-			app: {
-				kind: "GitRepository"
-				address: "https://code.v1.dodo.cloud/helm-charts"
-				branch: "main"
-				path: "charts/app-runner"
+			charts: {
+				app: {
+					kind: "GitRepository"
+					address: "https://code.v1.dodo.cloud/helm-charts"
+					branch: "main"
+					path: "charts/app-runner"
+				}
 			}
-		}
-		helm: {
-			app: {
-				chart: charts.app
-				values: {
-					image: {
-						repository: images.app.fullName
-						tag: images.app.tag
-						pullPolicy: images.app.pullPolicy
-					}
-					runtimeClassName: "untrusted-external" // TODO(gio): make this part of the infra config
-					appPort: _app.port
-					appDir: _app.rootDir
-					appId: input.appId
-					repoAddr: input.repoAddr
-					sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
-					runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
-					managerAddr: input.managerAddr
-					volumes: [
-						for key, value in _app.volumes {
-							name: value.name
-							mountPath: "/dodo-volume/\(key)"
+			helm: {
+				app: {
+					chart: charts.app
+					values: {
+						image: {
+							repository: images.app.fullName
+							tag: images.app.tag
+							pullPolicy: images.app.pullPolicy
 						}
-                ]
+						runtimeClassName: "untrusted-external" // TODO(gio): make this part of the infra config
+						appPort: _app.port
+						appDir: _app.rootDir
+						appId: input.appId
+						repoAddr: input.repoAddr
+						sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+						runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
+						managerAddr: input.managerAddr
+						volumes: [
+							for key, value in _app.volumes {
+								name: value.name
+								mountPath: "/dodo-volume/\(key)"
+							}
+					    ]
+					}
 				}
 			}
 		}
 	}
-		}
 }
 
 if _app.dev.enabled {
@@ -322,4 +329,14 @@
 	}
 }
 
+_vmName: "\(input.appId)-\(input.branch)"
+
+out: {
+	volumes: app.volumes
+	postgresql: app.postgresql
+	vm: {
+		"\(_vmName)": _devVM
+	}
+}
+
 _appDir: "/dodo-app"
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 2987c80..18cf85c 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -353,7 +353,7 @@
 
 var dodoAppDevEnabledCue = `
 app: {
-	type: "golang:1.22.0"
+	type: "golang:1.20.0"
 	run: "main.go"
 	ingress: {
 		network: "private"
@@ -364,6 +364,12 @@
 		enabled: true
 		username: "gio"
 	}
+    volumes: {
+      data: size: "5Gi"
+    }
+    postgresql: {
+      db: size: "10Gi"
+    }
 }`
 
 func TestDodoAppDevDisabled(t *testing.T) {
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 9f60449..7f3d383 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -235,6 +235,7 @@
 		r.HandleFunc("/update", s.handleAPIUpdate)
 		r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
 		r.HandleFunc("/api/add-public-key", s.handleAPIAddPublicKey).Methods(http.MethodPost)
+		r.HandleFunc("/api/apps/{app-name}/branch/{branch}/env-profile", s.handleBranchEnvProfile).Methods(http.MethodGet)
 		if !s.external {
 			r.HandleFunc("/api/sync-users", s.handleAPISyncUsers).Methods(http.MethodGet)
 		}
@@ -528,6 +529,34 @@
 	}
 }
 
+type appEnv struct {
+	Profile string `json:"envProfile"`
+}
+
+func (s *DodoAppServer) handleBranchEnvProfile(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	branch, ok := vars["branch"]
+	if !ok || branch == "" {
+		branch = "master"
+	}
+	info, err := s.st.GetLastCommitInfo(appName, branch)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var e appEnv
+	if err := json.NewDecoder(bytes.NewReader(info.Resources.RenderedRaw)).Decode(&e); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	fmt.Fprintln(w, e.Profile)
+}
+
 type volume struct {
 	Name string
 	Size string
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 3f8428d..7efdeaf 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -32,6 +32,11 @@
 	Resources installer.ReleaseResources
 }
 
+type LastCommitInfo struct {
+	Hash      string
+	Resources installer.ReleaseResources
+}
+
 type Store interface {
 	CreateUser(username string, password []byte, network string) error
 	GetUserPassword(username string) ([]byte, error)
@@ -43,6 +48,7 @@
 	CreateCommit(name, branch, hash, message, status, error string, resources []byte) error
 	GetCommitHistory(name, branch string) ([]CommitMeta, error)
 	GetCommit(hash string) (Commit, error)
+	GetLastCommitInfo(name, branch string) (LastCommitInfo, error)
 	GetBranches(name string) ([]string, error)
 }
 
@@ -79,6 +85,12 @@
             error TEXT,
             resources JSONB
 		);
+		CREATE TABLE IF NOT EXISTS branches (
+			app_name TEXT,
+			branch TEXT,
+            hash TEXT,
+            resources JSONB
+		);
 	`)
 	return err
 
@@ -189,9 +201,38 @@
 }
 
 func (s *storeImpl) CreateCommit(name, branch, hash, message, status, error string, resources []byte) error {
+	tx, err := s.db.Begin()
+	if err != nil {
+		return err
+	}
 	query := `INSERT INTO commits (app_name, branch, hash, message, status, error, resources) VALUES (?, ?, ?, ?, ?, ?, ?)`
-	_, err := s.db.Exec(query, name, branch, hash, message, status, error, resources)
-	return err
+	_, err = tx.Exec(query, name, branch, hash, message, status, error, resources)
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+	branchQuery := `UPDATE branches SET hash = ?, resources = ? WHERE app_name = ? AND branch = ?`
+	r, err := tx.Exec(branchQuery, hash, resources, name, branch)
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+	if cnt, err := r.RowsAffected(); err != nil {
+		tx.Rollback()
+		return err
+	} else if cnt == 0 {
+		branchQuery := `INSERT INTO branches (app_name, branch, hash, resources) VALUES (?, ?, ?, ?)`
+		_, err := tx.Exec(branchQuery, name, branch, hash, resources)
+		if err != nil {
+			tx.Rollback()
+			return err
+		}
+	}
+	if err := tx.Commit(); err != nil {
+		tx.Rollback()
+		return err
+	}
+	return nil
 }
 
 func (s *storeImpl) GetCommitHistory(name, branch string) ([]CommitMeta, error) {
@@ -234,6 +275,23 @@
 	return ret, nil
 }
 
+func (s *storeImpl) GetLastCommitInfo(name, branch string) (LastCommitInfo, error) {
+	query := `SELECT hash, resources FROM branches WHERE app_name = ? AND branch = ?`
+	row := s.db.QueryRow(query, name, branch)
+	if err := row.Err(); err != nil {
+		return LastCommitInfo{}, err
+	}
+	var ret LastCommitInfo
+	var res []byte
+	if err := row.Scan(&ret.Hash, &res); err != nil {
+		return LastCommitInfo{}, err
+	}
+	if err := json.NewDecoder(bytes.NewBuffer(res)).Decode(&ret.Resources); err != nil {
+		return LastCommitInfo{}, err
+	}
+	return ret, nil
+}
+
 func (s *storeImpl) GetBranches(name string) ([]string, error) {
 	query := `SELECT DISTINCT branch FROM commits WHERE app_name = ?`
 	rows, err := s.db.Query(query, name)