Dodo APP: infrastructure to deploy app by pusing to Git repo

Change-Id: I4034c6893255581b014ddb207c844261cb34202b
diff --git a/core/installer/Makefile b/core/installer/Makefile
index 800a8eb..ce9c184 100644
--- a/core/installer/Makefile
+++ b/core/installer/Makefile
@@ -37,6 +37,9 @@
 appmanager:
 	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner appmanager --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/config --port=9090 # --app-repo-addr=http://localhost:8080
 
+dodo-app:
+	./pcloud --kubeconfig=../../priv/kubeconfig-hetzner dodo-app --ssh-key=/Users/lekva/.ssh/id_ed25519 --repo-addr=ssh://localhost:2222/test
+
 welc:
 	./pcloud --kubeconfig=../../priv/kubeconfig welcome --ssh-key=/Users/lekva/.ssh/id_rsa --repo-addr=ssh://192.168.0.210/config --port=9090
 
diff --git a/core/installer/app.go b/core/installer/app.go
index 1626a1a..2a16c1e 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -2,6 +2,7 @@
 
 import (
 	"bytes"
+	_ "embed"
 	"encoding/json"
 	"fmt"
 	template "html/template"
@@ -16,8 +17,15 @@
 	cueyaml "cuelang.org/go/encoding/yaml"
 )
 
+//go:embed pcloud_app.cue
+var DodoAppCue []byte
+
 // TODO(gio): import
 const cueEnvAppGlobal = `
+import (
+    "net"
+)
+
 #Global: {
 	id: string | *""
 	pcloudEnvName: string | *""
@@ -31,20 +39,13 @@
 	network: #EnvNetwork
 }
 
-networks: {
-	public: #Network & {
-		name: "Public"
-		ingressClass: "\(global.pcloudEnvName)-ingress-public"
-		certificateIssuer: "\(global.id)-public"
-		domain: global.domain
-		allocatePortAddr: "http://port-allocator.\(global.pcloudEnvName)-ingress-public.svc.cluster.local/api/allocate"
-	}
-	private: #Network & {
-		name: "Private"
-		ingressClass: "\(global.id)-ingress-private"
-		domain: global.privateDomain
-		allocatePortAddr: "http://port-allocator.\(global.id)-ingress-private.svc.cluster.local/api/allocate"
-	}
+#EnvNetwork: {
+	dns: net.IPv4
+	dnsInClusterIP: net.IPv4
+	ingress: net.IPv4
+	headscale: net.IPv4
+	servicesFrom: net.IPv4
+	servicesTo: net.IPv4
 }
 
 // TODO(gio): remove
@@ -164,10 +165,6 @@
 `
 
 const cueBaseConfig = `
-import (
-  "net"
-)
-
 name: string | *""
 description: string | *""
 readme: string | *""
@@ -187,9 +184,11 @@
 #AppType: "infra" | "env"
 appType: #AppType | *"env"
 
-#Auth: {
-  enabled: bool | *false // TODO(gio): enabled by default?
-  groups: string | *"" // TODO(gio): []string
+#Release: {
+	appInstanceId: string
+	namespace: string
+	repoAddr: string
+	appDir: string
 }
 
 #Network: {
@@ -200,6 +199,11 @@
 	allocatePortAddr: string
 }
 
+#Auth: {
+  enabled: bool | *false // TODO(gio): enabled by default?
+  groups: string | *"" // TODO(gio): []string
+}
+
 #Image: {
 	registry: string | *"docker.io"
 	repository: string
@@ -222,22 +226,6 @@
 	namespace: string // TODO(gio): default global.id
 }
 
-#EnvNetwork: {
-	dns: net.IPv4
-	dnsInClusterIP: net.IPv4
-	ingress: net.IPv4
-	headscale: net.IPv4
-	servicesFrom: net.IPv4
-	servicesTo: net.IPv4
-}
-
-#Release: {
-	appInstanceId: string
-	namespace: string
-	repoAddr: string
-	appDir: string
-}
-
 #PortForward: {
 	allocator: string
 	protocol: "TCP" | "UDP" | *"TCP"
@@ -302,6 +290,8 @@
 	}
 }
 
+resources: {}
+
 #HelmRelease: {
 	_name: string
 	_chart: #Chart
@@ -349,6 +339,8 @@
 help: [...#HelpDocument] | *[]
 
 url: string | *""
+
+networks: {}
 `
 
 type rendered struct {
@@ -620,17 +612,34 @@
 	if err := res.LookupPath(cue.ParsePath("portForward")).Decode(&ret.Ports); err != nil {
 		return rendered{}, err
 	}
-	output := res.LookupPath(cue.ParsePath("output"))
-	i, err := output.Fields()
-	if err != nil {
-		return rendered{}, err
-	}
-	for i.Next() {
-		if contents, err := cueyaml.Encode(i.Value()); err != nil {
+	{
+		output := res.LookupPath(cue.ParsePath("output"))
+		i, err := output.Fields()
+		if err != nil {
 			return rendered{}, err
-		} else {
-			name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
-			ret.Resources[name] = contents
+		}
+		for i.Next() {
+			if contents, err := cueyaml.Encode(i.Value()); err != nil {
+				return rendered{}, err
+			} else {
+				name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+				ret.Resources[name] = contents
+			}
+		}
+	}
+	{
+		resources := res.LookupPath(cue.ParsePath("resources"))
+		i, err := resources.Fields()
+		if err != nil {
+			return rendered{}, err
+		}
+		for i.Next() {
+			if contents, err := cueyaml.Encode(i.Value()); err != nil {
+				return rendered{}, err
+			} else {
+				name := fmt.Sprintf("%s.yaml", cleanName(i.Selector().String()))
+				ret.Resources[name] = contents
+			}
 		}
 	}
 	helpValue := res.LookupPath(cue.ParsePath("help"))
@@ -664,6 +673,15 @@
 	return cueEnvApp{app}, nil
 }
 
+func NewDodoApp(appCfg []byte) (EnvApp, error) {
+	return NewCueEnvApp(CueAppData{
+		"app.cue":        appCfg,
+		"base.cue":       []byte(cueBaseConfig),
+		"pcloud_app.cue": DodoAppCue,
+		"env_app.cue":    []byte(cueEnvAppGlobal),
+	})
+}
+
 func (a cueEnvApp) Type() AppType {
 	return AppTypeEnv
 }
@@ -675,9 +693,10 @@
 		return EnvAppRendered{}, nil
 	}
 	ret, err := a.cueApp.render(map[string]any{
-		"global":  env,
-		"release": release,
-		"input":   derived,
+		"global":   env,
+		"release":  release,
+		"input":    derived,
+		"networks": networkMap(networks),
 	})
 	if err != nil {
 		return EnvAppRendered{}, err
@@ -747,3 +766,11 @@
 	}
 	return strings.Join(tmp, ",")
 }
+
+func networkMap(networks []Network) map[string]Network {
+	ret := make(map[string]Network)
+	for _, n := range networks {
+		ret[strings.ToLower(n.Name)] = n
+	}
+	return ret
+}
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index 44e39e6..ae18ff8 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -178,7 +178,7 @@
 }
 
 // TODO(gio): rename to CommitApp
-func InstallApp(
+func installApp(
 	repo soft.RepoIO,
 	appDir string,
 	name string,
@@ -242,7 +242,11 @@
 }
 
 // TODO(gio): commit instanceId -> appDir mapping as well
-func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any) (ReleaseResources, error) {
+func (m *AppManager) Install(app EnvApp, instanceId string, appDir string, namespace string, values map[string]any, opts ...InstallOption) (ReleaseResources, error) {
+	o := &installOptions{}
+	for _, i := range opts {
+		i(o)
+	}
 	appDir = filepath.Clean(appDir)
 	if err := m.repoIO.Pull(); err != nil {
 		return ReleaseResources{}, err
@@ -250,9 +254,15 @@
 	if err := m.nsCreator.Create(namespace); err != nil {
 		return ReleaseResources{}, err
 	}
-	env, err := m.Config()
-	if err != nil {
-		return ReleaseResources{}, err
+	var env EnvConfig
+	if o.Env != nil {
+		env = *o.Env
+	} else {
+		var err error
+		env, err = m.Config()
+		if err != nil {
+			return ReleaseResources{}, err
+		}
 	}
 	release := Release{
 		AppInstanceId: instanceId,
@@ -264,7 +274,12 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	if _, err := InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data); err != nil {
+	dopts := []soft.DoOption{}
+	if o.Branch != "" {
+		dopts = append(dopts, soft.WithForce())
+		dopts = append(dopts, soft.WithCommitToBranch(o.Branch))
+	}
+	if _, err := installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, dopts...); err != nil {
 		return ReleaseResources{}, err
 	}
 	// TODO(gio): add ingress-nginx to release resources
@@ -278,6 +293,7 @@
 
 type helmRelease struct {
 	Metadata Resource `json:"metadata"`
+	Kind     string   `json:"kind"`
 	Status   struct {
 		Conditions []struct {
 			Type   string `json:"type"`
@@ -293,7 +309,9 @@
 		if err := yaml.Unmarshal(contents, &h); err != nil {
 			panic(err) // TODO(gio): handle
 		}
-		ret = append(ret, h.Metadata)
+		if h.Kind == "HelmRelease" {
+			ret = append(ret, h.Metadata)
+		}
 	}
 	return ret
 }
@@ -322,7 +340,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
 
 func (m *AppManager) Remove(instanceId string) error {
@@ -368,6 +386,25 @@
 	nsCreator NamespaceCreator
 }
 
+type installOptions struct {
+	Env    *EnvConfig
+	Branch string
+}
+
+type InstallOption func(*installOptions)
+
+func WithConfig(env *EnvConfig) InstallOption {
+	return func(o *installOptions) {
+		o.Env = env
+	}
+}
+
+func WithBranch(branch string) InstallOption {
+	return func(o *installOptions) {
+		o.Branch = branch
+	}
+}
+
 func NewInfraAppManager(repoIO soft.RepoIO, nsCreator NamespaceCreator) (*InfraAppManager, error) {
 	return &InfraAppManager{
 		repoIO,
@@ -432,7 +469,7 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
+	return installApp(m.repoIO, appDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data)
 }
 
 func (m *InfraAppManager) Update(app InfraApp, instanceId string, values map[string]any, opts ...soft.DoOption) (ReleaseResources, error) {
@@ -459,5 +496,5 @@
 	if err != nil {
 		return ReleaseResources{}, err
 	}
-	return InstallApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
+	return installApp(m.repoIO, instanceDir, rendered.Name, rendered.Config, rendered.Ports, rendered.Resources, rendered.Data, opts...)
 }
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index b51f766..f35eb97 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -18,6 +18,7 @@
 var valuesTmpls embed.FS
 
 var storeEnvAppConfigs = []string{
+	"values-tmpl/dodo-app.cue",
 	"values-tmpl/url-shortener.cue",
 	"values-tmpl/matrix.cue",
 	"values-tmpl/vaultwarden.cue",
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 9b59d1c..a646425 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -1,6 +1,7 @@
 package installer
 
 import (
+	_ "embed"
 	"net"
 	"testing"
 )
@@ -298,3 +299,19 @@
 		t.Log(string(r))
 	}
 }
+
+//go:embed testapp.cue
+var testAppCue []byte
+
+type appInput struct {
+	RepoAddr string  `json:"repoAddr"`
+	SSHKey   string  `json:"sshKey"`
+	Network  Network `json:"network"`
+}
+
+func TestPCloudApp(t *testing.T) {
+	_, err := NewDodoApp(testAppCue)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
new file mode 100644
index 0000000..d9f9a69
--- /dev/null
+++ b/core/installer/cmd/dodo_app.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+	"os"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
+	"github.com/giolekva/pcloud/core/installer/welcome"
+
+	"github.com/spf13/cobra"
+)
+
+var dodoAppFlags struct {
+	port      int
+	sshKey    string
+	repoAddr  string
+	self      string
+	namespace string
+	envConfig string
+}
+
+func dodoAppCmd() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:  "dodo-app",
+		RunE: dodoAppCmdRun,
+	}
+	cmd.Flags().IntVar(
+		&dodoAppFlags.port,
+		"port",
+		8080,
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.repoAddr,
+		"repo-addr",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.sshKey,
+		"ssh-key",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.self,
+		"self",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.namespace,
+		"namespace",
+		"",
+		"",
+	)
+	cmd.Flags().StringVar(
+		&dodoAppFlags.envConfig,
+		"env-config",
+		"",
+		"",
+	)
+	return cmd
+}
+
+func dodoAppCmdRun(cmd *cobra.Command, args []string) error {
+	envConfig, err := os.Open(dodoAppFlags.envConfig)
+	if err != nil {
+		return err
+	}
+	defer envConfig.Close()
+	var env installer.EnvConfig
+	if err := json.NewDecoder(envConfig).Decode(&env); err != nil {
+		return err
+	}
+	sshKey, err := os.ReadFile(dodoAppFlags.sshKey)
+	if err != nil {
+		return err
+	}
+	softClient, err := soft.NewClient(dodoAppFlags.repoAddr, sshKey, log.Default())
+	if err != nil {
+		return err
+	}
+	if err := softClient.AddRepository("app"); err == nil {
+		repo, err := softClient.GetRepo("app")
+		if err != nil {
+			return err
+		}
+		if err := initRepo(repo); err != nil {
+			return err
+		}
+		if err := welcome.UpdateDodoApp(softClient, dodoAppFlags.namespace, string(sshKey), &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
+		}
+	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
+		return err
+	}
+	s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, env)
+	return s.Start()
+}
+
+const goMod = `module dodo.app
+
+go 1.18
+`
+
+const mainGo = `package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+
+func handler(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintln(w, "Hello from Dodo App!")
+}
+
+func main() {
+	flag.Parse()
+	http.HandleFunc("/", handler)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+`
+
+const appCue = `app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "Private" // or Public
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}
+`
+
+func initRepo(repo soft.RepoIO) error {
+	return repo.Do(func(fs soft.RepoFS) (string, error) {
+		{
+			w, err := fs.Writer("go.mod")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, goMod)
+		}
+		{
+			w, err := fs.Writer("main.go")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprintf(w, "%s", mainGo)
+		}
+		{
+			w, err := fs.Writer("app.cue")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, appCue)
+		}
+		return "go web app template", nil
+	})
+}
diff --git a/core/installer/cmd/main.go b/core/installer/cmd/main.go
index 5b05381..568efae 100644
--- a/core/installer/cmd/main.go
+++ b/core/installer/cmd/main.go
@@ -28,6 +28,7 @@
 	rootCmd.AddCommand(welcomeCmd())
 	rootCmd.AddCommand(rewriteCmd())
 	rootCmd.AddCommand(launcherCmd())
+	rootCmd.AddCommand(dodoAppCmd())
 }
 
 func main() {
diff --git a/core/installer/kube.go b/core/installer/kube.go
index a8ed275..c8251ff 100644
--- a/core/installer/kube.go
+++ b/core/installer/kube.go
@@ -31,6 +31,16 @@
 	Fetch(addr string) (string, error)
 }
 
+type noOpNamespaceCreator struct{}
+
+func (n *noOpNamespaceCreator) Create(name string) error {
+	return nil
+}
+
+func NewNoOpNamespaceCreator() NamespaceCreator {
+	return &noOpNamespaceCreator{}
+}
+
 type realNamespaceCreator struct {
 	clientset *kubernetes.Clientset
 }
diff --git a/core/installer/pcloud_app.cue b/core/installer/pcloud_app.cue
new file mode 100644
index 0000000..d453747
--- /dev/null
+++ b/core/installer/pcloud_app.cue
@@ -0,0 +1,102 @@
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+)
+
+input: {
+	repoAddr: string
+	sshPrivateKey: string
+}
+
+#AppIngress: {
+	network: string
+	subdomain: string
+	auth: #Auth
+}
+
+_goVer1220: "golang:1.22.0"
+_goVer1200: "golang:1.20.0"
+
+#GoAppTmpl: {
+	type: _goVer1220 | _goVer1200
+	run: string
+	ingress: #AppIngress
+
+	runConfiguration: [{
+		bin: "/usr/local/go/bin/go",
+		args: ["mod", "tidy"]
+	}, {
+		bin: "/usr/local/go/bin/go",
+		args: ["build", "-o", ".app", run]
+	}, {
+		bin: ".app",
+		args: []
+	}]
+}
+
+#GoApp1200: #GoAppTmpl & {
+	type: _goVer1200
+}
+
+#GoApp1220: #GoAppTmpl & {
+	type: _goVer1220
+}
+
+#GoApp: #GoApp1200 | #GoApp1220
+
+app: #GoApp
+
+// output
+
+_app: app
+ingress: {
+	app: {
+		network: networks[strings.ToLower(_app.ingress.network)]
+		subdomain: _app.ingress.subdomain
+		auth: _app.ingress.auth
+		service: {
+			name: "app-app"
+			port: name: "app"
+		}
+	}
+}
+
+images: {
+	app: {
+		repository: "giolekva"
+		name: "app-runner"
+		tag: strings.Replace(_app.type, ":", "-", -1)
+		pullPolicy: "Always"
+	}
+}
+
+charts: {
+	app: {
+		chart: "charts/app-runner"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.id
+		}
+	}
+}
+
+helm: {
+	app: {
+		chart: charts.app
+		values: {
+			image: {
+				repository: images.app.fullName
+				tag: images.app.tag
+				pullPolicy: images.app.pullPolicy
+			}
+			appPort: 8080
+			appDir: "/dodo-app"
+			repoAddr: input.repoAddr
+			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
+			runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
+			manager: "http://dodo-app.\(release.namespace).svc.cluster.local/register-worker"
+		}
+	}
+}
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 269f3d3..08103be 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -19,6 +19,8 @@
 	"github.com/go-git/go-git/v5/storage/memory"
 )
 
+var ErrorAlreadyExists = errors.New("already exists")
+
 type Client interface {
 	Address() string
 	Signer() ssh.Signer
@@ -32,6 +34,7 @@
 	MakeUserAdmin(name string) error
 	AddReadWriteCollaborator(repo, user string) error
 	AddReadOnlyCollaborator(repo, user string) error
+	AddWebhook(repo, url string, opts ...string) error
 }
 
 type realClient struct {
@@ -131,6 +134,9 @@
 
 func (ss *realClient) AddRepository(name string) error {
 	log.Printf("Adding repository %s", name)
+	if err := ss.RunCommand("repo", "info", name); err == nil {
+		return ErrorAlreadyExists
+	}
 	return ss.RunCommand("repo", "create", name)
 }
 
@@ -144,6 +150,14 @@
 	return ss.RunCommand("repo", "collab", "add", repo, user, "read-only")
 }
 
+func (ss *realClient) AddWebhook(repo, url string, opts ...string) error {
+	log.Printf("Adding webhook %s %s", repo, url)
+	return ss.RunCommand(append(
+		[]string{"repo", "webhook", "create", repo, url},
+		opts...,
+	)...)
+}
+
 type Repository struct {
 	*git.Repository
 	Addr RepositoryAddress
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index 6a5097a..b916d24 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -3,10 +3,12 @@
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"io"
 	"io/fs"
 	"io/ioutil"
 	"net"
+	"os"
 	"path/filepath"
 	"sync"
 	"time"
@@ -16,6 +18,7 @@
 	"github.com/go-git/go-billy/v5"
 	"github.com/go-git/go-billy/v5/util"
 	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/config"
 	"github.com/go-git/go-git/v5/plumbing/object"
 	gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
 	"golang.org/x/crypto/ssh"
@@ -33,6 +36,8 @@
 
 type doOptions struct {
 	NoCommit bool
+	Force    bool
+	ToBranch string
 }
 
 type DoOption func(*doOptions)
@@ -43,11 +48,42 @@
 	}
 }
 
+func WithForce() DoOption {
+	return func(o *doOptions) {
+		o.Force = true
+	}
+}
+
+func WithCommitToBranch(branch string) DoOption {
+	return func(o *doOptions) {
+		o.ToBranch = branch
+	}
+}
+
+type pushOptions struct {
+	ToBranch string
+	Force    bool
+}
+
+type PushOption func(*pushOptions)
+
+func WithToBranch(branch string) PushOption {
+	return func(o *pushOptions) {
+		o.ToBranch = branch
+	}
+}
+
+func PushWithForce() PushOption {
+	return func(o *pushOptions) {
+		o.Force = true
+	}
+}
+
 type RepoIO interface {
 	RepoFS
 	FullAddress() string
 	Pull() error
-	CommitAndPush(message string) error
+	CommitAndPush(message string, opts ...PushOption) error
 	Do(op DoFn, opts ...DoOption) error
 }
 
@@ -120,8 +156,9 @@
 		return nil
 	}
 	err = wt.Pull(&git.PullOptions{
-		Auth:  auth(r.signer),
-		Force: true,
+		Auth:     auth(r.signer),
+		Force:    true,
+		Progress: os.Stdout,
 	})
 	if err == nil {
 		return nil
@@ -130,10 +167,15 @@
 		return nil
 	}
 	// TODO(gio): check `remote repository is empty`
+	fmt.Println(err)
 	return nil
 }
 
-func (r *repoIO) CommitAndPush(message string) error {
+func (r *repoIO) CommitAndPush(message string, opts ...PushOption) error {
+	var o pushOptions
+	for _, i := range opts {
+		i(&o)
+	}
 	wt, err := r.repo.Worktree()
 	if err != nil {
 		return err
@@ -149,10 +191,17 @@
 	}); err != nil {
 		return err
 	}
-	return r.repo.Push(&git.PushOptions{
+	gopts := &git.PushOptions{
 		RemoteName: "origin",
 		Auth:       auth(r.signer),
-	})
+	}
+	if o.ToBranch != "" {
+		gopts.RefSpecs = []config.RefSpec{config.RefSpec(fmt.Sprintf("refs/heads/master:refs/heads/%s", o.ToBranch))}
+	}
+	if o.Force {
+		gopts.Force = true
+	}
+	return r.repo.Push(gopts)
 }
 
 func (r *repoIO) Do(op DoFn, opts ...DoOption) error {
@@ -169,7 +218,14 @@
 		return err
 	} else {
 		if !o.NoCommit {
-			return r.CommitAndPush(msg)
+			popts := []PushOption{}
+			if o.Force {
+				popts = append(popts, PushWithForce())
+			}
+			if o.ToBranch != "" {
+				popts = append(popts, WithToBranch(o.ToBranch))
+			}
+			return r.CommitAndPush(msg, popts...)
 		}
 	}
 	return nil
@@ -248,3 +304,12 @@
 	}
 	return ret, nil
 }
+
+func ReadFile(repo RepoFS, path string) ([]byte, error) {
+	r, err := repo.Reader(path)
+	if err != nil {
+		return nil, err
+	}
+	defer r.Close()
+	return io.ReadAll(r)
+}
diff --git a/core/installer/testapp.cue b/core/installer/testapp.cue
new file mode 100644
index 0000000..2573c5c
--- /dev/null
+++ b/core/installer/testapp.cue
@@ -0,0 +1,12 @@
+app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "private"
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}
+
+// do create app --type=go[1.22.0] [--run-cmd=(*default main.go)]
+// do create ingress --subdomain=testapp [--network=public (*default private)] [--auth] [--auth-groups="admin" (*default empty)] TODO(gio): port
diff --git a/core/installer/values-tmpl/dodo-app.cue b/core/installer/values-tmpl/dodo-app.cue
new file mode 100644
index 0000000..5acc6db
--- /dev/null
+++ b/core/installer/values-tmpl/dodo-app.cue
@@ -0,0 +1,161 @@
+import (
+	"encoding/base64"
+	"encoding/json"
+	"strings"
+)
+
+input: {
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
+	sshPort: int @name(SSH Port)
+	adminKey: string @name(Admin SSH Public Key)
+
+	// TODO(gio): auto generate
+	ssKeys: #SSHKey
+	fluxKeys: #SSHKey
+	dAppKeys: #SSHKey
+}
+
+name: "Dodo App"
+namespace: "dodo-app"
+readme: "Deploy app by pushing to Git repository"
+description: "Deploy app by pushing to Git repository"
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 48 48'><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M2.837 27.257c3.363 2.45 11.566 3.523 12.546 1.4s.424-10.94.424-10.94s-1.763 1.192-2.302.147s.44-2.433 2.319-2.858c-1.96.05-2.221-.571-2.205-.93s.67-1.878 3.527-1.241c-1.6-.751-1.943-2.956 2.352-1.568c-1.421-.735-.36-2.825 1.649-.62c-.261-1.323 1.584-1.46 2.694.907M10.648 34.633a19 19 0 0 0-4.246.719'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M15.144 43.402c3.625-2.482 7.685-6.32 7.293-13.406s-1.6-6.368-.523-7.577s6.924-.99 10.712 3.353c.032-2.874-2.504-5.508-2.504-5.508a33 33 0 0 1 5.53.163c2.852.49 2.394 2.514 3.58 2.035s.971-3.472-.39-5.377c-1.666-2.33-3.223-2.83-6.358-2.188s-4.474.458-5.54-.587s-2.026-3.538-4.605-2.515c-2.935 1.164-4.398 2.438-3.767 5.04s2.34 4.558 2.972 6.844'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M22.001 16.552c-.925-.043-1.894.055-1.709 1.328'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.662 16.763c1.72 2.695 3.405 3.643 9.46 3.501'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M32.14 14.966c-1.223.879-2.18 3.781-2.496 5.307M23.1 14.908c.48 1.209 1.23.728 1.315.283a1.552 1.552 0 0 0-1.543-1.883m-.408 17.472c5.328 2.71 11.631.229 16.269-2.123c-1.176 4.572-5.911 5.585-8.916 6.107'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M29.099 37.115c4.376-.294 8.024-1.578 7.833-5.296'/><path fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' d='M20.27 38.702c6.771 3.834 12.505.798 13.786-2.615'/><circle cx='24' cy='24' r='21.5' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round'/></svg>"
+_domain: "\(input.subdomain).\(input.network.domain)"
+
+images: {
+	softserve: {
+		repository: "charmcli"
+		name: "soft-serve"
+		tag: "v0.7.1"
+		pullPolicy: "IfNotPresent"
+	}
+	dodoApp: {
+		repository: "giolekva"
+		name: "pcloud-installer"
+		tag: "latest"
+		pullPolicy: "Always"
+	}
+}
+
+charts: {
+	softserve: {
+		chart: "charts/soft-serve"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
+	dodoApp: {
+		chart: "charts/dodo-app"
+		sourceRef: {
+			kind: "GitRepository"
+			name: "pcloud"
+			namespace: global.pcloudEnvName
+		}
+	}
+}
+
+portForward: [#PortForward & {
+	allocator: input.network.allocatePortAddr
+	sourcePort: input.sshPort
+	// TODO(gio): namespace part must be populated by app manager. Otherwise
+	// third-party app developer might point to a service from different namespace.
+	targetService: "\(release.namespace)/soft-serve"
+	targetPort: 22
+}]
+
+helm: {
+	softserve: {
+		chart: charts.softserve
+		values: {
+			serviceType: "ClusterIP"
+			addressPool: ""
+			reservedIP: ""
+			adminKey: strings.Join([input.adminKey, input.fluxKeys.public, input.dAppKeys.public], "\n")
+			privateKey: input.ssKeys.private
+			publicKey: input.ssKeys.public
+			ingress: {
+				enabled: false
+			}
+			image: {
+				repository: images.softserve.fullName
+				tag: images.softserve.tag
+				pullPolicy: images.softserve.pullPolicy
+			}
+		}
+	}
+	"dodo-app": {
+		chart: charts.dodoApp
+		values: {
+			image: {
+				repository: images.dodoApp.fullName
+				tag: images.dodoApp.tag
+				pullPolicy: images.dodoApp.pullPolicy
+			}
+			repoAddr: "soft-serve.\(release.namespace).svc.cluster.local:22"
+			sshPrivateKey: base64.Encode(null, input.dAppKeys.private)
+			self: "dodo-app.\(release.namespace).svc.cluster.local"
+			namespace: release.namespace
+			envConfig: base64.Encode(null, json.Marshal(global))
+		}
+	}
+}
+
+resources: {
+	"config-kustomization": {
+		apiVersion: "kustomize.toolkit.fluxcd.io/v1"
+		kind: "Kustomization"
+		metadata: {
+			name: "app"
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m"
+			path: "./.dodo"
+			sourceRef: {
+				kind: "GitRepository"
+				name: "app"
+				namespace: release.namespace
+			}
+			prune: true
+		}
+	}
+	"config-secret": {
+		apiVersion: "v1"
+		kind: "Secret"
+		type: "Opaque"
+		metadata: {
+			name: "app"
+			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.ssKeys.public)")
+		}
+	}
+	"config-source": {
+		apiVersion: "source.toolkit.fluxcd.io/v1"
+		kind: "GitRepository"
+		metadata: {
+			name: "app"
+			namespace: release.namespace
+		}
+		spec: {
+			interval: "1m0s"
+			ref: branch: "dodo"
+			secretRef: name: "app"
+			timeout: "60s"
+			url: "ssh://soft-serve.\(release.namespace).svc.cluster.local:22/app"
+		}
+	}
+}
+
+help: [{
+	title: "How to use"
+	contents: """
+	Clone: git clone ssh://\(_domain):\(input.sshPort)/app  
+	"""
+}]
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index b99430a..1bae24f 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -5,7 +5,7 @@
 	adminKey: string @name(Admin SSH Public Key)
 }
 
-_domain: "\(input.subdomain).\(global.privateDomain)"
+_domain: "\(input.subdomain).\(input.network.domain)"
 
 name: "Soft-Serve"
 namespace: "app-soft-serve"
@@ -35,7 +35,7 @@
 }
 
 ingress: {
-	gerrit: {
+	gerrit: { // TODO(gio): rename to soft-serve
 		auth: enabled: false
 		network: input.network
 		subdomain: input.subdomain
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
new file mode 100644
index 0000000..5eb2f58
--- /dev/null
+++ b/core/installer/welcome/dodo_app.go
@@ -0,0 +1,122 @@
+package welcome
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/giolekva/pcloud/core/installer"
+	"github.com/giolekva/pcloud/core/installer/soft"
+)
+
+type DodoAppServer struct {
+	port      int
+	sshKey    string
+	client    soft.Client
+	namespace string
+	env       installer.EnvConfig
+	workers   map[string]struct{}
+}
+
+func NewDodoAppServer(
+	port int,
+	sshKey string,
+	client soft.Client,
+	namespace string,
+	env installer.EnvConfig,
+) *DodoAppServer {
+	return &DodoAppServer{
+		port,
+		sshKey,
+		client,
+		namespace,
+		env,
+		map[string]struct{}{},
+	}
+}
+
+func (s *DodoAppServer) Start() error {
+	http.HandleFunc("/update", s.handleUpdate)
+	http.HandleFunc("/register-worker", s.handleRegisterWorker)
+	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
+}
+
+type updateReq struct {
+	Ref string `json:"ref"`
+}
+
+func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+	fmt.Println("update")
+	var req updateReq
+	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 {
+		fmt.Println(err)
+		return
+	}
+	if req.Ref != "refs/heads/master" {
+		return
+	}
+	go func() {
+		time.Sleep(20 * time.Second)
+		if err := UpdateDodoApp(s.client, s.namespace, s.sshKey, &s.env); err != nil {
+			fmt.Println(err)
+		}
+	}()
+	for addr, _ := range s.workers {
+		go func() {
+			// TODO(gio): make port configurable
+			http.Get(fmt.Sprintf("http://%s:3000/update", addr))
+		}()
+	}
+}
+
+type registerWorkerReq struct {
+	Address string `json:"address"`
+}
+
+func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+	var req registerWorkerReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	s.workers[req.Address] = struct{}{}
+	fmt.Printf("registered worker: %s\n", req.Address)
+}
+
+func UpdateDodoApp(client soft.Client, namespace string, sshKey string, env *installer.EnvConfig) error {
+	repo, err := client.GetRepo("app")
+	if err != nil {
+		return err
+	}
+	nsCreator := installer.NewNoOpNamespaceCreator()
+	if err != nil {
+		return err
+	}
+	m, err := installer.NewAppManager(repo, nsCreator, "/.dodo")
+	if err != nil {
+		return err
+	}
+	appCfg, err := soft.ReadFile(repo, "app.cue")
+	fmt.Println(string(appCfg))
+	if err != nil {
+		return err
+	}
+	app, err := installer.NewDodoApp(appCfg)
+	if err != nil {
+		return err
+	}
+	if _, err := m.Install(app, "app", "/.dodo/app", namespace, map[string]any{
+		"repoAddr":      repo.FullAddress(),
+		"sshPrivateKey": sshKey,
+	}, installer.WithConfig(env), installer.WithBranch("dodo")); err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index e4cee83..0803e64 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -59,7 +59,7 @@
 	return nil
 }
 
-func (r mockRepoIO) CommitAndPush(message string) error {
+func (r mockRepoIO) CommitAndPush(message string, opts ...soft.PushOption) error {
 	r.t.Logf("Commit and push: %s", message)
 	return nil
 }
@@ -128,6 +128,10 @@
 	return nil
 }
 
+func (f fakeSoftServeClient) AddWebhook(repo, url string, opts ...string) error {
+	return nil
+}
+
 type fakeClientGetter struct {
 	t     *testing.T
 	envFS billy.Filesystem