From 6db8684c02e4052483d10f1b233d883e0ccda9f7 Mon Sep 17 00:00:00 2001 From: bigbes Date: Sat, 18 Jul 2026 21:26:48 +0300 Subject: [PATCH] cmd/comparesrht: daemon entry point, startup validation, dev stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the compare.sr.ht daemon on core-go's server.New: it runs crypto.InitCrypto, parses -b/-d/-m/-p, and drives the standard SourceHut warm-shutdown loop (SIGINT). We deliberately skip WithDefaultMiddleware (it demands Postgres/Redis and 401s anonymous viewers) and instead install the web package's documented middleware chain — RealIP, Recoverer, Logger, config.Middleware, authz.Middleware — inside a Group on the anonymous router. validateConfig checks every required key up front (network-key, webhook key, repos root, a git.sr.ht API origin candidate, meta and compare origins) with a single clear fatal, so a misconfiguration fails loudly at startup rather than as a deep panic in config.GetAPI on the first request. A build-and-exec smoke test proves the binary starts against a synthesized config, answers /healthz 200, and exits cleanly on SIGINT in under 15s. Makefile: build ./comparesrht from ./cmd/comparesrht, run-dev binds localhost:5090 against a local config.ini, install ships the binary and static assets. contrib/dev-stub is a stdlib-only fake git.sr.ht GraphQL API returning fixed public repos for local development, and the systemd unit now stops with KillSignal=SIGINT to hit the warm-shutdown path. --- Makefile | 19 ++-- cmd/comparesrht/main.go | 172 +++++++++++++++++++++++++++++++++++ cmd/comparesrht/main_test.go | 148 ++++++++++++++++++++++++++++++ contrib/compare-srht.service | 8 +- contrib/dev-stub/main.go | 118 ++++++++++++++++++++++++ 5 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 cmd/comparesrht/main.go create mode 100644 cmd/comparesrht/main_test.go create mode 100644 contrib/dev-stub/main.go diff --git a/Makefile b/Makefile index d7afe919e4197b2fb7eb2327a76f8a303fda7c24..72b9e17a101f95c4a5271c7d5c85ca1a4e13a4c9 100644 --- a/Makefile +++ b/Makefile @@ -19,11 +19,9 @@ SASSC_INCLUDE=-I$(ASSETS)/scss all: build -# Compile the service. Intentionally tolerant: cmd/ does not exist until -# phase 3, so this is a no-op until then. +# Compile the service into ./comparesrht. build: - @if [ -d cmd ]; then go build ./cmd/...; else \ - echo "cmd/ not present yet (added in phase 3); nothing to build"; fi + go build -o $(BIN) ./cmd/$(BIN) test: go test ./... @@ -51,13 +49,16 @@ bundle: npx esbuild src/app.ts --bundle --minify --format=esm \ --outfile=../web/static/bundle.js -# Local development run against ./config.ini. Fails until cmd/ exists (phase 3). -run-dev: - go run ./cmd/$(BIN) +# Local development run. Requires a ./config.ini in the working directory (or +# ../config.ini, /etc/sr.ht/config.ini): copy config.example.ini and fill in the +# instance's shared [sr.ht]/[webhooks] keys, plus a [git.sr.ht] repos root and an +# api-origin pointing at a real (or stubbed, see contrib/dev-stub) GraphQL API. +# See the "Development" section of README.md for the full recipe. +run-dev: build + ./$(BIN) -b localhost:5090 install: build - mkdir -p $(DESTDIR)$(BINDIR) - go build -o $(DESTDIR)$(BINDIR)/$(BIN) ./cmd/$(BIN) + install -Dm755 $(BIN) $(DESTDIR)$(BINDIR)/$(BIN) mkdir -p $(DESTDIR)$(STATICDIR) install -Dm644 -t $(DESTDIR)$(STATICDIR) web/static/* diff --git a/cmd/comparesrht/main.go b/cmd/comparesrht/main.go new file mode 100644 index 0000000000000000000000000000000000000000..e45cf17ea55c200a1c01df65fe13afca3c2ded2d --- /dev/null +++ b/cmd/comparesrht/main.go @@ -0,0 +1,172 @@ +// Command comparesrht is the compare.sr.ht daemon: a stateless HTTP service +// that renders git ref-to-ref diffs and single-commit views for a self-hosted +// SourceHut instance. +// +// It reuses core-go's server.New assembly so it shares the standard SourceHut +// daemon lifecycle (crypto initialization, -b/-m/-p flags, warm shutdown on +// SIGINT) with the rest of the fleet, but it deliberately does NOT call +// WithDefaultMiddleware: compare.sr.ht owns no Postgres or Redis and must serve +// anonymous viewers, so it installs its own lightweight middleware group on the +// anonymous router instead (see the web package documentation for the exact +// chain). +// +// Flags (parsed by core-go's server.New): +// +// -b addr bind address (repeatable); default 127.0.0.1:5090 +// -d debug (verbose request logging in core-go) +// -m addr Prometheus metrics bind (default random port) +// -p addr pprof bind (default random localhost port) +// +// Configuration is loaded from the shared SourceHut config.ini via core-go's +// fixed search path (./config.ini, ../config.ini, /etc/sr.ht/config.ini, +// /etc/sr.ht/*.ini). All required keys are validated up front with clear +// messages so a misconfiguration fails loudly at startup rather than as a deep +// panic on the first request. +package main + +import ( + "fmt" + "os" + "strings" + "time" + + "git.sr.ht/~sircmpwn/core-go/config" + coreserver "git.sr.ht/~sircmpwn/core-go/server" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/sirupsen/logrus" + "github.com/vaughan0/go-ini" + + "go.bigb.es/sourcehut-compare/authz" + "go.bigb.es/sourcehut-compare/web" +) + +const ( + service = "compare.sr.ht" + defaultBind = "127.0.0.1:5090" + + // authzTTL is how long the GraphQL authorizer memoizes positive and + // not-found repository lookups, sparing git.sr.ht a round trip per page. + authzTTL = 60 * time.Second +) + +func main() { + logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true}) + + // LoadConfig never panics on a missing file (it returns a nil ini.File); + // validateConfig turns any absent required key into a single clear fatal. + conf := config.LoadConfig() + apiOrigin := validateConfig(conf) + + // server.New parses -b/-d/-m/-p from the argument vector and runs + // crypto.InitCrypto(conf) — the network-key and webhook key validated above + // are exactly what it needs, so this cannot fatal after validateConfig. + // It expects the full os.Args (core-go's getopt skips argv[0] as the + // program name, exactly as every upstream SourceHut daemon calls it). + srv := coreserver.New(service, defaultBind, conf, os.Args) + + authorizer := authz.NewAuthorizer(authzTTL) + app, err := web.New(conf, authorizer) + if err != nil { + logrus.Fatalf("initialize web server: %v", err) + } + + // Middleware chain per the web package contract (outermost first). This is + // the hand-rolled substitute for WithDefaultMiddleware: no database, no + // redis, and authz.Middleware never issues a 401 so anonymous browsing + // works. config.Middleware must be present because the GraphQL authorizer + // resolves git.sr.ht's API origin from config.ForContext at request time. + // + // server.New already froze the anonymous router for direct middleware + // registration (it built inline sub-routers during construction), so the + // group + middleware + routes are installed together inside a Group, which + // chi permits on a fresh inline mux sharing the same routing tree. + srv.AnonRouter().Group(func(r chi.Router) { + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Logger) + r.Use(config.Middleware(conf, service)) + r.Use(authz.Middleware()) + app.Register(r) + }) + + reposRoot, _ := conf.Get("git.sr.ht", "repos") + logrus.WithFields(logrus.Fields{ + "bind": resolveBind(os.Args[1:]), + "repos": reposRoot, + "git.sr.ht-api": apiOrigin, + }).Info("compare.sr.ht starting") + + // Run blocks until SIGINT, then performs a warm shutdown. systemd should + // stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service). + srv.Run() +} + +// validateConfig verifies every configuration key compare.sr.ht needs before it +// can serve or authorize a request. It reports all missing keys at once via a +// single logrus.Fatal so operators fix the config in one pass instead of +// discovering each gap on a separate restart. It returns the git.sr.ht internal +// API origin that GraphQL authorization will use (also logged at startup). +// +// This runs BEFORE anything can reach config.GetAPI, which panics when no +// origin candidate is configured, and before crypto.InitCrypto (invoked by +// server.New), which would otherwise fatal with a terse message on a missing +// network-key or webhook key. +func validateConfig(conf ini.File) string { + var missing []string + require := func(section, key string) { + if v, ok := conf.Get(section, key); !ok || strings.TrimSpace(v) == "" { + missing = append(missing, fmt.Sprintf("[%s] %s", section, key)) + } + } + + require("sr.ht", "network-key") // crypto: unified-login cookie fernet key + require("webhooks", "private-key") // crypto: webhook signing key (shared) + require("git.sr.ht", "repos") // bare repository root on disk + require("meta.sr.ht", "origin") // login/logout links in the nav + require("compare.sr.ht", "origin") // our own external origin + + // git.sr.ht needs at least one internal API origin candidate; without it + // config.GetAPI panics on the first authorization request. + apiOrigin := firstConfigured(conf, "git.sr.ht", + "api-internal-origin", "internal-origin", "api-origin", "origin") + if apiOrigin == "" { + missing = append(missing, + "[git.sr.ht] one of api-internal-origin, internal-origin, api-origin, origin") + } + + if len(missing) > 0 { + logrus.Fatalf("incomplete configuration; missing required keys:\n\t%s", + strings.Join(missing, "\n\t")) + } + return apiOrigin +} + +// firstConfigured returns the value of the first present, non-empty key in +// section, or "" if none are set. +func firstConfigured(conf ini.File, section string, keys ...string) string { + for _, k := range keys { + if v, ok := conf.Get(section, k); ok && strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// resolveBind reconstructs the primary bind address server.New will use, for +// logging only. server.New owns the authoritative parse; this mirrors its -b +// handling (last -b wins here; the real server binds every -b given) and falls +// back to the same default. +func resolveBind(args []string) string { + bind := defaultBind + for i := 0; i < len(args); i++ { + switch a := args[i]; { + case a == "-b" && i+1 < len(args): + bind = args[i+1] + i++ + case strings.HasPrefix(a, "-b") && len(a) > 2: + bind = a[2:] + } + } + return bind +} diff --git a/cmd/comparesrht/main_test.go b/cmd/comparesrht/main_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7f8f57913b0200d0d4254bc29ab00af1158f3638 --- /dev/null +++ b/cmd/comparesrht/main_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/fernet/fernet-go" +) + +// TestStartupSmoke builds the daemon, runs it against a synthesized minimal +// config.ini (fresh crypto keys, an empty repos root), waits for /healthz to +// answer 200, then sends SIGINT and asserts a clean (exit code 0) warm +// shutdown. It exercises the real startup path end to end: config validation, +// crypto init via server.New, web.New (which globs the embedded hashed CSS), +// the middleware chain, and core-go's signal-driven Run loop. +// +// server.New's Run installs its warm-shutdown handler on SIGINT (the SourceHut +// fleet convention); the deployed systemd unit sets KillSignal=SIGINT so +// `systemctl stop` triggers the same graceful path this test verifies. +func TestStartupSmoke(t *testing.T) { + if testing.Short() { + t.Skip("smoke test builds a binary; skipped in -short mode") + } + if _, err := exec.LookPath("go"); err != nil { + t.Skipf("go toolchain not available: %v", err) + } + + // Build the daemon from the current package directory. + binDir := t.TempDir() + bin := filepath.Join(binDir, "comparesrht") + build := exec.Command("go", "build", "-o", bin, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + + // Synthesize a config.ini with valid crypto keys and an empty repos root. + var fk fernet.Key + if err := fk.Generate(); err != nil { + t.Fatalf("generate fernet key: %v", err) + } + seed := make([]byte, 32) + if _, err := rand.Read(seed); err != nil { + t.Fatalf("generate webhook seed: %v", err) + } + + confDir := t.TempDir() // becomes the process cwd; LoadConfig finds config.ini here + reposDir := t.TempDir() + confBody := fmt.Sprintf(`[sr.ht] +network-key=%s +site-name=sourcehut +environment=development + +[webhooks] +private-key=%s + +[compare.sr.ht] +origin=http://compare.example + +[meta.sr.ht] +origin=http://meta.example + +[git.sr.ht] +origin=http://git.example +repos=%s +`, fk.Encode(), base64.StdEncoding.EncodeToString(seed), reposDir) + if err := os.WriteFile(filepath.Join(confDir, "config.ini"), []byte(confBody), 0o644); err != nil { + t.Fatalf("write config.ini: %v", err) + } + + addr := freeAddr(t) + + cmd := exec.Command(bin, "-b", addr) + cmd.Dir = confDir // so config.LoadConfig picks up ./config.ini + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("start daemon: %v", err) + } + // Guarantee the child is reaped even if an assertion below fails early. + defer func() { + if cmd.ProcessState == nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } + }() + + healthz := "http://" + addr + "/healthz" + if !waitForOK(healthz, 10*time.Second) { + t.Fatalf("daemon did not become healthy at %s within timeout", healthz) + } + + // Warm shutdown: core-go's Run listens for SIGINT. + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("signal daemon: %v", err) + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + t.Fatalf("daemon exited uncleanly after SIGINT: %v", err) + } + case <-time.After(10 * time.Second): + _ = cmd.Process.Kill() + t.Fatal("daemon did not shut down within 10s of SIGINT") + } +} + +// freeAddr reserves and immediately releases a loopback TCP port, returning its +// host:port. The daemon binds with SO_REUSEPORT so the brief release window is +// harmless. +func freeAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve port: %v", err) + } + addr := l.Addr().String() + _ = l.Close() + return addr +} + +// waitForOK polls url until it returns HTTP 200 or the deadline elapses. +func waitForOK(url string, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + client := &http.Client{Timeout: time.Second} + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true + } + } + time.Sleep(100 * time.Millisecond) + } + return false +} diff --git a/contrib/compare-srht.service b/contrib/compare-srht.service index 678f8533cb90b6037bbe32a582badfa6b406d4c3..c72ec379602055a8d5eeeedee88baa58ced0cd75 100644 --- a/contrib/compare-srht.service +++ b/contrib/compare-srht.service @@ -8,7 +8,13 @@ Type=simple # /var/lib/git. compare.sr.ht is stateless — no database or Redis dependency. User=git Restart=always -ExecStart=/usr/local/bin/comparesrht +ExecStart=/usr/local/bin/comparesrht -b 127.0.0.1:5090 + +# core-go's server.Run performs its warm shutdown on SIGINT (the SourceHut +# fleet convention), not the systemd default SIGTERM. Send SIGINT on stop so +# `systemctl stop`/restart drains in-flight requests instead of hard-killing. +KillSignal=SIGINT +TimeoutStopSec=35 # The service only ever reads: bare repos on disk and the shared config. It # writes nothing, so lock the filesystem down. diff --git a/contrib/dev-stub/main.go b/contrib/dev-stub/main.go new file mode 100644 index 0000000000000000000000000000000000000000..09c5ce28a819ceefae7f4b97a98964fae6077d06 --- /dev/null +++ b/contrib/dev-stub/main.go @@ -0,0 +1,118 @@ +// Command dev-stub is a throwaway fake git.sr.ht GraphQL API for local +// development of compare.sr.ht. It answers the two queries compare.sr.ht's +// authorizer issues (a single `user{repository}` lookup and the `me` +// repository list) with fixed PUBLIC data, so you can drive the UI without a +// real SourceHut instance or its internal-auth machinery. +// +// It ignores authentication entirely: every request — anonymous or "logged in" +// — sees the same public repository, which is exactly what you want when +// exercising the compare/commit pages against a directory of local bare repos. +// +// Usage: +// +// go run ./contrib/dev-stub -addr 127.0.0.1:5101 +// +// Then point compare.sr.ht's config at it and start the daemon: +// +// [git.sr.ht] +// api-origin=http://127.0.0.1:5101 # dev-stub serves POST /query here +// repos=/path/to/local/bare/repos # {repos}/~{owner}/{name} +// +// make run-dev +// +// The repository NAME echoed back is taken from the query variables, so any +// /~owner/ you visit resolves; put a matching bare repo at +// {repos}/~owner/ for gitx to read. See README.md ("Development") for the +// full recipe including forging a dev login cookie. +// +// stdlib only, no build-time dependencies. Not meant for production. +package main + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "strings" +) + +// repoNode is the repository shape compare.sr.ht's authorizer decodes for both +// the single-repo lookup and the me.repositories list. +type repoNode struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +func main() { + addr := flag.String("addr", "127.0.0.1:5101", "address to listen on") + flag.Parse() + + http.HandleFunc("/query", handleQuery) + http.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok\n")) + }) + + log.Printf("dev-stub git.sr.ht GraphQL API listening on http://%s/query", *addr) + log.Printf("every query resolves to a fixed PUBLIC repository (auth ignored)") + if err := http.ListenAndServe(*addr, nil); err != nil { + log.Fatal(err) + } +} + +func handleQuery(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + var req gqlRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeData(w, map[string]any{}) // let the client surface a decode-shaped miss + return + } + + switch { + case strings.Contains(req.Query, "repositories("): + // me { repositories { results { ... } cursor } } + writeData(w, map[string]any{ + "me": map[string]any{ + "repositories": map[string]any{ + "results": []repoNode{ + {ID: 1, Name: "demo", Description: "a public demo repo", Visibility: "PUBLIC"}, + {ID: 2, Name: "playground", Description: "scratch space", Visibility: "UNLISTED"}, + }, + "cursor": nil, + }, + }, + }) + case strings.Contains(req.Query, "repository("): + // user(username:$u) { repository(name:$r) { ... } } + name, _ := req.Variables["r"].(string) + if name == "" { + name = "demo" + } + writeData(w, map[string]any{ + "user": map[string]any{ + "repository": repoNode{ + ID: 1, + Name: name, + Description: "a public demo repo (dev-stub)", + Visibility: "PUBLIC", + }, + }, + }) + default: + writeData(w, map[string]any{}) + } +} + +func writeData(w http.ResponseWriter, data map[string]any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) +}