Compare commits

..
Author SHA1 Message Date
mathiasandClaude Opus 5.5 8fdf7284aa fix(linkcheck): skip a linked tree that is not a real directory (#157)
CI / Lint / Test / Vet (pull_request) Successful in 56s
CI / Mirror to GitHub (pull_request) Skipped
Review round 1 on PR #175:

- entryIndex returned WalkRegular's walk-root ErrNotRegular as an error,
  and StripUnresolvedLinks runs before every brain_write: a symlinked,
  dangling or regular-file knowledge/ (or wiki/) made every write fail
  and lose the learning. main's filepath.WalkDir skipped such a root. It
  is now skipped with a warning; other walk errors still abort, as on
  main. New table test over the three shapes.
- The symlinked-hall assertion passed with no index written at all. It
  now indexes a real hall next to the symlinked one and asserts no
  error, the index exists and lists the real note; a new test does the
  same for BuildAllWingIndexes with a symlinked wing next to a real one.
- The test comment no longer says this PR fixed the FIFO hang on every
  brain_write: that path is tunnel.DetectTunnels (slice 5b).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:17:43 +02:00
mathiasandClaude Opus 5.5 6e3c51c952 fix(brain): wing indexes and the link check read through brainfs (#157, slice 5a)
CI / Lint / Test / Vet (pull_request) Successful in 56s
CI / Mirror to GitHub (pull_request) Skipped
BuildWingIndex/BuildAllWingIndexes listed wiki/<wing>/<hall>/ with
os.ReadDir and opened each note by path: a symlinked note put the
target's title into _index.md (110k leaks in the swap race), a symlinked
wing was followed and its _index.md written into the target, and a FIFO
hung the build (it runs on every brain_write).

- New brainfs.ReadDirDirs: real subdirectories only, every component of
  the dir a real directory. Wings and halls are listed with it and
  ReadDirRegular; titles are read with ReadRegular
  (readTitleAndCreatedIn). A symlinked wing is an error; a symlinked hall
  is skipped.
- linkcheck entryIndex walks with WalkRegular: a symlinked .md name no
  longer makes a [[link]] resolve.
- readTitleAndCreated (path-based) stays for tunnel.DetectTunnels until
  PR #170 lands; that is slice 5b.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:09:03 +02:00
13 changed files with 353 additions and 453 deletions
+12 -13
View File
@@ -2,25 +2,24 @@ package main
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
) )
// buildJWTValidator builds the JWT validator for issuer (nil when issuer is // buildJWTValidator runs OIDC discovery under policy, so a 502 from an auth
// empty: JWT auth disabled). Since mcp-chassis v0.7 construction no longer // upstream that is not serving yet on a cold boot is retried instead of
// fails when OIDC discovery does (mcp-chassis#4): the issuer resolves lazily, // exiting (#83). It honours ctx between attempts.
// and until it does a JWT is answered 503-and-retry, not 401. That replaces func buildJWTValidator(
// #83's boot-time retry loop (hyperguild#155). An issuer still unresolved at ctx context.Context, logger *slog.Logger, issuer, audience string, policy startupretry.Policy,
// boot is logged, so a misconfigured issuer is visible rather than silent. ) (*chassisauth.JWTValidator, error) {
func buildJWTValidator(ctx context.Context, logger *slog.Logger, issuer, audience string) (*chassisauth.JWTValidator, error) { return startupretry.Do(ctx, policy, func(ctx context.Context) (*chassisauth.JWTValidator, error) {
v, err := chassisauth.NewJWTValidator(ctx, issuer, audience) v, err := chassisauth.NewJWTValidator(ctx, issuer, audience)
if err != nil { if err != nil {
return nil, fmt.Errorf("build jwt validator: %w", err) logger.Warn("oidc discovery not ready, retrying", "err", err)
} }
if ok, rerr := v.Ready(ctx); !ok { return v, err
logger.Warn("jwt issuer not resolved at boot; JWTs answer 503 until it is", "issuer", issuer, "err", rerr) })
}
return v, nil
} }
+36 -61
View File
@@ -1,7 +1,6 @@
package main package main
import ( import (
"bytes"
"context" "context"
"io" "io"
"log/slog" "log/slog"
@@ -9,11 +8,12 @@ import (
"net/http/httptest" "net/http/httptest"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics" "github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
) )
// fakeIssuer answers OIDC discovery with 502 for the first fail requests, as // fakeIssuer answers OIDC discovery with 502 for the first fail requests, as
@@ -41,71 +41,46 @@ func fakeIssuer(t *testing.T, fail int32) (*httptest.Server, *atomic.Int32) {
return srv, &discoveries return srv, &discoveries
} }
// #83 retried discovery at boot because chassis v0.2.0 failed construction on var fastPolicy = startupretry.Policy{Attempts: 8, Base: time.Millisecond, Max: 5 * time.Millisecond}
// a 502. Since chassis v0.7 (mcp-chassis#4) the validator is built anyway and
// resolves the issuer lazily, so boot no longer waits or exits on it func TestBuildJWTValidatorRetriesDiscovery502sThenSucceeds(t *testing.T) {
// (hyperguild#155). An unresolved issuer is logged at boot. srv, discoveries := fakeIssuer(t, 3)
func TestBuildJWTValidatorReturnsAValidatorWhileDiscovery502s(t *testing.T) { ctx, cancel := context.WithCancel(context.Background())
srv, _ := fakeIssuer(t, 1000) defer cancel()
var buf bytes.Buffer
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain") v, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, v) assert.NotNil(t, v)
assert.Contains(t, buf.String(), "jwt issuer not resolved at boot") assert.Equal(t, int32(4), discoveries.Load(), "three 502s, then the successful discovery")
} }
func TestBuildJWTValidatorLogsNothingAgainstAHealthyIssuer(t *testing.T) { func TestBuildJWTValidatorGivesUpAfterTheBudgetWithTheLastError(t *testing.T) {
srv, _ := fakeIssuer(t, 0) srv, discoveries := fakeIssuer(t, 1000)
var buf bytes.Buffer ctx, cancel := context.WithCancel(context.Background())
v, err := buildJWTValidator(context.Background(), slog.New(slog.NewTextHandler(&buf, nil)), srv.URL, "brain") defer cancel()
require.NoError(t, err)
assert.NotNil(t, v) _, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", fastPolicy)
assert.NotContains(t, buf.String(), "not resolved") require.Error(t, err)
assert.Contains(t, err.Error(), "status 502")
assert.Equal(t, int32(8), discoveries.Load(), "bounded: exactly the attempt budget")
} }
func TestBuildJWTValidatorIsNilWithoutAnIssuer(t *testing.T) { func TestBuildJWTValidatorStopsRetryingWhenTheContextIsCancelled(t *testing.T) {
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), "", "brain")
require.NoError(t, err)
assert.Nil(t, v)
}
// With the issuer unresolved, a JWT on a REST route answers 503 (retry), not
// 401: the token may be fine, the server just cannot check it yet.
func TestBuildMuxAnswers503ToAJWTWhileTheIssuerIsUnresolved(t *testing.T) {
srv, _ := fakeIssuer(t, 1000) srv, _ := fakeIssuer(t, 1000)
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain") ctx, cancel := context.WithCancel(context.Background())
require.NoError(t, err) slow := startupretry.Policy{Attempts: 8, Base: time.Hour, Max: time.Hour}
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
code, _ := do(t, mux, "GET", "/pending", "Bearer aaa.bbb.ccc")
assert.Equal(t, http.StatusServiceUnavailable, code)
}
// PR #183 review: /mcp shares REST's bearer, but only the static token was done := make(chan error, 1)
// asserted on it. With the issuer unresolved, a JWT on /mcp answers 503 too, go func() {
// so /mcp's middleware was built with the JWT validator. _, err := buildJWTValidator(ctx, slog.New(slog.DiscardHandler), srv.URL, "brain", slow)
func TestBuildMuxAnswers503ToAJWTOnMCPWhileTheIssuerIsUnresolved(t *testing.T) { done <- err
srv, _ := fakeIssuer(t, 1000) }()
v, err := buildJWTValidator(context.Background(), slog.New(slog.DiscardHandler), srv.URL, "brain") time.Sleep(50 * time.Millisecond)
require.NoError(t, err) cancel()
mux, err := buildMux(restAuth{token: routesTok, validator: v, realm: "brain"}, testHandler(t), select {
okHandler("mcp"), metrics.New(), nil) case err := <-done:
require.NoError(t, err) assert.ErrorIs(t, err, context.Canceled)
code, _ := do(t, mux, "POST", "/mcp", "Bearer aaa.bbb.ccc") case <-time.After(3 * time.Second):
assert.Equal(t, http.StatusServiceUnavailable, code) t.Fatal("did not return after the context was cancelled")
} }
// PR #183 review: a 401 on /mcp names the protected-resource metadata in
// WWW-Authenticate (what the claude.ai OAuth connector discovers from), so
// /mcp's middleware was built with resourceMetadataURL.
func TestBuildMuxMCP401CarriesResourceMetadataInWWWAuthenticate(t *testing.T) {
const rm = "https://brain.example/.well-known/oauth-protected-resource"
mux, err := buildMux(restAuth{token: routesTok, realm: "brain", resourceMetadataURL: rm}, testHandler(t),
okHandler("mcp"), metrics.New(), nil)
require.NoError(t, err)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil))
require.Equal(t, http.StatusUnauthorized, rec.Code)
assert.Contains(t, rec.Header().Get("WWW-Authenticate"), `resource_metadata="`+rm+`"`)
} }
+32 -22
View File
@@ -32,6 +32,7 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
"github.com/mathiasbq/hyperguild/ingestion/internal/reranker" "github.com/mathiasbq/hyperguild/ingestion/internal/reranker"
"github.com/mathiasbq/hyperguild/ingestion/internal/search" "github.com/mathiasbq/hyperguild/ingestion/internal/search"
"github.com/mathiasbq/hyperguild/ingestion/internal/startupretry"
"github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard" "github.com/mathiasbq/hyperguild/ingestion/internal/symlinkguard"
"github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore"
"github.com/mathiasbq/hyperguild/ingestion/internal/watcher" "github.com/mathiasbq/hyperguild/ingestion/internal/watcher"
@@ -368,15 +369,21 @@ func main() {
// hyperguild#157: report a symlink that appears in the brain while running. // hyperguild#157: report a symlink that appears in the brain while running.
go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier()) go symlinkguard.Watch(ctx, brainDir, symlinkScanInterval(os.Getenv), buildNotifier())
// Never fatal on an unreachable issuer (#83): on a cold cluster boot this // Built before the watcher, the graph backfill and session ingestion, and
// pod can beat dex to serving and get a 502 from ingress, and exiting meant // retried rather than fatal (#83). Two separate reasons:
// CrashLoopBackOff redoing the backfill. Since mcp-chassis v0.7 the //
// validator resolves the issuer lazily and answers JWTs 503-and-retry until // - On a cold cluster boot this pod can beat dex to serving and gets a
// it does (hyperguild#155), so no boot retry is needed. A misconfigured // 502 from ingress. Nothing is misconfigured; it simply started first.
// issuer no longer exits the process: it is logged at boot and every JWT // Exiting there meant CrashLoopBackOff redoing the backfill on every
// gets 503; the static bearer keeps working. // attempt, with exponential backoff stretching the brain outage.
// - Constructing it first means a genuine config error (wrong issuer,
// bad audience) fails in seconds instead of after all that work.
//
// The budget stays bounded: retrying forever would turn a misconfiguration
// into a process that looks alive and never serves.
jwtValidator, err := buildJWTValidator(ctx, logger, jwtValidator, err := buildJWTValidator(ctx, logger,
os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE")) os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"),
startupretry.Policy{Attempts: 8, Base: time.Second, Max: 30 * time.Second})
if err != nil { if err != nil {
logger.Error("build jwt validator", "err", err) logger.Error("build jwt validator", "err", err)
os.Exit(1) os.Exit(1)
@@ -516,6 +523,8 @@ func main() {
} }
} }
public["/mcp"] = chassisauth.BearerMiddleware(mcpToken, jwtValidator, "brain", resourceMetadataURL, mcpSrv)
// POST /capture (#53/#54): the uniform capture REST door. Needs a ticket // POST /capture (#53/#54): the uniform capture REST door. Needs a ticket
// tracker to file action items, so it only mounts when Gitea is // tracker to file action items, so it only mounts when Gitea is
// configured. It reuses the MCP server's graph-wired brain store (one // configured. It reuses the MCP server's graph-wired brain store (one
@@ -539,7 +548,7 @@ func main() {
public["POST /capture"] = captureH public["POST /capture"] = captureH
// Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native // Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native
// harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through // harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through
// the existing /mcp OAuth connector. buildMux wraps this same mcpSrv; // the existing /mcp OAuth connector. mcpSrv is already wrapped above;
// WithCapture mutates the same instance, so the tool appears live. // WithCapture mutates the same instance, so the tool appears live.
mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
// hyperguild#97: a governance-file rewrite gets the same trail a // hyperguild#97: a governance-file rewrite gets the same trail a
@@ -581,18 +590,19 @@ func main() {
os.Exit(1) os.Exit(1)
} }
// /metrics — unauthenticated Prometheus endpoint, scraped via the // /metrics — unauthenticated Prometheus endpoint. kube-prometheus-stack
// ServiceMonitor in k3s/apps/supervisor/. buildMux registers it and wraps // scrapes it via the ServiceMonitor in k3s/apps/supervisor/. The metrics
// the whole router in the registry's middleware. // middleware below wraps every other registered handler so it observes
// // real request latency. /metrics itself is excluded from its own
// The whole router, /mcp's bearer included, is built in buildMux so its // observation by registering it on the outer mux (post-wrap).
// tests are tests of production wiring (PR #152 review, hyperguild#154). reg := metrics.New()
// The REST API's credential is the same as /mcp's. Untested here: the public["GET /metrics"] = http.HandlerFunc(reg.Handler())
// restAuth fields and the public map this call passes in (e.g. a nil
// validator would drop JWT auth on /mcp and REST and the suite stays // The whole router, built in one place so the test of buildMux is a test
// green), and ListenAndServe serving the result unchanged (hyperguild#182). // of production wiring (PR #152 review). The REST API's credential is the
handler, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain", // same as /mcp's.
resourceMetadataURL: resourceMetadataURL}, h, mcpSrv, metrics.New(), public) mux, err := buildMux(restAuth{token: mcpToken, validator: jwtValidator, realm: "brain",
resourceMetadataURL: resourceMetadataURL}, h, public)
if err != nil { if err != nil {
logger.Error("build router", "err", err) logger.Error("build router", "err", err)
os.Exit(1) os.Exit(1)
@@ -613,7 +623,7 @@ func main() {
"watch_interval", watchIntervalLog, "watch_interval", watchIntervalLog,
"mcp_enabled", true, "mcp_enabled", true,
) )
if err := http.ListenAndServe(addr, handler); err != nil { if err := http.ListenAndServe(addr, reg.Middleware(mux)); err != nil {
logger.Error("server stopped", "err", err) logger.Error("server stopped", "err", err)
os.Exit(1) os.Exit(1)
} }
+19 -51
View File
@@ -8,22 +8,22 @@ import (
chassisauth "git.d-ma.be/mathias/mcp-chassis/auth" chassisauth "git.d-ma.be/mathias/mcp-chassis/auth"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
) )
// publicRoutes is the complete set of patterns a caller may register on the // publicRoutes is the complete set of patterns allowed on the outer mux,
// outer mux, outside the REST API's auth (hyperguild#149). Each authenticates // outside the REST API's auth (hyperguild#149). Each authenticates itself or is
// itself or is meant to be open: /capture (bearer/JWT), the webhook (HMAC), // meant to be open: /mcp (bearer/JWT), /capture (bearer/JWT), the webhook
// /oauth/token (client secret) and OAuth metadata (open). /mcp and /metrics // (HMAC), /oauth/token (client secret), OAuth metadata and /metrics (open).
// are not here: buildMux builds them itself (hyperguild#154). A REST pattern // A REST pattern added here would beat the authenticated catch-all by
// added here would beat the authenticated catch-all by specificity and ship // specificity and ship open, which is why buildMux refuses anything else.
// open, which is why buildMux refuses anything else.
var publicRoutes = map[string]bool{ var publicRoutes = map[string]bool{
"/mcp": true,
"POST /capture": true, "POST /capture": true,
"POST /webhooks/brain-sync": true, "POST /webhooks/brain-sync": true,
"GET /.well-known/oauth-protected-resource": true, "GET /.well-known/oauth-protected-resource": true,
"GET /.well-known/oauth-authorization-server": true, "GET /.well-known/oauth-authorization-server": true,
"POST /oauth/token": true, "POST /oauth/token": true,
"GET /metrics": true,
} }
// restAuth is the credential the REST API checks: the same as /mcp's. // restAuth is the credential the REST API checks: the same as /mcp's.
@@ -34,25 +34,16 @@ type restAuth struct {
resourceMetadataURL string resourceMetadataURL string
} }
// buildMux builds the handler main passes to ListenAndServe unchanged. The // buildMux is the server's whole router. The REST API is mounted at "/"
// REST API is mounted at "/" and /mcp is registered, both behind bearer auth // behind bearer auth that buildMux constructs itself, so a caller cannot pass
// that buildMux constructs itself from auth (one credential), so a caller // a no-op; public handlers are registered as given, and only for patterns on
// cannot pass a no-op. GET /metrics serves reg, and the whole router is // the publicRoutes allowlist. It returns an http.Handler, not the
// wrapped in reg's middleware. Other public handlers are registered as given, // *ServeMux, so nothing can register a route on the result afterwards and
// and only for patterns on the publicRoutes allowlist. It returns an // bypass the allowlist.
// http.Handler, not the *ServeMux, so nothing can register a route on the func buildMux(auth restAuth, h *api.Handler, public map[string]http.Handler) (http.Handler, error) {
// result. What stays untested is main's call site: the restAuth fields and
// the public map main passes in, and ListenAndServe serving this handler
// unchanged (hyperguild#182). The tests build their own arguments.
func buildMux(
auth restAuth, h *api.Handler, mcpHandler http.Handler, reg *metrics.Registry, public map[string]http.Handler,
) (http.Handler, error) {
if auth.token == "" { if auth.token == "" {
return nil, errors.New("buildMux: REST auth needs a token") return nil, errors.New("buildMux: REST auth needs a token")
} }
if mcpHandler == nil || reg == nil {
return nil, errors.New("buildMux: needs the MCP handler and the metrics registry")
}
mux := http.NewServeMux() mux := http.NewServeMux()
for pattern, handler := range public { for pattern, handler := range public {
if !publicRoutes[pattern] { if !publicRoutes[pattern] {
@@ -60,15 +51,10 @@ func buildMux(
} }
mux.Handle(pattern, handler) mux.Handle(pattern, handler)
} }
bearer := func(next http.Handler) http.Handler { mountREST(mux, h, func(next http.Handler) http.Handler {
return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next) return chassisauth.BearerMiddleware(auth.token, auth.validator, auth.realm, auth.resourceMetadataURL, next)
} })
mux.Handle("/mcp", bearer(mcpHandler)) return mux, nil
// /metrics is registered on the mux and so observed by its own
// middleware, like any route.
mux.Handle("GET /metrics", reg.Handler())
mountREST(mux, h, bearer)
return reg.Middleware(mux), nil
} }
// mountREST registers the REST API on its own mux and mounts it at "/" behind // mountREST registers the REST API on its own mux and mounts it at "/" behind
@@ -87,23 +73,5 @@ func mountREST(mux *http.ServeMux, h *api.Handler, auth func(http.Handler) http.
rest.HandleFunc("POST /promote", h.Promote) rest.HandleFunc("POST /promote", h.Promote)
rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings) rest.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
rest.HandleFunc("GET /pass-rate", h.PassRate) rest.HandleFunc("GET /pass-rate", h.PassRate)
mux.Handle("/", labelRESTPattern(rest, auth(rest))) mux.Handle("/", auth(rest))
}
// labelRESTPattern sets the request's Pattern to the REST route it addresses
// before auth runs, so the metrics middleware (which reads Pattern from the
// same *http.Request) labels a refused request by its route rather than by
// the "/" mount (hyperguild#155). A refused request to a path no route
// matches keeps "/", so for refused requests the label set stays bounded by
// the registered routes. That bound does not hold for authenticated
// requests: an unregistered path reaches the REST mux, which 404s with an
// empty Pattern, and the metrics middleware falls back to the raw URL path
// (hyperguild#185).
func labelRESTPattern(rest *http.ServeMux, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, pattern := rest.Handler(r); pattern != "" {
r.Pattern = pattern
}
next.ServeHTTP(w, r)
})
} }
@@ -1,88 +0,0 @@
package main
import (
"bytes"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// wrongTokSentinel is a presented credential distinctive enough that any
// occurrence in a log line is the raw token, not a coincidence.
const wrongTokSentinel = "wrong-tok-sentinel-q7zx"
// hyperguild#155: since #149 every REST route sits behind BearerMiddleware,
// and chassis v0.2.0 refused without logging, so an unknown caller got
// silent 401s. The chassis now writes an `mcp auth rejected` line, and the
// presented token appears nowhere in the log as a bare value (PR #181
// review: asserting on the "Bearer " header form could never fail, since
// the chassis logs fields, not headers).
func TestBuildMuxRejectionLogLineOmitsTheBarePresentedToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "POST", "/query", "Bearer "+wrongTokSentinel)
require.Equal(t, http.StatusUnauthorized, code)
assert.Contains(t, buf.String(), "mcp auth rejected")
assert.Contains(t, buf.String(), "path=/query")
assert.NotContains(t, buf.String(), wrongTokSentinel, "the rejected token is logged as a bare value")
assert.NotContains(t, buf.String(), routesTok, "the valid static token is logged")
}
// The `mcp auth accepted` line leaves out the valid static token.
func TestBuildMuxAcceptedLogLineOmitsTheBareStaticToken(t *testing.T) {
var buf bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
code, _ := do(t, testMux(t), "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
assert.Contains(t, buf.String(), "mcp auth accepted")
assert.NotContains(t, buf.String(), routesTok, "the accepted static token is logged as a bare value")
}
// An authenticated REST request is labelled with the REST mux's pattern:
// the inner mux sets it on the same *http.Request the outer middleware reads.
func TestMetricsLabelAnAuthenticatedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="GET /pending"`)
assert.NotContains(t, rec.Body.String(), `path="/"`)
}
// hyperguild#155: a request refused by REST auth never reaches the REST mux,
// so it was labelled path="/" and a 4xx on /query could not be told from one
// on /write. It is now labelled with the REST pattern it was addressed to.
func TestMetricsLabelARejectedRESTRequestWithItsRoutePattern(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/query", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="POST /query",status="4xx"`)
}
// A path no REST route matches keeps the catch-all label, so an attacker
// probing random URLs cannot grow the series set.
func TestMetricsLabelARejectedUnknownPathWithTheCatchAll(t *testing.T) {
mux, reg := testMuxAndRegistry(t)
code, _ := do(t, mux, "POST", "/no-such-route-zq", "")
require.Equal(t, http.StatusUnauthorized, code)
rec := httptest.NewRecorder()
reg.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
assert.Contains(t, rec.Body.String(), `path="/",status="4xx"`)
assert.NotContains(t, rec.Body.String(), "no-such-route-zq")
}
+12 -67
View File
@@ -12,7 +12,6 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/mathiasbq/hyperguild/ingestion/internal/api" "github.com/mathiasbq/hyperguild/ingestion/internal/api"
"github.com/mathiasbq/hyperguild/ingestion/internal/metrics"
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
) )
@@ -22,34 +21,20 @@ func okHandler(body string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) })
} }
// testMux builds the router through buildMux, the function main calls, with // testMux builds the router exactly as main does: buildMux with the full
// every allowlisted public pattern (stub handlers), a stub MCP server and a // public set main registers (stub handlers) and real bearer config.
// static token only: no JWT validator and no resourceMetadataURL (the tests // hyperguild#149: the REST API is authenticated as a whole; the PR #152
// in jwt_validator_test.go pass those). It does not use main's own restAuth // review showed a test of a COPY of the router let main.go regress unseen.
// or public map (hyperguild#182). hyperguild#149: the REST API is
// authenticated as a whole; the PR #152 review showed a test of a COPY of
// the router let main.go regress unseen.
func testMux(t *testing.T) http.Handler { func testMux(t *testing.T) http.Handler {
t.Helper() t.Helper()
mux, _ := testMuxAndRegistry(t) h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
return mux
}
func testMuxAndRegistry(t *testing.T) (http.Handler, *metrics.Registry) {
t.Helper()
public := map[string]http.Handler{} public := map[string]http.Handler{}
for p := range publicRoutes { for p := range publicRoutes {
public[p] = okHandler("public " + p) public[p] = okHandler("public " + p)
} }
reg := metrics.New() mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, public)
mux, err := buildMux(restAuth{token: routesTok, realm: "brain"}, testHandler(t), okHandler("mcp"), reg, public)
require.NoError(t, err) require.NoError(t, err)
return mux, reg return mux
}
func testHandler(t *testing.T) *api.Handler {
t.Helper()
return api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
} }
func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) { func do(t *testing.T, h http.Handler, method, path, authz string) (int, string) {
@@ -89,9 +74,8 @@ func TestBuildMuxAuthenticatesAnUnregisteredPath(t *testing.T) {
} }
// buildMux registers public handlers as given, without adding auth: each // buildMux registers public handlers as given, without adding auth: each
// public route owns its own (bearer/JWT on /capture, HMAC on the webhook, // public route owns its own (bearer on /mcp, HMAC on the webhook, client
// client secret on /oauth/token) or is meant to be open (OAuth metadata). // secret on /oauth/token) or is meant to be open (/metrics, OAuth metadata).
// /mcp and /metrics are not public routes: buildMux builds them itself.
func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) { func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
r := testMux(t) r := testMux(t)
for p := range publicRoutes { for p := range publicRoutes {
@@ -109,53 +93,14 @@ func TestBuildMuxDoesNotWrapPublicRoutes(t *testing.T) {
// over the authenticated catch-all (more specific pattern), so it is refused. // over the authenticated catch-all (more specific pattern), so it is refused.
func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) { func TestBuildMuxRefusesAPublicPatternOffTheAllowlist(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{}) h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
// "/mcp" and "GET /metrics" are built by buildMux itself (hyperguild#154): for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics"} {
// passing either as public would register them unauthenticated or twice. _, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, map[string]http.Handler{p: okHandler("x")})
for _, p := range []string{"POST /query", "GET /pending", "/", "POST /metrics", "/mcp", "GET /metrics"} {
_, err := buildMux(restAuth{token: routesTok, realm: "brain"}, h, okHandler("mcp"), metrics.New(),
map[string]http.Handler{p: okHandler("x")})
assert.Error(t, err, "public pattern %q must be refused", p) assert.Error(t, err, "public pattern %q must be refused", p)
} }
} }
func TestBuildMuxRefusesAnEmptyToken(t *testing.T) { func TestBuildMuxRefusesAnEmptyToken(t *testing.T) {
h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{}) h := api.NewHandler(t.TempDir(), slog.New(slog.NewTextHandler(os.Stderr, nil)), pipeline.Config{})
_, err := buildMux(restAuth{token: "", realm: "brain"}, h, okHandler("mcp"), metrics.New(), nil) _, err := buildMux(restAuth{token: "", realm: "brain"}, h, nil)
assert.Error(t, err) assert.Error(t, err)
} }
// hyperguild#154: /mcp's bearer was built in main.go, untested; the buildMux
// tests used stubs. buildMux now wraps the MCP handler itself, with the same
// credential as the REST API.
func TestBuildMuxRefusesMCPWithoutAValidToken(t *testing.T) {
r := testMux(t)
for _, authz := range []string{"", "Bearer wrong"} {
code, body := do(t, r, "POST", "/mcp", authz)
assert.Equal(t, http.StatusUnauthorized, code, "authz %q", authz)
assert.NotEqual(t, "mcp", body)
}
code, body := do(t, r, "POST", "/mcp", "Bearer "+routesTok)
assert.Equal(t, http.StatusOK, code)
assert.Equal(t, "mcp", body)
}
func TestBuildMuxRefusesANilMCPHandlerOrRegistry(t *testing.T) {
auth := restAuth{token: routesTok, realm: "brain"}
_, err := buildMux(auth, testHandler(t), nil, metrics.New(), nil)
assert.Error(t, err)
_, err = buildMux(auth, testHandler(t), okHandler("mcp"), nil, nil)
assert.Error(t, err)
}
// hyperguild#154: main wrapped buildMux's result in the metrics middleware
// before ListenAndServe, untested. buildMux now returns the handler main
// serves as-is, metrics included: a request through it is observed and
// exposed on its own GET /metrics.
func TestBuildMuxObservesRequestsAndServesMetrics(t *testing.T) {
r := testMux(t)
code, _ := do(t, r, "GET", "/pending", "Bearer "+routesTok)
require.Equal(t, http.StatusOK, code)
code, body := do(t, r, "GET", "/metrics", "")
require.Equal(t, http.StatusOK, code)
assert.Contains(t, body, `path="GET /pending"`)
}
+1 -1
View File
@@ -51,7 +51,7 @@ require (
) )
require ( require (
git.d-ma.be/mathias/mcp-chassis v0.7.1 git.d-ma.be/mathias/mcp-chassis v0.2.0
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect github.com/goccy/go-json v0.10.3 // indirect
+2 -2
View File
@@ -1,5 +1,5 @@
git.d-ma.be/mathias/mcp-chassis v0.7.1 h1:4h8TOBHTA0L/no4IPcr4tLcQtmKUlhE2/cXyPdNFGmM= git.d-ma.be/mathias/mcp-chassis v0.2.0 h1:6fLmb7xqRa2nNVWsHaUbbfbArgDXJw/gDhb09clBIjo=
git.d-ma.be/mathias/mcp-chassis v0.7.1/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8= git.d-ma.be/mathias/mcp-chassis v0.2.0/go.mod h1:Ks7EK2UnGAN0H3rJjKUxUagX8/ZBdtLrOlcUbv0RwH8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -27,7 +27,7 @@ const outsideContent = "---\ntitle: " + Sentinel + "\naliases:\n - " + Sentinel
"\n\n" + Sentinel + " [[" + Sentinel + "]]\n" "\n\n" + Sentinel + " [[" + Sentinel + "]]\n"
// Outside writes a file outside any brain dir containing Sentinel. // Outside writes a file outside any brain dir containing Sentinel.
func Outside(t testing.TB) string { func Outside(t *testing.T) string {
t.Helper() t.Helper()
p := filepath.Join(t.TempDir(), "secret.md") p := filepath.Join(t.TempDir(), "secret.md")
if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil { if err := os.WriteFile(p, []byte(outsideContent), 0o644); err != nil {
@@ -42,12 +42,9 @@ var swapSeq atomic.Int64
// a symlink to outside or a FIFO into path, until the test ends. FIFOs get a // a symlink to outside or a FIFO into path, until the test ends. FIFOs get a
// writer held open until the swapper exits, so a reader that skips the // writer held open until the swapper exits, so a reader that skips the
// handle check parks forever instead of reading EOF. The swapper is stopped // handle check parks forever instead of reading EOF. The swapper is stopped
// and awaited before t.TempDir cleanup. It returns the number of symlinks // and awaited before t.TempDir cleanup.
// or FIFOs actually renamed into path so far, so a caller can tell a race func StartSwap(t *testing.T, path, inside, outside string, fifo bool) {
// that ran from one whose every swap failed (hyperguild#177).
func StartSwap(t testing.TB, path, inside, outside string, fifo bool) *atomic.Int64 {
t.Helper() t.Helper()
var swaps atomic.Int64
var stop atomic.Bool var stop atomic.Bool
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
@@ -70,26 +67,22 @@ func StartSwap(t testing.TB, path, inside, outside string, fifo bool) *atomic.In
}() }()
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp) _ = os.Remove(tmp)
var made error
switch { switch {
case i%2 == 0: case i%2 == 0:
_ = os.WriteFile(tmp, []byte(inside), 0o644) _ = os.WriteFile(tmp, []byte(inside), 0o644)
case fifo: case fifo:
if made = syscall.Mkfifo(tmp, 0o644); made == nil { if err := syscall.Mkfifo(tmp, 0o644); err == nil {
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil { if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
writers = append(writers, w) writers = append(writers, w)
} }
} }
default: default:
made = os.Symlink(link, tmp) _ = os.Symlink(link, tmp)
}
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
swaps.Add(1)
} }
_ = os.Rename(tmp, path)
} }
}() }()
t.Cleanup(func() { stop.Store(true); wg.Wait() }) t.Cleanup(func() { stop.Store(true); wg.Wait() })
return &swaps
} }
// WriteSentinelAt writes a file carrying Sentinel at p (creating its // WriteSentinelAt writes a file carrying Sentinel at p (creating its
@@ -114,41 +107,18 @@ func AssertNoLeak(t *testing.T, path, inside string, d time.Duration, read func(
AssertNoLeakFrom(t, path, inside, Outside(t), d, read) AssertNoLeakFrom(t, path, inside, Outside(t), d, read)
} }
// InsideMarker must appear in the inside note's content and in read's
// output whenever read saw that note: the floor below counts such reads.
const InsideMarker = "inside"
// AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g. // AssertNoLeakFrom is AssertNoLeak with the symlink pointing at target, e.g.
// a file elsewhere in the brain written with WriteSentinelAt. // a file elsewhere in the brain written with WriteSentinelAt.
// func AssertNoLeakFrom(t *testing.T, path, inside, target string, d time.Duration, read func() string) {
// Beyond "no Sentinel in any output" it sets two floors, because without
// them the assertion passes when nothing was tested (hyperguild#177): at
// least one symlink must have been swapped into path, and at least one read
// must have returned InsideMarker (a reader that always errors returns
// nothing, and so never leaks).
func AssertNoLeakFrom(t testing.TB, path, inside, target string, d time.Duration, read func() string) {
t.Helper() t.Helper()
if !strings.Contains(inside, InsideMarker) { StartSwap(t, path, inside, target, false)
t.Fatalf("inside content must contain %q", InsideMarker) leaks, iters := 0, 0
}
swaps := StartSwap(t, path, inside, target, false)
leaks, iters, insideReads := 0, 0, 0
for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(d); time.Now().Before(deadline); iters++ {
out := read() leaks += strings.Count(read(), Sentinel)
leaks += strings.Count(out, Sentinel)
if strings.Contains(out, InsideMarker) {
insideReads++
}
} }
if leaks > 0 { if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
} }
if n := swaps.Load(); n == 0 {
t.Fatalf("no symlink was swapped into %s in %d iterations: the race did not run", path, iters)
}
if insideReads == 0 {
t.Fatalf("no read in %d iterations returned the inside note (%q): the reader read nothing", iters, InsideMarker)
}
} }
// AssertNoHang loops read for d while a FIFO swapper (held writers) runs on // AssertNoHang loops read for d while a FIFO swapper (held writers) runs on
@@ -1,79 +0,0 @@
package brainfstest
import (
"fmt"
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
)
// recordingTB stands in for the test under AssertNoLeakFrom so a fatal
// there can be observed instead of failing this test. Fatalf ends the
// calling goroutine the way testing.T does.
type recordingTB struct {
testing.TB
failed bool
msg string
}
func (r *recordingTB) Fatalf(format string, args ...any) {
r.failed, r.msg = true, fmt.Sprintf(format, args...)
runtime.Goexit()
}
func runAssert(t *testing.T, path, target string, read func() string) *recordingTB {
t.Helper()
r := &recordingTB{TB: t}
done := make(chan struct{})
go func() {
defer close(done)
AssertNoLeakFrom(r, path, "inside", target, 200*time.Millisecond, read)
}()
<-done
return r
}
// hyperguild#177: with every swap failing, no leak can occur, so the leak
// assertion passed without testing anything.
func TestAssertNoLeakFromFailsWhenNoSymlinkSwapSucceeds(t *testing.T) {
path := filepath.Join(t.TempDir(), "missing-dir", "r.md")
r := runAssert(t, path, Outside(t), func() string { return "inside" })
if !r.failed {
t.Fatal("AssertNoLeakFrom passed with zero successful symlink swaps")
}
}
// hyperguild#177: a reader that always errors returns nothing, so it never
// leaks either.
func TestAssertNoLeakFromFailsWhenNoReadReturnsTheInsideNote(t *testing.T) {
path := filepath.Join(t.TempDir(), "r.md")
r := runAssert(t, path, Outside(t), func() string { return "" })
if !r.failed {
t.Fatal("AssertNoLeakFrom passed with zero reads returning the inside note")
}
}
// Positive control: a reader that refuses symlinks and reads regular files
// passes, so the floors do not fail a working reader.
func TestAssertNoLeakFromPassesAReaderThatRefusesSymlinks(t *testing.T) {
path := filepath.Join(t.TempDir(), "r.md")
read := func() string {
f, err := os.OpenFile(path, os.O_RDONLY|noFollow, 0)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
b := make([]byte, 64)
n, _ := f.Read(b)
return string(b[:n])
}
r := runAssert(t, path, Outside(t), read)
if r.failed {
t.Fatalf("a symlink-refusing reader failed: %s", r.msg)
}
}
const noFollow = syscall.O_NOFOLLOW
+9 -26
View File
@@ -93,20 +93,16 @@ func TestQueryDoesNotFollowASymlinkedWingUnderAWingAndHallRoot(t *testing.T) {
// bypass of readOpened hung Query after 5 iterations). // bypass of readOpened hung Query after 5 iterations).
// startSwap runs swapInto until the test ends and waits for it before // startSwap runs swapInto until the test ends and waits for it before
// t.TempDir's cleanup (hyperguild#162). // t.TempDir's cleanup (hyperguild#162).
// It returns the count of symlinks or FIFOs actually renamed into path func startSwap(t *testing.T, path, outside string, fifo bool) {
// (hyperguild#177: a swapper whose every swap fails tests nothing).
func startSwap(t *testing.T, path, outside string, fifo bool) *atomic.Int64 {
t.Helper() t.Helper()
var stop atomic.Bool var stop atomic.Bool
var swaps atomic.Int64
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop, &swaps) }() go func() { defer wg.Done(); swapInto(path, outside, fifo, &stop) }()
t.Cleanup(func() { stop.Store(true); wg.Wait() }) t.Cleanup(func() { stop.Store(true); wg.Wait() })
return &swaps
} }
func swapInto(path, outside string, fifo bool, stop *atomic.Bool, swaps *atomic.Int64) { func swapInto(path, outside string, fifo bool, stop *atomic.Bool) {
tmp := path + ".swap" tmp := path + ".swap"
// Writers stay open until the swapper exits, so a reader parked on a // Writers stay open until the swapper exits, so a reader parked on a
// FIFO stays parked (closing one would hand the reader EOF and hide the // FIFO stays parked (closing one would hand the reader EOF and hide the
@@ -119,46 +115,33 @@ func swapInto(path, outside string, fifo bool, stop *atomic.Bool, swaps *atomic.
}() }()
for i := 0; !stop.Load(); i++ { for i := 0; !stop.Load(); i++ {
_ = os.Remove(tmp) _ = os.Remove(tmp)
var made error
switch { switch {
case i%2 == 0: case i%2 == 0:
_ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644) _ = os.WriteFile(tmp, []byte("# r\n\nzqinside "+"zqracenote"), 0o644)
case fifo: case fifo:
if made = syscall.Mkfifo(tmp, 0o644); made == nil { if err := syscall.Mkfifo(tmp, 0o644); err == nil {
if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil { if w, err := os.OpenFile(tmp, os.O_RDWR, 0); err == nil {
writers = append(writers, w) writers = append(writers, w)
} }
} }
default: default:
made = os.Symlink(outside, tmp) _ = os.Symlink(outside, tmp)
}
if err := os.Rename(tmp, path); err == nil && made == nil && i%2 == 1 {
swaps.Add(1)
} }
_ = os.Rename(tmp, path)
} }
} }
func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) { func TestQueryLeaksNothingUnderASymlinkSwapRace(t *testing.T) {
brain, facts, outside := searchBrain(t) brain, facts, outside := searchBrain(t)
swaps := startSwap(t, filepath.Join(facts, "r.md"), outside, false) startSwap(t, filepath.Join(facts, "r.md"), outside, false)
leaks, iters, insideReads := 0, 0, 0 leaks, iters := 0, 0
for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ { for deadline := time.Now().Add(1500 * time.Millisecond); time.Now().Before(deadline); iters++ {
rs, err := Query(brain, QueryOptions{Query: sentinel157, Limit: 10}) rs, _ := Query(brain, QueryOptions{Query: sentinel157, Limit: 10})
require.NoError(t, err)
leaks += leaked(rs) leaks += leaked(rs)
// Positive control (hyperguild#177): a Query that always errors
// returns nothing and so never leaks.
rs, err = Query(brain, QueryOptions{Query: "zqracenote", Limit: 10})
require.NoError(t, err)
if len(rs) > 0 {
insideReads++
}
} }
if leaks > 0 { if leaks > 0 {
t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters) t.Fatalf("outside file leaked %d times in %d iterations", leaks, iters)
} }
require.Positive(t, swaps.Load(), "no symlink was swapped in: the race did not run")
require.Positive(t, insideReads, "no Query returned the inside note")
} }
func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) { func TestQueryDoesNotHangUnderAFIFOSwapRace(t *testing.T) {
@@ -0,0 +1,75 @@
// Package startupretry retries a startup dependency that may not be serving
// yet, instead of exiting on its first failure.
//
// It exists for the cold-boot ordering race (#83): on a cluster restart the
// ingestion pod can reach OIDC discovery before dex is answering, gets a 502
// from ingress, and dies — after having already connected postgres, backfilled
// the graph and ingested session batches, all of which the restart redoes.
// Nothing is misconfigured in that scenario; the pod simply started first.
//
// The budget is bounded on purpose. Retrying forever would turn a genuine
// misconfiguration (wrong issuer URL, bad audience) into a process that looks
// healthy and never serves, which is harder to diagnose than a crash loop.
package startupretry
import (
"context"
"fmt"
"time"
)
// Policy bounds the retry: at most Attempts calls, with the delay doubling
// from Base and capped at Max.
type Policy struct {
Attempts int // total calls, including the first; < 1 means 1
Base time.Duration // delay before the second attempt
Max time.Duration // ceiling for the doubling delay
}
// Do calls fn until it succeeds, the attempt budget is spent, or ctx ends.
//
// The returned error wraps the last failure, so callers keep the diagnosis
// they would have had without the retry.
func Do[T any](ctx context.Context, p Policy, fn func(context.Context) (T, error)) (T, error) {
var zero T
attempts := p.Attempts
if attempts < 1 {
attempts = 1
}
delay := p.Base
var lastErr error
for i := range attempts {
if err := ctx.Err(); err != nil {
return zero, fmt.Errorf("startupretry: %w", err)
}
v, err := fn(ctx)
if err == nil {
return v, nil
}
lastErr = err
if i == attempts-1 {
break
}
if delay <= 0 {
delay = time.Second
}
if p.Max > 0 && delay > p.Max {
delay = p.Max
}
select {
case <-ctx.Done():
return zero, fmt.Errorf("startupretry: %w", ctx.Err())
case <-time.After(delay):
}
delay *= 2
}
return zero, fmt.Errorf("startupretry: gave up after %d attempts: %w", attempts, lastErr)
}
@@ -0,0 +1,142 @@
// ingestion/internal/startupretry/startupretry_test.go
package startupretry
import (
"context"
"errors"
"testing"
"time"
)
func fastPolicy(attempts int) Policy {
return Policy{Attempts: attempts, Base: time.Millisecond, Max: 2 * time.Millisecond}
}
func TestDo_ReturnsImmediatelyOnFirstSuccess(t *testing.T) {
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
return "ready", nil
})
if err != nil {
t.Fatalf("Do: %v", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
func TestDo_SurvivesTransientFailuresThenSucceeds(t *testing.T) {
// The observed cold-boot race: dex is not serving yet, ingress answers
// 502 for the first few attempts, then discovery works.
calls := 0
got, err := Do(context.Background(), fastPolicy(5), func(context.Context) (string, error) {
calls++
if calls < 4 {
return "", errors.New("oidc discovery: status 502")
}
return "ready", nil
})
if err != nil {
t.Fatalf("Do returned %v — a transient 502 must not be fatal", err)
}
if got != "ready" {
t.Errorf("got %q, want %q", got, "ready")
}
if calls != 4 {
t.Errorf("calls = %d, want 4", calls)
}
}
func TestDo_GivesUpAfterTheAttemptBudgetAndReportsTheLastError(t *testing.T) {
// A genuinely broken issuer must still fail — retrying forever would turn
// a config error into a silent hang, which is worse than crash-looping.
last := errors.New("oidc discovery: status 404")
calls := 0
_, err := Do(context.Background(), fastPolicy(3), func(context.Context) (string, error) {
calls++
return "", last
})
if !errors.Is(err, last) {
t.Fatalf("err = %v, want it to wrap the last failure", err)
}
if calls != 3 {
t.Errorf("calls = %d, want 3 (the attempt budget)", calls)
}
}
func TestDo_StopsPromptlyWhenTheContextIsCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
done := make(chan struct{})
var err error
go func() {
defer close(done)
_, err = Do(ctx, Policy{Attempts: 100, Base: 50 * time.Millisecond, Max: time.Second},
func(context.Context) (string, error) {
calls++
if calls == 1 {
cancel()
}
return "", errors.New("still down")
})
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Do did not return after context cancellation")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("err = %v, want context.Canceled", err)
}
}
func TestDo_BacksOffAndIsCappedByMax(t *testing.T) {
var delays []time.Duration
p := Policy{Attempts: 5, Base: 10 * time.Millisecond, Max: 25 * time.Millisecond}
last := time.Now()
_, _ = Do(context.Background(), p, func(context.Context) (string, error) {
now := time.Now()
delays = append(delays, now.Sub(last))
last = now
return "", errors.New("down")
})
// delays[0] is the first call (no wait); the rest are the sleeps.
if len(delays) != 5 {
t.Fatalf("attempts = %d, want 5", len(delays))
}
if delays[1] >= delays[2] {
t.Errorf("backoff is not growing: %v then %v", delays[1], delays[2])
}
for i, d := range delays[1:] {
if d > 2*p.Max {
t.Errorf("delay %d = %v, exceeds the Max cap %v with slack", i+1, d, p.Max)
}
}
}
func TestPolicy_ZeroValueStillMakesOneAttempt(t *testing.T) {
// A caller that forgets to configure the policy must not silently get a
// no-op that returns a nil validator and a nil error.
calls := 0
_, err := Do(context.Background(), Policy{}, func(context.Context) (string, error) {
calls++
return "", errors.New("down")
})
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
if err == nil {
t.Error("err = nil, want the failure surfaced")
}
}