DodoApp: Logs

Change-Id: Idb429c417b639b53352642d1d59a6b29622d499f
diff --git a/apps/app-runner/log.go b/apps/app-runner/log.go
new file mode 100644
index 0000000..2e5f524
--- /dev/null
+++ b/apps/app-runner/log.go
@@ -0,0 +1,24 @@
+package main
+
+import (
+	"strings"
+	"sync"
+)
+
+type Log struct {
+	l sync.Mutex
+	d strings.Builder
+}
+
+func (l *Log) Write(p []byte) (n int, err error) {
+	l.l.Lock()
+	defer l.l.Unlock()
+	// TODO(gio): Reset s.logs periodically
+	return l.d.Write(p)
+}
+
+func (l *Log) Contents() string {
+	l.l.Lock()
+	defer l.l.Unlock()
+	return l.d.String()
+}
diff --git a/apps/app-runner/server.go b/apps/app-runner/server.go
index f3ee563..5969160 100644
--- a/apps/app-runner/server.go
+++ b/apps/app-runner/server.go
@@ -4,6 +4,7 @@
 	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"os"
 	"os/exec"
@@ -25,6 +26,7 @@
 	runCommands []Command
 	self        string
 	managerAddr string
+	logs        *Log
 }
 
 func NewServer(port int, appId string, repoAddr string, signer ssh.Signer, appDir string, runCommands []Command, self string, manager string) *Server {
@@ -39,12 +41,14 @@
 		runCommands: runCommands,
 		self:        self,
 		managerAddr: manager,
+		logs:        &Log{},
 	}
 }
 
 func (s *Server) Start() error {
 	http.HandleFunc("/update", s.handleUpdate)
 	http.HandleFunc("/ready", s.handleReady)
+	http.HandleFunc("/logs", s.handleLogs)
 	if err := s.run(); err != nil {
 		return err
 	}
@@ -52,6 +56,10 @@
 	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
 }
 
+func (s *Server) handleLogs(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprint(w, s.logs.Contents())
+}
+
 func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
 	s.l.Lock()
 	defer s.l.Unlock()
@@ -93,6 +101,7 @@
 	if err := CloneRepository(s.repoAddr, s.signer, s.appDir); err != nil {
 		return err
 	}
+	logM := io.MultiWriter(os.Stdout, s.logs)
 	for i, c := range s.runCommands {
 		args := []string{c.Bin}
 		args = append(args, c.Args...)
@@ -101,8 +110,8 @@
 			Path:   c.Bin,
 			Args:   args,
 			Env:    c.Env,
-			Stdout: os.Stdout,
-			Stderr: os.Stderr,
+			Stdout: logM,
+			Stderr: logM,
 		}
 		fmt.Printf("Running: %s\n", c.Bin)
 		if i < len(s.runCommands)-1 {
@@ -121,6 +130,7 @@
 
 type pingReq struct {
 	Address string `json:"address"`
+	Logs    string `json:"logs"`
 }
 
 func (s *Server) pingManager() {
@@ -132,6 +142,7 @@
 	}()
 	buf, err := json.Marshal(pingReq{
 		Address: fmt.Sprintf("%s:%d", s.self, s.port),
+		Logs:    s.logs.Contents(),
 	})
 	if err != nil {
 		return
diff --git a/core/installer/welcome/dodo-app-tmpl/app_status.html b/core/installer/welcome/dodo-app-tmpl/app_status.html
index eabc5b2..e96b801 100644
--- a/core/installer/welcome/dodo-app-tmpl/app_status.html
+++ b/core/installer/welcome/dodo-app-tmpl/app_status.html
@@ -2,7 +2,10 @@
 dodo app: {{ .Name }}
 {{ end }}
 {{- define "content" -}}
-{{ .GitCloneCommand }}
+{{ .GitCloneCommand }}<br/>
+<a href="/{{ .Name }}/logs">Logs</a>
+<hr class="divider">
+{{- template "resources" .LastCommit -}}
 <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 }} <a href="/{{ $.Name }}/{{ .Hash }}">{{ .Hash }}</a> {{ .Message }}<br/>
diff --git a/core/installer/welcome/dodo-app-tmpl/base.html b/core/installer/welcome/dodo-app-tmpl/base.html
index 6334f41..644ff41 100644
--- a/core/installer/welcome/dodo-app-tmpl/base.html
+++ b/core/installer/welcome/dodo-app-tmpl/base.html
@@ -19,3 +19,29 @@
     {{- end }}
 </body>
 </html>
+{{ define "resources" }}
+{{- if gt (len .Volume) 0 -}}
+<h3>Volumes</h3>
+{{- range $v := .Volume -}}
+Name: {{ $v.Name }}<br/>
+Size: {{ $v.Size }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- if gt (len .PostgreSQL) 0 -}}
+<h3>PostgreSQL</h3>
+{{- range $p := .PostgreSQL -}}
+Name: {{ $p.Name }}<br/>
+Version: {{ $p.Version }}<br/>
+Volume: {{ $p.Volume }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{- if gt (len .Ingress) 0 -}}
+<h3>Ingress</h3>
+{{- range $i := .Ingress -}}
+Host: {{ $i.Host }}<br/>
+<br/>
+{{- end -}}
+{{- end -}}
+{{ end }}
diff --git a/core/installer/welcome/dodo-app-tmpl/commit_status.html b/core/installer/welcome/dodo-app-tmpl/commit_status.html
index 8616545..b6c7b06 100644
--- a/core/installer/welcome/dodo-app-tmpl/commit_status.html
+++ b/core/installer/welcome/dodo-app-tmpl/commit_status.html
@@ -5,28 +5,5 @@
 {{ 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 -}}
+{{- template "resources" .Resources -}}
 {{- end -}}
diff --git a/core/installer/welcome/dodo-app-tmpl/logs.html b/core/installer/welcome/dodo-app-tmpl/logs.html
new file mode 100644
index 0000000..c30ea2c
--- /dev/null
+++ b/core/installer/welcome/dodo-app-tmpl/logs.html
@@ -0,0 +1,6 @@
+{{ define "title" }}
+dodo app: {{ .AppName }} - logs
+{{ end }}
+{{- define "content" -}}
+<code id="logs">{{ .Logs }}</code>
+{{- end -}}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 43a6bd7..fbf732b 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -49,6 +49,7 @@
 	index        *template.Template
 	appStatus    *template.Template
 	commitStatus *template.Template
+	logs         *template.Template
 }
 
 func parseTemplatesDodoApp(fs embed.FS) (dodoAppTmplts, error) {
@@ -75,7 +76,11 @@
 	if err != nil {
 		return dodoAppTmplts{}, err
 	}
-	return dodoAppTmplts{index, appStatus, commitStatus}, nil
+	logs, err := parse("dodo-app-tmpl/logs.html")
+	if err != nil {
+		return dodoAppTmplts{}, err
+	}
+	return dodoAppTmplts{index, appStatus, commitStatus, logs}, nil
 }
 
 type DodoAppServer struct {
@@ -101,6 +106,7 @@
 	appTmpls          AppTmplStore
 	external          bool
 	fetchUsersAddr    string
+	logs              map[string]string
 }
 
 type appConfig struct {
@@ -163,6 +169,7 @@
 		appTmpls,
 		external,
 		fetchUsersAddr,
+		map[string]string{},
 	}
 	config, err := client.GetRepo(ConfigRepoName)
 	if err != nil {
@@ -191,6 +198,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}/logs", s.handleAppLogs).Methods(http.MethodGet)
 		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)
@@ -425,6 +433,7 @@
 	Name            string
 	GitCloneCommand string
 	Commits         []CommitMeta
+	LastCommit      resourceData
 }
 
 func (s *DodoAppServer) handleAppStatus(w http.ResponseWriter, r *http.Request) {
@@ -458,6 +467,20 @@
 		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
+	}
 	data := appStatusData{
 		Navigation: []navItem{
 			navItem{"Home", "/"},
@@ -466,6 +489,7 @@
 		Name:            appName,
 		GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
 		Commits:         commits,
+		LastCommit:      lastCommitResources,
 	}
 	if err := s.tmplts.appStatus.Execute(w, data); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -564,6 +588,54 @@
 	}
 }
 
+type logData struct {
+	Navigation []navItem
+	AppName    string
+	Logs       template.HTML
+}
+
+func (s *DodoAppServer) 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 {
@@ -634,6 +706,7 @@
 
 type apiRegisterWorkerReq struct {
 	Address string `json:"address"`
+	Logs    string `json:"logs"`
 }
 
 func (s *DodoAppServer) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
@@ -652,6 +725,7 @@
 		s.workers[appName] = map[string]struct{}{}
 	}
 	s.workers[appName][req.Address] = struct{}{}
+	s.logs[appName] = req.Logs
 }
 
 func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {