jrouter/status/status.go

313 lines
7.7 KiB
Go

// Package status provides a status page handler, for exposing a summary of what
// the various pieces of the agent are doing.
//
// Inspired heavily by Google "/statsuz" - one public example is at:
// https://github.com/youtube/doorman/blob/master/go/status/status.go
package status
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"os"
"os/user"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
)
const errorTmplSrc = `<div class="error">❌ {{.Operation}}: <code>{{.Error}}</code><br>
Raw item data:<br>
<pre>{{.Item | printJSON}}</pre>
</div>`
var (
//go:embed status.html.tmpl
statusTmplSrc string
// Errors ignored below, as the status page is "best effort".
hostname, _ = os.Hostname()
username = func() string {
user, err := user.Current()
if err != nil {
return fmt.Sprintf("unknown (uid=unknown; error=%v)", err)
}
return fmt.Sprintf("%s (uid=%s)", user.Username, user.Uid)
}()
exepath, exename = func() (string, string) {
exe, _ := os.Executable()
if exe == "" {
return "(unknown)", "(unknown)"
}
return exe, filepath.Base(exe)
}()
startTime = time.Now()
rootItem = &simpleItem{
baseItem: baseItem{
items: make(map[string]item),
},
}
funcMap = template.FuncMap{
"printJSON": printJSON,
}
// The inbuilt templates should always parse. Rather than use template.Must,
// successful parsing is enforced by the smoke tests.
statusTmpl, _ = template.New("status").Funcs(funcMap).Parse(statusTmplSrc)
errorTmpl, _ = template.New("item-error").Funcs(funcMap).Parse(errorTmplSrc)
)
type statusData struct {
Items map[string]item
Version string
Build string
Hostname string
Username string
ExeName string
ExePath string
PID int
Compiler string
RuntimeVer string
GOOS string
GOARCH string
NumCPU int
NumGoroutine int
StartTime string
StartTimeAgo time.Duration
CurrentTime string
Ctx context.Context // request context for Eval calls inside the template execution only
}
type errorData struct {
Operation string
Error error
Item any
}
type item interface {
addSubItem(string, item)
delSubItem(string)
Eval(context.Context) template.HTML
Items() map[string]item
}
type itemCtxKey struct{}
func parentItem(ctx context.Context) item {
v := ctx.Value(itemCtxKey{})
if v == nil {
return rootItem
}
return v.(item)
}
type baseItem struct {
mu sync.RWMutex
items map[string]item
}
func (i *baseItem) addSubItem(title string, sub item) {
i.mu.Lock()
defer i.mu.Unlock()
i.items[title] = sub
}
func (i *baseItem) delSubItem(title string) {
i.mu.Lock()
defer i.mu.Unlock()
delete(i.items, title)
}
func (i *baseItem) Items() map[string]item {
i.mu.RLock()
defer i.mu.RUnlock()
icopy := make(map[string]item, len(i.items))
for k, v := range i.items {
icopy[k] = v
}
return icopy
}
// SimpleItem is for untemplated status items that only report a simple non-HTML string.
type simpleItem struct {
baseItem
stat string
}
// SetStatus sets the status of the item.
func (i *simpleItem) setStatus(s string) {
i.mu.Lock()
defer i.mu.Unlock()
i.stat = s
}
// Eval escapes the status string, and returns the current item value.
func (i *simpleItem) Eval(ctx context.Context) template.HTML {
i.mu.RLock()
defer i.mu.RUnlock()
return template.HTML(template.HTMLEscapeString(i.stat))
}
// ItemCallback funcs are used by templated status items to provide the value to
// hydrate the item template.
type ItemCallback = func(context.Context) (any, error)
// templatedItem uses a template to format the item.
type templatedItem struct {
baseItem
tmpl *template.Template
cb ItemCallback
}
// Eval calls the item callback, and feeds the result through the item's
// own template.
func (i *templatedItem) Eval(ctx context.Context) template.HTML {
var sb strings.Builder
data, err := i.cb(ctx)
if err != nil {
errorTmpl.Execute(&sb, errorData{
Operation: "Error from item callback",
Error: err,
Item: data,
})
return template.HTML(sb.String())
}
if err := i.tmpl.Execute(&sb, data); err != nil {
errorTmpl.Execute(&sb, errorData{
Operation: "Error while executing item template",
Error: err,
Item: data,
})
}
return template.HTML(sb.String())
}
// Handle handles status page requests.
func Handle(w http.ResponseWriter, r *http.Request) {
data := &statusData{
Items: rootItem.items,
Version: "TODO",
Build: "TODO",
Hostname: hostname,
Username: username,
ExeName: exename,
ExePath: exepath,
PID: os.Getpid(),
Compiler: runtime.Compiler,
RuntimeVer: runtime.Version(),
GOOS: runtime.GOOS,
GOARCH: runtime.GOARCH,
NumCPU: runtime.NumCPU(),
NumGoroutine: runtime.NumGoroutine(),
StartTime: startTime.Format(time.RFC1123),
StartTimeAgo: time.Since(startTime),
CurrentTime: time.Now().Format(time.RFC1123),
Ctx: r.Context(),
}
// The status template ranges over the items.
rootItem.mu.RLock()
defer rootItem.mu.RUnlock()
if err := statusTmpl.Execute(w, data); err != nil {
errorTmpl.Execute(w, errorData{
Operation: "Error while executing main template",
Error: err,
Item: data,
})
}
}
// AddItem adds an item to be displayed on the status page. On each page load,
// the item's callback is called, and the data returned used to fill the
// HTML template in tmpl. The title should be unique (among items under this
// parent).
func AddItem(parent context.Context, title, tmpl string, cb func(context.Context) (any, error)) (context.Context, func()) {
if cb == nil {
cb = func(context.Context) (any, error) { return nil, nil }
}
item := &templatedItem{
baseItem: baseItem{
items: make(map[string]item),
},
tmpl: template.New(title).Funcs(funcMap),
cb: cb,
}
if _, err := item.tmpl.Parse(tmpl); err != nil {
// Insert an item, but swap the template for the error template, and
// wrap the callback's return in an errorData.
item.tmpl = errorTmpl
item.cb = wrapInItemError("Could not parse item template", err, cb)
}
pitem := parentItem(parent)
pitem.addSubItem(title, item)
return context.WithValue(parent, itemCtxKey{}, item), func() { pitem.delSubItem(title) }
}
// AddSimpleItem adds a simple status item. Set the value shown by the
// item by calling setStatus.
func AddSimpleItem(parent context.Context, title string) (ctx context.Context, setStatus func(string), done func()) {
item := &simpleItem{
baseItem: baseItem{
items: make(map[string]item),
},
stat: "Unknown status",
}
pitem := parentItem(parent)
pitem.addSubItem(title, item)
return context.WithValue(parent, itemCtxKey{}, item), item.setStatus, func() { pitem.delSubItem(title) }
}
// DelItem removes a status item, specified by the title, from a parent context.
func DelItem(parent context.Context, title string) {
pitem := item(rootItem)
if v := parent.Value(itemCtxKey{}); v != nil {
pitem = v.(item)
}
pitem.delSubItem(title)
}
// wrapInItemError takes a callback and returns a new callback wrapping the
// result in an errorData.
func wrapInItemError(op string, err error, cb ItemCallback) ItemCallback {
return func(ctx context.Context) (any, error) {
if cb == nil {
return &errorData{
Operation: op,
Error: err,
}, nil
}
// Surface item callback errors first.
data, err2 := cb(ctx)
if err2 != nil {
return data, err2
}
// Surface the item template parse error.
return &errorData{
Operation: op,
Error: err,
Item: data,
}, nil
}
}
// printJSON is used as a fallback renderer for item status values.
func printJSON(v any) (string, error) {
b, err := json.MarshalIndent(v, "", " ")
return string(b), err
}