diff --git a/main.go b/main.go index a9e9989..437acf0 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ import ( "gitea.drjosh.dev/josh/jrouter/aurp" "gitea.drjosh.dev/josh/jrouter/router" + "gitea.drjosh.dev/josh/jrouter/status" "github.com/google/gopacket/pcap" "github.com/sfiera/multitalk/pkg/ddp" @@ -102,7 +103,13 @@ func main() { defer cancel() ctx, _ := signal.NotifyContext(cctx, os.Interrupt) - // Open PCAP session + // --------------------------------- HTTP --------------------------------- + http.HandleFunc("/status", status.Handle) + go func() { + log.Print(http.ListenAndServe(":9459", nil)) + }() + + // --------------------------------- Pcap --------------------------------- // First check the interface iface, err := net.InterfaceByName(cfg.EtherTalk.Device) if err != nil { @@ -129,6 +136,12 @@ func main() { } defer pcapHandle.Close() + // -------------------------------- Tables -------------------------------- + routes := router.NewRoutingTable() + zones := router.NewZoneTable() + zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true) + + // -------------------------------- Peers --------------------------------- // Wait until all peer handlers have finished before closing the port var handlersWG sync.WaitGroup defer func() { @@ -144,11 +157,6 @@ func main() { }() } - // -------------------------------- Tables -------------------------------- - routes := router.NewRoutingTable() - zones := router.NewZoneTable() - zones.Upsert(cfg.EtherTalk.NetStart, cfg.EtherTalk.ZoneName, true) - // ------------------------- Configured peer setup ------------------------ if cfg.PeerListURL != "" { log.Printf("Fetching peer list from %s...", cfg.PeerListURL) diff --git a/status/status.go b/status/status.go new file mode 100644 index 0000000..bd12e70 --- /dev/null +++ b/status/status.go @@ -0,0 +1,303 @@ +// 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" + "runtime" + "strings" + "sync" + "time" +) + +const errorTmplSrc = `
❌ {{.Operation}}: {{.Error}}
+Raw item data:
+
{{.Item | printJSON}}
+
` + +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, _ = os.Executable() + 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 + 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, + 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 +} diff --git a/status/status.html.tmpl b/status/status.html.tmpl new file mode 100644 index 0000000..b73d936 --- /dev/null +++ b/status/status.html.tmpl @@ -0,0 +1,81 @@ +{{- define "render_items" -}} + {{- range $title, $item := . -}} +
+ {{$title}} +
{{$item.Eval $.Ctx}}
+
{{template "render_items" $item.Items}}
+
+ {{- end -}} +{{- end -}} + + + + Status for buildkite-agent + + + +

Status for buildkite-agent

+
+ ⚠️ This page is intended only for debugging purposes!
+ Do not expect the structure and formatting of this page to be stable across versions. +
+
+ {{.ExePath}}
+ Started at {{.StartTime}} ({{.StartTimeAgo}} ago)
+ Current time {{.CurrentTime}}
+ Version {{.Version}}, build {{.Build}}
+ Running in PID {{.PID}} as {{.Username}} on {{.Hostname}}
+ {{.RuntimeVer}} on {{.GOOS}}/{{.GOARCH}} compiled by {{.Compiler}}
+ {{.NumGoroutine}} goroutines running/{{.NumCPU}} logical CPUs usable
+
+ {{template "render_items" .Items}} + + diff --git a/status/status_test.go b/status/status_test.go new file mode 100644 index 0000000..4fda0e2 --- /dev/null +++ b/status/status_test.go @@ -0,0 +1,88 @@ +package status + +import ( + "context" + "errors" + "html/template" + "io" + "net/http" + "net/http/httptest" + "os" + "runtime" + "testing" + "time" +) + +func TestSmokeErrorTemplate(t *testing.T) { + errData := &errorData{ + Operation: "Couldn't fluff the llamas", + Error: errors.New("llama comb unavailable"), + Item: map[string]any{ + "llamas": "yes", + "alpacas": 42, + }, + } + + if err := errorTmpl.Execute(io.Discard, errData); err != nil { + t.Errorf("errorTmpl.Execute(io.Discard, errData) = %v", err) + } +} + +func TestSmokeStatusTemplate(t *testing.T) { + data := &statusData{ + Items: map[string]item{ + "Llamas": &simpleItem{ + stat: "✅ Llamas enabled", + }, + "Alpacas": &templatedItem{ + tmpl: template.Must(template.New("alpacas").Parse("Alpacas enabled at: {{.AlpacasEnabled}}")), + cb: func(context.Context) (any, error) { + return struct { + AlpacasEnabled time.Time + }{time.Now()}, nil + }, + }, + }, + Version: "0.0.0", + Build: "1234", + Hostname: hostname, + Username: username, + 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: context.Background(), + } + + if err := statusTmpl.Execute(io.Discard, data); err != nil { + t.Errorf("statusData.Execute(io.Discard, data) = %v", err) + } +} + +func TestSmokeHandle(t *testing.T) { + ctx := context.Background() + cctx, setStat, done := AddSimpleItem(ctx, "Llamas") + defer done() + setStat("Essence of Llama") + + _, setStat2, done2 := AddSimpleItem(cctx, "Kuzco") + defer done2() + setStat2("Oh, right. The poison. The poison for Kuzco, the poison chosen especially to kill Kuzco, Kuzco's poison.") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/status", nil) + if err != nil { + t.Fatalf("http.NewReqeustWithContext(GET /status) error = %v", err) + } + rec := httptest.NewRecorder() + Handle(rec, req) + if got, want := rec.Result().StatusCode, http.StatusOK; got != want { + t.Errorf("Handle(rec, req): rec.Result().StatusCode = %v, want %v", got, want) + } +}