DodoApp: Status page

Implements basic status page, listing all apps and their commit
statuses. Separates web and api endpoints. Unifies API addresses a bit.

Change-Id: I98f9f949a49b60e80e188f7b51ec0e967666e65b
diff --git a/apps/app-runner/main.go b/apps/app-runner/main.go
index dce8ffe..21be651 100644
--- a/apps/app-runner/main.go
+++ b/apps/app-runner/main.go
@@ -21,7 +21,7 @@
 var sshKey = flag.String("ssh-key", "", "Private SSH key to access Git repository")
 var appDir = flag.String("app-dir", "", "Path to store application repository locally")
 var runCfg = flag.String("run-cfg", "", "Run configuration")
-var manager = flag.String("manager", "", "Address of the manager")
+var managerAddr = flag.String("manager-addr", "", "Address of the manager")
 
 type Command struct {
 	Bin  string   `json:"bin"`
@@ -98,6 +98,6 @@
 	if err := json.NewDecoder(r).Decode(&cmds); err != nil {
 		panic(err)
 	}
-	s := NewServer(*port, *appId, *repoAddr, signer, *appDir, cmds, self, *manager)
+	s := NewServer(*port, *appId, *repoAddr, signer, *appDir, cmds, self, *managerAddr)
 	s.Start()
 }
diff --git a/apps/app-runner/server.go b/apps/app-runner/server.go
index 7985ac6..f3ee563 100644
--- a/apps/app-runner/server.go
+++ b/apps/app-runner/server.go
@@ -24,7 +24,7 @@
 	appDir      string
 	runCommands []Command
 	self        string
-	manager     string
+	managerAddr string
 }
 
 func NewServer(port int, appId string, repoAddr string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
@@ -38,7 +38,7 @@
 		appDir:      appDir,
 		runCommands: runCommands,
 		self:        self,
-		manager:     manager,
+		managerAddr: manager,
 	}
 }
 
@@ -120,7 +120,6 @@
 }
 
 type pingReq struct {
-	AppId   string `json:"appId"`
 	Address string `json:"address"`
 }
 
@@ -131,9 +130,12 @@
 			s.pingManager()
 		}()
 	}()
-	buf, err := json.Marshal(pingReq{s.appId, s.self})
+	buf, err := json.Marshal(pingReq{
+		Address: fmt.Sprintf("%s:%d", s.self, s.port),
+	})
 	if err != nil {
 		return
 	}
-	http.Post(s.manager, "application/json", bytes.NewReader(buf))
+	registerWorkerAddr := fmt.Sprintf("%s/api/apps/%s/workers", s.managerAddr, s.appId)
+	http.Post(registerWorkerAddr, "application/json", bytes.NewReader(buf))
 }
diff --git a/charts/app-runner/templates/install.yaml b/charts/app-runner/templates/install.yaml
index 1c33df9..d05287d 100644
--- a/charts/app-runner/templates/install.yaml
+++ b/charts/app-runner/templates/install.yaml
@@ -97,7 +97,7 @@
         - --repo-addr={{ .Values.repoAddr }}
         - --ssh-key=/pcloud/ssh-key/private
         - --run-cfg=/pcloud/config/run
-        - --manager={{ .Values.manager }}
+        - --manager-addr={{ .Values.managerAddr }}
         volumeMounts:
         - name: ssh-key
           readOnly: true
diff --git a/charts/app-runner/values.yaml b/charts/app-runner/values.yaml
index f0625e4..1d21e06 100644
--- a/charts/app-runner/values.yaml
+++ b/charts/app-runner/values.yaml
@@ -8,5 +8,5 @@
 runCfg: ""
 appDir: /dodo-app
 appPort: 8080
-manager: ""
+managerAddr: ""
 volumes: []
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
index b8c1359..50ff7ab 100644
--- a/charts/dodo-app/templates/install.yaml
+++ b/charts/dodo-app/templates/install.yaml
@@ -46,7 +46,21 @@
 apiVersion: v1
 kind: Service
 metadata:
-  name: dodo-app
+  name: api
+spec:
+  type: ClusterIP
+  selector:
+    app: dodo-app
+  ports:
+  - name: http
+    port: 80
+    targetPort: api
+    protocol: TCP
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: web
 spec:
   type: ClusterIP
   selector:
@@ -78,25 +92,40 @@
       - name: env-config
         secret:
           secretName: env-config
+      - name: db
+        persistentVolumeClaim:
+          claimName: {{ .Values.persistentVolumeClaimName }}
+      initContainers:
+      - name: volume-permissions
+        image: busybox:latest
+        command: ["sh", "-c", "chmod -Rv 777 /dodo-app/db"]
+        volumeMounts:
+        - name: db
+          mountPath: /dodo-app/db
       containers:
       - name: dodo-app
         image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
         imagePullPolicy: {{ .Values.image.pullPolicy }}
         ports:
         - name: http
-          containerPort: 8080
+          containerPort: {{ .Values.port }}
+          protocol: TCP
+        - name: api
+          containerPort: {{ .Values.apiPort }}
           protocol: TCP
         command:
         - pcloud-installer
         - dodo-app
         - --repo-addr={{ .Values.repoAddr }}
         - --ssh-key=/pcloud/ssh-key/private
-        - --port=8080
+        - --port={{ .Values.port }}
+        - --api-port={{ .Values.apiPort }}
         - --self={{ .Values.self }}
         - --namespace={{ .Values.namespace }} # TODO(gio): maybe use .Release.Namespace ?
         - --env-config=/pcloud/env-config/config.json
         - --app-admin-key={{ .Values.appAdminKey }}
         - --git-repo-public-key={{ .Values.gitRepoPublicKey }}
+        - --db=/dodo-app/db/apps.db
         volumeMounts:
         - name: ssh-key
           readOnly: true
@@ -104,6 +133,8 @@
         - name: env-config
           readOnly: true
           mountPath: /pcloud/env-config
+        - name: db
+          mountPath: /dodo-app/db
 ---
 apiVersion: v1
 kind: Secret
diff --git a/charts/dodo-app/values.yaml b/charts/dodo-app/values.yaml
index 66588cf..50aae4d 100644
--- a/charts/dodo-app/values.yaml
+++ b/charts/dodo-app/values.yaml
@@ -2,6 +2,8 @@
   repository: giolekva/pcloud-installer
   tag: latest
   pullPolicy: Always
+port: 8080
+apiPort: 8081
 clusterRoleName: dodo-app-creator
 repoAddr: 192.168.0.11
 sshPrivateKey: key
@@ -10,3 +12,4 @@
 envConfig: ""
 appAdminKey: ""
 gitRepoPublicKey: ""
+persistentVolumeClaimName: ""
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index 32e0c45..c5b0554 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -6,7 +6,7 @@
 
 input: {
 	repoAddr: string
-	registerWorkerAddr: string
+	managerAddr: string
 	appId: string
 	sshPrivateKey: string
 }
@@ -159,7 +159,7 @@
 			repoAddr: input.repoAddr
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 			runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
-			manager: input.registerWorkerAddr
+			managerAddr: input.managerAddr
 			volumes: [
 				for key, value in _app.volumes {
 					name: value.name
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 44d2dbd..7935ec2 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -315,10 +315,10 @@
 		AppDir:        "/foo/bar",
 	}
 	_, err = app.Render(release, env, map[string]any{
-		"repoAddr":           "",
-		"registerWorkerAddr": "",
-		"appId":              "",
-		"sshPrivateKey":      "",
+		"repoAddr":      "",
+		"managerAddr":   "",
+		"appId":         "",
+		"sshPrivateKey": "",
 	}, nil)
 	if err != nil {
 		t.Fatal(err)
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index 78c88d2..4b34f01 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"database/sql"
 	"encoding/json"
 	"log"
 	"os"
@@ -9,11 +10,15 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/welcome"
 
+	_ "github.com/ncruces/go-sqlite3"
+	_ "github.com/ncruces/go-sqlite3/driver"
+	_ "github.com/ncruces/go-sqlite3/embed"
 	"github.com/spf13/cobra"
 )
 
 var dodoAppFlags struct {
 	port             int
+	apiPort          int
 	sshKey           string
 	repoAddr         string
 	self             string
@@ -21,6 +26,7 @@
 	envConfig        string
 	appAdminKey      string
 	gitRepoPublicKey string
+	db               string
 }
 
 func dodoAppCmd() *cobra.Command {
@@ -34,6 +40,18 @@
 		8080,
 		"",
 	)
+	cmd.Flags().IntVar(
+		&dodoAppFlags.apiPort,
+		"api-port",
+		8081,
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.db,
+		"db",
+		"",
+		"",
+	)
 	cmd.Flags().StringVar(
 		&dodoAppFlags.repoAddr,
 		"repo-addr",
@@ -80,6 +98,10 @@
 }
 
 func dodoAppCmdRun(cmd *cobra.Command, args []string) error {
+	sshKey, err := os.ReadFile(dodoAppFlags.sshKey)
+	if err != nil {
+		return err
+	}
 	envConfig, err := os.Open(dodoAppFlags.envConfig)
 	if err != nil {
 		return err
@@ -89,10 +111,6 @@
 	if err := json.NewDecoder(envConfig).Decode(&env); err != nil {
 		return err
 	}
-	sshKey, err := os.ReadFile(dodoAppFlags.sshKey)
-	if err != nil {
-		return err
-	}
 	cg := soft.RealClientGetter{}
 	softClient, err := cg.Get(dodoAppFlags.repoAddr, sshKey, log.Default())
 	if err != nil {
@@ -106,8 +124,29 @@
 	if err != nil {
 		return err
 	}
+	if ok, err := softClient.RepoExists(welcome.ConfigRepoName); err != nil {
+		return err
+	} else if !ok {
+		if err := softClient.AddRepository(welcome.ConfigRepoName); err != nil {
+			return err
+		}
+	}
+	configRepo, err := softClient.GetRepo(welcome.ConfigRepoName)
+	if err != nil {
+		return err
+	}
+	db, err := sql.Open("sqlite3", dodoAppFlags.db)
+	if err != nil {
+		return err
+	}
+	st, err := welcome.NewStore(configRepo, db)
+	if err != nil {
+		return err
+	}
 	s, err := welcome.NewDodoAppServer(
+		st,
 		dodoAppFlags.port,
+		dodoAppFlags.apiPort,
 		dodoAppFlags.self,
 		string(sshKey),
 		dodoAppFlags.gitRepoPublicKey,
diff --git a/core/installer/go.mod b/core/installer/go.mod
index fffd1fa..0e389fa 100644
--- a/core/installer/go.mod
+++ b/core/installer/go.mod
@@ -17,8 +17,9 @@
 	github.com/libdns/gandi v1.0.3
 	github.com/libdns/libdns v0.2.2
 	github.com/miekg/dns v1.1.58
+	github.com/ncruces/go-sqlite3 v0.17.0
 	github.com/spf13/cobra v1.8.0
-	golang.org/x/crypto v0.22.0
+	golang.org/x/crypto v0.24.0
 	golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8
 	helm.sh/helm/v3 v3.14.3
 	k8s.io/api v0.30.0
@@ -117,6 +118,7 @@
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
+	github.com/ncruces/julianday v1.0.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.0 // indirect
 	github.com/peterbourgon/diskv v2.0.1+incompatible // indirect
@@ -138,6 +140,7 @@
 	github.com/skeema/knownhosts v1.2.2 // indirect
 	github.com/spf13/cast v1.6.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/tetratelabs/wazero v1.7.3 // indirect
 	github.com/xanzy/ssh-agent v0.3.3 // indirect
 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
@@ -148,15 +151,15 @@
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
 	go.starlark.net v0.0.0-20240329153429-e6e8e7ce1b7a // indirect
-	golang.org/x/mod v0.16.0 // indirect
-	golang.org/x/net v0.24.0 // indirect
+	golang.org/x/mod v0.17.0 // indirect
+	golang.org/x/net v0.25.0 // indirect
 	golang.org/x/oauth2 v0.18.0 // indirect
-	golang.org/x/sync v0.6.0 // indirect
-	golang.org/x/sys v0.19.0 // indirect
-	golang.org/x/term v0.19.0 // indirect
-	golang.org/x/text v0.14.0 // indirect
+	golang.org/x/sync v0.7.0 // indirect
+	golang.org/x/sys v0.22.0 // indirect
+	golang.org/x/term v0.21.0 // indirect
+	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.19.0 // indirect
+	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect
 	google.golang.org/grpc v1.63.0 // indirect
diff --git a/core/installer/go.sum b/core/installer/go.sum
index e0033a2..8356115 100644
--- a/core/installer/go.sum
+++ b/core/installer/go.sum
@@ -316,6 +316,10 @@
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
+github.com/ncruces/go-sqlite3 v0.17.0 h1:tbrmwAF9Iq6O6i8NX+pO7rQYIwIJ4cZ/nZFQyw7GB18=
+github.com/ncruces/go-sqlite3 v0.17.0/go.mod h1:Ik98tXgiGdF2HgHYZlEkh84RAC3U3eqgS7PYsmLwLxY=
+github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M=
+github.com/ncruces/julianday v1.0.0/go.mod h1:Dusn2KvZrrovOMJuOt0TNXL6tB7U2E8kvza5fFc9G7g=
 github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8=
 github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs=
 github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk=
@@ -397,6 +401,8 @@
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.7.3 h1:PBH5KVahrt3S2AHgEjKu4u+LlDbbk+nsGE3KLucy6Rw=
+github.com/tetratelabs/wazero v1.7.3/go.mod h1:ytl6Zuh20R/eROuyDaGPkp82O9C/DJfXAwJfQ3X6/7Y=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
 github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -440,16 +446,16 @@
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
-golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
-golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -462,8 +468,8 @@
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
 golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
 golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -473,8 +479,8 @@
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
-golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -495,15 +501,15 @@
 golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
-golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
-golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
+golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
+golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -512,8 +518,8 @@
 golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -522,8 +528,8 @@
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
-golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
-golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index abc09b2..ddd3b17 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -53,6 +53,20 @@
 	}
 }
 
+volumes: db: size: "10Gi"
+
+ingress: {
+	"dodo-app": {
+		auth: enabled: false
+		network: input.network
+		subdomain: input.subdomain
+		service: {
+			name: "web"
+			port: name: "http"
+		}
+	}
+}
+
 portForward: [#PortForward & {
 	allocator: input.network.allocatePortAddr
 	reservator: input.network.reservePortAddr
@@ -92,13 +106,16 @@
 				tag: images.dodoApp.tag
 				pullPolicy: images.dodoApp.pullPolicy
 			}
+			port: 8080
+			apiPort: 8081
 			repoAddr: "soft-serve.\(release.namespace).svc.cluster.local:22"
 			sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
-			self: "dodo-app.\(release.namespace).svc.cluster.local"
+			self: "api.\(release.namespace).svc.cluster.local"
 			namespace: release.namespace
 			envConfig: base64.Encode(null, json.Marshal(global))
 			appAdminKey: input.adminKey
 			gitRepoPublicKey: input.ssKeys.public
+			persistentVolumeClaimName: volumes.db.name
 		}
 	}
 }
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 8cfa72f..89b7ad2 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -9,7 +9,6 @@
 	"net/http"
 	"strings"
 	"sync"
-	"time"
 
 	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
@@ -18,13 +17,15 @@
 )
 
 const (
-	configRepoName = "config"
+	ConfigRepoName = "config"
 	namespacesFile = "/namespaces.json"
 )
 
 type DodoAppServer struct {
 	l                sync.Locker
+	st               Store
 	port             int
+	apiPort          int
 	self             string
 	sshKey           string
 	gitRepoPublicKey string
@@ -39,7 +40,9 @@
 
 // TODO(gio): Initialize appNs on startup
 func NewDodoAppServer(
+	st Store,
 	port int,
+	apiPort int,
 	self string,
 	sshKey string,
 	gitRepoPublicKey string,
@@ -49,16 +52,11 @@
 	jc installer.JobCreator,
 	env installer.EnvConfig,
 ) (*DodoAppServer, error) {
-	if ok, err := client.RepoExists(configRepoName); err != nil {
-		return nil, err
-	} else if !ok {
-		if err := client.AddRepository(configRepoName); err != nil {
-			return nil, err
-		}
-	}
 	s := &DodoAppServer{
 		&sync.Mutex{},
+		st,
 		port,
+		apiPort,
 		self,
 		sshKey,
 		gitRepoPublicKey,
@@ -70,7 +68,7 @@
 		map[string]map[string]struct{}{},
 		map[string]string{},
 	}
-	config, err := client.GetRepo(configRepoName)
+	config, err := client.GetRepo(ConfigRepoName)
 	if err != nil {
 		return nil, err
 	}
@@ -87,12 +85,50 @@
 }
 
 func (s *DodoAppServer) Start() error {
-	r := mux.NewRouter()
-	r.HandleFunc("/update", s.handleUpdate)
-	r.HandleFunc("/register-worker", s.handleRegisterWorker).Methods(http.MethodPost)
-	r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
-	r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
-	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
+	e := make(chan error)
+	go func() {
+		r := mux.NewRouter()
+		r.HandleFunc("/status/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
+		r.HandleFunc("/status", s.handleStatus).Methods(http.MethodGet)
+		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
+	}()
+	go func() {
+		r := mux.NewRouter()
+		r.HandleFunc("/update", s.handleUpdate)
+		r.HandleFunc("/api/apps/{app-name}/workers", s.handleRegisterWorker).Methods(http.MethodPost)
+		r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
+		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
+	}()
+	return <-e
+}
+
+func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
+	apps, err := s.st.GetApps()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for _, a := range apps {
+		fmt.Fprintf(w, "%s\n", a)
+	}
+}
+
+func (s *DodoAppServer) handleAppStatus(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
+	}
+	commits, err := s.st.GetCommitHistory(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	for _, c := range commits {
+		fmt.Fprintf(w, "%s %s\n", c.Hash, c.Message)
+	}
 }
 
 type updateReq struct {
@@ -100,6 +136,7 @@
 	Repository struct {
 		Name string `json:"name"`
 	} `json:"repository"`
+	After string `json:"after"`
 }
 
 func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
@@ -113,38 +150,49 @@
 		fmt.Println(err)
 		return
 	}
-	if req.Ref != "refs/heads/master" || strings.HasPrefix(req.Repository.Name, configRepoName) {
+	if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
 		return
 	}
+	// TODO(gio): Create commit record on app init as well
 	go func() {
-		time.Sleep(20 * time.Second)
 		if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name]); err != nil {
-			fmt.Println(err)
+			if err := s.st.CreateCommit(req.Repository.Name, req.After, err.Error()); err != nil {
+				fmt.Printf("Error: %s\n", err.Error())
+				return
+			}
+		}
+		if err := s.st.CreateCommit(req.Repository.Name, req.After, "OK"); err != nil {
+			fmt.Printf("Error: %s\n", err.Error())
+		}
+		for addr, _ := range s.workers[req.Repository.Name] {
+			go func() {
+				// TODO(gio): make port configurable
+				http.Get(fmt.Sprintf("http://%s/update", addr))
+			}()
 		}
 	}()
-	for addr, _ := range s.workers[req.Repository.Name] {
-		go func() {
-			// TODO(gio): make port configurable
-			http.Get(fmt.Sprintf("http://%s:3000/update", addr))
-		}()
-	}
 }
 
 type registerWorkerReq struct {
-	AppId   string `json:"appId"`
 	Address string `json:"address"`
 }
 
 func (s *DodoAppServer) handleRegisterWorker(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
+	}
 	var req registerWorkerReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if _, ok := s.workers[req.AppId]; !ok {
-		s.workers[req.AppId] = map[string]struct{}{}
+	if _, ok := s.workers[appName]; !ok {
+		s.workers[appName] = map[string]struct{}{}
 	}
-	s.workers[req.AppId][req.Address] = struct{}{}
+	s.workers[appName][req.Address] = struct{}{}
 }
 
 type createAppReq struct {
@@ -187,6 +235,9 @@
 	} else if ok {
 		return nil
 	}
+	if err := s.st.CreateApp(appName); err != nil {
+		return err
+	}
 	if err := s.client.AddRepository(appName); err != nil {
 		return err
 	}
@@ -212,7 +263,7 @@
 	if err := s.updateDodoApp(appName, namespace); err != nil {
 		return err
 	}
-	repo, err := s.client.GetRepo(configRepoName)
+	repo, err := s.client.GetRepo(ConfigRepoName)
 	if err != nil {
 		return err
 	}
@@ -337,10 +388,10 @@
 		"/.dodo/app",
 		namespace,
 		map[string]any{
-			"repoAddr":           repo.FullAddress(),
-			"registerWorkerAddr": fmt.Sprintf("http://%s/register-worker", s.self),
-			"appId":              name,
-			"sshPrivateKey":      s.sshKey,
+			"repoAddr":      repo.FullAddress(),
+			"managerAddr":   fmt.Sprintf("http://%s", s.self),
+			"appId":         name,
+			"sshPrivateKey": s.sshKey,
 		},
 		installer.WithConfig(&s.env),
 		installer.WithLocalChartGenerator(lg),
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
new file mode 100644
index 0000000..0ae5f4e
--- /dev/null
+++ b/core/installer/welcome/store.go
@@ -0,0 +1,103 @@
+package welcome
+
+import (
+	"database/sql"
+
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type Commit struct {
+	Hash    string
+	Message string
+}
+
+type Store interface {
+	GetApps() ([]string, error)
+	CreateApp(name string) error
+	CreateCommit(name, hash, message string) error
+	GetCommitHistory(name string) ([]Commit, error)
+}
+
+func NewStore(cf soft.RepoIO, db *sql.DB) (Store, error) {
+	s := &storeImpl{cf, db}
+	if err := s.init(); err != nil {
+		return nil, err
+	}
+	return s, nil
+}
+
+type storeImpl struct {
+	cf soft.RepoIO
+	db *sql.DB
+}
+
+func (s *storeImpl) init() error {
+	_, err := s.db.Exec(`
+		CREATE TABLE IF NOT EXISTS apps (
+			name TEXT PRIMARY KEY
+		);
+		CREATE TABLE IF NOT EXISTS commits (
+			app_name TEXT,
+            hash TEXT,
+            message TEXT
+		);
+	`)
+	return err
+
+}
+
+func (s *storeImpl) CreateApp(name string) error {
+	query := `INSERT INTO apps (name) VALUES (?)`
+	_, err := s.db.Exec(query, name)
+	return err
+}
+
+func (s *storeImpl) GetApps() ([]string, error) {
+	query := `SELECT name FROM apps`
+	rows, err := s.db.Query(query)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	ret := []string{}
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		var name string
+		if err := rows.Scan(&name); err != nil {
+			return nil, err
+		}
+		ret = append(ret, name)
+
+	}
+	return ret, nil
+}
+
+func (s *storeImpl) CreateCommit(name, hash, message string) error {
+	query := `INSERT INTO commits (app_name, hash, message) VALUES (?, ?, ?)`
+	_, err := s.db.Exec(query, name, hash, message)
+	return err
+}
+
+func (s *storeImpl) GetCommitHistory(name string) ([]Commit, error) {
+	query := `SELECT hash, message FROM commits WHERE app_name = ?`
+	rows, err := s.db.Query(query, name)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	ret := []Commit{}
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		var c Commit
+		if err := rows.Scan(&c.Hash, &c.Message); err != nil {
+			return nil, err
+		}
+		ret = append(ret, c)
+
+	}
+	return ret, nil
+}