summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-04-14 09:59:52 +0300
committerPaul Buetow <paul@buetow.org>2026-04-14 09:59:52 +0300
commit385cc685ab8f5312225342c66d29aaa3bf008a45 (patch)
treeb24dd4c8633083db35ef53a100f72cdcb063488f /internal
parente2653b1c6c4b27c31c601389f686515605f32074 (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.go35
-rw-r--r--internal/daemon/daemon.go107
-rw-r--r--internal/daemon/daemon_test.go91
-rw-r--r--internal/goprecords/report.go74
-rw-r--r--internal/goprecords/report_test.go44
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: