blob: e6e1be7de6024213f3dec83a4efd67e009e27a08 [file] [log] [blame]
// 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/*.gohtml
templateFS embed.FS
templates = template.Must(template.New("base.gohtml").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
// strictConsistency, when enabled, makes webug render its views with the
// freshest available data, potentially conflicting with online
// transactions. This should only be enabled during testing, as it tends to
// clog up the database query planner and make everything slow.
strictConsistency bool
}
// 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, enableStrictConsistency bool) 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,
strictConsistency: enableStrictConsistency,
}
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
// Enables strict consistency
WebugDBFetchStrict bool
}
// 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.")
flag.BoolVar(&c.WebugDBFetchStrict, "webug_dbfetch_strict", false, "Enables strict consistency")
}
// 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, c.WebugDBFetchStrict); err != nil {
return err
}
klog.Infof("Webug listening at %s", c.WebugListenAddress)
return http.ListenAndServe(c.WebugListenAddress, mux)
}