DodoApp: Prepare dodo-app to support multiple app repositories

Previously Git repository storing configuration for PCloud
environment, linked dodo-app repositories directly. After this change,
dodo-app will first create config repository which will link
individual application repositories. And PCloud env will link to the
config repo. That way dodo-app manger will be able to create multiple
app repositories per installation.

Change-Id: I647cacda7a9a4f241d2acc28ae5d8bbd8c6424d6
diff --git a/apps/app-runner/main.go b/apps/app-runner/main.go
index 10d74ae..dce8ffe 100644
--- a/apps/app-runner/main.go
+++ b/apps/app-runner/main.go
@@ -16,6 +16,7 @@
 )
 
 var port = flag.Int("port", 3000, "Port to listen on")
+var appId = flag.String("app-id", "", "Application ID")
 var repoAddr = flag.String("repo-addr", "", "Git repository address")
 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")
@@ -97,6 +98,6 @@
 	if err := json.NewDecoder(r).Decode(&cmds); err != nil {
 		panic(err)
 	}
-	s := NewServer(*port, *repoAddr, signer, *appDir, cmds, self, *manager)
+	s := NewServer(*port, *appId, *repoAddr, signer, *appDir, cmds, self, *manager)
 	s.Start()
 }
diff --git a/apps/app-runner/server.go b/apps/app-runner/server.go
index 865f724..7985ac6 100644
--- a/apps/app-runner/server.go
+++ b/apps/app-runner/server.go
@@ -16,6 +16,7 @@
 type Server struct {
 	l           sync.Locker
 	port        int
+	appId       string
 	ready       bool
 	cmd         *exec.Cmd
 	repoAddr    string
@@ -26,11 +27,12 @@
 	manager     string
 }
 
-func NewServer(port int, repoAddr string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
+func NewServer(port int, appId string, repoAddr string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
 	return &Server{
 		l:           &sync.Mutex{},
 		port:        port,
 		ready:       false,
+		appId:       appId,
 		repoAddr:    repoAddr,
 		signer:      signer,
 		appDir:      appDir,
@@ -118,6 +120,7 @@
 }
 
 type pingReq struct {
+	AppId   string `json:"appId"`
 	Address string `json:"address"`
 }
 
@@ -128,7 +131,7 @@
 			s.pingManager()
 		}()
 	}()
-	buf, err := json.Marshal(pingReq{s.self})
+	buf, err := json.Marshal(pingReq{s.appId, s.self})
 	if err != nil {
 		return
 	}
diff --git a/charts/app-runner/templates/install.yaml b/charts/app-runner/templates/install.yaml
index d2b7f70..1c33df9 100644
--- a/charts/app-runner/templates/install.yaml
+++ b/charts/app-runner/templates/install.yaml
@@ -92,6 +92,7 @@
         command:
         - app-runner
         - --port=3000
+        - --app-id={{ .Values.appId }}
         - --app-dir=/dodo-app
         - --repo-addr={{ .Values.repoAddr }}
         - --ssh-key=/pcloud/ssh-key/private
diff --git a/charts/app-runner/values.yaml b/charts/app-runner/values.yaml
index 97e793f..f0625e4 100644
--- a/charts/app-runner/values.yaml
+++ b/charts/app-runner/values.yaml
@@ -4,6 +4,7 @@
   pullPolicy: Always
 repoAddr: 192.168.0.11
 sshPrivateKey: key
+appId: ""
 runCfg: ""
 appDir: /dodo-app
 appPort: 8080
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
index 4746869..30a6553 100644
--- a/charts/dodo-app/templates/install.yaml
+++ b/charts/dodo-app/templates/install.yaml
@@ -83,6 +83,8 @@
         - --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 }}
         volumeMounts:
         - name: ssh-key
           readOnly: true
diff --git a/charts/dodo-app/values.yaml b/charts/dodo-app/values.yaml
index dcdc380..c606c50 100644
--- a/charts/dodo-app/values.yaml
+++ b/charts/dodo-app/values.yaml
@@ -7,3 +7,5 @@
 self: ""
 namespace: ""
 envConfig: ""
+appAdminKey: ""
+gitRepoPublicKey: ""
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index 4138024..477a68d 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -173,6 +173,7 @@
 	...
 }
 
+helm: {}
 _helmValidate: {
 	for key, value in helm {
 		"\(key)": #Helm & value & {
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index c721863..e3add4f 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -6,6 +6,7 @@
 
 input: {
 	repoAddr: string
+	appId: string
 	sshPrivateKey: string
 }
 
@@ -153,6 +154,7 @@
 			}
 			appPort: _appPort
 			appDir: _appDir
+			appId: input.appId
 			repoAddr: input.repoAddr
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 			runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index f35eb97..27b9b43 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -36,6 +36,7 @@
 }
 
 var envAppConfigs = []string{
+	"values-tmpl/dodo-app-instance.cue",
 	"values-tmpl/certificate-issuer-private.cue",
 	"values-tmpl/certificate-issuer-public.cue",
 	"values-tmpl/appmanager.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 559de31..db8d5b8 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -316,9 +316,36 @@
 	}
 	_, err = app.Render(release, env, map[string]any{
 		"repoAddr":      "",
+		"appId":         "",
 		"sshPrivateKey": "",
 	}, nil)
 	if err != nil {
 		t.Fatal(err)
 	}
 }
+
+func TestDodoAppInstance(t *testing.T) {
+	r := NewInMemoryAppRepository(CreateAllApps())
+	a, err := FindEnvApp(r, "dodo-app-instance")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if a == nil {
+		t.Fatal("returned app is nil")
+	}
+	release := Release{
+		Namespace: "foo",
+	}
+	values := map[string]any{
+		"appName":          "",
+		"repoAddr":         "",
+		"gitRepoPublicKey": "",
+	}
+	rendered, err := a.Render(release, env, values, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, r := range rendered.Resources {
+		t.Log(string(r))
+	}
+}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index c45cd35..3e4f6bd 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -15,12 +15,14 @@
 )
 
 var dodoAppFlags struct {
-	port      int
-	sshKey    string
-	repoAddr  string
-	self      string
-	namespace string
-	envConfig string
+	port             int
+	sshKey           string
+	repoAddr         string
+	self             string
+	namespace        string
+	envConfig        string
+	appAdminKey      string
+	gitRepoPublicKey string
 }
 
 func dodoAppCmd() *cobra.Command {
@@ -64,6 +66,18 @@
 		"",
 		"",
 	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.appAdminKey,
+		"app-admin-key",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.gitRepoPublicKey,
+		"git-repo-public-key",
+		"",
+		"",
+	)
 	return cmd
 }
 
@@ -89,6 +103,53 @@
 	if err != nil {
 		return err
 	}
+	if err := softClient.AddRepository("config"); err == nil {
+		repo, err := softClient.GetRepo("config")
+		if err != nil {
+			return err
+		}
+		appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		app, err := installer.FindEnvApp(appRepo, "dodo-app-instance")
+		if err != nil {
+			return err
+		}
+		nsc := installer.NewNoOpNamespaceCreator()
+		if err != nil {
+			return err
+		}
+		hf := installer.NewGitHelmFetcher()
+		m, err := installer.NewAppManager(repo, nsc, jc, hf, "/")
+		if err != nil {
+			return err
+		}
+		if _, err := m.Install(app, "app", "/app", dodoAppFlags.namespace, map[string]any{
+			"appName":          "app",
+			"repoAddr":         softClient.GetRepoAddress("app"),
+			"gitRepoPublicKey": dodoAppFlags.gitRepoPublicKey,
+		}, installer.WithConfig(&env)); err != nil {
+			return err
+		}
+		if cfg, err := m.FindInstance("app"); err != nil {
+			return err
+		} else {
+			fluxKeys, ok := cfg.Input["fluxKeys"]
+			if !ok {
+				return fmt.Errorf("Fluxcd keys not found")
+			}
+			fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
+			if !ok {
+				return fmt.Errorf("Fluxcd keys not found")
+			}
+			if err := softClient.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
+				return err
+			}
+			if err := softClient.AddReadOnlyCollaborator("app", "fluxcd"); err != nil {
+				return err
+			}
+		}
+	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
+		return err
+	}
 	if err := softClient.AddRepository("app"); err == nil {
 		repo, err := softClient.GetRepo("app")
 		if err != nil {
@@ -97,12 +158,18 @@
 		if err := initRepo(repo); err != nil {
 			return err
 		}
-		if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), jc, &env); err != nil {
+		if err := welcome.UpdateDodoApp("app", softClient, dodoAppFlags.namespace, string(sshKey), jc, &env); err != nil {
 			return err
 		}
 		if err := softClient.AddWebhook("app", fmt.Sprintf("http://%s/update", dodoAppFlags.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
 			return err
 		}
+		if err := softClient.AddUser("app", dodoAppFlags.appAdminKey); err != nil {
+			return err
+		}
+		if err := softClient.AddReadWriteCollaborator("app", "app"); err != nil {
+			return err
+		}
 	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
 		return err
 	}
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 1c93666..473efeb 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -8,6 +8,7 @@
 	"net"
 	"os"
 	"regexp"
+	"slices"
 	"strings"
 	"time"
 
@@ -93,7 +94,7 @@
 
 func (ss *realClient) AddUser(name, pubKey string) error {
 	log.Printf("Adding user %s", name)
-	if err := ss.RunCommand("user", "create", name); err != nil {
+	if _, err := ss.RunCommand("user", "create", name); err != nil {
 		return err
 	}
 	return ss.AddPublicKey(name, pubKey)
@@ -101,61 +102,83 @@
 
 func (ss *realClient) MakeUserAdmin(name string) error {
 	log.Printf("Making user %s admin", name)
-	return ss.RunCommand("user", "set-admin", name, "true")
+	_, err := ss.RunCommand("user", "set-admin", name, "true")
+	return err
 }
 
 func (ss *realClient) AddPublicKey(user string, pubKey string) error {
 	log.Printf("Adding public key: %s %s\n", user, pubKey)
-	return ss.RunCommand("user", "add-pubkey", user, pubKey)
+	_, err := ss.RunCommand("user", "add-pubkey", user, pubKey)
+	return err
 }
 
 func (ss *realClient) RemovePublicKey(user string, pubKey string) error {
 	log.Printf("Removing public key: %s %s\n", user, pubKey)
-	return ss.RunCommand("user", "remove-pubkey", user, pubKey)
+	_, err := ss.RunCommand("user", "remove-pubkey", user, pubKey)
+	return err
 }
 
-func (ss *realClient) RunCommand(args ...string) error {
+func (ss *realClient) RunCommand(args ...string) (string, error) {
 	cmd := strings.Join(args, " ")
 	log.Printf("Running command %s", cmd)
 	client, err := ssh.Dial("tcp", ss.addr, ss.sshClientConfig())
 	if err != nil {
-		return err
+		return "", err
 	}
 	defer client.Close()
 	session, err := client.NewSession()
 	if err != nil {
-		return err
+		return "", err
 	}
 	defer session.Close()
-	session.Stdout = os.Stdout
+	var buf strings.Builder
+	session.Stdout = &buf
 	session.Stderr = os.Stderr
-	return session.Run(cmd)
+	err = session.Run(cmd)
+	return buf.String(), err
+}
+
+func (ss *realClient) repoExists(name string) (bool, error) {
+	// if err := ss.RunCommand("repo", "info", name); err == nil {
+	// 	return ErrorAlreadyExists
+	// }
+	out, err := ss.RunCommand("repo", "list")
+	if err != nil {
+		return false, err
+	}
+	return slices.Contains(strings.Fields(out), name), nil
 }
 
 func (ss *realClient) AddRepository(name string) error {
 	log.Printf("Adding repository %s", name)
-	if err := ss.RunCommand("repo", "info", name); err == nil {
+	if ok, err := ss.repoExists(name); ok {
 		return ErrorAlreadyExists
+	} else if err != nil {
+		return err
 	}
-	return ss.RunCommand("repo", "create", name)
+	_, err := ss.RunCommand("repo", "create", name)
+	return err
 }
 
 func (ss *realClient) AddReadWriteCollaborator(repo, user string) error {
 	log.Printf("Adding read-write collaborator %s %s", repo, user)
-	return ss.RunCommand("repo", "collab", "add", repo, user, "read-write")
+	_, err := ss.RunCommand("repo", "collab", "add", repo, user, "read-write")
+	return err
 }
 
 func (ss *realClient) AddReadOnlyCollaborator(repo, user string) error {
 	log.Printf("Adding read-only collaborator %s %s", repo, user)
-	return ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
+	_, err := ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
+	return err
 }
 
 func (ss *realClient) AddWebhook(repo, url string, opts ...string) error {
 	log.Printf("Adding webhook %s %s", repo, url)
-	return ss.RunCommand(append(
+	_, err := ss.RunCommand(append(
 		[]string{"repo", "webhook", "create", repo, url},
 		opts...,
 	)...)
+	return err
 }
 
 type Repository struct {
diff --git a/core/installer/values-tmpl/dodo-app-instance.cue b/core/installer/values-tmpl/dodo-app-instance.cue
new file mode 100644
index 0000000..e783ffe
--- /dev/null
+++ b/core/installer/values-tmpl/dodo-app-instance.cue
@@ -0,0 +1,68 @@
+import (
+	"encoding/base64"
+)
+
+input: {
+	appName: string
+	repoAddr: string
+	gitRepoPublicKey: string
+	// TODO(gio): auto generate
+	fluxKeys: #SSHKey
+}
+
+name: "Dodo App Instance"
+namespace: "dodo-app-instance"
+readme: "Deploy app by pushing to Git repository"
+description: "Deploy app by pushing to Git repository"
+icon: ""
+_domain: "\(input.subdomain).\(input.network.domain)"
+
+resources: {
+	"config-kustomization": {
+		apiVersion: "kustomize.toolkit.fluxcd.io/v1"
+		kind: "Kustomization"
+		metadata: {
+			name: input.appName
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m"
+			path: "./"
+			sourceRef: {
+				kind: "GitRepository"
+				name: "app"
+				namespace: release.namespace
+			}
+			prune: true
+		}
+	}
+	"config-secret": {
+		apiVersion: "v1"
+		kind: "Secret"
+		type: "Opaque"
+		metadata: {
+			name: input.appName
+			namespace: release.namespace
+		}
+		data: {
+			identity: base64.Encode(null, input.fluxKeys.private)
+			"identity.pub": base64.Encode(null, input.fluxKeys.public)
+			known_hosts: base64.Encode(null, "soft-serve.\(release.namespace).svc.cluster.local \(input.gitRepoPublicKey)")
+		}
+	}
+	"config-source": {
+		apiVersion: "source.toolkit.fluxcd.io/v1"
+		kind: "GitRepository"
+		metadata: {
+			name: input.appName
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m0s"
+			ref: branch: "dodo"
+			secretRef: name: input.appName
+			timeout: "60s"
+			url: input.repoAddr
+		}
+	}
+}
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
index 80d0e4c..abc09b2 100644
--- a/core/installer/values-tmpl/dodo-app.cue
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -70,7 +70,7 @@
 			serviceType: "ClusterIP"
 			addressPool: ""
 			reservedIP: ""
-			adminKey: strings.Join([input.adminKey, input.fluxKeys.public, input.dAppKeys.public], "\n")
+			adminKey: strings.Join([input.fluxKeys.public, input.dAppKeys.public], "\n")
 			privateKey: input.ssKeys.private
 			publicKey: input.ssKeys.public
 			ingress: {
@@ -97,6 +97,8 @@
 			self: "dodo-app.\(release.namespace).svc.cluster.local"
 			namespace: release.namespace
 			envConfig: base64.Encode(null, json.Marshal(global))
+			appAdminKey: input.adminKey
+			gitRepoPublicKey: input.ssKeys.public
 		}
 	}
 }
@@ -106,15 +108,15 @@
 		apiVersion: "kustomize.toolkit.fluxcd.io/v1"
 		kind: "Kustomization"
 		metadata: {
-			name: "app"
+			name: "config"
 			namespace: release.namespace
 		}
 		spec: {
 			interval: "1m"
-			path: "./.dodo"
+			path: "./"
 			sourceRef: {
 				kind: "GitRepository"
-				name: "app"
+				name: "config"
 				namespace: release.namespace
 			}
 			prune: true
@@ -125,7 +127,7 @@
 		kind: "Secret"
 		type: "Opaque"
 		metadata: {
-			name: "app"
+			name: "config"
 			namespace: release.namespace
 		}
 		data: {
@@ -138,15 +140,15 @@
 		apiVersion: "source.toolkit.fluxcd.io/v1"
 		kind: "GitRepository"
 		metadata: {
-			name: "app"
+			name: "config"
 			namespace: release.namespace
 		}
 		spec: {
 			interval: "1m0s"
-			ref: branch: "dodo"
-			secretRef: name: "app"
+			ref: branch: "master"
+			secretRef: name: "config"
 			timeout: "60s"
-			url: "ssh://soft-serve.\(release.namespace).svc.cluster.local:22/app"
+			url: "ssh://soft-serve.\(release.namespace).svc.cluster.local:22/config"
 		}
 	}
 }
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 39d3a03..e032393 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -19,7 +19,7 @@
 	namespace string
 	env       installer.EnvConfig
 	jc        installer.JobCreator
-	workers   map[string]struct{}
+	workers   map[string]map[string]struct{}
 }
 
 func NewDodoAppServer(
@@ -37,7 +37,7 @@
 		namespace,
 		env,
 		jc,
-		map[string]struct{}{},
+		map[string]map[string]struct{}{},
 	}
 }
 
@@ -49,7 +49,10 @@
 }
 
 type updateReq struct {
-	Ref string `json:"ref"`
+	Ref        string `json:"ref"`
+	Repository struct {
+		Name string `json:"name"`
+	} `json:"repository"`
 }
 
 func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
@@ -63,16 +66,16 @@
 		fmt.Println(err)
 		return
 	}
-	if req.Ref != "refs/heads/master" {
+	if req.Ref != "refs/heads/master" || strings.HasPrefix(req.Repository.Name, "dodo") {
 		return
 	}
 	go func() {
 		time.Sleep(20 * time.Second)
-		if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, s.jc, &s.env); err != nil {
+		if err := UpdateDodoApp(req.Repository.Name, s.client, s.namespace, s.sshKey, s.jc, &s.env); err != nil {
 			fmt.Println(err)
 		}
 	}()
-	for addr, _ := range s.workers {
+	for addr, _ := range s.workers[req.Repository.Name] {
 		go func() {
 			// TODO(gio): make port configurable
 			http.Get(fmt.Sprintf("http://%s:3000/update", addr))
@@ -81,6 +84,7 @@
 }
 
 type registerWorkerReq struct {
+	AppId   string `json:"appId"`
 	Address string `json:"address"`
 }
 
@@ -90,8 +94,10 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	s.workers[req.Address] = struct{}{}
-	fmt.Printf("registered worker: %s\n", req.Address)
+	if _, ok := s.workers[req.AppId]; !ok {
+		s.workers[req.AppId] = map[string]struct{}{}
+	}
+	s.workers[req.AppId][req.Address] = struct{}{}
 }
 
 type addAdminKeyReq struct {
@@ -110,8 +116,8 @@
 	}
 }
 
-func UpdateDodoApp(client soft.Client, namespace string, sshKey string, jc installer.JobCreator, env *installer.EnvConfig) error {
-	repo, err := client.GetRepo("app")
+func UpdateDodoApp(name string, client soft.Client, namespace string, sshKey string, jc installer.JobCreator, env *installer.EnvConfig) error {
+	repo, err := client.GetRepo(name)
 	if err != nil {
 		return err
 	}
@@ -141,6 +147,7 @@
 		namespace,
 		map[string]any{
 			"repoAddr":      repo.FullAddress(),
+			"appId":         name,
 			"sshPrivateKey": sshKey,
 		},
 		installer.WithConfig(env),