DodoApp: Implement commit status page

Render used volume, postgresql and ingress resource details.

Change-Id: I87f34fd19d0d0d31ec495d2798c9f5ce99c0fd43
diff --git a/core/installer/app_configs/app_base.cue b/core/installer/app_configs/app_base.cue
index 4954bef..5035fc7 100644
--- a/core/installer/app_configs/app_base.cue
+++ b/core/installer/app_configs/app_base.cue
@@ -49,6 +49,13 @@
 
 volumes: {}
 volumes: {
+	for _, p in _postgresql {
+		for k, v in p.out.volumes {
+			"\(k)": v
+		}
+	}
+}
+volumes: {
 	for key, value in volumes {
 		"\(key)": #volume & value & {
 			name: key
@@ -163,7 +170,7 @@
 	size: string | *"1Gi"
 
 	_size: size
-	_volumeClaimName: "postgresql"
+	_volumeClaimName: "\(name)-postgresql"
 
 	out: {
 		images: {
@@ -175,12 +182,6 @@
 			}
 		}
 		charts: {
-			volume: #Chart & {
-				kind: "GitRepository"
-				address: "https://code.v1.dodo.cloud/helm-charts"
-				branch: "main"
-				path: "charts/volumes"
-			}
 			postgres: #Chart & {
 				kind: "GitRepository"
 				address: "https://code.v1.dodo.cloud/helm-charts"
@@ -188,6 +189,9 @@
 				path: "charts/postgresql"
 			}
 		}
+		volumes: {
+			"\(_volumeClaimName)": size: _size
+		}
 		charts: {
 			for key, value in charts {
 				"\(key)": #Chart & value & {
@@ -196,15 +200,14 @@
 			}
 		}
 		helm: {
-			"volume-name": {
-				chart: charts.volume
-				values: {
-					name: _volumeClaimName
-					size: _size
-				}
-			}
 			postgres: {
 				chart: charts.postgres
+				annotations: {
+					"dodo.cloud/resource-type": "postgresql"
+					"dodo.cloud/resource.postgresql.name": name
+					"dodo.cloud/resource.postgresql.version": version
+					"dodo.cloud/resource.postgresql.volume": _volumeClaimName
+				}
 				values: {
 					fullnameOverride: "postgres"
 					image: {
@@ -270,6 +273,7 @@
 	name: string
 	dependsOn: [...#ResourceReference] | *[]
 	info: string | *""
+	annotations: {...} | *{}
 	...
 }
 
@@ -284,6 +288,11 @@
 		"\(key)-volume": #Helm & {
 			chart: charts.volume
 			info: "Creating disk for \(key)"
+			annotations: {
+				"dodo.cloud/resource-type": "volume"
+				"dodo.cloud/resource.volume.name": value.name
+				"dodo.cloud/resource.volume.size": value.size
+			}
 			values: value
 		}
 	}
@@ -309,13 +318,14 @@
 	_values: _
 	_dependencies: [...#ResourceReference] | *[]
 	_info: string | *""
+	_annotations: {...} | *{}
 
 	apiVersion: "helm.toolkit.fluxcd.io/v2beta1"
 	kind: "HelmRelease"
 	metadata: {
 		name: _name
    		namespace: release.namespace
-        annotations: {
+        annotations: _annotations & {
           "dodo.cloud/installer-info": _info
         }
 	}
@@ -335,6 +345,7 @@
 			_values: r.values
 			_dependencies: r.dependsOn
 			_info: r.info
+			_annotations: r.annotations
 		}
 	}
 }
diff --git a/core/installer/app_configs/app_global_env.cue b/core/installer/app_configs/app_global_env.cue
index c53df4a..1681a1b 100644
--- a/core/installer/app_configs/app_global_env.cue
+++ b/core/installer/app_configs/app_global_env.cue
@@ -101,6 +101,10 @@
 				chart: charts.ingress
 				_service: service
                 info: "Generating TLS certificate for https://\(_domain)"
+				annotations: {
+					"dodo.cloud/resource-type": "ingress"
+					"dodo.cloud/resource.ingress.host": "https://\(_domain)"
+				}
 				values: {
 					domain: _domain
 					appRoot: _appRoot
diff --git a/core/installer/app_manager.go b/core/installer/app_manager.go
index a7c23f0..c27b641 100644
--- a/core/installer/app_manager.go
+++ b/core/installer/app_manager.go
@@ -285,9 +285,10 @@
 }
 
 type Resource struct {
-	Name      string `json:"name"`
-	Namespace string `json:"namespace"`
-	Info      string `json:"info"`
+	Name        string            `json:"name"`
+	Namespace   string            `json:"namespace"`
+	Info        string            `json:"info"`
+	Annotations map[string]string `json:"annotations"`
 }
 
 type ReleaseResources struct {
@@ -326,7 +327,7 @@
 	if o.NoLock {
 		dopts = append(dopts, soft.WithNoLock())
 	}
-	return repo.Do(func(r soft.RepoFS) (string, error) {
+	_, err := repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := r.RemoveDir(appDir); err != nil {
 			return "", err
 		}
@@ -386,6 +387,7 @@
 		}
 		return fmt.Sprintf("install: %s", name), nil
 	}, dopts...)
+	return err
 }
 
 // TODO(gio): commit instanceId -> appDir mapping as well
@@ -529,11 +531,13 @@
 		}
 		if h.Kind == "HelmRelease" {
 			res := Resource{
-				Name:      h.Metadata.Name,
-				Namespace: h.Metadata.Namespace,
-				Info:      fmt.Sprintf("%s/%s", h.Metadata.Namespace, h.Metadata.Name),
+				Name:        h.Metadata.Name,
+				Namespace:   h.Metadata.Namespace,
+				Info:        fmt.Sprintf("%s/%s", h.Metadata.Namespace, h.Metadata.Name),
+				Annotations: nil,
 			}
 			if h.Metadata.Annotations != nil {
+				res.Annotations = h.Metadata.Annotations
 				info, ok := h.Metadata.Annotations["dodo.cloud/installer-info"]
 				if ok && len(info) != 0 {
 					res.Info = info
@@ -599,7 +603,7 @@
 		return err
 	}
 	var portForward []PortForward
-	if err := m.repoIO.Do(func(r soft.RepoFS) (string, error) {
+	if _, err := m.repoIO.Do(func(r soft.RepoFS) (string, error) {
 		instanceDir := filepath.Join(m.appDirRoot, instanceId)
 		renderedCfg, err := readRendered(m.repoIO, filepath.Join(instanceDir, "rendered.json"))
 		if err != nil {
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index 4b72556..8a83eea 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -343,7 +343,7 @@
 	if err != nil {
 		return err
 	}
-	if err := repoIO.Do(func(r soft.RepoFS) (string, error) {
+	if _, err := repoIO.Do(func(r soft.RepoFS) (string, error) {
 		w, err := r.Writer("README.md")
 		if err != nil {
 			return "", err
@@ -438,7 +438,7 @@
 }
 
 func configureMainRepo(repo soft.RepoIO, bootstrap BootstrapConfig) error {
-	return repo.Do(func(r soft.RepoFS) (string, error) {
+	_, err := repo.Do(func(r soft.RepoFS) (string, error) {
 		if err := soft.WriteYaml(r, "bootstrap-config.yaml", bootstrap); err != nil {
 			return "", err
 		}
@@ -495,6 +495,7 @@
 		}
 		return "initialize pcloud directory structure", nil
 	})
+	return err
 }
 
 func (b Bootstrapper) installEnvManager(mgr *InfraAppManager, ss soft.Client, env BootstrapConfig) error {
diff --git a/core/installer/soft/repoio.go b/core/installer/soft/repoio.go
index f4c05bb..191b291 100644
--- a/core/installer/soft/repoio.go
+++ b/core/installer/soft/repoio.go
@@ -98,8 +98,8 @@
 	RepoFS
 	FullAddress() string
 	Pull() error
-	CommitAndPush(message string, opts ...PushOption) error
-	Do(op DoFn, opts ...DoOption) error
+	CommitAndPush(message string, opts ...PushOption) (string, error)
+	Do(op DoFn, opts ...DoOption) (string, error)
 }
 
 type repoFS struct {
@@ -190,32 +190,33 @@
 	return nil
 }
 
-func (r *repoIO) CommitAndPush(message string, opts ...PushOption) error {
+func (r *repoIO) CommitAndPush(message string, opts ...PushOption) (string, error) {
 	var o pushOptions
 	for _, i := range opts {
 		i(&o)
 	}
 	wt, err := r.repo.Worktree()
 	if err != nil {
-		return err
+		return "", err
 	}
 	if err := wt.AddGlob("*"); err != nil {
-		return err
+		return "", err
 	}
 	st, err := wt.Status()
 	if err != nil {
-		return err
+		return "", err
 	}
 	if len(st) == 0 {
-		return nil // TODO(gio): maybe return ErrorNothingToCommit
+		return "", nil // TODO(gio): maybe return ErrorNothingToCommit
 	}
-	if _, err := wt.Commit(message, &git.CommitOptions{
+	hash, err := wt.Commit(message, &git.CommitOptions{
 		Author: &object.Signature{
 			Name: "pcloud-installer",
 			When: time.Now(),
 		},
-	}); err != nil {
-		return err
+	})
+	if err != nil {
+		return "", err
 	}
 	gopts := &git.PushOptions{
 		RemoteName: "origin",
@@ -227,10 +228,10 @@
 	if o.Force {
 		gopts.Force = true
 	}
-	return r.repo.Push(gopts)
+	return hash.String(), r.repo.Push(gopts)
 }
 
-func (r *repoIO) Do(op DoFn, opts ...DoOption) error {
+func (r *repoIO) Do(op DoFn, opts ...DoOption) (string, error) {
 	o := &doOptions{}
 	for _, i := range opts {
 		i(o)
@@ -241,15 +242,15 @@
 	}
 	if !o.NoPull {
 		if err := r.pullWithoutLock(); err != nil {
-			return err
+			return "", err
 		}
 	}
 	msg, err := op(r)
 	if err != nil {
-		return err
+		return "", err
 	}
 	if o.NoCommit {
-		return nil
+		return "", nil
 	}
 	popts := []PushOption{}
 	if o.Force {
diff --git a/core/installer/tasks/activate.go b/core/installer/tasks/activate.go
index 6916262..dafbaf7 100644
--- a/core/installer/tasks/activate.go
+++ b/core/installer/tasks/activate.go
@@ -32,7 +32,7 @@
 			return err
 		}
 		repoHost := strings.Split(st.ssClient.Address(), ":")[0]
-		return st.repo.Do(func(r soft.RepoFS) (string, error) {
+		_, err = st.repo.Do(func(r soft.RepoFS) (string, error) {
 			kust, err := soft.ReadKustomization(r, "environments/kustomization.yaml")
 			if err != nil {
 				return "", err
@@ -69,6 +69,7 @@
 			}
 			return fmt.Sprintf("%s: initialize environment", env.Id), nil
 		})
+		return err
 	})
 	return &t
 }
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index eb4840f..9d0c011 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -88,13 +88,14 @@
 		if err != nil {
 			return err
 		}
-		return r.Do(func(r soft.RepoFS) (string, error) {
+		_, err = r.Do(func(r soft.RepoFS) (string, error) {
 			fa := firstAccount{false, env.Domain, initGroups}
 			if err := soft.WriteYaml(r, "first-account.yaml", fa); err != nil {
 				return "", err
 			}
 			return "first account membership configuration", nil
 		})
+		return err
 	})
 	return &t
 }
diff --git a/core/installer/tasks/init.go b/core/installer/tasks/init.go
index f55344d..30e35e0 100644
--- a/core/installer/tasks/init.go
+++ b/core/installer/tasks/init.go
@@ -102,7 +102,7 @@
 		if err != nil {
 			return err
 		}
-		if err := repoIO.Do(func(r soft.RepoFS) (string, error) {
+		if _, err := repoIO.Do(func(r soft.RepoFS) (string, error) {
 			w, err := r.Writer("README.md")
 			if err != nil {
 				return "", err
diff --git a/core/installer/welcome/dodo-app-tmpl/app_status.html b/core/installer/welcome/dodo-app-tmpl/app_status.html
index efe74a1..eabc5b2 100644
--- a/core/installer/welcome/dodo-app-tmpl/app_status.html
+++ b/core/installer/welcome/dodo-app-tmpl/app_status.html
@@ -2,10 +2,9 @@
 dodo app: {{ .Name }}
 {{ end }}
 {{- define "content" -}}
-<a href="/">Home</a><br/><br/>
 {{ .GitCloneCommand }}
 <hr class="divider">
 {{- 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 }} {{ .Hash }} {{ .Message }}<br/>
+{{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 -}}
 {{- end -}}
diff --git a/core/installer/welcome/dodo-app-tmpl/base.html b/core/installer/welcome/dodo-app-tmpl/base.html
index b0251cd..6334f41 100644
--- a/core/installer/welcome/dodo-app-tmpl/base.html
+++ b/core/installer/welcome/dodo-app-tmpl/base.html
@@ -8,6 +8,13 @@
     <link rel="stylesheet" href="/stat/dodo_app.css?v=0.0.8">
 </head>
 <body class="container">
+	<nav aria-label="breadcrumb">
+		<ul>
+			{{- range $i := .Navigation }}
+			<li><a href="{{ $i.Address }}">{{- $i.Name -}}</a></li>
+			{{- end }}
+		</ul>
+	</nav>
     {{- block "content" . }}
     {{- end }}
 </body>
diff --git a/core/installer/welcome/dodo-app-tmpl/commit_status.html b/core/installer/welcome/dodo-app-tmpl/commit_status.html
new file mode 100644
index 0000000..8616545
--- /dev/null
+++ b/core/installer/welcome/dodo-app-tmpl/commit_status.html
@@ -0,0 +1,32 @@
+{{ define "title" }}
+dodo app: {{ .AppName }}
+{{ end }}
+{{- define "content" -}}
+{{ if ne .Commit.Error "" }}
+{{ .CommitError }}
+{{ end }}
+{{- if gt (len .Resources.Volume) 0 -}}
+<h2>Volumes</h2>
+{{- range $v := .Resources.Volume -}}
+Name: {{ $v.Name }}<br/>
+Size: {{ $v.Size }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- if gt (len .Resources.PostgreSQL) 0 -}}
+<h2>PostgreSQL</h2>
+{{- range $p := .Resources.PostgreSQL -}}
+Name: {{ $p.Name }}<br/>
+Version: {{ $p.Version }}<br/>
+Volume: {{ $p.Volume }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- if gt (len .Resources.Ingress) 0 -}}
+<h2>Ingress</h2>
+{{- range $i := .Resources.Ingress -}}
+Host: {{ $i.Host }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- end -}}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 7c35d69..43a6bd7 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -42,11 +42,13 @@
 	apiCreateApp   = "/api/apps"
 	sessionCookie  = "dodo-app-session"
 	userCtx        = "user"
+	initCommitMsg  = "init"
 )
 
 type dodoAppTmplts struct {
-	index     *template.Template
-	appStatus *template.Template
+	index        *template.Template
+	appStatus    *template.Template
+	commitStatus *template.Template
 }
 
 func parseTemplatesDodoApp(fs embed.FS) (dodoAppTmplts, error) {
@@ -69,7 +71,11 @@
 	if err != nil {
 		return dodoAppTmplts{}, err
 	}
-	return dodoAppTmplts{index, appStatus}, nil
+	commitStatus, err := parse("dodo-app-tmpl/commit_status.html")
+	if err != nil {
+		return dodoAppTmplts{}, err
+	}
+	return dodoAppTmplts{index, appStatus, commitStatus}, nil
 }
 
 type DodoAppServer struct {
@@ -185,6 +191,7 @@
 		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}/{hash}", s.handleAppCommit).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)
@@ -373,10 +380,16 @@
 	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
 }
 
+type navItem struct {
+	Name    string
+	Address string
+}
+
 type statusData struct {
-	Apps     []string
-	Networks []installer.Network
-	Types    []string
+	Navigation []navItem
+	Apps       []string
+	Networks   []installer.Network
+	Types      []string
 }
 
 func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
@@ -399,7 +412,8 @@
 	for _, t := range s.appTmpls.Types() {
 		types = append(types, strings.Replace(t, "-", ":", 1))
 	}
-	data := statusData{apps, networks, types}
+	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
@@ -407,9 +421,10 @@
 }
 
 type appStatusData struct {
+	Navigation      []navItem
 	Name            string
 	GitCloneCommand string
-	Commits         []Commit
+	Commits         []CommitMeta
 }
 
 func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
@@ -444,6 +459,10 @@
 		return
 	}
 	data := appStatusData{
+		Navigation: []navItem{
+			navItem{"Home", "/"},
+			navItem{appName, "/" + appName},
+		},
 		Name:            appName,
 		GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
 		Commits:         commits,
@@ -454,6 +473,97 @@
 	}
 }
 
+type volume struct {
+	Name string
+	Size string
+}
+
+type postgresql struct {
+	Name    string
+	Version string
+	Volume  string
+}
+
+type ingress struct {
+	Host string
+}
+
+type resourceData struct {
+	Volume     []volume
+	PostgreSQL []postgresql
+	Ingress    []ingress
+}
+
+type commitStatusData struct {
+	Navigation []navItem
+	AppName    string
+	Commit     Commit
+	Resources  resourceData
+}
+
+func (s *DodoAppServer) 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 apiUpdateReq struct {
 	Ref        string `json:"ref"`
 	Repository struct {
@@ -508,16 +618,11 @@
 			fmt.Printf("Error: could not find commit message")
 			return
 		}
-		if err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, s.appConfigs[req.Repository.Name].Namespace, networks); err != nil {
+		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 {
 			fmt.Printf("Error: %s\n", err.Error())
-			if err := s.st.CreateCommit(req.Repository.Name, req.After, commitMsg, err.Error()); err != nil {
-				fmt.Printf("Error: %s\n", err.Error())
-			}
 			return
 		}
-		if err := s.st.CreateCommit(req.Repository.Name, req.After, commitMsg, "OK"); err != nil {
-			fmt.Printf("Error: %s\n", err.Error())
-		}
 		for addr, _ := range s.workers[req.Repository.Name] {
 			go func() {
 				// TODO(gio): make port configurable
@@ -710,7 +815,8 @@
 	if err != nil {
 		return err
 	}
-	if err := s.initRepo(appRepo, appType, n, subdomain); err != nil {
+	commit, err := s.initRepo(appRepo, appType, n, subdomain)
+	if err != nil {
 		return err
 	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
@@ -729,7 +835,12 @@
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
 	s.appConfigs[appName] = appConfig{namespace, network}
-	if err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks); err != nil {
+	resources, err := s.updateDodoApp(instanceAppStatus, appName, namespace, networks)
+	if err != nil {
+		return err
+	}
+	if err = s.createCommit(appName, commit, initCommitMsg, err, resources); err != nil {
+		fmt.Printf("Error: %s\n", err.Error())
 		return err
 	}
 	configRepo, err := s.client.GetRepo(ConfigRepoName)
@@ -741,7 +852,7 @@
 	if err != nil {
 		return err
 	}
-	if err := configRepo.Do(func(fs soft.RepoFS) (string, error) {
+	_, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
 		w, err := fs.Writer(appConfigsFile)
 		if err != nil {
 			return "", err
@@ -768,7 +879,8 @@
 			return "", err
 		}
 		return fmt.Sprintf("Installed app: %s", appName), nil
-	}); err != nil {
+	})
+	if err != nil {
 		return err
 	}
 	cfg, err := m.FindInstance(appName)
@@ -849,32 +961,33 @@
 	} `json:"input"`
 }
 
-func (s *DodoAppServer) updateDodoApp(appStatus installer.EnvApp, name, namespace string, networks []installer.Network) error {
+func (s *DodoAppServer) updateDodoApp(appStatus installer.EnvApp, name, namespace string, networks []installer.Network) (installer.ReleaseResources, error) {
 	fmt.Println("111")
 	repo, err := s.client.GetRepo(name)
 	if err != nil {
-		return err
+		return installer.ReleaseResources{}, err
 	}
 	fmt.Println("111")
 	hf := installer.NewGitHelmFetcher()
 	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
 	if err != nil {
-		return err
+		return installer.ReleaseResources{}, err
 	}
 	fmt.Println("111")
 	appCfg, err := soft.ReadFile(repo, "app.cue")
 	if err != nil {
-		return err
+		return installer.ReleaseResources{}, err
 	}
 	fmt.Println("111")
 	app, err := installer.NewDodoApp(appCfg)
 	if err != nil {
-		return err
+		return installer.ReleaseResources{}, err
 	}
 	fmt.Println("111")
 	lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
-	return repo.Do(func(r soft.RepoFS) (string, error) {
-		res, err := m.Install(
+	var ret installer.ReleaseResources
+	if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
+		ret, err = m.Install(
 			app,
 			"app",
 			"/.dodo/app",
@@ -898,7 +1011,7 @@
 		}
 		fmt.Println("111")
 		var rendered dodoAppRendered
-		if err := json.NewDecoder(bytes.NewReader(res.RenderedRaw)).Decode(&rendered); err != nil {
+		if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
 			return "", nil
 		}
 		fmt.Println("111")
@@ -926,20 +1039,23 @@
 	},
 		soft.WithCommitToBranch("dodo"),
 		soft.WithForce(),
-	)
+	); err != nil {
+		return installer.ReleaseResources{}, err
+	}
+	return ret, nil
 }
 
-func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) error {
+func (s *DodoAppServer) initRepo(repo soft.RepoIO, appType string, network installer.Network, subdomain string) (string, error) {
 	appType = strings.Replace(appType, ":", "-", 1)
 	appTmpl, err := s.appTmpls.Find(appType)
 	if err != nil {
-		return err
+		return "", err
 	}
 	return repo.Do(func(fs soft.RepoFS) (string, error) {
 		if err := appTmpl.Render(network, subdomain, repo); err != nil {
 			return "", err
 		}
-		return "init", nil
+		return initCommitMsg, nil
 	})
 }
 
@@ -994,6 +1110,30 @@
 	}
 }
 
+func (s *DodoAppServer) createCommit(name, 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 {
+			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, 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 {
+		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 {
@@ -1194,3 +1334,48 @@
 		}
 	}
 }
+
+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})
+		default:
+			fmt.Printf("Unknown resource: %+v\n", r.Annotations)
+		}
+	}
+	return ret, nil
+}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index 356e47c..82fb3d5 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -280,7 +280,8 @@
 			return err
 		}
 	}
-	return s.repo.CommitAndPush("Generated new invitation")
+	_, err = s.repo.CommitAndPush("Generated new invitation")
+	return err
 }
 
 func extractRequest(r *http.Request) (createEnvReq, error) {
@@ -380,7 +381,7 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if err := s.repo.CommitAndPush(fmt.Sprintf("Allocate CIDR for %s", req.Name)); err != nil {
+	if _, err := s.repo.CommitAndPush(fmt.Sprintf("Allocate CIDR for %s", req.Name)); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index 040607d..e7e75a8 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -76,17 +76,17 @@
 	return nil
 }
 
-func (r mockRepoIO) CommitAndPush(message string, opts ...soft.PushOption) error {
+func (r mockRepoIO) CommitAndPush(message string, opts ...soft.PushOption) (string, error) {
 	r.t.Logf("Commit and push: %s", message)
-	return nil
+	return "", nil
 }
 
-func (r mockRepoIO) Do(op soft.DoFn, _ ...soft.DoOption) error {
+func (r mockRepoIO) Do(op soft.DoFn, _ ...soft.DoOption) (string, error) {
 	r.l.Lock()
 	defer r.l.Unlock()
 	msg, err := op(r)
 	if err != nil {
-		return err
+		return "", err
 	}
 	return r.CommitAndPush(msg)
 }
diff --git a/core/installer/welcome/store.go b/core/installer/welcome/store.go
index 031cdff..06be06a 100644
--- a/core/installer/welcome/store.go
+++ b/core/installer/welcome/store.go
@@ -1,11 +1,14 @@
 package welcome
 
 import (
+	"bytes"
 	"database/sql"
+	"encoding/json"
 	"errors"
 
 	"github.com/ncruces/go-sqlite3"
 
+	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
 )
 
@@ -17,12 +20,18 @@
 	ErrorAlreadyExists = errors.New("already exists")
 )
 
-type Commit struct {
-	Hash    string
+type CommitMeta struct {
 	Status  string
+	Error   string
+	Hash    string
 	Message string
 }
 
+type Commit struct {
+	CommitMeta
+	Resources installer.ReleaseResources
+}
+
 type Store interface {
 	CreateUser(username string, password []byte, network string) error
 	GetUserPassword(username string) ([]byte, error)
@@ -31,8 +40,9 @@
 	GetUserApps(username string) ([]string, error)
 	CreateApp(name, username string) error
 	GetAppOwner(name string) (string, error)
-	CreateCommit(name, hash, message, status string) error
-	GetCommitHistory(name string) ([]Commit, error)
+	CreateCommit(name, hash, message, status, error string, resources []byte) error
+	GetCommitHistory(name string) ([]CommitMeta, error)
+	GetCommit(hash string) (Commit, error)
 }
 
 func NewStore(cf soft.RepoIO, db *sql.DB) (Store, error) {
@@ -63,7 +73,9 @@
 			app_name TEXT,
             hash TEXT,
             message TEXT,
-            status TEXT
+            status TEXT,
+            error TEXT,
+            resources JSONB
 		);
 	`)
 	return err
@@ -174,26 +186,26 @@
 	return ret, nil
 }
 
-func (s *storeImpl) CreateCommit(name, hash, message, status string) error {
-	query := `INSERT INTO commits (app_name, hash, message, status) VALUES (?, ?, ?, ?)`
-	_, err := s.db.Exec(query, name, hash, message, status)
+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)
 	return err
 }
 
-func (s *storeImpl) GetCommitHistory(name string) ([]Commit, error) {
-	query := `SELECT hash, message, status FROM commits WHERE app_name = ?`
+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)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
-	ret := []Commit{}
+	ret := []CommitMeta{}
 	for rows.Next() {
 		if err := rows.Err(); err != nil {
 			return nil, err
 		}
-		var c Commit
-		if err := rows.Scan(&c.Hash, &c.Message, &c.Status); err != nil {
+		var c CommitMeta
+		if err := rows.Scan(&c.Hash, &c.Message, &c.Status, &c.Error); err != nil {
 			return nil, err
 		}
 		ret = append(ret, c)
@@ -201,3 +213,21 @@
 	}
 	return ret, nil
 }
+
+func (s *storeImpl) GetCommit(hash string) (Commit, error) {
+	query := `SELECT hash, message, status, error, resources FROM commits WHERE hash = ?`
+	row := s.db.QueryRow(query, hash)
+	if err := row.Err(); err != nil {
+		return Commit{}, err
+	}
+	var ret Commit
+	var c Commit
+	var res []byte
+	if err := row.Scan(&c.Hash, &c.Message, &c.Status, &c.Error, &res); err != nil {
+		return Commit{}, err
+	}
+	if err := json.NewDecoder(bytes.NewBuffer(res)).Decode(&ret.Resources); err != nil {
+		return Commit{}, err
+	}
+	return ret, nil
+}
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index 234a53b..9e45de5 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -266,7 +266,7 @@
 }
 
 func (s *Server) createUser(username string) error {
-	return s.repo.Do(func(r soft.RepoFS) (string, error) {
+	_, err := s.repo.Do(func(r soft.RepoFS) (string, error) {
 		var fa firstAccount
 		if err := soft.ReadYaml(r, "first-account.yaml", &fa); err != nil {
 			return "", err
@@ -311,4 +311,5 @@
 		}
 		return "initialized groups for first account", nil
 	})
+	return err
 }