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/webug.go b/cloud/bmaas/bmdb/webug/webug.go
new file mode 100644
index 0000000..91888d5
--- /dev/null
+++ b/cloud/bmaas/bmdb/webug/webug.go
@@ -0,0 +1,174 @@
+// Package webug implements a web-based debug/troubleshooting/introspection
+// system for the BMDB. It's optimized for use by developers and trained
+// operators, prioritizing information density, fast navigation and heavy
+// interlinking.
+package webug
+
+import (
+	"context"
+	"embed"
+	"flag"
+	"fmt"
+	"html/template"
+	"net/http"
+	"regexp"
+	"sync"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"k8s.io/klog/v2"
+
+	"source.monogon.dev/cloud/bmaas/bmdb"
+	"source.monogon.dev/cloud/bmaas/bmdb/reflection"
+)
+
+var (
+	//go:embed templates/*html
+	templateFS embed.FS
+	templates  = template.Must(template.New("base.html").Funcs(templateFuncs).ParseFS(templateFS, "templates/*"))
+)
+
+// server holds the state of an active webug interface.
+type server struct {
+	// connection pool to BMDB.
+	conn *bmdb.Connection
+	// schema retrieved from BMDB.
+	schema *reflection.Schema
+	// muSchema locks schema for updates.
+	muSchema sync.RWMutex
+}
+
+// curSchema returns the current cached BMDB schema.
+func (s *server) curSchema() *reflection.Schema {
+	s.muSchema.Lock()
+	defer s.muSchema.Unlock()
+	return s.schema
+}
+
+// schemaWorker runs a background goroutine which attempts to update the server's
+// cached BMDB schema every hour.
+func (s *server) schemaWorker(ctx context.Context) {
+	t := time.NewTicker(time.Hour)
+	defer t.Stop()
+
+	for {
+		// Wait for the timer to tick, or for the context to expire.
+		select {
+		case <-ctx.Done():
+			klog.Infof("Schema fetch worker exiting: %v", ctx.Err())
+			return
+		case <-t.C:
+		}
+
+		// Time to check the schema. Do that in an exponential backoff loop until
+		// successful.
+		bo := backoff.NewExponentialBackOff()
+		bo.MaxElapsedTime = 0
+		var schema *reflection.Schema
+		err := backoff.Retry(func() error {
+			var err error
+			schema, err = s.conn.Reflect(ctx)
+			if err != nil {
+				klog.Warningf("Failed to fetch new schema: %v", err)
+				return err
+			}
+			return nil
+		}, backoff.WithContext(bo, ctx))
+		// This will only happen due to context expiration.
+		if err != nil {
+			klog.Errorf("Giving up on schema fetch: %v", err)
+			continue
+		}
+
+		// Swap the current schema if necessary.
+		cur := s.curSchema().Version
+		new := schema.Version
+		if cur != new {
+			klog.Infof("Got new schema: %s -> %s", cur, new)
+			s.muSchema.Lock()
+			s.schema = schema
+			s.muSchema.Unlock()
+		}
+	}
+}
+
+// Register webug on an HTTP mux, using a BMDB connection pool.
+//
+// The given context will be used not only to time out the registration call, but
+// also used to run a BMDB schema fetching goroutine that will attempt to fetch
+// newer versions of the schema every hour.
+//
+// This is a low-level function useful when tying webug into an existing web
+// application. If you just want to run webug on a separate port that's
+// configured by flags, use Config and Config.RegisterFlags.
+func Register(ctx context.Context, conn *bmdb.Connection, mux *http.ServeMux) error {
+	schema, err := conn.Reflect(ctx)
+	if err != nil {
+		return fmt.Errorf("could not get BMDB schema for webug: %w", err)
+	}
+	s := server{
+		conn:   conn,
+		schema: schema,
+	}
+	go s.schemaWorker(ctx)
+
+	type route struct {
+		pattern *regexp.Regexp
+		handler func(w http.ResponseWriter, r *http.Request, args ...string)
+	}
+
+	routes := []route{
+		{regexp.MustCompile(`^/$`), s.viewMachines},
+		{regexp.MustCompile(`^/machine/([a-fA-F0-9\-]+)$`), s.viewMachineDetail},
+		{regexp.MustCompile(`^/provider/([^/]+)/([^/]+)$`), s.viewProviderRedirect},
+		{regexp.MustCompile(`^/session/([^/]+)`), s.viewSession},
+	}
+
+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		for _, route := range routes {
+			match := route.pattern.FindStringSubmatch(r.URL.Path)
+			if match == nil {
+				continue
+			}
+			route.handler(w, r, match[1:]...)
+			return
+		}
+		http.NotFound(w, r)
+	})
+	return nil
+}
+
+// Config describes the webug interface configuration. This should be embedded
+// inside your component's Config object.
+//
+// To configure, either set values or call RegisterFlags before flag.Parse.
+//
+// To run after configuration, call Start.
+type Config struct {
+	// If set, start a webug interface on an HTTP listener bound to the given address.
+	WebugListenAddress string
+}
+
+// RegisterFlags for webug interface.
+func (c *Config) RegisterFlags() {
+	flag.StringVar(&c.WebugListenAddress, "webug_listen_address", "", "Address to start BMDB webug on. If not set, webug will not be started.")
+}
+
+// Start the webug interface in the foreground if enabled. The returned error
+// will be either a configuration/connection error returned as soon as possible,
+// or a context expiration error.
+//
+// The given context will be used for all connections from the webug interface to
+// the given BMDB connection.
+func (c *Config) Start(ctx context.Context, conn *bmdb.Connection) error {
+	if c.WebugListenAddress == "" {
+		return nil
+	}
+	mux := http.NewServeMux()
+	if err := Register(ctx, conn, mux); err != nil {
+		return err
+	}
+
+	klog.Infof("Webug listening at %s", c.WebugListenAddress)
+	return http.ListenAndServe(c.WebugListenAddress, mux)
+}