DodoApp: Support dev virtual machines

Change-Id: Ib7641adb5be477bdde7cd9a06df4b45aa65a1c01
diff --git a/core/installer/welcome/app_tmpl.go b/core/installer/welcome/app_tmpl.go
index 33f8d8d..fb512fa 100644
--- a/core/installer/welcome/app_tmpl.go
+++ b/core/installer/welcome/app_tmpl.go
@@ -1,15 +1,14 @@
 package welcome
 
 import (
+	"bytes"
 	"fmt"
-	"io"
 	"io/fs"
 	"sort"
 	"strings"
 	"text/template"
 
 	"github.com/giolekva/pcloud/core/installer"
-	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 const tmplSuffix = ".gotmpl"
@@ -69,7 +68,7 @@
 }
 
 type AppTmpl interface {
-	Render(network installer.Network, subdomain string, out soft.RepoFS) error
+	Render(network installer.Network, subdomain string) (map[string][]byte, error)
 }
 
 type appTmplFS struct {
@@ -109,29 +108,20 @@
 	return &appTmplFS{files, tmpls}, nil
 }
 
-func (a *appTmplFS) Render(network installer.Network, subdomain string, out soft.RepoFS) error {
+func (a *appTmplFS) Render(network installer.Network, subdomain string) (map[string][]byte, error) {
+	ret := map[string][]byte{}
+	for path, contents := range a.files {
+		ret[path] = contents
+	}
 	for path, tmpl := range a.tmpls {
-		f, err := out.Writer(path)
-		if err != nil {
-			return err
-		}
-		defer f.Close()
-		if err := tmpl.Execute(f, map[string]any{
+		var buf bytes.Buffer
+		if err := tmpl.Execute(&buf, map[string]any{
 			"Network":   network,
 			"Subdomain": subdomain,
 		}); err != nil {
-			return err
+			return nil, err
 		}
+		ret[path] = buf.Bytes()
 	}
-	for path, contents := range a.files {
-		f, err := out.Writer(path)
-		if err != nil {
-			return err
-		}
-		defer f.Close()
-		if _, err := io.WriteString(f, string(contents)); err != nil {
-			return err
-		}
-	}
-	return nil
+	return ret, nil
 }
diff --git a/core/installer/welcome/app_tmpl_test.go b/core/installer/welcome/app_tmpl_test.go
index daaf4c8..00e5a3c 100644
--- a/core/installer/welcome/app_tmpl_test.go
+++ b/core/installer/welcome/app_tmpl_test.go
@@ -6,10 +6,7 @@
 	"io/fs"
 	"testing"
 
-	"github.com/go-git/go-billy/v5/memfs"
-
 	"github.com/giolekva/pcloud/core/installer"
-	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
 //go:embed app-tmpl
@@ -38,8 +35,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	out := soft.NewBillyRepoFS(memfs.New())
-	if err := a.Render(network, "testapp", out); err != nil {
+	if _, err := a.Render(network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -57,8 +53,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	out := soft.NewBillyRepoFS(memfs.New())
-	if err := a.Render(network, "testapp", out); err != nil {
+	if _, err := a.Render(network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
@@ -76,8 +71,7 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	out := soft.NewBillyRepoFS(memfs.New())
-	if err := a.Render(network, "testapp", out); err != nil {
+	if _, err := a.Render(network, "testapp"); err != nil {
 		t.Fatal(err)
 	}
 }
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index b25f5b1..c6874cb 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -31,9 +31,9 @@
     {{ else if eq $schema.Kind 1 }}
       <label {{ if $schema.Advanced }}hidden{{ end }}>
           {{ $schema.Name }}
-	  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
+		  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
+	  </label>
     {{ else if eq $schema.Kind 4 }}
-      </label>
       <label {{ if $schema.Advanced }}hidden{{ end }}>
           {{ $schema.Name }}
 		  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index 82421e9..7f168dc 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -163,7 +163,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	instances, err := s.m.FindAllAppInstances(slug)
+	instances, err := s.m.GetAllAppInstances(slug)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -182,7 +182,7 @@
 		http.Error(w, "empty slug", http.StatusBadRequest)
 		return
 	}
-	instance, err := s.m.FindInstance(slug)
+	instance, err := s.m.GetInstance(slug)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -339,7 +339,7 @@
 	}
 	resp := make([]app, 0)
 	for _, a := range apps {
-		instances, err := s.m.FindAllAppInstances(a.Slug())
+		instances, err := s.m.GetAllAppInstances(a.Slug())
 		if err != nil {
 			http.Error(w, err.Error(), http.StatusInternalServerError)
 			return
@@ -393,7 +393,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	instances, err := s.m.FindAllAppInstances(slug)
+	instances, err := s.m.GetAllAppInstances(slug)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -427,7 +427,7 @@
 		return
 	}
 	t, ok := s.tasks[slug]
-	instance, err := s.m.FindInstance(slug)
+	instance, err := s.m.GetInstance(slug)
 	if err != nil && !ok {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -446,7 +446,7 @@
 			panic("MUST NOT REACH!")
 		}
 	}
-	instances, err := s.m.FindAllAppInstances(a.Slug())
+	instances, err := s.m.GetAllAppInstances(a.Slug())
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
diff --git a/core/installer/welcome/dodo-app-tmpl/app_status.html b/core/installer/welcome/dodo-app-tmpl/app_status.html
index e96b801..916525e 100644
--- a/core/installer/welcome/dodo-app-tmpl/app_status.html
+++ b/core/installer/welcome/dodo-app-tmpl/app_status.html
@@ -3,11 +3,23 @@
 {{ end }}
 {{- define "content" -}}
 {{ .GitCloneCommand }}<br/>
+<form action="/{{ .Name }}/dev-branch/create" method="POST">
+	<fieldset class="grid">
+		<input type="text" name="branch" placeholder="branch" />
+		<button id="create-dev-branch-button" aria-busy="false" type="submit" name="create-dev-branch">create dev branch</button>
+	</fieldset>
+</form>
 <a href="/{{ .Name }}/logs">Logs</a>
 <hr class="divider">
 {{- template "resources" .LastCommit -}}
 <hr class="divider">
+<h3>Commit History</h3>
 {{- range .Commits -}}
 {{if eq .Status "OK" }}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="black" d="M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z"/></svg>{{ else }}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 48 48"><path fill="black" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="M6 11L11 6L24 19L37 6L42 11L29 24L42 37L37 42L24 29L11 42L6 37L19 24L6 11Z" clip-rule="evenodd"/></svg>{{ end }} <a href="/{{ $.Name }}/{{ .Hash }}">{{ .Hash }}</a> {{ .Message }}<br/>
 {{- end -}}
+<hr class="divider">
+<h3>Branches</h3>
+{{- range .Branches -}}
+<a href="/{{ $.Name }}/branch/{{ . }}">{{ . }}</a><br/>
+{{- end -}}
 {{- end -}}
diff --git a/core/installer/welcome/dodo-app-tmpl/base.html b/core/installer/welcome/dodo-app-tmpl/base.html
index 644ff41..4e9401b 100644
--- a/core/installer/welcome/dodo-app-tmpl/base.html
+++ b/core/installer/welcome/dodo-app-tmpl/base.html
@@ -20,11 +20,20 @@
 </body>
 </html>
 {{ define "resources" }}
-{{- if gt (len .Volume) 0 -}}
-<h3>Volumes</h3>
-{{- range $v := .Volume -}}
-Name: {{ $v.Name }}<br/>
-Size: {{ $v.Size }}<br/>
+{{- if gt (len .Ingress) 0 -}}
+<h3>Ingress</h3>
+{{- range $i := .Ingress -}}
+Host: <a href="{{ $i.Host }}">{{ $i.Host }}</a><br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- if gt (len .VirtualMachine) 0 -}}
+<h3>Virtual Machine</h3>
+{{- range $i := .VirtualMachine -}}
+Name: {{ $i.Name }}<br/>
+User: {{ $i.User }}<br/>
+CPU Cores: {{ $i.CPUCores }}<br/>
+Memory: {{ $i.Memory }}<br/>
 <br/>
 {{- end -}}
 {{- end -}}
@@ -37,10 +46,11 @@
 <br/>
 {{- end -}}
 {{- end -}}
-{{- if gt (len .Ingress) 0 -}}
-<h3>Ingress</h3>
-{{- range $i := .Ingress -}}
-Host: {{ $i.Host }}<br/>
+{{- if gt (len .Volume) 0 -}}
+<h3>Volumes</h3>
+{{- range $v := .Volume -}}
+Name: {{ $v.Name }}<br/>
+Size: {{ $v.Size }}<br/>
 <br/>
 {{- end -}}
 {{- end -}}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index bd72aef..57de8b2 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -12,6 +12,7 @@
 	"io/fs"
 	"net/http"
 	"slices"
+	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -23,6 +24,7 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 	"github.com/giolekva/pcloud/core/installer/tasks"
 
+	"cuelang.org/go/cue"
 	"github.com/gorilla/mux"
 	"github.com/gorilla/securecookie"
 )
@@ -194,7 +196,21 @@
 	return s, nil
 }
 
+func (s *DodoAppServer) getAppConfig(app, branch string) appConfig {
+	return s.appConfigs[fmt.Sprintf("%s-%s", app, branch)]
+}
+
+func (s *DodoAppServer) setAppConfig(app, branch string, cfg appConfig) {
+	s.appConfigs[fmt.Sprintf("%s-%s", app, branch)] = cfg
+}
+
 func (s *DodoAppServer) Start() error {
+	// if err := s.client.DisableKeyless(); err != nil {
+	// 	return err
+	// }
+	// if err := s.client.DisableAnonAccess(); err != nil {
+	// 	return err
+	// }
 	e := make(chan error)
 	go func() {
 		r := mux.NewRouter()
@@ -207,6 +223,8 @@
 		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
 		r.HandleFunc("/{app-name}/logs", s.handleAppLogs).Methods(http.MethodGet)
 		r.HandleFunc("/{app-name}/{hash}", s.handleAppCommit).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}/dev-branch/create", s.handleCreateDevBranch).Methods(http.MethodPost)
+		r.HandleFunc("/{app-name}/branch/{branch}", s.handleAppStatus).Methods(http.MethodGet)
 		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
 		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
 		r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
@@ -216,7 +234,7 @@
 		r := mux.NewRouter()
 		r.HandleFunc("/update", s.handleAPIUpdate)
 		r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
-		r.HandleFunc("/api/add-admin-key", s.handleAPIAddAdminKey).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-public-key", s.handleAPIAddPublicKey).Methods(http.MethodPost)
 		if !s.external {
 			r.HandleFunc("/api/sync-users", s.handleAPISyncUsers).Methods(http.MethodGet)
 		}
@@ -224,7 +242,6 @@
 	}()
 	if !s.external {
 		go func() {
-			rand.Seed(uint64(time.Now().UnixNano()))
 			s.syncUsers()
 			for {
 				delay := time.Duration(rand.Intn(60)+60) * time.Second
@@ -434,6 +451,7 @@
 	GitCloneCommand string
 	Commits         []CommitMeta
 	LastCommit      resourceData
+	Branches        []string
 }
 
 func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
@@ -443,6 +461,10 @@
 		http.Error(w, "missing app-name", http.StatusBadRequest)
 		return
 	}
+	branch, ok := vars["branch"]
+	if !ok || branch == "" {
+		branch = "master"
+	}
 	u := r.Context().Value(userCtx)
 	if u == nil {
 		http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -462,7 +484,7 @@
 		http.Error(w, "unauthorized", http.StatusUnauthorized)
 		return
 	}
-	commits, err := s.st.GetCommitHistory(appName)
+	commits, err := s.st.GetCommitHistory(appName, branch)
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -481,6 +503,11 @@
 		}
 		lastCommitResources = r
 	}
+	branches, err := s.st.GetBranches(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
 	data := appStatusData{
 		Navigation: []navItem{
 			navItem{"Home", "/"},
@@ -490,6 +517,10 @@
 		GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
 		Commits:         commits,
 		LastCommit:      lastCommitResources,
+		Branches:        branches,
+	}
+	if branch != "master" {
+		data.Navigation = append(data.Navigation, navItem{branch, fmt.Sprintf("/%s/branch/%s", appName, branch)})
 	}
 	if err := s.tmplts.appStatus.Execute(w, data); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -512,10 +543,18 @@
 	Host string
 }
 
+type vm struct {
+	Name     string
+	User     string
+	CPUCores int
+	Memory   string
+}
+
 type resourceData struct {
-	Volume     []volume
-	PostgreSQL []postgresql
-	Ingress    []ingress
+	Volume         []volume
+	PostgreSQL     []postgresql
+	Ingress        []ingress
+	VirtualMachine []vm
 }
 
 type commitStatusData struct {
@@ -659,7 +698,12 @@
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if req.Ref != "refs/heads/master" || req.Repository.Name == ConfigRepoName {
+	if strings.HasPrefix(req.Ref, "refs/heads/dodo_") || req.Repository.Name == ConfigRepoName {
+		return
+	}
+	branch, ok := strings.CutPrefix(req.Ref, "refs/heads/")
+	if !ok {
+		http.Error(w, "invalid branch", http.StatusBadRequest)
 		return
 	}
 	// TODO(gio): Create commit record on app init as well
@@ -690,8 +734,10 @@
 			fmt.Printf("Error: could not find commit message")
 			return
 		}
-		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks)
-		if err = s.createCommit(req.Repository.Name, req.After, commitMsg, err, resources); err != nil {
+		s.l.Lock()
+		defer s.l.Unlock()
+		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, owner)
+		if err = s.createCommit(req.Repository.Name, branch, req.After, commitMsg, err, resources); err != nil {
 			fmt.Printf("Error: %s\n", err.Error())
 			return
 		}
@@ -710,6 +756,7 @@
 }
 
 func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
+	// TODO(gio): lock
 	vars := mux.Vars(r)
 	appName, ok := vars["app-name"]
 	if !ok || appName == "" {
@@ -782,6 +829,35 @@
 	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
 }
 
+func (s *DodoAppServer) handleCreateDevBranch(w http.ResponseWriter, r *http.Request) {
+	u := r.Context().Value(userCtx)
+	if u == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	user, ok := u.(string)
+	if !ok {
+		http.Error(w, "could not get user", http.StatusInternalServerError)
+		return
+	}
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	branch := r.FormValue("branch")
+	if branch == "" {
+		http.Error(w, "missing network", http.StatusBadRequest)
+		return
+	}
+	if err := s.createDevBranch(appName, "master", branch, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/branch/%s", appName, branch), http.StatusSeeOther)
+}
+
 type apiCreateAppReq struct {
 	AppType        string `json:"type"`
 	AdminPublicKey string `json:"adminPublicKey"`
@@ -889,7 +965,52 @@
 	if err != nil {
 		return err
 	}
-	commit, err := s.initRepo(appRepo, appType, n, subdomain)
+	files, err := s.renderAppConfigTemplate(appType, n, subdomain)
+	if err != nil {
+		return err
+	}
+	return s.createAppForBranch(appRepo, appName, "master", user, network, files)
+}
+
+func (s *DodoAppServer) createDevBranch(appName, fromBranch, toBranch, user string) error {
+	s.l.Lock()
+	defer s.l.Unlock()
+	fmt.Printf("Creating dev branch app: %s %s %s\n", appName, fromBranch, toBranch)
+	appRepo, err := s.client.GetRepoBranch(appName, fromBranch)
+	if err != nil {
+		return err
+	}
+	appCfg, err := soft.ReadFile(appRepo, "app.cue")
+	if err != nil {
+		return err
+	}
+	network, branchCfg, err := createDevBranchAppConfig(appCfg, toBranch, user)
+	if err != nil {
+		return err
+	}
+	return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.cue": branchCfg})
+}
+
+func (s *DodoAppServer) createAppForBranch(
+	repo soft.RepoIO,
+	appName string,
+	branch string,
+	user string,
+	network string,
+	files map[string][]byte,
+) error {
+	commit, err := repo.Do(func(fs soft.RepoFS) (string, error) {
+		for path, contents := range files {
+			if err := soft.WriteFile(fs, path, string(contents)); err != nil {
+				return "", err
+			}
+		}
+		return "init", nil
+	}, soft.WithCommitToBranch(branch))
+	if err != nil {
+		return err
+	}
+	networks, err := s.getNetworks(user)
 	if err != nil {
 		return err
 	}
@@ -908,12 +1029,13 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
-	s.appConfigs[appName] = appConfig{namespace, network}
-	resources, err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks)
+	s.setAppConfig(appName, branch, appConfig{namespace, network})
+	resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, user)
 	if err != nil {
+		fmt.Printf("Error: %s\n", err.Error())
 		return err
 	}
-	if err = s.createCommit(appName, commit, initCommitMsg, err, resources); err != nil {
+	if err = s.createCommit(appName, branch, commit, initCommitMsg, err, resources); err != nil {
 		fmt.Printf("Error: %s\n", err.Error())
 		return err
 	}
@@ -926,6 +1048,7 @@
 	if err != nil {
 		return err
 	}
+	appPath := fmt.Sprintf("/%s/%s", appName, branch)
 	_, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
 		w, err := fs.Writer(appConfigsFile)
 		if err != nil {
@@ -938,11 +1061,12 @@
 		if _, err := m.Install(
 			instanceApp,
 			appName,
-			"/"+appName,
+			appPath,
 			namespace,
 			map[string]any{
 				"repoAddr":         s.client.GetRepoAddress(appName),
 				"repoHost":         strings.Split(s.client.Address(), ":")[0],
+				"branch":           fmt.Sprintf("dodo_%s", branch),
 				"gitRepoPublicKey": s.gitRepoPublicKey,
 			},
 			installer.WithConfig(&s.env),
@@ -957,7 +1081,11 @@
 	if err != nil {
 		return err
 	}
-	cfg, err := m.FindInstance(appName)
+	return s.initAppACLs(m, appPath, appName, branch, user)
+}
+
+func (s *DodoAppServer) initAppACLs(m *installer.AppManager, path, appName, branch, user string) error {
+	cfg, err := m.GetInstance(path)
 	if err != nil {
 		return err
 	}
@@ -980,13 +1108,16 @@
 			return err
 		}
 	}
+	if branch != "master" {
+		return nil
+	}
 	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
 		return err
 	}
-	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
+	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
 		return err
 	}
-	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
 		return err
 	}
 	if !s.external {
@@ -1010,16 +1141,25 @@
 }
 
 type apiAddAdminKeyReq struct {
-	Public string `json:"public"`
+	User      string `json:"user"`
+	PublicKey string `json:"publicKey"`
 }
 
-func (s *DodoAppServer) handleAPIAddAdminKey(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleAPIAddPublicKey(w http.ResponseWriter, r *http.Request) {
 	var req apiAddAdminKeyReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
 	}
-	if err := s.client.AddPublicKey("admin", req.Public); err != nil {
+	if req.User == "" {
+		http.Error(w, "invalid user", http.StatusBadRequest)
+		return
+	}
+	if req.PublicKey == "" {
+		http.Error(w, "invalid public key", http.StatusBadRequest)
+		return
+	}
+	if err := s.client.AddPublicKey(req.User, req.PublicKey); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
@@ -1037,12 +1177,16 @@
 	} `json:"input"`
 }
 
+// TODO(gio): must not require owner, now we need it to bootstrap dev vm.
 func (s *DodoAppServer) updateDodoApp(
 	appStatus installer.EnvApp,
-	name, namespace string,
+	name string,
+	branch string,
+	namespace string,
 	networks []installer.Network,
+	owner string,
 ) (installer.ReleaseResources, error) {
-	repo, err := s.client.GetRepo(name)
+	repo, err := s.client.GetRepoBranch(name, branch)
 	if err != nil {
 		return installer.ReleaseResources{}, err
 	}
@@ -1068,10 +1212,13 @@
 			"/.dodo/app",
 			namespace,
 			map[string]any{
-				"repoAddr":      repo.FullAddress(),
-				"managerAddr":   fmt.Sprintf("http://%s", s.self),
-				"appId":         name,
-				"sshPrivateKey": s.sshKey,
+				"repoAddr":       repo.FullAddress(),
+				"repoPublicAddr": s.repoPublicAddr,
+				"managerAddr":    fmt.Sprintf("http://%s", s.self),
+				"appId":          name,
+				"branch":         branch,
+				"sshPrivateKey":  s.sshKey,
+				"username":       owner,
 			},
 			installer.WithNoPull(),
 			installer.WithNoPublish(),
@@ -1108,7 +1255,7 @@
 		}
 		return "install app", nil
 	},
-		soft.WithCommitToBranch("dodo"),
+		soft.WithCommitToBranch(fmt.Sprintf("dodo_%s", branch)),
 		soft.WithForce(),
 	); err != nil {
 		return installer.ReleaseResources{}, err
@@ -1118,18 +1265,13 @@
 	return ret, nil
 }
 
-func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) (string, error) {
+func (s *DodoAppServer) renderAppConfigTemplate(appType string, network installer.Network, subdomain string) (map[string][]byte, error) {
 	appType = strings.Replace(appType, ":", "-", 1)
 	appTmpl, err := s.appTmpls.Find(appType)
 	if err != nil {
-		return "", err
+		return nil, err
 	}
-	return repo.Do(func(fs soft.RepoFS) (string, error) {
-		if err := appTmpl.Render(network, subdomain, repo); err != nil {
-			return "", err
-		}
-		return initCommitMsg, nil
-	})
+	return appTmpl.Render(network, subdomain)
 }
 
 func generatePassword() string {
@@ -1183,10 +1325,10 @@
 	}
 }
 
-func (s *DodoAppServer) createCommit(name, hash, message string, err error, resources installer.ReleaseResources) error {
+func (s *DodoAppServer) createCommit(name, branch, hash, message string, err error, resources installer.ReleaseResources) error {
 	if err != nil {
 		fmt.Printf("Error: %s\n", err.Error())
-		if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
+		if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
 			fmt.Printf("Error: %s\n", err.Error())
 			return err
 		}
@@ -1194,13 +1336,13 @@
 	}
 	var resB bytes.Buffer
 	if err := json.NewEncoder(&resB).Encode(resources); err != nil {
-		if err := s.st.CreateCommit(name, hash, message, "FAILED", err.Error(), nil); err != nil {
+		if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
 			fmt.Printf("Error: %s\n", err.Error())
 			return err
 		}
 		return err
 	}
-	if err := s.st.CreateCommit(name, hash, message, "OK", "", resB.Bytes()); err != nil {
+	if err := s.st.CreateCommit(name, branch, hash, message, "OK", "", resB.Bytes()); err != nil {
 		fmt.Printf("Error: %s\n", err.Error())
 		return err
 	}
@@ -1446,9 +1588,75 @@
 				return resourceData{}, fmt.Errorf("no host")
 			}
 			ret.Ingress = append(ret.Ingress, ingress{host})
+		case "virtual-machine":
+			name, ok := r.Annotations["dodo.cloud/resource.virtual-machine.name"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no name")
+			}
+			user, ok := r.Annotations["dodo.cloud/resource.virtual-machine.user"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no user")
+			}
+			cpuCoresS, ok := r.Annotations["dodo.cloud/resource.virtual-machine.cpu-cores"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no cpu cores")
+			}
+			cpuCores, err := strconv.Atoi(cpuCoresS)
+			if err != nil {
+				return resourceData{}, fmt.Errorf("invalid cpu cores: %s", cpuCoresS)
+			}
+			memory, ok := r.Annotations["dodo.cloud/resource.virtual-machine.memory"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no memory")
+			}
+			ret.VirtualMachine = append(ret.VirtualMachine, vm{name, user, cpuCores, memory})
 		default:
 			fmt.Printf("Unknown resource: %+v\n", r.Annotations)
 		}
 	}
 	return ret, nil
 }
+
+func createDevBranchAppConfig(from []byte, branch, username string) (string, []byte, error) {
+	cfg, err := installer.ParseCueAppConfig(installer.CueAppData{"app.cue": from})
+	if err != nil {
+		return "", nil, err
+	}
+	if err := cfg.Err(); err != nil {
+		return "", nil, err
+	}
+	if err := cfg.Validate(); err != nil {
+		return "", nil, err
+	}
+	subdomain := cfg.LookupPath(cue.ParsePath("app.ingress.subdomain"))
+	if err := subdomain.Err(); err != nil {
+		return "", nil, err
+	}
+	subdomainStr, err := subdomain.String()
+	network := cfg.LookupPath(cue.ParsePath("app.ingress.network"))
+	if err := network.Err(); err != nil {
+		return "", nil, err
+	}
+	networkStr, err := network.String()
+	if err != nil {
+		return "", nil, err
+	}
+	newCfg := map[string]any{}
+	if err := cfg.Decode(&newCfg); err != nil {
+		return "", nil, err
+	}
+	app, ok := newCfg["app"].(map[string]any)
+	if !ok {
+		return "", nil, fmt.Errorf("not a map")
+	}
+	app["ingress"].(map[string]any)["subdomain"] = fmt.Sprintf("%s-%s", branch, subdomainStr)
+	app["dev"] = map[string]any{
+		"enabled":  true,
+		"username": username,
+	}
+	buf, err := json.MarshalIndent(newCfg, "", "\t")
+	if err != nil {
+		return "", nil, err
+	}
+	return networkStr, buf, nil
+}
diff --git a/core/installer/welcome/dodo_app_test.go b/core/installer/welcome/dodo_app_test.go
new file mode 100644
index 0000000..0f0f526
--- /dev/null
+++ b/core/installer/welcome/dodo_app_test.go
@@ -0,0 +1,24 @@
+package welcome
+
+import (
+	"testing"
+)
+
+func TestCreateDevBranch(t *testing.T) {
+	cfg := []byte(`
+app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "private"
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}`)
+	network, newCfg, err := createDevBranchAppConfig(cfg, "foo", "bar")
+	if err != nil {
+		t.Fatal(err)
+	}
+	t.Log(network)
+	t.Log(string(newCfg))
+}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index f41110d..ee80e4e 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -15,6 +15,7 @@
 
 	"golang.org/x/crypto/ssh"
 
+	"cuelang.org/go/cue/errors"
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/memfs"
 	"github.com/go-git/go-billy/v5/util"
@@ -117,6 +118,10 @@
 	return mockRepoIO{soft.NewBillyRepoFS(f.envFS), "foo.bar", f.t, &l}, nil
 }
 
+func (f fakeSoftServeClient) GetRepoBranch(name, branch string) (soft.RepoIO, error) {
+	return f.GetRepo(name)
+}
+
 func (f fakeSoftServeClient) GetAllRepos() ([]string, error) {
 	return []string{}, nil
 }
@@ -176,6 +181,14 @@
 	return nil
 }
 
+func (f fakeSoftServeClient) DisableAnonAccess() error {
+	return nil
+}
+
+func (f fakeSoftServeClient) DisableKeyless() error {
+	return nil
+}
+
 type fakeClientGetter struct {
 	t     *testing.T
 	envFS billy.Filesystem
@@ -279,6 +292,9 @@
 		if _, err := infraMgr.Install(app, "/infrastructure/dns-gateway", "dns-gateway", map[string]any{
 			"servers": []installer.EnvDNS{},
 		}); err != nil {
+			for _, e := range errors.Errors(err) {
+				t.Log(e)
+			}
 			t.Fatal(err)
 		}
 	}
diff --git a/core/installer/welcome/launcher.go b/core/installer/welcome/launcher.go
index a828f1a..88047d8 100644
--- a/core/installer/welcome/launcher.go
+++ b/core/installer/welcome/launcher.go
@@ -42,7 +42,7 @@
 }
 
 func (d *AppManagerDirectory) GetAllApps() ([]AppLauncherInfo, error) {
-	all, err := d.AppManager.FindAllInstances()
+	all, err := d.AppManager.GetAllInstances()
 	if err != nil {
 		return nil, err
 	}
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 06be06a..3f8428d 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -40,9 +40,10 @@
 	GetUserApps(username string) ([]string, error)
 	CreateApp(name, username string) error
 	GetAppOwner(name string) (string, error)
-	CreateCommit(name, hash, message, status, error string, resources []byte) error
-	GetCommitHistory(name string) ([]CommitMeta, error)
+	CreateCommit(name, branch, hash, message, status, error string, resources []byte) error
+	GetCommitHistory(name, branch string) ([]CommitMeta, error)
 	GetCommit(hash string) (Commit, error)
+	GetBranches(name string) ([]string, error)
 }
 
 func NewStore(cf soft.RepoIO, db *sql.DB) (Store, error) {
@@ -71,6 +72,7 @@
 		);
 		CREATE TABLE IF NOT EXISTS commits (
 			app_name TEXT,
+			branch TEXT,
             hash TEXT,
             message TEXT,
             status TEXT,
@@ -186,15 +188,15 @@
 	return ret, nil
 }
 
-func (s *storeImpl) CreateCommit(name, hash, message, status, error string, resources []byte) error {
-	query := `INSERT INTO commits (app_name, hash, message, status, error, resources) VALUES (?, ?, ?, ?, ?, ?)`
-	_, err := s.db.Exec(query, name, hash, message, status, error, resources)
+func (s *storeImpl) CreateCommit(name, branch, hash, message, status, error string, resources []byte) error {
+	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
 }
 
-func (s *storeImpl) GetCommitHistory(name string) ([]CommitMeta, error) {
-	query := `SELECT hash, message, status, error FROM commits WHERE app_name = ?`
-	rows, err := s.db.Query(query, name)
+func (s *storeImpl) GetCommitHistory(name, branch string) ([]CommitMeta, error) {
+	query := `SELECT hash, message, status, error FROM commits WHERE app_name = ? AND branch = ?`
+	rows, err := s.db.Query(query, name, branch)
 	if err != nil {
 		return nil, err
 	}
@@ -231,3 +233,25 @@
 	}
 	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)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	ret := []string{}
+	for rows.Next() {
+		if err := rows.Err(); err != nil {
+			return nil, err
+		}
+		var b string
+		if err := rows.Scan(&b); err != nil {
+			return nil, err
+		}
+		ret = append(ret, b)
+
+	}
+	return ret, nil
+}