Serge Bazanski | 7762831 | 2023-02-15 23:33:22 +0100 | [diff] [blame] | 1 | // Package webug implements a web-based debug/troubleshooting/introspection |
| 2 | // system for the BMDB. It's optimized for use by developers and trained |
| 3 | // operators, prioritizing information density, fast navigation and heavy |
| 4 | // interlinking. |
| 5 | package webug |
| 6 | |
| 7 | import ( |
| 8 | "context" |
| 9 | "embed" |
| 10 | "flag" |
| 11 | "fmt" |
| 12 | "html/template" |
| 13 | "net/http" |
| 14 | "regexp" |
| 15 | "sync" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/cenkalti/backoff/v4" |
| 19 | "k8s.io/klog/v2" |
| 20 | |
| 21 | "source.monogon.dev/cloud/bmaas/bmdb" |
| 22 | "source.monogon.dev/cloud/bmaas/bmdb/reflection" |
| 23 | ) |
| 24 | |
| 25 | var ( |
Tim Windelschmidt | 93b6fad | 2023-05-04 16:35:17 +0200 | [diff] [blame^] | 26 | //go:embed templates/*.gohtml |
Serge Bazanski | 7762831 | 2023-02-15 23:33:22 +0100 | [diff] [blame] | 27 | templateFS embed.FS |
Tim Windelschmidt | 93b6fad | 2023-05-04 16:35:17 +0200 | [diff] [blame^] | 28 | templates = template.Must(template.New("base.gohtml").Funcs(templateFuncs).ParseFS(templateFS, "templates/*")) |
Serge Bazanski | 7762831 | 2023-02-15 23:33:22 +0100 | [diff] [blame] | 29 | ) |
| 30 | |
| 31 | // server holds the state of an active webug interface. |
| 32 | type server struct { |
| 33 | // connection pool to BMDB. |
| 34 | conn *bmdb.Connection |
| 35 | // schema retrieved from BMDB. |
| 36 | schema *reflection.Schema |
| 37 | // muSchema locks schema for updates. |
| 38 | muSchema sync.RWMutex |
| 39 | } |
| 40 | |
| 41 | // curSchema returns the current cached BMDB schema. |
| 42 | func (s *server) curSchema() *reflection.Schema { |
| 43 | s.muSchema.Lock() |
| 44 | defer s.muSchema.Unlock() |
| 45 | return s.schema |
| 46 | } |
| 47 | |
| 48 | // schemaWorker runs a background goroutine which attempts to update the server's |
| 49 | // cached BMDB schema every hour. |
| 50 | func (s *server) schemaWorker(ctx context.Context) { |
| 51 | t := time.NewTicker(time.Hour) |
| 52 | defer t.Stop() |
| 53 | |
| 54 | for { |
| 55 | // Wait for the timer to tick, or for the context to expire. |
| 56 | select { |
| 57 | case <-ctx.Done(): |
| 58 | klog.Infof("Schema fetch worker exiting: %v", ctx.Err()) |
| 59 | return |
| 60 | case <-t.C: |
| 61 | } |
| 62 | |
| 63 | // Time to check the schema. Do that in an exponential backoff loop until |
| 64 | // successful. |
| 65 | bo := backoff.NewExponentialBackOff() |
| 66 | bo.MaxElapsedTime = 0 |
| 67 | var schema *reflection.Schema |
| 68 | err := backoff.Retry(func() error { |
| 69 | var err error |
| 70 | schema, err = s.conn.Reflect(ctx) |
| 71 | if err != nil { |
| 72 | klog.Warningf("Failed to fetch new schema: %v", err) |
| 73 | return err |
| 74 | } |
| 75 | return nil |
| 76 | }, backoff.WithContext(bo, ctx)) |
| 77 | // This will only happen due to context expiration. |
| 78 | if err != nil { |
| 79 | klog.Errorf("Giving up on schema fetch: %v", err) |
| 80 | continue |
| 81 | } |
| 82 | |
| 83 | // Swap the current schema if necessary. |
| 84 | cur := s.curSchema().Version |
| 85 | new := schema.Version |
| 86 | if cur != new { |
| 87 | klog.Infof("Got new schema: %s -> %s", cur, new) |
| 88 | s.muSchema.Lock() |
| 89 | s.schema = schema |
| 90 | s.muSchema.Unlock() |
| 91 | } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | // Register webug on an HTTP mux, using a BMDB connection pool. |
| 96 | // |
| 97 | // The given context will be used not only to time out the registration call, but |
| 98 | // also used to run a BMDB schema fetching goroutine that will attempt to fetch |
| 99 | // newer versions of the schema every hour. |
| 100 | // |
| 101 | // This is a low-level function useful when tying webug into an existing web |
| 102 | // application. If you just want to run webug on a separate port that's |
| 103 | // configured by flags, use Config and Config.RegisterFlags. |
| 104 | func Register(ctx context.Context, conn *bmdb.Connection, mux *http.ServeMux) error { |
| 105 | schema, err := conn.Reflect(ctx) |
| 106 | if err != nil { |
| 107 | return fmt.Errorf("could not get BMDB schema for webug: %w", err) |
| 108 | } |
| 109 | s := server{ |
| 110 | conn: conn, |
| 111 | schema: schema, |
| 112 | } |
| 113 | go s.schemaWorker(ctx) |
| 114 | |
| 115 | type route struct { |
| 116 | pattern *regexp.Regexp |
| 117 | handler func(w http.ResponseWriter, r *http.Request, args ...string) |
| 118 | } |
| 119 | |
| 120 | routes := []route{ |
| 121 | {regexp.MustCompile(`^/$`), s.viewMachines}, |
| 122 | {regexp.MustCompile(`^/machine/([a-fA-F0-9\-]+)$`), s.viewMachineDetail}, |
| 123 | {regexp.MustCompile(`^/provider/([^/]+)/([^/]+)$`), s.viewProviderRedirect}, |
| 124 | {regexp.MustCompile(`^/session/([^/]+)`), s.viewSession}, |
| 125 | } |
| 126 | |
| 127 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { |
| 128 | for _, route := range routes { |
| 129 | match := route.pattern.FindStringSubmatch(r.URL.Path) |
| 130 | if match == nil { |
| 131 | continue |
| 132 | } |
| 133 | route.handler(w, r, match[1:]...) |
| 134 | return |
| 135 | } |
| 136 | http.NotFound(w, r) |
| 137 | }) |
| 138 | return nil |
| 139 | } |
| 140 | |
| 141 | // Config describes the webug interface configuration. This should be embedded |
| 142 | // inside your component's Config object. |
| 143 | // |
| 144 | // To configure, either set values or call RegisterFlags before flag.Parse. |
| 145 | // |
| 146 | // To run after configuration, call Start. |
| 147 | type Config struct { |
| 148 | // If set, start a webug interface on an HTTP listener bound to the given address. |
| 149 | WebugListenAddress string |
| 150 | } |
| 151 | |
| 152 | // RegisterFlags for webug interface. |
| 153 | func (c *Config) RegisterFlags() { |
| 154 | flag.StringVar(&c.WebugListenAddress, "webug_listen_address", "", "Address to start BMDB webug on. If not set, webug will not be started.") |
| 155 | } |
| 156 | |
| 157 | // Start the webug interface in the foreground if enabled. The returned error |
| 158 | // will be either a configuration/connection error returned as soon as possible, |
| 159 | // or a context expiration error. |
| 160 | // |
| 161 | // The given context will be used for all connections from the webug interface to |
| 162 | // the given BMDB connection. |
| 163 | func (c *Config) Start(ctx context.Context, conn *bmdb.Connection) error { |
| 164 | if c.WebugListenAddress == "" { |
| 165 | return nil |
| 166 | } |
| 167 | mux := http.NewServeMux() |
| 168 | if err := Register(ctx, conn, mux); err != nil { |
| 169 | return err |
| 170 | } |
| 171 | |
| 172 | klog.Infof("Webug listening at %s", c.WebugListenAddress) |
| 173 | return http.ListenAndServe(c.WebugListenAddress, mux) |
| 174 | } |