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)
+}