Installer: Refactor and give each searver its own directory

Change-Id: I1db2929e7a35b6f92022dec0c6506d68e0297563
diff --git a/core/installer/server/dodo-app/server.go b/core/installer/server/dodo-app/server.go
new file mode 100644
index 0000000..b932c33
--- /dev/null
+++ b/core/installer/server/dodo-app/server.go
@@ -0,0 +1,1854 @@
+package dodoapp
+
+import (
+	"bytes"
+	"context"
+	"embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"html/template"
+	"io"
+	"io/fs"
+	"net/http"
+	"slices"
+	"strconv"
+	"strings"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/bcrypt"
+	"golang.org/x/exp/rand"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/server"
+	"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"
+)
+
+//go:embed templates/*
+var templates embed.FS
+
+//go:embed all:app-templates
+var appTmplsFS embed.FS
+
+//go:embed static/*
+var staticAssets embed.FS
+
+//go:embed static/schemas/app.schema.json
+var dodoAppJsonSchema []byte
+
+const (
+	ConfigRepoName = "config"
+	appConfigsFile = "/apps.json"
+	loginPath      = "/login"
+	logoutPath     = "/logout"
+	staticPath     = "/static/"
+	schemasPath    = "/schemas/"
+	apiPublicData  = "/api/public-data"
+	apiCreateApp   = "/api/apps"
+	sessionCookie  = "dodo-app-session"
+	userCtx        = "user"
+	initCommitMsg  = "init"
+)
+
+type tmplts struct {
+	index        *template.Template
+	appStatus    *template.Template
+	commitStatus *template.Template
+	logs         *template.Template
+}
+
+func parseTemplates(fs embed.FS) (tmplts, error) {
+	base, err := template.ParseFS(fs, "templates/base.html")
+	if err != nil {
+		return tmplts{}, err
+	}
+	parse := func(path string) (*template.Template, error) {
+		if b, err := base.Clone(); err != nil {
+			return nil, err
+		} else {
+			return b.ParseFS(fs, path)
+		}
+	}
+	index, err := parse("templates/index.html")
+	if err != nil {
+		return tmplts{}, err
+	}
+	appStatus, err := parse("templates/app_status.html")
+	if err != nil {
+		return tmplts{}, err
+	}
+	commitStatus, err := parse("templates/commit_status.html")
+	if err != nil {
+		return tmplts{}, err
+	}
+	logs, err := parse("templates/logs.html")
+	if err != nil {
+		return tmplts{}, err
+	}
+	return tmplts{index, appStatus, commitStatus, logs}, nil
+}
+
+type Server struct {
+	l                 sync.Locker
+	st                Store
+	nf                NetworkFilter
+	ug                UserGetter
+	port              int
+	apiPort           int
+	self              string
+	selfPublic        string
+	repoPublicAddr    string
+	sshKey            string
+	gitRepoPublicKey  string
+	client            soft.Client
+	namespace         string
+	envAppManagerAddr string
+	env               installer.EnvConfig
+	nsc               installer.NamespaceCreator
+	jc                installer.JobCreator
+	vpnKeyGen         installer.VPNAPIClient
+	cnc               installer.ClusterNetworkConfigurator
+	workers           map[string]map[string]struct{}
+	appConfigs        map[string]appConfig
+	tmplts            tmplts
+	appTmpls          AppTmplStore
+	external          bool
+	fetchUsersAddr    string
+	reconciler        tasks.Reconciler
+	logs              map[string]string
+}
+
+type appConfig struct {
+	Namespace string `json:"namespace"`
+	Network   string `json:"network"`
+}
+
+// TODO(gio): Initialize appNs on startup
+func NewServer(
+	st Store,
+	nf NetworkFilter,
+	ug UserGetter,
+	port int,
+	apiPort int,
+	self string,
+	selfPublic string,
+	repoPublicAddr string,
+	sshKey string,
+	gitRepoPublicKey string,
+	client soft.Client,
+	namespace string,
+	envAppManagerAddr string,
+	nsc installer.NamespaceCreator,
+	jc installer.JobCreator,
+	vpnKeyGen installer.VPNAPIClient,
+	cnc installer.ClusterNetworkConfigurator,
+	env installer.EnvConfig,
+	external bool,
+	fetchUsersAddr string,
+	reconciler tasks.Reconciler,
+) (*Server, error) {
+	tmplts, err := parseTemplates(templates)
+	if err != nil {
+		return nil, err
+	}
+	apps, err := fs.Sub(appTmplsFS, "app-tmpl")
+	if err != nil {
+		return nil, err
+	}
+	appTmpls, err := NewAppTmplStoreFS(apps)
+	if err != nil {
+		return nil, err
+	}
+	s := &Server{
+		&sync.Mutex{},
+		st,
+		nf,
+		ug,
+		port,
+		apiPort,
+		self,
+		selfPublic,
+		repoPublicAddr,
+		sshKey,
+		gitRepoPublicKey,
+		client,
+		namespace,
+		envAppManagerAddr,
+		env,
+		nsc,
+		jc,
+		vpnKeyGen,
+		cnc,
+		map[string]map[string]struct{}{},
+		map[string]appConfig{},
+		tmplts,
+		appTmpls,
+		external,
+		fetchUsersAddr,
+		reconciler,
+		map[string]string{},
+	}
+	config, err := client.GetRepo(ConfigRepoName)
+	if err != nil {
+		return nil, err
+	}
+	r, err := config.Reader(appConfigsFile)
+	if err == nil {
+		defer r.Close()
+		if err := json.NewDecoder(r).Decode(&s.appConfigs); err != nil {
+			return nil, err
+		}
+	} else if !errors.Is(err, fs.ErrNotExist) {
+		return nil, err
+	}
+	return s, nil
+}
+
+func (s *Server) getAppConfig(app, branch string) appConfig {
+	return s.appConfigs[fmt.Sprintf("%s-%s", app, branch)]
+}
+
+func (s *Server) setAppConfig(app, branch string, cfg appConfig) {
+	s.appConfigs[fmt.Sprintf("%s-%s", app, branch)] = cfg
+}
+
+func (s *Server) 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()
+		r.Use(s.mwAuth)
+		r.HandleFunc(schemasPath+"app.schema.json", s.handleSchema).Methods(http.MethodGet)
+		r.PathPrefix(staticPath).Handler(server.NewCachingHandler(http.FileServer(http.FS(staticAssets))))
+		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
+		r.HandleFunc(apiPublicData, s.handleAPIPublicData)
+		r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
+		r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
+		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}/branch/{branch}/delete", s.handleBranchDelete).Methods(http.MethodPost)
+		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}/delete", s.handleAppDelete).Methods(http.MethodPost)
+		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
+		r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
+		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
+	}()
+	go func() {
+		r := mux.NewRouter()
+		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)
+		}
+		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
+	}()
+	if !s.external {
+		go func() {
+			s.syncUsers()
+			for {
+				delay := time.Duration(rand.Intn(60)+60) * time.Second
+				time.Sleep(delay)
+				s.syncUsers()
+			}
+		}()
+	}
+	return <-e
+}
+
+type UserGetter interface {
+	Get(r *http.Request) string
+	Encode(w http.ResponseWriter, user string) error
+}
+
+type externalUserGetter struct {
+	sc *securecookie.SecureCookie
+}
+
+func NewExternalUserGetter() UserGetter {
+	return &externalUserGetter{securecookie.New(
+		securecookie.GenerateRandomKey(64),
+		securecookie.GenerateRandomKey(32),
+	)}
+}
+
+func (ug *externalUserGetter) Get(r *http.Request) string {
+	cookie, err := r.Cookie(sessionCookie)
+	if err != nil {
+		return ""
+	}
+	var user string
+	if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
+		return ""
+	}
+	return user
+}
+
+func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
+	if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
+		cookie := &http.Cookie{
+			Name:     sessionCookie,
+			Value:    encoded,
+			Path:     "/",
+			Secure:   true,
+			HttpOnly: true,
+		}
+		http.SetCookie(w, cookie)
+		return nil
+	} else {
+		return err
+	}
+}
+
+type internalUserGetter struct{}
+
+func NewInternalUserGetter() UserGetter {
+	return internalUserGetter{}
+}
+
+func (ug internalUserGetter) Get(r *http.Request) string {
+	return r.Header.Get("X-Forwarded-User")
+}
+
+func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
+	return nil
+}
+
+func (s *Server) mwAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasSuffix(r.URL.Path, loginPath) ||
+			strings.HasPrefix(r.URL.Path, logoutPath) ||
+			strings.HasPrefix(r.URL.Path, staticPath) ||
+			strings.HasPrefix(r.URL.Path, schemasPath) ||
+			strings.HasPrefix(r.URL.Path, apiPublicData) ||
+			strings.HasPrefix(r.URL.Path, apiCreateApp) {
+			next.ServeHTTP(w, r)
+			return
+		}
+		user := s.ug.Get(r)
+		if user == "" {
+			vars := mux.Vars(r)
+			appName, ok := vars["app-name"]
+			if !ok || appName == "" {
+				http.Error(w, "missing app-name", http.StatusBadRequest)
+				return
+			}
+			http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
+			return
+		}
+		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
+	})
+}
+
+func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "application/schema+json")
+	w.Write(dodoAppJsonSchema)
+}
+
+func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
+	// TODO(gio): move to UserGetter
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookie,
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   true,
+	})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) handleLoginForm(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
+	}
+	fmt.Fprint(w, `
+<!DOCTYPE html>
+<html lang='en'>
+	<head>
+		<title>dodo: app - login</title>
+		<meta charset='utf-8'>
+	</head>
+	<body>
+        <form action="" method="POST">
+          <input type="password" placeholder="Password" name="password" required />
+          <button type="submit">Login</button>
+        </form>
+	</body>
+</html>
+`)
+}
+
+func (s *Server) handleLogin(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
+	}
+	password := r.FormValue("password")
+	if password == "" {
+		http.Error(w, "missing password", http.StatusBadRequest)
+		return
+	}
+	user, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	hashed, err := s.st.GetUserPassword(user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
+		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+		return
+	}
+	if err := s.ug.Encode(w, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
+type navItem struct {
+	Name    string
+	Address string
+}
+
+type statusData struct {
+	Navigation []navItem
+	Apps       []string
+	Networks   []installer.Network
+	Types      []string
+}
+
+func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
+	user := r.Context().Value(userCtx)
+	if user == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	apps, err := s.st.GetUserApps(user.(string))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	networks, err := s.getNetworks(user.(string))
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var types []string
+	for _, t := range s.appTmpls.Types() {
+		types = append(types, strings.Replace(t, "-", ":", 1))
+	}
+	n := []navItem{navItem{"Home", "/"}}
+	data := statusData{n, apps, networks, types}
+	if err := s.tmplts.index.Execute(w, data); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type appStatusData struct {
+	Navigation      []navItem
+	Name            string
+	Branch          string
+	GitCloneCommand string
+	Commits         []CommitMeta
+	LastCommit      resourceData
+	Branches        []string
+}
+
+func (s *Server) 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
+	}
+	branch, ok := vars["branch"]
+	if !ok || branch == "" {
+		branch = "master"
+	}
+	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
+	}
+	owner, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if owner != user {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	commits, err := s.st.GetCommitHistory(appName, branch)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var lastCommitResources resourceData
+	if len(commits) > 0 {
+		lastCommit, err := s.st.GetCommit(commits[len(commits)-1].Hash)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		r, err := extractResourceData(lastCommit.Resources.Helm)
+		if err != nil {
+			http.Error(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		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", "/"},
+			navItem{appName, "/" + appName},
+		},
+		Name:            appName,
+		Branch:          branch,
+		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)
+		return
+	}
+}
+
+type appEnv struct {
+	Profile string `json:"envProfile"`
+}
+
+func (s *Server) 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
+}
+
+type postgresql struct {
+	Name    string
+	Version string
+	Volume  string
+}
+
+type ingress struct {
+	Host string
+}
+
+type vm struct {
+	Name     string
+	User     string
+	CPUCores int
+	Memory   string
+}
+
+type resourceData struct {
+	Volume         []volume
+	PostgreSQL     []postgresql
+	Ingress        []ingress
+	VirtualMachine []vm
+}
+
+type commitStatusData struct {
+	Navigation []navItem
+	AppName    string
+	Commit     Commit
+	Resources  resourceData
+}
+
+func (s *Server) handleAppCommit(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
+	}
+	hash, ok := vars["hash"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	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
+	}
+	owner, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if owner != user {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	commit, err := s.st.GetCommit(hash)
+	if err != nil {
+		// TODO(gio): not-found ?
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var res strings.Builder
+	if err := json.NewEncoder(&res).Encode(commit.Resources.Helm); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	resData, err := extractResourceData(commit.Resources.Helm)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	data := commitStatusData{
+		Navigation: []navItem{
+			navItem{"Home", "/"},
+			navItem{appName, "/" + appName},
+			navItem{hash, "/" + appName + "/" + hash},
+		},
+		AppName:   appName,
+		Commit:    commit,
+		Resources: resData,
+	}
+	if err := s.tmplts.commitStatus.Execute(w, data); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type logData struct {
+	Navigation []navItem
+	AppName    string
+	Logs       template.HTML
+}
+
+func (s *Server) handleAppLogs(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
+	}
+	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
+	}
+	owner, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if owner != user {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	data := logData{
+		Navigation: []navItem{
+			navItem{"Home", "/"},
+			navItem{appName, "/" + appName},
+			navItem{"Logs", "/" + appName + "/logs"},
+		},
+		AppName: appName,
+		Logs:    template.HTML(strings.ReplaceAll(s.logs[appName], "\n", "<br/>")),
+	}
+	if err := s.tmplts.logs.Execute(w, data); err != nil {
+		fmt.Println(err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+type apiUpdateReq struct {
+	Ref        string `json:"ref"`
+	Repository struct {
+		Name string `json:"name"`
+	} `json:"repository"`
+	After   string `json:"after"`
+	Commits []struct {
+		Id      string `json:"id"`
+		Message string `json:"message"`
+	} `json:"commits"`
+}
+
+func (s *Server) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("update")
+	var req apiUpdateReq
+	var contents strings.Builder
+	io.Copy(&contents, r.Body)
+	c := contents.String()
+	fmt.Println(c)
+	if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	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
+	go func() {
+		owner, err := s.st.GetAppOwner(req.Repository.Name)
+		if err != nil {
+			return
+		}
+		networks, err := s.getNetworks(owner)
+		if err != nil {
+			return
+		}
+		// TODO(gio): get only available ones by owner
+		clusters, err := s.getClusters()
+		if err != nil {
+			return
+		}
+		apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+		instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
+		if err != nil {
+			return
+		}
+		found := false
+		commitMsg := ""
+		for _, c := range req.Commits {
+			if c.Id == req.After {
+				found = true
+				commitMsg = c.Message
+				break
+			}
+		}
+		if !found {
+			fmt.Printf("Error: could not find commit message")
+			return
+		}
+		s.l.Lock()
+		defer s.l.Unlock()
+		resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, clusters, owner)
+		if err = s.createCommit(req.Repository.Name, branch, req.After, commitMsg, err, resources); err != nil {
+			fmt.Printf("Error: %s\n", err.Error())
+			return
+		}
+		for addr, _ := range s.workers[req.Repository.Name] {
+			go func() {
+				// TODO(gio): make port configurable
+				http.Get(fmt.Sprintf("http://%s/update", addr))
+			}()
+		}
+	}()
+}
+
+type apiRegisterWorkerReq struct {
+	Address string `json:"address"`
+	Logs    string `json:"logs"`
+}
+
+func (s *Server) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
+	// TODO(gio): lock
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	var req apiRegisterWorkerReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if _, ok := s.workers[appName]; !ok {
+		s.workers[appName] = map[string]struct{}{}
+	}
+	s.workers[appName][req.Address] = struct{}{}
+	s.logs[appName] = req.Logs
+}
+
+func (s *Server) handleCreateApp(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
+	}
+	network := r.FormValue("network")
+	if network == "" {
+		http.Error(w, "missing network", http.StatusBadRequest)
+		return
+	}
+	subdomain := r.FormValue("subdomain")
+	if subdomain == "" {
+		http.Error(w, "missing subdomain", http.StatusBadRequest)
+		return
+	}
+	appType := r.FormValue("type")
+	if appType == "" {
+		http.Error(w, "missing type", http.StatusBadRequest)
+		return
+	}
+	appName := r.FormValue("name")
+	var err error
+	if appName == "" {
+		g := installer.NewFixedLengthRandomNameGenerator(3)
+		appName, err = g.Generate()
+	}
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if ok, err := s.client.UserExists(user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	} else if !ok {
+		http.Error(w, "user sync has not finished, please try again in few minutes", http.StatusFailedDependency)
+		return
+	}
+	if err := s.st.CreateUser(user, nil, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
+func (s *Server) 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 branch", 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)
+}
+
+func (s *Server) handleBranchDelete(w http.ResponseWriter, r *http.Request) {
+	u := r.Context().Value(userCtx)
+	if u == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	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 == "" {
+		http.Error(w, "missing branch", http.StatusBadRequest)
+		return
+	}
+	if err := s.deleteBranch(appName, branch); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
+func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
+	u := r.Context().Value(userCtx)
+	if u == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	if err := s.deleteApp(appName); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+type apiCreateAppReq struct {
+	AppType        string `json:"type"`
+	AdminPublicKey string `json:"adminPublicKey"`
+	Network        string `json:"network"`
+	Subdomain      string `json:"subdomain"`
+}
+
+type apiCreateAppResp struct {
+	AppName  string `json:"appName"`
+	Password string `json:"password"`
+}
+
+func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	var req apiCreateAppReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	g := installer.NewFixedLengthRandomNameGenerator(3)
+	appName, err := g.Generate()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	user, err := s.client.FindUser(req.AdminPublicKey)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if user != "" {
+		http.Error(w, "public key already registered", http.StatusBadRequest)
+		return
+	}
+	user = appName
+	if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	password := generatePassword()
+	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateUser(user, hashed, req.Network); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	resp := apiCreateAppResp{
+		AppName:  appName,
+		Password: password,
+	}
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) isNetworkUseAllowed(network string) bool {
+	if !s.external {
+		return true
+	}
+	for _, cfg := range s.appConfigs {
+		if strings.ToLower(cfg.Network) == network {
+			return false
+		}
+	}
+	return true
+}
+
+func (s *Server) createApp(user, appName, appType, network, subdomain string) error {
+	s.l.Lock()
+	defer s.l.Unlock()
+	fmt.Printf("Creating app: %s\n", appName)
+	network = strings.ToLower(network)
+	if !s.isNetworkUseAllowed(network) {
+		return fmt.Errorf("network already used: %s", network)
+	}
+	if ok, err := s.client.RepoExists(appName); err != nil {
+		return err
+	} else if ok {
+		return nil
+	}
+	networks, err := s.getNetworks(user)
+	if err != nil {
+		return err
+	}
+	n, ok := installer.NetworkMap(networks)[network]
+	if !ok {
+		return fmt.Errorf("network not found: %s\n", network)
+	}
+	if err := s.client.AddRepository(appName); err != nil {
+		return err
+	}
+	appRepo, err := s.client.GetRepo(appName)
+	if err != nil {
+		return err
+	}
+	files, err := s.renderAppConfigTemplate(appType, n, subdomain)
+	if err != nil {
+		return err
+	}
+	return s.createAppForBranch(appRepo, appName, "master", user, network, files)
+}
+
+func (s *Server) 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.json")
+	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.json": branchCfg})
+}
+
+func (s *Server) deleteBranch(appName string, branch string) error {
+	appBranch := fmt.Sprintf("dodo_%s", branch)
+	hf := installer.NewGitHelmFetcher()
+	if err := func() error {
+		repo, err := s.client.GetRepoBranch(appName, appBranch)
+		if err != nil {
+			return err
+		}
+		m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
+		if err != nil {
+			return err
+		}
+		return m.Remove("app")
+	}(); err != nil {
+		return err
+	}
+	configRepo, err := s.client.GetRepo(ConfigRepoName)
+	if err != nil {
+		return err
+	}
+	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
+	if err != nil {
+		return err
+	}
+	appPath := fmt.Sprintf("%s/%s", appName, branch)
+	if err := m.Remove(appPath); err != nil {
+		return err
+	}
+	if err := s.client.DeleteRepoBranch(appName, appBranch); err != nil {
+		return err
+	}
+	if branch != "master" {
+		if err := s.client.DeleteRepoBranch(appName, branch); err != nil {
+			return err
+		}
+	}
+	return s.st.DeleteBranch(appName, branch)
+}
+
+func (s *Server) deleteApp(appName string) error {
+	configRepo, err := s.client.GetRepo(ConfigRepoName)
+	if err != nil {
+		return err
+	}
+	branches, err := configRepo.ListDir(fmt.Sprintf("/%s", appName))
+	if err != nil {
+		return err
+	}
+	for _, b := range branches {
+		if !b.IsDir() || strings.HasPrefix(b.Name(), "dodo_") {
+			continue
+		}
+		if err := s.deleteBranch(appName, b.Name()); err != nil {
+			return err
+		}
+	}
+	if err := s.client.DeleteRepo(appName); err != nil {
+		return err
+	}
+	return s.st.DeleteApp(appName)
+}
+
+func (s *Server) 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
+	}
+	// TODO(gio): get only available ones by owner
+	clusters, err := s.getClusters()
+	if err != nil {
+		return err
+	}
+	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
+	if err != nil {
+		return err
+	}
+	instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
+	if err != nil {
+		return err
+	}
+	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+	suffix, err := suffixGen.Generate()
+	if err != nil {
+		return err
+	}
+	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
+	s.setAppConfig(appName, branch, appConfig{namespace, network})
+	resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, clusters, user)
+	if err != nil {
+		fmt.Printf("Error: %s\n", err.Error())
+		return err
+	}
+	if err = s.createCommit(appName, branch, commit, initCommitMsg, err, resources); err != nil {
+		fmt.Printf("Error: %s\n", err.Error())
+		return err
+	}
+	configRepo, err := s.client.GetRepo(ConfigRepoName)
+	if err != nil {
+		return err
+	}
+	hf := installer.NewGitHelmFetcher()
+	m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
+	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 {
+			return "", err
+		}
+		defer w.Close()
+		if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
+			return "", err
+		}
+		if _, err := m.Install(
+			instanceApp,
+			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),
+			installer.WithNoNetworks(),
+			installer.WithNoPublish(),
+			installer.WithNoLock(),
+		); err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("Installed app: %s", appName), nil
+	})
+	if err != nil {
+		return err
+	}
+	return s.initAppACLs(m, appPath, appName, branch, user)
+}
+
+func (s *Server) initAppACLs(m *installer.AppManager, path, appName, branch, user string) error {
+	cfg, err := m.GetInstance(path)
+	if err != nil {
+		return err
+	}
+	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 ok, err := s.client.UserExists("fluxcd"); err != nil {
+		return err
+	} else if ok {
+		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
+			return err
+		}
+	} else {
+		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
+			return err
+		}
+	}
+	if branch != "master" {
+		return nil
+	}
+	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
+		return err
+	}
+	if err := s.client.AddReadWriteCollaborator(appName, user); 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 {
+		return err
+	}
+	if !s.external {
+		go func() {
+			users, err := s.client.GetAllUsers()
+			if err != nil {
+				fmt.Println(err)
+				return
+			}
+			for _, user := range users {
+				// TODO(gio): fluxcd should have only read access
+				if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+					fmt.Println(err)
+				}
+			}
+		}()
+	}
+	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
+	go s.reconciler.Reconcile(ctx, s.namespace, "config")
+	return nil
+}
+
+type apiAddAdminKeyReq struct {
+	User      string `json:"user"`
+	PublicKey string `json:"publicKey"`
+}
+
+func (s *Server) 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 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
+	}
+}
+
+type dodoAppRendered struct {
+	App struct {
+		Ingress struct {
+			Network   string `json:"network"`
+			Subdomain string `json:"subdomain"`
+		} `json:"ingress"`
+	} `json:"app"`
+	Input struct {
+		AppId string `json:"appId"`
+	} `json:"input"`
+}
+
+// TODO(gio): must not require owner, now we need it to bootstrap dev vm.
+func (s *Server) updateDodoApp(
+	appStatus installer.EnvApp,
+	name string,
+	branch string,
+	namespace string,
+	networks []installer.Network,
+	clusters []installer.Cluster,
+	owner string,
+) (installer.ReleaseResources, error) {
+	repo, err := s.client.GetRepoBranch(name, branch)
+	if err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	hf := installer.NewGitHelmFetcher()
+	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
+	if err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	appCfg, err := soft.ReadFile(repo, "app.json")
+	if err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	app, err := installer.NewDodoApp(appCfg)
+	if err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
+	var ret installer.ReleaseResources
+	if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
+		ret, err = m.Install(
+			app,
+			"app",
+			"/.dodo/app",
+			namespace,
+			map[string]any{
+				"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(),
+			installer.WithConfig(&s.env),
+			installer.WithNetworks(networks),
+			installer.WithClusters(clusters),
+			installer.WithLocalChartGenerator(lg),
+			installer.WithNoLock(),
+		)
+		if err != nil {
+			return "", err
+		}
+		var rendered dodoAppRendered
+		if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
+			return "", nil
+		}
+		if _, err := m.Install(
+			appStatus,
+			"status",
+			"/.dodo/status",
+			s.namespace,
+			map[string]any{
+				"appName":      rendered.Input.AppId,
+				"network":      rendered.App.Ingress.Network,
+				"appSubdomain": rendered.App.Ingress.Subdomain,
+			},
+			installer.WithNoPull(),
+			installer.WithNoPublish(),
+			installer.WithConfig(&s.env),
+			installer.WithNetworks(networks),
+			installer.WithClusters(clusters),
+			installer.WithLocalChartGenerator(lg),
+			installer.WithNoLock(),
+		); err != nil {
+			return "", err
+		}
+		return "install app", nil
+	},
+		soft.WithCommitToBranch(fmt.Sprintf("dodo_%s", branch)),
+		soft.WithForce(),
+	); err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
+	go s.reconciler.Reconcile(ctx, namespace, "app")
+	return ret, nil
+}
+
+func (s *Server) 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 nil, err
+	}
+	return appTmpl.Render(fmt.Sprintf("%s/statit/schemas/dodo_app.jsonschema", s.selfPublic), network, subdomain)
+}
+
+func generatePassword() string {
+	return "foo"
+}
+
+func (s *Server) getNetworks(user string) ([]installer.Network, error) {
+	addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
+	resp, err := http.Get(addr)
+	if err != nil {
+		return nil, err
+	}
+	networks := []installer.Network{}
+	if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
+		return nil, err
+	}
+	return s.nf.Filter(user, networks)
+}
+
+func (s *Server) getClusters() ([]installer.Cluster, error) {
+	addr := fmt.Sprintf("%s/api/clusters", s.envAppManagerAddr)
+	resp, err := http.Get(addr)
+	if err != nil {
+		return nil, err
+	}
+	clusters := []installer.Cluster{}
+	if json.NewDecoder(resp.Body).Decode(&clusters); err != nil {
+		return nil, err
+	}
+	fmt.Printf("CLUSTERS %+v\n", clusters)
+	return clusters, nil
+}
+
+type publicNetworkData struct {
+	Name   string `json:"name"`
+	Domain string `json:"domain"`
+}
+
+type publicData struct {
+	Networks []publicNetworkData `json:"networks"`
+	Types    []string            `json:"types"`
+}
+
+func (s *Server) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Access-Control-Allow-Origin", "*")
+	s.l.Lock()
+	defer s.l.Unlock()
+	networks, err := s.getNetworks("")
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	var ret publicData
+	for _, n := range networks {
+		if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
+			ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
+		}
+	}
+	for _, t := range s.appTmpls.Types() {
+		ret.Types = append(ret.Types, strings.Replace(t, "-", ":", 1))
+	}
+	if err := json.NewEncoder(w).Encode(ret); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *Server) 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, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
+			fmt.Printf("Error: %s\n", err.Error())
+			return err
+		}
+		return err
+	}
+	var resB bytes.Buffer
+	if err := json.NewEncoder(&resB).Encode(resources); 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, branch, hash, message, "OK", "", resB.Bytes()); err != nil {
+		fmt.Printf("Error: %s\n", err.Error())
+		return err
+	}
+	return nil
+}
+
+func pickNetwork(networks []installer.Network, network string) []installer.Network {
+	for _, n := range networks {
+		if n.Name == network {
+			return []installer.Network{n}
+		}
+	}
+	return []installer.Network{}
+}
+
+type NetworkFilter interface {
+	Filter(user string, networks []installer.Network) ([]installer.Network, error)
+}
+
+type noNetworkFilter struct{}
+
+func NewNoNetworkFilter() NetworkFilter {
+	return noNetworkFilter{}
+}
+
+func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	return networks, nil
+}
+
+type filterByOwner struct {
+	st Store
+}
+
+func NewNetworkFilterByOwner(st Store) NetworkFilter {
+	return &filterByOwner{st}
+}
+
+func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	if user == "" {
+		return networks, nil
+	}
+	network, err := f.st.GetUserNetwork(user)
+	if err != nil {
+		return nil, err
+	}
+	ret := []installer.Network{}
+	for _, n := range networks {
+		if n.Name == network {
+			ret = append(ret, n)
+		}
+	}
+	return ret, nil
+}
+
+type allowListFilter struct {
+	allowed []string
+}
+
+func NewAllowListFilter(allowed []string) NetworkFilter {
+	return &allowListFilter{allowed}
+}
+
+func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	ret := []installer.Network{}
+	for _, n := range networks {
+		if slices.Contains(f.allowed, n.Name) {
+			ret = append(ret, n)
+		}
+	}
+	return ret, nil
+}
+
+type combinedNetworkFilter struct {
+	filters []NetworkFilter
+}
+
+func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
+	return &combinedNetworkFilter{filters}
+}
+
+func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
+	ret := networks
+	var err error
+	for _, f := range f.filters {
+		ret, err = f.Filter(user, ret)
+		if err != nil {
+			return nil, err
+		}
+	}
+	return ret, nil
+}
+
+type user struct {
+	Username      string   `json:"username"`
+	Email         string   `json:"email"`
+	SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
+}
+
+func (s *Server) handleAPISyncUsers(_ http.ResponseWriter, _ *http.Request) {
+	go s.syncUsers()
+}
+
+func (s *Server) syncUsers() {
+	if s.external {
+		panic("MUST NOT REACH!")
+	}
+	resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/api/sync-users", s.fetchUsersAddr, s.self))
+	if err != nil {
+		return
+	}
+	users := []user{}
+	if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
+		fmt.Println(err)
+		return
+	}
+	validUsernames := make(map[string]user)
+	for _, u := range users {
+		validUsernames[u.Username] = u
+	}
+	allClientUsers, err := s.client.GetAllUsers()
+	if err != nil {
+		fmt.Println(err)
+		return
+	}
+	keyToUser := make(map[string]string)
+	for _, clientUser := range allClientUsers {
+		if clientUser == "admin" || clientUser == "fluxcd" {
+			continue
+		}
+		userData, ok := validUsernames[clientUser]
+		if !ok {
+			if err := s.client.RemoveUser(clientUser); err != nil {
+				fmt.Println(err)
+				return
+			}
+		} else {
+			existingKeys, err := s.client.GetUserPublicKeys(clientUser)
+			if err != nil {
+				fmt.Println(err)
+				return
+			}
+			for _, existingKey := range existingKeys {
+				cleanKey := soft.CleanKey(existingKey)
+				keyOk := slices.ContainsFunc(userData.SSHPublicKeys, func(key string) bool {
+					return cleanKey == soft.CleanKey(key)
+				})
+				if !keyOk {
+					if err := s.client.RemovePublicKey(clientUser, existingKey); err != nil {
+						fmt.Println(err)
+					}
+				} else {
+					keyToUser[cleanKey] = clientUser
+				}
+			}
+		}
+	}
+	for _, u := range users {
+		if err := s.st.CreateUser(u.Username, nil, ""); err != nil && !errors.Is(err, ErrorAlreadyExists) {
+			fmt.Println(err)
+			return
+		}
+		if len(u.SSHPublicKeys) == 0 {
+			continue
+		}
+		ok, err := s.client.UserExists(u.Username)
+		if err != nil {
+			fmt.Println(err)
+			return
+		}
+		if !ok {
+			if err := s.client.AddUser(u.Username, u.SSHPublicKeys[0]); err != nil {
+				fmt.Println(err)
+				return
+			}
+		} else {
+			for _, key := range u.SSHPublicKeys {
+				cleanKey := soft.CleanKey(key)
+				if user, ok := keyToUser[cleanKey]; ok {
+					if u.Username != user {
+						panic("MUST NOT REACH! IMPOSSIBLE KEY USER RECORD")
+					}
+					continue
+				}
+				if err := s.client.AddPublicKey(u.Username, cleanKey); err != nil {
+					fmt.Println(err)
+					return
+				}
+			}
+		}
+	}
+	repos, err := s.client.GetAllRepos()
+	if err != nil {
+		return
+	}
+	for _, r := range repos {
+		if r == ConfigRepoName {
+			continue
+		}
+		for _, u := range users {
+			if err := s.client.AddReadWriteCollaborator(r, u.Username); err != nil {
+				fmt.Println(err)
+				continue
+			}
+		}
+	}
+}
+
+func extractResourceData(resources []installer.Resource) (resourceData, error) {
+	var ret resourceData
+	for _, r := range resources {
+		t, ok := r.Annotations["dodo.cloud/resource-type"]
+		if !ok {
+			continue
+		}
+		switch t {
+		case "volume":
+			name, ok := r.Annotations["dodo.cloud/resource.volume.name"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no name")
+			}
+			size, ok := r.Annotations["dodo.cloud/resource.volume.size"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no size")
+			}
+			ret.Volume = append(ret.Volume, volume{name, size})
+		case "postgresql":
+			name, ok := r.Annotations["dodo.cloud/resource.postgresql.name"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no name")
+			}
+			version, ok := r.Annotations["dodo.cloud/resource.postgresql.version"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no version")
+			}
+			volume, ok := r.Annotations["dodo.cloud/resource.postgresql.volume"]
+			if !ok {
+				return resourceData{}, fmt.Errorf("no volume")
+			}
+			ret.PostgreSQL = append(ret.PostgreSQL, postgresql{name, version, volume})
+		case "ingress":
+			host, ok := r.Annotations["dodo.cloud/resource.ingress.host"]
+			if !ok {
+				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
+}