~bigbes/sr-ht-compare

6db8684c02e4052483d10f1b233d883e0ccda9f7 — bigbes 30 days ago 7147529
cmd/comparesrht: daemon entry point, startup validation, dev stub

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.
M Makefile => Makefile +10 -9
@@ 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/*


A cmd/comparesrht/main.go => cmd/comparesrht/main.go +172 -0
@@ 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
}

A cmd/comparesrht/main_test.go => cmd/comparesrht/main_test.go +148 -0
@@ 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
}

M contrib/compare-srht.service => contrib/compare-srht.service +7 -1
@@ 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.

A contrib/dev-stub/main.go => contrib/dev-stub/main.go +118 -0
@@ 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/<name> you visit resolves; put a matching bare repo at
// {repos}/~owner/<name> 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})
}