DodoApp: Implement commit status page

Render used volume, postgresql and ingress resource details.

Change-Id: I87f34fd19d0d0d31ec495d2798c9f5ce99c0fd43
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
 }