diff options
| author | Paul Buetow <paul@buetow.org> | 2026-04-14 09:59:52 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-04-14 09:59:52 +0300 |
| commit | 385cc685ab8f5312225342c66d29aaa3bf008a45 (patch) | |
| tree | b24dd4c8633083db35ef53a100f72cdcb063488f /internal | |
| parent | e2653b1c6c4b27c31c601389f686515605f32074 (diff) | |
Add --daemon plain HTTP server (y2)
Long-lived goprecords --daemon with -stats-dir and -listen, env
GOPRECORDS_STATS_DIR and GOPRECORDS_LISTEN. Serves GET /health and
GET /report (query params match report flags). Uses standard
http.Server; no TLS.
Made-with: Cursor
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/cli/cli.go | 35 | ||||
| -rw-r--r-- | internal/daemon/daemon.go | 107 | ||||
| -rw-r--r-- | internal/daemon/daemon_test.go | 91 | ||||
| -rw-r--r-- | internal/goprecords/report.go | 74 | ||||
| -rw-r--r-- | internal/goprecords/report_test.go | 44 |
5 files changed, 351 insertions, 0 deletions
diff --git a/internal/cli/cli.go b/internal/cli/cli.go index e9e1893..0752806 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -2,10 +2,14 @@ package cli import ( "context" + "errors" "flag" "fmt" "os" + "os/signal" + "syscall" + "codeberg.org/snonux/goprecords/internal/daemon" "codeberg.org/snonux/goprecords/internal/goprecords" "codeberg.org/snonux/goprecords/internal/version" ) @@ -17,6 +21,9 @@ func Execute(args []string) error { fmt.Println(version.Version) return nil } + if len(args) > 0 && (args[0] == "-daemon" || args[0] == "--daemon") { + return runDaemon(args[1:]) + } // No subcommand – treat args as flags for a direct report from files. if len(args) == 0 { @@ -118,3 +125,31 @@ func runReportFromFiles(args []string) error { func runTests() error { return goprecords.RunIntegrationTests("./fixtures") } + +func defaultListenFromEnv() string { + if s := os.Getenv("GOPRECORDS_LISTEN"); s != "" { + return s + } + return ":8080" +} + +func runDaemon(args []string) error { + fs := flag.NewFlagSet("daemon", flag.ExitOnError) + statsDir := fs.String("stats-dir", os.Getenv("GOPRECORDS_STATS_DIR"), "Uptimed stats directory (required; env GOPRECORDS_STATS_DIR)") + listen := fs.String("listen", defaultListenFromEnv(), "TCP listen address (env GOPRECORDS_LISTEN, default :8080)") + if err := fs.Parse(args); err != nil { + return err + } + if *statsDir == "" { + fmt.Fprintln(os.Stderr, "daemon: missing required flag: -stats-dir (or GOPRECORDS_STATS_DIR)") + fs.Usage() + return fmt.Errorf("missing -stats-dir") + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + err := daemon.Run(ctx, daemon.Config{StatsDir: *statsDir, Addr: *listen}) + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + return nil +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go new file mode 100644 index 0000000..4e2402f --- /dev/null +++ b/internal/daemon/daemon.go @@ -0,0 +1,107 @@ +package daemon + +import ( + "bytes" + "context" + "fmt" + "net/http" + "time" + + "codeberg.org/snonux/goprecords/internal/goprecords" +) + +// Config holds daemon HTTP server settings. +type Config struct { + StatsDir string + Addr string +} + +// Handler returns an HTTP handler that serves health checks and reports from statsDir. +func Handler(statsDir string) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/health", health) + mux.HandleFunc("/report", report(statsDir)) + return mux +} + +// Run starts a plain HTTP server until ctx is cancelled or ListenAndServe fails. +func Run(ctx context.Context, cfg Config) error { + if cfg.StatsDir == "" { + return fmt.Errorf("stats directory is required") + } + if cfg.Addr == "" { + return fmt.Errorf("listen address is required") + } + srv := &http.Server{ + Addr: cfg.Addr, + Handler: Handler(cfg.StatsDir), + } + errCh := make(chan error, 1) + go func() { errCh <- srv.ListenAndServe() }() + select { + case <-ctx.Done(): + shutCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = srv.Shutdown(shutCtx) + err := <-errCh + if err != nil && err != http.ErrServerClosed { + return fmt.Errorf("shutdown: %w", err) + } + return ctx.Err() + case err := <-errCh: + if err == http.ErrServerClosed { + return nil + } + if err != nil { + return fmt.Errorf("listen %s: %w", cfg.Addr, err) + } + return nil + } +} + +func health(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) +} + +func report(statsDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + cfg, err := goprecords.ParseReportQuery(r.URL.Query()) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + aggr := goprecords.NewAggregator(statsDir) + aggregates, err := aggr.Aggregate(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var buf bytes.Buffer + if err := goprecords.WriteReports(&buf, aggregates, cfg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", reportContentType(cfg.OutputFormat)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) + } +} + +func reportContentType(f goprecords.OutputFormat) string { + switch f { + case goprecords.FormatMarkdown: + return "text/markdown; charset=utf-8" + default: + return "text/plain; charset=utf-8" + } +} diff --git a/internal/daemon/daemon_test.go b/internal/daemon/daemon_test.go new file mode 100644 index 0000000..38878fa --- /dev/null +++ b/internal/daemon/daemon_test.go @@ -0,0 +1,91 @@ +package daemon + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" +) + +func TestHealth(t *testing.T) { + srv := httptest.NewServer(Handler(t.TempDir())) + defer srv.Close() + res, err := http.Get(srv.URL + "/health") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status %d", res.StatusCode) + } + b, _ := io.ReadAll(res.Body) + if string(b) != "ok\n" { + t.Fatalf("body %q", b) + } +} + +func TestHealthMethodNotAllowed(t *testing.T) { + srv := httptest.NewServer(Handler(t.TempDir())) + defer srv.Close() + req, _ := http.NewRequest(http.MethodPost, srv.URL+"/health", nil) + res, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status %d", res.StatusCode) + } +} + +func TestReport(t *testing.T) { + fixtures := filepath.Join("..", "..", "fixtures") + h := Handler(fixtures) + srv := httptest.NewServer(h) + defer srv.Close() + res, err := http.Get(srv.URL + "/report?category=Host&metric=Boots&limit=3&output-format=Plaintext") + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Fatalf("status %d", res.StatusCode) + } + b, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "Host") { + t.Fatalf("expected report body, got %q", b) + } +} + +func TestReportBadQuery(t *testing.T) { + srv := httptest.NewServer(Handler(t.TempDir())) + defer srv.Close() + res, err := http.Get(srv.URL + "/report?category=Nope") + if err != nil { + t.Fatal(err) + } + res.Body.Close() + if res.StatusCode != http.StatusBadRequest { + t.Fatalf("status %d", res.StatusCode) + } +} + +func TestRunEmptyStatsDir(t *testing.T) { + err := Run(context.Background(), Config{StatsDir: "", Addr: ":0"}) + if err == nil { + t.Fatal("expected error") + } +} + +func TestRunEmptyAddr(t *testing.T) { + err := Run(context.Background(), Config{StatsDir: t.TempDir(), Addr: ""}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/goprecords/report.go b/internal/goprecords/report.go index 148563c..939f0e9 100644 --- a/internal/goprecords/report.go +++ b/internal/goprecords/report.go @@ -4,7 +4,9 @@ import ( "flag" "fmt" "io" + "net/url" "sort" + "strconv" "strings" ) @@ -68,6 +70,78 @@ func (rf *ReportFlags) Parse() (ReportConfig, error) { }, nil } +// ParseReportQuery builds a ReportConfig from URL query parameters using the +// same names and defaults as RegisterReportFlags (category, metric, limit, +// output-format, all, include-kernel, stats-order). +func ParseReportQuery(q url.Values) (ReportConfig, error) { + catStr := q.Get("category") + if catStr == "" { + catStr = "Host" + } + cat, err := ParseCategory(catStr) + if err != nil { + return ReportConfig{}, err + } + metStr := q.Get("metric") + if metStr == "" { + metStr = "Uptime" + } + met, err := ParseMetric(metStr) + if err != nil { + return ReportConfig{}, err + } + limit := uint(20) + if ls := q.Get("limit"); ls != "" { + v, err := strconv.ParseUint(ls, 10, 32) + if err != nil { + return ReportConfig{}, fmt.Errorf("invalid limit %q", ls) + } + limit = uint(v) + } + outStr := q.Get("output-format") + if outStr == "" { + outStr = "Plaintext" + } + outFmt, err := ParseOutputFormat(outStr) + if err != nil { + return ReportConfig{}, err + } + all := false + if v := q.Get("all"); v != "" { + all, err = parseQueryBool(v) + if err != nil { + return ReportConfig{}, err + } + } + includeKernel := false + if v := q.Get("include-kernel"); v != "" { + includeKernel, err = parseQueryBool(v) + if err != nil { + return ReportConfig{}, err + } + } + return ReportConfig{ + Category: cat, + Metric: met, + Limit: limit, + OutputFormat: outFmt, + All: all, + IncludeKernel: includeKernel, + StatsOrder: q.Get("stats-order"), + }, nil +} + +func parseQueryBool(s string) (bool, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "true", "1", "yes": + return true, nil + case "false", "0", "no", "": + return false, nil + default: + return false, fmt.Errorf("invalid boolean %q", s) + } +} + // WriteReports renders reports to w based on the given config. func WriteReports(w io.Writer, aggregates *Aggregates, cfg ReportConfig) error { if !cfg.All { diff --git a/internal/goprecords/report_test.go b/internal/goprecords/report_test.go index 2daedec..c068ce9 100644 --- a/internal/goprecords/report_test.go +++ b/internal/goprecords/report_test.go @@ -2,6 +2,7 @@ package goprecords import ( "bytes" + "net/url" "strings" "testing" ) @@ -304,6 +305,49 @@ func testAggregates() *Aggregates { return aggs } +func TestParseReportQuery(t *testing.T) { + q := url.Values{} + cfg, err := ParseReportQuery(q) + if err != nil { + t.Fatal(err) + } + if cfg.Category != CategoryHost || cfg.Metric != MetricUptime || cfg.Limit != 20 { + t.Fatalf("defaults: %+v", cfg) + } + if cfg.OutputFormat != FormatPlaintext || cfg.All || cfg.IncludeKernel || cfg.StatsOrder != "" { + t.Fatalf("defaults: %+v", cfg) + } + q.Set("category", "Kernel") + q.Set("metric", "Boots") + q.Set("limit", "5") + q.Set("output-format", "Markdown") + q.Set("all", "true") + q.Set("include-kernel", "1") + q.Set("stats-order", "Host:Uptime") + cfg, err = ParseReportQuery(q) + if err != nil { + t.Fatal(err) + } + if cfg.Category != CategoryKernel || cfg.Metric != MetricBoots || cfg.Limit != 5 { + t.Fatalf("got %+v", cfg) + } + if cfg.OutputFormat != FormatMarkdown || !cfg.All || !cfg.IncludeKernel || cfg.StatsOrder != "Host:Uptime" { + t.Fatalf("got %+v", cfg) + } + _, err = ParseReportQuery(url.Values{"category": []string{"nope"}}) + if err == nil { + t.Fatal("expected error") + } + _, err = ParseReportQuery(url.Values{"limit": []string{"x"}}) + if err == nil { + t.Fatal("expected error") + } + _, err = ParseReportQuery(url.Values{"all": []string{"maybe"}}) + if err == nil { + t.Fatal("expected error") + } +} + func hostName(i int) string { switch i { case 0: |
