cloud/bmaas: implement webug

Webug (pronounced: /wɛbʌɡ/, not /wiːbʌɡ/) is a web debug interface for
BMDB, inspired by the great web debug interfaces of old.

It uses the new BMDB reflection API to access most
machine/tag/work/backoff information, plus sqlc queries to access
session information.

Change-Id: If0e65b6fc33ad92baef9c6d98333f90a02efa1b3
Reviewed-on: https://review.monogon.dev/c/monogon/+/1132
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
Tested-by: Jenkins CI
diff --git a/cloud/bmaas/bmdb/webug/views.go b/cloud/bmaas/bmdb/webug/views.go
new file mode 100644
index 0000000..7e6d91f
--- /dev/null
+++ b/cloud/bmaas/bmdb/webug/views.go
@@ -0,0 +1,171 @@
+package webug
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/google/uuid"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/bmaas/bmdb/model"
+	"source.monogon.dev/cloud/bmaas/bmdb/reflection"
+)
+
+// baseParams are passed to all rendered templates, and are consumed by tags in
+// templates/base.html.
+type baseParams struct {
+	// Address to display in page header.
+	BMDBAddress string
+	// Schema version to display in page header.
+	BMDBSchema string
+}
+
+// makeBase builds baseParams from information about the current connection.
+func (s *server) makeBase() baseParams {
+	address := fmt.Sprintf("%s@%s", s.conn.DatabaseName, s.conn.Address)
+	if s.conn.InMemory {
+		address += " (in memory)"
+	}
+	return baseParams{
+		BMDBAddress: address,
+		BMDBSchema:  s.curSchema().Version,
+	}
+}
+
+// viewMachines renders a list of all machines in the BMDB.
+func (s *server) viewMachines(w http.ResponseWriter, r *http.Request, args ...string) {
+	start := time.Now()
+	res, err := s.curSchema().GetMachines(r.Context(), &reflection.GetMachinesOpts{Strict: true})
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		fmt.Fprintf(w, "could not dump BMDB: %v", err)
+		return
+	}
+	duration := time.Since(start)
+
+	type params struct {
+		Base       baseParams
+		Query      string
+		Machines   []*reflection.Machine
+		NMachines  int
+		RenderTime time.Duration
+	}
+	err = templates.ExecuteTemplate(w, "machines.html", &params{
+		Base:       s.makeBase(),
+		Query:      res.Query,
+		Machines:   res.Data,
+		NMachines:  len(res.Data),
+		RenderTime: duration,
+	})
+	if err != nil {
+		klog.Errorf("Template rendering failed: %v", err)
+	}
+}
+
+// viewMachineDetail renders a detailed page for a single machine.
+func (s *server) viewMachineDetail(w http.ResponseWriter, r *http.Request, args ...string) {
+	mid, err := uuid.Parse(args[0])
+	if err != nil {
+		w.WriteHeader(http.StatusUnprocessableEntity)
+		fmt.Fprintf(w, "invalid machine UUID")
+		return
+	}
+
+	opts := reflection.GetMachinesOpts{
+		FilterMachine:   &mid,
+		Strict:          true,
+		ExpiredBackoffs: true,
+	}
+	res, err := s.curSchema().GetMachines(r.Context(), &opts)
+	if err != nil {
+		w.WriteHeader(http.StatusInternalServerError)
+		fmt.Fprintf(w, "could not dump BMDB: %v", err)
+		return
+	}
+	if len(res.Data) == 0 {
+		w.WriteHeader(http.StatusNotFound)
+		fmt.Fprintf(w, "machine not found")
+		return
+	}
+	machine := res.Data[0]
+
+	// Params to pass to template.
+	type sessionOrError struct {
+		Session *model.Session
+		Error   string
+	}
+	type params struct {
+		Base    baseParams
+		Machine *reflection.Machine
+
+		HistoryError string
+		History      []model.WorkHistory
+
+		Sessions map[string]sessionOrError
+	}
+	p := params{
+		Base:     s.makeBase(),
+		Machine:  machine,
+		Sessions: make(map[string]sessionOrError),
+	}
+
+	// History retrieval is performed with strict consistency guarantees, and thus
+	// might block. Make sure we don't block the entire page.
+	subQueriesCtx, subQueriesCtxC := context.WithTimeout(r.Context(), time.Second)
+	defer subQueriesCtxC()
+	history, err := s.conn.ListHistoryOf(subQueriesCtx, mid)
+	if err != nil {
+		p.HistoryError = err.Error()
+	}
+
+	// Same for sessions.
+	for name, work := range machine.Work {
+		sessions, err := s.conn.GetSession(subQueriesCtx, work.SessionID)
+		if err != nil {
+			p.Sessions[name] = sessionOrError{Error: err.Error()}
+		} else {
+			if len(sessions) == 0 {
+				// This can happen if the session literally just disappeared.
+				//
+				// TODO(q3k): put all of these operations in a DB TX so that we don't end up with
+				// possible inconsistencies?
+				p.Sessions[name] = sessionOrError{Error: "not found"}
+				continue
+			}
+			p.Sessions[name] = sessionOrError{Session: &sessions[0]}
+		}
+	}
+
+	p.History = make([]model.WorkHistory, len(history))
+	for i := 0; i < len(history); i += 1 {
+		p.History[i] = history[len(history)-(i+1)]
+	}
+	if err := templates.ExecuteTemplate(w, "machine.html", &p); err != nil {
+		klog.Errorf("Template rendering failed: %v", err)
+	}
+}
+
+// viewProviderRedirects redirects a given provider and provider_id into a
+// provider's web portal for more detailed information about an underlying
+// machine.
+func (s *server) viewProviderRedirect(w http.ResponseWriter, r *http.Request, args ...string) {
+	providerUrls := map[string]string{
+		"Equinix": "https://console.equinix.com/devices/%s/overview",
+	}
+	if providerUrls[args[0]] == "" {
+		w.WriteHeader(http.StatusNotFound)
+		fmt.Fprintf(w, "Usage: /provider/Equinix/<id>")
+		return
+	}
+	url := fmt.Sprintf(providerUrls[args[0]], args[1])
+	http.Redirect(w, r, url, http.StatusFound)
+}
+
+// viewSession shows detailed information about a BMDB session.
+func (s *server) viewSession(w http.ResponseWriter, r *http.Request, args ...string) {
+	// TODO(q3k): implement this once we add session info to work history so that
+	// this can actually display something useful.
+	fmt.Fprintf(w, "underconstruction.gif")
+}