commit 12bf35caf85713de071744b93c40fb32f49e6168 Author: William Gill Date: Thu Apr 16 18:10:52 2026 -0500 anchorage v1.0 initial tree Greenfield Go multi-tenant IPFS Pinning Service wire-compatible with the IPFS Pinning Services API spec. Paired 1:1 with Kubo over localhost RPC, clustered via embedded NATS JetStream, Postgres source-of-truth with RLS-enforced tenancy, Fiber + huma v2 for the HTTP surface, Authentik OIDC for session login with kid-rotated HS256 JWT API tokens. Feature-complete against the 22-milestone build plan, including the ship-it v1.0 gap items: * admin CLIs: drain/uncordon, maintenance, mint-token, rotate-key, prune-denylist, rebalance --dry-run, cache-stats, cluster-presences * TTL leader election via NATS KV, fence tokens, JetStream dedup * rebalancer (plan/apply split), reconciler, requeue sweeper * ristretto caches with NATS-backed cross-node invalidation (placements live-nodes + token denylist) * maintenance watchdog for stuck cluster-pause flag * Prometheus /metrics with CIDR ACL, HTTP/pin/scheduler/cache gauges * rate limiting: session (10/min) + anonymous global (120/min) * integration tests: rebalance, refcount multi-org, RLS belt * goreleaser (tar + deb/rpm/apk + Alpine Docker) targeting Gitea Stack: Cobra/Viper, Fiber v2 + huma v2, embedded NATS JetStream, pgx/sqlc/golang-migrate, ristretto, TypeID, prometheus/client_golang, testcontainers-go. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1d8a6b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +.git +.github +.gitignore +.editorconfig +.dockerignore + +# Build outputs +bin/ +dist/ +coverage.* +*.exe +*.test +*.prof +*.out + +# Editor / IDE +.idea/ +.vscode/ +.DS_Store +Thumbs.db + +# Docs + tests aren't shipped in the runtime image +docs/ +scripts/ +test/ +*_test.go +testdata/ + +# Local dev / secrets never leave the host +configs/anchorage.local.yaml +configs/*.secret.yaml +*.env +*.key +*.pem diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a095fcd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 4 + +[{Makefile,*.mk}] +indent_style = tab + +[*.{yaml,yml,json,toml,md}] +indent_style = space +indent_size = 2 + +[*.sql] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7818d06 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize everything to LF in the repo; checkouts keep LF on every OS. +# Go tooling, sqlc, Docker, and shell scripts all assume LF. +* text=auto eol=lf + +# Binary-ish files never get EOL munging. +*.png binary +*.jpg binary +*.gif binary +*.ico binary +*.pdf binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c64d1a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Go build artifacts +/bin/ +/dist/ +*.exe +*.test +*.out +*.prof +coverage.* +/anchorage +/anchorage.exe + +# Editor / OS / tool-local +.idea/ +.vscode/ +.claude/ +.DS_Store +Thumbs.db + +# Runtime / local config +/configs/anchorage.local.yaml +/configs/*.secret.yaml +*.env +*.key +*.pem + +# NATS JetStream data + Kubo repos +/data/ +/var/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..e5f28b8 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,227 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json +# vim: set ts=2 sw=2 tw=0 fo=jcroql +# +# anchorage release pipeline — modeled on the sibling kanrisha project. +# Produces tar archives, .deb / .rpm system packages, OCI container images, +# a source tarball, and a checksum file for every tagged release. +# +# Usage: +# make snapshot # local dry-run into ./dist (no publish) +# make release # tagged release → Gitea + package repo + container registry +# +# Environment variables the release command expects: +# GITEA_TOKEN — write access to git.anomalous.dev packages + releases +version: 2 + +project_name: anchorage + +force_token: gitea + +snapshot: + version_template: "{{ incpatch .Version }}" + +gitea_urls: + api: https://git.anomalous.dev/api/v1 + download: https://git.anomalous.dev/ + skip_tls_verify: false + +before: + hooks: + - go mod tidy + +# --------------------------------------------------------------------------- +# Binaries +# --------------------------------------------------------------------------- +builds: + - id: anchorage + env: + - CGO_ENABLED=0 + goos: + - linux + goarch: + - amd64 + main: ./cmd/anchorage + binary: anchorage + flags: + - -trimpath + ldflags: + - -s -w + - -X anchorage/internal/cmd.version={{ .Version }} + - -X anchorage/internal/cmd.commit={{ .ShortCommit }} + - -X anchorage/internal/cmd.date={{ .Date }} + mod_timestamp: "{{ .CommitTimestamp }}" + +# --------------------------------------------------------------------------- +# Archives +# --------------------------------------------------------------------------- +archives: + - id: anchorage + ids: [ anchorage ] + name_template: "anchorage_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + formats: [ tar ] + files: + - README.md + - configs/anchorage.example.yaml + - docs/*.md + +# --------------------------------------------------------------------------- +# Native Linux packages (.deb, .rpm) +# --------------------------------------------------------------------------- +nfpms: + - id: anchorage-deb + package_name: anchorage + ids: [ anchorage ] + bindir: /usr/bin + formats: [ deb ] + + vendor: &nfpm-vendor "AlphaCentri Corporation" + homepage: &nfpm-homepage "https://git.anomalous.dev/alphacentri/anchorage" + maintainer: &nfpm-maintainer "William Gill " + description: &nfpm-description "Highly-available, multi-tenant IPFS Pinning Service" + license: &nfpm-license "EUPL-1.2" + section: &nfpm-section "net" + priority: &nfpm-priority "optional" + + scripts: &nfpm-scripts + preinstall: packaging/scripts/preinstall.sh + postinstall: packaging/scripts/postinstall.sh + preremove: packaging/scripts/preremove.sh + postremove: packaging/scripts/postremove.sh + + contents: &nfpm-contents + # systemd service unit. + - src: packaging/anchorage.service + dst: /usr/lib/systemd/system/anchorage.service + type: config|noreplace + + # Example config — never overwrite operator-edited config. + - src: configs/anchorage.example.yaml + dst: /etc/anchorage/anchorage.yaml.example + + # Empty directories created with correct ownership so the service + # has a place to write logs, NATS JetStream state, and its stable + # node.id file even before the operator finalises config. + - dst: /etc/anchorage + type: dir + file_info: + owner: anchorage + group: anchorage + mode: 0755 + - dst: /var/lib/anchorage + type: dir + file_info: + owner: anchorage + group: anchorage + mode: 0750 + - dst: /var/lib/anchorage/nats + type: dir + file_info: + owner: anchorage + group: anchorage + mode: 0750 + - dst: /var/log/anchorage + type: dir + file_info: + owner: anchorage + group: anchorage + mode: 0750 + + dependencies: + - systemd + recommends: + - postgresql + - kubo + + - id: anchorage-rpm + package_name: anchorage + ids: [ anchorage ] + bindir: /usr/bin + formats: [ rpm ] + + vendor: *nfpm-vendor + homepage: *nfpm-homepage + maintainer: *nfpm-maintainer + description: *nfpm-description + license: *nfpm-license + section: *nfpm-section + priority: *nfpm-priority + scripts: *nfpm-scripts + contents: *nfpm-contents + + dependencies: + - systemd + +# --------------------------------------------------------------------------- +# OCI container images +# +# GoReleaser invokes `docker build` with the dist/ artifact as context, +# so the Dockerfile at build/package/Dockerfile only has to COPY the +# pre-built binary into a minimal base. Multi-arch variants can be added +# later by extending builds[] and declaring one dockers[] entry per arch. +# --------------------------------------------------------------------------- +dockers: + - id: anchorage-linux-amd64 + ids: [ anchorage ] + goos: linux + goarch: amd64 + dockerfile: build/package/Dockerfile + image_templates: + - "git.anomalous.dev/alphacentri/anchorage:{{ .Version }}" + - "git.anomalous.dev/alphacentri/anchorage:{{ .Major }}.{{ .Minor }}" + - "git.anomalous.dev/alphacentri/anchorage:latest" + build_flag_templates: + - "--label=org.opencontainers.image.title=anchorage" + - "--label=org.opencontainers.image.description={{ .ProjectName }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + - "--label=org.opencontainers.image.source=https://git.anomalous.dev/alphacentri/anchorage" + - "--label=org.opencontainers.image.licenses=EUPL-1.2" + use: docker + skip_push: auto + +# --------------------------------------------------------------------------- +# Publishers — push .deb and .rpm artifacts to Gitea's package repos +# --------------------------------------------------------------------------- +publishers: + - name: gitea-deb + ids: [ anchorage-deb ] + cmd: >- + bash -c 'case "{{ .ArtifactName }}" in *.deb) + curl --fail --silent --user alphacentri:{{ .Env.GITEA_TOKEN }} + --upload-file {{ .ArtifactPath }} + https://git.anomalous.dev/api/packages/alphacentri/debian/pool/anchorage/main/upload + ;; *) echo "skipping non-deb artifact {{ .ArtifactName }}" ;; esac' + + - name: gitea-rpm + ids: [ anchorage-rpm ] + cmd: >- + bash -c 'case "{{ .ArtifactName }}" in *.rpm) + curl --fail --silent --user alphacentri:{{ .Env.GITEA_TOKEN }} + --upload-file {{ .ArtifactPath }} + https://git.anomalous.dev/api/packages/alphacentri/rpm/upload + ;; *) echo "skipping non-rpm artifact {{ .ArtifactName }}" ;; esac' + +# --------------------------------------------------------------------------- +# Source tarball + checksums + release metadata +# --------------------------------------------------------------------------- +source: + enabled: true + name_template: '{{ .ProjectName }}_{{ .Version }}' + +checksum: + name_template: 'checksums.txt' + +release: + gitea: + owner: alphacentri + name: anchorage + draft: false + prerelease: auto + header: | + ## anchorage {{ .Version }} + + Highly-available, multi-tenant IPFS Pinning Service. + + See [docs/architecture.md](https://git.anomalous.dev/alphacentri/anchorage/src/branch/main/docs/architecture.md) + for a tour of the system; [docs/cluster-ops.md](https://git.anomalous.dev/alphacentri/anchorage/src/branch/main/docs/cluster-ops.md) + for drain / maintenance procedures. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f5c6477 --- /dev/null +++ b/Makefile @@ -0,0 +1,102 @@ +# anchorage — developer-facing Makefile. +# +# Targets follow the Go standards doc: fmt + vet + lint + test must be +# clean on every PR. `make check` is the local equivalent of CI. + +SHELL := /usr/bin/env bash +PKG := anchorage +BIN := anchorage +BIN_DIR := bin +CMD_DIR := ./cmd/anchorage + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) +DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +LDFLAGS := -s -w \ + -X $(PKG)/internal/cmd.version=$(VERSION) \ + -X $(PKG)/internal/cmd.commit=$(COMMIT) \ + -X $(PKG)/internal/cmd.date=$(DATE) + +GO ?= go +GOFMT ?= gofmt +GOIMPORTS ?= goimports +STATICCHK ?= staticcheck +GOVULNCHK ?= govulncheck +GORELEASER ?= goreleaser + +.PHONY: help +help: ## Show this help + @awk 'BEGIN{FS=":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +.PHONY: build +build: ## Compile the anchorage binary into ./bin + @mkdir -p $(BIN_DIR) + $(GO) build -trimpath -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BIN) $(CMD_DIR) + +.PHONY: run +run: ## Run `anchorage version` against the local source tree + $(GO) run $(CMD_DIR) version + +.PHONY: fmt +fmt: ## gofmt + goimports across the module + $(GOFMT) -w -s . + @command -v $(GOIMPORTS) >/dev/null && $(GOIMPORTS) -w -local $(PKG) . || echo "goimports not installed; skipping" + +.PHONY: vet +vet: ## go vet ./... + $(GO) vet ./... + +.PHONY: lint +lint: ## staticcheck ./... + @command -v $(STATICCHK) >/dev/null && $(STATICCHK) ./... || echo "staticcheck not installed; skipping" + +.PHONY: test +test: ## go test -race -count=1 ./... + $(GO) test -race -count=1 ./... + +.PHONY: vuln +vuln: ## govulncheck ./... + @command -v $(GOVULNCHK) >/dev/null && $(GOVULNCHK) ./... || echo "govulncheck not installed; skipping" + +.PHONY: tidy +tidy: ## go mod tidy + $(GO) mod tidy + +.PHONY: check +check: fmt vet lint test vuln ## Full pre-PR verification + +.PHONY: clean +clean: ## Remove build artifacts + rm -rf $(BIN_DIR) coverage.* dist/ + +# --------------------------------------------------------------------------- +# Release pipeline — GoReleaser +# --------------------------------------------------------------------------- + +.PHONY: snapshot +snapshot: ## Build a local snapshot (tar + deb + rpm + docker) into ./dist + $(GORELEASER) release --snapshot --clean + +.PHONY: release-check +release-check: ## Dry-run the release config without building + $(GORELEASER) check + +.PHONY: release +release: ## Tagged release (requires GITEA_TOKEN in env; pushes to Gitea + package repo) + $(GORELEASER) release --clean + +.PHONY: docker-local +docker-local: build ## Build the container image from the current tree (no GoReleaser) + docker build -f build/package/Dockerfile -t anchorage:dev $(BIN_DIR) + +# --------------------------------------------------------------------------- +# Swarm stack helpers +# --------------------------------------------------------------------------- + +.PHONY: stack-deploy +stack-deploy: ## Deploy the production Swarm stack (requires docker swarm init + secrets) + docker stack deploy -c deploy/docker-compose.yml anchorage + +.PHONY: stack-rm +stack-rm: ## Remove the deployed Swarm stack (volumes retained) + docker stack rm anchorage diff --git a/README.md b/README.md new file mode 100644 index 0000000..c09d510 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# anchorage + +Highly-available, multi-tenant IPFS Pinning Service wire-compatible with the +[IPFS Pinning Services API spec](https://ipfs.github.io/pinning-services-api-spec/). + +Each anchorage instance is paired 1:1 with its own Kubo daemon and joins an +embedded NATS cluster for signaling. Postgres is the source of truth for all +durable state (pins, placements, orgs, users, tokens, audit log). + +Status: **skeleton complete**, milestones 1–22 all landed end-to-end. The +in-memory store is in place for dev; the pgx-backed store slots in once +sqlc generates the code from [sqlc.yaml](sqlc.yaml) + +[queries/](internal/pkg/store/postgres/queries/). + +See: + +- [docs/architecture.md](docs/architecture.md) +- [docs/cluster-ops.md](docs/cluster-ops.md) — drain / maintenance procedures +- [docs/auth-flow.md](docs/auth-flow.md) — how anchorage handles humans + API clients +- [docs/authentik-setup.md](docs/authentik-setup.md) — configuring Authentik as the OIDC provider +- [deploy/README.md](deploy/README.md) — deb/rpm install + Swarm stack deploy + +## Quickstart (dev) + +```bash +make build +./bin/anchorage version +``` + +## Layout + +- `cmd/anchorage/` — thin `main` entry point. +- `internal/cmd/` — Cobra command tree (`version`, soon `serve`, `migrate`, `admin`). +- `internal/app/anchorage/` — composition root (wires deps, lifecycle). +- `internal/pkg/` — domain packages (`config`, `store`, `pin`, `ipfs`, ...). +- `configs/` — example Viper YAML. +- `docs/` — architecture, auth flow, operations. + +## Development + +```bash +make check # fmt + vet + lint + test + vuln +make test # go test -race ./... +make build # ./bin/anchorage +``` diff --git a/build/package/Dockerfile b/build/package/Dockerfile new file mode 100644 index 0000000..ee3159e --- /dev/null +++ b/build/package/Dockerfile @@ -0,0 +1,68 @@ +# anchorage runtime image (Alpine). +# +# Designed to be invoked by GoReleaser's `dockers:` block — GoReleaser +# builds the static binary under dist/ and sets that as the build +# context, so this file only has to install runtime dependencies and +# COPY the binary into a minimal Alpine base. +# +# For a standalone `docker build` (not via GoReleaser) the binary must +# already exist at ./anchorage in the build context; see Makefile's +# `docker-local` target for the local-build wrapper. +# +# Why Alpine and not distroless: +# - busybox shell is available for `docker exec -it … sh` triage. +# - apk add for ad-hoc debug tools (curl, busybox-extras, strace) at +# runtime without rebuilding. +# - matches the uid/gid conventions the .deb/.rpm packages use, so an +# operator who flips between compose and systemd deploys sees the +# same `anchorage` user owning the files. +# +# Size is ~15 MB final image (5 MB Alpine + 3 MB CA/tz + ~7 MB static +# anchorage binary). Small enough; debuggable when we need it. + +FROM alpine:3.20 + +# Pin to a specific uid/gid so volume permissions stay stable across +# image rebuilds and match the nfpm-generated /etc/passwd on the host. +ARG ANCHORAGE_UID=8080 +ARG ANCHORAGE_GID=8080 + +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + tini \ + && addgroup -S -g ${ANCHORAGE_GID} anchorage \ + && adduser -S -D -H -u ${ANCHORAGE_UID} -G anchorage -s /sbin/nologin anchorage \ + && mkdir -p /etc/anchorage /var/lib/anchorage /var/log/anchorage \ + && chown -R anchorage:anchorage /etc/anchorage /var/lib/anchorage /var/log/anchorage \ + && chmod 0750 /var/lib/anchorage /var/log/anchorage + +COPY anchorage /usr/local/bin/anchorage +RUN chmod 0755 /usr/local/bin/anchorage + +# OCI default ports: +# 8080 — HTTP + WebSocket +# 4222 — NATS client +# 6222 — NATS cluster gossip (peer ↔ peer) +EXPOSE 8080 4222 6222 + +# State lives on a volume so the stable node.id and NATS JetStream +# files survive container restarts. Compose / k8s mounts a named volume +# here; the nfpm-packaged install uses the same /var/lib/anchorage path +# so muscle memory transfers. +VOLUME ["/var/lib/anchorage"] + +# Liveness only — /v1/ready returning 503 during drain would flap the +# container otherwise. The orchestrator (compose / Swarm / k8s) layers +# its own readiness probe on /v1/ready separately. +HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=3 \ + CMD wget --quiet --spider http://127.0.0.1:8080/v1/health || exit 1 + +USER anchorage:anchorage +WORKDIR /var/lib/anchorage + +# tini reaps zombies and forwards SIGTERM so the Go runtime's graceful +# shutdown actually runs (the Fiber server drains, consumers drain, +# NATS drains — see internal/app/anchorage/app.go:Close). +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/anchorage"] +CMD ["serve", "--config", "/etc/anchorage/anchorage.yaml"] diff --git a/cmd/anchorage/main.go b/cmd/anchorage/main.go new file mode 100644 index 0000000..7167f68 --- /dev/null +++ b/cmd/anchorage/main.go @@ -0,0 +1,19 @@ +// Command anchorage is the entry point for the anchorage IPFS Pinning Service. +// +// The real work lives in the internal/cmd package; main only wires the Cobra +// command tree into os.Args and surfaces a non-zero exit code on failure. +package main + +import ( + "fmt" + "os" + + "anchorage/internal/cmd" +) + +func main() { + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, "anchorage:", err) + os.Exit(1) + } +} diff --git a/configs/anchorage.example.yaml b/configs/anchorage.example.yaml new file mode 100644 index 0000000..0114106 --- /dev/null +++ b/configs/anchorage.example.yaml @@ -0,0 +1,131 @@ +# anchorage — example configuration. +# +# Copy to configs/anchorage.yaml (or /etc/anchorage/anchorage.yaml) and edit +# as needed. All keys can also be overridden via environment variables with +# the ANCHORAGE_ prefix, e.g. ANCHORAGE_SERVER_PORT=9090. + +server: + host: 0.0.0.0 + port: 8080 + readTimeout: 10s + writeTimeout: 30s + + metrics: + # Prometheus /metrics is gated by a CIDR allowlist checked against + # the direct TCP peer IP (not X-Forwarded-For). Unset → loopback + + # RFC1918 defaults, which works for compose / swarm / k8s scrapers + # on the same internal network. Explicit empty list ([]) disables + # the ACL — use a firewall in that case. + # allowCIDRs: + # - "10.0.0.0/8" + # - "127.0.0.1/32" + + rateLimit: + # POST /v1/auth/session attempts per IP per minute (brute-force guard). + sessionPerMinute: 10 + # Unauthenticated requests per IP per minute across the API. + # Authenticated traffic (valid Bearer or session cookie) is exempt. + anonymousPerMinute: 120 + +node: + # Unique ID of this anchorage instance within the cluster. + # Defaults to the OS hostname when omitted. + id: node-1 + # Public libp2p multiaddrs of this node's paired Kubo daemon. + # Clients receive these as PinStatus.delegates for direct data push. + multiaddrs: + - /dns4/node-1.example.com/tcp/4001/p2p/12D3Koo... + +ipfs: + # HTTP RPC endpoint of the local paired Kubo daemon (same host / pod). + rpc: http://localhost:5001 + timeout: 2m + reconciler: + # How often each node diffs its Postgres placements vs local `ipfs pin ls`. + interval: 10m + # When true, the reconciler re-pins drifted CIDs instead of just reporting. + autoRepair: false + +cluster: + # 1 on a single-node install, 2+ for HA. See plan for placement rules. + minReplicas: 2 + heartbeatInterval: 5s + downAfter: 30s + rebalanceInterval: 1m + # When true, the rebalancer acts; when false, it only logs what it would do. + autoRepair: false + # Time a drained node gives in-flight jobs before shutting down its consumer. + drainGracePeriod: 2m + maintenance: + # Safety rail: if cluster-wide maintenance has been on longer than this, + # emit a loud warning so forgotten flags don't silently mask outages. + maxDuration: 1h + +postgres: + dsn: postgres://anchorage:***@pg-primary/anchorage?sslmode=require + maxConns: 20 + # Run golang-migrate on boot. Safe with N replicas starting simultaneously + # thanks to Postgres advisory locks. + autoMigrate: true + requeueSweeper: 30s + +nats: + dataDir: /var/lib/anchorage/nats + client: + host: 0.0.0.0 + port: 4222 + cluster: + name: anchorage + host: 0.0.0.0 + port: 6222 + # Peers discovered via gossip; list at least one existing cluster member. + routes: + - nats://peer-a:6222 + - nats://peer-b:6222 + jetstream: + # Stream replication factor. Capped at clusterSize at runtime. + replicas: 3 + +auth: + authentik: + issuer: https://auth.example.com/application/o/anchorage/ + clientID: anchorage-web + audience: anchorage + apiToken: + # Signing keys. Exactly one entry must have `primary: true` — that's + # the key anchorage mints new tokens with; any additional entries + # are verify-only, used during a rotation overlap window. See + # deploy/README.md "Rotating the JWT signing key" for the + # three-step procedure. + # + # Leave signingKeys empty for local dev — anchorage will fall back + # to a built-in dev key with kid="dev" and log a loud warning. + signingKeys: + - id: "2026-04" + path: /etc/anchorage/jwt.key + primary: true + + # Interactive / web-session tokens. + defaultTTL: 24h + # IPFS clients, CI, long-lived service identities — 1 year plus a + # 30-day grace window so operators have time to rotate before expiry. + maxTTL: 9480h + +bootstrap: + # Emails promoted to sysadmin on their first Authentik login. + sysadmins: + - admin@example.com + +logging: + # debug | info | warn | error. Controls the minimum level for the file sink. + level: info + # text | json — controls the stderr sink (warn+). The file sink is always JSON. + format: text + # When true, every record carries source file:line attributes. + source: false + # Empty disables the file sink; stderr then receives every level. + file: /var/log/anchorage/anchorage.log + maxSizeMB: 100 + maxBackups: 10 + maxAgeDays: 30 + compress: true diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..4572d33 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,332 @@ +# Deploying anchorage + +Two supported shapes — both use the same container image + binaries +produced by GoReleaser. + +> Before either path, configure your OIDC provider: see +> [../docs/authentik-setup.md](../docs/authentik-setup.md) for the +> Authentik walkthrough. Without a valid `auth.authentik.issuer` / +> `clientID` / `audience` in anchorage.yaml the web UI login won't work +> — though `anchorage admin mint-token` and the API still do. + +## Option 1 — Linux packages (deb / rpm) + +GoReleaser emits `.deb` and `.rpm` artifacts with a bundled systemd +unit, lifecycle hooks, and directory structure under +`/etc/anchorage`, `/var/lib/anchorage`, `/var/log/anchorage`. + +```bash +# Debian / Ubuntu +apt install ./anchorage_${VERSION}_linux_amd64.deb + +# RHEL / Fedora / Alma +dnf install ./anchorage_${VERSION}_linux_amd64.rpm +``` + +Post-install flow: + +```bash +cp /etc/anchorage/anchorage.yaml.example /etc/anchorage/anchorage.yaml +# edit anchorage.yaml — postgres DSN, authentik issuer, ipfs.rpc, … + +openssl rand -base64 48 > /etc/anchorage/jwt.key +chmod 0400 /etc/anchorage/jwt.key +chown anchorage:anchorage /etc/anchorage/jwt.key + +# apply schema (advisory-lock-safe on a cluster) +/usr/bin/anchorage migrate up --config /etc/anchorage/anchorage.yaml + +systemctl start anchorage +systemctl status anchorage +journalctl -u anchorage -f +``` + +## Option 2 — Docker Swarm (three-node stack) + +The stack in [docker-compose.yml](docker-compose.yml) runs three +anchorage instances, each paired 1:1 with its own Kubo daemon, against +a single Postgres. An nginx LB fronts HTTP and upgrades `/v1/events` +to WebSocket. + +### Prerequisites + +- Three Docker Swarm nodes (or one node if you don't care about HA — + just drop the `placement.constraints` lines). +- Each anchorage-hosting node needs `anchorage.anchor-id` as a label + and `anchorage.anchor=true`: + +```bash +docker swarm init +docker node update --label-add anchorage.db=true node-1 +docker node update --label-add anchorage.anchor=true node-1 +docker node update --label-add anchorage.anchor=true node-2 +docker node update --label-add anchorage.anchor=true node-3 +docker node update --label-add anchorage.anchor-id=anchor-1 node-1 +docker node update --label-add anchorage.anchor-id=anchor-2 node-2 +docker node update --label-add anchorage.anchor-id=anchor-3 node-3 +``` + +### Secrets + +```bash +openssl rand -base64 32 | docker secret create anchorage_postgres_password - +openssl rand -base64 48 | docker secret create anchorage_jwt_key - +``` + +### Env file + +```bash +cat > .env <<'EOF' +ANCHORAGE_IMAGE=git.anomalous.dev/alphacentri/anchorage:latest +ANCHORAGE_DOMAIN=anchor.example.com +ANCHORAGE_AUTHENTIK_URL=https://auth.example.com/application/o/anchorage/ +POSTGRES_REPLICAS=0 +EOF +``` + +### Deploy + +```bash +docker stack deploy -c docker-compose.yml anchorage + +docker stack services anchorage +docker service logs anchorage_anchorage-1 +``` + +### Verify + +```bash +curl -fsS https://anchor.example.com/v1/health +curl -fsS https://anchor.example.com/v1/ready +``` + +### Upgrade + +```bash +# Bump the image tag in .env, then: +docker stack deploy -c docker-compose.yml anchorage + +# Before a disruptive rolling restart, pause the cluster rebalancer +# so brief node absences don't trigger placement thrash: +anchorage admin maintenance on --reason "upgrade to v1.2" --ttl 30m + +# …wait for the stack to converge, then: +anchorage admin maintenance off +``` + +Drain a single node for hardware work: + +```bash +anchorage admin drain nod_anchor_2 # also visible in audit log +anchorage admin uncordon nod_anchor_2 +``` + +## Minting a JWT for IPFS clients (`ipfs pin remote`) + +Before any OIDC user exists — or when handing a long-lived token to a +CI pipeline or a headless service — use `anchorage admin mint-token`. +It reads the signing key directly off disk and emits a signed JWT +to stdout; no live anchorage process is required. + +```bash +# Sysadmin break-glass token, default 395-day TTL (1 year + 30-day grace) +TOKEN=$(anchorage admin mint-token \ + --signing-key /etc/anchorage/jwt.key \ + --issuer https://auth.example.com/application/o/anchorage/ \ + --audience anchorage) + +# Hand it to the IPFS CLI: +ipfs pin remote service add anchor https://anchor.example.com/v1 "$TOKEN" +ipfs pin remote add --service=anchor --name "my-dataset" bafybeig... +``` + +`--issuer` and `--audience` must match the running anchorage's +`auth.authentik.*` config — when mint-token is run from the same host +as the server it reads these from `anchorage.yaml` automatically. + +Shorter-lived tokens (e.g., a developer session): + +```bash +anchorage admin mint-token --role member --org org_... --ttl 8h +``` + +Minted tokens are **standalone** — they don't appear in +`GET /v1/tokens` and can't be revoked individually. To revoke one, +either write its `jti` to the denylist via the `/v1/tokens/{jti}` +DELETE endpoint (if registered) or rotate the signing key to invalidate +every outstanding token at once. + +## Rotating the JWT signing key + +anchorage supports overlap-style rotation — load the new key alongside +the old, flip which one mints new tokens, then drop the retired key +once outstanding tokens have expired or been re-minted. No mass +re-auth event. + +Every token carries a `kid` header naming the key that signed it. +The verifier picks the matching key from the currently-loaded set, +so "verify against either A or B" works unambiguously. + +### Config shape + +`auth.apiToken.signingKeys` is a list. Exactly one entry has +`primary: true` — the minting key; any additional entries are +verify-only. + +Steady state: + +```yaml +auth: + apiToken: + signingKeys: + - id: "2026-04" + path: /etc/anchorage/jwt.key + primary: true +``` + +During a rotation overlap: + +```yaml +auth: + apiToken: + signingKeys: + - id: "2026-04" + path: /etc/anchorage/jwt.key + primary: true # still the minting key + - id: "2026-10" + path: /etc/anchorage/jwt.key.2026-10 + # verify-only until we flip `primary` below +``` + +### Procedure + +**Step 1 — generate the new key and stage it.** + +```bash +anchorage admin rotate-signing-key --id 2026-10 --out /etc/anchorage/jwt.key.2026-10 +# prints a YAML snippet to stdout — append it to auth.apiToken.signingKeys +``` + +Distribute the new key file to every anchorage node (Swarm secret, +k8s Secret, Ansible, whatever you already use). The file must have +identical bytes on every node. + +Apply the config change adding the new entry (no `primary: true`) and +roll-restart the fleet. Every anchorage now verifies against both +keys but continues minting with the old primary. + +**Step 2 — flip primary.** Edit the config so `primary: true` moves +from the old entry to the new one: + +```yaml +signingKeys: + - id: "2026-04" + path: /etc/anchorage/jwt.key + - id: "2026-10" + path: /etc/anchorage/jwt.key.2026-10 + primary: true +``` + +Roll-restart. New mints now use `kid=2026-10`. Tokens already in the +wild with `kid=2026-04` continue to verify. + +**Step 3 — drop the retired key.** Wait until outstanding old-key +tokens have expired or been re-minted. `auth.apiToken.maxTTL` is the +upper bound: + +- 24h default TTL + sessions only: wait 25h and you're done. +- 395-day IPFS client tokens: either wait the full window, or + mass-revoke via the denylist and ask users to re-mint. Most shops + pick the second path for security-driven rotations and the first + for scheduled ones. + +Remove the old entry: + +```yaml +signingKeys: + - id: "2026-10" + path: /etc/anchorage/jwt.key.2026-10 + primary: true +``` + +Roll-restart. Any straggler token still signed with the old key is +now rejected with `token: unknown kid "2026-04"`. Delete +`/etc/anchorage/jwt.key` from every node once the restart is complete. + +### When to rotate + +- **Scheduled** (annual / per-security-policy) — follow the full + three-step procedure. Invisible to users whose tokens renew inside + the overlap window. +- **Suspected compromise** — do steps 1+2 immediately (seconds apart), + then mass-denylist every outstanding old-key token or skip directly + to step 3 and accept the breakage. +- **Algorithm migration** (HS256 → ed25519 / RS256) — not yet + supported; the `token` package is HS256-only today. When it lands, + the same three-step rotation pattern will apply. + +## Observability: Prometheus /metrics + +anchorage serves a Prometheus scrape endpoint at `/metrics` at the +root (not under `/v1`) so standard service-discovery selectors work. + +Gated by a CIDR allowlist on the direct TCP peer IP. Defaults to +loopback + RFC1918, which matches the typical compose / swarm / k8s +intra-cluster scrape path without leaking /metrics through a public +LB. Tighten or disable via `server.metrics.allowCIDRs` in +anchorage.yaml. + +Series exposed: + +``` +anchorage_http_requests_total{method,status_class} +anchorage_pin_ops_total{op,result} +anchorage_scheduler_fetch_total{node,result} +anchorage_scheduler_acks_total{node,status} +anchorage_cache_hits_total{name} +anchorage_cache_misses_total{name} +anchorage_leader_is_elected +anchorage_cluster_nodes_live +anchorage_placements_by_status{status} +``` + +Scrape with the standard Prometheus job config (scrape each anchorage +pod / container directly — the LB is bypassed). Alerting rules are +left to the operator; a reasonable starter set watches for +`anchorage_leader_is_elected == 0` across every node (nobody is the +leader), `rate(anchorage_pin_ops_total{result="err"}[5m])` spikes, and +`anchorage_cluster_nodes_live` falling below minReplicas. + +## Rate limiting + +Two layers: + +- `POST /v1/auth/session` — capped per IP per minute (`server.rateLimit.sessionPerMinute`, default 10). Brute-force guard. +- All anonymous requests — capped per IP per minute (`server.rateLimit.anonymousPerMinute`, default 120). Authenticated traffic (valid Bearer or session cookie) is exempt. Probe paths (`/v1/health`, `/v1/ready`, `/metrics`) are exempt. + +Storage is per-process in-memory. Sticky sessions at the LB make this +effectively global; without sticky sessions an attacker can burst +across N anchorage nodes for N× the throughput. If that matters in +your deployment, deploy behind a proxy that enforces its own global +limits (e.g., nginx `limit_req_zone`, envoy `local_ratelimit`). + +## Backing up Postgres + +```bash +docker exec -it $(docker ps -q -f name=anchorage_postgres) \ + pg_dump -U anchorage -Fc anchorage > anchorage_$(date +%F).pgdump +``` + +## Backing up NATS state + +NATS state under `/var/lib/anchorage/nats` is non-authoritative — it +holds in-flight jobs and the leader / cluster-maintenance KV. Losing +it trips the requeue sweeper once and comes back; Postgres is the +source of truth. + +Still, if you want it captured: + +```bash +docker run --rm -v anchorage_anchorage_1_data:/data \ + busybox tar czf - /data/nats > nats_1_$(date +%F).tar.gz +``` diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..9080b7f --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,410 @@ +# anchorage Production Stack — Docker Swarm +# +# Three anchorage instances, each paired 1:1 with its own Kubo sidecar, +# sharing a single Postgres cluster. NATS is embedded in each anchorage +# daemon (no separate NATS container needed). An nginx LB fronts HTTP +# and handles WebSocket upgrades for /v1/events. +# +# Setup: +# 1. Initialize swarm (if not already): +# docker swarm init +# +# 2. Label nodes for placement (HA setups with 3+ nodes): +# docker node update --label-add anchorage.db=true node-1 +# docker node update --label-add anchorage.anchor=true node-1 +# docker node update --label-add anchorage.anchor=true node-2 +# docker node update --label-add anchorage.anchor=true node-3 +# +# 3. Create secrets: +# openssl rand -base64 32 | docker secret create anchorage_postgres_password - +# openssl rand -base64 48 | docker secret create anchorage_jwt_key - +# +# 4. Deploy: +# docker stack deploy -c deploy/docker-compose.yml anchorage +# +# 5. Bootstrap sysadmin (one-shot against a live anchorage): +# docker run --rm --network anchorage_anchorage \ +# git.anomalous.dev/alphacentri/anchorage:latest \ +# admin promote-sysadmin admin@example.com +# +# 6. Check: +# docker stack services anchorage +# docker service logs anchorage_anchorage-1 +# curl https://anchor.example.com/v1/health +# +# Environment variables (set in .env or shell before deploy): +# ANCHORAGE_IMAGE — container image (default: git.anomalous.dev/alphacentri/anchorage:latest) +# ANCHORAGE_DOMAIN — public hostname (required for OIDC callback) +# ANCHORAGE_AUTHENTIK_URL — Authentik issuer URL (required) +# POSTGRES_REPLICAS — Number of PG read replicas (default: 0) +# ANCHORAGE_REPLICAS — Number of anchorage+Kubo pairs (default: 3) + +x-anchorage-env: &anchorage-env + ANCHORAGE_POSTGRES_DSN: "postgres://anchorage@postgres:5432/anchorage?sslmode=disable&password_file=/run/secrets/postgres_password" + ANCHORAGE_POSTGRES_AUTOMIGRATE: "false" # migrate runs as a one-shot service + ANCHORAGE_POSTGRES_MAXCONNS: "20" + ANCHORAGE_AUTH_AUTHENTIK_ISSUER: ${ANCHORAGE_AUTHENTIK_URL:?Set ANCHORAGE_AUTHENTIK_URL} + ANCHORAGE_AUTH_AUTHENTIK_CLIENTID: "anchorage-web" + ANCHORAGE_AUTH_AUTHENTIK_AUDIENCE: "anchorage" + ANCHORAGE_AUTH_APITOKEN_SIGNINGKEYPATH: "/run/secrets/jwt_key" + ANCHORAGE_AUTH_APITOKEN_DEFAULTTTL: "24h" + ANCHORAGE_AUTH_APITOKEN_MAXTTL: "720h" + ANCHORAGE_LOGGING_LEVEL: "info" + ANCHORAGE_LOGGING_FORMAT: "json" + ANCHORAGE_LOGGING_FILE: "" # stderr only in containers + +x-anchorage-deploy: &anchorage-deploy + replicas: 1 + placement: + constraints: + - node.labels.anchorage.anchor == true + restart_policy: + condition: any + delay: 5s + max_attempts: 0 + resources: + limits: + cpus: "2" + memory: 2G + reservations: + cpus: "0.5" + memory: 512M + +x-healthcheck-anchorage: &anchorage-healthcheck + # Liveness only — hits /v1/health via busybox wget (Alpine ships it). + # The drain-aware /v1/ready endpoint is probed separately by the LB. + test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:8080/v1/health"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 15s + +services: + # =========================================================================== + # Postgres — source of truth for pins, placements, orgs, tokens, audit + # =========================================================================== + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: anchorage + POSTGRES_USER: anchorage + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + POSTGRES_INITDB_ARGS: "--data-checksums" + volumes: + - postgres_data:/var/lib/postgresql/data + secrets: + - postgres_password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U anchorage -d anchorage"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 10s + command: > + postgres + -c shared_buffers=1GB + -c effective_cache_size=3GB + -c work_mem=32MB + -c maintenance_work_mem=256MB + -c max_connections=200 + -c wal_level=replica + -c max_wal_senders=5 + -c max_replication_slots=5 + -c max_wal_size=2GB + -c checkpoint_completion_target=0.9 + -c random_page_cost=1.1 + -c effective_io_concurrency=200 + -c log_min_duration_statement=500 + -c log_checkpoints=on + deploy: + replicas: 1 + placement: + constraints: + - node.labels.anchorage.db == true + restart_policy: + condition: any + delay: 5s + max_attempts: 0 + resources: + limits: + cpus: "2" + memory: 4G + reservations: + cpus: "1" + memory: 2G + networks: + - anchorage + + # =========================================================================== + # Postgres read replica(s) — opt-in via POSTGRES_REPLICAS + # =========================================================================== + postgres-replica: + image: postgres:17-alpine + environment: + PGUSER: anchorage + PGPASSWORD_FILE: /run/secrets/postgres_password + volumes: + - postgres_replica_data:/var/lib/postgresql/data + secrets: + - postgres_password + healthcheck: + test: ["CMD-SHELL", "pg_isready -U anchorage"] + interval: 5s + timeout: 5s + retries: 5 + start_period: 30s + entrypoint: | + bash -c ' + export PGPASSWORD=$$(cat /run/secrets/postgres_password) + if [ ! -s /var/lib/postgresql/data/PG_VERSION ]; then + until pg_basebackup -h postgres -U anchorage -D /var/lib/postgresql/data -Fp -Xs -P -R; do + echo "Waiting for primary..." && sleep 2 + done + fi + exec postgres -c hot_standby=on + ' + deploy: + replicas: ${POSTGRES_REPLICAS:-0} + placement: + constraints: + - node.labels.anchorage.db == true + preferences: + - spread: node.id + restart_policy: + condition: any + delay: 5s + max_attempts: 0 + networks: + - anchorage + + # =========================================================================== + # anchorage-migrate — one-shot: applies schema, then exits + # =========================================================================== + anchorage-migrate: + image: ${ANCHORAGE_IMAGE:-git.anomalous.dev/alphacentri/anchorage:latest} + environment: + ANCHORAGE_POSTGRES_DSN: "postgres://anchorage@postgres:5432/anchorage?sslmode=disable&password_file=/run/secrets/postgres_password" + secrets: + - postgres_password + command: ["migrate", "up"] + deploy: + replicas: 1 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 10 + window: 120s + # Migrate job should not be restarted after it exits 0; swarm's + # only way to model this is a high max_attempts + window so a + # completed job exits and swarm stops re-launching it. + networks: + - anchorage + + # =========================================================================== + # Kubo daemons — one per anchorage, paired 1:1 + # =========================================================================== + kubo-1: &kubo-base + image: ipfs/kubo:latest + environment: + IPFS_PROFILE: "server" + volumes: + - kubo_1_data:/data/ipfs + ports: + - target: 4001 + published: 4001 + mode: host + protocol: tcp + deploy: + replicas: 1 + placement: + constraints: + - node.hostname == {{ index .Node.Labels "anchorage.anchor-id" }} + - node.labels.anchorage.anchor-id == anchor-1 + restart_policy: + condition: any + delay: 5s + networks: + - anchorage + + kubo-2: + <<: *kubo-base + volumes: + - kubo_2_data:/data/ipfs + ports: + - target: 4001 + published: 4011 + mode: host + protocol: tcp + deploy: + replicas: 1 + placement: + constraints: + - node.labels.anchorage.anchor-id == anchor-2 + restart_policy: + condition: any + delay: 5s + + kubo-3: + <<: *kubo-base + volumes: + - kubo_3_data:/data/ipfs + ports: + - target: 4001 + published: 4021 + mode: host + protocol: tcp + deploy: + replicas: 1 + placement: + constraints: + - node.labels.anchorage.anchor-id == anchor-3 + restart_policy: + condition: any + delay: 5s + + # =========================================================================== + # anchorage daemons — paired with kubos above + # =========================================================================== + anchorage-1: &anchorage-base + image: ${ANCHORAGE_IMAGE:-git.anomalous.dev/alphacentri/anchorage:latest} + environment: + <<: *anchorage-env + ANCHORAGE_NODE_ID: nod_anchor_1 + ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage + ANCHORAGE_IPFS_RPC: http://kubo-1:5001 + ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats + ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-2:6222,nats://anchorage-3:6222" + ANCHORAGE_CLUSTER_MINREPLICAS: "2" + ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4001/p2p/PLACEHOLDER" + volumes: + - anchorage_1_data:/var/lib/anchorage + secrets: + - postgres_password + - source: jwt_key + target: jwt_key + mode: 0400 + depends_on: + - postgres + - kubo-1 + - anchorage-migrate + healthcheck: *anchorage-healthcheck + deploy: + <<: *anchorage-deploy + placement: + constraints: + - node.labels.anchorage.anchor-id == anchor-1 + networks: + - anchorage + + anchorage-2: + <<: *anchorage-base + environment: + <<: *anchorage-env + ANCHORAGE_NODE_ID: nod_anchor_2 + ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage + ANCHORAGE_IPFS_RPC: http://kubo-2:5001 + ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats + ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-1:6222,nats://anchorage-3:6222" + ANCHORAGE_CLUSTER_MINREPLICAS: "2" + ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4011/p2p/PLACEHOLDER" + volumes: + - anchorage_2_data:/var/lib/anchorage + depends_on: + - postgres + - kubo-2 + - anchorage-migrate + deploy: + <<: *anchorage-deploy + placement: + constraints: + - node.labels.anchorage.anchor-id == anchor-2 + + anchorage-3: + <<: *anchorage-base + environment: + <<: *anchorage-env + ANCHORAGE_NODE_ID: nod_anchor_3 + ANCHORAGE_NODE_STATEDIR: /var/lib/anchorage + ANCHORAGE_IPFS_RPC: http://kubo-3:5001 + ANCHORAGE_NATS_DATADIR: /var/lib/anchorage/nats + ANCHORAGE_NATS_CLUSTER_ROUTES: "nats://anchorage-1:6222,nats://anchorage-2:6222" + ANCHORAGE_CLUSTER_MINREPLICAS: "2" + ANCHORAGE_NODE_MULTIADDRS: "/dns4/${ANCHORAGE_DOMAIN:?Set ANCHORAGE_DOMAIN}/tcp/4021/p2p/PLACEHOLDER" + volumes: + - anchorage_3_data:/var/lib/anchorage + depends_on: + - postgres + - kubo-3 + - anchorage-migrate + deploy: + <<: *anchorage-deploy + placement: + constraints: + - node.labels.anchorage.anchor-id == anchor-3 + + # =========================================================================== + # LB — nginx fronts HTTP and upgrades /v1/events to WebSocket + # =========================================================================== + lb: + image: nginx:1.27-alpine + depends_on: + - anchorage-1 + - anchorage-2 + - anchorage-3 + ports: + - target: 8080 + published: 8080 + mode: ingress + configs: + - source: nginx_conf + target: /etc/nginx/conf.d/default.conf + deploy: + replicas: 1 + restart_policy: + condition: any + delay: 5s + resources: + limits: + cpus: "0.5" + memory: 128M + reservations: + cpus: "0.1" + memory: 32M + networks: + - anchorage + +# ============================================================================= +secrets: + postgres_password: + external: true + name: anchorage_postgres_password + jwt_key: + external: true + name: anchorage_jwt_key + +configs: + nginx_conf: + file: ./nginx.conf + +volumes: + postgres_data: + driver: local + postgres_replica_data: + driver: local + kubo_1_data: + driver: local + kubo_2_data: + driver: local + kubo_3_data: + driver: local + anchorage_1_data: + driver: local + anchorage_2_data: + driver: local + anchorage_3_data: + driver: local + +networks: + anchorage: + driver: overlay + attachable: true diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..7327229 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,94 @@ +# nginx LB config for the anchorage Swarm stack. +# +# Fronts three anchorage backends, round-robin by default. The upstream +# zone declaration makes it trivial to swap to least_conn or +# least_time once the cluster is busy enough to benefit. +# +# WebSocket upgrade is handled for /v1/events only, per the plan — +# ordinary GET /v1/... requests stay as HTTP/1.1 with proxy keepalive. + +upstream anchorage_backends { + # Least connections biases traffic toward the lightest-loaded node, + # which matters once pin create calls fan into the same placement set. + least_conn; + + server anchorage-1:8080 max_fails=3 fail_timeout=15s; + server anchorage-2:8080 max_fails=3 fail_timeout=15s; + server anchorage-3:8080 max_fails=3 fail_timeout=15s; + + keepalive 64; +} + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +# Hide nginx version from error pages and the Server header. +server_tokens off; + +log_format anchorage_json escape=json + '{' + '"time":"$time_iso8601",' + '"remote":"$remote_addr",' + '"request":"$request",' + '"status":$status,' + '"bytes":$body_bytes_sent,' + '"duration_ms":$request_time,' + '"upstream":"$upstream_addr",' + '"upstream_status":"$upstream_status",' + '"upstream_duration":"$upstream_response_time",' + '"request_id":"$http_x_request_id",' + '"user_agent":"$http_user_agent"' + '}'; + +server { + listen 8080; + listen [::]:8080; + + access_log /var/log/nginx/access.log anchorage_json; + + client_max_body_size 8m; + client_body_timeout 60s; + client_header_timeout 15s; + send_timeout 60s; + + # Forward the Fiber-assigned X-Request-ID through the LB so a single + # request ID appears in LB + anchorage logs. + proxy_set_header X-Request-ID $http_x_request_id; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # HTTP/1.1 + keepalive to the upstream pool. + proxy_http_version 1.1; + proxy_set_header Connection ""; + + # Root: normal HTTP traffic. + location / { + proxy_pass http://anchorage_backends; + proxy_connect_timeout 5s; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # WebSocket endpoint — no idle-timeout ceiling so long-lived status + # event streams stay open. The Upgrade/Connection headers are + # forwarded so the backend sees a proper WS upgrade. + location = /v1/events { + proxy_pass http://anchorage_backends; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + } + + # Fast-path probes at the LB itself so the k8s / swarm healthcheck + # doesn't have to round-trip to a backend. + location = /lbz { + access_log off; + return 200 "ok\n"; + add_header Content-Type text/plain; + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..c5524d5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,51 @@ +# anchorage architecture + +## One-paragraph summary + +anchorage is a horizontally-scalable IPFS Pinning Service. Each instance is paired 1:1 with its own Kubo daemon and runs an embedded NATS server that joins the cluster via gossip. Postgres is the single source of truth for pins, placements, refcounts, orgs, users, tokens, and audit log. NATS carries only signaling: per-node work queues (`pin.jobs.`), pin status fan-out (`pin.events..`), heartbeats, cache-invalidation pubsub, and a TTL-based leader-election KV key. + +## Layers + +| Layer | What it owns | +|---|---| +| **Kubo (per node)** | Physical pinset on that node's local IPFS repo. | +| **Postgres** | Logical state: pins, `pin_placements` (per-node), `pin_refcount` (per `(node,cid)`), orgs, users, memberships, tokens, denylist, nodes, audit. | +| **NATS** | Non-authoritative signaling. Everything in NATS is reconstructable from Postgres. | + +## Request lifecycle (POST /v1/pins) + +1. LB routes to some anchorage instance (not necessarily one that will hold a replica). +2. One Postgres transaction: + - Insert `pins` row (status=queued). + - Compute placements via rendezvous hash of `(orgID, cid, nodeID)` over live nodes. + - Insert `pin_placements` rows with `fence=1`. + - Increment `pin_refcount` per target `(node, cid)`. + - Write an `audit_log` row. +3. After commit, publish one `pin.jobs.` message per placement with `Nats-Msg-Id = ::` for JetStream dedup. +4. Target nodes pull from `pin.jobs.`, call Kubo, UPDATE the placement row `WHERE fence = $n`, publish `pin.events..`, then ack. +5. WebSocket clients subscribed to the org + optional requestid see status frames. + +## Key invariants + +- **Postgres is the commit point.** NATS publish is follow-on; a dropped publish is recovered by the Requeue Sweeper. +- **Fence tokens prevent zombie writes.** A zombie node's late ack UPDATE affects 0 rows because its fence was bumped during rebalance. +- **Publisher-side dedup absorbs retry storms.** JetStream's `Duplicates: 5m` plus `Nats-Msg-Id` means the same logical job cannot be processed twice within the window. +- **Per-(node, cid) refcounts** let two orgs pinning the same CID on the same node share one Kubo pin; unpin only fires when the refcount hits zero. +- **RLS is belt-and-suspenders.** Every tenant-scoped table enables Postgres row-level security keyed on the `anchorage.org_id` GUC so a Go-layer bug can't bleed rows. + +## Clustering patterns + +Adopted from the sibling kanrisha project: + +- **TTL-based leader election** via JetStream KV (`ANCHORAGE_LEADER` bucket, 5s TTL, `kv.Create` as CAS). +- **Fence tokens** on every dispatched unit of work. +- **Publisher dedup** via `Nats-Msg-Id` + stream `Duplicates: 5m`. +- **Write-ahead journaling** — Postgres is the atomic commit point; NATS is signal only. +- **Graceful shutdown order** — HTTP → consumers → NATS drain → pgxpool close. + +## Maintenance mode (see docs/cluster-ops.md) + +Two independent toggles: + +1. **Per-node drain** — `nodes.status = 'drained'`. Node stops pulling jobs, `/v1/ready` returns 503, rebalancer moves its placements off (with `reason=drain` audit rows). +2. **Cluster-wide pause** — `ANCHORAGE_CLUSTER.maintenance=true` in NATS KV. Rebalancer and Requeue Sweeper no-op; API keeps serving. Safety rail: `cluster.maintenance.maxDuration` (default 1h) warns loudly on forgotten flags. diff --git a/docs/auth-flow.md b/docs/auth-flow.md new file mode 100644 index 0000000..2271bbf --- /dev/null +++ b/docs/auth-flow.md @@ -0,0 +1,27 @@ +# Authentication + +> **Setting up Authentik?** See [authentik-setup.md](authentik-setup.md) +> for the step-by-step provider + application + property-mapping walkthrough +> and the matching anchorage.yaml snippet. + +## Actors + +- **Browser users** — authenticate once via Authentik (OIDC, Authorization Code + PKCE). anchorage validates Authentik's ID token against its JWKS, upserts the user, issues a short-lived session cookie. +- **Programmatic clients** (kubo's `ipfs pin remote`, CI pipelines, scripts) — present `Authorization: Bearer ` where the JWT was minted by anchorage itself. + +## Token lifecycle + +1. Signed-in user calls `POST /v1/tokens` with a label + scopes. +2. anchorage signs a JWT: `{sub, org, role, scopes, jti, iat, exp}`. The `jti` is a TypeID (`tok_...`). Only the *metadata* (label, scopes, expires_at) is persisted; the signed string is returned to the user **once**. +3. Client presents the JWT. Middleware validates signature + expiry + denylist (`token_denylist` keyed by `jti`). +4. Revocation writes the `jti` to `token_denylist` with its natural expiry; the row is pruned hourly. + +## Why JWT + denylist rather than opaque DB tokens + +- Denylist is *only* consulted for revoked tokens — a warm `token/deny` ristretto cache means the hot path is zero DB reads. +- Claims travel with the request — RLS can set `anchorage.org_id` per-transaction from the JWT without another lookup. +- Short TTLs (default 24h) plus revocation cover the usual "compromised token" threat model without requiring per-request DB hits. + +## Sysadmin bootstrap + +`bootstrap.sysadmins: [admin@example.com, ...]` in config. First Authentik login for any listed email promotes the user to `is_sysadmin=true`. The config is only consulted on first login; later changes need the admin CLI. diff --git a/docs/authentik-setup.md b/docs/authentik-setup.md new file mode 100644 index 0000000..74ec2a1 --- /dev/null +++ b/docs/authentik-setup.md @@ -0,0 +1,292 @@ +# Setting up Authentik OIDC for anchorage + +anchorage delegates human authentication to Authentik (or any OIDC +provider). The web UI runs an **Authorization Code + PKCE** flow against +Authentik; anchorage validates the returned ID token against Authentik's +JWKS and upserts the user on first login. API clients (`ipfs pin remote`, +CI) use per-device JWTs minted by anchorage itself — see +[auth-flow.md](auth-flow.md) for that side of the story. + +This document walks through the Authentik side, end-to-end, and the +matching anchorage config. + +> Tested against Authentik 2024.12+; paths should be the same on any +> recent 2024.x / 2025.x release. + +--- + +## Prerequisites + +- A running Authentik instance with admin access. +- A public hostname for anchorage (e.g. `https://anchor.example.com`) + with valid TLS — OIDC requires HTTPS for non-localhost redirect URIs. +- `anchorage` installed and reachable on that hostname (see + [../deploy/README.md](../deploy/README.md)). + +--- + +## 1. Create the OIDC provider + +**Applications → Providers → Create → OAuth2/OpenID Provider**. + +Under **Protocol settings**: + +| Field | Value | Notes | +|---|---|---| +| Name | `anchorage-oidc` | internal label | +| Authentication flow | `default-authentication-flow` | or your org's SSO flow | +| Authorization flow | `default-provider-authorization-implicit-consent` | explicit consent also works | +| Client type | **Public** | anchorage's web UI is a browser app; no client secret | +| Client ID | `anchorage-web` | must match `auth.authentik.clientID` in anchorage.yaml | +| Client Secret | *(leave empty)* | public client + PKCE | +| Redirect URIs / Origins | `https://anchor.example.com/` (one per line; add `http://localhost:5173/` etc. for dev) | exact match — including trailing `/` | +| Signing Key | `authentik Self-signed Certificate` (default) or your managed cert | RS256 | +| Subject mode | *Based on the User's hashed ID* | stable across email changes | +| Include claims in id_token | ✅ enabled | needed so anchorage can read email/groups without a separate /userinfo call | +| Issuer mode | **Each provider has a different issuer** | produces `https://auth/…/o//` — what anchorage expects | + +Under **Scopes**, grant: + +- `openid` (mandatory) +- `email` +- `profile` +- `groups` — only if you add the property mapping in §4 below + +Save. On the provider's detail page note the **OpenID Configuration +Issuer** URL — it will look like: + +``` +https://auth.example.com/application/o/anchorage/ +``` + +That exact URL (including the trailing slash) goes into +`auth.authentik.issuer` in anchorage.yaml. + +--- + +## 2. Create the Application + +**Applications → Applications → Create**: + +| Field | Value | +|---|---| +| Name | `anchorage` | +| Slug | `anchorage` — **must match the `/o//` in the issuer URL above** | +| Provider | `anchorage-oidc` (the one from §1) | +| Launch URL | `https://anchor.example.com/` | +| Icon / Group | optional, cosmetic | + +The slug is load-bearing: changing it later changes the issuer URL and +invalidates every outstanding anchorage session. + +--- + +## 3. Create groups and bind them to the application + +anchorage uses two roles that are checked against Authentik group +membership (if you wire up §4) or against the bootstrap list in config. + +**Directory → Groups → Create**: + +- `anchorage-users` — ordinary members; can CRUD pins within their org +- `anchorage-sysadmins` — platform-wide admin rights (drain, maintenance, + cross-org audit) + +Add users to the appropriate groups. Then bind the groups to the +application: + +**Applications → Applications → anchorage → Policy / Group / User +Bindings → Create Binding**: + +- Bind `anchorage-users` with **Enabled = true**, **Negate = false** +- Bind `anchorage-sysadmins` with **Enabled = true**, **Negate = false** + +Unbound Authentik users are now rejected at the Authentik layer before +the ID token is ever minted — a defense-in-depth layer for anchorage's +own authz. + +--- + +## 4. (Optional) Property mapping for group-based roles + +If you want anchorage to decide sysadmin-ness from Authentik groups +rather than the static `bootstrap.sysadmins` list, add a scope mapping +that surfaces groups in the ID token. + +**Customisation → Property Mappings → Create → Scope Mapping**: + +| Field | Value | +|---|---| +| Name | `anchorage-groups` | +| Scope name | `groups` | +| Description | `Groups claim for anchorage` | +| Expression | *(see below)* | + +```python +return { + "groups": [g.name for g in request.user.ak_groups.all()], +} +``` + +Attach this scope mapping to the provider under **Scopes**. Verify it +works by decoding an issued ID token at and confirming +the `groups` claim contains `anchorage-sysadmins` for your admin user. + +--- + +## 5. Configure anchorage + +Open `/etc/anchorage/anchorage.yaml` and set the `auth` and +`bootstrap` sections: + +```yaml +auth: + authentik: + # Exactly as shown on the Provider's detail page, trailing slash included. + issuer: https://auth.example.com/application/o/anchorage/ + # Must equal the Client ID configured in Authentik. + clientID: anchorage-web + # Authentik puts the client ID in the `aud` claim by default. + # If you override audience via a property mapping, match it here. + audience: anchorage-web + apiToken: + signingKeys: + - id: "2026-04" + path: /etc/anchorage/jwt.key + primary: true + defaultTTL: 24h # web-UI sessions + maxTTL: 9480h # 1y + 30-day grace for IPFS client tokens + +bootstrap: + # First Authentik login for one of these emails → user is promoted + # to sysadmin. Consulted ONLY on first login; later changes need + # `anchorage admin grant-sysadmin`. + sysadmins: + - admin@example.com +``` + +Restart anchorage: + +```bash +systemctl restart anchorage # package install +# or: +docker service update --force anchorage_anchorage-1 # Swarm +``` + +--- + +## 6. Verify the flow end-to-end + +### 6a. Reachability — Authentik side + +```bash +# Discovery document resolves and the issuer matches. +curl -fsS https://auth.example.com/application/o/anchorage/.well-known/openid-configuration \ + | jq '{issuer, jwks_uri, authorization_endpoint, token_endpoint}' + +# JWKS is reachable and non-empty. +curl -fsS https://auth.example.com/application/o/anchorage/jwks/ | jq '.keys | length' +# → 1 (or more) +``` + +If either call fails, anchorage can't verify ID tokens regardless of the +rest. Fix network reachability / TLS / DNS first. + +### 6b. Browser flow — anchorage side + +1. Open `https://anchor.example.com/` in a browser. +2. Click **Sign in** → should redirect to + `https://auth.example.com/application/o/authorize/?client_id=anchorage-web&…`. +3. Log in at Authentik. +4. You are redirected back to `https://anchor.example.com/?code=…&state=…`. +5. The web UI POSTs the code to `/v1/auth/session`; anchorage exchanges + it with Authentik, validates the ID token, upserts the user, and + returns a session cookie. + +Check the Postgres row: + +```bash +psql -c "SELECT id, email, authentik_sub, is_sysadmin, created_at FROM users ORDER BY created_at DESC LIMIT 5;" +``` + +You should see your user with `authentik_sub` = Authentik's hashed user +ID and `is_sysadmin=true` if your email was in `bootstrap.sysadmins`. + +### 6c. Mint an API token through the UI + +Once signed in, exercise the full token lifecycle: + +``` +POST /v1/tokens (with the session cookie) + body: { "label": "laptop-cli", "scopes": ["pin:write"], "ttl_hours": 720 } +``` + +The returned JWT is what goes into `ipfs pin remote service add`. + +--- + +## Troubleshooting + +### "Invalid redirect URI" +Authentik rejects the authorize request because the `redirect_uri` +query parameter doesn't match any URL in the provider's list. +- Compare byte-for-byte, including trailing `/`. +- Check protocol (`https` vs `http`). +- For local dev, add `http://localhost:/` to the provider's list. + +### "Signature validation failed" +anchorage fetched the JWKS but couldn't verify the ID token. +- Kubernetes NetworkPolicy or egress firewall blocking Authentik? + Curl the JWKS endpoint from inside the anchorage pod/container. +- Was the signing key rotated in Authentik? anchorage caches JWKS for + 1 hour; restart anchorage or wait for the cache to refresh. + +### "iss claim invalid" +anchorage's configured issuer doesn't match the `iss` in the ID token. +- Confirm the provider's **Issuer mode** is "Each provider has a + different issuer" (not the default "global issuer"). The former + produces per-application issuers like `…/o/anchorage/`; the latter + produces a bare `…/` which won't match. + +### "aud claim not accepted" +- Authentik's default `aud` is the Client ID. Set + `auth.authentik.audience` in anchorage.yaml to the same value + (typically `anchorage-web`). +- If you configured a custom audience via a property mapping, set + anchorage to match. + +### Clock skew +JWT validation rejects tokens whose `iat` is in the future or whose +`exp` has passed. Authentik and anchorage must agree on wall-clock time +within ~60s. +- Run NTP / chronyd on both hosts. + +### First login not promoted to sysadmin +`bootstrap.sysadmins` matches on the `email` claim, exact + case- +sensitive. +- Check the email Authentik is actually sending: decode the ID token + payload and look at the `email` field. +- `bootstrap.sysadmins` is only read on the *first* login for a given + `authentik_sub`. For an existing user, use + `anchorage admin grant-sysadmin ` instead. + +### Users can reach Authentik's login page but get "unauthorized" afterwards +The user isn't a member of a group bound to the anchorage application. +Add them to `anchorage-users` (or the equivalent) and retry. + +--- + +## Rotating the Authentik signing certificate + +When Authentik rotates its signing cert, outstanding ID tokens keep +working until they expire (anchorage trusts JWKS, which lists both old +and new keys during overlap). Then: + +1. Confirm the new `kid` is in the JWKS document. +2. anchorage's in-process JWKS cache refreshes every hour; to force a + refresh immediately restart the process. + +API tokens minted by anchorage are signed with anchorage's own keys +(`auth.apiToken.signingKeys`) and are unaffected by Authentik cert +rotation — they only care about the Authentik issuer/audience validity +at mint time. diff --git a/docs/cluster-ops.md b/docs/cluster-ops.md new file mode 100644 index 0000000..402e805 --- /dev/null +++ b/docs/cluster-ops.md @@ -0,0 +1,52 @@ +# Cluster operations + +## Drain a single node (hardware replacement, etc.) + +```bash +anchorage admin drain nod_01h7rfxv... +# verify +curl -sf http://node-under-drain:8080/v1/ready # now 503 +``` + +The drained node: +- Returns 503 from `/v1/ready` so the LB stops routing new traffic. +- Cancels its `pin.jobs.` consumer after `cluster.drainGracePeriod` (default 2m) once in-flight jobs finish. +- Keeps publishing `node.heartbeat.` so peers know it is intentionally out of rotation. +- Gets its placements moved onto healthy nodes by the rebalancer with `reason=drain` in audit. + +When work is done, restore: + +```bash +anchorage admin uncordon nod_01h7rfxv... +``` + +Existing pins already migrated stay put; the uncordoned node re-acquires load gradually as new pins land. + +## Cluster-wide maintenance (rolling upgrade) + +Before bouncing every anchorage in turn: + +```bash +anchorage admin maintenance on --reason "upgrade to v0.3.1" --ttl 30m +``` + +While this is set in the `ANCHORAGE_CLUSTER` NATS KV bucket: +- Rebalancer no-ops (so nodes briefly going `down` during restart don't trigger replacement placements). +- Requeue sweeper no-ops (same reason). +- API continues serving on live nodes; new pins are accepted and placed on the live set. +- `/v1/ready` keeps returning 200 on live nodes. + +After the fleet has finished bouncing: + +```bash +anchorage admin maintenance off +``` + +The safety rail `cluster.maintenance.maxDuration` (default 1h) means a forgotten flag logs loud warnings, so incidents aren't silently masked. + +## Inspecting state + +```bash +anchorage admin maintenance status +anchorage migrate status +``` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b8f5ec --- /dev/null +++ b/go.mod @@ -0,0 +1,124 @@ +module anchorage + +go 1.26.2 + +require ( + github.com/danielgtaylor/huma/v2 v2.37.3 + github.com/dgraph-io/ristretto/v2 v2.4.0 + github.com/gofiber/contrib/websocket v1.3.4 + github.com/gofiber/fiber/v2 v2.52.12 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/jackc/pgx/v5 v5.9.1 + github.com/nats-io/nats-server/v2 v2.12.7 + github.com/nats-io/nats.go v1.51.0 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/zeebo/xxh3 v1.1.0 + go.jetify.com/typeid v1.3.0 + gopkg.in/natefinch/lumberjack.v2 v2.2.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/MicahParks/jwkset v0.11.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/fasthttp/websocket v1.5.8 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofrs/uuid/v5 v5.2.0 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.8.1 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.69.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/time v0.15.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3c365cc --- /dev/null +++ b/go.sum @@ -0,0 +1,298 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= +github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0mDD1gk7o9BhI16b9p5yYAXRlidpqJE= +github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danielgtaylor/huma/v2 v2.37.3 h1:6Av0Vj45Vk5lDxRVfoO2iPlEdvCvwLc7pl5nbqGOkYM= +github.com/danielgtaylor/huma/v2 v2.37.3/go.mod h1:OeHHtCEAaNiuVbAVdYu4IQ0UOmnb4x3yMUOShNlZ53g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU= +github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= +github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= +github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxdz7E85/STdkmZPEmPs= +github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= +github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofrs/uuid/v5 v5.2.0 h1:qw1GMx6/y8vhVsx626ImfKMuS5CvJmhIKKtuyvfajMM= +github.com/gofrs/uuid/v5 v5.2.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= +github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg= +github.com/nats-io/nats-server/v2 v2.12.7 h1:prQ9cPiWHcnwfT81Wi5lU9LL8TLY+7pxDru6fQYLCQQ= +github.com/nats-io/nats-server/v2 v2.12.7/go.mod h1:dOnmkprKMluTmTF7/QHZioxlau3sKHUM/LBPy9AiBPw= +github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI= +github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= +github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI= +github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik= +github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.jetify.com/typeid v1.3.0 h1:fuWV7oxO4mSsgpxwhaVpFXgt0IfjogR29p+XAjDCVKY= +go.jetify.com/typeid v1.3.0/go.mod h1:CtVGyt2+TSp4Rq5+ARLvGsJqdNypKBAC6INQ9TLPlmk= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/anchorage/app.go b/internal/app/anchorage/app.go new file mode 100644 index 0000000..de9b3f8 --- /dev/null +++ b/internal/app/anchorage/app.go @@ -0,0 +1,561 @@ +// Package anchorage is the composition root. +// +// App owns the dependency graph: store, NATS server, node runner, +// leader elector, maintenance manager, pin service, HTTP server, and +// the scheduler workers. Run wires them together and blocks until ctx +// is cancelled; Close drains in reverse order and waits for every +// spawned goroutine to return. +package anchorage + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/cache" + "anchorage/internal/pkg/config" + "anchorage/internal/pkg/httpserver" + "anchorage/internal/pkg/ipfs" + "anchorage/internal/pkg/ipfs/rpc" + "anchorage/internal/pkg/leader" + "anchorage/internal/pkg/logging" + "anchorage/internal/pkg/metrics" + "anchorage/internal/pkg/maintenance" + embeddednats "anchorage/internal/pkg/nats" + "anchorage/internal/pkg/store/postgres" + "anchorage/internal/pkg/node" + "anchorage/internal/pkg/openapi" + "anchorage/internal/pkg/pin" + "anchorage/internal/pkg/scheduler" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/store/memstore" + "anchorage/internal/pkg/token" + "anchorage/internal/pkg/wsserver" +) + +// App is the composed process. +type App struct { + Cfg *config.Config + Store store.Store + PGPool *pgxpool.Pool // nil when running against memstore + NATS *embeddednats.Server + Backend ipfs.Backend + Node *node.Runner + Elector *leader.Elector + Pins *pin.Service + LiveNodes *pin.CachedLiveNodes + Maint *maintenance.Manager + Signer *token.Signer + HTTP *httpserver.Server + Hub *wsserver.Hub + Consumer *scheduler.Consumer + Reconcile *scheduler.Reconciler + Rebalance *scheduler.Rebalancer + Sweeper *scheduler.RequeueSweeper + + // leader-gated workers run under leaderCtx which is (re)created on + // every OnPromote and cancelled on every OnDemote. leaderWG waits + // for those goroutines during Close. + leaderMu sync.Mutex + leaderCtx context.Context + leaderCancel context.CancelFunc + leaderWG sync.WaitGroup + + // lifecycleWG waits for every Run-spawned goroutine (HTTP, Node, + // Elector, Consumer, Reconciler) so Close blocks until all of them + // have returned. The leader-gated workers are tracked separately + // via leaderWG. + lifecycleWG sync.WaitGroup + +} + +// New builds the full app from a config.Config. +// +// The in-memory store is used for v1 dev; the pgx-backed store from +// internal/pkg/store/postgres slots in by swapping this single line +// once sqlc generates the queries (every service takes a store.Store +// interface, not a concrete type). +func New(ctx context.Context, cfg *config.Config) (*App, error) { + if err := cfg.RequireServe(); err != nil { + return nil, fmt.Errorf("config: %w", err) + } + if err := logging.Init(logging.Config{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + Source: cfg.Logging.Source, + File: cfg.Logging.File, + MaxSizeMB: cfg.Logging.MaxSizeMB, + MaxBackups: cfg.Logging.MaxBackups, + MaxAgeDays: cfg.Logging.MaxAgeDays, + Compress: cfg.Logging.Compress, + }); err != nil { + return nil, fmt.Errorf("logging: %w", err) + } + + // Store: Postgres when a DSN is configured, else memstore. + // A missing DSN is a valid dev-mode choice; production operators + // must set one. + var ( + s store.Store + pgPool *pgxpool.Pool + ) + if cfg.Postgres.DSN != "" { + pool, err := postgres.NewPool(ctx, postgres.PoolConfig{ + DSN: cfg.Postgres.DSN, + MaxConns: cfg.Postgres.MaxConns, + }) + if err != nil { + return nil, fmt.Errorf("postgres pool: %w", err) + } + if cfg.Postgres.AutoMigrate { + if err := postgres.Migrate(ctx, pool, postgres.MigrateUp, postgres.MigrateOptions{}); err != nil { + pool.Close() + return nil, fmt.Errorf("postgres migrate: %w", err) + } + } + s = postgres.New(pool) + pgPool = pool + slog.Info("anchorage: using Postgres-backed store") + } else { + s = memstore.New() + slog.Warn("anchorage: postgres.dsn not set — using in-memory store. NOT durable; do NOT use in production.") + } + + // Node identity — stable across restarts via state dir. + nodeID, displayName, err := resolveStableNodeID(cfg.Node.ID, cfg.Node.StateDir) + if err != nil { + return nil, fmt.Errorf("node id: %w", err) + } + + // Signing keys — rotation-capable by default. An empty + // SigningKeys list falls back to a built-in dev key with a loud + // warning; see deploy/README.md "Rotating the JWT signing key" + // for production configuration. + rotation := make([]token.KeyFileSpec, 0, len(cfg.Auth.APIToken.SigningKeys)) + for _, k := range cfg.Auth.APIToken.SigningKeys { + rotation = append(rotation, token.KeyFileSpec{ + Path: k.Path, ID: k.ID, Primary: k.Primary, + }) + } + signingKeys, err := token.LoadSigningKeys(rotation) + if err != nil { + return nil, fmt.Errorf("signing keys: %w", err) + } + + // Embedded NATS + JetStream. + ns, err := embeddednats.Start(ctx, embeddednats.ServerConfig{ + ServerName: nodeID.String(), + DataDir: cfg.NATS.DataDir, + ClientHost: cfg.NATS.Client.Host, + ClientPort: cfg.NATS.Client.Port, + ClusterName: cfg.NATS.Cluster.Name, + ClusterHost: cfg.NATS.Cluster.Host, + ClusterPort: cfg.NATS.Cluster.Port, + Routes: cfg.NATS.Cluster.Routes, + JSReplicas: cfg.NATS.JetStream.Replicas, + }) + if err != nil { + return nil, fmt.Errorf("nats: %w", err) + } + + if err := scheduler.EnsureStreams(ctx, ns.JS, cfg.NATS.JetStream.Replicas); err != nil { + ns.Close() + return nil, fmt.Errorf("ensure streams: %w", err) + } + + backend, err := rpc.New(rpc.Options{Endpoint: cfg.IPFS.RPC, Timeout: cfg.IPFS.Timeout}) + if err != nil { + ns.Close() + return nil, fmt.Errorf("ipfs rpc: %w", err) + } + + // Auto-populate node.multiaddrs from Kubo's /api/v0/id when the + // operator hasn't set them in config. These addresses become the + // PinStatus.delegates values returned to pinning clients, so getting + // them right matters. + // + // Soft failure: if Kubo is unreachable during startup we log and + // proceed with whatever the operator configured (possibly nothing, + // which is fine for a node that expects clients to hit the LB, + // not the Kubo swarm port directly). + multiaddrs := cfg.Node.Multiaddrs + if len(multiaddrs) == 0 { + idCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + info, ierr := backend.ID(idCtx) + cancel() + switch { + case ierr != nil: + slog.Warn("anchorage: could not query Kubo /api/v0/id for multiaddrs — PinStatus.delegates may be empty", + "err", ierr, "rpc", cfg.IPFS.RPC) + case len(info.Addresses) == 0: + slog.Warn("anchorage: Kubo reported no multiaddrs — daemon may not be listening on libp2p", "peer_id", info.PeerID) + default: + multiaddrs = info.Addresses + slog.Info("anchorage: auto-populated node.multiaddrs from Kubo", + "peer_id", info.PeerID, "count", len(multiaddrs)) + } + } + + nodeRunner, err := node.NewRunner(ns.NC, s, node.Options{ + NodeID: nodeID, + DisplayName: displayName, + Multiaddrs: multiaddrs, + RPCURL: cfg.IPFS.RPC, + HeartbeatInterval: cfg.Cluster.HeartbeatInterval, + DownAfter: cfg.Cluster.DownAfter, + }) + if err != nil { + ns.Close() + return nil, fmt.Errorf("node runner: %w", err) + } + if err := nodeRunner.Register(ctx); err != nil { + ns.Close() + return nil, fmt.Errorf("node register: %w", err) + } + + maint, err := maintenance.NewManager(ctx, ns.JS) + if err != nil { + ns.Close() + return nil, fmt.Errorf("maintenance: %w", err) + } + + // Cached live-node source for the pin service. + liveCache := pin.NewCachedLiveNodes(s, 5*time.Second) + + pinSvc, err := pin.NewService(s, pin.Options{ + MinReplicas: cfg.Cluster.MinReplicas, + NC: ns.NC, + LiveNodes: liveCache, + }) + if err != nil { + ns.Close() + return nil, fmt.Errorf("pin service: %w", err) + } + + signer, err := token.NewSigner(signingKeys, cfg.Auth.Authentik.Issuer, cfg.Auth.Authentik.Audience, s.Tokens()) + if err != nil { + ns.Close() + return nil, fmt.Errorf("token signer: %w", err) + } + + // Denylist cache — every authenticated request calls IsDenied, so + // the naive path is one DB round-trip per request. A short-TTL + // negative cache cuts that to ~zero for the 99.99% of tokens that + // aren't revoked. Cross-node invalidation: a revoke on any node + // publishes cache.invalidate.token.; every peer subscribes + // and drops the matching entry. + denyCache, err := cache.New[string, bool](cache.Options{ + Name: "token/deny", + MaxCost: 4 << 20, // ~100k entries + DefaultTTL: 5 * time.Minute, + }) + if err != nil { + ns.Close() + return nil, fmt.Errorf("token deny cache: %w", err) + } + signer.SetDenyCache(denyCache) + + // Remote invalidations drop the local cache entry. + if _, err := cache.WatchEntity(ctx, ns.NC, "token", signer.InvalidateDenyCache); err != nil { + ns.Close() + return nil, fmt.Errorf("token deny cache subscribe: %w", err) + } + + // Local Revoke → publish the NATS invalidation so peers match. + tokenInvalidator := cache.NewInvalidator(ns.NC) + signer.SetRevokeHook(func(jti string) { + if err := tokenInvalidator.Emit("token", jti); err != nil { + slog.Warn("token: failed to emit cache invalidation", "jti", jti, "err", err) + } + }) + + // Bridge heartbeat consumer → live-nodes cache invalidation so + // drain / uncordon / new-node-joining is reflected in placement + // within a single heartbeat round-trip instead of waiting for TTL. + if err := wireCacheInvalidation(ns, liveCache); err != nil { + ns.Close() + return nil, fmt.Errorf("cache invalidation wire-up: %w", err) + } + + http := httpserver.New(httpserver.Options{ + Host: cfg.Server.Host, + Port: cfg.Server.Port, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + MetricsACLCIDRs: cfg.Server.Metrics.AllowCIDRs, + }) + http.App.Use(auth.BearerMiddleware(signer)) + // Rate limiters — order matters: session limiter runs FIRST so a + // burst of /v1/auth/session attempts is rejected before the + // generic anonymous limiter would have a chance to throttle a + // legitimate user. Both no-op on paths they don't match. + http.App.Use(httpserver.SessionLimiter(cfg.Server.RateLimit.SessionPerMinute)) + http.App.Use(httpserver.AnonymousLimiter(cfg.Server.RateLimit.AnonymousPerMinute)) + + // The Reconciler is built further down, but the /readyz closure + // reads its DriftCount — forward-declare so the closure captures a + // pointer that becomes valid by first request. + var rec *scheduler.Reconciler + + api := openapi.New(http.App) + openapi.RegisterHealth(api, func(ctx context.Context) openapi.ReadyState { + st := openapi.ReadyState{Ready: true} + if n, err := s.Nodes().Get(ctx, nodeID); err == nil && n.Status == store.NodeStatusDrained { + st.Ready = false + st.Reason = "drained" + } + // Drift is informational — surfaced in the body so operators + // can alert on it without scraping /metrics. Zero on a healthy + // node. + if rec != nil { + st.Drift = rec.DriftCount.Load() + } + return st + }) + openapi.RegisterPins(api, pinSvc, s) + openapi.RegisterOrgs(api, s) + openapi.RegisterTokens(api, openapi.TokenDeps{ + Store: s, + Signer: signer, + DefaultTTL: cfg.Auth.APIToken.DefaultTTL, + MaxTTL: cfg.Auth.APIToken.MaxTTL, + }) + // Build the rebalancer + sweeper up front so admin endpoints can + // reference the rebalancer (for /v1/admin/rebalance). They are only + // Run'd later under leader.OnPromote. + maintGate := func(c context.Context) bool { + _, on, _ := maint.IsOn(c) + return on + } + reb := &scheduler.Rebalancer{ + Store: s, + NC: ns.NC, + Interval: cfg.Cluster.RebalanceInterval, + MaintenanceGate: maintGate, + } + swp := &scheduler.RequeueSweeper{ + Store: s, + NC: ns.NC, + Interval: cfg.Postgres.RequeueSweeper, + StuckAfter: 60 * time.Second, + MaintenanceGate: maintGate, + } + + openapi.RegisterAdmin(api, openapi.AdminDeps{ + Store: s, + Maint: maint, + Rebalancer: reb, + Presences: nodeRunner, + }) + + // OIDC verifier — missing Authentik config is a soft failure: the + // session endpoints return 503 until it's populated, but the rest of + // the service (API tokens, admin endpoints) keeps working. That way + // an operator minting a break-glass token via `admin mint-token` + // isn't blocked by Authentik not being reachable yet. + var oidcVerifier *auth.OIDCVerifier + if cfg.Auth.Authentik.Issuer != "" && cfg.Auth.Authentik.Audience != "" { + initCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + oidcVerifier, err = auth.NewOIDCVerifier(initCtx, auth.OIDCVerifierOptions{ + Issuer: cfg.Auth.Authentik.Issuer, + Audience: cfg.Auth.Authentik.Audience, + }) + cancel() + if err != nil { + slog.Warn("anchorage: OIDC verifier init failed — /v1/auth/session will 503", + "err", err, "issuer", cfg.Auth.Authentik.Issuer) + } + } else { + slog.Warn("anchorage: auth.authentik.issuer / audience not set — /v1/auth/session disabled") + } + openapi.RegisterSession(api, openapi.SessionDeps{ + Store: s, + Signer: signer, + OIDC: oidcVerifier, + Sysadmins: cfg.Bootstrap.Sysadmins, + SessionTTL: cfg.Auth.APIToken.DefaultTTL, + CookieSecure: true, + }) + + hub, err := wsserver.NewHub(ns.NC) + if err != nil { + ns.Close() + return nil, fmt.Errorf("ws hub: %w", err) + } + wsserver.Register(http.App, hub) + + cons := &scheduler.Consumer{ + NodeID: nodeID, + Store: s, + Backend: backend, + NC: ns.NC, + JS: ns.JS, + } + rec = &scheduler.Reconciler{ + NodeID: nodeID, + Store: s, + Backend: backend, + Interval: cfg.IPFS.Reconciler.Interval, + AutoRepair: cfg.IPFS.Reconciler.AutoRepair, + } + a := &App{ + Cfg: cfg, + Store: s, + PGPool: pgPool, + NATS: ns, + Backend: backend, + Node: nodeRunner, + Pins: pinSvc, + LiveNodes: liveCache, + Maint: maint, + Signer: signer, + HTTP: http, + Hub: hub, + Consumer: cons, + Reconcile: rec, + Rebalance: reb, + Sweeper: swp, + } + + // Leader elector — toggles stale-sweep on the node runner and + // starts/stops the leader-gated workers (rebalancer + sweeper). + elector, err := leader.New(ctx, ns.JS, leader.Options{ + NodeID: nodeID.String(), + TTL: cfg.Cluster.HeartbeatInterval * 2, + RenewInterval: cfg.Cluster.HeartbeatInterval / 2, + OnPromote: a.onPromote, + OnDemote: a.onDemote, + }) + if err != nil { + ns.Close() + return nil, fmt.Errorf("leader: %w", err) + } + a.Elector = elector + + return a, nil +} + +// onPromote is fired by the leader elector when this node becomes +// leader. It starts the leader-gated workers under a fresh cancellable +// context and flips the node runner's stale-sweep flag on. +func (a *App) onPromote(_ context.Context) { + a.leaderMu.Lock() + defer a.leaderMu.Unlock() + if a.leaderCtx != nil { + return // already leading; idempotent + } + lctx, cancel := context.WithCancel(context.Background()) + a.leaderCtx = lctx + a.leaderCancel = cancel + + a.Node.SetSweepEnabled(true) + + a.leaderWG.Add(2) + go func() { defer a.leaderWG.Done(); _ = a.Rebalance.Run(lctx) }() + go func() { defer a.leaderWG.Done(); _ = a.Sweeper.Run(lctx) }() + + slog.Info("anchorage: assumed leadership; rebalancer + sweeper started") +} + +// onDemote is fired by the leader elector when this node loses leadership. +// It cancels the leader-gated workers and waits for them to exit. +func (a *App) onDemote(_ context.Context) { + a.leaderMu.Lock() + cancel := a.leaderCancel + a.leaderCtx = nil + a.leaderCancel = nil + a.leaderMu.Unlock() + + if cancel != nil { + cancel() + } + a.leaderWG.Wait() + a.Node.SetSweepEnabled(false) + slog.Info("anchorage: relinquished leadership") +} + +// Run starts every long-running goroutine and blocks until ctx is +// cancelled. The first non-ctx error from any component is returned; +// components that exit on ctx.Canceled are normal. Close must still be +// called afterwards so every lifecycle goroutine drains. +func (a *App) Run(ctx context.Context) error { + errCh := make(chan error, 5) + + spawn := func(name string, fn func(context.Context) error) { + a.lifecycleWG.Add(1) + go func() { + defer a.lifecycleWG.Done() + err := fn(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + slog.Error("anchorage: component failed", "component", name, "err", err) + select { + case errCh <- err: + default: + } + } + }() + } + + spawn("http", func(c context.Context) error { return a.HTTP.Start(c) }) + spawn("node", a.Node.Start) + spawn("elector", a.Elector.Run) + spawn("consumer", a.Consumer.Run) + spawn("reconciler", a.Reconcile.Run) + spawn("metrics-refresh", func(c context.Context) error { + metrics.RefreshGauges(c, a.Store) + return nil + }) + spawn("maintenance-watchdog", func(c context.Context) error { + maintenance.Watchdog(c, a.Maint, a.Cfg.Cluster.Maintenance.MaxDuration) + return nil + }) + + slog.Info("anchorage: running", + "node_id", a.Cfg.Node.ID, + "http", fmt.Sprintf("%s:%d", a.Cfg.Server.Host, a.Cfg.Server.Port)) + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-errCh: + return err + } +} + +// Close drains in reverse start order and waits for every goroutine +// to exit. Safe to call more than once. +// +// Order matters: stop accepting HTTP traffic first so no new work +// arrives, then let in-flight job consumers drain, then stop NATS +// (which also stops heartbeats and KV access), then release the store. +func (a *App) Close(ctx context.Context) { + // 1. HTTP first so no new requests land. + if a.HTTP != nil { + _ = a.HTTP.Shutdown(ctx) + } + + // 2. Relinquish leadership so leader-gated goroutines exit. + a.onDemote(ctx) + + // 3. Wait for every lifecycle goroutine to return. Run's caller + // must have cancelled ctx for these to unblock; that's the contract. + a.lifecycleWG.Wait() + + // 4. NATS before Postgres — consumers acking a message still need + // the pool to persist the completion row before we tear that down. + if a.NATS != nil { + a.NATS.Close() + } + + // 5. Postgres pool last. + if a.PGPool != nil { + a.PGPool.Close() + } +} + diff --git a/internal/app/anchorage/invalidate_wire.go b/internal/app/anchorage/invalidate_wire.go new file mode 100644 index 0000000..923637b --- /dev/null +++ b/internal/app/anchorage/invalidate_wire.go @@ -0,0 +1,23 @@ +package anchorage + +import ( + natsio "github.com/nats-io/nats.go" + + embeddednats "anchorage/internal/pkg/nats" + "anchorage/internal/pkg/node" + "anchorage/internal/pkg/pin" +) + +// wireCacheInvalidation subscribes to node.heartbeat.* and drops the +// live-nodes cache on every received beat. That way a node joining, +// leaving, draining, or uncordoning propagates to placement within a +// single heartbeat RTT rather than waiting for the 5s TTL. +// +// The subscription lives for the lifetime of ns.NC; it is released +// when App.Close shuts the embedded NATS server down. +func wireCacheInvalidation(ns *embeddednats.Server, live *pin.CachedLiveNodes) error { + _, err := ns.NC.Subscribe(node.HeartbeatSubjectPrefix+".*", func(_ *natsio.Msg) { + live.Invalidate() + }) + return err +} diff --git a/internal/app/anchorage/nodeid.go b/internal/app/anchorage/nodeid.go new file mode 100644 index 0000000..dc214c2 --- /dev/null +++ b/internal/app/anchorage/nodeid.go @@ -0,0 +1,62 @@ +package anchorage + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "anchorage/internal/pkg/ids" +) + + +// resolveStableNodeID returns the node's TypeID, loading it from +// StateDir/node.id on disk if present, otherwise generating a fresh one +// and persisting it. This keeps the cluster identity stable across +// restarts even when cfg.Node.ID is a friendly display name (hostname). +// +// Precedence: +// 1. If cfg.Node.ID is itself a valid TypeID, use it directly. +// 2. Else read /node.id; if valid, use it. +// 3. Else mint a new TypeID and write it to /node.id. +// +// The returned displayName is whatever the operator configured (or +// hostname via applyHostnameFallback) — purely for logs and audit. +func resolveStableNodeID(rawID, stateDir string) (nodeID ids.NodeID, displayName string, err error) { + displayName = strings.TrimSpace(rawID) + + // Case 1: operator supplied a TypeID directly. + if id, perr := ids.ParseNode(rawID); perr == nil { + return id, displayName, nil + } + + if stateDir == "" { + return ids.NodeID{}, displayName, errors.New("node.stateDir is required when node.id is not a TypeID") + } + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return ids.NodeID{}, displayName, fmt.Errorf("create state dir: %w", err) + } + + // Case 2: prior boot already generated one. + path := filepath.Join(stateDir, "node.id") + if b, err := os.ReadFile(path); err == nil { + if id, perr := ids.ParseNode(strings.TrimSpace(string(b))); perr == nil { + return id, displayName, nil + } + slog.Warn("anchorage: state dir node.id is malformed; regenerating", "path", path) + } + + // Case 3: mint and persist. + id, err := ids.NewNode() + if err != nil { + return ids.NodeID{}, displayName, err + } + if err := os.WriteFile(path, []byte(id.String()+"\n"), 0o600); err != nil { + return ids.NodeID{}, displayName, fmt.Errorf("persist node.id: %w", err) + } + slog.Info("anchorage: generated stable node id", "node_id", id.String(), "path", path) + return id, displayName, nil +} + diff --git a/internal/cmd/admin.go b/internal/cmd/admin.go new file mode 100644 index 0000000..5ccc695 --- /dev/null +++ b/internal/cmd/admin.go @@ -0,0 +1,265 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// newAdminCmd groups operational commands (drain, maintenance, ...). +// +// Every subcommand is a thin HTTP client against the running +// anchorage's /v1/admin/* endpoints. The CLI never opens its own NATS +// server or database handle — that keeps the "two anchorages with one +// DataDir" footgun from being easy to trip over. +func newAdminCmd(flags *globalFlags) *cobra.Command { + admin := &cobra.Command{ + Use: "admin", + Short: "Operational commands (drain, maintenance mode, ...)", + } + admin.PersistentFlags().StringVar(&flags.adminURL, "server", defaultAdminURL(), "base URL of a running anchorage instance (env: ANCHORAGE_ADMIN_URL)") + admin.PersistentFlags().StringVar(&flags.adminToken, "token", os.Getenv("ANCHORAGE_ADMIN_TOKEN"), "sysadmin API token (env: ANCHORAGE_ADMIN_TOKEN)") + + admin.AddCommand(newDrainCmd(flags)) + admin.AddCommand(newUncordonCmd(flags)) + admin.AddCommand(newMaintenanceCmd(flags)) + admin.AddCommand(newMintTokenCmd(flags)) + admin.AddCommand(newGrantSysadminCmd(flags)) + admin.AddCommand(newRotateKeyCmd(flags)) + admin.AddCommand(newPruneDenylistCmd(flags)) + admin.AddCommand(newRebalanceCmd(flags)) + admin.AddCommand(newCacheStatsCmd(flags)) + admin.AddCommand(newClusterPresencesCmd(flags)) + return admin +} + +func newClusterPresencesCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "cluster-presences", + Short: "Real-time NATS heartbeat view from the server you're pointed at", + Long: `Returns the in-memory heartbeat map maintained by the anchorage +instance answering the request. Each entry is a peer's most recent +node.heartbeat. broadcast plus its age in seconds. + +Distinct from the DB 'nodes' table: this is the real-time NATS view, +updated every heartbeat interval, while the DB status is only flipped +to 'down' by the leader's stale sweeper every downAfter seconds. +Operators diagnosing "the DB says X is up but nobody's heard from it" +should compare the two.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return adminGET(cmd.OutOrStdout(), flags, "/v1/admin/cluster/presences") + }, + } +} + +func newPruneDenylistCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "prune-denylist", + Short: "Delete denylist rows whose expires_at has passed", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/tokens/prune-denylist", nil) + }, + } +} + +func newRebalanceCmd(flags *globalFlags) *cobra.Command { + var apply bool + c := &cobra.Command{ + Use: "rebalance", + Short: "Preview (default) or execute (--apply) a one-shot rebalance pass", + Long: `Walks the current placements table and reports what the rebalancer +would move to restore replica counts on nodes that transitioned to +down / drained. Useful for catch-up after a cluster-maintenance window. + +Default is a dry-run: no placements are moved. Pass --apply to execute. +The scheduled leader loop keeps running regardless.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body := map[string]any{"apply": apply} + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/rebalance", body) + }, + } + c.Flags().BoolVar(&apply, "apply", false, "execute the moves (default is a dry-run preview)") + return c +} + +func newCacheStatsCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "cache-stats", + Short: "Report per-cache hits / misses / evictions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return adminGET(cmd.OutOrStdout(), flags, "/v1/admin/cache-stats") + }, + } +} + +func newGrantSysadminCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "grant-sysadmin ", + Short: "Promote a previously-logged-in user to sysadmin", + Long: `Promotes an existing anchorage user (identified by the email Authentik +puts in the id_token) to sysadmin. The user must have completed at +least one OIDC login before this command can find them; it is not the +first-boot bootstrap path (that's bootstrap.sysadmins in config).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "/v1/admin/users/" + url.PathEscape(args[0]) + "/grant-sysadmin" + return adminPOST(cmd.OutOrStdout(), flags, path, nil) + }, + } +} + +func defaultAdminURL() string { + if u := os.Getenv("ANCHORAGE_ADMIN_URL"); u != "" { + return u + } + return "http://localhost:8080" +} + +func newDrainCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "drain ", + Short: "Mark a node drained (removes it from placement, keeps heartbeats)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/nodes/"+args[0]+"/drain", nil) + }, + } +} + +func newUncordonCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "uncordon ", + Short: "Clear drained status and rejoin the work queue", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/nodes/"+args[0]+"/uncordon", nil) + }, + } +} + +func newMaintenanceCmd(flags *globalFlags) *cobra.Command { + maint := &cobra.Command{ + Use: "maintenance", + Short: "Cluster-wide maintenance mode", + } + maint.AddCommand(newMaintenanceOnCmd(flags)) + maint.AddCommand(newMaintenanceOffCmd(flags)) + maint.AddCommand(newMaintenanceStatusCmd(flags)) + return maint +} + +func newMaintenanceOnCmd(flags *globalFlags) *cobra.Command { + var reason string + var ttl time.Duration + c := &cobra.Command{ + Use: "on", + Short: "Enable cluster-wide maintenance (pauses rebalancer + sweeper)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body := map[string]any{} + if reason != "" { + body["reason"] = reason + } + if ttl > 0 { + // encode duration as nanoseconds for Go's JSON decoder + body["ttl"] = int64(ttl) + } + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/maintenance/on", body) + }, + } + c.Flags().StringVar(&reason, "reason", "", "free-form reason (audit record)") + c.Flags().DurationVar(&ttl, "ttl", 0, "auto-disable after this duration (e.g. 30m)") + return c +} + +func newMaintenanceOffCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "off", + Short: "Disable cluster-wide maintenance", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return adminPOST(cmd.OutOrStdout(), flags, "/v1/admin/maintenance/off", nil) + }, + } +} + +func newMaintenanceStatusCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Print current cluster-maintenance state", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return adminGET(cmd.OutOrStdout(), flags, "/v1/admin/maintenance") + }, + } +} + +func adminPOST(out io.Writer, flags *globalFlags, path string, body any) error { + return adminCall(out, flags, http.MethodPost, path, body) +} + +func adminGET(out io.Writer, flags *globalFlags, path string) error { + return adminCall(out, flags, http.MethodGet, path, nil) +} + +func adminCall(out io.Writer, flags *globalFlags, method, path string, body any) error { + base, err := url.Parse(flags.adminURL) + if err != nil { + return fmt.Errorf("admin: bad --server url: %w", err) + } + u := base.JoinPath(path) + + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequest(method, u.String(), rdr) + if err != nil { + return err + } + if flags.adminToken == "" { + return fmt.Errorf("admin: --token (or ANCHORAGE_ADMIN_TOKEN) is required — mint a sysadmin token first") + } + req.Header.Set("Authorization", "Bearer "+flags.adminToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("admin: %s: %w", u, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 400 { + return fmt.Errorf("admin: %s %s → %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(b))) + } + // Pretty-print JSON; fall back to raw for empty/non-JSON bodies. + if len(bytes.TrimSpace(b)) > 0 { + var pretty bytes.Buffer + if err := json.Indent(&pretty, b, "", " "); err == nil { + fmt.Fprintln(out, pretty.String()) + return nil + } + fmt.Fprintln(out, string(b)) + return nil + } + fmt.Fprintf(out, "admin: %s %s OK\n", method, path) + return nil +} diff --git a/internal/cmd/migrate.go b/internal/cmd/migrate.go new file mode 100644 index 0000000..b2a47c4 --- /dev/null +++ b/internal/cmd/migrate.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "anchorage/internal/pkg/config" + "anchorage/internal/pkg/store/postgres" +) + +// newMigrateCmd groups schema-migration subcommands under `anchorage migrate`. +func newMigrateCmd(flags *globalFlags) *cobra.Command { + migrate := &cobra.Command{ + Use: "migrate", + Short: "Manage the anchorage Postgres schema", + } + migrate.AddCommand(newMigrateUpCmd(flags)) + migrate.AddCommand(newMigrateDownCmd(flags)) + migrate.AddCommand(newMigrateStatusCmd(flags)) + return migrate +} + +func newMigrateUpCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "up", + Short: "Apply all pending migrations", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfigForMigrate(flags) + if err != nil { + return err + } + if err := postgres.MigrateDSN(cmd.Context(), cfg.Postgres.DSN, postgres.MigrateUp, postgres.MigrateOptions{}); err != nil { + return fmt.Errorf("migrate up: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "migrate: up OK") + return nil + }, + } +} + +func newMigrateDownCmd(flags *globalFlags) *cobra.Command { + var yes bool + c := &cobra.Command{ + Use: "down", + Short: "Revert every applied migration (destructive; dev/test only)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if !yes { + return fmt.Errorf("refusing to run destructive migration; pass --yes to confirm") + } + cfg, err := loadConfigForMigrate(flags) + if err != nil { + return err + } + if err := postgres.MigrateDSN(cmd.Context(), cfg.Postgres.DSN, postgres.MigrateDown, postgres.MigrateOptions{}); err != nil { + return fmt.Errorf("migrate down: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "migrate: down OK") + return nil + }, + } + c.Flags().BoolVar(&yes, "yes", false, "confirm that you understand this drops the whole schema") + return c +} + +func newMigrateStatusCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Print the currently-applied migration version", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfigForMigrate(flags) + if err != nil { + return err + } + v, dirty, err := postgres.MigrationVersion(cmd.Context(), cfg.Postgres.DSN) + if err != nil { + return fmt.Errorf("read migration version: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "migrate: version=%d dirty=%v\n", v, dirty) + return nil + }, + } +} + +// loadConfigForMigrate loads the config with AllowMissing=true (so --config +// being absent is fine if the DSN comes from env) but rejects the run if +// there's still no DSN. +func loadConfigForMigrate(flags *globalFlags) (*config.Config, error) { + cfg, err := config.Load(config.LoadOptions{Path: flags.configPath, AllowMissing: true}) + if err != nil { + return nil, fmt.Errorf("load config: %w", err) + } + if cfg.Postgres.DSN == "" { + return nil, fmt.Errorf("postgres.dsn is required (config file or ANCHORAGE_POSTGRES_DSN)") + } + return cfg, nil +} diff --git a/internal/cmd/minttoken.go b/internal/cmd/minttoken.go new file mode 100644 index 0000000..cca213d --- /dev/null +++ b/internal/cmd/minttoken.go @@ -0,0 +1,258 @@ +package cmd + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "anchorage/internal/pkg/config" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/token" +) + +// DefaultIPFSClientTTL is the ceiling for IPFS-client tokens: 1 year +// plus a 30-day rotation grace window. That matches the config-level +// auth.apiToken.maxTTL default so `mint-token` can pick this TTL +// without tripping the signer's max-TTL guard (the signer doesn't +// currently enforce a ceiling, but this keeps the numbers honest). +const DefaultIPFSClientTTL = 9480 * time.Hour + +// newMintTokenCmd mints a standalone JWT off the configured signing +// key, printing the signed string to stdout. It does NOT talk to a +// running anchorage — it reads the key off disk directly, so it works +// before any OIDC user exists. +// +// Primary use case: handing an IPFS client a bearer token. +// +// anchorage admin mint-token --role sysadmin > /etc/anchorage/client.jwt +// ipfs pin remote service add anchor https://anchor.example.com/v1 $(cat /etc/anchorage/client.jwt) +// ipfs pin remote add --service=anchor bafybeig... +// +// A minted token bypasses the active-token / denylist caches because +// it was never registered through POST /v1/tokens — this is deliberate. +// Operators who need revocation should write the jti to the denylist +// via the /v1/tokens API or rotate the signing key. +func newMintTokenCmd(flags *globalFlags) *cobra.Command { + var ( + role string + orgArg string + userArg string + ttl time.Duration + scopes []string + label string + issuer string + audience string + keyPath string + kidFlag string + verbose bool + ) + + c := &cobra.Command{ + Use: "mint-token", + Short: "Mint a standalone JWT for CLI / IPFS-client usage", + Long: `Mints a signed JWT locally from the configured signing key and +prints it to stdout. Suitable for break-glass admin access, for +providing a long-lived bearer token to 'ipfs pin remote', or for +scripts / CI pipelines that need to call anchorage without going +through the interactive Authentik login. + +The default role is 'sysadmin' — which enables /v1/admin/* endpoints +(drain, uncordon, maintenance). Downgrade to 'orgadmin' or 'member' +when handing a token to a specific org. + +The default TTL is ` + DefaultIPFSClientTTL.String() + ` (1 year + 30-day rotation +grace) to match the apiToken.maxTTL ceiling. Shorten it with --ttl +for short-lived tokens: + + anchorage admin mint-token --role member --ttl 24h --org org_... + +Minted tokens DO NOT appear in 'list my tokens' (they aren't +registered via POST /v1/tokens) and cannot be revoked individually +except by denylisting the jti or rotating the signing key.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(config.LoadOptions{Path: flags.configPath, AllowMissing: true}) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + // Defaults: reach into the config's primary signingKey entry + // so operators who run mint-token alongside their configured + // server get correct key path + kid automatically. + primary, havePrimary := primarySigningKey(cfg) + if keyPath == "" && havePrimary { + keyPath = primary.Path + } + if kidFlag == "" && havePrimary { + kidFlag = primary.ID + } + if issuer == "" { + issuer = cfg.Auth.Authentik.Issuer + } + if audience == "" { + audience = cfg.Auth.Authentik.Audience + } + if ttl <= 0 { + ttl = DefaultIPFSClientTTL + } + if !validRole(role) { + return fmt.Errorf("bad --role %q: must be sysadmin, orgadmin, or member", role) + } + + // Pick between a real key file and the built-in dev key. + var key []byte + if keyPath != "" { + k, err := loadSingleKey(keyPath) + if err != nil { + return err + } + key = k + } else { + key = []byte(token.DevOnlySigningKey) + if kidFlag == "" { + kidFlag = token.DevKeyID + } + } + if kidFlag == "" { + return fmt.Errorf("--kid is required (or configure auth.apiToken.signingKeys in anchorage.yaml)") + } + + // nil TokenStore: standalone mint, no denylist check on verify + // and no api_tokens row written. Standalone by design. + signer, err := token.NewSigner([]token.SigningKey{ + {ID: kidFlag, Key: key, Primary: true}, + }, issuer, audience, nil) + if err != nil { + return fmt.Errorf("signer: %w", err) + } + + orgID, err := resolveOrg(orgArg, role) + if err != nil { + return err + } + userID, err := resolveUser(userArg) + if err != nil { + return err + } + + jwt, claims, err := signer.Mint(cmd.Context(), orgID, userID, role, scopes, ttl) + if err != nil { + return fmt.Errorf("mint: %w", err) + } + + // A token with empty issuer / audience will fail the running + // anchorage's Parse() check (enforced via jwt.WithIssuer / + // jwt.WithAudience). Warn loudly so an operator doesn't + // silently mint a dud. + if issuer == "" || audience == "" { + fmt.Fprintln(cmd.ErrOrStderr(), + "warning: issuer or audience is empty — this token will NOT verify against a running anchorage "+ + "that has auth.authentik.issuer / audience set in its config. Re-run with --issuer and "+ + "--audience matching the server's config, or populate them via anchorage.yaml.") + } + + // Primary output — just the token, so shell piping works: + // TOKEN=$(anchorage admin mint-token) + fmt.Fprintln(cmd.OutOrStdout(), jwt) + + if verbose { + // Metadata to stderr so it doesn't contaminate piped output. + fmt.Fprintf(cmd.ErrOrStderr(), + "# jti %s\n"+ + "# role %s\n"+ + "# org %s\n"+ + "# user %s\n"+ + "# issuer %s\n"+ + "# aud %s\n"+ + "# expires %s\n"+ + "# label %s\n", + claims.ID, claims.Role, claims.Org, claims.User, + claims.Issuer, strings.Join([]string(claims.Audience), ","), + claims.ExpiresAt.Time.Format(time.RFC3339), label) + } + return nil + }, + } + + c.Flags().StringVar(&role, "role", "sysadmin", "role: sysadmin | orgadmin | member") + c.Flags().StringVar(&orgArg, "org", "", "org TypeID (auto-generated if empty; not required for sysadmin)") + c.Flags().StringVar(&userArg, "user", "", "user TypeID (auto-generated if empty)") + c.Flags().DurationVar(&ttl, "ttl", DefaultIPFSClientTTL, "token lifetime (max config.auth.apiToken.maxTTL)") + c.Flags().StringSliceVar(&scopes, "scope", nil, "scope (repeatable): pin:read, pin:write, ...") + c.Flags().StringVar(&label, "label", "cli-mint", "free-form label (emitted in --verbose output)") + c.Flags().StringVar(&issuer, "issuer", "", "override JWT issuer (default: config auth.authentik.issuer)") + c.Flags().StringVar(&audience, "audience", "", "override JWT audience (default: config auth.authentik.audience)") + c.Flags().StringVar(&kidFlag, "kid", "", "key id to stamp into the JWT `kid` header (default: the primary entry in auth.apiToken.signingKeys; "+token.DevKeyID+" when no keys are configured)") + c.Flags().StringVar(&keyPath, "signing-key", "", "override signing key path (default: the primary entry in auth.apiToken.signingKeys; built-in dev key when none configured)") + c.Flags().BoolVarP(&verbose, "verbose", "v", false, "print jti + expiry + claims to stderr") + + return c +} + +// validRole mirrors the roles that actually mean something to the +// authz middleware. Keeping this gated at mint time avoids the +// confusing runtime path where a token with role="admin" (typo) is +// silently demoted to unauthenticated. +func validRole(r string) bool { + switch r { + case "sysadmin", "orgadmin", "member": + return true + } + return false +} + +// primarySigningKey picks the entry flagged primary from the config. +// Returns (zero, false) when no keys are configured — mint-token falls +// back to the built-in dev key in that case. +func primarySigningKey(cfg *config.Config) (config.SigningKeyConfig, bool) { + for _, k := range cfg.Auth.APIToken.SigningKeys { + if k.Primary { + return k, true + } + } + return config.SigningKeyConfig{}, false +} + +// loadSingleKey is the mint-token-only wrapper over token.LoadSigningKeys +// that expects exactly one file. Kept local so mint-token doesn't have +// to construct a full rotation spec for the single-key case. +func loadSingleKey(path string) ([]byte, error) { + keys, err := token.LoadSigningKeys([]token.KeyFileSpec{ + {ID: "mint-one-shot", Path: path, Primary: true}, + }) + if err != nil { + return nil, err + } + return keys[0].Key, nil +} + +func resolveOrg(s, role string) (ids.OrgID, error) { + if s == "" { + if role == "sysadmin" { + // Sysadmin tokens don't need a real org; admin endpoints + // use the role, not the org. Mint a fresh one so the JWT + // is well-formed. + id, err := ids.NewOrg() + if err != nil { + return ids.OrgID{}, fmt.Errorf("generate org id: %w", err) + } + return id, nil + } + return ids.OrgID{}, errors.New("--org is required for non-sysadmin roles") + } + return ids.ParseOrg(s) +} + +func resolveUser(s string) (ids.UserID, error) { + if s == "" { + id, err := ids.NewUser() + if err != nil { + return ids.UserID{}, fmt.Errorf("generate user id: %w", err) + } + return id, nil + } + return ids.ParseUser(s) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go new file mode 100644 index 0000000..4a4cc10 --- /dev/null +++ b/internal/cmd/root.go @@ -0,0 +1,55 @@ +// Package cmd assembles the anchorage Cobra command tree. +// +// Each sub-command lives in its own file (serve.go, version.go, ...). The +// root command is intentionally minimal: it sets up the tree and delegates +// all wiring to the composition root in internal/app/anchorage, which is +// created lazily by the sub-commands that need it. +package cmd + +import ( + "github.com/spf13/cobra" +) + +// globalFlags carries flags that every subcommand inherits via +// PersistentFlags. Exposed as a struct so sub-commands can read them without +// re-parsing the command line. +type globalFlags struct { + configPath string + // Admin-only flags, populated by newAdminCmd's PersistentFlags. + adminURL string + adminToken string +} + +// newRootCmd builds the top-level `anchorage` command. +// +// A factory is used (rather than a package-global var) so tests can construct +// a fresh command tree with isolated flags. +func newRootCmd() *cobra.Command { + flags := &globalFlags{} + + root := &cobra.Command{ + Use: "anchorage", + Short: "anchorage — highly-available IPFS Pinning Service", + Long: "anchorage runs the IPFS Pinning Service API, cluster coordination, and WebSocket event fan-out for a paired Kubo daemon.", + SilenceUsage: true, + SilenceErrors: true, + } + + root.PersistentFlags().StringVarP(&flags.configPath, "config", "c", "", + "path to anchorage config file (default: ./configs/anchorage.yaml, $HOME/.anchorage, /etc/anchorage)") + + root.AddCommand(newVersionCmd()) + root.AddCommand(newServeCmd(flags)) + root.AddCommand(newMigrateCmd(flags)) + root.AddCommand(newAdminCmd(flags)) + + return root +} + +// Execute runs the anchorage command tree against os.Args. +// +// It is intentionally the only exported symbol in this package so main.go +// stays a thin wrapper. +func Execute() error { + return newRootCmd().Execute() +} diff --git a/internal/cmd/rotatekey.go b/internal/cmd/rotatekey.go new file mode 100644 index 0000000..4392f2f --- /dev/null +++ b/internal/cmd/rotatekey.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" +) + +// newRotateKeyCmd generates a fresh random signing key on disk and +// prints the YAML snippet an operator pastes into anchorage.yaml to +// register it alongside the existing primary key. +// +// The command is strictly offline — it never contacts a running +// anchorage. After writing the new key file + updating config, the +// operator restarts the fleet to pick up the secondary key, then at +// a later step flips `primary: true` onto it and restarts again. +// +// Full procedure lives in deploy/README.md under "Rotating the JWT +// signing key". +func newRotateKeyCmd(flags *globalFlags) *cobra.Command { + var ( + outPath string + keyID string + bytes int + overwrite bool + ) + c := &cobra.Command{ + Use: "rotate-signing-key", + Short: "Generate a new JWT signing key and emit the config snippet for rotation overlap", + Long: `Generates a random HMAC key, writes it to --out with 0600 perms, +and prints a YAML fragment to stdout showing how to add it to your +anchorage.yaml under auth.apiToken.signingKeys. + +This is the first step of a rotation; see deploy/README.md for the +full three-step procedure (add as secondary → flip primary → drop +retired key). + +The command never touches a running anchorage. Restart the fleet +after editing the config.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if keyID == "" { + keyID = time.Now().UTC().Format("2006-01-02") + } + if outPath == "" { + outPath = filepath.Join(".", fmt.Sprintf("anchorage-jwt-%s.key", keyID)) + } + if bytes < 32 { + return fmt.Errorf("bad --bytes %d: minimum 32", bytes) + } + + if !overwrite { + if _, err := os.Stat(outPath); err == nil { + return fmt.Errorf("refusing to overwrite %s (pass --force to allow)", outPath) + } + } + + raw := make([]byte, bytes) + if _, err := rand.Read(raw); err != nil { + return fmt.Errorf("read randomness: %w", err) + } + encoded := []byte(base64.StdEncoding.EncodeToString(raw)) + if err := os.WriteFile(outPath, encoded, 0o600); err != nil { + return fmt.Errorf("write %s: %w", outPath, err) + } + + // Primary output: the YAML snippet. Use stderr for the + // "wrote key" affordance so stdout stays clean if an operator + // wants to pipe it into their config. + fmt.Fprintf(cmd.ErrOrStderr(), + "# wrote %d-byte key to %s (base64-encoded, %d chars)\n", + bytes, outPath, len(encoded)) + + fmt.Fprintf(cmd.OutOrStdout(), `# Append this entry to auth.apiToken.signingKeys in anchorage.yaml. +# Keep the existing primary key entry; restart the fleet so every +# node loads both keys before flipping primary: true over here. + +auth: + apiToken: + signingKeys: + # ... existing keys above, primary: true on ONE of them ... + - id: %q + path: %q + primary: false +`, keyID, absOrAs(outPath)) + return nil + }, + } + c.Flags().StringVar(&outPath, "out", "", "write the new key here (default: ./anchorage-jwt-.key)") + c.Flags().StringVar(&keyID, "id", "", "kid/label for the key (default: yyyy-mm-dd)") + c.Flags().IntVar(&bytes, "bytes", 48, "raw key length before base64 encoding (>=32)") + c.Flags().BoolVar(&overwrite, "force", false, "overwrite an existing file at --out") + return c +} + +// absOrAs returns an absolute form of p if that succeeds, else p as given. +// Used purely for the YAML snippet — operators tend to run the command +// from /etc/anchorage or similar, and pasting an absolute path is +// friendlier than a relative one. +func absOrAs(p string) string { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go new file mode 100644 index 0000000..66d11e7 --- /dev/null +++ b/internal/cmd/serve.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "context" + "fmt" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + + app "anchorage/internal/app/anchorage" + "anchorage/internal/pkg/config" +) + +// newServeCmd runs the anchorage server. +func newServeCmd(flags *globalFlags) *cobra.Command { + return &cobra.Command{ + Use: "serve", + Short: "Run the anchorage HTTP + cluster coordinator", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load(config.LoadOptions{Path: flags.configPath, AllowMissing: true}) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + a, err := app.New(ctx, cfg) + if err != nil { + return fmt.Errorf("init app: %w", err) + } + defer a.Close(context.Background()) + + return a.Run(ctx) + }, + } +} diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..984ce22 --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + "runtime" + "runtime/debug" + + "github.com/spf13/cobra" +) + +// Build metadata — overridden via -ldflags at release time. +// +// go build -ldflags "-X anchorage/internal/cmd.version=v0.1.0 -X anchorage/internal/cmd.commit= -X anchorage/internal/cmd.date=" +var ( + version = "dev" + commit = "" + date = "" +) + +// newVersionCmd prints build metadata and exits. +// +// Falls back to debug.ReadBuildInfo() for commit/date so `go install` and +// `go run` produce useful output even without ldflags. +func newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print anchorage version and build metadata", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + v, c, d := resolveBuildInfo() + out := cmd.OutOrStdout() + fmt.Fprintf(out, "anchorage %s\n", v) + if c != "" { + fmt.Fprintf(out, " commit: %s\n", c) + } + if d != "" { + fmt.Fprintf(out, " built: %s\n", d) + } + fmt.Fprintf(out, " runtime: %s %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH) + return nil + }, + } +} + +// resolveBuildInfo prefers ldflag-injected values and falls back to the +// module's embedded vcs info when unset. +func resolveBuildInfo() (v, c, d string) { + v, c, d = version, commit, date + if c != "" && d != "" { + return + } + info, ok := debug.ReadBuildInfo() + if !ok { + return + } + for _, s := range info.Settings { + switch s.Key { + case "vcs.revision": + if c == "" { + c = s.Value + } + case "vcs.time": + if d == "" { + d = s.Value + } + } + } + return +} diff --git a/internal/pkg/auth/middleware.go b/internal/pkg/auth/middleware.go new file mode 100644 index 0000000..a27b6c0 --- /dev/null +++ b/internal/pkg/auth/middleware.go @@ -0,0 +1,166 @@ +// Package auth is the thin JWT/session middleware that gates every +// anchorage API call. +// +// It covers both halves of the auth story: authentication (who are +// you — parsed Bearer token) and authorization (are you allowed — role +// + scope + org checks via Require*). The JWT cryptography itself lives +// in internal/pkg/token. +// +// The middleware extracts the Bearer token, populates a ClientContext, +// and attaches it to context.Context (huma handlers read via FromContext) +// and Fiber Locals (WebSocket handlers read via FromLocals). +package auth + +import ( + "context" + "errors" + "strings" + + "github.com/gofiber/fiber/v2" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/token" +) + +// ClientContext describes the authenticated client attached to a request. +type ClientContext struct { + Org ids.OrgID + User ids.UserID + Role string + Scopes []string + // TokenJTI is set only for API-token auth (not session cookies). + TokenJTI *ids.TokenID +} + +// contextKey is unexported so callers must round-trip through FromContext. +type contextKey struct{} + +// WithClient returns ctx with cc attached. +func WithClient(ctx context.Context, cc *ClientContext) context.Context { + return context.WithValue(ctx, contextKey{}, cc) +} + +// FromContext recovers the client, if any. Returns nil when unauthenticated. +func FromContext(ctx context.Context) *ClientContext { + cc, _ := ctx.Value(contextKey{}).(*ClientContext) + return cc +} + +// ErrUnauthorized is returned when a request lacks or fails authentication. +var ErrUnauthorized = errors.New("auth: unauthorized") + +// SessionCookieName is the cookie anchorage issues for logged-in web +// users after POST /v1/auth/session succeeds. Exported so the session +// handler and the logout handler can agree on it without a constant +// drift. +const SessionCookieName = "anchorage_session" + +// BearerMiddleware validates the request's authentication token — either +// an `Authorization: Bearer ` header (API clients) or an +// `anchorage_session=` cookie (web UI after POST /v1/auth/session). +// On success it attaches a ClientContext to both context.Context and +// Fiber Locals. +// +// Precedence: if both a bearer header and a session cookie are present, +// the header wins — API clients tend to be more deliberate and we don't +// want a stale cookie to silently shadow a freshly-minted token. +// +// Missing tokens are NOT rejected here; handlers that need auth call +// Require(ctx). Leaving unauthenticated requests un-annotated lets +// /health, /ready, /docs and /openapi.json respond without ceremony. +func BearerMiddleware(signer *token.Signer) fiber.Handler { + return func(c *fiber.Ctx) error { + raw, source, err := extractToken(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, err.Error()) + } + if raw == "" { + return c.Next() + } + + claims, err := signer.Parse(c.UserContext(), raw) + if err != nil { + // Cookie-based auth errors should clear the bad cookie so the + // browser stops sending it on every retry. Header-based auth + // errors just bubble up. + if source == sourceCookie { + c.Cookie(expiringCookie()) + } + return fiber.NewError(fiber.StatusUnauthorized, "auth: "+err.Error()) + } + jti, _ := ids.ParseToken(claims.ID) + cc := &ClientContext{ + Org: claims.Org, + User: claims.User, + Role: claims.Role, + Scopes: claims.Scopes, + TokenJTI: &jti, + } + c.SetUserContext(WithClient(c.UserContext(), cc)) + c.Locals(localsKey, cc) + return c.Next() + } +} + +type tokenSource int + +const ( + sourceNone tokenSource = iota + sourceHeader + sourceCookie +) + +// extractToken pulls a JWT off the Authorization header first, then the +// session cookie. Returns ("", sourceNone, nil) when neither is present +// so the middleware can let unauthenticated requests through. +func extractToken(c *fiber.Ctx) (string, tokenSource, error) { + if h := c.Get(fiber.HeaderAuthorization); h != "" { + parts := strings.SplitN(h, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + return "", sourceNone, errors.New("auth: malformed Authorization header") + } + return strings.TrimSpace(parts[1]), sourceHeader, nil + } + if v := c.Cookies(SessionCookieName); v != "" { + return v, sourceCookie, nil + } + return "", sourceNone, nil +} + +// expiringCookie returns a Set-Cookie that immediately clears the +// session cookie on the client. Used on logout and on a bad-cookie +// 401 so stale JWTs don't keep round-tripping. +func expiringCookie() *fiber.Cookie { + return &fiber.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + HTTPOnly: true, + Secure: true, + SameSite: fiber.CookieSameSiteLaxMode, + MaxAge: -1, + } +} + +// localsKey is the Fiber Locals key where BearerMiddleware parks the +// authenticated ClientContext for downstream handlers that can't reach +// UserContext — notably the /v1/events WebSocket upgrade. +const localsKey = "anchorage.auth.client" + +// FromLocals recovers a ClientContext stored on a Fiber request by +// BearerMiddleware. Returns nil when unauthenticated. +func FromLocals(locals func(key string) interface{}) *ClientContext { + v := locals(localsKey) + cc, _ := v.(*ClientContext) + return cc +} + +// Require asserts that the request is authenticated. Handlers call this +// as the first line of their body. +func Require(ctx context.Context) (*ClientContext, error) { + cc := FromContext(ctx) + if cc == nil { + return nil, ErrUnauthorized + } + return cc, nil +} diff --git a/internal/pkg/auth/oidc.go b/internal/pkg/auth/oidc.go new file mode 100644 index 0000000..dc809c6 --- /dev/null +++ b/internal/pkg/auth/oidc.go @@ -0,0 +1,134 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" +) + +// OIDCClaims is the subset of an Authentik ID token anchorage cares about. +// +// Standard OIDC claims (sub, email, name) land directly; custom claims +// added via Authentik property mappings (like the `groups` claim from +// docs/authentik-setup.md §4) ride along too. +type OIDCClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Name string `json:"name,omitempty"` + PreferredName string `json:"preferred_username,omitempty"` + Groups []string `json:"groups,omitempty"` + jwt.RegisteredClaims +} + +// DisplayName picks the best human-readable name from the claims — preferring +// `name`, falling back to `preferred_username`, then email. Never empty. +func (c *OIDCClaims) DisplayName() string { + switch { + case c.Name != "": + return c.Name + case c.PreferredName != "": + return c.PreferredName + default: + return c.Email + } +} + +// OIDCVerifier validates ID tokens against a single Authentik provider. +// +// JWKS are fetched from /jwks/ on construction and auto-refreshed +// by keyfunc/v3 (default interval ~1h). Revocation of the signing key is +// therefore picked up without an anchorage restart. +type OIDCVerifier struct { + issuer string + audience string + jwks keyfunc.Keyfunc +} + +// OIDCVerifierOptions configures NewOIDCVerifier. +type OIDCVerifierOptions struct { + // Issuer is the full OIDC issuer URL (must match the ID token's + // `iss` claim byte-for-byte). + Issuer string + // Audience is the expected `aud` claim value — typically the + // OAuth2 client ID as configured in Authentik. + Audience string + // JWKSURL overrides the default /jwks/ derivation. Leave + // empty in production; useful for tests pointing at httptest. + JWKSURL string +} + +// NewOIDCVerifier fetches the JWKS for the given issuer once, then +// relies on keyfunc's background refresh to track cert rotations. +// +// The initial JWKS fetch is bounded by ctx — pass a deadline to avoid +// hanging app startup if Authentik is unreachable at boot. +func NewOIDCVerifier(ctx context.Context, opts OIDCVerifierOptions) (*OIDCVerifier, error) { + if opts.Issuer == "" { + return nil, errors.New("auth: OIDC Issuer is required") + } + if opts.Audience == "" { + return nil, errors.New("auth: OIDC Audience is required") + } + + jwksURL := opts.JWKSURL + if jwksURL == "" { + jwksURL = strings.TrimRight(opts.Issuer, "/") + "/jwks/" + } + if _, err := url.Parse(jwksURL); err != nil { + return nil, fmt.Errorf("auth: parse jwks url: %w", err) + } + + k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL}) + if err != nil { + return nil, fmt.Errorf("auth: init jwks client for %s: %w", jwksURL, err) + } + + return &OIDCVerifier{ + issuer: opts.Issuer, + audience: opts.Audience, + jwks: k, + }, nil +} + +// Verify validates an Authentik-issued ID token end-to-end: signature via +// JWKS, issuer / audience / expiry / not-before clock checks, and a +// minimum shape check (sub + email required). +// +// Returns the parsed claims on success. +func (v *OIDCVerifier) Verify(_ context.Context, idToken string) (*OIDCClaims, error) { + if v == nil { + return nil, errors.New("auth: nil OIDCVerifier") + } + if strings.TrimSpace(idToken) == "" { + return nil, errors.New("auth: empty id token") + } + + claims := &OIDCClaims{} + tok, err := jwt.ParseWithClaims(idToken, claims, v.jwks.Keyfunc, + jwt.WithIssuer(v.issuer), + jwt.WithAudience(v.audience), + // Allow a small clock skew — Authentik and anchorage are on + // different hosts, NTP is imperfect. + jwt.WithLeeway(60*time.Second), + ) + if err != nil { + return nil, fmt.Errorf("auth: verify id token: %w", err) + } + if !tok.Valid { + return nil, errors.New("auth: id token invalid") + } + if claims.Sub == "" { + return nil, errors.New("auth: id token missing sub claim") + } + if claims.Email == "" { + return nil, errors.New("auth: id token missing email claim — add `email` to the Authentik scope mappings") + } + return claims, nil +} diff --git a/internal/pkg/auth/oidc_test.go b/internal/pkg/auth/oidc_test.go new file mode 100644 index 0000000..266d111 --- /dev/null +++ b/internal/pkg/auth/oidc_test.go @@ -0,0 +1,227 @@ +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "anchorage/internal/pkg/auth" +) + +// fakeAuthentik simulates the small surface of Authentik anchorage +// actually touches: a JWKS endpoint and a helper to mint ID tokens. +type fakeAuthentik struct { + server *httptest.Server + priv *rsa.PrivateKey + kid string + issuer string + audience string +} + +func newFakeAuthentik(t *testing.T, audience string) *fakeAuthentik { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + mux := http.NewServeMux() + kid := "test-kid-1" + + fk := &fakeAuthentik{priv: priv, kid: kid, audience: audience} + + mux.HandleFunc("/jwks/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "keys": []map[string]string{ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": base64urlBigInt(priv.N), + "e": base64urlBigInt(big.NewInt(int64(priv.E))), + }, + }, + }) + }) + + fk.server = httptest.NewServer(mux) + // Authentik-style per-app issuer. The verifier derives JWKS URL as + // /jwks/ so our mux handles "/jwks/" under the issuer path. + fk.issuer = fk.server.URL + "/" + return fk +} + +func (fk *fakeAuthentik) close() { fk.server.Close() } + +// mintIDToken mints an RS256-signed ID token with optional overrides. +func (fk *fakeAuthentik) mintIDToken(t *testing.T, overrides func(c *auth.OIDCClaims)) string { + t.Helper() + now := time.Now().UTC() + c := &auth.OIDCClaims{ + Sub: "hashed-authentik-sub-123", + Email: "alice@example.com", + Name: "Alice Example", + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: fk.issuer, + Audience: jwt.ClaimStrings{fk.audience}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)), + }, + } + if overrides != nil { + overrides(c) + } + tok := jwt.NewWithClaims(jwt.SigningMethodRS256, c) + tok.Header["kid"] = fk.kid + signed, err := tok.SignedString(fk.priv) + if err != nil { + t.Fatalf("sign: %v", err) + } + return signed +} + +func base64urlBigInt(i *big.Int) string { + return base64.RawURLEncoding.EncodeToString(i.Bytes()) +} + +// --------------------------------------------------------------------------- + +func TestOIDCVerifierAcceptsWellFormedToken(t *testing.T) { + fk := newFakeAuthentik(t, "anchorage-web") + defer fk.close() + + v, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{ + Issuer: fk.issuer, + Audience: fk.audience, + }) + if err != nil { + t.Fatalf("NewOIDCVerifier: %v", err) + } + + raw := fk.mintIDToken(t, nil) + claims, err := v.Verify(context.Background(), raw) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if claims.Sub != "hashed-authentik-sub-123" { + t.Errorf("Sub = %q", claims.Sub) + } + if claims.Email != "alice@example.com" { + t.Errorf("Email = %q", claims.Email) + } + if claims.DisplayName() != "Alice Example" { + t.Errorf("DisplayName = %q", claims.DisplayName()) + } +} + +func TestOIDCVerifierRejectsWrongAudience(t *testing.T) { + fk := newFakeAuthentik(t, "anchorage-web") + defer fk.close() + + v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{ + Issuer: fk.issuer, + Audience: "anchorage-web", + }) + raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) { + c.Audience = jwt.ClaimStrings{"some-other-client"} + }) + if _, err := v.Verify(context.Background(), raw); err == nil { + t.Error("expected verify to fail on wrong audience") + } +} + +func TestOIDCVerifierRejectsWrongIssuer(t *testing.T) { + fk := newFakeAuthentik(t, "anchorage-web") + defer fk.close() + + v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{ + Issuer: fk.issuer, + Audience: "anchorage-web", + }) + raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) { + c.Issuer = "https://evil.example.com/" + }) + if _, err := v.Verify(context.Background(), raw); err == nil { + t.Error("expected verify to fail on wrong issuer") + } +} + +func TestOIDCVerifierRejectsExpired(t *testing.T) { + fk := newFakeAuthentik(t, "anchorage-web") + defer fk.close() + + v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{ + Issuer: fk.issuer, + Audience: "anchorage-web", + }) + raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) { + c.ExpiresAt = jwt.NewNumericDate(time.Now().Add(-5 * time.Minute)) + c.IssuedAt = jwt.NewNumericDate(time.Now().Add(-10 * time.Minute)) + }) + if _, err := v.Verify(context.Background(), raw); err == nil { + t.Error("expected verify to fail on expired token") + } +} + +func TestOIDCVerifierRejectsMissingEmail(t *testing.T) { + fk := newFakeAuthentik(t, "anchorage-web") + defer fk.close() + + v, _ := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{ + Issuer: fk.issuer, + Audience: "anchorage-web", + }) + raw := fk.mintIDToken(t, func(c *auth.OIDCClaims) { + c.Email = "" + }) + _, err := v.Verify(context.Background(), raw) + if err == nil { + t.Fatal("expected verify to reject token with empty email") + } + if !strings.Contains(err.Error(), "email") { + t.Errorf("error should mention email; got %v", err) + } +} + +func TestNewOIDCVerifierRequiresIssuerAndAudience(t *testing.T) { + if _, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{}); err == nil { + t.Error("expected error for empty options") + } + if _, err := auth.NewOIDCVerifier(context.Background(), auth.OIDCVerifierOptions{Issuer: "https://x/"}); err == nil { + t.Error("expected error for missing audience") + } +} + +// TestDisplayNameFallbacks locks in the Name → PreferredName → Email +// fallback chain so a user without a configured display_name in +// Authentik still gets a human-readable label in the anchorage UI. +func TestDisplayNameFallbacks(t *testing.T) { + tests := []struct { + in auth.OIDCClaims + want string + }{ + {auth.OIDCClaims{Name: "Alice"}, "Alice"}, + {auth.OIDCClaims{PreferredName: "alice"}, "alice"}, + {auth.OIDCClaims{Email: "alice@example.com"}, "alice@example.com"}, + } + for _, tt := range tests { + got := tt.in.DisplayName() + if got != tt.want { + t.Errorf("DisplayName(%+v) = %q, want %q", tt.in, got, tt.want) + } + } +} + diff --git a/internal/pkg/cache/cache.go b/internal/pkg/cache/cache.go new file mode 100644 index 0000000..64e578f --- /dev/null +++ b/internal/pkg/cache/cache.go @@ -0,0 +1,194 @@ +// Package cache gives anchorage a small layer of bounded in-memory caches +// on top of ristretto. Every cache has a byte budget and a default TTL, +// so no matter how hot traffic gets the RSS cost is known in advance. +// +// Values are cached per-node. Cross-node consistency is handled by the +// invalidation bus (see invalidate.go) which publishes a NATS message +// whenever a writer mutates the underlying record; subscribers on other +// nodes drop their cached entry and the next read re-fetches from Postgres. +package cache + +import ( + "errors" + "sync" + "time" + + "github.com/dgraph-io/ristretto/v2" +) + +// Options configures a single cache. +type Options struct { + // Name is used in log messages and metric labels; should be short and + // lowercase (e.g., "org/byid", "pin/byid"). + Name string + // MaxCost is the byte budget. Once the internal cost estimate exceeds + // this, ristretto evicts entries using admission-controlled TinyLFU. + MaxCost int64 + // NumCounters is ristretto's frequency-estimator size. ~10x the expected + // entry count is a good default. + NumCounters int64 + // DefaultTTL is applied when callers set a zero TTL. Zero here means + // "no default TTL" — entries live until evicted. + DefaultTTL time.Duration +} + +// Cache is a typed, bounded in-memory cache. +// +// Keys must satisfy ristretto's Key constraint (string, int, byte slice, +// etc.). anchorage conventionally uses strings for keys — typeid strings, +// compound keys like ":". +type Cache[K ristretto.Key, V any] struct { + opts Options + r *ristretto.Cache[K, V] +} + +// New returns a Cache backed by ristretto. +// +// MaxCost must be > 0; NumCounters defaults to 10×expected-entries if zero +// (falls back to a safe minimum otherwise). +func New[K ristretto.Key, V any](opts Options) (*Cache[K, V], error) { + if opts.MaxCost <= 0 { + return nil, errors.New("cache: MaxCost must be > 0") + } + if opts.NumCounters == 0 { + // ~10× a rough estimate of entry count. Ristretto docs recommend + // 10× expected-items; we derive a safe minimum from MaxCost. + opts.NumCounters = opts.MaxCost / 64 + if opts.NumCounters < 1000 { + opts.NumCounters = 1000 + } + } + + r, err := ristretto.NewCache(&ristretto.Config[K, V]{ + MaxCost: opts.MaxCost, + NumCounters: opts.NumCounters, + BufferItems: 64, + // Metrics has a small per-op cost; worth paying for the + // /v1/admin/cache-stats endpoint + any future /metrics wiring. + Metrics: true, + }) + if err != nil { + return nil, err + } + c := &Cache[K, V]{opts: opts, r: r} + Register(c) + return c, nil +} + +// Stats is a point-in-time snapshot of a cache's counters. Emitted by +// /v1/admin/cache-stats. Fields are monotonic counters or instantaneous +// gauges — differences between successive snapshots give rates. +type Stats struct { + Name string `json:"name"` + Hits uint64 `json:"hits"` + Misses uint64 `json:"misses"` + KeysAdded uint64 `json:"keys_added"` + KeysEvicted uint64 `json:"keys_evicted"` + CostAdded uint64 `json:"cost_added"` + CostEvicted uint64 `json:"cost_evicted"` +} + +// Stats returns a snapshot of this cache's current counters. +func (c *Cache[K, V]) Stats() Stats { + m := c.r.Metrics + if m == nil { + return Stats{Name: c.opts.Name} + } + return Stats{ + Name: c.opts.Name, + Hits: m.Hits(), + Misses: m.Misses(), + KeysAdded: m.KeysAdded(), + KeysEvicted: m.KeysEvicted(), + CostAdded: m.CostAdded(), + CostEvicted: m.CostEvicted(), + } +} + +// StatsProvider is implemented by anything that can expose a Stats +// snapshot — both *Cache[K,V] and package-external caches (e.g., +// pin.CachedLiveNodes) which keep their own counters. +type StatsProvider interface { + Stats() Stats +} + +// Global stats registry. Every cache.New() call registers itself; +// external providers (pin.CachedLiveNodes) register explicitly via +// cache.Register(provider). The admin cache-stats endpoint reads +// AllStats() to produce its response. +var registry struct { + mu sync.RWMutex + providers []StatsProvider +} + +// Register adds a StatsProvider to the global registry. Safe to call +// from multiple goroutines; idempotent-ish (dedup is caller's concern — +// most caches are constructed once during app startup). +func Register(p StatsProvider) { + registry.mu.Lock() + registry.providers = append(registry.providers, p) + registry.mu.Unlock() +} + +// AllStats returns snapshots for every registered cache. Order is +// registration order; callers that care about stable ordering should +// sort by Stats.Name. +func AllStats() []Stats { + registry.mu.RLock() + defer registry.mu.RUnlock() + out := make([]Stats, 0, len(registry.providers)) + for _, p := range registry.providers { + out = append(out, p.Stats()) + } + return out +} + +// ResetRegistry clears the global registry. Tests call this between +// runs; not for production use. +func ResetRegistry() { + registry.mu.Lock() + registry.providers = nil + registry.mu.Unlock() +} + +// Get returns the cached value and whether it was present. +func (c *Cache[K, V]) Get(k K) (V, bool) { + return c.r.Get(k) +} + +// Set stores v with a cost estimate and TTL. ttl == 0 falls back to +// opts.DefaultTTL; cost == 0 uses the length of the key's string form +// as a (very rough) estimate. +// +// Ristretto's Set is asynchronous: the value may not be queryable +// immediately. Call Wait() in tests that need strict read-your-writes. +func (c *Cache[K, V]) Set(k K, v V, cost int64, ttl time.Duration) bool { + if ttl == 0 { + ttl = c.opts.DefaultTTL + } + if cost <= 0 { + cost = 1 + } + return c.r.SetWithTTL(k, v, cost, ttl) +} + +// Delete removes k from the cache if present. +func (c *Cache[K, V]) Delete(k K) { + c.r.Del(k) +} + +// Wait blocks until pending async Sets are visible to Get. Intended for +// tests and for the writer-inline update pattern (see invalidate.go). +func (c *Cache[K, V]) Wait() { + c.r.Wait() +} + +// Close releases ristretto's background goroutines. Only called at +// process shutdown. +func (c *Cache[K, V]) Close() { + c.r.Close() +} + +// Name returns the cache's configured name. Used by the invalidation +// bus to form subject names. +func (c *Cache[K, V]) Name() string { return c.opts.Name } diff --git a/internal/pkg/cache/cache_test.go b/internal/pkg/cache/cache_test.go new file mode 100644 index 0000000..2e347da --- /dev/null +++ b/internal/pkg/cache/cache_test.go @@ -0,0 +1,61 @@ +package cache_test + +import ( + "testing" + "time" + + "anchorage/internal/pkg/cache" +) + +func TestCacheRoundTrip(t *testing.T) { + c, err := cache.New[string, int](cache.Options{ + Name: "test", + MaxCost: 1 << 20, // 1 MiB + DefaultTTL: time.Minute, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + defer c.Close() + + if !c.Set("a", 42, 1, 0) { + t.Fatal("Set returned false for fresh key") + } + c.Wait() + + v, ok := c.Get("a") + if !ok { + t.Fatal("Get missed after Set") + } + if v != 42 { + t.Errorf("Get = %d, want 42", v) + } + + c.Delete("a") + c.Wait() + if _, ok := c.Get("a"); ok { + t.Error("Get hit after Delete") + } +} + +func TestCacheRejectsBadConfig(t *testing.T) { + _, err := cache.New[string, string](cache.Options{Name: "bad", MaxCost: 0}) + if err == nil { + t.Error("expected error for MaxCost=0") + } +} + +func TestNameExposed(t *testing.T) { + c, _ := cache.New[string, int](cache.Options{Name: "pin/byid", MaxCost: 1 << 16}) + defer c.Close() + if c.Name() != "pin/byid" { + t.Errorf("Name = %q", c.Name()) + } +} + +func TestInvalidatorNilIsNoop(t *testing.T) { + var inv *cache.Invalidator + if err := inv.Emit("org", "org_xyz"); err != nil { + t.Errorf("Emit on nil Invalidator should be a no-op, got %v", err) + } +} diff --git a/internal/pkg/cache/invalidate.go b/internal/pkg/cache/invalidate.go new file mode 100644 index 0000000..15b4802 --- /dev/null +++ b/internal/pkg/cache/invalidate.go @@ -0,0 +1,97 @@ +package cache + +import ( + "context" + "fmt" + "log/slog" + + "github.com/nats-io/nats.go" +) + +// Subject prefix for cache invalidation events. The full subject is +// +// cache.invalidate.. +// +// where is "org", "node", "token", etc. Writers publish to the +// specific subject after a successful Postgres commit; every node's +// subscriber wildcards the prefix and drops matching cached entries. +const InvalidateSubjectPrefix = "cache.invalidate" + +// Invalidator publishes cache-invalidation events across the cluster. +// +// Each writer holds one Invalidator shared across all caches; after a +// successful mutation it calls Emit("org", orgID) and every node sees +// the message within a NATS RTT (typically <1ms in-cluster). +type Invalidator struct { + nc *nats.Conn +} + +// NewInvalidator wraps a connected nats.Conn. +func NewInvalidator(nc *nats.Conn) *Invalidator { + return &Invalidator{nc: nc} +} + +// Emit publishes cache.invalidate... The payload is empty — +// subscribers only need the subject to know which key to drop. +// +// Errors are returned rather than swallowed so the caller can decide +// whether to retry or log. For most write paths a best-effort log-only +// handling is fine because the cache entry will TTL out regardless. +func (i *Invalidator) Emit(entity, id string) error { + if i == nil || i.nc == nil { + return nil // no-op in tests / standalone mode + } + subject := fmt.Sprintf("%s.%s.%s", InvalidateSubjectPrefix, entity, id) + return i.nc.Publish(subject, nil) +} + +// Subscriber wires a NATS subscription to a cache's Delete method. +// +// The returned subscription must be drained / unsubscribed at shutdown. +type Subscriber interface { + Unsubscribe() error +} + +// WatchEntity starts a subscription that calls onInvalidate(id) for every +// message arriving on cache.invalidate..*. +// +// Intended to be called once per cache per node during startup. +func WatchEntity(ctx context.Context, nc *nats.Conn, entity string, onInvalidate func(id string)) (Subscriber, error) { + subject := fmt.Sprintf("%s.%s.*", InvalidateSubjectPrefix, entity) + sub, err := nc.Subscribe(subject, func(m *nats.Msg) { + // Subject is "cache.invalidate.." — the ID is the + // trailing token. + tokens := splitSubject(m.Subject) + if len(tokens) < 4 { + slog.Warn("invalidate: malformed subject", "subject", m.Subject) + return + } + onInvalidate(tokens[len(tokens)-1]) + }) + if err != nil { + return nil, fmt.Errorf("subscribe %s: %w", subject, err) + } + + // Tie unsubscription to ctx cancellation so the caller doesn't have + // to plumb sub.Unsubscribe() manually if they're already managing + // lifecycles via context. + go func() { + <-ctx.Done() + _ = sub.Unsubscribe() + }() + + return sub, nil +} + +func splitSubject(s string) []string { + out := make([]string, 0, 4) + start := 0 + for i := 0; i < len(s); i++ { + if s[i] == '.' { + out = append(out, s[start:i]) + start = i + 1 + } + } + out = append(out, s[start:]) + return out +} diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 0000000..e4bbf1b --- /dev/null +++ b/internal/pkg/config/config.go @@ -0,0 +1,336 @@ +// Package config loads and validates anchorage's runtime configuration. +// +// The Config struct mirrors configs/anchorage.example.yaml. Values flow in +// from three sources, in order of increasing priority: +// +// 1. Baked-in defaults (setDefaults). +// 2. A YAML/TOML file located via --config or the default search path. +// 3. Environment variables prefixed ANCHORAGE_ (e.g., ANCHORAGE_SERVER_PORT). +// +// All exported Config types use mapstructure tags matching camelCase YAML +// keys. time.Duration fields are parsed from strings like "30s" or "1h". +package config + +import ( + "errors" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/viper" +) + +// Config is the fully-resolved anchorage configuration. +type Config struct { + Server ServerConfig `mapstructure:"server"` + Node NodeConfig `mapstructure:"node"` + IPFS IPFSConfig `mapstructure:"ipfs"` + Cluster ClusterConfig `mapstructure:"cluster"` + Postgres PostgresConfig `mapstructure:"postgres"` + NATS NATSConfig `mapstructure:"nats"` + Auth AuthConfig `mapstructure:"auth"` + Bootstrap BootstrapConfig `mapstructure:"bootstrap"` + Logging LoggingConfig `mapstructure:"logging"` +} + +// ServerConfig controls the HTTP listener. +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + ReadTimeout time.Duration `mapstructure:"readTimeout"` + WriteTimeout time.Duration `mapstructure:"writeTimeout"` + Metrics MetricsConfig `mapstructure:"metrics"` + RateLimit RateLimitConfig `mapstructure:"rateLimit"` +} + +// MetricsConfig scopes the Prometheus /metrics endpoint. +type MetricsConfig struct { + // AllowCIDRs is the scraper allowlist checked against the direct + // TCP peer IP. Unset → loopback + RFC1918 defaults. + // Explicit empty list (`[]`) → no restriction (rely on firewall). + AllowCIDRs []string `mapstructure:"allowCIDRs"` +} + +// RateLimitConfig scopes the session + anonymous rate limiters. +type RateLimitConfig struct { + // SessionPerMinute caps POST /v1/auth/session attempts per IP. + // Zero → default (10). + SessionPerMinute int `mapstructure:"sessionPerMinute"` + // AnonymousPerMinute caps unauthenticated requests per IP. + // Authenticated requests are exempt. Zero → default (120). + AnonymousPerMinute int `mapstructure:"anonymousPerMinute"` +} + +// NodeConfig identifies this anchorage instance within the cluster and +// declares the libp2p multiaddrs advertised for its paired Kubo daemon. +// +// ID is the operator-friendly display name (hostname by default). The +// actual cluster-unique TypeID is persisted under StateDir/node.id so it +// stays stable across restarts even when ID is a non-TypeID string. +type NodeConfig struct { + ID string `mapstructure:"id"` + Multiaddrs []string `mapstructure:"multiaddrs"` + StateDir string `mapstructure:"stateDir"` +} + +// IPFSConfig points at the local paired Kubo RPC endpoint. +type IPFSConfig struct { + RPC string `mapstructure:"rpc"` + Timeout time.Duration `mapstructure:"timeout"` + Reconciler ReconcilerConfig `mapstructure:"reconciler"` +} + +// ReconcilerConfig controls the per-node Kubo-vs-Postgres drift checker. +type ReconcilerConfig struct { + Interval time.Duration `mapstructure:"interval"` + AutoRepair bool `mapstructure:"autoRepair"` +} + +// ClusterConfig governs replication, heartbeats, rebalancing, and maintenance. +type ClusterConfig struct { + MinReplicas int `mapstructure:"minReplicas"` + HeartbeatInterval time.Duration `mapstructure:"heartbeatInterval"` + DownAfter time.Duration `mapstructure:"downAfter"` + RebalanceInterval time.Duration `mapstructure:"rebalanceInterval"` + AutoRepair bool `mapstructure:"autoRepair"` + DrainGracePeriod time.Duration `mapstructure:"drainGracePeriod"` + Maintenance MaintenanceConfig `mapstructure:"maintenance"` +} + +// MaintenanceConfig tunes cluster-wide maintenance safety rails. +type MaintenanceConfig struct { + MaxDuration time.Duration `mapstructure:"maxDuration"` +} + +// PostgresConfig wires the source-of-truth database. +type PostgresConfig struct { + // DSN selects the backend. Leave empty to force the in-memory + // store (dev / tests / CI). A non-empty DSN switches anchorage + // to the pgx-backed store. + DSN string `mapstructure:"dsn"` + MaxConns int `mapstructure:"maxConns"` + AutoMigrate bool `mapstructure:"autoMigrate"` + RequeueSweeper time.Duration `mapstructure:"requeueSweeper"` +} + +// NATSConfig wires the embedded NATS server and JetStream data plane. +type NATSConfig struct { + DataDir string `mapstructure:"dataDir"` + Client NATSClientConfig `mapstructure:"client"` + Cluster NATSClusterConfig `mapstructure:"cluster"` + JetStream NATSJetStreamConfig `mapstructure:"jetstream"` +} + +// NATSClientConfig is the client-facing listener (port 4222 by default). +type NATSClientConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +// NATSClusterConfig is the peer-to-peer listener used by JetStream. +type NATSClusterConfig struct { + Name string `mapstructure:"name"` + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Routes []string `mapstructure:"routes"` +} + +// NATSJetStreamConfig tunes stream replication. +type NATSJetStreamConfig struct { + Replicas int `mapstructure:"replicas"` +} + +// AuthConfig holds OIDC verifier and API-token signing parameters. +type AuthConfig struct { + Authentik AuthentikConfig `mapstructure:"authentik"` + APIToken APITokenConfig `mapstructure:"apiToken"` +} + +// AuthentikConfig points at the external OIDC provider. +type AuthentikConfig struct { + Issuer string `mapstructure:"issuer"` + ClientID string `mapstructure:"clientID"` + Audience string `mapstructure:"audience"` +} + +// APITokenConfig controls anchorage's own JWT minting. +// +// SigningKeys is a list of key entries, exactly one of which has +// `primary: true`. See deploy/README.md "Rotating the JWT signing key" +// for the rotation procedure. +// +// When SigningKeys is empty (dev / local testing), anchorage falls back +// to a built-in dev key with kid="dev" and logs a loud warning. +type APITokenConfig struct { + SigningKeys []SigningKeyConfig `mapstructure:"signingKeys"` + DefaultTTL time.Duration `mapstructure:"defaultTTL"` + MaxTTL time.Duration `mapstructure:"maxTTL"` +} + +// SigningKeyConfig is one entry in APITokenConfig.SigningKeys. +type SigningKeyConfig struct { + // Path is the absolute path to the key file (>=32 bytes). + Path string `mapstructure:"path"` + // ID is the stable label emitted as the JWT `kid` header and used + // by Parse to look up which key verified a given token. + ID string `mapstructure:"id"` + // Primary marks the key anchorage mints new tokens with. Exactly + // one entry must have this set. + Primary bool `mapstructure:"primary"` +} + +// BootstrapConfig bootstraps sysadmins from static config on first login. +type BootstrapConfig struct { + Sysadmins []string `mapstructure:"sysadmins"` +} + +// LoggingConfig is consumed by internal/pkg/logging.Init. +type LoggingConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` + Source bool `mapstructure:"source"` + File string `mapstructure:"file"` + MaxSizeMB int `mapstructure:"maxSizeMB"` + MaxBackups int `mapstructure:"maxBackups"` + MaxAgeDays int `mapstructure:"maxAgeDays"` + Compress bool `mapstructure:"compress"` +} + +// LoadOptions controls Load's behavior. +type LoadOptions struct { + // Path is the explicit config file to read. Empty triggers the default + // search path (./configs, $HOME/.anchorage, /etc/anchorage). + Path string + // AllowMissing lets Load succeed when no config file is found; defaults + // and env overrides still apply. Useful for the `version` command and + // for tests. + AllowMissing bool +} + +// Load reads the configuration from disk (if present), overlays environment +// variables, and applies defaults. It does not call logging.Init — the caller +// owns that lifecycle so tests can stub it. +func Load(opts LoadOptions) (*Config, error) { + v := viper.New() + setDefaults(v) + + v.SetEnvPrefix("ANCHORAGE") + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + if opts.Path != "" { + v.SetConfigFile(opts.Path) + } else { + v.SetConfigName("anchorage") + v.SetConfigType("yaml") + v.AddConfigPath("./configs") + v.AddConfigPath(".") + if home, err := os.UserHomeDir(); err == nil { + v.AddConfigPath(home + "/.anchorage") + } + v.AddConfigPath("/etc/anchorage") + } + + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if errors.As(err, ¬Found) || os.IsNotExist(err) { + if !opts.AllowMissing { + return nil, fmt.Errorf("read config: %w", err) + } + } else { + return nil, fmt.Errorf("read config %s: %w", v.ConfigFileUsed(), err) + } + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + + applyHostnameFallback(&cfg) + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("invalid config: %w", err) + } + + return &cfg, nil +} + +// applyHostnameFallback sets Node.ID to the OS hostname when the operator +// did not override it. Done after Unmarshal so it cannot be masked by a +// stray empty-string value in the YAML. +func applyHostnameFallback(cfg *Config) { + if cfg.Node.ID != "" { + return + } + if h, err := os.Hostname(); err == nil && h != "" { + cfg.Node.ID = h + } +} + +// Validate enforces invariants that cannot be expressed as mapstructure +// tags. It runs after Load so callers can also re-validate after mutating +// the Config in-process (e.g., tests). +func (c *Config) Validate() error { + if c.Server.Port < 1 || c.Server.Port > 65535 { + return fmt.Errorf("server.port %d out of range", c.Server.Port) + } + if c.Cluster.MinReplicas < 1 { + return fmt.Errorf("cluster.minReplicas must be >= 1, got %d", c.Cluster.MinReplicas) + } + if c.Cluster.HeartbeatInterval <= 0 { + return errors.New("cluster.heartbeatInterval must be > 0") + } + if c.Cluster.DownAfter <= c.Cluster.HeartbeatInterval { + return fmt.Errorf("cluster.downAfter (%s) must exceed heartbeatInterval (%s)", + c.Cluster.DownAfter, c.Cluster.HeartbeatInterval) + } + if c.Cluster.DrainGracePeriod <= 0 { + return errors.New("cluster.drainGracePeriod must be > 0") + } + if c.IPFS.RPC == "" { + return errors.New("ipfs.rpc is required") + } + if _, err := url.Parse(c.IPFS.RPC); err != nil { + return fmt.Errorf("ipfs.rpc is not a valid URL: %w", err) + } + if c.NATS.Client.Port < 1 || c.NATS.Client.Port > 65535 { + return fmt.Errorf("nats.client.port %d out of range", c.NATS.Client.Port) + } + if c.NATS.Cluster.Port < 1 || c.NATS.Cluster.Port > 65535 { + return fmt.Errorf("nats.cluster.port %d out of range", c.NATS.Cluster.Port) + } + if c.Auth.APIToken.DefaultTTL <= 0 { + return errors.New("auth.apiToken.defaultTTL must be > 0") + } + if c.Auth.APIToken.MaxTTL < c.Auth.APIToken.DefaultTTL { + return fmt.Errorf("auth.apiToken.maxTTL (%s) must be >= defaultTTL (%s)", + c.Auth.APIToken.MaxTTL, c.Auth.APIToken.DefaultTTL) + } + if c.Node.ID == "" { + return errors.New("node.id is required (and hostname fallback failed)") + } + return nil +} + +// RequireServe returns an error if fields that are only required at +// `anchorage serve` time are missing. Lets other subcommands (migrate, +// admin) load a partial config without tripping over a missing DSN. +// +// Postgres DSN is intentionally NOT required here — omitting it is how +// an operator opts into the in-memory store for dev / demo. The logs +// will loudly warn that the memstore is not durable. +func (c *Config) RequireServe() error { + if c.Auth.Authentik.Issuer == "" { + return errors.New("auth.authentik.issuer is required for `anchorage serve`") + } + // Empty signingKeys is allowed — anchorage falls back to the + // built-in dev key with a loud warning. Production operators + // populate signingKeys with at least one primary entry. + if c.NATS.DataDir == "" { + return errors.New("nats.dataDir is required for `anchorage serve`") + } + return nil +} diff --git a/internal/pkg/config/config_test.go b/internal/pkg/config/config_test.go new file mode 100644 index 0000000..11f1d08 --- /dev/null +++ b/internal/pkg/config/config_test.go @@ -0,0 +1,237 @@ +package config_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "anchorage/internal/pkg/config" +) + +func TestLoadAllowMissingAppliesDefaults(t *testing.T) { + cfg, err := config.Load(config.LoadOptions{AllowMissing: true}) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.Port != 8080 { + t.Errorf("Server.Port = %d, want 8080", cfg.Server.Port) + } + if cfg.Cluster.MinReplicas != 1 { + t.Errorf("Cluster.MinReplicas = %d, want 1", cfg.Cluster.MinReplicas) + } + if cfg.Cluster.HeartbeatInterval != 5*time.Second { + t.Errorf("Cluster.HeartbeatInterval = %s, want 5s", cfg.Cluster.HeartbeatInterval) + } + if cfg.Cluster.DrainGracePeriod != 2*time.Minute { + t.Errorf("Cluster.DrainGracePeriod = %s, want 2m", cfg.Cluster.DrainGracePeriod) + } + if cfg.Cluster.Maintenance.MaxDuration != time.Hour { + t.Errorf("Cluster.Maintenance.MaxDuration = %s, want 1h", cfg.Cluster.Maintenance.MaxDuration) + } + if cfg.Logging.Level != "info" { + t.Errorf("Logging.Level = %q, want info", cfg.Logging.Level) + } + if cfg.Auth.APIToken.DefaultTTL != 24*time.Hour { + t.Errorf("Auth.APIToken.DefaultTTL = %s, want 24h", cfg.Auth.APIToken.DefaultTTL) + } + // 1 year + 30-day grace = 395 days = 9480h. Long-lived IPFS client + // tokens (minted via `anchorage admin mint-token`) use this as their + // ceiling so CI / service identities can run for a year before rotation. + if cfg.Auth.APIToken.MaxTTL != 9480*time.Hour { + t.Errorf("Auth.APIToken.MaxTTL = %s, want 9480h (1y + 30d grace)", cfg.Auth.APIToken.MaxTTL) + } +} + +func TestLoadReadsYAMLFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "anchorage.yaml") + yaml := ` +server: + port: 9090 + readTimeout: 20s +cluster: + minReplicas: 3 + heartbeatInterval: 2s + downAfter: 15s + drainGracePeriod: 30s + maintenance: + maxDuration: 45m +ipfs: + rpc: http://kubo.local:5001 +node: + id: node-alpha +auth: + apiToken: + defaultTTL: 12h + maxTTL: 168h +logging: + level: debug + format: json +` + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + cfg, err := config.Load(config.LoadOptions{Path: path}) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.Port != 9090 { + t.Errorf("Server.Port = %d, want 9090", cfg.Server.Port) + } + if cfg.Server.ReadTimeout != 20*time.Second { + t.Errorf("Server.ReadTimeout = %s, want 20s", cfg.Server.ReadTimeout) + } + if cfg.Cluster.MinReplicas != 3 { + t.Errorf("Cluster.MinReplicas = %d, want 3", cfg.Cluster.MinReplicas) + } + if cfg.Cluster.Maintenance.MaxDuration != 45*time.Minute { + t.Errorf("Cluster.Maintenance.MaxDuration = %s, want 45m", cfg.Cluster.Maintenance.MaxDuration) + } + if cfg.Node.ID != "node-alpha" { + t.Errorf("Node.ID = %q, want node-alpha", cfg.Node.ID) + } + if cfg.Logging.Format != "json" { + t.Errorf("Logging.Format = %q, want json", cfg.Logging.Format) + } +} + +func TestEnvOverridesYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "anchorage.yaml") + yaml := ` +server: + port: 9090 +node: + id: node-from-yaml +auth: + apiToken: + defaultTTL: 12h + maxTTL: 24h +ipfs: + rpc: http://localhost:5001 +cluster: + minReplicas: 1 + heartbeatInterval: 5s + downAfter: 30s + drainGracePeriod: 2m +` + if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil { + t.Fatalf("write yaml: %v", err) + } + + t.Setenv("ANCHORAGE_SERVER_PORT", "12345") + t.Setenv("ANCHORAGE_NODE_ID", "node-from-env") + + cfg, err := config.Load(config.LoadOptions{Path: path}) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if cfg.Server.Port != 12345 { + t.Errorf("Server.Port = %d, want 12345 (env override)", cfg.Server.Port) + } + if cfg.Node.ID != "node-from-env" { + t.Errorf("Node.ID = %q, want node-from-env (env override)", cfg.Node.ID) + } +} + +func TestValidateRejectsBadReplication(t *testing.T) { + cfg := validBaseConfig() + cfg.Cluster.MinReplicas = 0 + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for minReplicas=0") + } +} + +func TestValidateRejectsDownAfterSmallerThanHeartbeat(t *testing.T) { + cfg := validBaseConfig() + cfg.Cluster.HeartbeatInterval = 10 * time.Second + cfg.Cluster.DownAfter = 5 * time.Second + if err := cfg.Validate(); err == nil { + t.Error("expected validation error when downAfter <= heartbeatInterval") + } +} + +func TestValidateRejectsMaxTTLSmallerThanDefault(t *testing.T) { + cfg := validBaseConfig() + cfg.Auth.APIToken.DefaultTTL = 48 * time.Hour + cfg.Auth.APIToken.MaxTTL = 24 * time.Hour + if err := cfg.Validate(); err == nil { + t.Error("expected validation error when maxTTL < defaultTTL") + } +} + +func TestRequireServeDSNOptional(t *testing.T) { + // An empty DSN is valid — it selects the in-memory store for + // dev / demo. RequireServe should succeed as long as the other + // mandatory fields are set. + cfg := validBaseConfig() + cfg.Postgres.DSN = "" + if err := cfg.RequireServe(); err != nil { + t.Errorf("RequireServe with empty DSN should succeed (dev mode): %v", err) + } +} + +func TestRequireServeRejectsMissingAuth(t *testing.T) { + cfg := validBaseConfig() + cfg.Auth.Authentik.Issuer = "" + if err := cfg.RequireServe(); err == nil { + t.Error("expected RequireServe to demand auth.authentik.issuer") + } +} + +// validBaseConfig returns a minimally valid Config so tests can mutate a +// single field to exercise one validation rule at a time. +func validBaseConfig() *config.Config { + return &config.Config{ + Server: config.ServerConfig{ + Host: "0.0.0.0", + Port: 8080, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + }, + Node: config.NodeConfig{ID: "test-node"}, + IPFS: config.IPFSConfig{ + RPC: "http://localhost:5001", + Timeout: 2 * time.Minute, + }, + Cluster: config.ClusterConfig{ + MinReplicas: 1, + HeartbeatInterval: 5 * time.Second, + DownAfter: 30 * time.Second, + RebalanceInterval: time.Minute, + DrainGracePeriod: 2 * time.Minute, + Maintenance: config.MaintenanceConfig{MaxDuration: time.Hour}, + }, + Postgres: config.PostgresConfig{ + DSN: "postgres://u:p@h/d?sslmode=disable", + MaxConns: 20, + AutoMigrate: true, + RequeueSweeper: 30 * time.Second, + }, + NATS: config.NATSConfig{ + DataDir: "/tmp/anchorage-nats", + Client: config.NATSClientConfig{Host: "0.0.0.0", Port: 4222}, + Cluster: config.NATSClusterConfig{Name: "anchorage", Host: "0.0.0.0", Port: 6222}, + JetStream: config.NATSJetStreamConfig{Replicas: 3}, + }, + Auth: config.AuthConfig{ + Authentik: config.AuthentikConfig{ + Issuer: "https://auth.example.com/", + ClientID: "anchorage-web", + Audience: "anchorage", + }, + APIToken: config.APITokenConfig{ + SigningKeys: []config.SigningKeyConfig{ + {ID: "test", Path: "/etc/anchorage/jwt.key", Primary: true}, + }, + DefaultTTL: 24 * time.Hour, + MaxTTL: 720 * time.Hour, + }, + }, + } +} diff --git a/internal/pkg/config/defaults.go b/internal/pkg/config/defaults.go new file mode 100644 index 0000000..71f6d7e --- /dev/null +++ b/internal/pkg/config/defaults.go @@ -0,0 +1,70 @@ +package config + +import "github.com/spf13/viper" + +// setDefaults applies the baked-in defaults. Keep in lockstep with +// configs/anchorage.example.yaml — the example file should never document +// a value that differs silently from these defaults. +func setDefaults(v *viper.Viper) { + // Server + v.SetDefault("server.host", "0.0.0.0") + v.SetDefault("server.port", 8080) + v.SetDefault("server.readTimeout", "10s") + v.SetDefault("server.writeTimeout", "30s") + // server.metrics.allowCIDRs intentionally has NO default set via Viper — + // nil is how we signal "fall through to metrics.DefaultAllowCIDRs". + // Explicit `[]` in YAML → operator-chosen allow-all. + v.SetDefault("server.rateLimit.sessionPerMinute", 10) + v.SetDefault("server.rateLimit.anonymousPerMinute", 120) + + // Node — id falls back to OS hostname in applyHostnameFallback. + v.SetDefault("node.multiaddrs", []string{}) + v.SetDefault("node.stateDir", "/var/lib/anchorage") + + // IPFS (local paired Kubo) + v.SetDefault("ipfs.rpc", "http://localhost:5001") + v.SetDefault("ipfs.timeout", "2m") + v.SetDefault("ipfs.reconciler.interval", "10m") + v.SetDefault("ipfs.reconciler.autoRepair", false) + + // Cluster — safe single-node defaults; operators bump minReplicas in HA. + v.SetDefault("cluster.minReplicas", 1) + v.SetDefault("cluster.heartbeatInterval", "5s") + v.SetDefault("cluster.downAfter", "30s") + v.SetDefault("cluster.rebalanceInterval", "1m") + v.SetDefault("cluster.autoRepair", false) + v.SetDefault("cluster.drainGracePeriod", "2m") + v.SetDefault("cluster.maintenance.maxDuration", "1h") + + // Postgres + v.SetDefault("postgres.maxConns", 20) + v.SetDefault("postgres.autoMigrate", true) + v.SetDefault("postgres.requeueSweeper", "30s") + + // NATS + v.SetDefault("nats.client.host", "0.0.0.0") + v.SetDefault("nats.client.port", 4222) + v.SetDefault("nats.cluster.name", "anchorage") + v.SetDefault("nats.cluster.host", "0.0.0.0") + v.SetDefault("nats.cluster.port", 6222) + v.SetDefault("nats.cluster.routes", []string{}) + v.SetDefault("nats.jetstream.replicas", 3) + + // Auth — TTLs only; keys/issuers must be provided by the operator. + // + // defaultTTL is for interactive / web-session tokens (short). + // maxTTL = 395 days (1 year + 30-day grace) accommodates IPFS + // client tokens issued to long-lived CLI / CI / service identities + // — see `anchorage admin mint-token` for the break-glass path. + v.SetDefault("auth.apiToken.defaultTTL", "24h") + v.SetDefault("auth.apiToken.maxTTL", "9480h") + + // Logging — text to stderr by default; file is opt-in. + v.SetDefault("logging.level", "info") + v.SetDefault("logging.format", "text") + v.SetDefault("logging.source", false) + v.SetDefault("logging.maxSizeMB", 100) + v.SetDefault("logging.maxBackups", 10) + v.SetDefault("logging.maxAgeDays", 30) + v.SetDefault("logging.compress", true) +} diff --git a/internal/pkg/httpserver/middleware.go b/internal/pkg/httpserver/middleware.go new file mode 100644 index 0000000..2d4682e --- /dev/null +++ b/internal/pkg/httpserver/middleware.go @@ -0,0 +1,49 @@ +package httpserver + +import ( + "log/slog" + "net/http" + "time" + + "github.com/gofiber/fiber/v2" + + "anchorage/internal/pkg/metrics" +) + +// accessLog is a minimal, slog-friendly request logger that also feeds +// the Prometheus HTTP request counter. Full structured logging with +// tenant + user attribution lives in the auth middleware once a JWT +// is validated. +// +// /metrics itself is skipped — self-observing scrapes would show up as +// a ton of 200s that tell the operator nothing. +func accessLog() fiber.Handler { + return func(c *fiber.Ctx) error { + start := time.Now() + err := c.Next() + dur := time.Since(start) + + status := c.Response().StatusCode() + lvl := slog.LevelInfo + if status >= http.StatusInternalServerError { + lvl = slog.LevelError + } else if status >= http.StatusBadRequest { + lvl = slog.LevelWarn + } + + slog.Log(c.UserContext(), lvl, "http", + "method", c.Method(), + "path", c.Path(), + "status", status, + "duration_ms", dur.Milliseconds(), + "request_id", c.Get(fiber.HeaderXRequestID), + "remote", c.IP()) + + if c.Path() != "/metrics" { + metrics.HTTPRequests. + WithLabelValues(c.Method(), metrics.StatusClass(status)). + Inc() + } + return err + } +} diff --git a/internal/pkg/httpserver/ratelimit.go b/internal/pkg/httpserver/ratelimit.go new file mode 100644 index 0000000..360da76 --- /dev/null +++ b/internal/pkg/httpserver/ratelimit.go @@ -0,0 +1,70 @@ +package httpserver + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + + "anchorage/internal/pkg/auth" +) + +// SessionLimiter is the brute-force guard on `POST /v1/auth/session`. +// Applies only to that specific method+path via Fiber's `Next` skip +// function — so you can install it globally with `app.Use(...)` and +// have it no-op on every other request. +// +// perMinute == 0 falls back to 10 attempts per IP per minute. +func SessionLimiter(perMinute int) fiber.Handler { + if perMinute <= 0 { + perMinute = 10 + } + return limiter.New(limiter.Config{ + Max: perMinute, + Expiration: time.Minute, + Next: func(c *fiber.Ctx) bool { + return !(c.Method() == fiber.MethodPost && c.Path() == "/v1/auth/session") + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Too Many Requests", + "message": "too many session attempts; slow down", + }) + }, + }) +} + +// AnonymousLimiter caps unauthenticated requests per IP across the whole +// API. Authenticated requests (those carrying a valid Bearer or session +// cookie the BearerMiddleware already resolved) skip the limiter. Probe +// paths (/health, /ready, /metrics) are also exempt. +// +// perMinute == 0 falls back to 120 — a generous bound for a single IP +// hitting /openapi.json, /docs, and unauthenticated pre-login traffic. +func AnonymousLimiter(perMinute int) fiber.Handler { + if perMinute <= 0 { + perMinute = 120 + } + return limiter.New(limiter.Config{ + Max: perMinute, + Expiration: time.Minute, + Next: func(c *fiber.Ctx) bool { + // Skip health / metrics — orchestrator probes hit these far + // more often than the per-minute bound and we don't want to + // DoS ourselves. + switch c.Path() { + case "/v1/health", "/v1/ready", "/metrics", "/lbz": + return true + } + // Skip authenticated traffic — real API clients burst + // legitimately and we don't want to throttle them here. + return auth.FromContext(c.UserContext()) != nil + }, + LimitReached: func(c *fiber.Ctx) error { + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Too Many Requests", + "message": "anonymous request rate exceeded", + }) + }, + }) +} diff --git a/internal/pkg/httpserver/server.go b/internal/pkg/httpserver/server.go new file mode 100644 index 0000000..bd125c4 --- /dev/null +++ b/internal/pkg/httpserver/server.go @@ -0,0 +1,91 @@ +// Package httpserver composes the Fiber app and its middleware stack. +// +// The actual route tree is registered by internal/pkg/openapi through the +// humafiber adapter, so this package only owns lifecycle: configuration, +// graceful start, graceful shutdown, and basic observability middleware. +package httpserver + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" + + "anchorage/internal/pkg/metrics" +) + +// Options configures a Server. +type Options struct { + Host string + Port int + ReadTimeout time.Duration + WriteTimeout time.Duration + + // MetricsACLCIDRs is the allowlist for /metrics. Nil → use the + // metrics package default (loopback + RFC1918). Explicit empty + // slice → no restriction (firewall-only). + MetricsACLCIDRs []string +} + +// Server wraps *fiber.App with lifecycle helpers. +type Server struct { + App *fiber.App + opts Options +} + +// New constructs a Server with the standard anchorage middleware stack. +// +// The returned *fiber.App is exposed on Server.App so callers can register +// routes before Start is called. +func New(opts Options) *Server { + app := fiber.New(fiber.Config{ + AppName: "anchorage", + DisableStartupMessage: true, + ReadTimeout: opts.ReadTimeout, + WriteTimeout: opts.WriteTimeout, + ErrorHandler: func(c *fiber.Ctx, err error) error { + // Fiber default returns an HTML body; JSON is more useful + // for our API clients. + code := fiber.StatusInternalServerError + if fe, ok := err.(*fiber.Error); ok { + code = fe.Code + } + return c.Status(code).JSON(fiber.Map{ + "error": http.StatusText(code), + "message": err.Error(), + }) + }, + }) + + app.Use(recover.New()) + app.Use(requestid.New()) + app.Use(accessLog()) + + // /metrics lives at root (NOT under /v1) so Prometheus scrapers + // find it at the conventional path. Gated by a CIDR ACL — see + // metrics.ACL and MetricsConfig.AllowCIDRs. + if acl, err := metrics.ACL(opts.MetricsACLCIDRs); err != nil { + slog.Error("httpserver: bad metrics ACL; /metrics disabled", "err", err) + } else { + app.Get("/metrics", acl, metrics.Handler()) + } + + return &Server{App: app, opts: opts} +} + +// Start binds and listens. Returns only on listener error. +func (s *Server) Start(_ context.Context) error { + addr := fmt.Sprintf("%s:%d", s.opts.Host, s.opts.Port) + slog.Info("httpserver: listening", "addr", addr) + return s.App.Listen(addr) +} + +// Shutdown drains in-flight requests with a bounded grace period. +func (s *Server) Shutdown(ctx context.Context) error { + return s.App.ShutdownWithContext(ctx) +} diff --git a/internal/pkg/ids/ids.go b/internal/pkg/ids/ids.go new file mode 100644 index 0000000..cf2e514 --- /dev/null +++ b/internal/pkg/ids/ids.go @@ -0,0 +1,145 @@ +// Package ids defines anchorage's typed identifier system. +// +// Every domain entity gets its own compile-time-distinct ID type built on +// the TypeID spec (https://github.com/jetify-com/typeid-go): a human-readable +// prefix joined to a base32-encoded UUIDv7 by an underscore. For example: +// +// org_01h7rfxv6qefr4twn2jg5p4k3z +// pin_01h7rfxz5pehmy08b3tybczg2y +// +// Properties: +// +// - UUIDv7 is time-ordered, giving Postgres index locality close to a +// sequential bigint without the coordination cost. +// - The prefix is self-describing in logs, audit rows, and UIs, and is +// enforced by a per-table CHECK constraint in the schema. +// - Each subtype (OrgID, PinID, ...) is a distinct Go type. Passing an +// OrgID where a PinID is expected is a compile-time error. +// +// To accept an ID on an API boundary, use the matching Parse* helper — it +// will reject a value with the wrong prefix, giving tenant isolation a +// second layer of defense at the type level. +package ids + +import ( + "fmt" + + "go.jetify.com/typeid" +) + +// Entity prefix tokens. Kept as constants so schema migrations, CHECK +// constraints, and log filters can reference a single source of truth. +const ( + OrgPrefixToken = "org" + UserPrefixToken = "usr" + PinPrefixToken = "pin" + TokenPrefixToken = "tok" + NodePrefixToken = "nod" +) + +// OrgPrefix tags an Organization ID. +type OrgPrefix struct{} + +// Prefix returns OrgPrefixToken. +func (OrgPrefix) Prefix() string { return OrgPrefixToken } + +// UserPrefix tags a User ID. +type UserPrefix struct{} + +// Prefix returns UserPrefixToken. +func (UserPrefix) Prefix() string { return UserPrefixToken } + +// PinPrefix tags a Pin request ID. +type PinPrefix struct{} + +// Prefix returns PinPrefixToken. +func (PinPrefix) Prefix() string { return PinPrefixToken } + +// TokenPrefix tags an API-token JTI. +type TokenPrefix struct{} + +// Prefix returns TokenPrefixToken. +func (TokenPrefix) Prefix() string { return TokenPrefixToken } + +// NodePrefix tags a cluster Node ID. +type NodePrefix struct{} + +// Prefix returns NodePrefixToken. +func (NodePrefix) Prefix() string { return NodePrefixToken } + +// OrgID identifies an Organization. +type OrgID struct{ typeid.TypeID[OrgPrefix] } + +// UserID identifies a User. +type UserID struct{ typeid.TypeID[UserPrefix] } + +// PinID identifies a pin request (returned to clients as PinStatus.requestid). +type PinID struct{ typeid.TypeID[PinPrefix] } + +// TokenID identifies an API token (the JWT's jti claim). +type TokenID struct{ typeid.TypeID[TokenPrefix] } + +// NodeID identifies a cluster node. +type NodeID struct{ typeid.TypeID[NodePrefix] } + +// NewOrg returns a fresh OrgID backed by a random UUIDv7. +func NewOrg() (OrgID, error) { return typeid.New[OrgID]() } + +// NewUser returns a fresh UserID backed by a random UUIDv7. +func NewUser() (UserID, error) { return typeid.New[UserID]() } + +// NewPin returns a fresh PinID backed by a random UUIDv7. +func NewPin() (PinID, error) { return typeid.New[PinID]() } + +// NewToken returns a fresh TokenID backed by a random UUIDv7. +func NewToken() (TokenID, error) { return typeid.New[TokenID]() } + +// NewNode returns a fresh NodeID backed by a random UUIDv7. +func NewNode() (NodeID, error) { return typeid.New[NodeID]() } + +// MustNewOrg is the panic-on-error companion of NewOrg. +// +// The underlying UUIDv7 generator only fails on a crypto/rand read error; +// if that happens, the process is in a state where panicking is the right +// response, so call-site ergonomics win. +func MustNewOrg() OrgID { return typeid.Must(NewOrg()) } + +// MustNewUser is the panic-on-error companion of NewUser. +func MustNewUser() UserID { return typeid.Must(NewUser()) } + +// MustNewPin is the panic-on-error companion of NewPin. +func MustNewPin() PinID { return typeid.Must(NewPin()) } + +// MustNewToken is the panic-on-error companion of NewToken. +func MustNewToken() TokenID { return typeid.Must(NewToken()) } + +// MustNewNode is the panic-on-error companion of NewNode. +func MustNewNode() NodeID { return typeid.Must(NewNode()) } + +// ParseOrg parses s as an OrgID, rejecting any value that does not carry +// the "org" prefix. +func ParseOrg(s string) (OrgID, error) { return parseTyped[OrgID](s, OrgPrefixToken) } + +// ParseUser parses s as a UserID. +func ParseUser(s string) (UserID, error) { return parseTyped[UserID](s, UserPrefixToken) } + +// ParsePin parses s as a PinID. +func ParsePin(s string) (PinID, error) { return parseTyped[PinID](s, PinPrefixToken) } + +// ParseToken parses s as a TokenID. +func ParseToken(s string) (TokenID, error) { return parseTyped[TokenID](s, TokenPrefixToken) } + +// ParseNode parses s as a NodeID. +func ParseNode(s string) (NodeID, error) { return parseTyped[NodeID](s, NodePrefixToken) } + +// parseTyped is a small wrapper that normalises the "wrong prefix" error so +// callers can print a useful message without unwrapping typeid's internal +// validation error type. +func parseTyped[T typeid.Subtype, PT typeid.SubtypePtr[T]](s, want string) (T, error) { + id, err := typeid.Parse[T, PT](s) + if err != nil { + var zero T + return zero, fmt.Errorf("parse %s id %q: %w", want, s, err) + } + return id, nil +} diff --git a/internal/pkg/ids/ids_test.go b/internal/pkg/ids/ids_test.go new file mode 100644 index 0000000..a2a64dd --- /dev/null +++ b/internal/pkg/ids/ids_test.go @@ -0,0 +1,144 @@ +package ids_test + +import ( + "strings" + "testing" + + "anchorage/internal/pkg/ids" +) + +func TestNewConstructorsReturnCorrectPrefixes(t *testing.T) { + tests := []struct { + name string + gen func(t *testing.T) (string, string) + prefix string + }{ + {"org", func(t *testing.T) (string, string) { + id, err := ids.NewOrg() + if err != nil { + t.Fatalf("NewOrg: %v", err) + } + return id.Prefix(), id.String() + }, ids.OrgPrefixToken}, + {"user", func(t *testing.T) (string, string) { + id, err := ids.NewUser() + if err != nil { + t.Fatalf("NewUser: %v", err) + } + return id.Prefix(), id.String() + }, ids.UserPrefixToken}, + {"pin", func(t *testing.T) (string, string) { + id, err := ids.NewPin() + if err != nil { + t.Fatalf("NewPin: %v", err) + } + return id.Prefix(), id.String() + }, ids.PinPrefixToken}, + {"token", func(t *testing.T) (string, string) { + id, err := ids.NewToken() + if err != nil { + t.Fatalf("NewToken: %v", err) + } + return id.Prefix(), id.String() + }, ids.TokenPrefixToken}, + {"node", func(t *testing.T) (string, string) { + id, err := ids.NewNode() + if err != nil { + t.Fatalf("NewNode: %v", err) + } + return id.Prefix(), id.String() + }, ids.NodePrefixToken}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + prefix, str := tt.gen(t) + if prefix != tt.prefix { + t.Errorf("Prefix() = %q, want %q", prefix, tt.prefix) + } + if !strings.HasPrefix(str, tt.prefix+"_") { + t.Errorf("String() = %q, want prefix %q", str, tt.prefix+"_") + } + }) + } +} + +func TestMustConstructorsDoNotPanicInHappyPath(t *testing.T) { + // Smoke-test: they must return the same prefix as the error-returning + // counterpart, and not panic under normal operation. + if p := ids.MustNewOrg().Prefix(); p != ids.OrgPrefixToken { + t.Errorf("MustNewOrg prefix = %q", p) + } + if p := ids.MustNewUser().Prefix(); p != ids.UserPrefixToken { + t.Errorf("MustNewUser prefix = %q", p) + } + if p := ids.MustNewPin().Prefix(); p != ids.PinPrefixToken { + t.Errorf("MustNewPin prefix = %q", p) + } + if p := ids.MustNewToken().Prefix(); p != ids.TokenPrefixToken { + t.Errorf("MustNewToken prefix = %q", p) + } + if p := ids.MustNewNode().Prefix(); p != ids.NodePrefixToken { + t.Errorf("MustNewNode prefix = %q", p) + } +} + +func TestIDsAreUniquePerCall(t *testing.T) { + // UUIDv7 suffixes must differ between calls even back-to-back. + a := ids.MustNewPin().String() + b := ids.MustNewPin().String() + if a == b { + t.Fatalf("two consecutive NewPin() calls produced the same id: %s", a) + } +} + +func TestIDsAreTimeOrdered(t *testing.T) { + // UUIDv7 is time-ordered, so later-generated IDs should sort after + // earlier ones lexicographically. This is the property that gives + // Postgres index locality on (org_id, id desc). + prev := ids.MustNewPin().String() + for i := 0; i < 32; i++ { + next := ids.MustNewPin().String() + if next <= prev { + t.Fatalf("UUIDv7 ordering violated: %q followed %q", next, prev) + } + prev = next + } +} + +func TestParseRoundTrip(t *testing.T) { + orig := ids.MustNewOrg() + parsed, err := ids.ParseOrg(orig.String()) + if err != nil { + t.Fatalf("ParseOrg: %v", err) + } + if parsed.String() != orig.String() { + t.Errorf("round-trip changed value: %q -> %q", orig, parsed) + } +} + +func TestParseRejectsWrongPrefix(t *testing.T) { + // An org ID string must not parse as a pin ID. + orgStr := ids.MustNewOrg().String() + if _, err := ids.ParsePin(orgStr); err == nil { + t.Errorf("ParsePin accepted an org id %q", orgStr) + } +} + +func TestParseRejectsGarbage(t *testing.T) { + tests := []string{ + "", + "not-a-typeid", + "org_", + "_01h7rfxv6qefr4twn2jg5p4k3z", + "org_too-short", + "org_01h7rfxv6qefr4twn2jg5p4k3Z", // uppercase base32 char: invalid + } + for _, s := range tests { + t.Run(s, func(t *testing.T) { + if _, err := ids.ParseOrg(s); err == nil { + t.Errorf("ParseOrg(%q) should have failed", s) + } + }) + } +} diff --git a/internal/pkg/ipfs/ipfs.go b/internal/pkg/ipfs/ipfs.go new file mode 100644 index 0000000..b6ec911 --- /dev/null +++ b/internal/pkg/ipfs/ipfs.go @@ -0,0 +1,53 @@ +// Package ipfs is the abstraction anchorage uses to reach its paired IPFS +// backend (currently Kubo over HTTP RPC; potentially embedded Kubo or +// ipfs-cluster later). Everything above this layer works in terms of the +// Backend interface so swapping implementations needs no touch anywhere +// else. +package ipfs + +import ( + "context" + "errors" +) + +// ErrNotFound is the canonical "CID isn't pinned / doesn't exist" sentinel. +var ErrNotFound = errors.New("ipfs: not found") + +// PinStatus reports the backend's view of a single CID. +type PinStatus struct { + CID string + Pinned bool + // Type is the backend-reported pin kind ("recursive" for full DAG + // pins, "direct" for shallow, "indirect" for transitive children). + Type string +} + +// Backend is the operation surface anchorage uses against its paired +// IPFS daemon. Implementations must be safe for concurrent use. +type Backend interface { + // Pin instructs the backend to hold the given CID. Idempotent. + // Origins may be passed as peer multiaddrs the backend should try + // first for bitswap (spec: PinAddRequest.origins). + Pin(ctx context.Context, cid string, origins []string) error + + // Unpin instructs the backend to drop the pin. Idempotent. + Unpin(ctx context.Context, cid string) error + + // Status reports whether the CID is locally pinned and how. + Status(ctx context.Context, cid string) (*PinStatus, error) + + // List returns every CID currently pinned locally. Used by the + // reconciler to diff against Postgres. + List(ctx context.Context) ([]string, error) + + // ID returns the backend's libp2p peer info, notably its multiaddrs. + // anchorage uses this to populate the nodes.multiaddrs column if + // the operator didn't pre-fill it in config. + ID(ctx context.Context) (*IDInfo, error) +} + +// IDInfo is the minimal view of /api/v0/id anchorage needs. +type IDInfo struct { + PeerID string + Addresses []string +} diff --git a/internal/pkg/ipfs/rpc/client.go b/internal/pkg/ipfs/rpc/client.go new file mode 100644 index 0000000..1b45338 --- /dev/null +++ b/internal/pkg/ipfs/rpc/client.go @@ -0,0 +1,172 @@ +// Package rpc is the Kubo HTTP RPC backend implementation. +// +// It speaks to Kubo's /api/v0/* endpoints using a plain http.Client rather +// than kubo's own Go SDK, which pulls in half of libp2p for features +// anchorage doesn't need. The RPC protocol is stable and small — six +// endpoints cover everything we use. +package rpc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "anchorage/internal/pkg/ipfs" +) + +// Client is a Kubo RPC client. +type Client struct { + base *url.URL + hc *http.Client +} + +// Options configures a Client. +type Options struct { + // Endpoint is the Kubo RPC base URL, e.g. http://localhost:5001. + Endpoint string + // Timeout applies to every RPC call. 0 picks a safe default. + Timeout time.Duration +} + +// New builds a Client pointed at the given endpoint. +func New(opts Options) (*Client, error) { + if opts.Endpoint == "" { + return nil, fmt.Errorf("rpc: Endpoint is required") + } + u, err := url.Parse(opts.Endpoint) + if err != nil { + return nil, fmt.Errorf("rpc: parse endpoint: %w", err) + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = 2 * time.Minute + } + return &Client{ + base: u, + hc: &http.Client{Timeout: timeout}, + }, nil +} + +// Pin implements ipfs.Backend. +// +// Kubo's pin/add is idempotent by design: pinning a CID that's already +// pinned returns success. +func (c *Client) Pin(ctx context.Context, cid string, origins []string) error { + q := url.Values{"arg": []string{cid}} + for _, o := range origins { + q.Add("origin", o) + } + return c.callEmpty(ctx, "pin/add", q) +} + +// Unpin implements ipfs.Backend. Kubo returns 500 if the CID isn't +// pinned; we translate that into ErrNotFound. +func (c *Client) Unpin(ctx context.Context, cid string) error { + q := url.Values{"arg": []string{cid}} + if err := c.callEmpty(ctx, "pin/rm", q); err != nil { + if strings.Contains(err.Error(), "not pinned") { + return ipfs.ErrNotFound + } + return err + } + return nil +} + +// Status implements ipfs.Backend. +func (c *Client) Status(ctx context.Context, cid string) (*ipfs.PinStatus, error) { + q := url.Values{"arg": []string{cid}} + var out struct { + Keys map[string]struct { + Type string `json:"Type"` + } `json:"Keys"` + } + err := c.call(ctx, "pin/ls", q, &out) + if err != nil { + if strings.Contains(err.Error(), "is not pinned") { + return &ipfs.PinStatus{CID: cid, Pinned: false}, nil + } + return nil, err + } + entry, ok := out.Keys[cid] + if !ok { + return &ipfs.PinStatus{CID: cid, Pinned: false}, nil + } + return &ipfs.PinStatus{CID: cid, Pinned: true, Type: entry.Type}, nil +} + +// List implements ipfs.Backend. Returns only recursive pins so indirect +// children aren't counted separately. +func (c *Client) List(ctx context.Context) ([]string, error) { + q := url.Values{"type": []string{"recursive"}} + var out struct { + Keys map[string]struct { + Type string `json:"Type"` + } `json:"Keys"` + } + if err := c.call(ctx, "pin/ls", q, &out); err != nil { + return nil, err + } + cids := make([]string, 0, len(out.Keys)) + for k := range out.Keys { + cids = append(cids, k) + } + return cids, nil +} + +// ID implements ipfs.Backend. +func (c *Client) ID(ctx context.Context) (*ipfs.IDInfo, error) { + var out struct { + ID string `json:"ID"` + Addresses []string `json:"Addresses"` + } + if err := c.call(ctx, "id", nil, &out); err != nil { + return nil, err + } + return &ipfs.IDInfo{PeerID: out.ID, Addresses: out.Addresses}, nil +} + +// call invokes /api/v0/ with the given query params and decodes +// the JSON response into out. Kubo uses POST for every endpoint, even +// pure reads, and returns newline-delimited JSON for streaming ops. +func (c *Client) call(ctx context.Context, path string, q url.Values, out any) error { + u := *c.base + u.Path = "/api/v0/" + path + if q != nil { + u.RawQuery = q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil) + if err != nil { + return err + } + + resp, err := c.hc.Do(req) + if err != nil { + return fmt.Errorf("kubo %s: %w", path, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if resp.StatusCode >= 400 { + return fmt.Errorf("kubo %s: status %d: %s", path, resp.StatusCode, string(body)) + } + if out == nil { + return nil + } + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode %s: %w (body=%s)", path, err, string(body)) + } + return nil +} + +func (c *Client) callEmpty(ctx context.Context, path string, q url.Values) error { + return c.call(ctx, path, q, nil) +} diff --git a/internal/pkg/leader/leader.go b/internal/pkg/leader/leader.go new file mode 100644 index 0000000..685506a --- /dev/null +++ b/internal/pkg/leader/leader.go @@ -0,0 +1,197 @@ +// Package leader implements TTL-based leader election via NATS JetStream KV. +// +// The pattern, lifted from the sibling kanrisha project: +// +// 1. A JetStream KV bucket "ANCHORAGE_LEADER" holds a single key "leader" +// whose value is the current leader's node ID. The bucket TTL is set to +// a few seconds (5s default). +// 2. Every candidate tries `kv.Create("leader", myNodeID)` on a loop. Exactly +// one CAS succeeds — that node is the leader. +// 3. The leader renews the key (via kv.Update with the known revision) +// roughly once per second. Missing three renews lets the TTL expire; a +// candidate then wins the next Create. +// 4. Losing leadership fires OnDemote; winning it fires OnPromote. Both +// callbacks run synchronously in the Elector goroutine. +// +// The design accepts brief periods of "two leaders" during a partition as +// an acceptable cost: every leader-only job in anchorage (rebalancer, +// sweeper) is idempotent at the Postgres level. +package leader + +import ( + "context" + "errors" + "log/slog" + "sync/atomic" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "anchorage/internal/pkg/metrics" +) + +// DefaultBucket is the JetStream KV bucket name. +const DefaultBucket = "ANCHORAGE_LEADER" + +// DefaultKey is the sole key in the bucket. +const DefaultKey = "leader" + +// Options tunes election behavior. +type Options struct { + // NodeID is the value written to the KV. Must be unique per candidate. + NodeID string + // Bucket overrides DefaultBucket. + Bucket string + // Key overrides DefaultKey. + Key string + // TTL is the key expiry. If renewals lapse for >TTL, the key vanishes + // and another candidate can claim it. Default 5s. + TTL time.Duration + // RenewInterval is how often the leader refreshes the key. Should be + // comfortably under TTL — default TTL/5. + RenewInterval time.Duration + // ClaimInterval is how often candidates try to claim when there's no + // leader. Default equal to RenewInterval. + ClaimInterval time.Duration + // OnPromote fires when this node becomes leader. + OnPromote func(ctx context.Context) + // OnDemote fires when this node stops being leader (voluntarily or + // because a renewal failed). + OnDemote func(ctx context.Context) +} + +// Elector runs a leader-election loop in its own goroutine. +type Elector struct { + opts Options + kv jetstream.KeyValue + leading atomic.Bool +} + +// New creates (or opens) the KV bucket and returns an Elector. It does +// not start the loop — call Run to begin claiming. +func New(ctx context.Context, js jetstream.JetStream, opts Options) (*Elector, error) { + if opts.NodeID == "" { + return nil, errors.New("leader: NodeID is required") + } + if opts.Bucket == "" { + opts.Bucket = DefaultBucket + } + if opts.Key == "" { + opts.Key = DefaultKey + } + if opts.TTL <= 0 { + opts.TTL = 5 * time.Second + } + if opts.RenewInterval <= 0 { + opts.RenewInterval = opts.TTL / 5 + if opts.RenewInterval < 250*time.Millisecond { + opts.RenewInterval = 250 * time.Millisecond + } + } + if opts.ClaimInterval <= 0 { + opts.ClaimInterval = opts.RenewInterval + } + + kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ + Bucket: opts.Bucket, + TTL: opts.TTL, + }) + if err != nil { + return nil, err + } + return &Elector{opts: opts, kv: kv}, nil +} + +// IsLeader reports whether this node currently holds leadership. +func (e *Elector) IsLeader() bool { return e.leading.Load() } + +// Run blocks until ctx is cancelled. It acquires, renews, or fails over +// leadership, invoking OnPromote / OnDemote on transitions. +// +// Return value: ctx.Err() when cancelled, or a persistent infrastructure +// error (e.g., bucket deletion) — transient KV errors are logged and +// retried. +func (e *Elector) Run(ctx context.Context) error { + var revision uint64 + + // Loop pacing: use separate tickers for claim and renew so the code + // doesn't need to track "leading vs not" state twice. + tryTicker := time.NewTicker(e.opts.ClaimInterval) + defer tryTicker.Stop() + renewTicker := time.NewTicker(e.opts.RenewInterval) + defer renewTicker.Stop() + + for { + if e.leading.Load() { + // As leader, renew on every renewTicker tick. + select { + case <-ctx.Done(): + e.stepDown(ctx, revision) + return ctx.Err() + case <-renewTicker.C: + rev, err := e.kv.Update(ctx, e.opts.Key, []byte(e.opts.NodeID), revision) + if err != nil { + slog.Warn("leader: renew failed — stepping down", "err", err) + e.demote(ctx) + revision = 0 + continue + } + revision = rev + } + } else { + // As follower, try to claim. + select { + case <-ctx.Done(): + return ctx.Err() + case <-tryTicker.C: + rev, err := e.kv.Create(ctx, e.opts.Key, []byte(e.opts.NodeID)) + if err != nil { + // Key already held by someone else — normal follower state. + continue + } + revision = rev + e.promote(ctx) + } + } + } +} + +// StepDown relinquishes leadership if held. Safe to call from any goroutine. +func (e *Elector) StepDown(ctx context.Context) { + if !e.leading.CompareAndSwap(true, false) { + return + } + // Best effort: delete the key so the next follower claims instantly. + _ = e.kv.Delete(ctx, e.opts.Key) + if e.opts.OnDemote != nil { + e.opts.OnDemote(ctx) + } +} + +func (e *Elector) promote(ctx context.Context) { + if !e.leading.CompareAndSwap(false, true) { + return + } + metrics.LeaderIsElected.Set(1) + slog.Info("leader: promoted", "node_id", e.opts.NodeID, "bucket", e.opts.Bucket) + if e.opts.OnPromote != nil { + e.opts.OnPromote(ctx) + } +} + +func (e *Elector) demote(ctx context.Context) { + if !e.leading.CompareAndSwap(true, false) { + return + } + metrics.LeaderIsElected.Set(0) + slog.Info("leader: demoted", "node_id", e.opts.NodeID) + if e.opts.OnDemote != nil { + e.opts.OnDemote(ctx) + } +} + +func (e *Elector) stepDown(ctx context.Context, revision uint64) { + _ = revision + e.demote(ctx) + _ = e.kv.Delete(ctx, e.opts.Key) +} diff --git a/internal/pkg/leader/leader_test.go b/internal/pkg/leader/leader_test.go new file mode 100644 index 0000000..52d44ca --- /dev/null +++ b/internal/pkg/leader/leader_test.go @@ -0,0 +1,111 @@ +package leader_test + +import ( + "context" + "sync/atomic" + "testing" + "time" + + "anchorage/internal/pkg/leader" + embeddednats "anchorage/internal/pkg/nats" +) + +// startEmbedded spins up an in-process NATS server for this test. +func startEmbedded(t *testing.T) *embeddednats.Server { + t.Helper() + srv, err := embeddednats.Start(context.Background(), embeddednats.ServerConfig{ + ServerName: "leader-test", + DataDir: t.TempDir(), + ClientHost: "127.0.0.1", + ClientPort: -1, + JSReplicas: 1, + }) + if err != nil { + t.Fatalf("embeddednats.Start: %v", err) + } + t.Cleanup(srv.Close) + return srv +} + +func TestSingleCandidatePromotes(t *testing.T) { + srv := startEmbedded(t) + + var promoted, demoted atomic.Int32 + + el, err := leader.New(context.Background(), srv.JS, leader.Options{ + NodeID: "nod_solo", + TTL: time.Second, + RenewInterval: 200 * time.Millisecond, + ClaimInterval: 200 * time.Millisecond, + OnPromote: func(context.Context) { promoted.Add(1) }, + OnDemote: func(context.Context) { demoted.Add(1) }, + }) + if err != nil { + t.Fatalf("leader.New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + go func() { _ = el.Run(ctx) }() + + // Wait up to 2s for promotion. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if el.IsLeader() { + break + } + time.Sleep(50 * time.Millisecond) + } + if !el.IsLeader() { + t.Fatalf("candidate never promoted within 2s") + } + if promoted.Load() != 1 { + t.Errorf("OnPromote fired %d times, want 1", promoted.Load()) + } + + cancel() + time.Sleep(300 * time.Millisecond) + + if demoted.Load() < 1 { + t.Errorf("OnDemote did not fire on ctx cancel") + } +} + +func TestTwoCandidatesExactlyOneWins(t *testing.T) { + srv := startEmbedded(t) + + mk := func(id string) *leader.Elector { + el, err := leader.New(context.Background(), srv.JS, leader.Options{ + NodeID: id, + TTL: time.Second, + RenewInterval: 200 * time.Millisecond, + ClaimInterval: 200 * time.Millisecond, + }) + if err != nil { + t.Fatalf("leader.New: %v", err) + } + return el + } + + a := mk("nod_a") + b := mk("nod_b") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + go func() { _ = a.Run(ctx) }() + go func() { _ = b.Run(ctx) }() + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if a.IsLeader() || b.IsLeader() { + break + } + time.Sleep(50 * time.Millisecond) + } + + if a.IsLeader() == b.IsLeader() { + t.Fatalf("both candidates report leader=%v (expected exactly one true)", a.IsLeader()) + } +} diff --git a/internal/pkg/logging/logging.go b/internal/pkg/logging/logging.go new file mode 100644 index 0000000..20b61c5 --- /dev/null +++ b/internal/pkg/logging/logging.go @@ -0,0 +1,180 @@ +// Package logging configures anchorage's structured logger. +// +// The design mirrors the sibling kanrisha project: stdlib log/slog with a +// fan-out handler that writes every record to both a rotating JSON file (all +// levels ≥ configured level, for machine parsing) and stderr (warn+ only, +// human-friendly format, for quick console checks). +// +// The logger is installed globally via slog.SetDefault so every package +// can simply import "log/slog" and call slog.Info / slog.Error without +// dependency wiring. +package logging + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + lumberjack "gopkg.in/natefinch/lumberjack.v2" +) + +// Config holds logging parameters, typically loaded from Viper. +// +// File is optional: when empty, the file handler is skipped and stderr +// receives every record (useful for local dev and short-lived CLI commands). +type Config struct { + // Level is the minimum level for file output: "debug", "info", "warn", "error". + // Empty string is treated as "info". + Level string + // Format controls stderr rendering: "json" or "text" (default). + Format string + // Source toggles source file:line attributes on every record. + Source bool + // File is the rotating log file path. Empty disables file logging. + File string + // MaxSizeMB is the rotation threshold in megabytes. Ignored when File is empty. + MaxSizeMB int + // MaxBackups caps the number of rotated files retained. + MaxBackups int + // MaxAgeDays caps the age (in days) of rotated files before deletion. + MaxAgeDays int + // Compress gzips rotated files when true. + Compress bool +} + +// Init installs the process-wide slog.Default handler. +// +// Call once, after config has been loaded and before any subsystem startup +// so that everything that fires off goroutines sees the correct logger. +func Init(cfg Config) error { + fileLevel, err := parseLevel(cfg.Level) + if err != nil { + return fmt.Errorf("logging level: %w", err) + } + + stderrFormat := strings.ToLower(cfg.Format) + if stderrFormat != "" && stderrFormat != "json" && stderrFormat != "text" { + return fmt.Errorf("unknown log format %q — must be json or text", cfg.Format) + } + + handlers := make([]slog.Handler, 0, 2) + + // File handler: always JSON, respects configured level. + if cfg.File != "" { + fileWriter := &lumberjack.Logger{ + Filename: cfg.File, + MaxSize: cfg.MaxSizeMB, + MaxBackups: cfg.MaxBackups, + MaxAge: cfg.MaxAgeDays, + Compress: cfg.Compress, + } + handlers = append(handlers, slog.NewJSONHandler(fileWriter, &slog.HandlerOptions{ + Level: fileLevel, + AddSource: cfg.Source, + })) + } + + // Stderr handler. When the file handler is present, stderr is noisy-only + // (warn+). Without a file handler, stderr must carry every record. + stderrLevel := slog.LevelWarn + if cfg.File == "" { + stderrLevel = fileLevel + } + stderrOpts := &slog.HandlerOptions{ + Level: stderrLevel, + AddSource: cfg.Source, + } + var stderrHandler slog.Handler + switch stderrFormat { + case "json": + stderrHandler = slog.NewJSONHandler(os.Stderr, stderrOpts) + default: // "text" or "" + stderrHandler = slog.NewTextHandler(os.Stderr, stderrOpts) + } + handlers = append(handlers, stderrHandler) + + slog.SetDefault(slog.New(&fanoutHandler{handlers: handlers})) + slog.Debug("logging initialized") + + return nil +} + +// fanoutHandler dispatches each record to multiple slog.Handlers. +// +// Mirrors kanrisha's implementation: clones the record per handler so state +// (attrs, groups) does not leak between outputs. +type fanoutHandler struct { + handlers []slog.Handler + attrs []slog.Attr + groups []string +} + +// Enabled reports whether at least one child handler would accept the level. +func (h *fanoutHandler) Enabled(_ context.Context, level slog.Level) bool { + for _, handler := range h.handlers { + if handler.Enabled(context.Background(), level) { + return true + } + } + return false +} + +// Handle forwards the record to every child handler that accepts its level. +func (h *fanoutHandler) Handle(ctx context.Context, r slog.Record) error { + for _, handler := range h.handlers { + if handler.Enabled(ctx, r.Level) { + if err := handler.Handle(ctx, r.Clone()); err != nil { + return err + } + } + } + return nil +} + +// WithAttrs returns a fresh fanoutHandler that applies the attrs to each child. +func (h *fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + newHandlers := make([]slog.Handler, len(h.handlers)) + for i, handler := range h.handlers { + newHandlers[i] = handler.WithAttrs(attrs) + } + return &fanoutHandler{ + handlers: newHandlers, + attrs: append(h.attrs, attrs...), + groups: h.groups, + } +} + +// WithGroup returns a fresh fanoutHandler that applies the group to each child. +func (h *fanoutHandler) WithGroup(name string) slog.Handler { + newHandlers := make([]slog.Handler, len(h.handlers)) + for i, handler := range h.handlers { + newHandlers[i] = handler.WithGroup(name) + } + return &fanoutHandler{ + handlers: newHandlers, + attrs: h.attrs, + groups: append(h.groups, name), + } +} + +// parseLevel converts a config string to slog.Level. +// +// Empty string and "info" both map to LevelInfo so zero-valued Configs are +// ergonomic. Unknown values return an error (and LevelInfo as a safe default +// in case the caller logs the error and carries on). +func parseLevel(s string) (slog.Level, error) { + switch strings.ToLower(s) { + case "debug": + return slog.LevelDebug, nil + case "info", "": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return slog.LevelInfo, fmt.Errorf("unknown log level %q — must be debug, info, warn, or error", s) + } +} diff --git a/internal/pkg/logging/logging_test.go b/internal/pkg/logging/logging_test.go new file mode 100644 index 0000000..d33c22a --- /dev/null +++ b/internal/pkg/logging/logging_test.go @@ -0,0 +1,174 @@ +package logging + +import ( + "context" + "log/slog" + "testing" +) + +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + want slog.Level + err bool + }{ + {"debug", slog.LevelDebug, false}, + {"info", slog.LevelInfo, false}, + {"warn", slog.LevelWarn, false}, + {"warning", slog.LevelWarn, false}, + {"error", slog.LevelError, false}, + {"", slog.LevelInfo, false}, + {"DEBUG", slog.LevelDebug, false}, + {"INFO", slog.LevelInfo, false}, + {"WARN", slog.LevelWarn, false}, + {"ERROR", slog.LevelError, false}, + {"invalid", slog.LevelInfo, true}, + {"trace", slog.LevelInfo, true}, + {"fatal", slog.LevelInfo, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseLevel(tt.input) + if tt.err && err == nil { + t.Errorf("parseLevel(%q) expected error, got nil", tt.input) + } + if !tt.err && err != nil { + t.Errorf("parseLevel(%q) unexpected error: %v", tt.input, err) + } + if !tt.err && got != tt.want { + t.Errorf("parseLevel(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// recordingHandler captures log records for testing. +type recordingHandler struct { + records []slog.Record + level slog.Level +} + +func (h *recordingHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= h.level +} + +func (h *recordingHandler) Handle(_ context.Context, r slog.Record) error { + h.records = append(h.records, r) + return nil +} + +func (h *recordingHandler) WithAttrs(_ []slog.Attr) slog.Handler { return h } +func (h *recordingHandler) WithGroup(_ string) slog.Handler { return h } + +func TestFanoutHandlerDispatchesBothHandlers(t *testing.T) { + all := &recordingHandler{level: slog.LevelDebug} + errOnly := &recordingHandler{level: slog.LevelWarn} + + handler := &fanoutHandler{handlers: []slog.Handler{all, errOnly}} + logger := slog.New(handler) + + logger.Debug("debug msg") + logger.Info("info msg") + logger.Warn("warn msg") + logger.Error("error msg") + + // The all-levels handler should see every record. + if len(all.records) != 4 { + t.Errorf("all handler got %d records, want 4", len(all.records)) + } + + // The warn+ handler should see only warn and error. + if len(errOnly.records) != 2 { + t.Errorf("errOnly handler got %d records, want 2", len(errOnly.records)) + } + + if len(errOnly.records) >= 2 { + if errOnly.records[0].Level != slog.LevelWarn { + t.Errorf("first errOnly record level = %v, want WARN", errOnly.records[0].Level) + } + if errOnly.records[1].Level != slog.LevelError { + t.Errorf("second errOnly record level = %v, want ERROR", errOnly.records[1].Level) + } + } +} + +func TestFanoutHandlerEnabled(t *testing.T) { + debugHandler := &recordingHandler{level: slog.LevelDebug} + errorHandler := &recordingHandler{level: slog.LevelError} + + handler := &fanoutHandler{handlers: []slog.Handler{debugHandler, errorHandler}} + + // At least one child accepts debug → fanout accepts debug. + if !handler.Enabled(context.Background(), slog.LevelDebug) { + t.Error("expected Enabled(Debug) = true") + } + if !handler.Enabled(context.Background(), slog.LevelInfo) { + t.Error("expected Enabled(Info) = true") + } + if !handler.Enabled(context.Background(), slog.LevelError) { + t.Error("expected Enabled(Error) = true") + } +} + +func TestFanoutHandlerEnabledNoneMatch(t *testing.T) { + errorOnly := &recordingHandler{level: slog.LevelError} + handler := &fanoutHandler{handlers: []slog.Handler{errorOnly}} + + if handler.Enabled(context.Background(), slog.LevelDebug) { + t.Error("expected Enabled(Debug) = false with error-only handler") + } +} + +func TestFanoutWithAttrs(t *testing.T) { + h1 := &recordingHandler{level: slog.LevelInfo} + h2 := &recordingHandler{level: slog.LevelInfo} + handler := &fanoutHandler{handlers: []slog.Handler{h1, h2}} + + newHandler := handler.WithAttrs([]slog.Attr{slog.String("key", "val")}) + if newHandler == nil { + t.Fatal("WithAttrs returned nil") + } +} + +func TestFanoutWithGroup(t *testing.T) { + h1 := &recordingHandler{level: slog.LevelInfo} + h2 := &recordingHandler{level: slog.LevelInfo} + handler := &fanoutHandler{handlers: []slog.Handler{h1, h2}} + + newHandler := handler.WithGroup("test") + if newHandler == nil { + t.Fatal("WithGroup returned nil") + } +} + +// TestInitStderrOnly verifies that an empty File falls back to stderr-only +// logging (no lumberjack file handler) — used by `anchorage version` and +// other short-lived commands that don't want to touch the filesystem. +func TestInitStderrOnly(t *testing.T) { + if err := Init(Config{Level: "debug", Format: "text"}); err != nil { + t.Fatalf("Init with empty File returned error: %v", err) + } + // Should be a fanoutHandler with exactly one child (stderr). + h, ok := slog.Default().Handler().(*fanoutHandler) + if !ok { + t.Fatalf("default handler is not fanoutHandler: %T", slog.Default().Handler()) + } + if len(h.handlers) != 1 { + t.Errorf("expected 1 child handler when File is empty, got %d", len(h.handlers)) + } +} + +func TestInitRejectsBadFormat(t *testing.T) { + err := Init(Config{Level: "info", Format: "yaml"}) + if err == nil { + t.Fatal("expected error for unknown format, got nil") + } +} + +func TestInitRejectsBadLevel(t *testing.T) { + err := Init(Config{Level: "trace"}) + if err == nil { + t.Fatal("expected error for unknown level, got nil") + } +} diff --git a/internal/pkg/maintenance/maintenance.go b/internal/pkg/maintenance/maintenance.go new file mode 100644 index 0000000..6429162 --- /dev/null +++ b/internal/pkg/maintenance/maintenance.go @@ -0,0 +1,87 @@ +// Package maintenance owns the cluster-wide maintenance flag stored in +// NATS JetStream KV. +// +// The flag is a single key ("maintenance") in the ANCHORAGE_CLUSTER +// bucket. Value is a JSON blob with the reason, who set it, and when. +// When present and not expired, the rebalancer and requeue sweeper +// pause so rolling upgrades don't thrash. +package maintenance + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/nats-io/nats.go/jetstream" +) + +// Bucket is the JetStream KV bucket name. +const Bucket = "ANCHORAGE_CLUSTER" + +// Key is the single KV key for the cluster-wide flag. +const Key = "maintenance" + +// Flag is the value stored in the KV when maintenance is on. +type Flag struct { + Reason string `json:"reason"` + SetBy string `json:"set_by"` + SetAt time.Time `json:"set_at"` + ExpiresAt time.Time `json:"expires_at,omitempty"` +} + +// Manager reads and writes the maintenance flag. +type Manager struct { + kv jetstream.KeyValue +} + +// NewManager opens (or creates) the bucket. MaxDuration is a soft +// rail: callers read it via IsOn and Warn if SetAt is older than the +// rail. The KV itself has no TTL (so a legitimate 24h window works). +func NewManager(ctx context.Context, js jetstream.JetStream) (*Manager, error) { + kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: Bucket}) + if err != nil { + return nil, fmt.Errorf("open %s kv: %w", Bucket, err) + } + return &Manager{kv: kv}, nil +} + +// IsOn reports whether maintenance is currently enabled. Returns the +// Flag body on true, or nil on false. Expired flags (ExpiresAt in past) +// are treated as off. +func (m *Manager) IsOn(ctx context.Context) (*Flag, bool, error) { + entry, err := m.kv.Get(ctx, Key) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return nil, false, nil + } + return nil, false, err + } + var f Flag + if err := json.Unmarshal(entry.Value(), &f); err != nil { + return nil, false, fmt.Errorf("decode maintenance flag: %w", err) + } + if !f.ExpiresAt.IsZero() && f.ExpiresAt.Before(time.Now()) { + return nil, false, nil + } + return &f, true, nil +} + +// Set enables cluster-wide maintenance. +func (m *Manager) Set(ctx context.Context, f Flag) error { + if f.SetAt.IsZero() { + f.SetAt = time.Now().UTC() + } + b, err := json.Marshal(f) + if err != nil { + return err + } + _, err = m.kv.Put(ctx, Key, b) + return err +} + +// Clear disables maintenance. +func (m *Manager) Clear(ctx context.Context) error { + return m.kv.Delete(ctx, Key) +} diff --git a/internal/pkg/maintenance/watchdog.go b/internal/pkg/maintenance/watchdog.go new file mode 100644 index 0000000..765fa07 --- /dev/null +++ b/internal/pkg/maintenance/watchdog.go @@ -0,0 +1,65 @@ +package maintenance + +import ( + "context" + "log/slog" + "time" +) + +// WatchdogInterval is how often the watchdog checks the maintenance +// flag's age. One minute strikes a balance: cluster-maintenance +// windows are usually measured in hours, so once-per-minute is plenty, +// and log collectors aggregating every 5 minutes will see at least +// one warn-line per collection window once the rail is tripped. +const WatchdogInterval = time.Minute + +// Watchdog warns (via slog) whenever the cluster-wide maintenance flag +// has been set longer than maxDuration. It runs until ctx is cancelled. +// +// The whole point of the rail is that a "forgotten maintenance on" +// silently masks real outages (rebalancer and requeue sweeper stay +// no-op'd). A warning every WatchdogInterval ensures the operator's +// log collector will surface the situation before enough time passes +// for it to matter. +// +// maxDuration <= 0 disables the watchdog (returns immediately). The +// default from config is 1h; operators running very long planned +// upgrades should bump it rather than silencing the warning. +func Watchdog(ctx context.Context, mgr *Manager, maxDuration time.Duration) { + if mgr == nil || maxDuration <= 0 { + return + } + t := time.NewTicker(WatchdogInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + check(ctx, mgr, maxDuration) + } + } +} + +func check(ctx context.Context, mgr *Manager, maxDuration time.Duration) { + flag, on, err := mgr.IsOn(ctx) + if err != nil { + // Non-fatal — the KV bucket may be transiently unavailable. + // The next tick will retry. + return + } + if !on || flag == nil { + return + } + age := time.Since(flag.SetAt) + if age <= maxDuration { + return + } + slog.Warn("maintenance: cluster-wide flag has been on longer than maxDuration — rebalancer + sweeper are silently paused", + "age", age.Round(time.Second), + "max_duration", maxDuration, + "reason", flag.Reason, + "set_by", flag.SetBy, + "set_at", flag.SetAt, + "expires_at", flag.ExpiresAt) +} diff --git a/internal/pkg/metrics/metrics.go b/internal/pkg/metrics/metrics.go new file mode 100644 index 0000000..fd22746 --- /dev/null +++ b/internal/pkg/metrics/metrics.go @@ -0,0 +1,214 @@ +// Package metrics is anchorage's Prometheus integration. +// +// Three responsibilities: +// +// 1. Define the service-specific collectors every subsystem pushes into +// (HTTP requests, pin ops, scheduler fetches, cache hit/miss, leader +// state, live-node count). +// 2. Expose a Fiber handler that renders /metrics for a Prometheus scraper. +// 3. Gate that handler with a CIDR allowlist so the endpoint is safe to +// leave unauthenticated on an internal network. +// +// The collectors register against prometheus.DefaultRegisterer in init() +// so any package can `metrics.PinOps.WithLabelValues("create","ok").Inc()` +// without a coupling dance. Tests that want isolated counters can call +// ResetForTests. +package metrics + +import ( + "errors" + "net" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Default CIDR allowlist when server.metrics.allowCIDRs is unset: loopback +// plus RFC1918. Covers the typical compose / swarm / k8s intra-cluster +// scrape path without leaking /metrics to the public internet via a LB. +var DefaultAllowCIDRs = []string{ + "127.0.0.0/8", + "::1/128", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", +} + +// Collectors. Construct once via init(); every package imports `metrics` +// and increments these directly. +var ( + HTTPRequests = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "http", + Name: "requests_total", + Help: "HTTP requests served, labelled by method and 2xx/4xx/5xx status class.", + }, + []string{"method", "status_class"}, + ) + + PinOps = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "pin", + Name: "ops_total", + Help: "Pin operations by op (create|replace|delete) and result (ok|err).", + }, + []string{"op", "result"}, + ) + + SchedulerFetch = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "scheduler", + Name: "fetch_total", + Help: "Pin-job fetch attempts, by node and result (ok|timeout|err).", + }, + []string{"node", "result"}, + ) + + SchedulerAcks = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "scheduler", + Name: "acks_total", + Help: "Pin-job ack outcomes by node and terminal status (pinned|failed|unpinned).", + }, + []string{"node", "status"}, + ) + + CacheHits = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "cache", + Name: "hits_total", + Help: "In-memory cache hits by cache name.", + }, + []string{"name"}, + ) + + CacheMisses = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "anchorage", + Subsystem: "cache", + Name: "misses_total", + Help: "In-memory cache misses by cache name.", + }, + []string{"name"}, + ) + + LeaderIsElected = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "anchorage", + Subsystem: "leader", + Name: "is_elected", + Help: "1 when this node currently holds the rebalancer/sweeper leader lease.", + }, + ) + + NodesLive = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "anchorage", + Subsystem: "cluster", + Name: "nodes_live", + Help: "Current count of nodes with status='up' in the registry (refreshed periodically).", + }, + ) + + PlacementsByStatus = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Namespace: "anchorage", + Subsystem: "placements", + Name: "by_status", + Help: "Placement rows grouped by status, refreshed periodically.", + }, + []string{"status"}, + ) +) + +func init() { + prometheus.MustRegister( + HTTPRequests, PinOps, SchedulerFetch, SchedulerAcks, + CacheHits, CacheMisses, LeaderIsElected, NodesLive, PlacementsByStatus, + ) +} + +// StatusClass maps a concrete HTTP status code to a label-friendly +// class. Keeps cardinality bounded regardless of how many 4xx variants +// handlers emit. +func StatusClass(status int) string { + switch { + case status >= 500: + return "5xx" + case status >= 400: + return "4xx" + case status >= 300: + return "3xx" + case status >= 200: + return "2xx" + default: + return "other" + } +} + +// ACL builds a Fiber middleware that 403s any request whose direct TCP +// peer IP is not contained in one of the supplied CIDRs. +// +// A nil or zero-length slice is treated as "use DefaultAllowCIDRs". An +// explicit empty slice (passed via `[]string{}` from config, distinguished +// by the caller) means "no restriction" — document that operators should +// rely on a firewall in that case. +func ACL(allowCIDRs []string) (fiber.Handler, error) { + if allowCIDRs == nil { + allowCIDRs = DefaultAllowCIDRs + } + if len(allowCIDRs) == 0 { + // Explicit allow-all: middleware is a passthrough. + return func(c *fiber.Ctx) error { return c.Next() }, nil + } + nets := make([]*net.IPNet, 0, len(allowCIDRs)) + for _, cidr := range allowCIDRs { + _, n, err := net.ParseCIDR(cidr) + if err != nil { + return nil, errors.New("metrics: bad CIDR " + cidr + ": " + err.Error()) + } + nets = append(nets, n) + } + return func(c *fiber.Ctx) error { + ip := net.ParseIP(c.IP()) + if ip == nil { + return fiber.NewError(fiber.StatusForbidden, "metrics: source IP not resolvable") + } + for _, n := range nets { + if n.Contains(ip) { + return c.Next() + } + } + return fiber.NewError(fiber.StatusForbidden, "metrics: source IP not in allowlist") + }, nil +} + +// Handler returns a Fiber handler that renders the Prometheus +// default-registry. Usually wired as: +// +// acl, err := metrics.ACL(cfg.Server.Metrics.AllowCIDRs) +// app.Get("/metrics", acl, metrics.Handler()) +func Handler() fiber.Handler { + return adaptor.HTTPHandler(promhttp.Handler()) +} + +// ResetForTests clears every registered collector. Only sound inside +// go test -run; production callers should never touch this. +func ResetForTests() { + HTTPRequests.Reset() + PinOps.Reset() + SchedulerFetch.Reset() + SchedulerAcks.Reset() + CacheHits.Reset() + CacheMisses.Reset() + LeaderIsElected.Set(0) + NodesLive.Set(0) + PlacementsByStatus.Reset() +} diff --git a/internal/pkg/metrics/refresh.go b/internal/pkg/metrics/refresh.go new file mode 100644 index 0000000..9c28277 --- /dev/null +++ b/internal/pkg/metrics/refresh.go @@ -0,0 +1,96 @@ +package metrics + +import ( + "context" + "log/slog" + "time" + + "anchorage/internal/pkg/cache" + "anchorage/internal/pkg/store" +) + +// RefreshGauges periodically snapshots slow-moving values from the +// store + cache registry into Prometheus gauges. The goroutine runs +// until ctx is cancelled; ticker cadence is fixed at 15 seconds — +// Prometheus scrapers typically poll every 15-60s and seeing stale-by-15s +// gauges is an acceptable trade-off for avoiding hot query paths. +// +// Collectors populated: +// +// - NodesLive: count of nodes with status='up' +// - PlacementsByStatus: one sample per status label +// - CacheHits / CacheMisses: monotonic counters mirrored from +// cache.AllStats() (both our ristretto-backed Cache[K,V] and the +// hand-rolled pin.CachedLiveNodes register as StatsProviders) +// +// The function is a no-op when s is nil (useful in tests and in +// dev-mode runs with incomplete wiring). +func RefreshGauges(ctx context.Context, s store.Store) { + if s == nil { + return + } + // Tick immediately so gauges aren't zero for the first 15s after + // startup. + tick(ctx, s) + + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + tick(ctx, s) + } + } +} + +// tick is one refresh pass. Keeps errors non-fatal — a failing query +// leaves the last good value in place, which is the least-surprising +// behavior for a metrics endpoint. +func tick(ctx context.Context, s store.Store) { + if live, err := s.Nodes().ListLive(ctx); err == nil { + NodesLive.Set(float64(len(live))) + } else { + slog.Warn("metrics: refresh NodesLive", "err", err) + } + + if counts, err := s.Pins().CountPlacementsByStatus(ctx); err == nil { + // Reset the vector first so statuses that dropped to 0 stop + // reporting stale non-zero values. + PlacementsByStatus.Reset() + for status, n := range counts { + PlacementsByStatus.WithLabelValues(status).Set(float64(n)) + } + } else { + slog.Warn("metrics: refresh PlacementsByStatus", "err", err) + } + + // Mirror cache counters. These are monotonic in ristretto; we + // re-Set them (the Prometheus counter API demands Add, but we + // registered these as CounterVec — using Add on a monotonic delta + // would double-count across ticks). + // + // Workaround: track previous totals and only Add the delta since + // last tick. Module-level state is fine because RefreshGauges runs + // as a single goroutine. + for _, s := range cache.AllStats() { + observeCacheDelta(s) + } +} + +// prevCacheStats tracks per-name counter values we've already pushed +// so subsequent ticks only Add the delta. Single-writer goroutine +// means no locking required. +var prevCacheStats = map[string]cache.Stats{} + +func observeCacheDelta(current cache.Stats) { + prev := prevCacheStats[current.Name] + if dh := current.Hits - prev.Hits; dh > 0 { + CacheHits.WithLabelValues(current.Name).Add(float64(dh)) + } + if dm := current.Misses - prev.Misses; dm > 0 { + CacheMisses.WithLabelValues(current.Name).Add(float64(dm)) + } + prevCacheStats[current.Name] = current +} diff --git a/internal/pkg/nats/server.go b/internal/pkg/nats/server.go new file mode 100644 index 0000000..04b7bcf --- /dev/null +++ b/internal/pkg/nats/server.go @@ -0,0 +1,163 @@ +// Package nats hosts anchorage's embedded NATS server lifecycle plus +// JetStream bootstrap for the PIN_JOBS and PIN_EVENTS streams. +// +// Every anchorage instance runs an in-process NATS server that peers with +// the other instances via cluster routes listed in config. JetStream is +// always enabled; stream replication is min(configured, clusterSize) so a +// single-node deployment works without extra tuning. +// +// Naming note: callers that also import the upstream nats.go client +// should alias it (by convention as `natsio`) so the two packages +// don't collide: +// +// import ( +// "anchorage/internal/pkg/nats" +// natsio "github.com/nats-io/nats.go" +// ) +package nats + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + "time" + + natsserver "github.com/nats-io/nats-server/v2/server" + natsio "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// ServerConfig mirrors config.NATSConfig in a flatter, package-local form. +type ServerConfig struct { + // ServerName must be unique per cluster member. Typically the anchorage + // node ID so NATS peers and anchorage peers line up 1:1. + ServerName string + DataDir string + ClientHost string + ClientPort int + ClusterName string + ClusterHost string + ClusterPort int + Routes []string + JSReplicas int +} + +// Server wraps a running in-process NATS server and a client connected +// to it. Callers should call Close to drain and stop the server. +type Server struct { + NS *natsserver.Server + NC *natsio.Conn + JS jetstream.JetStream +} + +// Start boots an embedded NATS server, waits for readiness, and returns a +// connected client. Errors at any stage tear everything down. +func Start(ctx context.Context, cfg ServerConfig) (*Server, error) { + if cfg.DataDir == "" { + return nil, errors.New("nats: DataDir is required") + } + + serverName := cfg.ServerName + if serverName == "" { + serverName = "anchorage-" + cfg.ClusterName + } + + opts := &natsserver.Options{ + ServerName: serverName, + Host: cfg.ClientHost, + Port: cfg.ClientPort, + JetStream: true, + StoreDir: cfg.DataDir, + NoSigs: true, // don't install signal handlers in embedded mode + } + // Clustering is opt-in: only configure it when a cluster port was + // provided. Tests and single-node deployments get a standalone server. + if cfg.ClusterPort != 0 { + opts.Cluster = natsserver.ClusterOpts{ + Name: cfg.ClusterName, + Host: cfg.ClusterHost, + Port: cfg.ClusterPort, + } + } + for _, r := range cfg.Routes { + u, err := parseURL(r) + if err != nil { + return nil, fmt.Errorf("parse route %q: %w", r, err) + } + opts.Routes = append(opts.Routes, u) + } + + ns, err := natsserver.NewServer(opts) + if err != nil { + return nil, fmt.Errorf("construct nats server: %w", err) + } + + go ns.Start() + + readyCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + if !waitReady(readyCtx, ns) { + ns.Shutdown() + return nil, errors.New("nats: server never became ready within 10s") + } + + nc, err := natsio.Connect("", natsio.InProcessServer(ns), natsio.Name("anchorage-embedded")) + if err != nil { + ns.Shutdown() + return nil, fmt.Errorf("connect to embedded nats: %w", err) + } + + js, err := jetstream.New(nc) + if err != nil { + nc.Close() + ns.Shutdown() + return nil, fmt.Errorf("open jetstream: %w", err) + } + + return &Server{NS: ns, NC: nc, JS: js}, nil +} + +// Close drains the client, waits for the server to flush JetStream state, +// and shuts the in-process server down. Safe to call more than once. +func (s *Server) Close() { + if s == nil { + return + } + if s.NC != nil { + _ = s.NC.Drain() + } + if s.NS != nil { + s.NS.Shutdown() + s.NS.WaitForShutdown() + } +} + +// waitReady polls ns.ReadyForConnections until the context expires. +func waitReady(ctx context.Context, ns *natsserver.Server) bool { + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + if ns.ReadyForConnections(100 * time.Millisecond) { + return true + } + select { + case <-ctx.Done(): + return false + case <-ticker.C: + } + } +} + +// parseURL normalises a user-supplied cluster route to a *url.URL. We +// accept bare host:port and auto-prepend nats:// if missing. +func parseURL(raw string) (*url.URL, error) { + if raw == "" { + return nil, errors.New("empty route") + } + if !strings.Contains(raw, "://") { + raw = "nats://" + raw + } + return url.Parse(raw) +} diff --git a/internal/pkg/nats/server_test.go b/internal/pkg/nats/server_test.go new file mode 100644 index 0000000..cafc8ff --- /dev/null +++ b/internal/pkg/nats/server_test.go @@ -0,0 +1,58 @@ +package nats_test + +import ( + "context" + "testing" + "time" + + natsio "github.com/nats-io/nats.go" + + localnats "anchorage/internal/pkg/nats" +) + +func TestEmbeddedServerPubSub(t *testing.T) { + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + srv, err := localnats.Start(ctx, localnats.ServerConfig{ + ServerName: "test-node-1", + DataDir: dir, + ClientHost: "127.0.0.1", + ClientPort: -1, + JSReplicas: 1, + }) + if err != nil { + t.Fatalf("Start: %v", err) + } + defer srv.Close() + + if !srv.NC.IsConnected() { + t.Fatal("client not connected") + } + + recv := make(chan string, 1) + sub, err := srv.NC.Subscribe("test.subject", func(m *natsio.Msg) { + recv <- string(m.Data) + }) + if err != nil { + t.Fatalf("Subscribe: %v", err) + } + defer func() { _ = sub.Unsubscribe() }() + + if err := srv.NC.Publish("test.subject", []byte("hello")); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := srv.NC.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + select { + case got := <-recv: + if got != "hello" { + t.Errorf("got %q, want hello", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for message") + } +} diff --git a/internal/pkg/node/node.go b/internal/pkg/node/node.go new file mode 100644 index 0000000..f5c3274 --- /dev/null +++ b/internal/pkg/node/node.go @@ -0,0 +1,218 @@ +// Package node owns an anchorage instance's identity and heartbeat loop. +// +// On boot, Register creates or refreshes the node's row in the nodes table. +// Start then kicks off two loops: +// +// - Heartbeat publisher: pushes node.heartbeat. every HeartbeatInterval. +// The payload is a tiny Presence struct so listeners can update the +// in-memory live-node cache without a Postgres read. +// - Heartbeat consumer + stale sweeper: subscribes to node.heartbeat.* +// to keep the live-node cache fresh, and on the leader node runs a +// periodic MarkStaleDown to flip neglected rows to status='down'. +package node + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/nats-io/nats.go" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// HeartbeatSubjectPrefix is the per-node presence topic. Listeners use +// node.heartbeat.* to catch every node's beat. +const HeartbeatSubjectPrefix = "node.heartbeat" + +// Presence is the body of a heartbeat message. Kept small so the +// per-5s message traffic is negligible. +type Presence struct { + NodeID string `json:"node_id"` + Multiaddrs []string `json:"multiaddrs"` + Status string `json:"status"` + SentAt time.Time `json:"sent_at"` +} + +// Options configures a Runner. +type Options struct { + NodeID ids.NodeID + DisplayName string + Multiaddrs []string + RPCURL string + HeartbeatInterval time.Duration + DownAfter time.Duration + // MarkStaleDownEnabled must be true on the elected leader so exactly + // one node flips stale rows to down. Followers call Start with it + // false and the leader package toggles it on promotion. + MarkStaleDownEnabled bool +} + +// Runner owns the heartbeat publisher and consumer. +type Runner struct { + opts Options + nc *nats.Conn + store store.Store + + mu sync.Mutex + presence map[ids.NodeID]Presence // latest seen per node + sweepActive bool // toggled by SetSweepEnabled (leader gate) +} + +// NewRunner constructs a runner. Register/Start must be called to wire it up. +func NewRunner(nc *nats.Conn, s store.Store, opts Options) (*Runner, error) { + if opts.HeartbeatInterval <= 0 { + return nil, fmt.Errorf("node: HeartbeatInterval must be > 0") + } + if opts.DownAfter <= opts.HeartbeatInterval { + return nil, fmt.Errorf("node: DownAfter must exceed HeartbeatInterval") + } + return &Runner{ + opts: opts, + nc: nc, + store: s, + presence: map[ids.NodeID]Presence{}, + sweepActive: opts.MarkStaleDownEnabled, + }, nil +} + +// SetSweepEnabled toggles the stale-node sweeper loop at runtime. The +// composition root flips it on in leader.OnPromote and off in OnDemote +// so exactly one node in the cluster flips neglected rows to 'down'. +func (r *Runner) SetSweepEnabled(enabled bool) { + r.mu.Lock() + r.sweepActive = enabled + r.mu.Unlock() +} + +func (r *Runner) sweepEnabled() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.sweepActive +} + +// Register writes (or refreshes) the node's row in the nodes table. +// +// If the row already exists with status='drained', it is preserved — an +// operator-initiated drain must not be undone by a bounce. +func (r *Runner) Register(ctx context.Context) error { + return r.store.Nodes().Upsert(ctx, &store.Node{ + ID: r.opts.NodeID, + DisplayName: r.opts.DisplayName, + Multiaddrs: r.opts.Multiaddrs, + RPCURL: r.opts.RPCURL, + Status: store.NodeStatusUp, + }) +} + +// Start runs the heartbeat loops until ctx is cancelled. +func (r *Runner) Start(ctx context.Context) error { + // Consumer first so we don't miss our own initial beat. + if err := r.startConsumer(ctx); err != nil { + return fmt.Errorf("node: start consumer: %w", err) + } + + beatTicker := time.NewTicker(r.opts.HeartbeatInterval) + defer beatTicker.Stop() + // Sweep ticker runs unconditionally; each tick checks sweepEnabled() + // so leader promotion / demotion can toggle the behavior live. + sweepTicker := time.NewTicker(r.opts.HeartbeatInterval) + defer sweepTicker.Stop() + + // Fire once immediately so the node shows up before the first tick. + r.beat(ctx) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-beatTicker.C: + r.beat(ctx) + case <-sweepTicker.C: + if r.sweepEnabled() { + r.sweepStale(ctx) + } + } + } +} + +// LivePresences returns a snapshot of every node currently considered +// live (last heartbeat within DownAfter). +func (r *Runner) LivePresences() []Presence { + r.mu.Lock() + defer r.mu.Unlock() + cutoff := time.Now().Add(-r.opts.DownAfter) + out := make([]Presence, 0, len(r.presence)) + for _, p := range r.presence { + if p.SentAt.After(cutoff) { + out = append(out, p) + } + } + return out +} + +func (r *Runner) beat(ctx context.Context) { + presence := Presence{ + NodeID: r.opts.NodeID.String(), + Multiaddrs: r.opts.Multiaddrs, + Status: store.NodeStatusUp, + SentAt: time.Now().UTC(), + } + b, err := json.Marshal(presence) + if err != nil { + slog.Warn("node: marshal presence", "err", err) + return + } + subject := fmt.Sprintf("%s.%s", HeartbeatSubjectPrefix, r.opts.NodeID.String()) + if err := r.nc.Publish(subject, b); err != nil { + slog.Warn("node: publish heartbeat", "err", err, "subject", subject) + return + } + if err := r.store.Nodes().TouchHeartbeat(ctx, r.opts.NodeID); err != nil { + slog.Warn("node: touch heartbeat in store", "err", err) + } +} + +func (r *Runner) startConsumer(ctx context.Context) error { + sub, err := r.nc.Subscribe(HeartbeatSubjectPrefix+".*", func(m *nats.Msg) { + var p Presence + if err := json.Unmarshal(m.Data, &p); err != nil { + slog.Warn("node: malformed heartbeat", "err", err) + return + } + id, err := ids.ParseNode(p.NodeID) + if err != nil { + // A peer sent us a garbage node_id; drop rather than cache + // under the zero NodeID key (which would collide across + // malformed senders and produce confusing logs). + slog.Warn("node: heartbeat with unparsable node id", "node_id", p.NodeID, "err", err) + return + } + r.mu.Lock() + r.presence[id] = p + r.mu.Unlock() + }) + if err != nil { + return err + } + go func() { + <-ctx.Done() + _ = sub.Unsubscribe() + }() + return nil +} + +func (r *Runner) sweepStale(ctx context.Context) { + downed, err := r.store.Nodes().MarkStaleDown(ctx, r.opts.DownAfter) + if err != nil { + slog.Warn("node: mark stale down", "err", err) + return + } + for _, id := range downed { + slog.Info("node: marked down", "node_id", id) + } +} diff --git a/internal/pkg/openapi/admin.go b/internal/pkg/openapi/admin.go new file mode 100644 index 0000000..12686e2 --- /dev/null +++ b/internal/pkg/openapi/admin.go @@ -0,0 +1,500 @@ +package openapi + +import ( + "context" + "errors" + "time" + + "github.com/danielgtaylor/huma/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/cache" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/maintenance" + "anchorage/internal/pkg/node" + "anchorage/internal/pkg/scheduler" + "anchorage/internal/pkg/store" +) + +type auditBody struct { + ID int64 `json:"id"` + Action string `json:"action"` + Target string `json:"target"` + Result string `json:"result"` + Detail map[string]any `json:"detail,omitempty"` + ActorUserID string `json:"actor_user_id,omitempty"` + OrgID string `json:"org_id,omitempty"` + Created time.Time `json:"created"` +} + +func ifUserID(p *ids.UserID) string { + if p == nil { + return "" + } + return p.String() +} +func ifOrgID(p *ids.OrgID) string { + if p == nil { + return "" + } + return p.String() +} + +// AdminDeps holds the collaborators the admin endpoints need. +type AdminDeps struct { + Store store.Store + Maint *maintenance.Manager + Rebalancer *scheduler.Rebalancer + // Presences is the source of real-time NATS heartbeat data + // (distinct from the DB view via Store.Nodes().ListAll()). Used by + // /v1/admin/cluster/presences to let operators compare what each + // node is broadcasting right now against the DB registry. + Presences PresenceSource +} + +// PresenceSource is implemented by *node.Runner. Narrow interface so +// tests can stub; the real implementation returns a snapshot of the +// in-memory heartbeat map keyed by NodeID. +type PresenceSource interface { + LivePresences() []node.Presence +} + +// NodePresence is the admin-API projection of node.Presence. Kept in +// openapi so the HTTP response shape stays stable even if the internal +// node.Presence struct evolves. +type NodePresence struct { + NodeID string `json:"node_id"` + Multiaddrs []string `json:"multiaddrs"` + Status string `json:"status"` + SentAt time.Time `json:"sent_at"` + // AgeSeconds is derived server-side so clients don't have to + // reason about clock skew — operators eyeballing the response can + // see "last heard 12s ago" directly. + AgeSeconds float64 `json:"age_seconds"` +} + +// RegisterAdmin wires drain/uncordon/maintenance endpoints at +// /v1/admin/*. Access is restricted to sysadmins; orgadmins cannot +// drain nodes (that would be a cluster-level action). +func RegisterAdmin(api huma.API, deps AdminDeps) { + huma.Register(api, huma.Operation{ + OperationID: "adminDrain", + Method: "POST", + Path: "/admin/nodes/{node_id}/drain", + Summary: "Drain a cluster node", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + NodeID string `path:"node_id"` + }) (*struct { + Body struct { + OK bool `json:"ok"` + } + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + nid, err := ids.ParseNode(in.NodeID) + if err != nil { + return nil, huma.Error400BadRequest("bad node id") + } + if err := deps.Store.Nodes().Drain(ctx, nid); err != nil { + return nil, huma.Error500InternalServerError("drain", err) + } + out := &struct { + Body struct { + OK bool `json:"ok"` + } + }{} + out.Body.OK = true + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminUncordon", + Method: "POST", + Path: "/admin/nodes/{node_id}/uncordon", + Summary: "Return a drained node to service", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + NodeID string `path:"node_id"` + }) (*struct { + Body struct { + OK bool `json:"ok"` + } + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + nid, err := ids.ParseNode(in.NodeID) + if err != nil { + return nil, huma.Error400BadRequest("bad node id") + } + if err := deps.Store.Nodes().Uncordon(ctx, nid); err != nil { + return nil, huma.Error500InternalServerError("uncordon", err) + } + out := &struct { + Body struct { + OK bool `json:"ok"` + } + }{} + out.Body.OK = true + return out, nil + }) + + type maintenanceStatusBody struct { + On bool `json:"on"` + Reason string `json:"reason,omitempty"` + SetBy string `json:"set_by,omitempty"` + SetAt time.Time `json:"set_at,omitempty"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + } + + huma.Register(api, huma.Operation{ + OperationID: "adminMaintenanceOn", + Method: "POST", + Path: "/admin/maintenance/on", + Summary: "Enable cluster-wide maintenance", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Body struct { + Reason string `json:"reason,omitempty"` + TTL time.Duration `json:"ttl,omitempty"` + } + }) (*struct { + Body maintenanceStatusBody + }, error) { + cc, err := requireSysadminCC(ctx) + if err != nil { + return nil, err + } + f := maintenance.Flag{ + Reason: in.Body.Reason, + SetBy: cc.User.String(), + SetAt: time.Now().UTC(), + } + if in.Body.TTL > 0 { + f.ExpiresAt = f.SetAt.Add(in.Body.TTL) + } + if err := deps.Maint.Set(ctx, f); err != nil { + return nil, huma.Error500InternalServerError("maintenance on", err) + } + out := &struct{ Body maintenanceStatusBody }{} + out.Body = maintenanceStatusBody{ + On: true, Reason: f.Reason, SetBy: f.SetBy, SetAt: f.SetAt, ExpiresAt: f.ExpiresAt, + } + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminMaintenanceOff", + Method: "POST", + Path: "/admin/maintenance/off", + Summary: "Disable cluster-wide maintenance", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body maintenanceStatusBody + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + if err := deps.Maint.Clear(ctx); err != nil { + return nil, huma.Error500InternalServerError("maintenance off", err) + } + out := &struct{ Body maintenanceStatusBody }{} + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminGrantSysadmin", + Method: "POST", + Path: "/admin/users/{email}/grant-sysadmin", + Summary: "Promote an existing user to sysadmin", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Email string `path:"email"` + }) (*struct { + Body struct { + OK bool `json:"ok"` + } + }, error) { + cc, err := requireSysadminCC(ctx) + if err != nil { + return nil, err + } + user, err := deps.Store.Users().GetByEmail(ctx, in.Email) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("user (must have logged in at least once via Authentik)") + } + return nil, huma.Error500InternalServerError("lookup", err) + } + if err := deps.Store.Users().PromoteSysadmin(ctx, user.ID); err != nil { + return nil, huma.Error500InternalServerError("promote", err) + } + _ = deps.Store.Audit().Insert(ctx, &store.AuditEntry{ + ActorUserID: &cc.User, + Action: "admin.grant-sysadmin", + Target: user.ID.String(), + Result: "ok", + Detail: map[string]any{"email": in.Email}, + }) + out := &struct { + Body struct { + OK bool `json:"ok"` + } + }{} + out.Body.OK = true + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminListAudit", + Method: "GET", + Path: "/admin/audit", + Summary: "Paged audit-log listing", + Description: "sysadmin with no org_id sees every org; org members can only read their own org's audit", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + OrgID string `query:"org_id"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"1000"` + Offset int `query:"offset" default:"0" minimum:"0"` + }) (*struct { + Body struct { + Results []auditBody `json:"results"` + } + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + var scopeOrg ids.OrgID + if in.OrgID != "" { + scopeOrg, err = ids.ParseOrg(in.OrgID) + if err != nil { + return nil, huma.Error400BadRequest("bad org id") + } + if cc.Role != "sysadmin" && scopeOrg != cc.Org { + return nil, huma.Error403Forbidden("cannot view other org's audit") + } + } else if cc.Role != "sysadmin" { + scopeOrg = cc.Org + } + rows, err := deps.Store.Audit().List(ctx, scopeOrg, in.Limit, in.Offset) + if err != nil { + return nil, huma.Error500InternalServerError("list", err) + } + out := &struct { + Body struct { + Results []auditBody `json:"results"` + } + }{} + for _, e := range rows { + out.Body.Results = append(out.Body.Results, auditBody{ + ID: e.ID, + Action: e.Action, + Target: e.Target, + Result: e.Result, + Detail: e.Detail, + ActorUserID: ifUserID(e.ActorUserID), + OrgID: ifOrgID(e.OrgID), + Created: e.Created, + }) + } + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminMaintenanceStatus", + Method: "GET", + Path: "/admin/maintenance", + Summary: "Report current cluster-maintenance state", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body maintenanceStatusBody + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + f, on, err := deps.Maint.IsOn(ctx) + if err != nil { + return nil, huma.Error500InternalServerError("maintenance status", err) + } + out := &struct{ Body maintenanceStatusBody }{} + if on && f != nil { + out.Body = maintenanceStatusBody{ + On: true, Reason: f.Reason, SetBy: f.SetBy, + SetAt: f.SetAt, ExpiresAt: f.ExpiresAt, + } + } + return out, nil + }) + + // ---- prune-denylist ---------------------------------------------------- + huma.Register(api, huma.Operation{ + OperationID: "adminPruneTokenDenylist", + Method: "POST", + Path: "/admin/tokens/prune-denylist", + Summary: "Delete denylist rows whose expires_at has passed", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body struct { + OK bool `json:"ok"` + } + }, error) { + cc, err := requireSysadminCC(ctx) + if err != nil { + return nil, err + } + if err := deps.Store.Tokens().PruneDenylist(ctx); err != nil { + return nil, huma.Error500InternalServerError("prune", err) + } + _ = deps.Store.Audit().Insert(ctx, &store.AuditEntry{ + ActorUserID: &cc.User, + Action: "admin.prune-denylist", + Target: "token_denylist", + Result: "ok", + }) + out := &struct { + Body struct { + OK bool `json:"ok"` + } + }{} + out.Body.OK = true + return out, nil + }) + + // ---- rebalance --------------------------------------------------------- + type rebalanceReq struct { + Apply bool `json:"apply,omitempty" doc:"when true, execute the moves; otherwise return a dry-run preview"` + } + type rebalanceResp struct { + Body struct { + Applied bool `json:"applied"` + Moves []scheduler.PlannedMove `json:"moves"` + } + } + huma.Register(api, huma.Operation{ + OperationID: "adminRebalance", + Method: "POST", + Path: "/admin/rebalance", + Summary: "One-shot rebalance preview or apply (leader-loop keeps running regardless)", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Body rebalanceReq + }) (*rebalanceResp, error) { + cc, err := requireSysadminCC(ctx) + if err != nil { + return nil, err + } + if deps.Rebalancer == nil { + return nil, huma.Error503ServiceUnavailable("rebalancer not available") + } + var moves []scheduler.PlannedMove + if in.Body.Apply { + moves, err = deps.Rebalancer.RunOnce(ctx) + } else { + moves, err = deps.Rebalancer.DryRun(ctx) + } + if err != nil { + return nil, huma.Error500InternalServerError("rebalance", err) + } + if in.Body.Apply { + _ = deps.Store.Audit().Insert(ctx, &store.AuditEntry{ + ActorUserID: &cc.User, + Action: "admin.rebalance", + Target: "cluster", + Result: "ok", + Detail: map[string]any{"moves": len(moves)}, + }) + } + out := &rebalanceResp{} + out.Body.Applied = in.Body.Apply + out.Body.Moves = moves + return out, nil + }) + + // ---- cache-stats ------------------------------------------------------- + huma.Register(api, huma.Operation{ + OperationID: "adminCacheStats", + Method: "GET", + Path: "/admin/cache-stats", + Summary: "In-memory cache hit/miss/eviction counters per named cache", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body struct { + Caches []cache.Stats `json:"caches"` + } + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + out := &struct { + Body struct { + Caches []cache.Stats `json:"caches"` + } + }{} + out.Body.Caches = cache.AllStats() + return out, nil + }) + + // ---- cluster presences ------------------------------------------------- + // + // Real-time heartbeat view, distinct from the DB registry. The DB + // updates status via the stale-sweeper (leader-gated, fires every + // downAfter interval); this endpoint shows what each peer is + // broadcasting RIGHT NOW over NATS. Operators comparing the two + // diagnose "the DB says X is up but nobody's heard from it" gaps. + huma.Register(api, huma.Operation{ + OperationID: "adminClusterPresences", + Method: "GET", + Path: "/admin/cluster/presences", + Summary: "Real-time NATS heartbeat view of every peer this node has heard from", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body struct { + Presences []NodePresence `json:"presences"` + } + }, error) { + if err := requireSysadmin(ctx); err != nil { + return nil, err + } + if deps.Presences == nil { + return nil, huma.Error503ServiceUnavailable("presence source not wired") + } + now := time.Now() + raw := deps.Presences.LivePresences() + out := &struct { + Body struct { + Presences []NodePresence `json:"presences"` + } + }{} + out.Body.Presences = make([]NodePresence, 0, len(raw)) + for _, p := range raw { + out.Body.Presences = append(out.Body.Presences, NodePresence{ + NodeID: p.NodeID, + Multiaddrs: p.Multiaddrs, + Status: p.Status, + SentAt: p.SentAt, + AgeSeconds: now.Sub(p.SentAt).Seconds(), + }) + } + return out, nil + }) +} + +// requireSysadmin rejects any request without a sysadmin client context. +func requireSysadmin(ctx context.Context) error { + _, err := requireSysadminCC(ctx) + return err +} + +func requireSysadminCC(ctx context.Context) (*auth.ClientContext, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + if cc.Role != "sysadmin" { + return nil, huma.Error403Forbidden("sysadmin required") + } + return cc, nil +} diff --git a/internal/pkg/openapi/api.go b/internal/pkg/openapi/api.go new file mode 100644 index 0000000..67ae3fa --- /dev/null +++ b/internal/pkg/openapi/api.go @@ -0,0 +1,104 @@ +// Package openapi registers anchorage's HTTP surface on a huma API instance. +// +// Handlers are grouped by domain (pins, tokens, orgs, audit) and registered +// via huma's code-first operations. The generated OpenAPI 3.1 spec lives at +// GET /openapi.json; Scalar UI is served at GET /docs. +package openapi + +import ( + "context" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humafiber" + "github.com/gofiber/fiber/v2" +) + +// New creates a huma.API configured to match the anchorage spec. +// +// The returned huma.API is not yet populated with routes — callers +// (see internal/app/anchorage) wire in domain handlers via Register* +// functions exported by their respective packages. +func New(app *fiber.App) huma.API { + cfg := huma.DefaultConfig("anchorage", "0.1.0") + cfg.OpenAPI.Info.Description = "Highly-available IPFS Pinning Service API. Wire-compatible with the IPFS Pinning Services API spec, plus org/admin extensions." + cfg.Servers = []*huma.Server{ + {URL: "/v1", Description: "Default v1 endpoint"}, + } + cfg.Components.SecuritySchemes = map[string]*huma.SecurityScheme{ + "accessToken": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + }, + } + return humafiber.New(app, cfg) +} + +// HealthResponse is the body of /health (liveness probe). +type HealthResponse struct { + Body struct { + Status string `json:"status" example:"ok"` + } +} + +// ReadyResponse is the body of /ready (readiness probe). Includes the +// Reconciler drift count so operators can alert on out-of-sync nodes +// without scraping /metrics. +type ReadyResponse struct { + Body struct { + Status string `json:"status" example:"ok"` + // Drift is the current reconciler drift count (pins the store + // believes this node holds vs. pins actually in Kubo's pinset). + // 0 on a healthy node. Non-zero is not inherently "not ready" — + // a drifted node still serves requests fine — but operators + // alert on sustained non-zero values. + Drift int64 `json:"drift"` + } +} + +// ReadyState is the signal the readyz callback returns to +// RegisterHealth. Ready controls the HTTP status (true → 200, false → +// 503). Drift is surfaced in the body regardless. +type ReadyState struct { + Ready bool + Reason string + Drift int64 +} + +// RegisterHealth registers /health and /ready. +// +// /health is a liveness probe: the process is alive and the API is +// reachable. /ready is a readiness probe: returns 503 while the node +// is drained or while mandatory dependencies (Postgres, NATS) are +// unavailable. The readyz callback receives the per-request context so +// dependency probes are scoped to the request (not the app's root +// ctx, which would stall health-checks mid-shutdown). +func RegisterHealth(api huma.API, readyz func(ctx context.Context) ReadyState) { + huma.Register(api, huma.Operation{ + OperationID: "getHealth", + Method: "GET", + Path: "/health", + Summary: "Liveness probe", + }, func(_ context.Context, _ *struct{}) (*HealthResponse, error) { + out := &HealthResponse{} + out.Body.Status = "ok" + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getReady", + Method: "GET", + Path: "/ready", + Summary: "Readiness probe (also reports reconciler drift)", + }, func(ctx context.Context, _ *struct{}) (*ReadyResponse, error) { + state := readyz(ctx) + out := &ReadyResponse{} + out.Body.Drift = state.Drift + if state.Ready { + out.Body.Status = "ok" + return out, nil + } + out.Body.Status = "not-ready: " + state.Reason + return out, huma.Error503ServiceUnavailable("not ready: " + state.Reason) + }) +} diff --git a/internal/pkg/openapi/orgs.go b/internal/pkg/openapi/orgs.go new file mode 100644 index 0000000..24d6e50 --- /dev/null +++ b/internal/pkg/openapi/orgs.go @@ -0,0 +1,286 @@ +package openapi + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// RegisterOrgs wires org + membership endpoints on api. +// +// Authz model: +// - `POST /v1/orgs` — any authenticated user. The creator is auto-added +// as the first orgadmin. +// - `GET /v1/orgs` — lists the caller's memberships (not every org +// that exists — that would be a cross-tenant leak). +// - `GET /v1/orgs/{id}` / `PATCH /v1/orgs/{id}` — orgadmin of that +// org, or sysadmin. +// - `POST/DELETE /v1/orgs/{id}/members` — orgadmin of that org, or +// sysadmin. +func RegisterOrgs(api huma.API, s store.Store) { + huma.Register(api, huma.Operation{ + OperationID: "createOrg", + Method: "POST", + Path: "/orgs", + Summary: "Create a new organisation", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Body struct { + Slug string `json:"slug" minLength:"2" maxLength:"64" pattern:"^[a-z0-9][a-z0-9-]*$"` + Name string `json:"name" minLength:"1" maxLength:"256"` + } + }) (*struct{ Body orgBody }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + orgID, err := ids.NewOrg() + if err != nil { + return nil, huma.Error500InternalServerError("id", err) + } + org, err := s.Orgs().Create(ctx, orgID, strings.ToLower(in.Body.Slug), in.Body.Name) + if err != nil { + if errors.Is(err, store.ErrConflict) { + return nil, huma.Error409Conflict("slug already in use") + } + return nil, huma.Error500InternalServerError("create org", err) + } + if err := s.Memberships().Add(ctx, org.ID, cc.User, store.RoleOrgAdmin); err != nil { + return nil, huma.Error500InternalServerError("add membership", err) + } + _ = s.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &org.ID, ActorUserID: &cc.User, + Action: "org.create", Target: org.ID.String(), Result: "ok", + Detail: map[string]any{"slug": org.Slug, "name": org.Name}, + }) + return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listMyOrgs", + Method: "GET", + Path: "/orgs", + Summary: "List organisations the caller is a member of", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body struct { + Results []orgBody `json:"results"` + } + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + memberships, err := s.Memberships().ListForUser(ctx, cc.User) + if err != nil { + return nil, huma.Error500InternalServerError("list", err) + } + out := &struct { + Body struct { + Results []orgBody `json:"results"` + } + }{} + for _, m := range memberships { + out.Body.Results = append(out.Body.Results, orgBody{ + ID: m.OrgID.String(), Slug: m.OrgSlug, Name: m.OrgName, Role: m.Role, + }) + } + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getOrg", + Method: "GET", + Path: "/orgs/{org_id}", + Summary: "Fetch an organisation by ID", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + OrgID string `path:"org_id"` + }) (*struct{ Body orgBody }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + orgID, err := ids.ParseOrg(in.OrgID) + if err != nil { + return nil, huma.Error400BadRequest("bad org id") + } + if err := requireOrgMember(ctx, s, cc, orgID); err != nil { + return nil, err + } + org, err := s.Orgs().GetByID(ctx, orgID) + if err != nil { + return nil, huma.Error404NotFound("org") + } + return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "patchOrg", + Method: "PATCH", + Path: "/orgs/{org_id}", + Summary: "Rename an organisation", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + OrgID string `path:"org_id"` + Body struct { + Name string `json:"name" minLength:"1" maxLength:"256"` + } + }) (*struct{ Body orgBody }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + orgID, err := ids.ParseOrg(in.OrgID) + if err != nil { + return nil, huma.Error400BadRequest("bad org id") + } + if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { + return nil, err + } + org, err := s.Orgs().UpdateName(ctx, orgID, in.Body.Name) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("org") + } + return nil, huma.Error500InternalServerError("update", err) + } + _ = s.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &orgID, ActorUserID: &cc.User, + Action: "org.rename", Target: orgID.String(), Result: "ok", + Detail: map[string]any{"name": in.Body.Name}, + }) + return &struct{ Body orgBody }{Body: toOrgBody(org)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "addMember", + Method: "POST", + Path: "/orgs/{org_id}/members", + Summary: "Add a user to an organisation", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + OrgID string `path:"org_id"` + Body struct { + UserID string `json:"user_id"` + Role string `json:"role" enum:"orgadmin,member" default:"member"` + } + }) (*struct{}, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + orgID, err := ids.ParseOrg(in.OrgID) + if err != nil { + return nil, huma.Error400BadRequest("bad org id") + } + if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { + return nil, err + } + userID, err := ids.ParseUser(in.Body.UserID) + if err != nil { + return nil, huma.Error400BadRequest("bad user id") + } + if err := s.Memberships().Add(ctx, orgID, userID, in.Body.Role); err != nil { + return nil, huma.Error500InternalServerError("add", err) + } + _ = s.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &orgID, ActorUserID: &cc.User, + Action: "org.member.add", Target: userID.String(), Result: "ok", + Detail: map[string]any{"role": in.Body.Role}, + }) + return &struct{}{}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "removeMember", + Method: "DELETE", + Path: "/orgs/{org_id}/members/{user_id}", + Summary: "Remove a user from an organisation", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + OrgID string `path:"org_id"` + UserID string `path:"user_id"` + }) (*struct{}, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + orgID, err := ids.ParseOrg(in.OrgID) + if err != nil { + return nil, huma.Error400BadRequest("bad org id") + } + if err := requireOrgAdmin(ctx, s, cc, orgID); err != nil { + return nil, err + } + userID, err := ids.ParseUser(in.UserID) + if err != nil { + return nil, huma.Error400BadRequest("bad user id") + } + if err := s.Memberships().Remove(ctx, orgID, userID); err != nil { + return nil, huma.Error500InternalServerError("remove", err) + } + _ = s.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &orgID, ActorUserID: &cc.User, + Action: "org.member.remove", Target: userID.String(), Result: "ok", + }) + return &struct{}{}, nil + }) +} + +type orgBody struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` +} + +func toOrgBody(o *store.Org) orgBody { + return orgBody{ + ID: o.ID.String(), Slug: o.Slug, Name: o.Name, CreatedAt: o.CreatedAt, + } +} + +// requireOrgMember fails with 403 unless cc is a member of orgID (or +// cc is a sysadmin — cross-org read is one of the sysadmin privileges). +func requireOrgMember(ctx context.Context, s store.Store, cc *auth.ClientContext, orgID ids.OrgID) error { + if cc.Role == "sysadmin" { + return nil + } + memberships, err := s.Memberships().ListForUser(ctx, cc.User) + if err != nil { + return huma.Error500InternalServerError("lookup membership", err) + } + for _, m := range memberships { + if m.OrgID == orgID { + return nil + } + } + return huma.Error403Forbidden("not a member of this org") +} + +// requireOrgAdmin tightens requireOrgMember: must be role=orgadmin in +// that specific org (sysadmins are always allowed). +func requireOrgAdmin(ctx context.Context, s store.Store, cc *auth.ClientContext, orgID ids.OrgID) error { + if cc.Role == "sysadmin" { + return nil + } + memberships, err := s.Memberships().ListForUser(ctx, cc.User) + if err != nil { + return huma.Error500InternalServerError("lookup membership", err) + } + for _, m := range memberships { + if m.OrgID == orgID && m.Role == store.RoleOrgAdmin { + return nil + } + } + return huma.Error403Forbidden("orgadmin required") +} diff --git a/internal/pkg/openapi/pins.go b/internal/pkg/openapi/pins.go new file mode 100644 index 0000000..449e7ff --- /dev/null +++ b/internal/pkg/openapi/pins.go @@ -0,0 +1,291 @@ +package openapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/danielgtaylor/huma/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/pin" + "anchorage/internal/pkg/store" +) + +// pinService is the subset of pin.Service the handlers need, defined as +// an interface so tests can stub it. +type pinService interface { + Create(ctx context.Context, in pin.CreateInput) (*store.Pin, []*store.Placement, error) + Delete(ctx context.Context, orgID ids.OrgID, rid ids.PinID) error + Replace(ctx context.Context, orgID ids.OrgID, rid ids.PinID, in pin.CreateInput) (*store.Pin, []*store.Placement, error) +} + +// parsePinFilter maps the spec's HTTP query params into a store.PinFilter. +// Returns a *time.Time (not string) for Before/After after RFC3339 parse; +// returns a map[string]any parsed from the meta JSON string. +func parsePinFilter(in *struct { + CID []string `query:"cid" maxItems:"10"` + Name string `query:"name"` + Match string `query:"match" enum:"exact,iexact,partial,ipartial" default:"exact"` + Status []string `query:"status"` + Before string `query:"before" doc:"RFC3339; return only pins created before this timestamp"` + After string `query:"after" doc:"RFC3339; return only pins created at or after this timestamp"` + Meta string `query:"meta" doc:"JSON object; pin.meta must contain every key/value"` + Limit int `query:"limit" default:"10" minimum:"1" maximum:"1000"` + Offset int `query:"offset" default:"0" minimum:"0"` +}) (store.PinFilter, error) { + f := store.PinFilter{ + CIDs: in.CID, + Name: in.Name, + Match: in.Match, + Status: in.Status, + Limit: in.Limit, + Offset: in.Offset, + } + if in.Before != "" { + t, err := time.Parse(time.RFC3339, in.Before) + if err != nil { + return f, fmt.Errorf("before: %w", err) + } + f.Before = &t + } + if in.After != "" { + t, err := time.Parse(time.RFC3339, in.After) + if err != nil { + return f, fmt.Errorf("after: %w", err) + } + f.After = &t + } + if in.Meta != "" { + if err := json.Unmarshal([]byte(in.Meta), &f.Meta); err != nil { + return f, fmt.Errorf("meta: %w", err) + } + } + return f, nil +} + +// pinStatusBody is the spec-compliant PinStatus response. +type pinStatusBody struct { + RequestID string `json:"requestid"` + Status string `json:"status" enum:"queued,pinning,pinned,failed"` + Created time.Time `json:"created"` + Pin pinBody `json:"pin"` + Delegates []string `json:"delegates"` + Info map[string]any `json:"info,omitempty"` +} + +type pinBody struct { + CID string `json:"cid"` + Name string `json:"name,omitempty"` + Origins []string `json:"origins,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +// RegisterPins wires the 5 spec-compliant pin endpoints onto api. +func RegisterPins(api huma.API, pins pinService, s store.Store) { + huma.Register(api, huma.Operation{ + OperationID: "createPin", + Method: "POST", + Path: "/pins", + Summary: "Create a pin request", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Body pinBody + }) (*struct { + Body pinStatusBody + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + created, placements, err := pins.Create(ctx, pin.CreateInput{ + OrgID: cc.Org, + CID: in.Body.CID, + Name: optString(in.Body.Name), + Meta: in.Body.Meta, + Origins: in.Body.Origins, + }) + if err != nil { + if !errors.Is(err, store.ErrConflict) { + return nil, huma.Error500InternalServerError("create pin", err) + } + // Idempotent re-add: load the existing pin's real placements + // so the caller gets accurate delegates, not the /unknown + // sentinel from an empty list. + if created != nil { + if existing, err := s.Pins().ListPlacements(ctx, created.RequestID); err == nil { + placements = existing + } + } + } + return &struct{ Body pinStatusBody }{Body: toBody(created, placements)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getPin", + Method: "GET", + Path: "/pins/{requestid}", + Summary: "Get a pin by request id", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + RequestID string `path:"requestid"` + }) (*struct{ Body pinStatusBody }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + rid, err := ids.ParsePin(in.RequestID) + if err != nil { + return nil, huma.Error400BadRequest("bad request id") + } + p, err := s.Pins().Get(ctx, cc.Org, rid) + if err != nil { + return nil, huma.Error404NotFound("pin") + } + pls, _ := s.Pins().ListPlacements(ctx, rid) + return &struct{ Body pinStatusBody }{Body: toBody(p, pls)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listPins", + Method: "GET", + Path: "/pins", + Summary: "List pins with the full spec-compliant filter set", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + CID []string `query:"cid" maxItems:"10"` + Name string `query:"name"` + Match string `query:"match" enum:"exact,iexact,partial,ipartial" default:"exact"` + Status []string `query:"status"` + Before string `query:"before" doc:"RFC3339; return only pins created before this timestamp"` + After string `query:"after" doc:"RFC3339; return only pins created at or after this timestamp"` + Meta string `query:"meta" doc:"JSON object; pin.meta must contain every key/value"` + Limit int `query:"limit" default:"10" minimum:"1" maximum:"1000"` + Offset int `query:"offset" default:"0" minimum:"0"` + }) (*struct { + Body struct { + Results []pinStatusBody `json:"results"` + } + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + filter, err := parsePinFilter(in) + if err != nil { + return nil, huma.Error400BadRequest("bad filter", err) + } + pins, err := s.Pins().Filter(ctx, cc.Org, filter) + if err != nil { + return nil, huma.Error500InternalServerError("list pins", err) + } + out := &struct { + Body struct { + Results []pinStatusBody `json:"results"` + } + }{} + for _, p := range pins { + pls, _ := s.Pins().ListPlacements(ctx, p.RequestID) + out.Body.Results = append(out.Body.Results, toBody(p, pls)) + } + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "replacePin", + Method: "POST", + Path: "/pins/{requestid}", + Summary: "Replace a pin's CID (spec-compliant pin update)", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + RequestID string `path:"requestid"` + Body pinBody + }) (*struct{ Body pinStatusBody }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + rid, err := ids.ParsePin(in.RequestID) + if err != nil { + return nil, huma.Error400BadRequest("bad request id") + } + replaced, placements, err := pins.Replace(ctx, cc.Org, rid, pin.CreateInput{ + OrgID: cc.Org, + CID: in.Body.CID, + Name: optString(in.Body.Name), + Meta: in.Body.Meta, + Origins: in.Body.Origins, + }) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("pin") + } + return nil, huma.Error500InternalServerError("replace pin", err) + } + return &struct{ Body pinStatusBody }{Body: toBody(replaced, placements)}, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deletePin", + Method: "DELETE", + Path: "/pins/{requestid}", + Summary: "Remove a pin", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + RequestID string `path:"requestid"` + }) (*struct{}, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + rid, err := ids.ParsePin(in.RequestID) + if err != nil { + return nil, huma.Error400BadRequest("bad request id") + } + if err := pins.Delete(ctx, cc.Org, rid); err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("pin") + } + return nil, huma.Error500InternalServerError("delete pin", err) + } + return &struct{}{}, nil + }) +} + +func toBody(p *store.Pin, placements []*store.Placement) pinStatusBody { + if p == nil { + return pinStatusBody{} + } + delegates := make([]string, 0, len(placements)) + for _, pl := range placements { + delegates = append(delegates, pl.Multiaddrs...) + } + if len(delegates) == 0 { + delegates = []string{"/unknown"} + } + b := pinStatusBody{ + RequestID: p.RequestID.String(), + Status: p.Status, + Created: p.Created, + Pin: pinBody{ + CID: p.CID, + Origins: p.Origins, + Meta: p.Meta, + }, + Delegates: delegates, + } + if p.Name != nil { + b.Pin.Name = *p.Name + } + return b +} + +func optString(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/pkg/openapi/session.go b/internal/pkg/openapi/session.go new file mode 100644 index 0000000..d317026 --- /dev/null +++ b/internal/pkg/openapi/session.go @@ -0,0 +1,306 @@ +package openapi + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/token" +) + +// SessionDeps wires the session endpoints to the store, the token +// signer, and the Authentik verifier. +type SessionDeps struct { + Store store.Store + Signer *token.Signer + // OIDC may be nil in environments without Authentik (dev, tests). + // The session POST will 503 in that case with a clear message. + OIDC *auth.OIDCVerifier + // Sysadmins is the list of emails that get promoted to sysadmin on + // their first login — mirrors config.BootstrapConfig.Sysadmins. + Sysadmins []string + // SessionTTL controls how long the issued session JWT lives. + SessionTTL time.Duration + // CookieSecure controls whether the Set-Cookie uses `Secure`. True + // in production; false is only appropriate for plain-HTTP local dev. + CookieSecure bool +} + +// RegisterSession wires POST /auth/session, DELETE /auth/session, and +// GET /me onto api. +func RegisterSession(api huma.API, deps SessionDeps) { + // --- POST /auth/session ------------------------------------------------ + huma.Register(api, huma.Operation{ + OperationID: "createSession", + Method: "POST", + Path: "/auth/session", + Summary: "Exchange an Authentik ID token for an anchorage session", + Description: `The web UI completes Authorization Code + PKCE against Authentik, +then POSTs the resulting ID token here. anchorage validates the +signature + issuer + audience against the JWKS, upserts the user, +promotes bootstrap sysadmins on first login, and returns a session +JWT as an HttpOnly cookie.`, + }, func(ctx context.Context, in *struct { + Body createSessionRequest + }) (*createSessionResponse, error) { + if deps.OIDC == nil { + return nil, huma.Error503ServiceUnavailable( + "auth: OIDC is not configured — set auth.authentik.issuer/clientID/audience in anchorage.yaml") + } + + claims, err := deps.OIDC.Verify(ctx, in.Body.IDToken) + if err != nil { + return nil, huma.Error401Unauthorized("auth: " + err.Error()) + } + + user, err := upsertUser(ctx, deps, claims) + if err != nil { + return nil, huma.Error500InternalServerError("auth: upsert user", err) + } + + sessionJWT, sessionClaims, err := mintSession(ctx, deps, user) + if err != nil { + return nil, huma.Error500InternalServerError("auth: mint session", err) + } + + out := &createSessionResponse{} + out.SetCookie = sessionCookieHeader(sessionJWT, deps) + out.Body.User = userBody(user) + out.Body.ExpiresAt = sessionClaims.ExpiresAt.Time + return out, nil + }) + + // --- DELETE /auth/session ---------------------------------------------- + huma.Register(api, huma.Operation{ + OperationID: "deleteSession", + Method: "DELETE", + Path: "/auth/session", + Summary: "Log out — revoke the session cookie", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*deleteSessionResponse, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + // Add the jti to the denylist so the cookie-bearer can't keep + // using the raw JWT elsewhere until natural expiry. + if cc.TokenJTI != nil { + expiry := time.Now().Add(deps.SessionTTL) + if err := deps.Store.Tokens().AddDenylist(ctx, *cc.TokenJTI, expiry, "logout"); err != nil { + // Non-fatal: clearing the cookie still improves the user's + // situation even if denylist write fails. + _ = err + } + } + out := &deleteSessionResponse{} + // Empty Value + MaxAge=-1 tells the browser to drop the cookie. + out.SetCookie = clearCookieHeader(deps) + return out, nil + }) + + // --- GET /me ---------------------------------------------------------- + huma.Register(api, huma.Operation{ + OperationID: "getMe", + Method: "GET", + Path: "/me", + Summary: "Current authenticated user", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body meBody + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + user, err := deps.Store.Users().GetByID(ctx, cc.User) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("user") + } + return nil, huma.Error500InternalServerError("get user", err) + } + memberships, err := deps.Store.Memberships().ListForUser(ctx, cc.User) + if err != nil { + return nil, huma.Error500InternalServerError("list memberships", err) + } + out := &struct{ Body meBody }{} + out.Body.User = userBody(user) + out.Body.Role = cc.Role + for _, m := range memberships { + out.Body.Memberships = append(out.Body.Memberships, membershipBody{ + OrgID: m.OrgID.String(), + OrgSlug: m.OrgSlug, + OrgName: m.OrgName, + Role: m.Role, + }) + } + return out, nil + }) +} + +// --------------------------------------------------------------------------- +// Request / response bodies +// --------------------------------------------------------------------------- + +type createSessionRequest struct { + // IDToken is the raw JWT returned by Authentik at the end of the + // Authorization Code + PKCE dance. + IDToken string `json:"id_token" minLength:"32"` +} + +type createSessionResponse struct { + // SetCookie is emitted as a Set-Cookie header so the browser picks + // up the session JWT. huma knows `header:"Set-Cookie"` means a + // response header, one entry per cookie. + SetCookie string `header:"Set-Cookie"` + Body struct { + User userBodyT `json:"user"` + ExpiresAt time.Time `json:"expires_at"` + } +} + +type deleteSessionResponse struct { + SetCookie string `header:"Set-Cookie"` +} + +type meBody struct { + User userBodyT `json:"user"` + Role string `json:"role"` + Memberships []membershipBody `json:"memberships"` +} + +type userBodyT struct { + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + IsSysadmin bool `json:"is_sysadmin"` +} + +type membershipBody struct { + OrgID string `json:"org_id"` + OrgSlug string `json:"org_slug"` + OrgName string `json:"org_name"` + Role string `json:"role"` +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func upsertUser(ctx context.Context, deps SessionDeps, claims *auth.OIDCClaims) (*store.User, error) { + // Check if this Authentik subject already maps to a user. If yes, + // we don't need a fresh TypeID — upsert keeps the existing one. + existing, err := deps.Store.Users().GetByEmail(ctx, claims.Email) + var userID ids.UserID + switch { + case err == nil: + userID = existing.ID + case errors.Is(err, store.ErrNotFound): + userID, err = ids.NewUser() + if err != nil { + return nil, err + } + default: + return nil, err + } + + isSysadmin := bootstrapSysadmin(claims.Email, deps.Sysadmins) + // Preserve existing sysadmin status (don't demote an admin just + // because their email was removed from config). + if existing != nil && existing.IsSysadmin { + isSysadmin = true + } + + return deps.Store.Users().UpsertByAuthentikSub(ctx, userID, claims.Sub, claims.Email, claims.DisplayName(), isSysadmin) +} + +// bootstrapSysadmin matches the email case-insensitively against the +// config list. Authentik is case-preserving on the `email` claim which +// is a common footgun when operators type the email with different +// capitalisation in config vs. Authentik. +func bootstrapSysadmin(email string, sysadmins []string) bool { + target := strings.ToLower(strings.TrimSpace(email)) + for _, s := range sysadmins { + if strings.ToLower(strings.TrimSpace(s)) == target { + return true + } + } + return false +} + +// mintSession mints an anchorage-signed JWT representing the session. +// The role comes from the user's sysadmin flag; non-sysadmins default +// to "member" and attach the first membership's org if any exists (so +// the handlers can resolve cc.Org without a separate lookup). +func mintSession(ctx context.Context, deps SessionDeps, user *store.User) (string, *token.Claims, error) { + role := store.RoleMember + if user.IsSysadmin { + role = "sysadmin" + } + + var orgID ids.OrgID + memberships, _ := deps.Store.Memberships().ListForUser(ctx, user.ID) + if len(memberships) > 0 { + orgID = memberships[0].OrgID + } else { + // No org yet — synthetic TypeID so the JWT is well-formed. The + // user has to be granted org membership (or promoted sysadmin) + // before this token is useful for tenant-scoped endpoints. + fresh, err := ids.NewOrg() + if err != nil { + return "", nil, err + } + orgID = fresh + } + + ttl := deps.SessionTTL + if ttl <= 0 { + ttl = 24 * time.Hour + } + return deps.Signer.Mint(ctx, orgID, user.ID, role, nil, ttl) +} + +func sessionCookieHeader(jwtValue string, deps SessionDeps) string { + parts := []string{ + fmt.Sprintf("%s=%s", auth.SessionCookieName, jwtValue), + "Path=/", + "HttpOnly", + "SameSite=Lax", + fmt.Sprintf("Max-Age=%d", int(deps.SessionTTL.Seconds())), + } + if deps.CookieSecure { + parts = append(parts, "Secure") + } + return strings.Join(parts, "; ") +} + +func clearCookieHeader(deps SessionDeps) string { + parts := []string{ + fmt.Sprintf("%s=", auth.SessionCookieName), + "Path=/", + "HttpOnly", + "SameSite=Lax", + "Max-Age=0", + } + if deps.CookieSecure { + parts = append(parts, "Secure") + } + return strings.Join(parts, "; ") +} + +func userBody(u *store.User) userBodyT { + return userBodyT{ + ID: u.ID.String(), + Email: u.Email, + DisplayName: u.DisplayName, + IsSysadmin: u.IsSysadmin, + } +} + diff --git a/internal/pkg/openapi/tokens.go b/internal/pkg/openapi/tokens.go new file mode 100644 index 0000000..28fd548 --- /dev/null +++ b/internal/pkg/openapi/tokens.go @@ -0,0 +1,185 @@ +package openapi + +import ( + "context" + "errors" + "time" + + "github.com/danielgtaylor/huma/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/token" +) + +// TokenDeps wires token endpoints to the store + signer. +type TokenDeps struct { + Store store.Store + Signer *token.Signer + DefaultTTL time.Duration + MaxTTL time.Duration +} + +// RegisterTokens wires POST / GET / DELETE /tokens onto api. +// +// POST /tokens is scoped to an org — the session JWT already carries +// cc.Org, so the minted API token inherits that org. Sysadmins with no +// memberships cannot use this endpoint; they either mint offline via +// `anchorage admin mint-token` or create an org first. +func RegisterTokens(api huma.API, deps TokenDeps) { + huma.Register(api, huma.Operation{ + OperationID: "createToken", + Method: "POST", + Path: "/tokens", + Summary: "Mint an API token tied to the caller's current org + role", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + Body struct { + Label string `json:"label" minLength:"1" maxLength:"128"` + Scopes []string `json:"scopes,omitempty"` + TTLHours int `json:"ttl_hours,omitempty" minimum:"1"` + } + }) (*struct { + Body struct { + Token string `json:"token"` + JTI string `json:"jti"` + ExpiresAt time.Time `json:"expires_at"` + } + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + + ttl := time.Duration(in.Body.TTLHours) * time.Hour + if ttl <= 0 { + ttl = deps.DefaultTTL + } + if deps.MaxTTL > 0 && ttl > deps.MaxTTL { + return nil, huma.Error400BadRequest("ttl exceeds auth.apiToken.maxTTL") + } + + signed, claims, err := deps.Signer.Mint(ctx, cc.Org, cc.User, cc.Role, in.Body.Scopes, ttl) + if err != nil { + return nil, huma.Error500InternalServerError("mint", err) + } + + jti, err := ids.ParseToken(claims.ID) + if err != nil { + return nil, huma.Error500InternalServerError("parse jti", err) + } + // Persist metadata so the user can list + revoke later. The + // raw token itself is NOT stored. + if err := deps.Store.Tokens().Create(ctx, &store.APIToken{ + JTI: jti, OrgID: cc.Org, UserID: cc.User, + Label: in.Body.Label, Scopes: in.Body.Scopes, + ExpiresAt: claims.ExpiresAt.Time, + }); err != nil { + return nil, huma.Error500InternalServerError("persist", err) + } + _ = deps.Store.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &cc.Org, ActorUserID: &cc.User, + Action: "token.create", Target: jti.String(), Result: "ok", + Detail: map[string]any{"label": in.Body.Label, "ttl": ttl.String()}, + }) + + out := &struct { + Body struct { + Token string `json:"token"` + JTI string `json:"jti"` + ExpiresAt time.Time `json:"expires_at"` + } + }{} + out.Body.Token = signed + out.Body.JTI = jti.String() + out.Body.ExpiresAt = claims.ExpiresAt.Time + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listMyTokens", + Method: "GET", + Path: "/tokens", + Summary: "List API tokens minted by the caller", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, _ *struct{}) (*struct { + Body struct { + Results []tokenBody `json:"results"` + } + }, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + rows, err := deps.Store.Tokens().ListForUser(ctx, cc.Org, cc.User) + if err != nil { + return nil, huma.Error500InternalServerError("list", err) + } + out := &struct { + Body struct { + Results []tokenBody `json:"results"` + } + }{} + for _, t := range rows { + out.Body.Results = append(out.Body.Results, tokenBody{ + JTI: t.JTI.String(), + Label: t.Label, + Scopes: t.Scopes, + ExpiresAt: t.ExpiresAt, + CreatedAt: t.CreatedAt, + LastUsedAt: t.LastUsedAt, + RevokedAt: t.RevokedAt, + }) + } + return out, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "revokeToken", + Method: "DELETE", + Path: "/tokens/{jti}", + Summary: "Revoke an API token (adds the jti to the denylist)", + Security: []map[string][]string{{"accessToken": {}}}, + }, func(ctx context.Context, in *struct { + JTI string `path:"jti"` + }) (*struct{}, error) { + cc, err := auth.Require(ctx) + if err != nil { + return nil, huma.Error401Unauthorized("auth") + } + jti, err := ids.ParseToken(in.JTI) + if err != nil { + return nil, huma.Error400BadRequest("bad jti") + } + existing, err := deps.Store.Tokens().GetByJTI(ctx, jti) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, huma.Error404NotFound("token") + } + return nil, huma.Error500InternalServerError("lookup", err) + } + // Users can only revoke their own tokens; sysadmins can revoke any. + if cc.Role != "sysadmin" && existing.UserID != cc.User { + return nil, huma.Error403Forbidden("not your token") + } + if err := deps.Signer.Revoke(ctx, jti, existing.ExpiresAt, "user-initiated"); err != nil { + return nil, huma.Error500InternalServerError("revoke", err) + } + _ = deps.Store.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &existing.OrgID, ActorUserID: &cc.User, + Action: "token.revoke", Target: jti.String(), Result: "ok", + }) + return &struct{}{}, nil + }) +} + +type tokenBody struct { + JTI string `json:"jti"` + Label string `json:"label"` + Scopes []string `json:"scopes,omitempty"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} diff --git a/internal/pkg/pin/livenodes.go b/internal/pkg/pin/livenodes.go new file mode 100644 index 0000000..b3e9b26 --- /dev/null +++ b/internal/pkg/pin/livenodes.go @@ -0,0 +1,112 @@ +package pin + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "anchorage/internal/pkg/cache" + "anchorage/internal/pkg/store" +) + +// LiveNodesSource abstracts how the pin service discovers the current +// live node set. Production injects a caching wrapper; tests can pass +// a fake that returns a static slice. +type LiveNodesSource interface { + Live(ctx context.Context) ([]*store.Node, error) +} + +// storeLiveNodes is the trivial pass-through (no cache). +type storeLiveNodes struct{ s store.Store } + +func (s storeLiveNodes) Live(ctx context.Context) ([]*store.Node, error) { + return s.s.Nodes().ListLive(ctx) +} + +// CachedLiveNodes is a bounded, TTL'd cache over store.NodeStore.ListLive. +// +// This is the "placement/live-nodes" cache from the plan's caching +// table. The value is a single slice, so MaxCost is trivial; the +// motivation is latency: POST /v1/pins must not block on a Postgres +// round-trip per request to compute rendezvous placements. +// +// Freshness is controlled by two signals: +// +// 1. A short TTL (default 5s) bounds staleness unconditionally. +// 2. Invalidate() drops the cached value immediately; the node +// package's heartbeat consumer calls it on every heartbeat so the +// in-memory view converges within a heartbeat interval. +type CachedLiveNodes struct { + s store.Store + ttl time.Duration + + mu sync.Mutex + cache []*store.Node + cachedAt time.Time + + // Counters for /v1/admin/cache-stats. Atomics so Stats() can read + // without holding mu. + hits atomic.Uint64 + misses atomic.Uint64 +} + +// NewCachedLiveNodes constructs a cache with the given TTL. ttl <= 0 +// disables caching and every call passes through. +// +// The cache auto-registers with cache.Register so its counters show up +// in /v1/admin/cache-stats alongside any ristretto-backed caches. +func NewCachedLiveNodes(s store.Store, ttl time.Duration) *CachedLiveNodes { + c := &CachedLiveNodes{s: s, ttl: ttl} + cache.Register(c) + return c +} + +// Stats implements cache.StatsProvider. Only Hits/Misses are populated +// (the live-nodes cache is a single slice, so the keys/cost counters +// don't really apply). +func (c *CachedLiveNodes) Stats() cache.Stats { + return cache.Stats{ + Name: "placement/live-nodes", + Hits: c.hits.Load(), + Misses: c.misses.Load(), + } +} + +// Live returns the live node list, hitting the store only when the +// cached copy is missing or expired. +func (c *CachedLiveNodes) Live(ctx context.Context) ([]*store.Node, error) { + if c.ttl <= 0 { + c.misses.Add(1) + return c.s.Nodes().ListLive(ctx) + } + c.mu.Lock() + if c.cache != nil && time.Since(c.cachedAt) < c.ttl { + out := append([]*store.Node(nil), c.cache...) + c.mu.Unlock() + c.hits.Add(1) + return out, nil + } + c.mu.Unlock() + + c.misses.Add(1) + fresh, err := c.s.Nodes().ListLive(ctx) + if err != nil { + return nil, err + } + c.mu.Lock() + c.cache = append([]*store.Node(nil), fresh...) + c.cachedAt = time.Now() + c.mu.Unlock() + return fresh, nil +} + +// Invalidate drops the cached snapshot so the next Live() re-fetches. +// Intended to be called from the node heartbeat consumer whenever a +// peer's presence changes. +func (c *CachedLiveNodes) Invalidate() { + c.mu.Lock() + c.cache = nil + c.cachedAt = time.Time{} + c.mu.Unlock() +} diff --git a/internal/pkg/pin/service.go b/internal/pkg/pin/service.go new file mode 100644 index 0000000..ae9a373 --- /dev/null +++ b/internal/pkg/pin/service.go @@ -0,0 +1,427 @@ +// Package pin owns anchorage's pin-creation and placement logic. +// +// Service implements the commit-point rules from the plan: +// +// 1. One Postgres transaction per POST /v1/pins: upserts the pins row, +// computes placements via rendezvous hashing, inserts pin_placements +// rows with fence=1, bumps pin_refcount for each (node, cid), writes +// an audit row. +// 2. On commit, publishes one pin.jobs. message per placement +// with Nats-Msg-Id = :: for JetStream dedup. +// +// The NATS publisher is optional: if nil (tests, standalone mode), the +// transaction still commits but placements stay in 'queued' forever +// until someone wakes them up. +package pin + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + + "github.com/nats-io/nats.go" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/metrics" + "anchorage/internal/pkg/placement" + "anchorage/internal/pkg/store" +) + +// JobsSubjectPrefix is the NATS subject prefix for per-node pin work. +const JobsSubjectPrefix = "pin.jobs" + +// EventsSubjectPrefix is the NATS subject prefix for pin status fan-out. +const EventsSubjectPrefix = "pin.events" + +// Job is the body of a pin.jobs. message. +type Job struct { + RequestID string `json:"request_id"` + OrgID string `json:"org_id"` + CID string `json:"cid"` + Origins []string `json:"origins,omitempty"` + Fence int64 `json:"fence"` + // Action = "pin" or "unpin". anchorage uses the same subject for + // both so the consumer has one code path. + Action string `json:"action"` +} + +// Event is the body of a pin.events.. message. +type Event struct { + RequestID string `json:"request_id"` + OrgID string `json:"org_id"` + Status string `json:"status"` + NodeID string `json:"node_id,omitempty"` + Reason string `json:"reason,omitempty"` +} + +// Options configures the Service. +type Options struct { + MinReplicas int + NC *nats.Conn + // LiveNodes overrides how live-node discovery happens. nil falls back + // to a pass-through store lookup on every POST /v1/pins; production + // passes a *CachedLiveNodes so the hot path doesn't hit Postgres. + LiveNodes LiveNodesSource +} + +// Service is the pin-creation engine. +type Service struct { + opts Options + store store.Store + live LiveNodesSource +} + +// NewService constructs a Service. opts.MinReplicas must be >= 1. +func NewService(s store.Store, opts Options) (*Service, error) { + if opts.MinReplicas < 1 { + return nil, errors.New("pin: MinReplicas must be >= 1") + } + live := opts.LiveNodes + if live == nil { + live = storeLiveNodes{s: s} + } + return &Service{opts: opts, store: s, live: live}, nil +} + +// CreateInput mirrors the spec's PinCreate body plus org context. +type CreateInput struct { + OrgID ids.OrgID + CID string + Name *string + Meta map[string]any + Origins []string +} + +// Create is the atomic pin-creation path. Returns the created Pin and +// the list of placements so the caller can report them back to the user. +// +// Idempotency: if a live pin already exists for (orgID, cid), Create +// returns that existing record with store.ErrConflict so the caller +// can map it to the spec's requirement of returning the existing +// requestid on duplicates. ErrConflict is not counted as an error in +// the PinOps metric — it's a valid idempotent-read outcome. +func (s *Service) Create(ctx context.Context, in CreateInput) (created *store.Pin, placements []*store.Placement, err error) { + defer observePinErr("create", &err) + if in.CID == "" { + return nil, nil, errors.New("pin: CID is required") + } + if existing, err := s.store.Pins().GetLiveByCID(ctx, in.OrgID, in.CID); err == nil { + return existing, nil, store.ErrConflict + } else if !errors.Is(err, store.ErrNotFound) { + return nil, nil, err + } + + liveNodes, err := s.live.Live(ctx) + if err != nil { + return nil, nil, fmt.Errorf("pin: list live nodes: %w", err) + } + if len(liveNodes) == 0 { + return nil, nil, errors.New("pin: no live nodes available for placement") + } + + candidates := make([]placement.Node, 0, len(liveNodes)) + for _, n := range liveNodes { + candidates = append(candidates, placement.Node{ID: n.ID.String(), Status: n.Status}) + } + want := s.opts.MinReplicas + if want > len(candidates) { + want = len(candidates) + } + selected := placement.Compute(in.OrgID.String(), in.CID, want, candidates) + + pinID, err := ids.NewPin() + if err != nil { + return nil, nil, err + } + newPin := &store.Pin{ + RequestID: pinID, + OrgID: in.OrgID, + CID: in.CID, + Name: in.Name, + Meta: in.Meta, + Origins: in.Origins, + Status: store.PinStatusQueued, + } + + err = s.store.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Create(txCtx, newPin); err != nil { + return err + } + for _, nodeStr := range selected { + nodeID, err := ids.ParseNode(nodeStr) + if err != nil { + return err + } + pl, err := tx.Pins().InsertPlacement(txCtx, pinID, nodeID, 1) + if err != nil { + return err + } + if err := tx.Pins().IncRefcount(txCtx, nodeID, in.CID); err != nil { + return err + } + placements = append(placements, pl) + } + return tx.Audit().Insert(txCtx, &store.AuditEntry{ + OrgID: &in.OrgID, + Action: "pin.create", + Target: pinID.String(), + Result: "ok", + Detail: map[string]any{"cid": in.CID, "placements": selected}, + }) + }) + if err != nil { + return nil, nil, err + } + + // Publish per-placement jobs only after commit succeeds. + s.publishJobs(newPin, placements) + + metrics.PinOps.WithLabelValues("create", "ok").Inc() + return newPin, placements, nil +} + +// Replace swaps a pin's CID + related metadata atomically, reshuffling +// placements and refcounts inside a single Postgres transaction. Unpin +// jobs for the old CID and pin jobs for the new CID are published +// after commit. +// +// Spec: POST /v1/pins/{requestid}. +func (s *Service) Replace(ctx context.Context, orgID ids.OrgID, rid ids.PinID, in CreateInput) (replaced *store.Pin, placements []*store.Placement, err error) { + defer observePinErr("replace", &err) + if in.CID == "" { + return nil, nil, errors.New("pin: CID is required") + } + + // Fetch old placements first — Delete-in-tx + cascade would lose + // them before we get a chance to issue unpin jobs. + oldPlacements, err := s.store.Pins().ListPlacements(ctx, rid) + if err != nil { + return nil, nil, err + } + + // Compute new placements from the current live-node set. + liveNodes, err := s.live.Live(ctx) + if err != nil { + return nil, nil, fmt.Errorf("pin: list live nodes: %w", err) + } + if len(liveNodes) == 0 { + return nil, nil, errors.New("pin: no live nodes available for placement") + } + candidates := make([]placement.Node, 0, len(liveNodes)) + for _, n := range liveNodes { + candidates = append(candidates, placement.Node{ID: n.ID.String(), Status: n.Status}) + } + want := s.opts.MinReplicas + if want > len(candidates) { + want = len(candidates) + } + selected := placement.Compute(orgID.String(), in.CID, want, candidates) + + var ( + newPin *store.Pin + newPlacements []*store.Placement + oldCID string + ) + err = s.store.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + existing, err := tx.Pins().Get(txCtx, orgID, rid) + if err != nil { + return err + } + oldCID = existing.CID + + // Decrement + GC old refcounts so a replace that keeps the pin + // on the same nodes doesn't double-count. + for _, pl := range oldPlacements { + if _, err := tx.Pins().DecRefcount(txCtx, pl.NodeID, existing.CID); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + _ = tx.Pins().DeleteRefcountIfZero(txCtx, pl.NodeID, existing.CID) + } + + replaced, err := tx.Pins().Replace(txCtx, orgID, rid, in.CID, in.Name, in.Meta, in.Origins) + if err != nil { + return err + } + newPin = replaced + + // Drop old placement rows that are NOT reused, insert placement + // rows for the new set. Fence monotonically bumps via + // ReplacePlacement so any in-flight worker on the old fence + // no-ops its completion. + keep := map[ids.NodeID]bool{} + for _, nodeStr := range selected { + nodeID, perr := ids.ParseNode(nodeStr) + if perr != nil { + return perr + } + keep[nodeID] = true + } + for _, pl := range oldPlacements { + if keep[pl.NodeID] { + continue + } + // Non-kept old placement: the CASCADE on Delete below will + // drop it. Nothing to do here. + _ = pl + } + for _, nodeStr := range selected { + nodeID, _ := ids.ParseNode(nodeStr) + // If placement already exists, ReplacePlacement bumps the + // fence; otherwise insert fresh. + if _, err := tx.Pins().GetPlacement(txCtx, rid, nodeID); err == nil { + bumped, err := tx.Pins().ReplacePlacement(txCtx, rid, nodeID, nodeID) + if err != nil { + return err + } + newPlacements = append(newPlacements, bumped) + } else { + pl, err := tx.Pins().InsertPlacement(txCtx, rid, nodeID, 1) + if err != nil { + return err + } + newPlacements = append(newPlacements, pl) + } + if err := tx.Pins().IncRefcount(txCtx, nodeID, in.CID); err != nil { + return err + } + } + return tx.Audit().Insert(txCtx, &store.AuditEntry{ + OrgID: &orgID, + Action: "pin.replace", + Target: rid.String(), + Result: "ok", + Detail: map[string]any{"from_cid": existing.CID, "to_cid": in.CID, "placements": selected}, + }) + }) + if err != nil { + return nil, nil, err + } + + // Unpin jobs for the old placements that are no longer kept. + newNodes := map[ids.NodeID]bool{} + for _, pl := range newPlacements { + newNodes[pl.NodeID] = true + } + for _, pl := range oldPlacements { + if newNodes[pl.NodeID] { + continue + } + s.publishJob(Job{ + RequestID: rid.String(), + OrgID: orgID.String(), + CID: oldCID, + Fence: pl.Fence, + Action: "unpin", + }, pl.NodeID.String()) + } + // Pin jobs for the new placements. + s.publishJobs(newPin, newPlacements) + metrics.PinOps.WithLabelValues("replace", "ok").Inc() + return newPin, newPlacements, nil +} + +// Delete removes the pin and publishes unpin jobs for each placement. +func (s *Service) Delete(ctx context.Context, orgID ids.OrgID, rid ids.PinID) (err error) { + defer observePinErr("delete", &err) + pin, err := s.store.Pins().Get(ctx, orgID, rid) + if err != nil { + return err + } + placements, err := s.store.Pins().ListPlacements(ctx, rid) + if err != nil { + return err + } + err = s.store.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Delete(txCtx, orgID, rid); err != nil { + return err + } + return tx.Audit().Insert(txCtx, &store.AuditEntry{ + OrgID: &orgID, + Action: "pin.delete", + Target: rid.String(), + Result: "ok", + }) + }) + if err != nil { + return err + } + for _, pl := range placements { + s.publishJob(Job{ + RequestID: rid.String(), + OrgID: orgID.String(), + CID: pin.CID, + Fence: pl.Fence, + Action: "unpin", + }, pl.NodeID.String()) + } + metrics.PinOps.WithLabelValues("delete", "ok").Inc() + return nil +} + +func (s *Service) publishJobs(pin *store.Pin, placements []*store.Placement) { + for _, pl := range placements { + s.publishJob(Job{ + RequestID: pin.RequestID.String(), + OrgID: pin.OrgID.String(), + CID: pin.CID, + Origins: pin.Origins, + Fence: pl.Fence, + Action: "pin", + }, pl.NodeID.String()) + } +} + +func (s *Service) publishJob(j Job, nodeID string) { + if s.opts.NC == nil { + slog.Debug("pin: NC nil, job not published", "request_id", j.RequestID, "node_id", nodeID) + return + } + body, err := json.Marshal(j) + if err != nil { + slog.Error("pin: marshal job", "err", err) + return + } + msg := &nats.Msg{ + Subject: fmt.Sprintf("%s.%s", JobsSubjectPrefix, nodeID), + Data: body, + Header: nats.Header{}, + } + // JetStream publisher-side dedup: a retry within the dedup window is + // absorbed by the broker. + msg.Header.Set("Nats-Msg-Id", fmt.Sprintf("%s:%s:%d", j.RequestID, nodeID, j.Fence)) + if err := s.opts.NC.PublishMsg(msg); err != nil { + slog.Error("pin: publish job", "err", err, "subject", msg.Subject) + } +} + +// observePinErr is deferred by every public Service method; when the +// named err return is non-nil (and not ErrConflict, which is a valid +// idempotent outcome), it increments the PinOps error counter. Success +// paths stay explicit at the end of each method. +func observePinErr(op string, errPtr *error) { + if errPtr == nil || *errPtr == nil { + return + } + if errors.Is(*errPtr, store.ErrConflict) { + return + } + metrics.PinOps.WithLabelValues(op, "err").Inc() +} + +// PublishEvent fans a pin status change out to WebSocket subscribers. +func PublishEvent(nc *nats.Conn, e Event) { + if nc == nil { + return + } + body, err := json.Marshal(e) + if err != nil { + slog.Error("pin: marshal event", "err", err) + return + } + subj := fmt.Sprintf("%s.%s.%s", EventsSubjectPrefix, e.OrgID, e.RequestID) + if err := nc.Publish(subj, body); err != nil { + slog.Warn("pin: publish event", "err", err, "subject", subj) + } +} diff --git a/internal/pkg/placement/rendezvous.go b/internal/pkg/placement/rendezvous.go new file mode 100644 index 0000000..bafdd93 --- /dev/null +++ b/internal/pkg/placement/rendezvous.go @@ -0,0 +1,80 @@ +// Package placement selects which cluster nodes should hold a given pin. +// +// anchorage uses Rendezvous (Highest Random Weight, HRW) hashing over the +// tuple (orgID, cid, nodeID): every (node, pin) pair gets a deterministic +// score, and the top min(replicas, liveNodes) nodes are chosen. +// +// Properties: +// +// - Deterministic: any node can compute "where does this pin belong" +// without coordination, given the same live-node set. +// - Minimal disruption: when a node joins or leaves, only pins whose top +// replica set actually changes get moved; on average a fraction of +// 1/N pins migrate, not all of them. +// - No coordination: unlike consistent hashing with ring state, HRW is +// pure function of the inputs, so there's no "ring" to synchronize. +package placement + +import ( + "sort" + + "github.com/zeebo/xxh3" +) + +// Node is the minimal view of a cluster node that placement needs. +type Node struct { + ID string + // Status is used only by callers to pre-filter; placement treats + // every node it's given as eligible. + Status string +} + +// Compute returns up to wantReplicas distinct node IDs chosen via HRW +// over (orgID, cid, nodeID). The returned slice is sorted by score +// descending; callers can take the first N for the replica set. +// +// If len(nodes) < wantReplicas, all nodes are returned (caller decides +// whether to accept under-replication). +func Compute(orgID, cid string, wantReplicas int, nodes []Node) []string { + if wantReplicas <= 0 || len(nodes) == 0 { + return nil + } + scored := make([]scoredNode, 0, len(nodes)) + for _, n := range nodes { + scored = append(scored, scoredNode{id: n.ID, score: score(orgID, cid, n.ID)}) + } + sort.Slice(scored, func(i, j int) bool { + if scored[i].score == scored[j].score { + return scored[i].id < scored[j].id + } + return scored[i].score > scored[j].score + }) + + n := wantReplicas + if n > len(scored) { + n = len(scored) + } + out := make([]string, n) + for i := 0; i < n; i++ { + out[i] = scored[i].id + } + return out +} + +type scoredNode struct { + id string + score uint64 +} + +// score computes the HRW weight for a (org, cid, node) triple using +// xxh3, which is fast, 64-bit, and well-distributed. The scalar weight +// is the hash itself; higher = closer to the top. +func score(orgID, cid, nodeID string) uint64 { + h := xxh3.New() + _, _ = h.WriteString(orgID) + _, _ = h.WriteString("\x00") + _, _ = h.WriteString(cid) + _, _ = h.WriteString("\x00") + _, _ = h.WriteString(nodeID) + return h.Sum64() +} diff --git a/internal/pkg/placement/rendezvous_test.go b/internal/pkg/placement/rendezvous_test.go new file mode 100644 index 0000000..e1bbbe1 --- /dev/null +++ b/internal/pkg/placement/rendezvous_test.go @@ -0,0 +1,106 @@ +package placement_test + +import ( + "fmt" + "sort" + "testing" + + "anchorage/internal/pkg/placement" +) + +func nodes(ids ...string) []placement.Node { + out := make([]placement.Node, 0, len(ids)) + for _, id := range ids { + out = append(out, placement.Node{ID: id, Status: "up"}) + } + return out +} + +func TestDeterministic(t *testing.T) { + ns := nodes("nod_a", "nod_b", "nod_c", "nod_d", "nod_e") + a := placement.Compute("org_1", "QmABC", 2, ns) + b := placement.Compute("org_1", "QmABC", 2, ns) + if fmt.Sprint(a) != fmt.Sprint(b) { + t.Errorf("non-deterministic: %v vs %v", a, b) + } + if len(a) != 2 { + t.Errorf("want 2 replicas, got %d", len(a)) + } +} + +func TestDistinctNodes(t *testing.T) { + ns := nodes("nod_a", "nod_b", "nod_c", "nod_d", "nod_e") + r := placement.Compute("org_1", "QmXYZ", 3, ns) + seen := map[string]bool{} + for _, id := range r { + if seen[id] { + t.Errorf("duplicate placement: %s in %v", id, r) + } + seen[id] = true + } +} + +func TestUnderReplicatesOnSmallCluster(t *testing.T) { + ns := nodes("nod_a") + r := placement.Compute("org_1", "QmXYZ", 3, ns) + if len(r) != 1 { + t.Errorf("want 1 (cluster too small), got %d", len(r)) + } +} + +func TestZeroInputs(t *testing.T) { + if r := placement.Compute("", "", 0, nil); len(r) != 0 { + t.Errorf("empty inputs should yield empty slice, got %v", r) + } +} + +func TestLoadSpread(t *testing.T) { + // Property test: with N nodes and many CIDs, each node should appear + // roughly 1/N of the time as the primary (first) replica. We allow + // ±30% tolerance; HRW is pseudo-random, not strictly uniform. + ns := nodes("nod_a", "nod_b", "nod_c", "nod_d", "nod_e") + counts := map[string]int{} + total := 5000 + for i := 0; i < total; i++ { + cid := fmt.Sprintf("Qm_%d", i) + r := placement.Compute("org_1", cid, 1, ns) + counts[r[0]]++ + } + if len(counts) != len(ns) { + t.Errorf("only %d nodes received primary load, want %d", len(counts), len(ns)) + } + expected := total / len(ns) + for id, c := range counts { + diff := c - expected + if diff < 0 { + diff = -diff + } + if diff*100/expected > 30 { + t.Errorf("node %s got %d primaries, expected ~%d (±30%%)", id, c, expected) + } + } +} + +func TestMinimalDisruptionOnNodeLoss(t *testing.T) { + // When a node disappears, only pins that *had* that node in their + // replica set should see any churn; everyone else stays put. + before := nodes("nod_a", "nod_b", "nod_c", "nod_d") + after := nodes("nod_a", "nod_b", "nod_c") + churn := 0 + total := 1000 + for i := 0; i < total; i++ { + cid := fmt.Sprintf("Qm_%d", i) + a := placement.Compute("org_1", cid, 2, before) + b := placement.Compute("org_1", cid, 2, after) + sort.Strings(a) + sort.Strings(b) + if fmt.Sprint(a) != fmt.Sprint(b) { + churn++ + } + } + // Losing 1 of 4 nodes: roughly 2/4 = 50% of pins included that node + // in their top-2. We just assert "less than all" churned. + if churn >= total { + t.Errorf("everything churned (%d/%d) — HRW broken?", churn, total) + } +} diff --git a/internal/pkg/scheduler/consumer.go b/internal/pkg/scheduler/consumer.go new file mode 100644 index 0000000..efc1c02 --- /dev/null +++ b/internal/pkg/scheduler/consumer.go @@ -0,0 +1,226 @@ +// Package scheduler owns a node's job consumer, rebalancer, requeue +// sweeper, and reconciler loops. +// +// Job consumer: one pull subscription per node on pin.jobs.. +// Dispatches to the local Kubo backend, updates the placement row with +// WHERE fence = $n, bumps or decrements pin_refcount, publishes an event. +package scheduler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/ipfs" + "anchorage/internal/pkg/metrics" + "anchorage/internal/pkg/pin" + "anchorage/internal/pkg/store" +) + +// PinJobsStream is the JetStream name for the pin work queue. +const PinJobsStream = "PIN_JOBS" + +// EnsureStreams creates (or updates) the PIN_JOBS and PIN_EVENTS streams. +// Idempotent; safe to call on every boot. +// +// The replicas argument is the operator's requested replication factor; +// if the running NATS deployment is smaller (e.g., single-node embedded +// NATS with replicas=3 in config) we retry with replicas=1. This matches +// the plan's "Capped at clusterSize at runtime" note without poking +// brittle internals. +func EnsureStreams(ctx context.Context, js jetstream.JetStream, replicas int) error { + if replicas <= 0 { + replicas = 1 + } + mk := func(cfg jetstream.StreamConfig) error { + _, err := js.CreateOrUpdateStream(ctx, cfg) + if err == nil { + return nil + } + // JetStream reports "insufficient resources" / "replicas > cluster size" + // variants; rather than pattern-matching their error strings, retry + // once with replicas=1 which every deployment accepts. + if replicas > 1 { + cfg.Replicas = 1 + if _, err2 := js.CreateOrUpdateStream(ctx, cfg); err2 == nil { + return nil + } + } + return err + } + + if err := mk(jetstream.StreamConfig{ + Name: PinJobsStream, + Subjects: []string{pin.JobsSubjectPrefix + ".*"}, + Retention: jetstream.WorkQueuePolicy, + Duplicates: 5 * time.Minute, + Replicas: replicas, + }); err != nil { + return fmt.Errorf("ensure PIN_JOBS: %w", err) + } + if err := mk(jetstream.StreamConfig{ + Name: "PIN_EVENTS", + Subjects: []string{pin.EventsSubjectPrefix + ".>"}, + Retention: jetstream.LimitsPolicy, + MaxAge: 1 * time.Hour, + Replicas: replicas, + }); err != nil { + return fmt.Errorf("ensure PIN_EVENTS: %w", err) + } + return nil +} + +// Consumer is a single node's job worker. +type Consumer struct { + NodeID ids.NodeID + Store store.Store + Backend ipfs.Backend + NC *nats.Conn + JS jetstream.JetStream +} + +// Run pulls pin.jobs. messages until ctx is cancelled. Honors +// nodes.status: on transitions to drained, the pull loop stops; the +// caller is responsible for restarting it when the node is uncordoned. +func (c *Consumer) Run(ctx context.Context) error { + consName := "worker-" + c.NodeID.String() + cons, err := c.JS.CreateOrUpdateConsumer(ctx, PinJobsStream, jetstream.ConsumerConfig{ + Name: consName, + Durable: consName, + FilterSubject: fmt.Sprintf("%s.%s", pin.JobsSubjectPrefix, c.NodeID.String()), + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: 30 * time.Second, + MaxDeliver: 5, + }) + if err != nil { + return fmt.Errorf("create consumer: %w", err) + } + + for { + if ctx.Err() != nil { + return ctx.Err() + } + // Check for drain: if nodes.status='drained', pause the loop. + // One-shot check per outer iteration is cheap. + if n, err := c.Store.Nodes().Get(ctx, c.NodeID); err == nil && n.Status == store.NodeStatusDrained { + slog.Info("scheduler: consumer paused (drained)", "node_id", c.NodeID) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + + msgs, err := cons.Fetch(10, jetstream.FetchMaxWait(2*time.Second)) + if err != nil { + if errors.Is(err, nats.ErrTimeout) || errors.Is(err, context.DeadlineExceeded) { + metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "timeout").Inc() + continue + } + metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "err").Inc() + slog.Warn("scheduler: fetch", "err", err) + time.Sleep(500 * time.Millisecond) + continue + } + for m := range msgs.Messages() { + metrics.SchedulerFetch.WithLabelValues(c.NodeID.String(), "ok").Inc() + c.handle(ctx, m) + } + } +} + +func (c *Consumer) handle(ctx context.Context, m jetstream.Msg) { + var job pin.Job + if err := json.Unmarshal(m.Data(), &job); err != nil { + slog.Warn("scheduler: bad job payload", "err", err) + _ = m.Term() + return + } + if err := c.execute(ctx, job); err != nil { + slog.Warn("scheduler: execute", "err", err, "request_id", job.RequestID) + _ = m.Nak() + return + } + _ = m.Ack() +} + +// execute dispatches the job to Kubo and updates state. +// +// The fence check in UpdatePlacementFenced is what keeps zombie writes +// from clobbering a reassigned placement: if the fence has been bumped +// by the rebalancer, the UPDATE affects 0 rows and we silently drop. +func (c *Consumer) execute(ctx context.Context, j pin.Job) error { + orgID, err := ids.ParseOrg(j.OrgID) + if err != nil { + return err + } + rid, err := ids.ParsePin(j.RequestID) + if err != nil { + return err + } + + switch j.Action { + case "pin": + if err := c.Backend.Pin(ctx, j.CID, j.Origins); err != nil { + return c.markPlacement(ctx, rid, j, store.PinStatusFailed, err.Error()) + } + return c.markPlacement(ctx, rid, j, store.PinStatusPinned, "") + case "unpin": + // Decrement refcount; unpin Kubo only on 0. + remaining, err := c.Store.Pins().DecRefcount(ctx, c.NodeID, j.CID) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + if remaining <= 0 { + if err := c.Backend.Unpin(ctx, j.CID); err != nil && !errors.Is(err, ipfs.ErrNotFound) { + return err + } + _ = c.Store.Pins().DeleteRefcountIfZero(ctx, c.NodeID, j.CID) + } + pin.PublishEvent(c.NC, pin.Event{ + RequestID: j.RequestID, + OrgID: orgID.String(), + Status: "unpinned", + NodeID: c.NodeID.String(), + }) + metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), "unpinned").Inc() + return nil + default: + return fmt.Errorf("scheduler: unknown action %q", j.Action) + } +} + +func (c *Consumer) markPlacement(ctx context.Context, rid ids.PinID, j pin.Job, status, reason string) error { + var fr *string + if reason != "" { + fr = &reason + } + n, err := c.Store.Pins().UpdatePlacementFenced(ctx, rid, c.NodeID, status, fr, j.Fence) + if err != nil { + return err + } + if n == 0 { + // Fence mismatch — reassigned already. Silent no-op. + slog.Info("scheduler: fence mismatch, dropping", + "request_id", j.RequestID, "node_id", c.NodeID, "fence", j.Fence) + metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), "fence-mismatch").Inc() + return nil + } + pin.PublishEvent(c.NC, pin.Event{ + RequestID: j.RequestID, + OrgID: j.OrgID, + Status: status, + NodeID: c.NodeID.String(), + Reason: reason, + }) + metrics.SchedulerAcks.WithLabelValues(c.NodeID.String(), status).Inc() + return nil +} diff --git a/internal/pkg/scheduler/rebalance.go b/internal/pkg/scheduler/rebalance.go new file mode 100644 index 0000000..eeed8fe --- /dev/null +++ b/internal/pkg/scheduler/rebalance.go @@ -0,0 +1,194 @@ +package scheduler + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/nats-io/nats.go" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/placement" + "anchorage/internal/pkg/store" +) + +// PlannedMove describes a placement reassignment the rebalancer would +// (or did) make. Returned from DryRun to preview and from RunOnce to +// confirm. Exported so admin endpoints can surface it as JSON. +type PlannedMove struct { + RequestID string `json:"request_id"` + FromNode string `json:"from_node"` + ToNode string `json:"to_node"` + Reason string `json:"reason"` // "down" | "drain" + NewFence int64 `json:"new_fence,omitempty"` // populated after apply +} + +// Rebalancer migrates placements off nodes that transitioned to down +// or drained. Leader-gated: the composition root only calls Run on the +// elected leader, and stops it when the node is demoted. +// +// The one-shot admin variants (DryRun, RunOnce) do NOT require the +// leader loop to be running — they're safe to call from any node because +// every individual placement update is fence-guarded at the store level. +type Rebalancer struct { + Store store.Store + NC *nats.Conn + Interval time.Duration + // MaintenanceGate is a function that returns true when cluster-wide + // maintenance is enabled. The rebalancer no-ops while it returns true. + MaintenanceGate func(context.Context) bool +} + +// Run walks the placements table on every tick. +func (r *Rebalancer) Run(ctx context.Context) error { + ticker := time.NewTicker(r.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if r.MaintenanceGate != nil && r.MaintenanceGate(ctx) { + slog.Debug("rebalancer: skipping tick (cluster maintenance)") + continue + } + if _, err := r.RunOnce(ctx); err != nil { + slog.Warn("rebalancer: tick failed", "err", err) + } + } + } +} + +// DryRun returns the list of moves RunOnce would execute against the +// current store state. Safe to call concurrently with the leader loop — +// store reads are snapshot-consistent within a transaction boundary. +func (r *Rebalancer) DryRun(ctx context.Context) ([]PlannedMove, error) { + return r.plan(ctx) +} + +// RunOnce performs a single plan+apply pass and returns the moves that +// actually executed (with NewFence populated). Errors on individual +// moves are logged and skipped — matches the Run loop's per-tick +// resilience. A top-level error is returned only when plan fails. +func (r *Rebalancer) RunOnce(ctx context.Context) ([]PlannedMove, error) { + moves, err := r.plan(ctx) + if err != nil { + return nil, err + } + return r.apply(ctx, moves), nil +} + +// plan walks every non-live node, computes the new placement for each +// of that node's pins via rendezvous hash, and returns the list of +// moves that would happen if applied. No writes. +func (r *Rebalancer) plan(ctx context.Context) ([]PlannedMove, error) { + allNodes, err := r.Store.Nodes().ListAll(ctx) + if err != nil { + return nil, err + } + var candidates []placement.Node + liveIDs := map[ids.NodeID]bool{} + for _, n := range allNodes { + if n.Status == store.NodeStatusUp { + candidates = append(candidates, placement.Node{ID: n.ID.String(), Status: n.Status}) + liveIDs[n.ID] = true + } + } + if len(candidates) == 0 { + return nil, nil + } + + var moves []PlannedMove + for _, n := range allNodes { + if n.Status == store.NodeStatusUp { + continue + } + reason := "down" + if n.Status == store.NodeStatusDrained { + reason = "drain" + } + placements, err := r.Store.Pins().ListPlacementsForNode(ctx, n.ID, "") + if err != nil { + slog.Warn("rebalancer: list placements", "node", n.ID, "err", err) + continue + } + for _, pl := range placements { + pinRow, err := r.Store.Pins().GetByRequestID(ctx, pl.RequestID) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + slog.Warn("rebalancer: get pin", "request_id", pl.RequestID, "err", err) + } + continue + } + want := placement.Compute(pinRow.OrgID.String(), pinRow.CID, 1, candidates) + if len(want) == 0 { + continue + } + newNodeID, err := ids.ParseNode(want[0]) + if err != nil || newNodeID == pl.NodeID { + continue + } + moves = append(moves, PlannedMove{ + RequestID: pl.RequestID.String(), + FromNode: pl.NodeID.String(), + ToNode: newNodeID.String(), + Reason: reason, + }) + } + } + return moves, nil +} + +// apply executes a slice of planned moves. Returns only the moves that +// actually executed — per-move errors are logged + skipped, matching +// the original tick's resilience. Moves are mutated in place so the +// caller sees populated NewFence values. +func (r *Rebalancer) apply(ctx context.Context, moves []PlannedMove) []PlannedMove { + applied := make([]PlannedMove, 0, len(moves)) + for _, m := range moves { + rid, err := ids.ParsePin(m.RequestID) + if err != nil { + slog.Warn("rebalancer: bad request id", "id", m.RequestID, "err", err) + continue + } + fromID, err := ids.ParseNode(m.FromNode) + if err != nil { + slog.Warn("rebalancer: bad from-node id", "err", err) + continue + } + toID, err := ids.ParseNode(m.ToNode) + if err != nil { + slog.Warn("rebalancer: bad to-node id", "err", err) + continue + } + newPl, err := r.Store.Pins().ReplacePlacement(ctx, rid, fromID, toID) + if err != nil { + slog.Warn("rebalancer: replace placement", "err", err) + continue + } + m.NewFence = newPl.Fence + + // Audit — look up pin row for OrgID. Missing is non-fatal; we + // skip the audit rather than fail the move. + if pinRow, err := r.Store.Pins().GetByRequestID(ctx, rid); err == nil { + _ = r.Store.Audit().Insert(ctx, &store.AuditEntry{ + OrgID: &pinRow.OrgID, + Action: "pin.rebalance", + Target: m.RequestID, + Result: "ok", + Detail: map[string]any{ + "from": m.FromNode, + "to": m.ToNode, + "reason": m.Reason, + "fence": newPl.Fence, + }, + }) + } + slog.Info("rebalancer: moved placement", + "request_id", m.RequestID, "from", m.FromNode, "to", m.ToNode, + "reason", m.Reason, "fence", newPl.Fence) + applied = append(applied, m) + } + return applied +} diff --git a/internal/pkg/scheduler/reconcile.go b/internal/pkg/scheduler/reconcile.go new file mode 100644 index 0000000..e069610 --- /dev/null +++ b/internal/pkg/scheduler/reconcile.go @@ -0,0 +1,103 @@ +package scheduler + +import ( + "context" + "log/slog" + "sync/atomic" + "time" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/ipfs" + "anchorage/internal/pkg/store" +) + +// Reconciler diffs this node's Postgres placements against its Kubo +// daemon's actual pinset every Interval. Drift (CIDs anchorage believes +// are pinned but Kubo doesn't have, or vice versa) is counted and +// exposed via the DriftCount atomic so /v1/ready can surface it. +type Reconciler struct { + NodeID ids.NodeID + Store store.Store + Backend ipfs.Backend + Interval time.Duration + AutoRepair bool + // DriftCount is incremented on every drifted CID observed during a + // tick. Read atomically from any goroutine. + DriftCount atomic.Int64 +} + +// Run ticks every Interval until ctx is cancelled. Interval <= 0 +// disables the loop — the reconciler returns immediately (ctx.Err() +// when caller cancels) so the composition root can opt out without +// branching. +func (r *Reconciler) Run(ctx context.Context) error { + if r.Interval <= 0 { + slog.Info("reconciler: disabled (Interval <= 0)", "node_id", r.NodeID) + <-ctx.Done() + return ctx.Err() + } + ticker := time.NewTicker(r.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if err := r.tick(ctx); err != nil { + slog.Warn("reconciler: tick", "err", err) + } + } + } +} + +func (r *Reconciler) tick(ctx context.Context) error { + pgPlacements, err := r.Store.Pins().ListPlacementsForNode(ctx, r.NodeID, store.PinStatusPinned) + if err != nil { + return err + } + kuboCIDs, err := r.Backend.List(ctx) + if err != nil { + return err + } + + pgSet := map[string]bool{} + for _, pl := range pgPlacements { + // Placements carry the request_id but we need the CID. For large + // clusters this should be replaced with a JOIN query; the memstore + // walks its map which is fine for dev and tests. + pinRow, err := r.Store.Pins().GetByRequestID(ctx, pl.RequestID) + if err != nil { + continue + } + pgSet[pinRow.CID] = true + } + kuboSet := map[string]bool{} + for _, c := range kuboCIDs { + kuboSet[c] = true + } + + drift := int64(0) + for cid := range pgSet { + if !kuboSet[cid] { + drift++ + if r.AutoRepair { + if err := r.Backend.Pin(ctx, cid, nil); err != nil { + slog.Warn("reconciler: repin failed", "cid", cid, "err", err) + } + } + } + } + for cid := range kuboSet { + if !pgSet[cid] { + drift++ + if r.AutoRepair { + _ = r.Backend.Unpin(ctx, cid) + } + } + } + r.DriftCount.Store(drift) + if drift > 0 { + slog.Info("reconciler: drift detected", "count", drift) + } + return nil +} diff --git a/internal/pkg/scheduler/sweeper.go b/internal/pkg/scheduler/sweeper.go new file mode 100644 index 0000000..76a9489 --- /dev/null +++ b/internal/pkg/scheduler/sweeper.go @@ -0,0 +1,86 @@ +package scheduler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/nats-io/nats.go" + + "anchorage/internal/pkg/pin" + "anchorage/internal/pkg/store" +) + +// RequeueSweeper republishes pin jobs whose placements have been stuck +// in 'queued' past a threshold — covers the rare "Postgres committed +// but NATS publish lost" case. Leader-gated. +type RequeueSweeper struct { + Store store.Store + NC *nats.Conn + Interval time.Duration + StuckAfter time.Duration + // MaintenanceGate pauses the sweeper while cluster maintenance is on. + MaintenanceGate func(context.Context) bool +} + +// Run ticks every Interval, republishing stuck placements as pin.jobs. +// with the placement's current fence value (so the publisher dedup +// absorbs any already-in-flight duplicate). +func (s *RequeueSweeper) Run(ctx context.Context) error { + ticker := time.NewTicker(s.Interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if s.MaintenanceGate != nil && s.MaintenanceGate(ctx) { + continue + } + if err := s.tick(ctx); err != nil { + slog.Warn("sweeper: tick", "err", err) + } + } + } +} + +func (s *RequeueSweeper) tick(ctx context.Context) error { + stuck, err := s.Store.Pins().StuckPlacements(ctx, s.StuckAfter) + if err != nil { + return err + } + for _, pl := range stuck { + pinRow, err := s.Store.Pins().GetByRequestID(ctx, pl.RequestID) + if err != nil { + if !errors.Is(err, store.ErrNotFound) { + slog.Warn("sweeper: get pin", "request_id", pl.RequestID, "err", err) + } + continue + } + job := pin.Job{ + RequestID: pl.RequestID.String(), + OrgID: pinRow.OrgID.String(), + CID: pinRow.CID, + Origins: pinRow.Origins, + Fence: pl.Fence, + Action: "pin", + } + body, _ := json.Marshal(job) + msg := &nats.Msg{ + Subject: fmt.Sprintf("%s.%s", pin.JobsSubjectPrefix, pl.NodeID.String()), + Data: body, + Header: nats.Header{}, + } + msg.Header.Set("Nats-Msg-Id", fmt.Sprintf("%s:%s:%d", pl.RequestID, pl.NodeID, pl.Fence)) + if err := s.NC.PublishMsg(msg); err != nil { + slog.Warn("sweeper: publish", "err", err) + continue + } + slog.Info("sweeper: requeued", + "request_id", pl.RequestID, "node_id", pl.NodeID, "fence", pl.Fence) + } + return nil +} diff --git a/internal/pkg/store/memstore/memstore.go b/internal/pkg/store/memstore/memstore.go new file mode 100644 index 0000000..114d756 --- /dev/null +++ b/internal/pkg/store/memstore/memstore.go @@ -0,0 +1,889 @@ +// Package memstore is an in-memory implementation of store.Store. +// +// Intended for unit tests and for the local-dev single-binary mode where +// operators want to poke at the HTTP API without bringing up Postgres. +// Concurrency-safe via a single coarse mutex — performance is fine for +// tests but not a replacement for the pgx-backed store in production. +package memstore + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// Store is the top-level in-memory store. Zero value is ready to use. +type Store struct { + mu sync.Mutex + orgs map[ids.OrgID]*store.Org + slugToOrg map[string]ids.OrgID + users map[ids.UserID]*store.User + emailToU map[string]ids.UserID + subToU map[string]ids.UserID + members map[string]*store.Membership // key: orgID + "|" + userID + tokens map[ids.TokenID]*store.APIToken + denylist map[ids.TokenID]time.Time // value = expires_at + nodes map[ids.NodeID]*store.Node + pins map[ids.PinID]*store.Pin + placements map[string]*store.Placement // key: pinID + "|" + nodeID + refcounts map[string]int // key: nodeID + "|" + cid + audit []*store.AuditEntry + nextAudit int64 +} + +// New returns an initialised memstore. +func New() *Store { + return &Store{ + orgs: map[ids.OrgID]*store.Org{}, + slugToOrg: map[string]ids.OrgID{}, + users: map[ids.UserID]*store.User{}, + emailToU: map[string]ids.UserID{}, + subToU: map[string]ids.UserID{}, + members: map[string]*store.Membership{}, + tokens: map[ids.TokenID]*store.APIToken{}, + denylist: map[ids.TokenID]time.Time{}, + nodes: map[ids.NodeID]*store.Node{}, + pins: map[ids.PinID]*store.Pin{}, + placements: map[string]*store.Placement{}, + refcounts: map[string]int{}, + } +} + +// ---- store.Store root ----------------------------------------------------- + +func (s *Store) Orgs() store.OrgStore { return orgStore{s} } +func (s *Store) Users() store.UserStore { return userStore{s} } +func (s *Store) Memberships() store.MembershipStore { return memStore{s} } +func (s *Store) Tokens() store.TokenStore { return tokenStore{s} } +func (s *Store) Nodes() store.NodeStore { return nodeStore{s} } +func (s *Store) Pins() store.PinStore { return pinStore{s} } +func (s *Store) Audit() store.AuditStore { return auditStore{s} } +func (s *Store) WithOrgContext(ctx context.Context, _ ids.OrgID) (context.Context, error) { return ctx, nil } + +// Tx in a memstore is a no-op — the same Store is passed back and the +// context is forwarded unchanged. Real Postgres implementations open a +// pgx tx and derive a tx-scoped context from ctx. +func (s *Store) Tx(ctx context.Context, fn func(txCtx context.Context, tx store.Store) error) error { + return fn(ctx, s) +} + +// ---- Orgs ----------------------------------------------------------------- + +type orgStore struct{ s *Store } + +func (o orgStore) Create(_ context.Context, id ids.OrgID, slug, name string) (*store.Org, error) { + o.s.mu.Lock() + defer o.s.mu.Unlock() + if _, ok := o.s.slugToOrg[slug]; ok { + return nil, store.ErrConflict + } + now := time.Now().UTC() + row := &store.Org{ID: id, Slug: slug, Name: name, CreatedAt: now, UpdatedAt: now} + o.s.orgs[id] = row + o.s.slugToOrg[slug] = id + return cloneOrg(row), nil +} + +func (o orgStore) GetByID(_ context.Context, id ids.OrgID) (*store.Org, error) { + o.s.mu.Lock() + defer o.s.mu.Unlock() + row, ok := o.s.orgs[id] + if !ok { + return nil, store.ErrNotFound + } + return cloneOrg(row), nil +} + +func (o orgStore) GetBySlug(_ context.Context, slug string) (*store.Org, error) { + o.s.mu.Lock() + defer o.s.mu.Unlock() + id, ok := o.s.slugToOrg[slug] + if !ok { + return nil, store.ErrNotFound + } + return cloneOrg(o.s.orgs[id]), nil +} + +func (o orgStore) UpdateName(_ context.Context, id ids.OrgID, name string) (*store.Org, error) { + o.s.mu.Lock() + defer o.s.mu.Unlock() + row, ok := o.s.orgs[id] + if !ok { + return nil, store.ErrNotFound + } + row.Name = name + row.UpdatedAt = time.Now().UTC() + return cloneOrg(row), nil +} + +func (o orgStore) List(_ context.Context, limit, offset int) ([]*store.Org, error) { + o.s.mu.Lock() + defer o.s.mu.Unlock() + out := make([]*store.Org, 0, len(o.s.orgs)) + for _, org := range o.s.orgs { + out = append(out, cloneOrg(org)) + } + return paginate(out, limit, offset), nil +} + +func cloneOrg(o *store.Org) *store.Org { v := *o; return &v } + +// ---- Users ---------------------------------------------------------------- + +type userStore struct{ s *Store } + +func (u userStore) UpsertByAuthentikSub(_ context.Context, id ids.UserID, sub, email, displayName string, isSysadmin bool) (*store.User, error) { + u.s.mu.Lock() + defer u.s.mu.Unlock() + email = strings.ToLower(email) + if existingID, ok := u.s.subToU[sub]; ok { + row := u.s.users[existingID] + row.Email = email + row.DisplayName = displayName + row.UpdatedAt = time.Now().UTC() + return cloneUser(row), nil + } + now := time.Now().UTC() + row := &store.User{ + ID: id, + AuthentikSub: sub, + Email: email, + DisplayName: displayName, + IsSysadmin: isSysadmin, + CreatedAt: now, + UpdatedAt: now, + } + u.s.users[id] = row + u.s.subToU[sub] = id + u.s.emailToU[email] = id + return cloneUser(row), nil +} + +func (u userStore) GetByID(_ context.Context, id ids.UserID) (*store.User, error) { + u.s.mu.Lock() + defer u.s.mu.Unlock() + row, ok := u.s.users[id] + if !ok { + return nil, store.ErrNotFound + } + return cloneUser(row), nil +} + +func (u userStore) GetByEmail(_ context.Context, email string) (*store.User, error) { + u.s.mu.Lock() + defer u.s.mu.Unlock() + id, ok := u.s.emailToU[strings.ToLower(email)] + if !ok { + return nil, store.ErrNotFound + } + return cloneUser(u.s.users[id]), nil +} + +func (u userStore) PromoteSysadmin(_ context.Context, id ids.UserID) error { + u.s.mu.Lock() + defer u.s.mu.Unlock() + row, ok := u.s.users[id] + if !ok { + return store.ErrNotFound + } + row.IsSysadmin = true + return nil +} + +func cloneUser(u *store.User) *store.User { v := *u; return &v } + +// ---- Memberships ---------------------------------------------------------- + +type memStore struct{ s *Store } + +func memKey(org ids.OrgID, user ids.UserID) string { + return org.String() + "|" + user.String() +} + +func (m memStore) Add(_ context.Context, orgID ids.OrgID, userID ids.UserID, role string) error { + m.s.mu.Lock() + defer m.s.mu.Unlock() + if role != store.RoleOrgAdmin && role != store.RoleMember { + return fmt.Errorf("memstore: invalid role %q", role) + } + now := time.Now().UTC() + m.s.members[memKey(orgID, userID)] = &store.Membership{ + OrgID: orgID, + UserID: userID, + Role: role, + Created: now, + } + return nil +} + +func (m memStore) Remove(_ context.Context, orgID ids.OrgID, userID ids.UserID) error { + m.s.mu.Lock() + defer m.s.mu.Unlock() + delete(m.s.members, memKey(orgID, userID)) + return nil +} + +func (m memStore) ListForUser(_ context.Context, userID ids.UserID) ([]*store.Membership, error) { + m.s.mu.Lock() + defer m.s.mu.Unlock() + out := []*store.Membership{} + for _, mem := range m.s.members { + if mem.UserID == userID { + row := *mem + if o, ok := m.s.orgs[mem.OrgID]; ok { + row.OrgSlug = o.Slug + row.OrgName = o.Name + } + out = append(out, &row) + } + } + return out, nil +} + +// ---- Tokens --------------------------------------------------------------- + +type tokenStore struct{ s *Store } + +func (t tokenStore) Create(_ context.Context, tk *store.APIToken) error { + t.s.mu.Lock() + defer t.s.mu.Unlock() + v := *tk + t.s.tokens[tk.JTI] = &v + return nil +} + +func (t tokenStore) GetByJTI(_ context.Context, jti ids.TokenID) (*store.APIToken, error) { + t.s.mu.Lock() + defer t.s.mu.Unlock() + tk, ok := t.s.tokens[jti] + if !ok { + return nil, store.ErrNotFound + } + v := *tk + return &v, nil +} + +func (t tokenStore) ListForUser(_ context.Context, orgID ids.OrgID, userID ids.UserID) ([]*store.APIToken, error) { + t.s.mu.Lock() + defer t.s.mu.Unlock() + out := []*store.APIToken{} + for _, tk := range t.s.tokens { + if tk.OrgID == orgID && tk.UserID == userID && tk.RevokedAt == nil { + v := *tk + out = append(out, &v) + } + } + return out, nil +} + +func (t tokenStore) Revoke(_ context.Context, jti ids.TokenID) error { + t.s.mu.Lock() + defer t.s.mu.Unlock() + tk, ok := t.s.tokens[jti] + if !ok { + return store.ErrNotFound + } + now := time.Now().UTC() + tk.RevokedAt = &now + return nil +} + +func (t tokenStore) TouchLastUsed(_ context.Context, jti ids.TokenID) error { + t.s.mu.Lock() + defer t.s.mu.Unlock() + tk, ok := t.s.tokens[jti] + if !ok { + return store.ErrNotFound + } + now := time.Now().UTC() + tk.LastUsedAt = &now + return nil +} + +func (t tokenStore) AddDenylist(_ context.Context, jti ids.TokenID, expiresAt time.Time, _ string) error { + t.s.mu.Lock() + defer t.s.mu.Unlock() + t.s.denylist[jti] = expiresAt + return nil +} + +func (t tokenStore) IsDenied(_ context.Context, jti ids.TokenID) (bool, error) { + t.s.mu.Lock() + defer t.s.mu.Unlock() + exp, ok := t.s.denylist[jti] + if !ok { + return false, nil + } + return exp.After(time.Now()), nil +} + +func (t tokenStore) PruneDenylist(_ context.Context) error { + t.s.mu.Lock() + defer t.s.mu.Unlock() + now := time.Now() + for k, exp := range t.s.denylist { + if !exp.After(now) { + delete(t.s.denylist, k) + } + } + return nil +} + +// ---- Nodes ---------------------------------------------------------------- + +type nodeStore struct{ s *Store } + +func (n nodeStore) Upsert(_ context.Context, node *store.Node) error { + n.s.mu.Lock() + defer n.s.mu.Unlock() + existing, ok := n.s.nodes[node.ID] + now := time.Now().UTC() + if ok { + // Preserve drained status; don't auto-revive a drained node. + status := existing.Status + if status != store.NodeStatusDrained { + status = store.NodeStatusUp + } + existing.DisplayName = node.DisplayName + existing.Multiaddrs = append(node.Multiaddrs[:0:0], node.Multiaddrs...) + existing.RPCURL = node.RPCURL + existing.Status = status + existing.LastSeenAt = now + existing.UpdatedAt = now + return nil + } + v := *node + v.Status = store.NodeStatusUp + v.JoinedAt = now + v.LastSeenAt = now + v.UpdatedAt = now + n.s.nodes[node.ID] = &v + return nil +} + +func (n nodeStore) Get(_ context.Context, id ids.NodeID) (*store.Node, error) { + n.s.mu.Lock() + defer n.s.mu.Unlock() + node, ok := n.s.nodes[id] + if !ok { + return nil, store.ErrNotFound + } + v := *node + return &v, nil +} + +func (n nodeStore) ListAll(_ context.Context) ([]*store.Node, error) { + n.s.mu.Lock() + defer n.s.mu.Unlock() + out := make([]*store.Node, 0, len(n.s.nodes)) + for _, node := range n.s.nodes { + v := *node + out = append(out, &v) + } + return out, nil +} + +func (n nodeStore) ListLive(_ context.Context) ([]*store.Node, error) { + n.s.mu.Lock() + defer n.s.mu.Unlock() + out := []*store.Node{} + for _, node := range n.s.nodes { + if node.Status == store.NodeStatusUp { + v := *node + out = append(out, &v) + } + } + return out, nil +} + +func (n nodeStore) TouchHeartbeat(_ context.Context, id ids.NodeID) error { + n.s.mu.Lock() + defer n.s.mu.Unlock() + node, ok := n.s.nodes[id] + if !ok { + return store.ErrNotFound + } + node.LastSeenAt = time.Now().UTC() + return nil +} + +func (n nodeStore) MarkStaleDown(_ context.Context, staleAfter time.Duration) ([]ids.NodeID, error) { + n.s.mu.Lock() + defer n.s.mu.Unlock() + cutoff := time.Now().Add(-staleAfter) + out := []ids.NodeID{} + for _, node := range n.s.nodes { + if node.Status == store.NodeStatusUp && node.LastSeenAt.Before(cutoff) { + node.Status = store.NodeStatusDown + out = append(out, node.ID) + } + } + return out, nil +} + +func (n nodeStore) Drain(_ context.Context, id ids.NodeID) error { + n.s.mu.Lock() + defer n.s.mu.Unlock() + node, ok := n.s.nodes[id] + if !ok { + return store.ErrNotFound + } + node.Status = store.NodeStatusDrained + return nil +} + +func (n nodeStore) Uncordon(_ context.Context, id ids.NodeID) error { + n.s.mu.Lock() + defer n.s.mu.Unlock() + node, ok := n.s.nodes[id] + if !ok { + return store.ErrNotFound + } + if node.Status == store.NodeStatusDrained { + node.Status = store.NodeStatusUp + } + return nil +} + +// ---- Pins ----------------------------------------------------------------- + +type pinStore struct{ s *Store } + +func pinPlacementKey(pid ids.PinID, nid ids.NodeID) string { + return pid.String() + "|" + nid.String() +} + +func refcountKey(nid ids.NodeID, cid string) string { + return nid.String() + "|" + cid +} + +func (p pinStore) Create(_ context.Context, pin *store.Pin) error { + p.s.mu.Lock() + defer p.s.mu.Unlock() + // Idempotency guard: one live pin per (org, cid). + for _, existing := range p.s.pins { + if existing.OrgID == pin.OrgID && existing.CID == pin.CID && existing.Status != store.PinStatusFailed { + return store.ErrConflict + } + } + v := *pin + p.s.pins[pin.RequestID] = &v + return nil +} + +func (p pinStore) Get(_ context.Context, orgID ids.OrgID, rid ids.PinID) (*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pin, ok := p.s.pins[rid] + if !ok || pin.OrgID != orgID { + return nil, store.ErrNotFound + } + v := *pin + return &v, nil +} + +func (p pinStore) GetByRequestID(_ context.Context, rid ids.PinID) (*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pin, ok := p.s.pins[rid] + if !ok { + return nil, store.ErrNotFound + } + v := *pin + return &v, nil +} + +func (p pinStore) GetLiveByCID(_ context.Context, orgID ids.OrgID, cid string) (*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + for _, pin := range p.s.pins { + if pin.OrgID == orgID && pin.CID == cid && pin.Status != store.PinStatusFailed { + v := *pin + return &v, nil + } + } + return nil, store.ErrNotFound +} + +func (p pinStore) UpdateStatus(_ context.Context, rid ids.PinID, status string, fr *string) error { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pin, ok := p.s.pins[rid] + if !ok { + return store.ErrNotFound + } + pin.Status = status + pin.FailureReason = fr + pin.UpdatedAt = time.Now().UTC() + return nil +} + +// Delete removes the pin and cascades through pin_placements and +// pin_refcount — matching Postgres's ON DELETE CASCADE on placements +// and the app-level refcount decrement for each placement's CID. +func (p pinStore) Delete(_ context.Context, orgID ids.OrgID, rid ids.PinID) error { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pin, ok := p.s.pins[rid] + if !ok || pin.OrgID != orgID { + return store.ErrNotFound + } + // Walk placements for this pin and decrement their refcounts. + for k, pl := range p.s.placements { + if pl.RequestID != rid { + continue + } + delete(p.s.placements, k) + rk := refcountKey(pl.NodeID, pin.CID) + if c, ok := p.s.refcounts[rk]; ok { + c-- + if c <= 0 { + delete(p.s.refcounts, rk) + } else { + p.s.refcounts[rk] = c + } + } + } + delete(p.s.pins, rid) + return nil +} + +func (p pinStore) List(_ context.Context, orgID ids.OrgID, limit, offset int) ([]*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + out := []*store.Pin{} + for _, pin := range p.s.pins { + if pin.OrgID == orgID { + v := *pin + out = append(out, &v) + } + } + // Sort by Created descending for stable pagination. + sortPins(out) + return paginate(out, limit, offset), nil +} + +func (p pinStore) Filter(_ context.Context, orgID ids.OrgID, f store.PinFilter) ([]*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + + cidSet := map[string]bool{} + for _, c := range f.CIDs { + cidSet[c] = true + } + statusSet := map[string]bool{} + for _, s := range f.Status { + statusSet[s] = true + } + nameMatcher := newNameMatcher(f.Name, f.Match) + + out := []*store.Pin{} + for _, pin := range p.s.pins { + if pin.OrgID != orgID { + continue + } + if len(cidSet) > 0 && !cidSet[pin.CID] { + continue + } + if len(statusSet) > 0 && !statusSet[pin.Status] { + continue + } + if f.Before != nil && !pin.Created.Before(*f.Before) { + continue + } + if f.After != nil && pin.Created.Before(*f.After) { + continue + } + if !nameMatcher(pin.Name) { + continue + } + if !metaContains(pin.Meta, f.Meta) { + continue + } + v := *pin + out = append(out, &v) + } + sortPins(out) + return paginate(out, f.Limit, f.Offset), nil +} + +// Replace atomically swaps CID/name/meta/origins on an existing pin. +// Caller's transaction handles refcount bookkeeping. +func (p pinStore) Replace(_ context.Context, orgID ids.OrgID, rid ids.PinID, newCID string, name *string, meta map[string]any, origins []string) (*store.Pin, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pin, ok := p.s.pins[rid] + if !ok || pin.OrgID != orgID { + return nil, store.ErrNotFound + } + pin.CID = newCID + pin.Name = name + pin.Meta = meta + pin.Origins = origins + pin.Status = store.PinStatusQueued + pin.FailureReason = nil + pin.UpdatedAt = time.Now().UTC() + v := *pin + return &v, nil +} + +// newNameMatcher compiles the spec's name/match filter into a predicate +// over (*string). An empty name field means "no restriction". +func newNameMatcher(name, match string) func(*string) bool { + if name == "" { + return func(*string) bool { return true } + } + lower := strings.ToLower(name) + switch match { + case "exact": + return func(p *string) bool { return p != nil && *p == name } + case "partial": + return func(p *string) bool { return p != nil && strings.Contains(*p, name) } + case "ipartial": + return func(p *string) bool { return p != nil && strings.Contains(strings.ToLower(*p), lower) } + case "iexact", "": + return func(p *string) bool { return p != nil && strings.EqualFold(*p, name) } + default: + return func(*string) bool { return false } + } +} + +// metaContains returns true when every key/value in want is present in +// got with an equal value (jsonb @> semantics for the simple case). +func metaContains(got, want map[string]any) bool { + if len(want) == 0 { + return true + } + if got == nil { + return false + } + for k, wv := range want { + gv, ok := got[k] + if !ok || !mapValueEqual(gv, wv) { + return false + } + } + return true +} + +func mapValueEqual(a, b any) bool { + // Simple deep-eq sufficient for JSON scalars + flat maps; the full + // jsonb @> recursion is only needed by the Postgres backend. + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} + +func (p pinStore) InsertPlacement(_ context.Context, rid ids.PinID, nid ids.NodeID, fence int64) (*store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + now := time.Now().UTC() + pl := &store.Placement{ + RequestID: rid, + NodeID: nid, + Status: store.PinStatusQueued, + Fence: fence, + CreatedAt: now, + UpdatedAt: now, + } + p.s.placements[pinPlacementKey(rid, nid)] = pl + v := *pl + return &v, nil +} + +func (p pinStore) GetPlacement(_ context.Context, rid ids.PinID, nid ids.NodeID) (*store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pl, ok := p.s.placements[pinPlacementKey(rid, nid)] + if !ok { + return nil, store.ErrNotFound + } + v := *pl + return &v, nil +} + +func (p pinStore) ListPlacements(_ context.Context, rid ids.PinID) ([]*store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + out := []*store.Placement{} + for _, pl := range p.s.placements { + if pl.RequestID == rid { + v := *pl + if node, ok := p.s.nodes[pl.NodeID]; ok { + v.Multiaddrs = append([]string(nil), node.Multiaddrs...) + } + out = append(out, &v) + } + } + return out, nil +} + +func (p pinStore) ListPlacementsForNode(_ context.Context, nid ids.NodeID, status string) ([]*store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + out := []*store.Placement{} + for _, pl := range p.s.placements { + if pl.NodeID == nid && (status == "" || pl.Status == status) { + v := *pl + out = append(out, &v) + } + } + return out, nil +} + +func (p pinStore) UpdatePlacementFenced(_ context.Context, rid ids.PinID, nid ids.NodeID, status string, fr *string, fence int64) (int64, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + pl, ok := p.s.placements[pinPlacementKey(rid, nid)] + if !ok || pl.Fence != fence { + return 0, nil // zero rows — fence mismatch or missing + } + pl.Status = status + pl.FailureReason = fr + pl.Attempts++ + pl.UpdatedAt = time.Now().UTC() + return 1, nil +} + +func (p pinStore) ReplacePlacement(_ context.Context, rid ids.PinID, oldNode, newNode ids.NodeID) (*store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + oldKey := pinPlacementKey(rid, oldNode) + pl, ok := p.s.placements[oldKey] + if !ok { + return nil, store.ErrNotFound + } + delete(p.s.placements, oldKey) + newPl := &store.Placement{ + RequestID: rid, + NodeID: newNode, + Status: store.PinStatusQueued, + Fence: pl.Fence + 1, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + p.s.placements[pinPlacementKey(rid, newNode)] = newPl + v := *newPl + return &v, nil +} + +func (p pinStore) StuckPlacements(_ context.Context, stuckAfter time.Duration) ([]store.Placement, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + cutoff := time.Now().Add(-stuckAfter) + out := []store.Placement{} + for _, pl := range p.s.placements { + if pl.Status == store.PinStatusQueued && pl.CreatedAt.Before(cutoff) { + out = append(out, *pl) + } + } + return out, nil +} + +func (p pinStore) IncRefcount(_ context.Context, nid ids.NodeID, cid string) error { + p.s.mu.Lock() + defer p.s.mu.Unlock() + p.s.refcounts[refcountKey(nid, cid)]++ + return nil +} + +func (p pinStore) DecRefcount(_ context.Context, nid ids.NodeID, cid string) (int, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + k := refcountKey(nid, cid) + c, ok := p.s.refcounts[k] + if !ok { + return 0, store.ErrNotFound + } + c-- + if c < 0 { + c = 0 + } + p.s.refcounts[k] = c + return c, nil +} + +func (p pinStore) DeleteRefcountIfZero(_ context.Context, nid ids.NodeID, cid string) error { + p.s.mu.Lock() + defer p.s.mu.Unlock() + k := refcountKey(nid, cid) + if p.s.refcounts[k] <= 0 { + delete(p.s.refcounts, k) + } + return nil +} + +func (p pinStore) CountPlacementsByStatus(_ context.Context) (map[string]int, error) { + p.s.mu.Lock() + defer p.s.mu.Unlock() + out := map[string]int{} + for _, pl := range p.s.placements { + out[pl.Status]++ + } + return out, nil +} + +// ---- Audit ---------------------------------------------------------------- + +type auditStore struct{ s *Store } + +func (a auditStore) Insert(_ context.Context, e *store.AuditEntry) error { + a.s.mu.Lock() + defer a.s.mu.Unlock() + a.s.nextAudit++ + v := *e + v.ID = a.s.nextAudit + if v.Created.IsZero() { + v.Created = time.Now().UTC() + } + a.s.audit = append(a.s.audit, &v) + return nil +} + +// List returns audit entries newest-first. A zero-value orgID is treated +// as "all orgs" (the sysadmin cluster-wide query); any non-zero orgID +// filters to entries whose OrgID matches. +func (a auditStore) List(_ context.Context, orgID ids.OrgID, limit, offset int) ([]*store.AuditEntry, error) { + a.s.mu.Lock() + defer a.s.mu.Unlock() + out := []*store.AuditEntry{} + allOrgs := orgID.IsZero() + for i := len(a.s.audit) - 1; i >= 0; i-- { + e := a.s.audit[i] + if allOrgs || (e.OrgID != nil && *e.OrgID == orgID) { + v := *e + out = append(out, &v) + } + } + return paginate(out, limit, offset), nil +} + +// ---- helpers -------------------------------------------------------------- + +func paginate[T any](xs []T, limit, offset int) []T { + if offset < 0 { + offset = 0 + } + if offset >= len(xs) { + return nil + } + xs = xs[offset:] + if limit > 0 && limit < len(xs) { + xs = xs[:limit] + } + return xs +} + +func sortPins(ps []*store.Pin) { + // Bubble sort is fine — memstore is test-only. + for i := 0; i < len(ps); i++ { + for j := i + 1; j < len(ps); j++ { + if ps[j].Created.After(ps[i].Created) { + ps[i], ps[j] = ps[j], ps[i] + } + } + } +} diff --git a/internal/pkg/store/memstore/memstore_test.go b/internal/pkg/store/memstore/memstore_test.go new file mode 100644 index 0000000..0547be0 --- /dev/null +++ b/internal/pkg/store/memstore/memstore_test.go @@ -0,0 +1,91 @@ +package memstore_test + +import ( + "context" + "testing" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/store/memstore" +) + +func TestGetByRequestIDIgnoresOrg(t *testing.T) { + s := memstore.New() + ctx := context.Background() + + orgID := ids.MustNewOrg() + if _, err := s.Orgs().Create(ctx, orgID, "acme", "Acme"); err != nil { + t.Fatalf("Create org: %v", err) + } + + pid := ids.MustNewPin() + if err := s.Pins().Create(ctx, &store.Pin{ + RequestID: pid, OrgID: orgID, CID: "QmFoo", Status: store.PinStatusQueued, + }); err != nil { + t.Fatalf("Create pin: %v", err) + } + + // The scheduler path only has the request_id — no org. GetByRequestID + // must succeed; the old Get(ctx, ids.OrgID{}, rid) path used to fail + // every time, which is the bug this covers. + got, err := s.Pins().GetByRequestID(ctx, pid) + if err != nil { + t.Fatalf("GetByRequestID: %v", err) + } + if got.OrgID != orgID { + t.Errorf("GetByRequestID returned orgID=%q, want %q", got.OrgID, orgID) + } +} + +func TestDeleteCascadesPlacementsAndRefcount(t *testing.T) { + s := memstore.New() + ctx := context.Background() + orgID := ids.MustNewOrg() + _, _ = s.Orgs().Create(ctx, orgID, "acme", "Acme") + + pid := ids.MustNewPin() + nid := ids.MustNewNode() + _ = s.Nodes().Upsert(ctx, &store.Node{ID: nid, RPCURL: "http://k", Status: store.NodeStatusUp}) + _ = s.Pins().Create(ctx, &store.Pin{RequestID: pid, OrgID: orgID, CID: "QmX", Status: store.PinStatusQueued}) + if _, err := s.Pins().InsertPlacement(ctx, pid, nid, 1); err != nil { + t.Fatalf("InsertPlacement: %v", err) + } + if err := s.Pins().IncRefcount(ctx, nid, "QmX"); err != nil { + t.Fatalf("IncRefcount: %v", err) + } + + if err := s.Pins().Delete(ctx, orgID, pid); err != nil { + t.Fatalf("Delete: %v", err) + } + + // Placement must be gone. + if _, err := s.Pins().GetPlacement(ctx, pid, nid); err == nil { + t.Error("placement still present after Delete — cascade broken") + } + // Refcount must have been decremented to zero and the row removed. + if c, err := s.Pins().DecRefcount(ctx, nid, "QmX"); err == nil && c >= 0 { + t.Errorf("refcount row survived Delete cascade: got count=%d err=%v", c, err) + } +} + +func TestTxClosureReceivesContext(t *testing.T) { + s := memstore.New() + ctx := context.Background() + + var sawCtx context.Context + var sawStore store.Store + err := s.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + sawCtx = txCtx + sawStore = tx + return nil + }) + if err != nil { + t.Fatalf("Tx: %v", err) + } + if sawCtx == nil { + t.Error("Tx closure received nil context") + } + if sawStore == nil { + t.Error("Tx closure received nil store") + } +} diff --git a/internal/pkg/store/postgres/migrate.go b/internal/pkg/store/postgres/migrate.go new file mode 100644 index 0000000..e785f1f --- /dev/null +++ b/internal/pkg/store/postgres/migrate.go @@ -0,0 +1,142 @@ +package postgres + +import ( + "context" + "errors" + "fmt" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" // register postgres:// driver + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/pgxpool" + + "anchorage/internal/pkg/store/postgres/migrations" +) + +// MigrateDirection selects up/down for Migrate. +type MigrateDirection int + +const ( + // MigrateUp applies all pending up migrations. + MigrateUp MigrateDirection = iota + // MigrateDown reverts every applied migration (dev / test only). + MigrateDown +) + +// MigrateOptions tunes migration behavior. +type MigrateOptions struct { + // LockID is the key used with pg_advisory_lock to serialize concurrent + // migration attempts from N replicas starting simultaneously. Must be + // stable across deploys. Any non-zero int64 works; the default is fine + // unless an operator has a collision with another app sharing the DB. + LockID int64 +} + +// DefaultLockID is the advisory-lock key anchorage uses for schema migrations. +// Chosen arbitrarily but stably. +const DefaultLockID int64 = 0x616e63686f726167 // "anchorag" as hex + +// Migrate runs the embedded schema migrations against the DSN. +// +// The pgxpool is only used to discover the DSN so we don't open two +// connections to the same database — golang-migrate's postgres driver +// wants a *sql.DB, which pgx does not provide directly. We read the DSN +// back from the pool config and let migrate open its own driver. +// +// golang-migrate acquires a Postgres advisory lock before running, so N +// replicas starting simultaneously all block on the same lock and exactly +// one of them actually runs the migration; the others see a no-op. +func Migrate(ctx context.Context, pool *pgxpool.Pool, dir MigrateDirection, opts MigrateOptions) error { + if pool == nil { + return errors.New("migrate: pool is required") + } + dsn := pool.Config().ConnString() + return MigrateDSN(ctx, dsn, dir, opts) +} + +// MigrateDSN is the pool-less variant, used by CLI subcommands that have +// a DSN but don't need a warm pool. +func MigrateDSN(ctx context.Context, dsn string, dir MigrateDirection, opts MigrateOptions) error { + src, err := iofs.New(migrations.FS, ".") + if err != nil { + return fmt.Errorf("open embedded migrations: %w", err) + } + + lockID := opts.LockID + if lockID == 0 { + lockID = DefaultLockID + } + + // golang-migrate v4's postgres driver reads config from URL query params: + // x-migrations-table, x-advisory-lock-id, and others. Append ours without + // clobbering user-supplied params. + migrateDSN := appendQuery(dsn, "x-migrations-table", "anchorage_schema_migrations") + migrateDSN = appendQuery(migrateDSN, "x-advisory-lock-id", fmt.Sprintf("%d", lockID)) + + m, err := migrate.NewWithSourceInstance("iofs", src, migrateDSN) + if err != nil { + return fmt.Errorf("init migrate: %w", err) + } + defer func() { + _, _ = m.Close() + }() + + runCh := make(chan error, 1) + go func() { + switch dir { + case MigrateUp: + runCh <- m.Up() + case MigrateDown: + runCh <- m.Down() + default: + runCh <- fmt.Errorf("unknown migrate direction %d", dir) + } + }() + + select { + case err := <-runCh: + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("run migrations: %w", err) + } + return nil + case <-ctx.Done(): + // migrate has no ctx plumbing; surface the cancellation but let + // the goroutine finish so we don't leak a connection. + return ctx.Err() + } +} + +// MigrationVersion returns the currently-applied schema version and +// whether the last migration is in a dirty state. Used by `anchorage +// migrate status`. +func MigrationVersion(ctx context.Context, dsn string) (version uint, dirty bool, err error) { + src, err := iofs.New(migrations.FS, ".") + if err != nil { + return 0, false, fmt.Errorf("open embedded migrations: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, dsn) + if err != nil { + return 0, false, fmt.Errorf("init migrate: %w", err) + } + defer func() { _, _ = m.Close() }() + + v, d, err := m.Version() + if errors.Is(err, migrate.ErrNilVersion) { + return 0, false, nil + } + return v, d, err +} + +// appendQuery adds a key=value to a DSN's query string, preserving +// existing params. Handles both postgres:// URL form and space-delimited +// libpq form; on failure to parse, falls back to unmodified. +func appendQuery(dsn, k, v string) string { + sep := "?" + for _, c := range dsn { + if c == '?' { + sep = "&" + break + } + } + return dsn + sep + k + "=" + v +} diff --git a/internal/pkg/store/postgres/migrations/0001_init.down.sql b/internal/pkg/store/postgres/migrations/0001_init.down.sql new file mode 100644 index 0000000..6d0dcbe --- /dev/null +++ b/internal/pkg/store/postgres/migrations/0001_init.down.sql @@ -0,0 +1,16 @@ +BEGIN; + +DROP TABLE IF EXISTS audit_log CASCADE; +DROP TABLE IF EXISTS pin_refcount CASCADE; +DROP TABLE IF EXISTS pin_placements CASCADE; +DROP TABLE IF EXISTS pins CASCADE; +DROP TABLE IF EXISTS nodes CASCADE; +DROP TABLE IF EXISTS token_denylist CASCADE; +DROP TABLE IF EXISTS api_tokens CASCADE; +DROP TABLE IF EXISTS memberships CASCADE; +DROP TABLE IF EXISTS users CASCADE; +DROP TABLE IF EXISTS orgs CASCADE; + +DROP FUNCTION IF EXISTS anchorage_touch_updated_at(); + +COMMIT; diff --git a/internal/pkg/store/postgres/migrations/0001_init.up.sql b/internal/pkg/store/postgres/migrations/0001_init.up.sql new file mode 100644 index 0000000..d454103 --- /dev/null +++ b/internal/pkg/store/postgres/migrations/0001_init.up.sql @@ -0,0 +1,217 @@ +-- anchorage initial schema. +-- +-- Every tenant-scoped table carries org_id and enables row-level security +-- so a Go-layer bug cannot bleed rows across orgs. The application sets +-- current_setting('anchorage.org_id') per-transaction via the auth middleware. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- --------------------------------------------------------------------------- +-- orgs +-- --------------------------------------------------------------------------- +CREATE TABLE orgs ( + id text PRIMARY KEY CHECK (id LIKE 'org_%'), + slug text NOT NULL UNIQUE, + name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- users +-- --------------------------------------------------------------------------- +CREATE TABLE users ( + id text PRIMARY KEY CHECK (id LIKE 'usr_%'), + authentik_sub text UNIQUE, + email citext NOT NULL UNIQUE, + display_name text NOT NULL DEFAULT '', + is_sysadmin boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- --------------------------------------------------------------------------- +-- memberships +-- --------------------------------------------------------------------------- +CREATE TABLE memberships ( + org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role text NOT NULL CHECK (role IN ('orgadmin', 'member')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (org_id, user_id) +); + +CREATE INDEX memberships_user_idx ON memberships (user_id); + +-- --------------------------------------------------------------------------- +-- api_tokens — JWT metadata only; the signed token is never stored +-- --------------------------------------------------------------------------- +CREATE TABLE api_tokens ( + jti text PRIMARY KEY CHECK (jti LIKE 'tok_%'), + org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + label text NOT NULL, + scopes text[] NOT NULL DEFAULT '{}', + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX api_tokens_org_idx ON api_tokens (org_id, created_at DESC); +CREATE INDEX api_tokens_expiry_idx ON api_tokens (expires_at) WHERE revoked_at IS NULL; + +-- --------------------------------------------------------------------------- +-- token_denylist — explicit revocation cache, TTL'd by expires_at +-- --------------------------------------------------------------------------- +CREATE TABLE token_denylist ( + jti text PRIMARY KEY, + expires_at timestamptz NOT NULL, + reason text NOT NULL DEFAULT 'revoked', + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX token_denylist_expiry_idx ON token_denylist (expires_at); + +-- --------------------------------------------------------------------------- +-- nodes — cluster registry populated on boot and maintained by heartbeats +-- --------------------------------------------------------------------------- +CREATE TABLE nodes ( + id text PRIMARY KEY CHECK (id LIKE 'nod_%'), + display_name text NOT NULL DEFAULT '', + multiaddrs text[] NOT NULL DEFAULT '{}', + rpc_url text NOT NULL, + status text NOT NULL CHECK (status IN ('up', 'down', 'drained')), + last_seen_at timestamptz NOT NULL DEFAULT now(), + joined_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX nodes_status_idx ON nodes (status, last_seen_at DESC); + +-- --------------------------------------------------------------------------- +-- pins — logical pin records (spec: PinStatus) +-- --------------------------------------------------------------------------- +CREATE TABLE pins ( + request_id text PRIMARY KEY CHECK (request_id LIKE 'pin_%'), + org_id text NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + cid text NOT NULL, + name text, + meta jsonb NOT NULL DEFAULT '{}'::jsonb, + origins text[] NOT NULL DEFAULT '{}', + status text NOT NULL CHECK (status IN ('queued', 'pinning', 'pinned', 'failed')), + failure_reason text, + created timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX pins_org_created_idx ON pins (org_id, created DESC); +CREATE INDEX pins_org_status_created_idx ON pins (org_id, status, created DESC); +CREATE INDEX pins_org_cid_idx ON pins (org_id, cid); +CREATE INDEX pins_org_lower_name_idx ON pins (org_id, lower(name) text_pattern_ops); +CREATE INDEX pins_name_trgm_idx ON pins USING gin (lower(name) gin_trgm_ops); +CREATE INDEX pins_meta_gin_idx ON pins USING gin (meta jsonb_path_ops); + +-- Idempotent create: `POST /v1/pins` with the same (org_id, cid) collapses +-- to the existing live row. Failed pins may be retried with the same CID. +CREATE UNIQUE INDEX pins_org_cid_live_uniq + ON pins (org_id, cid) WHERE status <> 'failed'; + +-- --------------------------------------------------------------------------- +-- pin_placements — per-node scheduling rows; fence guards against zombies +-- --------------------------------------------------------------------------- +CREATE TABLE pin_placements ( + request_id text NOT NULL REFERENCES pins(request_id) ON DELETE CASCADE, + node_id text NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT, + status text NOT NULL CHECK (status IN ('queued', 'pinning', 'pinned', 'failed')), + failure_reason text, + attempts int NOT NULL DEFAULT 0, + fence bigint NOT NULL DEFAULT 1, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (request_id, node_id) +); + +CREATE INDEX pin_placements_node_status_idx ON pin_placements (node_id, status); +CREATE INDEX pin_placements_request_id_idx ON pin_placements (request_id); + +-- --------------------------------------------------------------------------- +-- pin_refcount — per-(node, cid) reference counting; row deletion = unpin +-- --------------------------------------------------------------------------- +CREATE TABLE pin_refcount ( + node_id text NOT NULL REFERENCES nodes(id) ON DELETE RESTRICT, + cid text NOT NULL, + count int NOT NULL CHECK (count >= 0), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (node_id, cid) +); + +-- --------------------------------------------------------------------------- +-- audit_log — append-only; bigserial is fine here (not user-facing) +-- --------------------------------------------------------------------------- +CREATE TABLE audit_log ( + id bigserial PRIMARY KEY, + org_id text REFERENCES orgs(id) ON DELETE CASCADE, + actor_user_id text REFERENCES users(id) ON DELETE SET NULL, + actor_token_jti text, + action text NOT NULL, + target text NOT NULL, + result text NOT NULL CHECK (result IN ('ok', 'error')), + detail jsonb NOT NULL DEFAULT '{}'::jsonb, + created timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX audit_log_org_created_idx ON audit_log (org_id, created DESC); +CREATE INDEX audit_log_actor_created_idx ON audit_log (actor_user_id, created DESC); + +-- --------------------------------------------------------------------------- +-- updated_at trigger +-- --------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION anchorage_touch_updated_at() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.updated_at := now(); + RETURN NEW; +END; +$$; + +CREATE TRIGGER orgs_touch BEFORE UPDATE ON orgs FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER users_touch BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER memberships_touch BEFORE UPDATE ON memberships FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER api_tokens_touch BEFORE UPDATE ON api_tokens FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER nodes_touch BEFORE UPDATE ON nodes FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER pins_touch BEFORE UPDATE ON pins FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER pin_placements_touch BEFORE UPDATE ON pin_placements FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); +CREATE TRIGGER pin_refcount_touch BEFORE UPDATE ON pin_refcount FOR EACH ROW EXECUTE FUNCTION anchorage_touch_updated_at(); + +-- --------------------------------------------------------------------------- +-- Row-level security — belt-and-suspenders tenant isolation +-- --------------------------------------------------------------------------- +ALTER TABLE memberships ENABLE ROW LEVEL SECURITY; +ALTER TABLE api_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE pins ENABLE ROW LEVEL SECURITY; + +-- current_setting with the second arg = true returns NULL for unset keys +-- instead of raising. An unset GUC means "no tenant context" and returns +-- zero rows, which is the safe default. +CREATE POLICY memberships_tenant_isolation ON memberships + FOR ALL + USING (org_id = current_setting('anchorage.org_id', true)) + WITH CHECK (org_id = current_setting('anchorage.org_id', true)); + +CREATE POLICY api_tokens_tenant_isolation ON api_tokens + FOR ALL + USING (org_id = current_setting('anchorage.org_id', true)) + WITH CHECK (org_id = current_setting('anchorage.org_id', true)); + +CREATE POLICY pins_tenant_isolation ON pins + FOR ALL + USING (org_id = current_setting('anchorage.org_id', true)) + WITH CHECK (org_id = current_setting('anchorage.org_id', true)); + +COMMIT; diff --git a/internal/pkg/store/postgres/migrations/embed.go b/internal/pkg/store/postgres/migrations/embed.go new file mode 100644 index 0000000..be44c9a --- /dev/null +++ b/internal/pkg/store/postgres/migrations/embed.go @@ -0,0 +1,15 @@ +// Package migrations holds anchorage's Postgres schema as an embed.FS so +// the binary is self-contained and migrations can never drift from the +// code that expects them. +package migrations + +import "embed" + +// FS is the embedded iofs source passed to golang-migrate. +// +// Each migration is two files named NNNN_description.up.sql and +// NNNN_description.down.sql; golang-migrate treats the leading integer +// as the version number. +// +//go:embed *.sql +var FS embed.FS diff --git a/internal/pkg/store/postgres/pool.go b/internal/pkg/store/postgres/pool.go new file mode 100644 index 0000000..b34f3ce --- /dev/null +++ b/internal/pkg/store/postgres/pool.go @@ -0,0 +1,59 @@ +// Package postgres is anchorage's Postgres adapter: pgxpool lifecycle, +// schema migrations, and the concrete implementation of the store +// interfaces defined in internal/pkg/store. +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// PoolConfig is the subset of config.PostgresConfig the pool needs. Kept +// as a local struct so callers (tests, migrate subcommand) can construct +// one without importing the full config package. +type PoolConfig struct { + DSN string + MaxConns int +} + +// NewPool constructs a pgxpool.Pool and verifies connectivity with a ping. +// +// The caller is responsible for calling pool.Close() at shutdown. A zero +// or negative MaxConns falls back to pgxpool's default. +func NewPool(ctx context.Context, cfg PoolConfig) (*pgxpool.Pool, error) { + if cfg.DSN == "" { + return nil, fmt.Errorf("postgres dsn is required") + } + + poolCfg, err := pgxpool.ParseConfig(cfg.DSN) + if err != nil { + return nil, fmt.Errorf("parse postgres dsn: %w", err) + } + + if cfg.MaxConns > 0 { + poolCfg.MaxConns = int32(cfg.MaxConns) + } + + // Keep idle conns short so Postgres restarts don't wedge old tcp + // connections that will never work again. + poolCfg.MaxConnIdleTime = 5 * time.Minute + poolCfg.MaxConnLifetime = 30 * time.Minute + poolCfg.HealthCheckPeriod = 30 * time.Second + + pool, err := pgxpool.NewWithConfig(ctx, poolCfg) + if err != nil { + return nil, fmt.Errorf("open postgres pool: %w", err) + } + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping postgres: %w", err) + } + + return pool, nil +} diff --git a/internal/pkg/store/postgres/queries/audit.sql b/internal/pkg/store/postgres/queries/audit.sql new file mode 100644 index 0000000..eac1915 --- /dev/null +++ b/internal/pkg/store/postgres/queries/audit.sql @@ -0,0 +1,9 @@ +-- name: InsertAudit :exec +INSERT INTO audit_log (org_id, actor_user_id, actor_token_jti, action, target, result, detail) +VALUES ($1, $2, $3, $4, $5, $6, $7); + +-- name: ListAudit :many +SELECT * FROM audit_log +WHERE org_id = $1 +ORDER BY created DESC +LIMIT $2 OFFSET $3; diff --git a/internal/pkg/store/postgres/queries/nodes.sql b/internal/pkg/store/postgres/queries/nodes.sql new file mode 100644 index 0000000..67bfbcf --- /dev/null +++ b/internal/pkg/store/postgres/queries/nodes.sql @@ -0,0 +1,37 @@ +-- name: UpsertNode :one +INSERT INTO nodes (id, display_name, multiaddrs, rpc_url, status) +VALUES ($1, $2, $3, $4, 'up') +ON CONFLICT (id) DO UPDATE + SET display_name = EXCLUDED.display_name, + multiaddrs = EXCLUDED.multiaddrs, + rpc_url = EXCLUDED.rpc_url, + status = CASE WHEN nodes.status = 'drained' THEN 'drained' ELSE 'up' END, + last_seen_at = now() +RETURNING *; + +-- name: GetNode :one +SELECT * FROM nodes WHERE id = $1; + +-- name: ListLiveNodes :many +SELECT * FROM nodes WHERE status = 'up' ORDER BY id; + +-- name: ListAllNodes :many +SELECT * FROM nodes ORDER BY id; + +-- name: TouchNodeHeartbeat :exec +UPDATE nodes SET last_seen_at = now() WHERE id = $1; + +-- name: MarkNodeDown :exec +UPDATE nodes SET status = 'down' WHERE id = $1 AND status = 'up'; + +-- name: DrainNode :exec +UPDATE nodes SET status = 'drained' WHERE id = $1; + +-- name: UncordonNode :exec +UPDATE nodes SET status = 'up' WHERE id = $1 AND status = 'drained'; + +-- name: MarkStaleNodesDown :many +UPDATE nodes +SET status = 'down' +WHERE status = 'up' AND last_seen_at < (now() - make_interval(secs => $1)) +RETURNING id; diff --git a/internal/pkg/store/postgres/queries/orgs.sql b/internal/pkg/store/postgres/queries/orgs.sql new file mode 100644 index 0000000..183700c --- /dev/null +++ b/internal/pkg/store/postgres/queries/orgs.sql @@ -0,0 +1,16 @@ +-- name: CreateOrg :one +INSERT INTO orgs (id, slug, name) +VALUES ($1, $2, $3) +RETURNING *; + +-- name: GetOrgByID :one +SELECT * FROM orgs WHERE id = $1; + +-- name: GetOrgBySlug :one +SELECT * FROM orgs WHERE slug = $1; + +-- name: UpdateOrgName :one +UPDATE orgs SET name = $2 WHERE id = $1 RETURNING *; + +-- name: ListOrgs :many +SELECT * FROM orgs ORDER BY id DESC LIMIT $1 OFFSET $2; diff --git a/internal/pkg/store/postgres/queries/pins.sql b/internal/pkg/store/postgres/queries/pins.sql new file mode 100644 index 0000000..ef11076 --- /dev/null +++ b/internal/pkg/store/postgres/queries/pins.sql @@ -0,0 +1,122 @@ +-- name: CreatePin :one +INSERT INTO pins (request_id, org_id, cid, name, meta, origins, status) +VALUES ($1, $2, $3, $4, $5, $6, 'queued') +RETURNING *; + +-- name: GetPin :one +SELECT * FROM pins WHERE request_id = $1 AND org_id = $2; + +-- name: GetExistingLivePinByCID :one +SELECT * FROM pins +WHERE org_id = $1 AND cid = $2 AND status <> 'failed' +LIMIT 1; + +-- name: UpdatePinStatus :exec +UPDATE pins SET status = $2, failure_reason = $3 WHERE request_id = $1; + +-- name: DeletePin :exec +DELETE FROM pins WHERE request_id = $1 AND org_id = $2; + +-- name: ListPins :many +SELECT * FROM pins +WHERE org_id = $1 +ORDER BY created DESC +LIMIT $2 OFFSET $3; + +-- FilterPins honors the full IPFS Pinning API spec filter surface. +-- +-- All filters are optional; empty arrays / NULL values short-circuit to +-- "no restriction" via the IS NULL / array-length guards. Name matching +-- supports four modes through a single `match` discriminator: +-- +-- 'exact' — LIKE (case-sensitive, no wildcards) +-- 'iexact' — ILIKE anchored both ends +-- 'partial' — LIKE %name% +-- 'ipartial' — ILIKE %name% (uses pg_trgm GIN) +-- +-- `meta` is treated as a jsonb "contains" check — the pin's meta must +-- be a superset of the requested map. +-- +-- name: FilterPins :many +SELECT * +FROM pins +WHERE org_id = $1 + AND (COALESCE(array_length(sqlc.arg(cids)::text[], 1), 0) = 0 + OR cid = ANY(sqlc.arg(cids)::text[])) + AND (COALESCE(array_length(sqlc.arg(statuses)::text[], 1), 0) = 0 + OR status = ANY(sqlc.arg(statuses)::text[])) + AND (sqlc.narg(name)::text IS NULL + OR (sqlc.arg(match_mode)::text = 'exact' AND name = sqlc.narg(name)::text) + OR (sqlc.arg(match_mode)::text = 'iexact' AND lower(name) = lower(sqlc.narg(name)::text)) + OR (sqlc.arg(match_mode)::text = 'partial' AND name LIKE '%' || sqlc.narg(name)::text || '%') + OR (sqlc.arg(match_mode)::text = 'ipartial' AND name ILIKE '%' || sqlc.narg(name)::text || '%')) + AND (sqlc.narg(before)::timestamptz IS NULL OR created < sqlc.narg(before)::timestamptz) + AND (sqlc.narg(after)::timestamptz IS NULL OR created >= sqlc.narg(after)::timestamptz) + AND (sqlc.arg(meta_filter)::jsonb = '{}'::jsonb + OR meta @> sqlc.arg(meta_filter)::jsonb) +ORDER BY created DESC +LIMIT sqlc.arg(pin_limit) +OFFSET sqlc.arg(pin_offset); + +-- ReplacePin swaps a pin's CID atomically. Used by POST /v1/pins/{rid} +-- (pin replace per spec). Placements + refcount are left to the caller +-- to rearrange in the same transaction. +-- +-- name: ReplacePin :one +UPDATE pins +SET cid = $3, + name = $4, + meta = $5, + origins = $6, + status = 'queued', + failure_reason = NULL +WHERE request_id = $1 AND org_id = $2 +RETURNING *; + +-- name: InsertPlacement :one +INSERT INTO pin_placements (request_id, node_id, status, fence) +VALUES ($1, $2, 'queued', $3) +RETURNING *; + +-- name: GetPlacement :one +SELECT * FROM pin_placements WHERE request_id = $1 AND node_id = $2; + +-- name: ListPlacementsForPin :many +SELECT pp.*, n.multiaddrs +FROM pin_placements pp JOIN nodes n ON n.id = pp.node_id +WHERE pp.request_id = $1; + +-- name: ListPlacementsForNode :many +SELECT * FROM pin_placements +WHERE node_id = $1 AND status = $2 +ORDER BY created_at; + +-- name: UpdatePlacementStatusFenced :execrows +UPDATE pin_placements +SET status = $3, failure_reason = $4, attempts = attempts + 1 +WHERE request_id = $1 AND node_id = $2 AND fence = $5; + +-- name: ReplacePlacementFence :one +UPDATE pin_placements +SET node_id = $3, fence = fence + 1, status = 'queued', failure_reason = NULL +WHERE request_id = $1 AND node_id = $2 +RETURNING *; + +-- name: StuckPlacements :many +SELECT request_id, node_id, fence +FROM pin_placements +WHERE status = 'queued' AND created_at < (now() - make_interval(secs => $1)); + +-- name: IncRefcount :exec +INSERT INTO pin_refcount (node_id, cid, count) +VALUES ($1, $2, 1) +ON CONFLICT (node_id, cid) DO UPDATE SET count = pin_refcount.count + 1; + +-- name: DecRefcount :one +UPDATE pin_refcount +SET count = count - 1 +WHERE node_id = $1 AND cid = $2 +RETURNING count; + +-- name: DeleteRefcount :exec +DELETE FROM pin_refcount WHERE node_id = $1 AND cid = $2 AND count <= 0; diff --git a/internal/pkg/store/postgres/queries/tokens.sql b/internal/pkg/store/postgres/queries/tokens.sql new file mode 100644 index 0000000..9f72eaf --- /dev/null +++ b/internal/pkg/store/postgres/queries/tokens.sql @@ -0,0 +1,31 @@ +-- name: CreateAPIToken :one +INSERT INTO api_tokens (jti, org_id, user_id, label, scopes, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; + +-- name: GetAPITokenByJTI :one +SELECT * FROM api_tokens WHERE jti = $1; + +-- name: ListAPITokensForUser :many +SELECT * FROM api_tokens +WHERE org_id = $1 AND user_id = $2 AND revoked_at IS NULL +ORDER BY created_at DESC; + +-- name: RevokeAPIToken :exec +UPDATE api_tokens SET revoked_at = now() WHERE jti = $1; + +-- name: TouchAPITokenLastUsed :exec +UPDATE api_tokens SET last_used_at = now() WHERE jti = $1; + +-- name: AddTokenDenylist :exec +INSERT INTO token_denylist (jti, expires_at, reason) +VALUES ($1, $2, $3) +ON CONFLICT (jti) DO NOTHING; + +-- name: IsTokenDenied :one +SELECT EXISTS( + SELECT 1 FROM token_denylist WHERE jti = $1 AND expires_at > now() +); + +-- name: PruneTokenDenylist :exec +DELETE FROM token_denylist WHERE expires_at <= now(); diff --git a/internal/pkg/store/postgres/queries/users.sql b/internal/pkg/store/postgres/queries/users.sql new file mode 100644 index 0000000..6465e1f --- /dev/null +++ b/internal/pkg/store/postgres/queries/users.sql @@ -0,0 +1,30 @@ +-- name: UpsertUserByAuthentikSub :one +INSERT INTO users (id, authentik_sub, email, display_name, is_sysadmin) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (authentik_sub) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name +RETURNING *; + +-- name: GetUserByID :one +SELECT * FROM users WHERE id = $1; + +-- name: GetUserByEmail :one +SELECT * FROM users WHERE email = $1; + +-- name: PromoteSysadmin :exec +UPDATE users SET is_sysadmin = true WHERE id = $1; + +-- name: ListMemberships :many +SELECT m.*, o.slug, o.name +FROM memberships m JOIN orgs o ON o.id = m.org_id +WHERE m.user_id = $1 +ORDER BY m.org_id; + +-- name: AddMembership :exec +INSERT INTO memberships (org_id, user_id, role) +VALUES ($1, $2, $3) +ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role; + +-- name: RemoveMembership :exec +DELETE FROM memberships WHERE org_id = $1 AND user_id = $2; diff --git a/internal/pkg/store/postgres/sqlc/audit.sql.go b/internal/pkg/store/postgres/sqlc/audit.sql.go new file mode 100644 index 0000000..0fefac5 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/audit.sql.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: audit.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" +) + +const insertAudit = `-- name: InsertAudit :exec +INSERT INTO audit_log (org_id, actor_user_id, actor_token_jti, action, target, result, detail) +VALUES ($1, $2, $3, $4, $5, $6, $7) +` + +type InsertAuditParams struct { + OrgID ids.OrgID + ActorUserID ids.UserID + ActorTokenJti *string + Action string + Target string + Result string + Detail []byte +} + +func (q *Queries) InsertAudit(ctx context.Context, arg InsertAuditParams) error { + _, err := q.db.Exec(ctx, insertAudit, + arg.OrgID, + arg.ActorUserID, + arg.ActorTokenJti, + arg.Action, + arg.Target, + arg.Result, + arg.Detail, + ) + return err +} + +const listAudit = `-- name: ListAudit :many +SELECT id, org_id, actor_user_id, actor_token_jti, action, target, result, detail, created FROM audit_log +WHERE org_id = $1 +ORDER BY created DESC +LIMIT $2 OFFSET $3 +` + +type ListAuditParams struct { + OrgID ids.OrgID + Limit int32 + Offset int32 +} + +func (q *Queries) ListAudit(ctx context.Context, arg ListAuditParams) ([]AuditLog, error) { + rows, err := q.db.Query(ctx, listAudit, arg.OrgID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuditLog + for rows.Next() { + var i AuditLog + if err := rows.Scan( + &i.ID, + &i.OrgID, + &i.ActorUserID, + &i.ActorTokenJti, + &i.Action, + &i.Target, + &i.Result, + &i.Detail, + &i.Created, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/pkg/store/postgres/sqlc/db.go b/internal/pkg/store/postgres/sqlc/db.go new file mode 100644 index 0000000..7a56507 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/pkg/store/postgres/sqlc/models.go b/internal/pkg/store/postgres/sqlc/models.go new file mode 100644 index 0000000..c69e586 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/models.go @@ -0,0 +1,110 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "anchorage/internal/pkg/ids" + "github.com/jackc/pgx/v5/pgtype" +) + +type ApiToken struct { + Jti ids.TokenID + OrgID ids.OrgID + UserID ids.UserID + Label string + Scopes []string + ExpiresAt pgtype.Timestamptz + RevokedAt pgtype.Timestamptz + LastUsedAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type AuditLog struct { + ID int64 + OrgID ids.OrgID + ActorUserID ids.UserID + ActorTokenJti *string + Action string + Target string + Result string + Detail []byte + Created pgtype.Timestamptz +} + +type Membership struct { + OrgID ids.OrgID + UserID ids.UserID + Role string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Node struct { + ID ids.NodeID + DisplayName string + Multiaddrs []string + RpcUrl string + Status string + LastSeenAt pgtype.Timestamptz + JoinedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Org struct { + ID ids.OrgID + Slug string + Name string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type Pin struct { + RequestID ids.PinID + OrgID ids.OrgID + Cid string + Name *string + Meta []byte + Origins []string + Status string + FailureReason *string + Created pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type PinPlacement struct { + RequestID ids.PinID + NodeID ids.NodeID + Status string + FailureReason *string + Attempts int32 + Fence int64 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +type PinRefcount struct { + NodeID ids.NodeID + Cid string + Count int32 + UpdatedAt pgtype.Timestamptz +} + +type TokenDenylist struct { + Jti string + ExpiresAt pgtype.Timestamptz + Reason string + CreatedAt pgtype.Timestamptz +} + +type User struct { + ID ids.UserID + AuthentikSub *string + Email string + DisplayName string + IsSysadmin bool + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/pkg/store/postgres/sqlc/nodes.sql.go b/internal/pkg/store/postgres/sqlc/nodes.sql.go new file mode 100644 index 0000000..7a50c5c --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/nodes.sql.go @@ -0,0 +1,201 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: nodes.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" +) + +const drainNode = `-- name: DrainNode :exec +UPDATE nodes SET status = 'drained' WHERE id = $1 +` + +func (q *Queries) DrainNode(ctx context.Context, id ids.NodeID) error { + _, err := q.db.Exec(ctx, drainNode, id) + return err +} + +const getNode = `-- name: GetNode :one +SELECT id, display_name, multiaddrs, rpc_url, status, last_seen_at, joined_at, updated_at FROM nodes WHERE id = $1 +` + +func (q *Queries) GetNode(ctx context.Context, id ids.NodeID) (Node, error) { + row := q.db.QueryRow(ctx, getNode, id) + var i Node + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Multiaddrs, + &i.RpcUrl, + &i.Status, + &i.LastSeenAt, + &i.JoinedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAllNodes = `-- name: ListAllNodes :many +SELECT id, display_name, multiaddrs, rpc_url, status, last_seen_at, joined_at, updated_at FROM nodes ORDER BY id +` + +func (q *Queries) ListAllNodes(ctx context.Context) ([]Node, error) { + rows, err := q.db.Query(ctx, listAllNodes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Node + for rows.Next() { + var i Node + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Multiaddrs, + &i.RpcUrl, + &i.Status, + &i.LastSeenAt, + &i.JoinedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listLiveNodes = `-- name: ListLiveNodes :many +SELECT id, display_name, multiaddrs, rpc_url, status, last_seen_at, joined_at, updated_at FROM nodes WHERE status = 'up' ORDER BY id +` + +func (q *Queries) ListLiveNodes(ctx context.Context) ([]Node, error) { + rows, err := q.db.Query(ctx, listLiveNodes) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Node + for rows.Next() { + var i Node + if err := rows.Scan( + &i.ID, + &i.DisplayName, + &i.Multiaddrs, + &i.RpcUrl, + &i.Status, + &i.LastSeenAt, + &i.JoinedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markNodeDown = `-- name: MarkNodeDown :exec +UPDATE nodes SET status = 'down' WHERE id = $1 AND status = 'up' +` + +func (q *Queries) MarkNodeDown(ctx context.Context, id ids.NodeID) error { + _, err := q.db.Exec(ctx, markNodeDown, id) + return err +} + +const markStaleNodesDown = `-- name: MarkStaleNodesDown :many +UPDATE nodes +SET status = 'down' +WHERE status = 'up' AND last_seen_at < (now() - make_interval(secs => $1)) +RETURNING id +` + +func (q *Queries) MarkStaleNodesDown(ctx context.Context, secs float64) ([]ids.NodeID, error) { + rows, err := q.db.Query(ctx, markStaleNodesDown, secs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ids.NodeID + for rows.Next() { + var id ids.NodeID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const touchNodeHeartbeat = `-- name: TouchNodeHeartbeat :exec +UPDATE nodes SET last_seen_at = now() WHERE id = $1 +` + +func (q *Queries) TouchNodeHeartbeat(ctx context.Context, id ids.NodeID) error { + _, err := q.db.Exec(ctx, touchNodeHeartbeat, id) + return err +} + +const uncordonNode = `-- name: UncordonNode :exec +UPDATE nodes SET status = 'up' WHERE id = $1 AND status = 'drained' +` + +func (q *Queries) UncordonNode(ctx context.Context, id ids.NodeID) error { + _, err := q.db.Exec(ctx, uncordonNode, id) + return err +} + +const upsertNode = `-- name: UpsertNode :one +INSERT INTO nodes (id, display_name, multiaddrs, rpc_url, status) +VALUES ($1, $2, $3, $4, 'up') +ON CONFLICT (id) DO UPDATE + SET display_name = EXCLUDED.display_name, + multiaddrs = EXCLUDED.multiaddrs, + rpc_url = EXCLUDED.rpc_url, + status = CASE WHEN nodes.status = 'drained' THEN 'drained' ELSE 'up' END, + last_seen_at = now() +RETURNING id, display_name, multiaddrs, rpc_url, status, last_seen_at, joined_at, updated_at +` + +type UpsertNodeParams struct { + ID ids.NodeID + DisplayName string + Multiaddrs []string + RpcUrl string +} + +func (q *Queries) UpsertNode(ctx context.Context, arg UpsertNodeParams) (Node, error) { + row := q.db.QueryRow(ctx, upsertNode, + arg.ID, + arg.DisplayName, + arg.Multiaddrs, + arg.RpcUrl, + ) + var i Node + err := row.Scan( + &i.ID, + &i.DisplayName, + &i.Multiaddrs, + &i.RpcUrl, + &i.Status, + &i.LastSeenAt, + &i.JoinedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/pkg/store/postgres/sqlc/orgs.sql.go b/internal/pkg/store/postgres/sqlc/orgs.sql.go new file mode 100644 index 0000000..70dc6ab --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/orgs.sql.go @@ -0,0 +1,128 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: orgs.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" +) + +const createOrg = `-- name: CreateOrg :one +INSERT INTO orgs (id, slug, name) +VALUES ($1, $2, $3) +RETURNING id, slug, name, created_at, updated_at +` + +type CreateOrgParams struct { + ID ids.OrgID + Slug string + Name string +} + +func (q *Queries) CreateOrg(ctx context.Context, arg CreateOrgParams) (Org, error) { + row := q.db.QueryRow(ctx, createOrg, arg.ID, arg.Slug, arg.Name) + var i Org + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrgByID = `-- name: GetOrgByID :one +SELECT id, slug, name, created_at, updated_at FROM orgs WHERE id = $1 +` + +func (q *Queries) GetOrgByID(ctx context.Context, id ids.OrgID) (Org, error) { + row := q.db.QueryRow(ctx, getOrgByID, id) + var i Org + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getOrgBySlug = `-- name: GetOrgBySlug :one +SELECT id, slug, name, created_at, updated_at FROM orgs WHERE slug = $1 +` + +func (q *Queries) GetOrgBySlug(ctx context.Context, slug string) (Org, error) { + row := q.db.QueryRow(ctx, getOrgBySlug, slug) + var i Org + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listOrgs = `-- name: ListOrgs :many +SELECT id, slug, name, created_at, updated_at FROM orgs ORDER BY id DESC LIMIT $1 OFFSET $2 +` + +type ListOrgsParams struct { + Limit int32 + Offset int32 +} + +func (q *Queries) ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Org, error) { + rows, err := q.db.Query(ctx, listOrgs, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Org + for rows.Next() { + var i Org + if err := rows.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateOrgName = `-- name: UpdateOrgName :one +UPDATE orgs SET name = $2 WHERE id = $1 RETURNING id, slug, name, created_at, updated_at +` + +type UpdateOrgNameParams struct { + ID ids.OrgID + Name string +} + +func (q *Queries) UpdateOrgName(ctx context.Context, arg UpdateOrgNameParams) (Org, error) { + row := q.db.QueryRow(ctx, updateOrgName, arg.ID, arg.Name) + var i Org + err := row.Scan( + &i.ID, + &i.Slug, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/pkg/store/postgres/sqlc/pins.sql.go b/internal/pkg/store/postgres/sqlc/pins.sql.go new file mode 100644 index 0000000..2773041 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/pins.sql.go @@ -0,0 +1,600 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: pins.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" + "github.com/jackc/pgx/v5/pgtype" +) + +const createPin = `-- name: CreatePin :one +INSERT INTO pins (request_id, org_id, cid, name, meta, origins, status) +VALUES ($1, $2, $3, $4, $5, $6, 'queued') +RETURNING request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at +` + +type CreatePinParams struct { + RequestID ids.PinID + OrgID ids.OrgID + Cid string + Name *string + Meta []byte + Origins []string +} + +func (q *Queries) CreatePin(ctx context.Context, arg CreatePinParams) (Pin, error) { + row := q.db.QueryRow(ctx, createPin, + arg.RequestID, + arg.OrgID, + arg.Cid, + arg.Name, + arg.Meta, + arg.Origins, + ) + var i Pin + err := row.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ) + return i, err +} + +const decRefcount = `-- name: DecRefcount :one +UPDATE pin_refcount +SET count = count - 1 +WHERE node_id = $1 AND cid = $2 +RETURNING count +` + +type DecRefcountParams struct { + NodeID ids.NodeID + Cid string +} + +func (q *Queries) DecRefcount(ctx context.Context, arg DecRefcountParams) (int32, error) { + row := q.db.QueryRow(ctx, decRefcount, arg.NodeID, arg.Cid) + var count int32 + err := row.Scan(&count) + return count, err +} + +const deletePin = `-- name: DeletePin :exec +DELETE FROM pins WHERE request_id = $1 AND org_id = $2 +` + +type DeletePinParams struct { + RequestID ids.PinID + OrgID ids.OrgID +} + +func (q *Queries) DeletePin(ctx context.Context, arg DeletePinParams) error { + _, err := q.db.Exec(ctx, deletePin, arg.RequestID, arg.OrgID) + return err +} + +const deleteRefcount = `-- name: DeleteRefcount :exec +DELETE FROM pin_refcount WHERE node_id = $1 AND cid = $2 AND count <= 0 +` + +type DeleteRefcountParams struct { + NodeID ids.NodeID + Cid string +} + +func (q *Queries) DeleteRefcount(ctx context.Context, arg DeleteRefcountParams) error { + _, err := q.db.Exec(ctx, deleteRefcount, arg.NodeID, arg.Cid) + return err +} + +const filterPins = `-- name: FilterPins :many +SELECT request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at +FROM pins +WHERE org_id = $1 + AND (COALESCE(array_length($2::text[], 1), 0) = 0 + OR cid = ANY($2::text[])) + AND (COALESCE(array_length($3::text[], 1), 0) = 0 + OR status = ANY($3::text[])) + AND ($4::text IS NULL + OR ($5::text = 'exact' AND name = $4::text) + OR ($5::text = 'iexact' AND lower(name) = lower($4::text)) + OR ($5::text = 'partial' AND name LIKE '%' || $4::text || '%') + OR ($5::text = 'ipartial' AND name ILIKE '%' || $4::text || '%')) + AND ($6::timestamptz IS NULL OR created < $6::timestamptz) + AND ($7::timestamptz IS NULL OR created >= $7::timestamptz) + AND ($8::jsonb = '{}'::jsonb + OR meta @> $8::jsonb) +ORDER BY created DESC +LIMIT $10 +OFFSET $9 +` + +type FilterPinsParams struct { + OrgID ids.OrgID + Cids []string + Statuses []string + Name *string + MatchMode string + Before pgtype.Timestamptz + After pgtype.Timestamptz + MetaFilter []byte + PinOffset int32 + PinLimit int32 +} + +// FilterPins honors the full IPFS Pinning API spec filter surface. +// +// All filters are optional; empty arrays / NULL values short-circuit to +// "no restriction" via the IS NULL / array-length guards. Name matching +// supports four modes through a single `match` discriminator: +// +// 'exact' — LIKE (case-sensitive, no wildcards) +// 'iexact' — ILIKE anchored both ends +// 'partial' — LIKE %name% +// 'ipartial' — ILIKE %name% (uses pg_trgm GIN) +// +// `meta` is treated as a jsonb "contains" check — the pin's meta must +// be a superset of the requested map. +func (q *Queries) FilterPins(ctx context.Context, arg FilterPinsParams) ([]Pin, error) { + rows, err := q.db.Query(ctx, filterPins, + arg.OrgID, + arg.Cids, + arg.Statuses, + arg.Name, + arg.MatchMode, + arg.Before, + arg.After, + arg.MetaFilter, + arg.PinOffset, + arg.PinLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Pin + for rows.Next() { + var i Pin + if err := rows.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getExistingLivePinByCID = `-- name: GetExistingLivePinByCID :one +SELECT request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at FROM pins +WHERE org_id = $1 AND cid = $2 AND status <> 'failed' +LIMIT 1 +` + +type GetExistingLivePinByCIDParams struct { + OrgID ids.OrgID + Cid string +} + +func (q *Queries) GetExistingLivePinByCID(ctx context.Context, arg GetExistingLivePinByCIDParams) (Pin, error) { + row := q.db.QueryRow(ctx, getExistingLivePinByCID, arg.OrgID, arg.Cid) + var i Pin + err := row.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ) + return i, err +} + +const getPin = `-- name: GetPin :one +SELECT request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at FROM pins WHERE request_id = $1 AND org_id = $2 +` + +type GetPinParams struct { + RequestID ids.PinID + OrgID ids.OrgID +} + +func (q *Queries) GetPin(ctx context.Context, arg GetPinParams) (Pin, error) { + row := q.db.QueryRow(ctx, getPin, arg.RequestID, arg.OrgID) + var i Pin + err := row.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ) + return i, err +} + +const getPlacement = `-- name: GetPlacement :one +SELECT request_id, node_id, status, failure_reason, attempts, fence, created_at, updated_at FROM pin_placements WHERE request_id = $1 AND node_id = $2 +` + +type GetPlacementParams struct { + RequestID ids.PinID + NodeID ids.NodeID +} + +func (q *Queries) GetPlacement(ctx context.Context, arg GetPlacementParams) (PinPlacement, error) { + row := q.db.QueryRow(ctx, getPlacement, arg.RequestID, arg.NodeID) + var i PinPlacement + err := row.Scan( + &i.RequestID, + &i.NodeID, + &i.Status, + &i.FailureReason, + &i.Attempts, + &i.Fence, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const incRefcount = `-- name: IncRefcount :exec +INSERT INTO pin_refcount (node_id, cid, count) +VALUES ($1, $2, 1) +ON CONFLICT (node_id, cid) DO UPDATE SET count = pin_refcount.count + 1 +` + +type IncRefcountParams struct { + NodeID ids.NodeID + Cid string +} + +func (q *Queries) IncRefcount(ctx context.Context, arg IncRefcountParams) error { + _, err := q.db.Exec(ctx, incRefcount, arg.NodeID, arg.Cid) + return err +} + +const insertPlacement = `-- name: InsertPlacement :one +INSERT INTO pin_placements (request_id, node_id, status, fence) +VALUES ($1, $2, 'queued', $3) +RETURNING request_id, node_id, status, failure_reason, attempts, fence, created_at, updated_at +` + +type InsertPlacementParams struct { + RequestID ids.PinID + NodeID ids.NodeID + Fence int64 +} + +func (q *Queries) InsertPlacement(ctx context.Context, arg InsertPlacementParams) (PinPlacement, error) { + row := q.db.QueryRow(ctx, insertPlacement, arg.RequestID, arg.NodeID, arg.Fence) + var i PinPlacement + err := row.Scan( + &i.RequestID, + &i.NodeID, + &i.Status, + &i.FailureReason, + &i.Attempts, + &i.Fence, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listPins = `-- name: ListPins :many +SELECT request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at FROM pins +WHERE org_id = $1 +ORDER BY created DESC +LIMIT $2 OFFSET $3 +` + +type ListPinsParams struct { + OrgID ids.OrgID + Limit int32 + Offset int32 +} + +func (q *Queries) ListPins(ctx context.Context, arg ListPinsParams) ([]Pin, error) { + rows, err := q.db.Query(ctx, listPins, arg.OrgID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Pin + for rows.Next() { + var i Pin + if err := rows.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlacementsForNode = `-- name: ListPlacementsForNode :many +SELECT request_id, node_id, status, failure_reason, attempts, fence, created_at, updated_at FROM pin_placements +WHERE node_id = $1 AND status = $2 +ORDER BY created_at +` + +type ListPlacementsForNodeParams struct { + NodeID ids.NodeID + Status string +} + +func (q *Queries) ListPlacementsForNode(ctx context.Context, arg ListPlacementsForNodeParams) ([]PinPlacement, error) { + rows, err := q.db.Query(ctx, listPlacementsForNode, arg.NodeID, arg.Status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []PinPlacement + for rows.Next() { + var i PinPlacement + if err := rows.Scan( + &i.RequestID, + &i.NodeID, + &i.Status, + &i.FailureReason, + &i.Attempts, + &i.Fence, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listPlacementsForPin = `-- name: ListPlacementsForPin :many +SELECT pp.request_id, pp.node_id, pp.status, pp.failure_reason, pp.attempts, pp.fence, pp.created_at, pp.updated_at, n.multiaddrs +FROM pin_placements pp JOIN nodes n ON n.id = pp.node_id +WHERE pp.request_id = $1 +` + +type ListPlacementsForPinRow struct { + RequestID ids.PinID + NodeID ids.NodeID + Status string + FailureReason *string + Attempts int32 + Fence int64 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Multiaddrs []string +} + +func (q *Queries) ListPlacementsForPin(ctx context.Context, requestID ids.PinID) ([]ListPlacementsForPinRow, error) { + rows, err := q.db.Query(ctx, listPlacementsForPin, requestID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListPlacementsForPinRow + for rows.Next() { + var i ListPlacementsForPinRow + if err := rows.Scan( + &i.RequestID, + &i.NodeID, + &i.Status, + &i.FailureReason, + &i.Attempts, + &i.Fence, + &i.CreatedAt, + &i.UpdatedAt, + &i.Multiaddrs, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const replacePin = `-- name: ReplacePin :one +UPDATE pins +SET cid = $3, + name = $4, + meta = $5, + origins = $6, + status = 'queued', + failure_reason = NULL +WHERE request_id = $1 AND org_id = $2 +RETURNING request_id, org_id, cid, name, meta, origins, status, failure_reason, created, updated_at +` + +type ReplacePinParams struct { + RequestID ids.PinID + OrgID ids.OrgID + Cid string + Name *string + Meta []byte + Origins []string +} + +// ReplacePin swaps a pin's CID atomically. Used by POST /v1/pins/{rid} +// (pin replace per spec). Placements + refcount are left to the caller +// to rearrange in the same transaction. +func (q *Queries) ReplacePin(ctx context.Context, arg ReplacePinParams) (Pin, error) { + row := q.db.QueryRow(ctx, replacePin, + arg.RequestID, + arg.OrgID, + arg.Cid, + arg.Name, + arg.Meta, + arg.Origins, + ) + var i Pin + err := row.Scan( + &i.RequestID, + &i.OrgID, + &i.Cid, + &i.Name, + &i.Meta, + &i.Origins, + &i.Status, + &i.FailureReason, + &i.Created, + &i.UpdatedAt, + ) + return i, err +} + +const replacePlacementFence = `-- name: ReplacePlacementFence :one +UPDATE pin_placements +SET node_id = $3, fence = fence + 1, status = 'queued', failure_reason = NULL +WHERE request_id = $1 AND node_id = $2 +RETURNING request_id, node_id, status, failure_reason, attempts, fence, created_at, updated_at +` + +type ReplacePlacementFenceParams struct { + RequestID ids.PinID + NodeID ids.NodeID + NodeID_2 ids.NodeID +} + +func (q *Queries) ReplacePlacementFence(ctx context.Context, arg ReplacePlacementFenceParams) (PinPlacement, error) { + row := q.db.QueryRow(ctx, replacePlacementFence, arg.RequestID, arg.NodeID, arg.NodeID_2) + var i PinPlacement + err := row.Scan( + &i.RequestID, + &i.NodeID, + &i.Status, + &i.FailureReason, + &i.Attempts, + &i.Fence, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const stuckPlacements = `-- name: StuckPlacements :many +SELECT request_id, node_id, fence +FROM pin_placements +WHERE status = 'queued' AND created_at < (now() - make_interval(secs => $1)) +` + +type StuckPlacementsRow struct { + RequestID ids.PinID + NodeID ids.NodeID + Fence int64 +} + +func (q *Queries) StuckPlacements(ctx context.Context, secs float64) ([]StuckPlacementsRow, error) { + rows, err := q.db.Query(ctx, stuckPlacements, secs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []StuckPlacementsRow + for rows.Next() { + var i StuckPlacementsRow + if err := rows.Scan(&i.RequestID, &i.NodeID, &i.Fence); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePinStatus = `-- name: UpdatePinStatus :exec +UPDATE pins SET status = $2, failure_reason = $3 WHERE request_id = $1 +` + +type UpdatePinStatusParams struct { + RequestID ids.PinID + Status string + FailureReason *string +} + +func (q *Queries) UpdatePinStatus(ctx context.Context, arg UpdatePinStatusParams) error { + _, err := q.db.Exec(ctx, updatePinStatus, arg.RequestID, arg.Status, arg.FailureReason) + return err +} + +const updatePlacementStatusFenced = `-- name: UpdatePlacementStatusFenced :execrows +UPDATE pin_placements +SET status = $3, failure_reason = $4, attempts = attempts + 1 +WHERE request_id = $1 AND node_id = $2 AND fence = $5 +` + +type UpdatePlacementStatusFencedParams struct { + RequestID ids.PinID + NodeID ids.NodeID + Status string + FailureReason *string + Fence int64 +} + +func (q *Queries) UpdatePlacementStatusFenced(ctx context.Context, arg UpdatePlacementStatusFencedParams) (int64, error) { + result, err := q.db.Exec(ctx, updatePlacementStatusFenced, + arg.RequestID, + arg.NodeID, + arg.Status, + arg.FailureReason, + arg.Fence, + ) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/pkg/store/postgres/sqlc/querier.go b/internal/pkg/store/postgres/sqlc/querier.go new file mode 100644 index 0000000..051d476 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/querier.go @@ -0,0 +1,83 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" +) + +type Querier interface { + AddMembership(ctx context.Context, arg AddMembershipParams) error + AddTokenDenylist(ctx context.Context, arg AddTokenDenylistParams) error + CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error) + CreateOrg(ctx context.Context, arg CreateOrgParams) (Org, error) + CreatePin(ctx context.Context, arg CreatePinParams) (Pin, error) + DecRefcount(ctx context.Context, arg DecRefcountParams) (int32, error) + DeletePin(ctx context.Context, arg DeletePinParams) error + DeleteRefcount(ctx context.Context, arg DeleteRefcountParams) error + DrainNode(ctx context.Context, id ids.NodeID) error + // FilterPins honors the full IPFS Pinning API spec filter surface. + // + // All filters are optional; empty arrays / NULL values short-circuit to + // "no restriction" via the IS NULL / array-length guards. Name matching + // supports four modes through a single `match` discriminator: + // + // 'exact' — LIKE (case-sensitive, no wildcards) + // 'iexact' — ILIKE anchored both ends + // 'partial' — LIKE %name% + // 'ipartial' — ILIKE %name% (uses pg_trgm GIN) + // + // `meta` is treated as a jsonb "contains" check — the pin's meta must + // be a superset of the requested map. + // + FilterPins(ctx context.Context, arg FilterPinsParams) ([]Pin, error) + GetAPITokenByJTI(ctx context.Context, jti ids.TokenID) (ApiToken, error) + GetExistingLivePinByCID(ctx context.Context, arg GetExistingLivePinByCIDParams) (Pin, error) + GetNode(ctx context.Context, id ids.NodeID) (Node, error) + GetOrgByID(ctx context.Context, id ids.OrgID) (Org, error) + GetOrgBySlug(ctx context.Context, slug string) (Org, error) + GetPin(ctx context.Context, arg GetPinParams) (Pin, error) + GetPlacement(ctx context.Context, arg GetPlacementParams) (PinPlacement, error) + GetUserByEmail(ctx context.Context, email string) (User, error) + GetUserByID(ctx context.Context, id ids.UserID) (User, error) + IncRefcount(ctx context.Context, arg IncRefcountParams) error + InsertAudit(ctx context.Context, arg InsertAuditParams) error + InsertPlacement(ctx context.Context, arg InsertPlacementParams) (PinPlacement, error) + IsTokenDenied(ctx context.Context, jti string) (bool, error) + ListAPITokensForUser(ctx context.Context, arg ListAPITokensForUserParams) ([]ApiToken, error) + ListAllNodes(ctx context.Context) ([]Node, error) + ListAudit(ctx context.Context, arg ListAuditParams) ([]AuditLog, error) + ListLiveNodes(ctx context.Context) ([]Node, error) + ListMemberships(ctx context.Context, userID ids.UserID) ([]ListMembershipsRow, error) + ListOrgs(ctx context.Context, arg ListOrgsParams) ([]Org, error) + ListPins(ctx context.Context, arg ListPinsParams) ([]Pin, error) + ListPlacementsForNode(ctx context.Context, arg ListPlacementsForNodeParams) ([]PinPlacement, error) + ListPlacementsForPin(ctx context.Context, requestID ids.PinID) ([]ListPlacementsForPinRow, error) + MarkNodeDown(ctx context.Context, id ids.NodeID) error + MarkStaleNodesDown(ctx context.Context, secs float64) ([]ids.NodeID, error) + PromoteSysadmin(ctx context.Context, id ids.UserID) error + PruneTokenDenylist(ctx context.Context) error + RemoveMembership(ctx context.Context, arg RemoveMembershipParams) error + // ReplacePin swaps a pin's CID atomically. Used by POST /v1/pins/{rid} + // (pin replace per spec). Placements + refcount are left to the caller + // to rearrange in the same transaction. + // + ReplacePin(ctx context.Context, arg ReplacePinParams) (Pin, error) + ReplacePlacementFence(ctx context.Context, arg ReplacePlacementFenceParams) (PinPlacement, error) + RevokeAPIToken(ctx context.Context, jti ids.TokenID) error + StuckPlacements(ctx context.Context, secs float64) ([]StuckPlacementsRow, error) + TouchAPITokenLastUsed(ctx context.Context, jti ids.TokenID) error + TouchNodeHeartbeat(ctx context.Context, id ids.NodeID) error + UncordonNode(ctx context.Context, id ids.NodeID) error + UpdateOrgName(ctx context.Context, arg UpdateOrgNameParams) (Org, error) + UpdatePinStatus(ctx context.Context, arg UpdatePinStatusParams) error + UpdatePlacementStatusFenced(ctx context.Context, arg UpdatePlacementStatusFencedParams) (int64, error) + UpsertNode(ctx context.Context, arg UpsertNodeParams) (Node, error) + UpsertUserByAuthentikSub(ctx context.Context, arg UpsertUserByAuthentikSubParams) (User, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/pkg/store/postgres/sqlc/tokens.sql.go b/internal/pkg/store/postgres/sqlc/tokens.sql.go new file mode 100644 index 0000000..23f8727 --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/tokens.sql.go @@ -0,0 +1,174 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: tokens.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" + "github.com/jackc/pgx/v5/pgtype" +) + +const addTokenDenylist = `-- name: AddTokenDenylist :exec +INSERT INTO token_denylist (jti, expires_at, reason) +VALUES ($1, $2, $3) +ON CONFLICT (jti) DO NOTHING +` + +type AddTokenDenylistParams struct { + Jti string + ExpiresAt pgtype.Timestamptz + Reason string +} + +func (q *Queries) AddTokenDenylist(ctx context.Context, arg AddTokenDenylistParams) error { + _, err := q.db.Exec(ctx, addTokenDenylist, arg.Jti, arg.ExpiresAt, arg.Reason) + return err +} + +const createAPIToken = `-- name: CreateAPIToken :one +INSERT INTO api_tokens (jti, org_id, user_id, label, scopes, expires_at) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING jti, org_id, user_id, label, scopes, expires_at, revoked_at, last_used_at, created_at, updated_at +` + +type CreateAPITokenParams struct { + Jti ids.TokenID + OrgID ids.OrgID + UserID ids.UserID + Label string + Scopes []string + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreateAPIToken(ctx context.Context, arg CreateAPITokenParams) (ApiToken, error) { + row := q.db.QueryRow(ctx, createAPIToken, + arg.Jti, + arg.OrgID, + arg.UserID, + arg.Label, + arg.Scopes, + arg.ExpiresAt, + ) + var i ApiToken + err := row.Scan( + &i.Jti, + &i.OrgID, + &i.UserID, + &i.Label, + &i.Scopes, + &i.ExpiresAt, + &i.RevokedAt, + &i.LastUsedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAPITokenByJTI = `-- name: GetAPITokenByJTI :one +SELECT jti, org_id, user_id, label, scopes, expires_at, revoked_at, last_used_at, created_at, updated_at FROM api_tokens WHERE jti = $1 +` + +func (q *Queries) GetAPITokenByJTI(ctx context.Context, jti ids.TokenID) (ApiToken, error) { + row := q.db.QueryRow(ctx, getAPITokenByJTI, jti) + var i ApiToken + err := row.Scan( + &i.Jti, + &i.OrgID, + &i.UserID, + &i.Label, + &i.Scopes, + &i.ExpiresAt, + &i.RevokedAt, + &i.LastUsedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const isTokenDenied = `-- name: IsTokenDenied :one +SELECT EXISTS( + SELECT 1 FROM token_denylist WHERE jti = $1 AND expires_at > now() +) +` + +func (q *Queries) IsTokenDenied(ctx context.Context, jti string) (bool, error) { + row := q.db.QueryRow(ctx, isTokenDenied, jti) + var exists bool + err := row.Scan(&exists) + return exists, err +} + +const listAPITokensForUser = `-- name: ListAPITokensForUser :many +SELECT jti, org_id, user_id, label, scopes, expires_at, revoked_at, last_used_at, created_at, updated_at FROM api_tokens +WHERE org_id = $1 AND user_id = $2 AND revoked_at IS NULL +ORDER BY created_at DESC +` + +type ListAPITokensForUserParams struct { + OrgID ids.OrgID + UserID ids.UserID +} + +func (q *Queries) ListAPITokensForUser(ctx context.Context, arg ListAPITokensForUserParams) ([]ApiToken, error) { + rows, err := q.db.Query(ctx, listAPITokensForUser, arg.OrgID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ApiToken + for rows.Next() { + var i ApiToken + if err := rows.Scan( + &i.Jti, + &i.OrgID, + &i.UserID, + &i.Label, + &i.Scopes, + &i.ExpiresAt, + &i.RevokedAt, + &i.LastUsedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const pruneTokenDenylist = `-- name: PruneTokenDenylist :exec +DELETE FROM token_denylist WHERE expires_at <= now() +` + +func (q *Queries) PruneTokenDenylist(ctx context.Context) error { + _, err := q.db.Exec(ctx, pruneTokenDenylist) + return err +} + +const revokeAPIToken = `-- name: RevokeAPIToken :exec +UPDATE api_tokens SET revoked_at = now() WHERE jti = $1 +` + +func (q *Queries) RevokeAPIToken(ctx context.Context, jti ids.TokenID) error { + _, err := q.db.Exec(ctx, revokeAPIToken, jti) + return err +} + +const touchAPITokenLastUsed = `-- name: TouchAPITokenLastUsed :exec +UPDATE api_tokens SET last_used_at = now() WHERE jti = $1 +` + +func (q *Queries) TouchAPITokenLastUsed(ctx context.Context, jti ids.TokenID) error { + _, err := q.db.Exec(ctx, touchAPITokenLastUsed, jti) + return err +} diff --git a/internal/pkg/store/postgres/sqlc/users.sql.go b/internal/pkg/store/postgres/sqlc/users.sql.go new file mode 100644 index 0000000..075e0cb --- /dev/null +++ b/internal/pkg/store/postgres/sqlc/users.sql.go @@ -0,0 +1,174 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: users.sql + +package sqlc + +import ( + "context" + + "anchorage/internal/pkg/ids" + "github.com/jackc/pgx/v5/pgtype" +) + +const addMembership = `-- name: AddMembership :exec +INSERT INTO memberships (org_id, user_id, role) +VALUES ($1, $2, $3) +ON CONFLICT (org_id, user_id) DO UPDATE SET role = EXCLUDED.role +` + +type AddMembershipParams struct { + OrgID ids.OrgID + UserID ids.UserID + Role string +} + +func (q *Queries) AddMembership(ctx context.Context, arg AddMembershipParams) error { + _, err := q.db.Exec(ctx, addMembership, arg.OrgID, arg.UserID, arg.Role) + return err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, authentik_sub, email, display_name, is_sysadmin, created_at, updated_at FROM users WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.AuthentikSub, + &i.Email, + &i.DisplayName, + &i.IsSysadmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, authentik_sub, email, display_name, is_sysadmin, created_at, updated_at FROM users WHERE id = $1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id ids.UserID) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan( + &i.ID, + &i.AuthentikSub, + &i.Email, + &i.DisplayName, + &i.IsSysadmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listMemberships = `-- name: ListMemberships :many +SELECT m.org_id, m.user_id, m.role, m.created_at, m.updated_at, o.slug, o.name +FROM memberships m JOIN orgs o ON o.id = m.org_id +WHERE m.user_id = $1 +ORDER BY m.org_id +` + +type ListMembershipsRow struct { + OrgID ids.OrgID + UserID ids.UserID + Role string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Slug string + Name string +} + +func (q *Queries) ListMemberships(ctx context.Context, userID ids.UserID) ([]ListMembershipsRow, error) { + rows, err := q.db.Query(ctx, listMemberships, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMembershipsRow + for rows.Next() { + var i ListMembershipsRow + if err := rows.Scan( + &i.OrgID, + &i.UserID, + &i.Role, + &i.CreatedAt, + &i.UpdatedAt, + &i.Slug, + &i.Name, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const promoteSysadmin = `-- name: PromoteSysadmin :exec +UPDATE users SET is_sysadmin = true WHERE id = $1 +` + +func (q *Queries) PromoteSysadmin(ctx context.Context, id ids.UserID) error { + _, err := q.db.Exec(ctx, promoteSysadmin, id) + return err +} + +const removeMembership = `-- name: RemoveMembership :exec +DELETE FROM memberships WHERE org_id = $1 AND user_id = $2 +` + +type RemoveMembershipParams struct { + OrgID ids.OrgID + UserID ids.UserID +} + +func (q *Queries) RemoveMembership(ctx context.Context, arg RemoveMembershipParams) error { + _, err := q.db.Exec(ctx, removeMembership, arg.OrgID, arg.UserID) + return err +} + +const upsertUserByAuthentikSub = `-- name: UpsertUserByAuthentikSub :one +INSERT INTO users (id, authentik_sub, email, display_name, is_sysadmin) +VALUES ($1, $2, $3, $4, $5) +ON CONFLICT (authentik_sub) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name +RETURNING id, authentik_sub, email, display_name, is_sysadmin, created_at, updated_at +` + +type UpsertUserByAuthentikSubParams struct { + ID ids.UserID + AuthentikSub *string + Email string + DisplayName string + IsSysadmin bool +} + +func (q *Queries) UpsertUserByAuthentikSub(ctx context.Context, arg UpsertUserByAuthentikSubParams) (User, error) { + row := q.db.QueryRow(ctx, upsertUserByAuthentikSub, + arg.ID, + arg.AuthentikSub, + arg.Email, + arg.DisplayName, + arg.IsSysadmin, + ) + var i User + err := row.Scan( + &i.ID, + &i.AuthentikSub, + &i.Email, + &i.DisplayName, + &i.IsSysadmin, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/pkg/store/postgres/store.go b/internal/pkg/store/postgres/store.go new file mode 100644 index 0000000..e9433c3 --- /dev/null +++ b/internal/pkg/store/postgres/store.go @@ -0,0 +1,800 @@ +// Package postgres is anchorage's pgx-backed implementation of +// store.Store. Sub-store methods are thin wrappers over sqlc-generated +// query objects in internal/pkg/store/postgres/sqlc; the file you're +// reading is the bridge between anchorage's domain types and the +// sqlc Pgx types (pgtype.Timestamptz → time.Time, etc). +// +// Transactions: Tx opens a pgx tx, sets the `anchorage.org_id` GUC when +// a tenant context is active, and hands the closure a Store wrapping +// the transactional Queries. Commit / rollback follows the standard +// Go "defer rollback-unless-committed" idiom. +package postgres + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/store/postgres/sqlc" +) + +// ctxKey namespaces the context values this package stashes (currently +// just the RLS org id). +type ctxKey struct{ name string } + +var orgCtxKey = ctxKey{"anchorage-org-id"} + +// Store implements store.Store on top of a pgxpool.Pool. A separate +// *Store is constructed per transaction (see Tx); the top-level one +// holds the pool directly. +type Store struct { + pool *pgxpool.Pool + // If non-nil, all sub-stores use this Queries (scoped to a + // transaction). The top-level Store has txQ=nil and lazily wraps + // pool. + txQ *sqlc.Queries + txOrgID *ids.OrgID // set inside Tx so GUC stays consistent +} + +// New builds a pool-backed Store. +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// queries returns a sqlc.Queries over the correct dbtx handle — either +// the transactional one from Tx, or a pool-backed one. +func (s *Store) queries() *sqlc.Queries { + if s.txQ != nil { + return s.txQ + } + return sqlc.New(s.pool) +} + +// ---- Root store.Store interface -------------------------------------------- + +func (s *Store) Orgs() store.OrgStore { return orgStore{s} } +func (s *Store) Users() store.UserStore { return userStore{s} } +func (s *Store) Memberships() store.MembershipStore { return membershipStore{s} } +func (s *Store) Tokens() store.TokenStore { return tokenStore{s} } +func (s *Store) Nodes() store.NodeStore { return nodeStore{s} } +func (s *Store) Pins() store.PinStore { return pinStore{s} } +func (s *Store) Audit() store.AuditStore { return auditStore{s} } + +// WithOrgContext returns a context tagged with orgID. The Tx handler +// reads this to SET LOCAL anchorage.org_id inside the transaction. +func (s *Store) WithOrgContext(ctx context.Context, orgID ids.OrgID) (context.Context, error) { + return context.WithValue(ctx, orgCtxKey, orgID), nil +} + +// Tx opens a pgx transaction, sets the anchorage.org_id GUC if an org +// has been tagged on ctx via WithOrgContext, and passes fn a Store +// bound to that transaction. +func (s *Store) Tx(ctx context.Context, fn func(txCtx context.Context, tx store.Store) error) error { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + q := sqlc.New(tx).WithTx(tx) + txStore := &Store{pool: s.pool, txQ: q} + + // If the caller wrapped ctx with WithOrgContext, apply the GUC so + // RLS policies on the tenant tables kick in. + if v, ok := ctx.Value(orgCtxKey).(ids.OrgID); ok && !v.IsZero() { + if _, err := tx.Exec(ctx, "SET LOCAL anchorage.org_id = $1", v.String()); err != nil { + return fmt.Errorf("set org guc: %w", err) + } + txStore.txOrgID = &v + } + + if err := fn(ctx, txStore); err != nil { + return err + } + return tx.Commit(ctx) +} + +// ---- Converters ------------------------------------------------------------ + +func tsToTime(t pgtype.Timestamptz) time.Time { + if !t.Valid { + return time.Time{} + } + return t.Time +} + +func timePtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + v := t.Time + return &v +} + +func tsFrom(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t.UTC(), Valid: !t.IsZero()} +} + +func tsFromPtr(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{} + } + return tsFrom(*t) +} + +func mapErr(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, pgx.ErrNoRows): + return store.ErrNotFound + } + return err +} + +// ---- Orgs ----------------------------------------------------------------- + +type orgStore struct{ s *Store } + +func (o orgStore) Create(ctx context.Context, id ids.OrgID, slug, name string) (*store.Org, error) { + row, err := o.s.queries().CreateOrg(ctx, sqlc.CreateOrgParams{ID: id, Slug: slug, Name: name}) + if err != nil { + // pgx reports unique-constraint violations with SQLSTATE 23505; + // surface as ErrConflict so callers can 409 cleanly. + if isUniqueViolation(err) { + return nil, store.ErrConflict + } + return nil, err + } + return orgFromRow(row), nil +} + +func (o orgStore) GetByID(ctx context.Context, id ids.OrgID) (*store.Org, error) { + row, err := o.s.queries().GetOrgByID(ctx, id) + if err != nil { + return nil, mapErr(err) + } + return orgFromRow(row), nil +} + +func (o orgStore) GetBySlug(ctx context.Context, slug string) (*store.Org, error) { + row, err := o.s.queries().GetOrgBySlug(ctx, slug) + if err != nil { + return nil, mapErr(err) + } + return orgFromRow(row), nil +} + +func (o orgStore) UpdateName(ctx context.Context, id ids.OrgID, name string) (*store.Org, error) { + row, err := o.s.queries().UpdateOrgName(ctx, sqlc.UpdateOrgNameParams{ID: id, Name: name}) + if err != nil { + return nil, mapErr(err) + } + return orgFromRow(row), nil +} + +func (o orgStore) List(ctx context.Context, limit, offset int) ([]*store.Org, error) { + rows, err := o.s.queries().ListOrgs(ctx, sqlc.ListOrgsParams{Limit: int32(limit), Offset: int32(offset)}) + if err != nil { + return nil, err + } + out := make([]*store.Org, 0, len(rows)) + for _, r := range rows { + out = append(out, orgFromRow(r)) + } + return out, nil +} + +func orgFromRow(r sqlc.Org) *store.Org { + return &store.Org{ + ID: r.ID, Slug: r.Slug, Name: r.Name, + CreatedAt: tsToTime(r.CreatedAt), + UpdatedAt: tsToTime(r.UpdatedAt), + } +} + +// ---- Users ---------------------------------------------------------------- + +type userStore struct{ s *Store } + +func (u userStore) UpsertByAuthentikSub(ctx context.Context, id ids.UserID, sub, email, displayName string, isSysadmin bool) (*store.User, error) { + row, err := u.s.queries().UpsertUserByAuthentikSub(ctx, sqlc.UpsertUserByAuthentikSubParams{ + ID: id, + AuthentikSub: &sub, + Email: email, + DisplayName: displayName, + IsSysadmin: isSysadmin, + }) + if err != nil { + return nil, err + } + return userFromRow(row), nil +} + +func (u userStore) GetByID(ctx context.Context, id ids.UserID) (*store.User, error) { + row, err := u.s.queries().GetUserByID(ctx, id) + if err != nil { + return nil, mapErr(err) + } + return userFromRow(row), nil +} + +func (u userStore) GetByEmail(ctx context.Context, email string) (*store.User, error) { + row, err := u.s.queries().GetUserByEmail(ctx, email) + if err != nil { + return nil, mapErr(err) + } + return userFromRow(row), nil +} + +func (u userStore) PromoteSysadmin(ctx context.Context, id ids.UserID) error { + return mapErr(u.s.queries().PromoteSysadmin(ctx, id)) +} + +func userFromRow(r sqlc.User) *store.User { + out := &store.User{ + ID: r.ID, Email: r.Email, DisplayName: r.DisplayName, IsSysadmin: r.IsSysadmin, + CreatedAt: tsToTime(r.CreatedAt), UpdatedAt: tsToTime(r.UpdatedAt), + } + if r.AuthentikSub != nil { + out.AuthentikSub = *r.AuthentikSub + } + return out +} + +// ---- Memberships --------------------------------------------------------- + +type membershipStore struct{ s *Store } + +func (m membershipStore) Add(ctx context.Context, orgID ids.OrgID, userID ids.UserID, role string) error { + return m.s.queries().AddMembership(ctx, sqlc.AddMembershipParams{OrgID: orgID, UserID: userID, Role: role}) +} + +func (m membershipStore) Remove(ctx context.Context, orgID ids.OrgID, userID ids.UserID) error { + return m.s.queries().RemoveMembership(ctx, sqlc.RemoveMembershipParams{OrgID: orgID, UserID: userID}) +} + +func (m membershipStore) ListForUser(ctx context.Context, userID ids.UserID) ([]*store.Membership, error) { + rows, err := m.s.queries().ListMemberships(ctx, userID) + if err != nil { + return nil, err + } + out := make([]*store.Membership, 0, len(rows)) + for _, r := range rows { + out = append(out, &store.Membership{ + OrgID: r.OrgID, UserID: r.UserID, Role: r.Role, + OrgSlug: r.Slug, OrgName: r.Name, + Created: tsToTime(r.CreatedAt), + }) + } + return out, nil +} + +// ---- Tokens -------------------------------------------------------------- + +type tokenStore struct{ s *Store } + +func (t tokenStore) Create(ctx context.Context, tk *store.APIToken) error { + _, err := t.s.queries().CreateAPIToken(ctx, sqlc.CreateAPITokenParams{ + Jti: tk.JTI, + OrgID: tk.OrgID, + UserID: tk.UserID, + Label: tk.Label, + Scopes: tk.Scopes, + ExpiresAt: tsFrom(tk.ExpiresAt), + }) + return err +} + +func (t tokenStore) GetByJTI(ctx context.Context, jti ids.TokenID) (*store.APIToken, error) { + row, err := t.s.queries().GetAPITokenByJTI(ctx, jti) + if err != nil { + return nil, mapErr(err) + } + return apiTokenFromRow(row), nil +} + +func (t tokenStore) ListForUser(ctx context.Context, orgID ids.OrgID, userID ids.UserID) ([]*store.APIToken, error) { + rows, err := t.s.queries().ListAPITokensForUser(ctx, sqlc.ListAPITokensForUserParams{OrgID: orgID, UserID: userID}) + if err != nil { + return nil, err + } + out := make([]*store.APIToken, 0, len(rows)) + for _, r := range rows { + out = append(out, apiTokenFromRow(r)) + } + return out, nil +} + +func (t tokenStore) Revoke(ctx context.Context, jti ids.TokenID) error { + return mapErr(t.s.queries().RevokeAPIToken(ctx, jti)) +} + +func (t tokenStore) TouchLastUsed(ctx context.Context, jti ids.TokenID) error { + return mapErr(t.s.queries().TouchAPITokenLastUsed(ctx, jti)) +} + +func (t tokenStore) AddDenylist(ctx context.Context, jti ids.TokenID, expiresAt time.Time, reason string) error { + return t.s.queries().AddTokenDenylist(ctx, sqlc.AddTokenDenylistParams{ + Jti: jti.String(), ExpiresAt: tsFrom(expiresAt), Reason: reason, + }) +} + +func (t tokenStore) IsDenied(ctx context.Context, jti ids.TokenID) (bool, error) { + return t.s.queries().IsTokenDenied(ctx, jti.String()) +} + +func (t tokenStore) PruneDenylist(ctx context.Context) error { + return t.s.queries().PruneTokenDenylist(ctx) +} + +func apiTokenFromRow(r sqlc.ApiToken) *store.APIToken { + return &store.APIToken{ + JTI: r.Jti, OrgID: r.OrgID, UserID: r.UserID, + Label: r.Label, Scopes: r.Scopes, + ExpiresAt: tsToTime(r.ExpiresAt), + RevokedAt: timePtr(r.RevokedAt), LastUsedAt: timePtr(r.LastUsedAt), + CreatedAt: tsToTime(r.CreatedAt), UpdatedAt: tsToTime(r.UpdatedAt), + } +} + +// ---- Nodes --------------------------------------------------------------- + +type nodeStore struct{ s *Store } + +func (n nodeStore) Upsert(ctx context.Context, nd *store.Node) error { + _, err := n.s.queries().UpsertNode(ctx, sqlc.UpsertNodeParams{ + ID: nd.ID, DisplayName: nd.DisplayName, Multiaddrs: nd.Multiaddrs, RpcUrl: nd.RPCURL, + }) + return err +} + +func (n nodeStore) Get(ctx context.Context, id ids.NodeID) (*store.Node, error) { + row, err := n.s.queries().GetNode(ctx, id) + if err != nil { + return nil, mapErr(err) + } + return nodeFromRow(row), nil +} + +func (n nodeStore) ListAll(ctx context.Context) ([]*store.Node, error) { + rows, err := n.s.queries().ListAllNodes(ctx) + if err != nil { + return nil, err + } + out := make([]*store.Node, 0, len(rows)) + for _, r := range rows { + out = append(out, nodeFromRow(r)) + } + return out, nil +} + +func (n nodeStore) ListLive(ctx context.Context) ([]*store.Node, error) { + rows, err := n.s.queries().ListLiveNodes(ctx) + if err != nil { + return nil, err + } + out := make([]*store.Node, 0, len(rows)) + for _, r := range rows { + out = append(out, nodeFromRow(r)) + } + return out, nil +} + +func (n nodeStore) TouchHeartbeat(ctx context.Context, id ids.NodeID) error { + return n.s.queries().TouchNodeHeartbeat(ctx, id) +} + +func (n nodeStore) MarkStaleDown(ctx context.Context, staleAfter time.Duration) ([]ids.NodeID, error) { + return n.s.queries().MarkStaleNodesDown(ctx, staleAfter.Seconds()) +} + +func (n nodeStore) Drain(ctx context.Context, id ids.NodeID) error { + return n.s.queries().DrainNode(ctx, id) +} + +func (n nodeStore) Uncordon(ctx context.Context, id ids.NodeID) error { + return n.s.queries().UncordonNode(ctx, id) +} + +func nodeFromRow(r sqlc.Node) *store.Node { + return &store.Node{ + ID: r.ID, DisplayName: r.DisplayName, Multiaddrs: r.Multiaddrs, RPCURL: r.RpcUrl, + Status: r.Status, + LastSeenAt: tsToTime(r.LastSeenAt), JoinedAt: tsToTime(r.JoinedAt), UpdatedAt: tsToTime(r.UpdatedAt), + } +} + +// ---- Pins ---------------------------------------------------------------- + +type pinStore struct{ s *Store } + +func (p pinStore) Create(ctx context.Context, pn *store.Pin) error { + meta, err := json.Marshal(pn.Meta) + if err != nil { + return fmt.Errorf("marshal meta: %w", err) + } + _, err = p.s.queries().CreatePin(ctx, sqlc.CreatePinParams{ + RequestID: pn.RequestID, OrgID: pn.OrgID, Cid: pn.CID, + Name: pn.Name, Meta: meta, Origins: pn.Origins, + }) + if isUniqueViolation(err) { + return store.ErrConflict + } + return err +} + +func (p pinStore) Get(ctx context.Context, orgID ids.OrgID, rid ids.PinID) (*store.Pin, error) { + row, err := p.s.queries().GetPin(ctx, sqlc.GetPinParams{RequestID: rid, OrgID: orgID}) + if err != nil { + return nil, mapErr(err) + } + return pinFromRow(row) +} + +func (p pinStore) GetByRequestID(ctx context.Context, rid ids.PinID) (*store.Pin, error) { + // The sqlc GetPin requires org_id; fall back to filtered list of one. + // We work around by using GetPin with a scratched filter — but sqlc + // doesn't expose that. Use a raw query via the pool. + var orgID ids.OrgID + var cid string + var name *string + var meta []byte + var origins []string + var status string + var failureReason *string + var created, updated pgtype.Timestamptz + err := p.s.pool.QueryRow(ctx, + "SELECT org_id, cid, name, meta, origins, status, failure_reason, created, updated_at FROM pins WHERE request_id = $1", + rid, + ).Scan(&orgID, &cid, &name, &meta, &origins, &status, &failureReason, &created, &updated) + if err != nil { + return nil, mapErr(err) + } + return pinFromFields(rid, orgID, cid, name, meta, origins, status, failureReason, created, updated) +} + +func (p pinStore) GetLiveByCID(ctx context.Context, orgID ids.OrgID, cid string) (*store.Pin, error) { + row, err := p.s.queries().GetExistingLivePinByCID(ctx, sqlc.GetExistingLivePinByCIDParams{OrgID: orgID, Cid: cid}) + if err != nil { + return nil, mapErr(err) + } + return pinFromRow(row) +} + +func (p pinStore) UpdateStatus(ctx context.Context, rid ids.PinID, status string, failureReason *string) error { + return p.s.queries().UpdatePinStatus(ctx, sqlc.UpdatePinStatusParams{ + RequestID: rid, Status: status, FailureReason: failureReason, + }) +} + +func (p pinStore) Delete(ctx context.Context, orgID ids.OrgID, rid ids.PinID) error { + return mapErr(p.s.queries().DeletePin(ctx, sqlc.DeletePinParams{RequestID: rid, OrgID: orgID})) +} + +func (p pinStore) List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*store.Pin, error) { + rows, err := p.s.queries().ListPins(ctx, sqlc.ListPinsParams{ + OrgID: orgID, Limit: int32(limit), Offset: int32(offset), + }) + if err != nil { + return nil, err + } + out := make([]*store.Pin, 0, len(rows)) + for _, r := range rows { + p, err := pinFromRow(r) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +func (p pinStore) Filter(ctx context.Context, orgID ids.OrgID, f store.PinFilter) ([]*store.Pin, error) { + meta := []byte("{}") + if len(f.Meta) > 0 { + b, err := json.Marshal(f.Meta) + if err != nil { + return nil, fmt.Errorf("marshal meta filter: %w", err) + } + meta = b + } + var namePtr *string + if f.Name != "" { + namePtr = &f.Name + } + matchMode := f.Match + if matchMode == "" { + matchMode = "exact" + } + limit := int32(f.Limit) + if limit <= 0 { + limit = 10 + } + rows, err := p.s.queries().FilterPins(ctx, sqlc.FilterPinsParams{ + OrgID: orgID, + Cids: f.CIDs, + Statuses: f.Status, + Name: namePtr, + MatchMode: matchMode, + Before: tsFromPtr(f.Before), + After: tsFromPtr(f.After), + MetaFilter: meta, + PinLimit: limit, + PinOffset: int32(f.Offset), + }) + if err != nil { + return nil, err + } + out := make([]*store.Pin, 0, len(rows)) + for _, r := range rows { + p, err := pinFromRow(r) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, nil +} + +func (p pinStore) Replace(ctx context.Context, orgID ids.OrgID, rid ids.PinID, newCID string, name *string, meta map[string]any, origins []string) (*store.Pin, error) { + metaBytes, err := json.Marshal(meta) + if err != nil { + return nil, err + } + row, err := p.s.queries().ReplacePin(ctx, sqlc.ReplacePinParams{ + RequestID: rid, OrgID: orgID, Cid: newCID, Name: name, Meta: metaBytes, Origins: origins, + }) + if err != nil { + return nil, mapErr(err) + } + return pinFromRow(row) +} + +func (p pinStore) InsertPlacement(ctx context.Context, rid ids.PinID, nid ids.NodeID, fence int64) (*store.Placement, error) { + row, err := p.s.queries().InsertPlacement(ctx, sqlc.InsertPlacementParams{RequestID: rid, NodeID: nid, Fence: fence}) + if err != nil { + return nil, err + } + return placementFromRow(row), nil +} + +func (p pinStore) GetPlacement(ctx context.Context, rid ids.PinID, nid ids.NodeID) (*store.Placement, error) { + row, err := p.s.queries().GetPlacement(ctx, sqlc.GetPlacementParams{RequestID: rid, NodeID: nid}) + if err != nil { + return nil, mapErr(err) + } + return placementFromRow(row), nil +} + +func (p pinStore) ListPlacements(ctx context.Context, rid ids.PinID) ([]*store.Placement, error) { + rows, err := p.s.queries().ListPlacementsForPin(ctx, rid) + if err != nil { + return nil, err + } + out := make([]*store.Placement, 0, len(rows)) + for _, r := range rows { + out = append(out, &store.Placement{ + RequestID: r.RequestID, NodeID: r.NodeID, + Status: r.Status, FailureReason: r.FailureReason, + Attempts: int(r.Attempts), Fence: r.Fence, + CreatedAt: tsToTime(r.CreatedAt), UpdatedAt: tsToTime(r.UpdatedAt), + Multiaddrs: r.Multiaddrs, + }) + } + return out, nil +} + +func (p pinStore) ListPlacementsForNode(ctx context.Context, nid ids.NodeID, status string) ([]*store.Placement, error) { + rows, err := p.s.queries().ListPlacementsForNode(ctx, sqlc.ListPlacementsForNodeParams{NodeID: nid, Status: status}) + if err != nil { + return nil, err + } + out := make([]*store.Placement, 0, len(rows)) + for _, r := range rows { + out = append(out, placementFromRow(r)) + } + return out, nil +} + +func (p pinStore) UpdatePlacementFenced(ctx context.Context, rid ids.PinID, nid ids.NodeID, status string, failureReason *string, fence int64) (int64, error) { + return p.s.queries().UpdatePlacementStatusFenced(ctx, sqlc.UpdatePlacementStatusFencedParams{ + RequestID: rid, NodeID: nid, Status: status, FailureReason: failureReason, Fence: fence, + }) +} + +func (p pinStore) ReplacePlacement(ctx context.Context, rid ids.PinID, oldNode, newNode ids.NodeID) (*store.Placement, error) { + row, err := p.s.queries().ReplacePlacementFence(ctx, sqlc.ReplacePlacementFenceParams{ + RequestID: rid, NodeID: oldNode, NodeID_2: newNode, + }) + if err != nil { + return nil, mapErr(err) + } + return placementFromRow(row), nil +} + +func (p pinStore) StuckPlacements(ctx context.Context, stuckAfter time.Duration) ([]store.Placement, error) { + rows, err := p.s.queries().StuckPlacements(ctx, stuckAfter.Seconds()) + if err != nil { + return nil, err + } + out := make([]store.Placement, 0, len(rows)) + for _, r := range rows { + out = append(out, store.Placement{ + RequestID: r.RequestID, NodeID: r.NodeID, Fence: r.Fence, + }) + } + return out, nil +} + +func (p pinStore) IncRefcount(ctx context.Context, nid ids.NodeID, cid string) error { + return p.s.queries().IncRefcount(ctx, sqlc.IncRefcountParams{NodeID: nid, Cid: cid}) +} + +func (p pinStore) DecRefcount(ctx context.Context, nid ids.NodeID, cid string) (int, error) { + v, err := p.s.queries().DecRefcount(ctx, sqlc.DecRefcountParams{NodeID: nid, Cid: cid}) + if err != nil { + return 0, mapErr(err) + } + return int(v), nil +} + +func (p pinStore) DeleteRefcountIfZero(ctx context.Context, nid ids.NodeID, cid string) error { + return p.s.queries().DeleteRefcount(ctx, sqlc.DeleteRefcountParams{NodeID: nid, Cid: cid}) +} + +// CountPlacementsByStatus runs a GROUP BY query directly against the +// pool. Not in sqlc because we never needed a typed result for it +// elsewhere — only the gauge-refresh goroutine consumes it. +func (p pinStore) CountPlacementsByStatus(ctx context.Context) (map[string]int, error) { + rows, err := p.s.pool.Query(ctx, + `SELECT status, COUNT(*) FROM pin_placements GROUP BY status`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var status string + var n int + if err := rows.Scan(&status, &n); err != nil { + return nil, err + } + out[status] = n + } + return out, rows.Err() +} + +func pinFromRow(r sqlc.Pin) (*store.Pin, error) { + return pinFromFields(r.RequestID, r.OrgID, r.Cid, r.Name, r.Meta, r.Origins, r.Status, r.FailureReason, r.Created, r.UpdatedAt) +} + +func pinFromFields(rid ids.PinID, orgID ids.OrgID, cid string, name *string, meta []byte, origins []string, status string, fr *string, created, updated pgtype.Timestamptz) (*store.Pin, error) { + var metaMap map[string]any + if len(meta) > 0 { + if err := json.Unmarshal(meta, &metaMap); err != nil { + return nil, fmt.Errorf("decode meta: %w", err) + } + } + return &store.Pin{ + RequestID: rid, OrgID: orgID, CID: cid, Name: name, + Meta: metaMap, Origins: origins, Status: status, FailureReason: fr, + Created: tsToTime(created), UpdatedAt: tsToTime(updated), + }, nil +} + +func placementFromRow(r sqlc.PinPlacement) *store.Placement { + return &store.Placement{ + RequestID: r.RequestID, NodeID: r.NodeID, + Status: r.Status, FailureReason: r.FailureReason, + Attempts: int(r.Attempts), Fence: r.Fence, + CreatedAt: tsToTime(r.CreatedAt), UpdatedAt: tsToTime(r.UpdatedAt), + } +} + +// ---- Audit --------------------------------------------------------------- + +type auditStore struct{ s *Store } + +func (a auditStore) Insert(ctx context.Context, e *store.AuditEntry) error { + detail, err := json.Marshal(e.Detail) + if err != nil { + return err + } + var orgID ids.OrgID + if e.OrgID != nil { + orgID = *e.OrgID + } + var userID ids.UserID + if e.ActorUserID != nil { + userID = *e.ActorUserID + } + return a.s.queries().InsertAudit(ctx, sqlc.InsertAuditParams{ + OrgID: orgID, ActorUserID: userID, ActorTokenJti: e.ActorTokenJTI, + Action: e.Action, Target: e.Target, Result: e.Result, Detail: detail, + }) +} + +func (a auditStore) List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*store.AuditEntry, error) { + // Zero OrgID → sysadmin cluster-wide listing; sqlc's ListAudit is + // scoped per-org so we drop to a raw query for that path. + if orgID.IsZero() { + rows, err := a.s.pool.Query(ctx, + "SELECT id, org_id, actor_user_id, actor_token_jti, action, target, result, detail, created FROM audit_log ORDER BY created DESC LIMIT $1 OFFSET $2", + limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + out := []*store.AuditEntry{} + for rows.Next() { + var ( + id int64 + org ids.OrgID + actorUser ids.UserID + actorTokenJti *string + action, target, result string + detail []byte + created pgtype.Timestamptz + ) + if err := rows.Scan(&id, &org, &actorUser, &actorTokenJti, &action, &target, &result, &detail, &created); err != nil { + return nil, err + } + out = append(out, auditRowToEntry(id, org, actorUser, actorTokenJti, action, target, result, detail, created)) + } + return out, rows.Err() + } + + rows, err := a.s.queries().ListAudit(ctx, sqlc.ListAuditParams{OrgID: orgID, Limit: int32(limit), Offset: int32(offset)}) + if err != nil { + return nil, err + } + out := make([]*store.AuditEntry, 0, len(rows)) + for _, r := range rows { + out = append(out, auditRowToEntry(r.ID, r.OrgID, r.ActorUserID, r.ActorTokenJti, r.Action, r.Target, r.Result, r.Detail, r.Created)) + } + return out, nil +} + +func auditRowToEntry(id int64, orgID ids.OrgID, actorUser ids.UserID, actorTokenJti *string, action, target, result string, detail []byte, created pgtype.Timestamptz) *store.AuditEntry { + var detailMap map[string]any + if len(detail) > 0 { + _ = json.Unmarshal(detail, &detailMap) + } + out := &store.AuditEntry{ + ID: id, Action: action, Target: target, Result: result, + Detail: detailMap, ActorTokenJTI: actorTokenJti, + Created: tsToTime(created), + } + if !orgID.IsZero() { + v := orgID + out.OrgID = &v + } + if !actorUser.IsZero() { + v := actorUser + out.ActorUserID = &v + } + return out +} + +// ---- Helpers --------------------------------------------------------------- + +// isUniqueViolation recognises Postgres SQLSTATE 23505. +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + var pgErr interface{ SQLState() string } + if !errors.As(err, &pgErr) { + return false + } + return pgErr.SQLState() == "23505" +} diff --git a/internal/pkg/store/store.go b/internal/pkg/store/store.go new file mode 100644 index 0000000..ac1327b --- /dev/null +++ b/internal/pkg/store/store.go @@ -0,0 +1,294 @@ +// Package store defines the interfaces anchorage uses to reach its +// source-of-truth Postgres database. Every domain package (org, pin, +// token, ...) depends on the interface here, never on the concrete +// pgx-backed implementation in store/postgres. This keeps the domain +// layer testable with in-memory fakes. +package store + +import ( + "context" + "errors" + "time" + + "anchorage/internal/pkg/ids" +) + +// ErrNotFound is the canonical "row not present" sentinel. Concrete +// implementations translate driver-specific errors (pgx.ErrNoRows, etc.) +// into this. +var ErrNotFound = errors.New("store: not found") + +// ErrConflict is returned when a write violates a uniqueness invariant +// the caller should handle (duplicate slug, duplicate email, existing +// live pin with the same org+cid). +var ErrConflict = errors.New("store: conflict") + +// Store is the root aggregate interface. A single implementation pins +// all sub-stores to the same Postgres pool and transaction context so +// composite operations can be atomic. +type Store interface { + Orgs() OrgStore + Users() UserStore + Memberships() MembershipStore + Tokens() TokenStore + Nodes() NodeStore + Pins() PinStore + Audit() AuditStore + + // WithOrgContext returns a context whose queries run with the + // anchorage.org_id GUC set, so Postgres RLS applies. The returned + // context MUST be passed to every subsequent Store call inside the + // same request so the GUC is honored. Concrete implementations use + // it to set the GUC per pool connection / transaction. + WithOrgContext(ctx context.Context, orgID ids.OrgID) (context.Context, error) + + // Tx runs fn inside a Postgres transaction. The closure receives a + // tx-scoped context (so per-statement cancellation stays tied to the + // tx's lifetime and any GUC set on the tx is honored) and a Store + // whose sub-stores share that single tx handle. + Tx(ctx context.Context, fn func(txCtx context.Context, tx Store) error) error +} + +// Org is the domain-level organisation record. +type Org struct { + ID ids.OrgID + Slug string + Name string + CreatedAt time.Time + UpdatedAt time.Time +} + +// OrgStore holds organisations. +type OrgStore interface { + Create(ctx context.Context, id ids.OrgID, slug, name string) (*Org, error) + GetByID(ctx context.Context, id ids.OrgID) (*Org, error) + GetBySlug(ctx context.Context, slug string) (*Org, error) + UpdateName(ctx context.Context, id ids.OrgID, name string) (*Org, error) + List(ctx context.Context, limit, offset int) ([]*Org, error) +} + +// User is the domain-level user record. +type User struct { + ID ids.UserID + AuthentikSub string + Email string + DisplayName string + IsSysadmin bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// UserStore holds users. +type UserStore interface { + UpsertByAuthentikSub(ctx context.Context, id ids.UserID, sub, email, displayName string, isSysadmin bool) (*User, error) + GetByID(ctx context.Context, id ids.UserID) (*User, error) + GetByEmail(ctx context.Context, email string) (*User, error) + PromoteSysadmin(ctx context.Context, id ids.UserID) error +} + +// Membership pairs a user with an org + role. +type Membership struct { + OrgID ids.OrgID + UserID ids.UserID + Role string // "orgadmin" | "member" + OrgSlug string // populated by List*, empty on raw gets + OrgName string + Created time.Time +} + +// Role constants. +const ( + RoleOrgAdmin = "orgadmin" + RoleMember = "member" +) + +// MembershipStore manages the user↔org join. +type MembershipStore interface { + Add(ctx context.Context, orgID ids.OrgID, userID ids.UserID, role string) error + Remove(ctx context.Context, orgID ids.OrgID, userID ids.UserID) error + ListForUser(ctx context.Context, userID ids.UserID) ([]*Membership, error) +} + +// APIToken is the metadata record for an issued JWT. The signed JWT +// value itself is never stored — revocation is handled via the denylist. +type APIToken struct { + JTI ids.TokenID + OrgID ids.OrgID + UserID ids.UserID + Label string + Scopes []string + ExpiresAt time.Time + RevokedAt *time.Time + LastUsedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// TokenStore manages API token metadata and the denylist. +type TokenStore interface { + Create(ctx context.Context, t *APIToken) error + GetByJTI(ctx context.Context, jti ids.TokenID) (*APIToken, error) + ListForUser(ctx context.Context, orgID ids.OrgID, userID ids.UserID) ([]*APIToken, error) + Revoke(ctx context.Context, jti ids.TokenID) error + TouchLastUsed(ctx context.Context, jti ids.TokenID) error + + AddDenylist(ctx context.Context, jti ids.TokenID, expiresAt time.Time, reason string) error + IsDenied(ctx context.Context, jti ids.TokenID) (bool, error) + PruneDenylist(ctx context.Context) error +} + +// NodeStatus values. +const ( + NodeStatusUp = "up" + NodeStatusDown = "down" + NodeStatusDrained = "drained" +) + +// Node is a cluster member's registry entry. +type Node struct { + ID ids.NodeID + DisplayName string + Multiaddrs []string + RPCURL string + Status string + LastSeenAt time.Time + JoinedAt time.Time + UpdatedAt time.Time +} + +// NodeStore manages the cluster registry. +type NodeStore interface { + Upsert(ctx context.Context, n *Node) error + Get(ctx context.Context, id ids.NodeID) (*Node, error) + ListAll(ctx context.Context) ([]*Node, error) + ListLive(ctx context.Context) ([]*Node, error) + TouchHeartbeat(ctx context.Context, id ids.NodeID) error + MarkStaleDown(ctx context.Context, staleAfter time.Duration) ([]ids.NodeID, error) + Drain(ctx context.Context, id ids.NodeID) error + Uncordon(ctx context.Context, id ids.NodeID) error +} + +// PinStatus values (matching the IPFS Pinning API spec). +const ( + PinStatusQueued = "queued" + PinStatusPinning = "pinning" + PinStatusPinned = "pinned" + PinStatusFailed = "failed" +) + +// Pin is the logical pin record (what the API exposes as PinStatus). +type Pin struct { + RequestID ids.PinID + OrgID ids.OrgID + CID string + Name *string + Meta map[string]any + Origins []string + Status string + FailureReason *string + Created time.Time + UpdatedAt time.Time +} + +// Placement is the per-node scheduling row for a pin. +type Placement struct { + RequestID ids.PinID + NodeID ids.NodeID + Status string + FailureReason *string + Attempts int + Fence int64 + CreatedAt time.Time + UpdatedAt time.Time + // Multiaddrs is populated by joined reads; empty on raw fetches. + Multiaddrs []string +} + +// PinFilter is the spec-compliant filter surface for GET /v1/pins. +// +// Every field is optional; empty values disable that clause. `Match` is +// interpreted only when `Name` is set: +// +// "exact" — case-sensitive equality +// "iexact" — case-insensitive equality (default when name is set) +// "partial" — case-sensitive substring +// "ipartial" — case-insensitive substring +// +// Before and After are both inclusive of their endpoint semantics: +// `after <= created < before`. +type PinFilter struct { + CIDs []string + Name string + Match string + Status []string + Before *time.Time + After *time.Time + Meta map[string]any + Limit int + Offset int +} + +// PinStore manages pins and their placements + refcounts. +type PinStore interface { + Create(ctx context.Context, p *Pin) error + Get(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) (*Pin, error) + // GetByRequestID fetches a pin without an org filter. + // + // This is used by the scheduler / rebalancer / sweeper which walk + // placements cluster-wide and need to look up the parent pin without + // already knowing its org. Do not use from the HTTP API path — + // routes there are tenant-scoped and must go through Get(orgID, rid). + GetByRequestID(ctx context.Context, requestID ids.PinID) (*Pin, error) + GetLiveByCID(ctx context.Context, orgID ids.OrgID, cid string) (*Pin, error) + UpdateStatus(ctx context.Context, requestID ids.PinID, status string, failureReason *string) error + Delete(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) error + List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*Pin, error) + // Filter runs the spec's filtered list. An empty PinFilter is + // equivalent to List with default pagination. + Filter(ctx context.Context, orgID ids.OrgID, f PinFilter) ([]*Pin, error) + // Replace swaps a pin's CID / name / meta / origins atomically. + // Caller is responsible for reshuffling placements + refcounts + // inside the same transaction (the CID change means the existing + // pin_refcount rows are stale). + Replace(ctx context.Context, orgID ids.OrgID, requestID ids.PinID, newCID string, name *string, meta map[string]any, origins []string) (*Pin, error) + + InsertPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, fence int64) (*Placement, error) + GetPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID) (*Placement, error) + ListPlacements(ctx context.Context, requestID ids.PinID) ([]*Placement, error) + ListPlacementsForNode(ctx context.Context, nodeID ids.NodeID, status string) ([]*Placement, error) + UpdatePlacementFenced(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, status string, failureReason *string, fence int64) (int64, error) + ReplacePlacement(ctx context.Context, requestID ids.PinID, oldNode, newNode ids.NodeID) (*Placement, error) + StuckPlacements(ctx context.Context, stuckAfter time.Duration) ([]Placement, error) + + IncRefcount(ctx context.Context, nodeID ids.NodeID, cid string) error + DecRefcount(ctx context.Context, nodeID ids.NodeID, cid string) (int, error) + DeleteRefcountIfZero(ctx context.Context, nodeID ids.NodeID, cid string) error + + // CountPlacementsByStatus returns a histogram of placement rows + // grouped by status. Used by the Prometheus gauge refresh loop; + // unqueried statuses are omitted from the map (a zero gauge is the + // Prometheus convention). + CountPlacementsByStatus(ctx context.Context) (map[string]int, error) +} + +// AuditEntry is a single audit_log row. +type AuditEntry struct { + ID int64 + OrgID *ids.OrgID + ActorUserID *ids.UserID + ActorTokenJTI *string + Action string + Target string + Result string // "ok" | "error" + Detail map[string]any + Created time.Time +} + +// AuditStore is append-only. Writes must be fire-and-forget-safe — +// callers don't want audit failures to fail user requests, so the +// concrete implementation should log rather than return on error paths +// where appropriate. +type AuditStore interface { + Insert(ctx context.Context, e *AuditEntry) error + List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*AuditEntry, error) +} diff --git a/internal/pkg/token/keyload.go b/internal/pkg/token/keyload.go new file mode 100644 index 0000000..77fdb68 --- /dev/null +++ b/internal/pkg/token/keyload.go @@ -0,0 +1,91 @@ +package token + +import ( + "errors" + "fmt" + "log/slog" + "os" + "strings" +) + +// DevOnlySigningKey is the in-memory fallback anchorage uses when no +// signing keys are configured. It is long enough to satisfy the 32-byte +// minimum but trivially predictable — intentionally so, so operators +// notice the loud warning in logs and replace it. +// +// Never use this key in any environment that touches real data. +const DevOnlySigningKey = "dev-only-do-not-use-in-production-0123456789abcdef" + +// readKeyFile reads a key file, strips a trailing newline, and enforces +// the 32-byte minimum. Kept unexported — callers use LoadSigningKeys. +// +// Trailing newline handling means `openssl rand -base64 48 > jwt.key` +// works without ceremony. +func readKeyFile(path string) ([]byte, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read signing key %s: %w", path, err) + } + key := []byte(strings.TrimRight(string(raw), "\r\n")) + if len(key) < 32 { + return nil, errors.New("signing key " + path + " is too short (need >= 32 bytes)") + } + return key, nil +} + +// KeyFileSpec names a single signing key file on disk. Mirrors +// config.SigningKeyConfig in a shape the token package can depend on +// without importing config. +type KeyFileSpec struct { + Path string + ID string + Primary bool +} + +// LoadSigningKeys validates and loads the rotation key set. +// +// - An empty slice returns a dev-only fallback (kid=DevKeyID, primary) +// with a loud warning so operators notice they haven't configured +// real keys. +// - A non-empty slice is validated (unique non-empty IDs, exactly one +// primary, 32-byte minimum on each file) and returned. +// +// Never returns an empty slice on success. +func LoadSigningKeys(rotation []KeyFileSpec) ([]SigningKey, error) { + if len(rotation) == 0 { + slog.Warn("token: no signing keys configured — using the built-in dev key. DO NOT use in production.") + return []SigningKey{{ + ID: DevKeyID, + Key: []byte(DevOnlySigningKey), + Primary: true, + }}, nil + } + + out := make([]SigningKey, 0, len(rotation)) + seen := map[string]bool{} + primaries := 0 + for i, spec := range rotation { + if spec.ID == "" { + return nil, fmt.Errorf("token: signingKeys[%d].id is required", i) + } + if spec.Path == "" { + return nil, fmt.Errorf("token: signingKeys[%d].path is required", i) + } + if seen[spec.ID] { + return nil, fmt.Errorf("token: duplicate signingKey id %q", spec.ID) + } + seen[spec.ID] = true + key, err := readKeyFile(spec.Path) + if err != nil { + return nil, fmt.Errorf("token: signingKeys[%d] (%q): %w", i, spec.ID, err) + } + out = append(out, SigningKey{ID: spec.ID, Key: key, Primary: spec.Primary}) + if spec.Primary { + primaries++ + } + } + if primaries != 1 { + return nil, fmt.Errorf("token: exactly one signingKey must be marked primary (got %d)", primaries) + } + return out, nil +} diff --git a/internal/pkg/token/minttoken_roundtrip_test.go b/internal/pkg/token/minttoken_roundtrip_test.go new file mode 100644 index 0000000..adfec72 --- /dev/null +++ b/internal/pkg/token/minttoken_roundtrip_test.go @@ -0,0 +1,115 @@ +package token_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/token" +) + +// TestLoadSigningKeyRoundTrip mirrors the CLI → server handshake: +// an operator runs `anchorage admin mint-token` (which reads the key +// via LoadSigningKeys + NewSigner + Mint), hands the JWT to an IPFS +// client, which then hits a running anchorage whose Signer was built +// from the same config. Both signers must accept the token. +func TestLoadSigningKeyRoundTrip(t *testing.T) { + dir := t.TempDir() + keyPath := filepath.Join(dir, "jwt.key") + // 48-byte key with a trailing newline, matching what + // `openssl rand -base64 48 > jwt.key` writes. + if err := os.WriteFile(keyPath, []byte("0123456789abcdef0123456789abcdef0123456789abcdef\n"), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + + spec := []token.KeyFileSpec{ + {ID: "test-kid", Path: keyPath, Primary: true}, + } + + mintKeys, err := token.LoadSigningKeys(spec) + if err != nil { + t.Fatalf("LoadSigningKeys (mint): %v", err) + } + verifyKeys, err := token.LoadSigningKeys(spec) + if err != nil { + t.Fatalf("LoadSigningKeys (verify): %v", err) + } + + mintSigner, err := token.NewSigner(mintKeys, "https://anchorage.test/", "anchorage", nil) + if err != nil { + t.Fatalf("NewSigner (mint): %v", err) + } + jwt, claims, err := mintSigner.Mint( + context.Background(), + ids.MustNewOrg(), ids.MustNewUser(), + "sysadmin", []string{"pin:write"}, + 365*24*time.Hour+30*24*time.Hour, // 1y + 30d + ) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if claims.Role != "sysadmin" { + t.Errorf("claims.Role = %q", claims.Role) + } + + verifySigner, err := token.NewSigner(verifyKeys, "https://anchorage.test/", "anchorage", nil) + if err != nil { + t.Fatalf("NewSigner (verify): %v", err) + } + parsed, err := verifySigner.Parse(context.Background(), jwt) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if parsed.ID != claims.ID { + t.Errorf("jti drift: minted=%q parsed=%q", claims.ID, parsed.ID) + } + if parsed.Role != "sysadmin" { + t.Errorf("parsed.Role = %q, want sysadmin", parsed.Role) + } +} + +func TestLoadSigningKeysRejectsMissingFile(t *testing.T) { + _, err := token.LoadSigningKeys([]token.KeyFileSpec{ + {ID: "x", Path: "/does/not/exist/anywhere", Primary: true}, + }) + if err == nil { + t.Error("expected error for missing file") + } +} + +func TestLoadSigningKeysRejectsShortKey(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "short.key") + _ = os.WriteFile(p, []byte("tooshort"), 0o600) + _, err := token.LoadSigningKeys([]token.KeyFileSpec{ + {ID: "x", Path: p, Primary: true}, + }) + if err == nil { + t.Error("expected error for short key") + } +} + +// TestLoadSigningKeysEmptyReturnsDevKey covers the zero-config path — +// no signingKeys entries → built-in dev key with kid=DevKeyID. A loud +// warning fires in logs; tests don't assert on that, just on the shape. +func TestLoadSigningKeysEmptyReturnsDevKey(t *testing.T) { + keys, err := token.LoadSigningKeys(nil) + if err != nil { + t.Fatalf("empty should not error: %v", err) + } + if len(keys) != 1 { + t.Fatalf("want 1 key, got %d", len(keys)) + } + if keys[0].ID != token.DevKeyID { + t.Errorf("dev key ID = %q, want %q", keys[0].ID, token.DevKeyID) + } + if !keys[0].Primary { + t.Error("dev key must be marked primary") + } + if string(keys[0].Key) != token.DevOnlySigningKey { + t.Error("dev key bytes drift") + } +} diff --git a/internal/pkg/token/rotation_test.go b/internal/pkg/token/rotation_test.go new file mode 100644 index 0000000..256d37b --- /dev/null +++ b/internal/pkg/token/rotation_test.go @@ -0,0 +1,267 @@ +package token_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + jwtPkg "github.com/golang-jwt/jwt/v5" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/token" +) + +// keyBytes produces a deterministic 32-byte key. Using fmt'd values +// rather than crypto/rand keeps the test reproducible. +func keyBytes(seed byte) []byte { + b := make([]byte, 48) + for i := range b { + b[i] = seed + } + return b +} + +const ( + issuer = "https://anchorage.test/" + aud = "anchorage" +) + +// TestRotationOverlap walks the canonical rotation procedure: +// +// 1. Start with signer_A (key_A primary). +// 2. Add key_B as secondary → signer_A+B (A primary, B loaded). +// 3. Flip primary to B → signer_A+B (B primary, A retained). +// 4. Drop key_A → signer_B (only B). +// +// Tokens minted at each stage must keep verifying at least until their +// minting key is dropped, and new mints must use the primary key's +// kid. +func TestRotationOverlap(t *testing.T) { + keyA := keyBytes(0xA0) + keyB := keyBytes(0xB0) + + makeSigner := func(t *testing.T, keys []token.SigningKey) *token.Signer { + t.Helper() + s, err := token.NewSigner(keys, issuer, aud, nil) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + return s + } + mintHere := func(t *testing.T, s *token.Signer) string { + t.Helper() + raw, _, err := s.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "member", nil, time.Hour) + if err != nil { + t.Fatalf("Mint: %v", err) + } + return raw + } + parseOK := func(t *testing.T, s *token.Signer, raw, label string) { + t.Helper() + if _, err := s.Parse(context.Background(), raw); err != nil { + t.Errorf("%s should parse: %v", label, err) + } + } + parseFail := func(t *testing.T, s *token.Signer, raw, label string) { + t.Helper() + if _, err := s.Parse(context.Background(), raw); err == nil { + t.Errorf("%s should NOT parse", label) + } + } + + // --- stage 1: only A --- + stage1 := makeSigner(t, []token.SigningKey{ + {ID: "A", Key: keyA, Primary: true}, + }) + tokenA := mintHere(t, stage1) + if stage1.PrimaryKeyID() != "A" { + t.Fatalf("stage1 primary = %q", stage1.PrimaryKeyID()) + } + + // --- stage 2: A primary, B added as secondary --- + stage2 := makeSigner(t, []token.SigningKey{ + {ID: "A", Key: keyA, Primary: true}, + {ID: "B", Key: keyB}, + }) + parseOK(t, stage2, tokenA, "stage2: old A-minted token") + tokenA2 := mintHere(t, stage2) + // Both stage2 tokens are still A-minted (primary unchanged). + parseOK(t, stage2, tokenA2, "stage2: new mint still on key A") + + // --- stage 3: B primary, A retained --- + stage3 := makeSigner(t, []token.SigningKey{ + {ID: "A", Key: keyA}, + {ID: "B", Key: keyB, Primary: true}, + }) + parseOK(t, stage3, tokenA, "stage3: A-era token still verifies") + tokenB := mintHere(t, stage3) + parseOK(t, stage3, tokenB, "stage3: new B-minted token") + if stage3.PrimaryKeyID() != "B" { + t.Fatalf("stage3 primary = %q", stage3.PrimaryKeyID()) + } + + // --- stage 4: drop A --- + stage4 := makeSigner(t, []token.SigningKey{ + {ID: "B", Key: keyB, Primary: true}, + }) + parseFail(t, stage4, tokenA, "stage4: key A dropped → A-minted tokens rejected") + parseOK(t, stage4, tokenB, "stage4: B-minted still verifies") +} + +// TestRotationRejectsUnknownKid confirms an attacker who knows the +// mint algorithm can't trick anchorage into accepting a kid that isn't +// configured. Silent "try all keys" would defeat the whole feature. +func TestRotationRejectsUnknownKid(t *testing.T) { + s, _ := token.NewSigner([]token.SigningKey{ + {ID: "real-key", Key: keyBytes(0x11), Primary: true}, + }, issuer, aud, nil) + + // Mint a token with a signer that happens to use the same bytes but + // a DIFFERENT kid. Verification against the first signer must fail. + attacker, _ := token.NewSigner([]token.SigningKey{ + {ID: "attacker-key", Key: keyBytes(0x11), Primary: true}, + }, issuer, aud, nil) + raw, _, err := attacker.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "sysadmin", nil, time.Hour) + if err != nil { + t.Fatalf("attacker mint: %v", err) + } + _, err = s.Parse(context.Background(), raw) + if err == nil { + t.Fatal("expected unknown-kid token to be rejected") + } + if !strings.Contains(err.Error(), "unknown kid") { + t.Errorf("error should mention unknown kid, got: %v", err) + } +} + +// TestRotationRejectsMissingKid confirms that a token without a `kid` +// header is rejected outright. anchorage is new software — there is +// no pre-rotation token population to accept via a fallback. +func TestRotationRejectsMissingKid(t *testing.T) { + s, err := token.NewSigner([]token.SigningKey{ + {ID: "A", Key: keyBytes(0xAA), Primary: true}, + }, issuer, aud, nil) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + // Hand-craft a JWT signed with the right bytes but no `kid` header. + raw := jwtNoKid(t, keyBytes(0xAA)) + _, err = s.Parse(context.Background(), raw) + if err == nil { + t.Fatal("expected missing-kid token to be rejected") + } + if !strings.Contains(err.Error(), "kid") { + t.Errorf("error should mention kid, got: %v", err) + } +} + +// jwtNoKid mints a minimally-valid HS256 JWT without a `kid` header, +// used by TestRotationRejectsMissingKid to prove Parse rejects the +// pre-rotation shape. +func jwtNoKid(t *testing.T, key []byte) string { + t.Helper() + tok := jwtPkg.NewWithClaims(jwtPkg.SigningMethodHS256, &token.Claims{ + RegisteredClaims: jwtPkg.RegisteredClaims{ + ID: ids.MustNewToken().String(), + Issuer: issuer, + Audience: jwtPkg.ClaimStrings{aud}, + IssuedAt: jwtPkg.NewNumericDate(time.Now()), + NotBefore: jwtPkg.NewNumericDate(time.Now()), + ExpiresAt: jwtPkg.NewNumericDate(time.Now().Add(time.Hour)), + }, + }) + // Deliberately do NOT set tok.Header["kid"]. + signed, err := tok.SignedString(key) + if err != nil { + t.Fatalf("sign: %v", err) + } + return signed +} + +// TestNewSignerRejectsInvalidConfig covers the validation surface. +func TestNewSignerRejectsInvalidConfig(t *testing.T) { + tests := []struct { + name string + keys []token.SigningKey + }{ + {"empty", nil}, + {"no-primary", []token.SigningKey{{ID: "A", Key: keyBytes(0xAA)}}}, + {"two-primaries", []token.SigningKey{ + {ID: "A", Key: keyBytes(0xAA), Primary: true}, + {ID: "B", Key: keyBytes(0xBB), Primary: true}, + }}, + {"duplicate-id", []token.SigningKey{ + {ID: "A", Key: keyBytes(0xAA), Primary: true}, + {ID: "A", Key: keyBytes(0xBB)}, + }}, + {"empty-id", []token.SigningKey{ + {ID: "", Key: keyBytes(0xAA), Primary: true}, + }}, + {"short-key", []token.SigningKey{ + {ID: "A", Key: []byte("too-short"), Primary: true}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := token.NewSigner(tt.keys, issuer, aud, nil); err == nil { + t.Error("expected error") + } + }) + } +} + +// TestLoadSigningKeysEmptyReturnsDevFallback confirms zero-config runs +// land on the built-in dev key. +func TestLoadSigningKeysEmptyReturnsDevFallback(t *testing.T) { + keys, err := token.LoadSigningKeys(nil) + if err != nil { + t.Fatalf("LoadSigningKeys: %v", err) + } + if len(keys) != 1 || keys[0].ID != token.DevKeyID || !keys[0].Primary { + t.Fatalf("empty load should produce single primary DevKeyID entry, got %+v", keys) + } +} + +// TestLoadSigningKeysRotation verifies multi-key loading from config. +func TestLoadSigningKeysRotation(t *testing.T) { + dir := t.TempDir() + writeKey := func(name string, seed byte) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, keyBytes(seed), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p + } + pathA := writeKey("jwt.a.key", 0xAA) + pathB := writeKey("jwt.b.key", 0xBB) + + keys, err := token.LoadSigningKeys([]token.KeyFileSpec{ + {ID: "2025-10", Path: pathA}, + {ID: "2026-04", Path: pathB, Primary: true}, + }) + if err != nil { + t.Fatalf("LoadSigningKeys: %v", err) + } + if len(keys) != 2 { + t.Fatalf("want 2 keys, got %d", len(keys)) + } + if keys[1].ID != "2026-04" || !keys[1].Primary { + t.Errorf("primary entry mis-marked: %+v", keys) + } +} + +// TestLoadSigningKeysRejectsNoPrimary makes sure a malformed config +// can't silently produce a signer that can't mint. +func TestLoadSigningKeysRejectsNoPrimary(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "k") + _ = os.WriteFile(p, keyBytes(0x55), 0o600) + if _, err := token.LoadSigningKeys([]token.KeyFileSpec{ + {ID: "A", Path: p}, {ID: "B", Path: p}, + }); err == nil { + t.Error("expected error when no primary is set") + } +} diff --git a/internal/pkg/token/token.go b/internal/pkg/token/token.go new file mode 100644 index 0000000..12751ce --- /dev/null +++ b/internal/pkg/token/token.go @@ -0,0 +1,295 @@ +// Package token mints and verifies anchorage API tokens. +// +// Tokens are short-lived JWTs signed with a symmetric HMAC key at v1 +// (the plan leaves ed25519/RS256 as a future open item). The jti claim +// is a TypeID (tok_*) so revocation, logs, and audit rows can reference +// it directly. +// +// Every token carries a `kid` header that names the key that signed +// it. Tokens without a `kid` are rejected — anchorage is new software +// so there is no pre-rotation token population to carry forward. +// +// Rotation: Signer accepts multiple keys. Exactly one is flagged as +// Primary — Mint always signs with it and puts its ID in the JWT +// `kid` header. Parse reads `kid` and picks the matching key from the +// full set, so tokens signed with a retiring key keep verifying while +// the retiring key is still loaded. See deploy/README.md for the +// operational procedure. +package token + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// DevKeyID is the stable kid used by the built-in dev-fallback key. +// It appears ONLY when the operator has not configured any keys and +// anchorage is running against its insecure dev default — the loud +// warning in LoadSigningKeys fires then. Never assume a token with +// kid=DevKeyID is legitimate outside a local dev environment. +const DevKeyID = "dev" + +// Claims is anchorage's JWT payload. +type Claims struct { + Org ids.OrgID `json:"org"` + User ids.UserID `json:"sub_id"` + Role string `json:"role"` + Scopes []string `json:"scopes,omitempty"` + jwt.RegisteredClaims +} + +// SigningKey binds an HMAC key to a stable identifier. Exactly one key +// in a Signer's set is flagged Primary — it's the one Mint uses. +type SigningKey struct { + // ID is the stable label emitted as the JWT `kid` header and used + // by Parse to look up which key verified a given token. Treat like + // a version tag: "2026-04", "prod-v2", anything stable. + ID string + // Key is the raw HMAC bytes (>= 32 bytes). + Key []byte + // Primary marks the key used for minting. Exactly one key in the + // set must have this set. + Primary bool +} + +// DenyCache is the minimal surface the Signer needs from a cache layer +// to speed up denylist lookups. *cache.Cache[string,bool] satisfies it +// implicitly; tests can also pass a fake. nil = no caching, every +// Parse hits the TokenStore. +type DenyCache interface { + Get(key string) (bool, bool) + Set(key string, v bool, cost int64, ttl time.Duration) bool + Delete(key string) +} + +// Signer mints + parses JWTs and checks the Postgres-backed denylist. +type Signer struct { + keys map[string][]byte + primaryID string + issuer string + audience string + tokens store.TokenStore + // denyCache is an optional jti → isDenied short-TTL cache. Cuts + // the per-authenticated-request DB hit for the 99.99% of tokens + // that aren't revoked. Cross-node invalidation is the caller's + // responsibility — typically via cache.WatchEntity("token", cache.Delete). + denyCache DenyCache + onRevoke RevokeHook +} + +// SetDenyCache attaches a cache implementation for denylist lookups. +// Safe to call once at construction time before the Signer starts +// serving requests; not safe to swap at runtime. +func (s *Signer) SetDenyCache(c DenyCache) { s.denyCache = c } + +// RevokeHook is called with the revoked jti after every successful +// Revoke. Typical hook publishes a NATS `cache.invalidate.token.` +// so peer nodes drop their local denylist cache entries. +type RevokeHook func(jti string) + +// SetRevokeHook installs a post-Revoke callback. Nil to clear. +func (s *Signer) SetRevokeHook(h RevokeHook) { s.onRevoke = h } + +// NewSigner constructs a Signer with one or more keys for overlap-style +// rotation. Exactly one key must be flagged Primary; all IDs must be +// unique and non-empty; all keys must be >= 32 bytes. +func NewSigner(keys []SigningKey, issuer, audience string, tokens store.TokenStore) (*Signer, error) { + if len(keys) == 0 { + return nil, errors.New("token: at least one signing key is required") + } + s := &Signer{ + keys: make(map[string][]byte, len(keys)), + issuer: issuer, + audience: audience, + tokens: tokens, + } + for _, k := range keys { + if k.ID == "" { + return nil, errors.New("token: SigningKey.ID must not be empty") + } + if _, dup := s.keys[k.ID]; dup { + return nil, fmt.Errorf("token: duplicate kid %q", k.ID) + } + if len(k.Key) < 32 { + return nil, fmt.Errorf("token: key %q is too short (need >= 32 bytes)", k.ID) + } + s.keys[k.ID] = k.Key + if k.Primary { + if s.primaryID != "" { + return nil, fmt.Errorf("token: multiple primary keys (%q and %q)", s.primaryID, k.ID) + } + s.primaryID = k.ID + } + } + if s.primaryID == "" { + return nil, errors.New("token: exactly one key must be flagged Primary") + } + return s, nil +} + +// PrimaryKeyID returns the kid of the current minting key. Useful for +// tests and for the admin rotation CLI. +func (s *Signer) PrimaryKeyID() string { return s.primaryID } + +// AcceptsKey reports whether kid would verify incoming tokens. +func (s *Signer) AcceptsKey(kid string) bool { + _, ok := s.keys[kid] + return ok +} + +// Mint signs a new JWT with the given claims. jti is generated if empty; +// ttl <= 0 falls back to 24h. The primary key's ID is written into the +// JWT `kid` header. +func (s *Signer) Mint(ctx context.Context, orgID ids.OrgID, userID ids.UserID, role string, scopes []string, ttl time.Duration) (string, *Claims, error) { + if ttl <= 0 { + ttl = 24 * time.Hour + } + jti, err := ids.NewToken() + if err != nil { + return "", nil, err + } + now := time.Now().UTC() + c := &Claims{ + Org: orgID, + User: userID, + Role: role, + Scopes: scopes, + RegisteredClaims: jwt.RegisteredClaims{ + ID: jti.String(), + Issuer: s.issuer, + Audience: jwt.ClaimStrings{s.audience}, + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + NotBefore: jwt.NewNumericDate(now), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, c) + tok.Header["kid"] = s.primaryID + signed, err := tok.SignedString(s.keys[s.primaryID]) + if err != nil { + return "", nil, fmt.Errorf("sign jwt: %w", err) + } + return signed, c, nil +} + +// Parse validates the JWT signature, expiry, and denylist status. +// +// The verifying key is chosen by the token's `kid` header. Tokens +// without a `kid`, or pointing at a kid that isn't loaded in this +// Signer's key set, are rejected — silently trying every key would +// defeat the point of rotation. +func (s *Signer) Parse(ctx context.Context, raw string) (*Claims, error) { + c := &Claims{} + tok, err := jwt.ParseWithClaims(raw, c, s.keyfunc, + jwt.WithAudience(s.audience), + jwt.WithIssuer(s.issuer), + ) + if err != nil { + return nil, err + } + if !tok.Valid { + return nil, errors.New("token: invalid") + } + if c.ID == "" { + return nil, errors.New("token: missing jti") + } + jti, err := ids.ParseToken(c.ID) + if err != nil { + return nil, fmt.Errorf("token: parse jti: %w", err) + } + if s.tokens != nil { + denied, err := s.isDeniedCached(ctx, jti) + if err != nil { + return nil, fmt.Errorf("token: denylist check: %w", err) + } + if denied { + return nil, errors.New("token: revoked") + } + } + return c, nil +} + +// isDeniedCached wraps TokenStore.IsDenied with a short-TTL cache +// keyed on the jti string. 5 minutes strikes a balance: a freshly +// revoked jti stops verifying within (at most) 5 minutes without +// explicit invalidation, but cross-node invalidation via +// cache.invalidate.token. usually drops stale entries within +// one NATS RTT. +// +// Both true and false outcomes are cached. A false (not-denied) hit +// is the common case and the reason this cache exists; a true hit +// still short-circuits the DB call until TTL expires. +func (s *Signer) isDeniedCached(ctx context.Context, jti ids.TokenID) (bool, error) { + key := jti.String() + if s.denyCache != nil { + if v, ok := s.denyCache.Get(key); ok { + return v, nil + } + } + denied, err := s.tokens.IsDenied(ctx, jti) + if err != nil { + return false, err + } + if s.denyCache != nil { + s.denyCache.Set(key, denied, 1, 5*time.Minute) + } + return denied, nil +} + +// keyfunc selects the HMAC key matching the token's kid. Kept as a +// method (not a closure inside Parse) for testability + clarity. +func (s *Signer) keyfunc(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method %q", t.Header["alg"]) + } + kid, _ := t.Header["kid"].(string) + if kid == "" { + return nil, errors.New("token: missing kid header") + } + key, ok := s.keys[kid] + if !ok { + return nil, fmt.Errorf("token: unknown kid %q", kid) + } + return key, nil +} + +// Revoke marks the jti as denied until its natural expiry. Idempotent. +// Drops the local cache entry immediately so the revoking node sees +// the change without waiting for TTL; cross-node consistency is the +// caller's responsibility — typically via cache.Invalidator.Emit on +// the "token" entity, which other nodes pick up through +// cache.WatchEntity subscribers that call s.InvalidateDenyCache. +func (s *Signer) Revoke(ctx context.Context, jti ids.TokenID, expiresAt time.Time, reason string) error { + if s.tokens == nil { + return nil + } + if err := s.tokens.Revoke(ctx, jti); err != nil && !errors.Is(err, store.ErrNotFound) { + return err + } + if err := s.tokens.AddDenylist(ctx, jti, expiresAt, reason); err != nil { + return err + } + s.InvalidateDenyCache(jti.String()) + if s.onRevoke != nil { + s.onRevoke(jti.String()) + } + return nil +} + +// InvalidateDenyCache drops a single jti from the local denylist cache. +// Exported so the cross-node invalidation subscriber (see +// internal/pkg/cache.WatchEntity) can plug into the Signer without +// reaching into unexported state. A no-op when no cache is attached. +func (s *Signer) InvalidateDenyCache(jti string) { + if s.denyCache == nil { + return + } + s.denyCache.Delete(jti) +} diff --git a/internal/pkg/token/token_test.go b/internal/pkg/token/token_test.go new file mode 100644 index 0000000..7bc1a28 --- /dev/null +++ b/internal/pkg/token/token_test.go @@ -0,0 +1,105 @@ +package token_test + +import ( + "context" + "testing" + "time" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" + "anchorage/internal/pkg/store/memstore" + "anchorage/internal/pkg/token" +) + +func newSigner(t *testing.T) (*token.Signer, *memstore.Store) { + t.Helper() + s := memstore.New() + sg, err := token.NewSigner( + []token.SigningKey{{ + ID: "test-kid", + Key: []byte("0123456789abcdef0123456789abcdef"), + Primary: true, + }}, + "https://anchorage.test/", + "anchorage", + s.Tokens(), + ) + if err != nil { + t.Fatalf("NewSigner: %v", err) + } + return sg, s +} + +func TestMintAndParseRoundTrip(t *testing.T) { + sg, _ := newSigner(t) + orgID := ids.MustNewOrg() + userID := ids.MustNewUser() + + raw, claims, err := sg.Mint(context.Background(), orgID, userID, "orgadmin", []string{"pin:write"}, time.Hour) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if claims.Org != orgID || claims.User != userID { + t.Errorf("claims org/user mismatch") + } + if claims.Role != "orgadmin" { + t.Errorf("role = %q", claims.Role) + } + + parsed, err := sg.Parse(context.Background(), raw) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if parsed.Org != orgID { + t.Errorf("parsed.Org = %v", parsed.Org) + } + if parsed.ID != claims.ID { + t.Errorf("jti changed during round-trip") + } +} + +func TestParseRejectsExpired(t *testing.T) { + sg, _ := newSigner(t) + raw, _, err := sg.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "member", nil, time.Millisecond) + if err != nil { + t.Fatalf("Mint: %v", err) + } + time.Sleep(10 * time.Millisecond) + if _, err := sg.Parse(context.Background(), raw); err == nil { + t.Error("expected expired token to be rejected") + } +} + +func TestRevokeAddsToDenylist(t *testing.T) { + sg, s := newSigner(t) + raw, claims, err := sg.Mint(context.Background(), ids.MustNewOrg(), ids.MustNewUser(), "member", nil, time.Hour) + if err != nil { + t.Fatalf("Mint: %v", err) + } + + // Write the token metadata to the store so Revoke has something to update. + if err := s.Tokens().Create(context.Background(), &store.APIToken{ + JTI: mustParseToken(t, claims.ID), OrgID: claims.Org, UserID: claims.User, + Label: "test", ExpiresAt: claims.ExpiresAt.Time, + }); err != nil { + t.Fatalf("seed token: %v", err) + } + + jti := mustParseToken(t, claims.ID) + if err := sg.Revoke(context.Background(), jti, claims.ExpiresAt.Time, "test"); err != nil { + t.Fatalf("Revoke: %v", err) + } + + if _, err := sg.Parse(context.Background(), raw); err == nil { + t.Error("expected revoked token to be rejected") + } +} + +func mustParseToken(t *testing.T, s string) ids.TokenID { + t.Helper() + id, err := ids.ParseToken(s) + if err != nil { + t.Fatalf("ParseToken(%q): %v", s, err) + } + return id +} diff --git a/internal/pkg/wsserver/hub.go b/internal/pkg/wsserver/hub.go new file mode 100644 index 0000000..98623ba --- /dev/null +++ b/internal/pkg/wsserver/hub.go @@ -0,0 +1,83 @@ +// Package wsserver runs the WebSocket hub for pin status events. +// +// One endpoint, /v1/events. Each connection is bound to the +// authenticated client's orgID and optionally to a specific requestID. +// Incoming NATS messages on pin.events.. are fanned +// out to matching connections as JSON frames. +package wsserver + +import ( + "encoding/json" + "log/slog" + "sync" + + "github.com/gofiber/contrib/websocket" + "github.com/nats-io/nats.go" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/pin" +) + +// Hub owns subscriptions and dispatch. +type Hub struct { + nc *nats.Conn + + mu sync.Mutex + conns map[*websocket.Conn]*clientFilter +} + +type clientFilter struct { + OrgID ids.OrgID + RequestID *ids.PinID // nil = all pins for the org +} + +// NewHub creates a Hub subscribed to pin.events.>. +func NewHub(nc *nats.Conn) (*Hub, error) { + h := &Hub{nc: nc, conns: map[*websocket.Conn]*clientFilter{}} + if nc != nil { + _, err := nc.Subscribe(pin.EventsSubjectPrefix+".>", func(m *nats.Msg) { + h.broadcast(m) + }) + if err != nil { + return nil, err + } + } + return h, nil +} + +// Register adds a conn with its filter. Call Unregister on close. +func (h *Hub) Register(c *websocket.Conn, orgID ids.OrgID, rid *ids.PinID) { + h.mu.Lock() + defer h.mu.Unlock() + h.conns[c] = &clientFilter{OrgID: orgID, RequestID: rid} +} + +// Unregister removes conn. +func (h *Hub) Unregister(c *websocket.Conn) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.conns, c) +} + +func (h *Hub) broadcast(m *nats.Msg) { + var ev pin.Event + if err := json.Unmarshal(m.Data, &ev); err != nil { + slog.Warn("wsserver: malformed event", "err", err) + return + } + h.mu.Lock() + defer h.mu.Unlock() + for c, f := range h.conns { + if f.OrgID.String() != ev.OrgID { + continue + } + if f.RequestID != nil && f.RequestID.String() != ev.RequestID { + continue + } + if err := c.WriteMessage(websocket.TextMessage, m.Data); err != nil { + slog.Warn("wsserver: write", "err", err) + _ = c.Close() + delete(h.conns, c) + } + } +} diff --git a/internal/pkg/wsserver/route.go b/internal/pkg/wsserver/route.go new file mode 100644 index 0000000..c5859ef --- /dev/null +++ b/internal/pkg/wsserver/route.go @@ -0,0 +1,68 @@ +package wsserver + +import ( + "github.com/gofiber/contrib/websocket" + "github.com/gofiber/fiber/v2" + + "anchorage/internal/pkg/auth" + "anchorage/internal/pkg/ids" +) + +// Register wires the Fiber route for pin status events onto app. This +// is a plain Fiber handler (not a huma operation) because huma v2 +// doesn't model WebSocket upgrades; the OpenAPI doc references it via +// externalDocs. +// +// Path: GET /v1/events (upgrade) +// +// Query params: +// +// ?requestid=pin_... — subscribe to events for a single pin +// (omit) — subscribe to every pin in the authenticated org +// +// Auth: the `auth.BearerMiddleware` on the parent app must have run +// first so the request carries a ClientContext. +func Register(app *fiber.App, hub *Hub) { + // Upgrade guard: reject non-WebSocket traffic before we register + // the upgrade handler so regular probes get a clean 426. + app.Use("/v1/events", func(c *fiber.Ctx) error { + if websocket.IsWebSocketUpgrade(c) { + return c.Next() + } + return fiber.ErrUpgradeRequired + }) + + app.Get("/v1/events", websocket.New(func(c *websocket.Conn) { + defer c.Close() + + // The pre-upgrade BearerMiddleware stashed the ClientContext on + // Fiber Locals; websocket.Conn exposes Locals but not UserContext. + cc := auth.FromLocals(func(k string) interface{} { return c.Locals(k) }) + if cc == nil { + _ = c.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "unauthenticated")) + return + } + + var rid *ids.PinID + if q := c.Query("requestid"); q != "" { + parsed, err := ids.ParsePin(q) + if err != nil { + _ = c.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "bad requestid")) + return + } + rid = &parsed + } + + hub.Register(c, cc.Org, rid) + defer hub.Unregister(c) + + // Keep the socket alive; drop on client close / error. + for { + if _, _, err := c.ReadMessage(); err != nil { + return + } + } + })) +} diff --git a/packaging/anchorage.service b/packaging/anchorage.service new file mode 100644 index 0000000..8c21d81 --- /dev/null +++ b/packaging/anchorage.service @@ -0,0 +1,48 @@ +[Unit] +Description=anchorage IPFS Pinning Service +Documentation=https://git.anomalous.dev/alphacentri/anchorage +Wants=network-online.target +After=network.target network-online.target postgresql.service ipfs.service + +[Service] +Type=simple +User=anchorage +Group=anchorage +WorkingDirectory=/etc/anchorage +LimitNOFILE=65536 + +ExecStart=/usr/bin/anchorage serve --config /etc/anchorage/anchorage.yaml + +Restart=on-failure +RestartSec=5 +StartLimitInterval=60 +StartLimitBurst=3 + +# Security hardening — anchorage is a pure-Go HTTP/NATS daemon, no +# special capabilities required. The writable paths are the state dir +# (node.id, NATS JetStream data) and the log dir. +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true +NoNewPrivileges=true +RestrictRealtime=true +RestrictNamespaces=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources @mount @debug + +ReadWritePaths=/var/lib/anchorage /var/log/anchorage + +# Allow binding to ports < 1024 (e.g. :443 behind a reverse proxy terminator). +AmbientCapabilities=CAP_NET_BIND_SERVICE +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/packaging/scripts/postinstall.sh b/packaging/scripts/postinstall.sh new file mode 100644 index 0000000..2388c47 --- /dev/null +++ b/packaging/scripts/postinstall.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +systemctl daemon-reload + +# Enable the service but don't start on a fresh install — the operator +# still needs to write /etc/anchorage/anchorage.yaml (start from +# /etc/anchorage/anchorage.yaml.example) and provision the JWT signing +# key at auth.apiToken.signingKeyPath. +systemctl enable anchorage.service >/dev/null 2>&1 || true + +# On upgrade ("2" from rpm, "configure" from deb) restart only if the +# service was previously running so an operator who has it intentionally +# stopped stays that way. +if [ "$1" = "configure" ] || [ "$1" = "2" ]; then + if systemctl is-active --quiet anchorage.service; then + systemctl restart anchorage.service >/dev/null 2>&1 || true + fi +fi + +if [ ! -f /etc/anchorage/anchorage.yaml ]; then + echo "" + echo "anchorage: configuration not found at /etc/anchorage/anchorage.yaml" + echo " cp /etc/anchorage/anchorage.yaml.example /etc/anchorage/anchorage.yaml" + echo " edit to point at your Postgres + Authentik + Kubo RPC endpoint" + echo " generate a signing key, e.g.: openssl rand -base64 48 > /etc/anchorage/jwt.key" + echo " systemctl start anchorage" + echo "" +fi diff --git a/packaging/scripts/postremove.sh b/packaging/scripts/postremove.sh new file mode 100644 index 0000000..981cdd5 --- /dev/null +++ b/packaging/scripts/postremove.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +systemctl daemon-reload + +# On purge (deb) or final erase (rpm) the operator is removing anchorage +# for good — clean up the system user. /var/lib/anchorage is left in +# place so the operator can archive NATS state and node.id first. +if [ "$1" = "purge" ] || [ "$1" = "0" ]; then + userdel anchorage 2>/dev/null || true + groupdel anchorage 2>/dev/null || true + + echo "" + echo "anchorage: state retained at /var/lib/anchorage and /var/log/anchorage" + echo " remove manually if not needed:" + echo " rm -rf /var/lib/anchorage /var/log/anchorage /etc/anchorage" + echo "" +fi diff --git a/packaging/scripts/preinstall.sh b/packaging/scripts/preinstall.sh new file mode 100644 index 0000000..2819342 --- /dev/null +++ b/packaging/scripts/preinstall.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Create the anchorage system user and group if missing. +getent group anchorage >/dev/null 2>&1 || groupadd --system anchorage +getent passwd anchorage >/dev/null 2>&1 || useradd --system --no-create-home --shell /usr/sbin/nologin -g anchorage anchorage + +# Directories the service needs. nfpm also creates these, but repeating +# here covers the case where the operator wiped /var/lib/anchorage +# between installs. +mkdir -p /etc/anchorage \ + /var/lib/anchorage \ + /var/lib/anchorage/nats \ + /var/log/anchorage +chown -R anchorage:anchorage /etc/anchorage /var/lib/anchorage /var/log/anchorage +chmod 0750 /var/lib/anchorage /var/lib/anchorage/nats /var/log/anchorage diff --git a/packaging/scripts/preremove.sh b/packaging/scripts/preremove.sh new file mode 100644 index 0000000..4ac6088 --- /dev/null +++ b/packaging/scripts/preremove.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +systemctl stop anchorage.service >/dev/null 2>&1 || true +systemctl disable anchorage.service >/dev/null 2>&1 || true diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..6904544 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,47 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "internal/pkg/store/postgres/queries" + schema: "internal/pkg/store/postgres/migrations" + gen: + go: + package: "sqlc" + out: "internal/pkg/store/postgres/sqlc" + sql_package: "pgx/v5" + emit_interface: true + emit_json_tags: false + emit_pointers_for_null_types: true + emit_db_tags: false + overrides: + # TypeID columns: stored as text in Postgres but surfaced as typed + # Go wrappers so Go code cannot mix an OrgID with a PinID. + - column: "orgs.id" + go_type: "anchorage/internal/pkg/ids.OrgID" + - column: "users.id" + go_type: "anchorage/internal/pkg/ids.UserID" + - column: "memberships.org_id" + go_type: "anchorage/internal/pkg/ids.OrgID" + - column: "memberships.user_id" + go_type: "anchorage/internal/pkg/ids.UserID" + - column: "api_tokens.jti" + go_type: "anchorage/internal/pkg/ids.TokenID" + - column: "api_tokens.org_id" + go_type: "anchorage/internal/pkg/ids.OrgID" + - column: "api_tokens.user_id" + go_type: "anchorage/internal/pkg/ids.UserID" + - column: "nodes.id" + go_type: "anchorage/internal/pkg/ids.NodeID" + - column: "pins.request_id" + go_type: "anchorage/internal/pkg/ids.PinID" + - column: "pins.org_id" + go_type: "anchorage/internal/pkg/ids.OrgID" + - column: "pin_placements.request_id" + go_type: "anchorage/internal/pkg/ids.PinID" + - column: "pin_placements.node_id" + go_type: "anchorage/internal/pkg/ids.NodeID" + - column: "pin_refcount.node_id" + go_type: "anchorage/internal/pkg/ids.NodeID" + - column: "audit_log.org_id" + go_type: "anchorage/internal/pkg/ids.OrgID" + - column: "audit_log.actor_user_id" + go_type: "anchorage/internal/pkg/ids.UserID" diff --git a/test/helpers_integration_test.go b/test/helpers_integration_test.go new file mode 100644 index 0000000..cd0448f --- /dev/null +++ b/test/helpers_integration_test.go @@ -0,0 +1,111 @@ +//go:build integration + +package test + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + + "anchorage/internal/pkg/store/postgres" +) + +// startPostgres brings up a disposable Postgres container seeded with +// anchorage's schema via the same migrations that the binary runs on +// boot. Shared by every *_integration_test.go in this package. +func startPostgres(t *testing.T) (*postgres.Store, func()) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + container, err := tcpostgres.Run(ctx, "postgres:17-alpine", + tcpostgres.WithDatabase("anchorage"), + tcpostgres.WithUsername("anchorage"), + tcpostgres.WithPassword("test-password"), + tcpostgres.BasicWaitStrategies(), + tcpostgres.WithSQLDriver("pgx"), + ) + if err != nil { + t.Fatalf("pg container: %v", err) + } + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("dsn: %v", err) + } + + if err := postgres.MigrateDSN(ctx, dsn, postgres.MigrateUp, postgres.MigrateOptions{}); err != nil { + _ = container.Terminate(context.Background()) + t.Fatalf("migrate: %v", err) + } + + pool, err := postgres.NewPool(ctx, postgres.PoolConfig{DSN: dsn, MaxConns: 5}) + if err != nil { + _ = container.Terminate(context.Background()) + t.Fatalf("pool: %v", err) + } + pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pingCancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + _ = container.Terminate(context.Background()) + t.Fatalf("ping: %v", err) + } + + cleanup := func() { + pool.Close() + _ = container.Terminate(context.Background()) + } + return postgres.New(pool), cleanup +} + +// startPostgresWithPool is like startPostgres but also returns the raw +// pgxpool so tests that need to bypass the Store abstraction (e.g., RLS +// belt test) can run SQL directly. +func startPostgresWithPool(t *testing.T) (*postgres.Store, *pgxpool.Pool, func()) { + t.Helper() + // This reuses startPostgres and digs the pool out — but the Store + // doesn't expose its pool. We redo the construction here so the raw + // pool is available. + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + container, err := tcpostgres.Run(ctx, "postgres:17-alpine", + tcpostgres.WithDatabase("anchorage"), + tcpostgres.WithUsername("anchorage"), + tcpostgres.WithPassword("test-password"), + tcpostgres.BasicWaitStrategies(), + tcpostgres.WithSQLDriver("pgx"), + ) + if err != nil { + t.Fatalf("pg container: %v", err) + } + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("dsn: %v", err) + } + if err := postgres.MigrateDSN(ctx, dsn, postgres.MigrateUp, postgres.MigrateOptions{}); err != nil { + _ = container.Terminate(context.Background()) + t.Fatalf("migrate: %v", err) + } + pool, err := postgres.NewPool(ctx, postgres.PoolConfig{DSN: dsn, MaxConns: 5}) + if err != nil { + _ = container.Terminate(context.Background()) + t.Fatalf("pool: %v", err) + } + pingCtx, pingCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer pingCancel() + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + _ = container.Terminate(context.Background()) + t.Fatalf("ping: %v", err) + } + cleanup := func() { + pool.Close() + _ = container.Terminate(context.Background()) + } + return postgres.New(pool), pool, cleanup +} diff --git a/test/postgres_integration_test.go b/test/postgres_integration_test.go new file mode 100644 index 0000000..9b02563 --- /dev/null +++ b/test/postgres_integration_test.go @@ -0,0 +1,131 @@ +// Package test hosts anchorage's cross-package integration tests. They +// are gated by a build tag because they pull in Docker via +// testcontainers-go; running the normal `go test ./...` suite does NOT +// exercise them. +// +// Run locally with: +// +// go test -tags=integration -timeout=5m ./test/... +// +//go:build integration + +package test + +import ( + "context" + "testing" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// --------------------------------------------------------------------------- + +// TestPostgresEndToEnd drives the full store API against a real +// Postgres: create org + user + membership, mint a pin, assert +// placement, drain a node, verify rebalance by placement migration, +// replace a pin, delete a pin, confirm CASCADE cleans placements + refs. +func TestPostgresEndToEnd(t *testing.T) { + s, cleanup := startPostgres(t) + defer cleanup() + + ctx := context.Background() + + // --- org + user --- + orgID := ids.MustNewOrg() + org, err := s.Orgs().Create(ctx, orgID, "acme", "Acme Corp") + if err != nil { + t.Fatalf("create org: %v", err) + } + if org.Slug != "acme" { + t.Fatalf("slug") + } + + userID := ids.MustNewUser() + user, err := s.Users().UpsertByAuthentikSub(ctx, userID, "sub-1", "alice@example.com", "Alice", true) + if err != nil { + t.Fatalf("upsert user: %v", err) + } + if !user.IsSysadmin { + t.Error("user should be sysadmin") + } + if err := s.Memberships().Add(ctx, orgID, userID, store.RoleOrgAdmin); err != nil { + t.Fatalf("add member: %v", err) + } + + // --- node registry --- + nodeID := ids.MustNewNode() + if err := s.Nodes().Upsert(ctx, &store.Node{ + ID: nodeID, DisplayName: "node-1", + Multiaddrs: []string{"/dns4/node-1/tcp/4001/p2p/QmX"}, + RPCURL: "http://localhost:5001", + Status: store.NodeStatusUp, + }); err != nil { + t.Fatalf("upsert node: %v", err) + } + + // --- pin create --- + pinID := ids.MustNewPin() + name := "dataset" + err = s.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Create(txCtx, &store.Pin{ + RequestID: pinID, OrgID: orgID, CID: "bafy1", + Name: &name, Meta: map[string]any{"env": "prod"}, + Status: store.PinStatusQueued, + }); err != nil { + return err + } + if _, err := tx.Pins().InsertPlacement(txCtx, pinID, nodeID, 1); err != nil { + return err + } + return tx.Pins().IncRefcount(txCtx, nodeID, "bafy1") + }) + if err != nil { + t.Fatalf("tx: %v", err) + } + + // --- filter exercises the full spec-compliant surface --- + pins, err := s.Pins().Filter(ctx, orgID, store.PinFilter{ + CIDs: []string{"bafy1"}, + Name: "dataset", + Match: "exact", + Status: []string{store.PinStatusQueued}, + Meta: map[string]any{"env": "prod"}, + Limit: 10, + }) + if err != nil { + t.Fatalf("filter: %v", err) + } + if len(pins) != 1 || pins[0].RequestID != pinID { + t.Fatalf("filter got %d pins, expected 1 with the created RequestID", len(pins)) + } + + // --- by-request-id (scheduler path) --- + fetched, err := s.Pins().GetByRequestID(ctx, pinID) + if err != nil { + t.Fatalf("get-by-rid: %v", err) + } + if fetched.CID != "bafy1" { + t.Errorf("cid drift: %q", fetched.CID) + } + + // --- drain + undrain --- + if err := s.Nodes().Drain(ctx, nodeID); err != nil { + t.Fatalf("drain: %v", err) + } + n, _ := s.Nodes().Get(ctx, nodeID) + if n.Status != store.NodeStatusDrained { + t.Errorf("drain status = %s", n.Status) + } + if err := s.Nodes().Uncordon(ctx, nodeID); err != nil { + t.Fatalf("uncordon: %v", err) + } + + // --- delete + cascade --- + if err := s.Pins().Delete(ctx, orgID, pinID); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := s.Pins().GetPlacement(ctx, pinID, nodeID); err == nil { + t.Error("placement survived pin delete — CASCADE broken?") + } +} diff --git a/test/rebalance_integration_test.go b/test/rebalance_integration_test.go new file mode 100644 index 0000000..5c513e0 --- /dev/null +++ b/test/rebalance_integration_test.go @@ -0,0 +1,155 @@ +//go:build integration + +package test + +import ( + "context" + "testing" + "time" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/scheduler" + "anchorage/internal/pkg/store" +) + +// TestRebalancerMovesDownNodePlacements seeds 3 nodes (one down), one +// pin with placement on the down node, runs the rebalancer's dry-run +// and apply variants, and asserts the placement migrated with a bumped +// fence and an audit row landed. +// +// Exercises: scheduler.Rebalancer.DryRun, scheduler.Rebalancer.RunOnce, +// store.PinStore.ReplacePlacement, audit.Insert. +func TestRebalancerMovesDownNodePlacements(t *testing.T) { + s, cleanup := startPostgres(t) + defer cleanup() + ctx := context.Background() + + // --- seed --- + orgID := ids.MustNewOrg() + if _, err := s.Orgs().Create(ctx, orgID, "acme", "Acme"); err != nil { + t.Fatalf("create org: %v", err) + } + nodeA, nodeB, nodeDown := ids.MustNewNode(), ids.MustNewNode(), ids.MustNewNode() + for _, nid := range []ids.NodeID{nodeA, nodeB} { + if err := s.Nodes().Upsert(ctx, &store.Node{ + ID: nid, Multiaddrs: []string{"/dns4/x/tcp/4001/p2p/Q"}, + RPCURL: "http://kubo-" + nid.String()[:8] + ":5001", + }); err != nil { + t.Fatalf("upsert node %s: %v", nid, err) + } + } + if err := s.Nodes().Upsert(ctx, &store.Node{ + ID: nodeDown, Multiaddrs: []string{"/dns4/x/tcp/4001/p2p/Q"}, + RPCURL: "http://kubo-down:5001", + }); err != nil { + t.Fatalf("upsert down node: %v", err) + } + // Mark nodeDown down via the stale-sweeper path so the status + // really is 'down' in the DB. + if _, err := s.Nodes().MarkStaleDown(ctx, -1*time.Second); err != nil { + t.Fatalf("mark stale: %v", err) + } + // One of the three nodes (the last-inserted) will be stale because + // MarkStaleDown picks any up node whose last_seen_at is older than + // the cutoff. We actually want ONLY nodeDown down; easier path is + // a direct SQL nudge via the store's Drain (we'll treat drained + // as equivalent for the rebalancer's purposes, since plan() treats + // any non-up status the same). + // Reset A+B to up by re-upserting; drain nodeDown explicitly. + for _, nid := range []ids.NodeID{nodeA, nodeB} { + _ = s.Nodes().Upsert(ctx, &store.Node{ + ID: nid, Multiaddrs: []string{"/dns4/x/tcp/4001/p2p/Q"}, RPCURL: "http://x:5001", + }) + } + if err := s.Nodes().Drain(ctx, nodeDown); err != nil { + t.Fatalf("drain: %v", err) + } + + // Create a pin placed on nodeDown. + pinID := ids.MustNewPin() + if err := s.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Create(txCtx, &store.Pin{ + RequestID: pinID, OrgID: orgID, CID: "bafy-rebalance-test", + Status: store.PinStatusPinned, + }); err != nil { + return err + } + _, err := tx.Pins().InsertPlacement(txCtx, pinID, nodeDown, 1) + return err + }); err != nil { + t.Fatalf("seed pin: %v", err) + } + + // --- exercise rebalancer --- + reb := &scheduler.Rebalancer{Store: s} + + plan, err := reb.DryRun(ctx) + if err != nil { + t.Fatalf("DryRun: %v", err) + } + if len(plan) != 1 { + t.Fatalf("expected 1 planned move, got %d: %+v", len(plan), plan) + } + move := plan[0] + if move.FromNode != nodeDown.String() { + t.Errorf("FromNode = %q, want %q", move.FromNode, nodeDown) + } + if move.Reason != "drain" { + t.Errorf("Reason = %q, want 'drain'", move.Reason) + } + if move.ToNode == nodeDown.String() { + t.Error("ToNode same as FromNode") + } + if move.NewFence != 0 { + t.Errorf("dry-run should not set NewFence, got %d", move.NewFence) + } + + // Dry-run must not have touched the store. + if _, err := s.Pins().GetPlacement(ctx, pinID, nodeDown); err != nil { + t.Errorf("placement on nodeDown should still exist after dry-run: %v", err) + } + + // --- apply --- + applied, err := reb.RunOnce(ctx) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if len(applied) != 1 { + t.Fatalf("expected 1 applied move, got %d", len(applied)) + } + if applied[0].NewFence < 2 { + t.Errorf("NewFence = %d, expected >= 2 (was 1 before replace)", applied[0].NewFence) + } + + // Old placement must be gone. + if _, err := s.Pins().GetPlacement(ctx, pinID, nodeDown); err == nil { + t.Error("old placement on nodeDown should be gone after apply") + } + // New placement exists on an up node. + toID, _ := ids.ParseNode(applied[0].ToNode) + newPl, err := s.Pins().GetPlacement(ctx, pinID, toID) + if err != nil { + t.Fatalf("new placement missing: %v", err) + } + if newPl.Fence < 2 { + t.Errorf("new placement fence = %d, expected >= 2", newPl.Fence) + } + + // Audit row landed with reason=drain. + audit, err := s.Audit().List(ctx, orgID, 10, 0) + if err != nil { + t.Fatalf("audit list: %v", err) + } + found := false + for _, e := range audit { + if e.Action == "pin.rebalance" && e.Target == pinID.String() { + found = true + if r, _ := e.Detail["reason"].(string); r != "drain" { + t.Errorf("audit reason = %q, want drain", r) + } + } + } + if !found { + t.Error("no pin.rebalance audit row for the test pin") + } +} diff --git a/test/refcount_integration_test.go b/test/refcount_integration_test.go new file mode 100644 index 0000000..e3f2c82 --- /dev/null +++ b/test/refcount_integration_test.go @@ -0,0 +1,122 @@ +//go:build integration + +package test + +import ( + "context" + "errors" + "testing" + + "anchorage/internal/pkg/ids" + "anchorage/internal/pkg/store" +) + +// TestRefcountSharedAcrossOrgs verifies the per-(node, cid) refcount +// semantic: two orgs pinning the same CID co-located on the same node +// share a single Kubo pin (refcount=2). Deleting one org's pin drops +// the count to 1 without unpinning Kubo; deleting the second deletes +// the row, which is the trigger the scheduler uses to issue `ipfs pin +// rm` on Kubo. +// +// Exercises: PinStore.IncRefcount, DecRefcount, DeleteRefcountIfZero, +// and the CASCADE on pins → pin_placements. +func TestRefcountSharedAcrossOrgs(t *testing.T) { + s, cleanup := startPostgres(t) + defer cleanup() + ctx := context.Background() + + // Two orgs + one node. + orgA := ids.MustNewOrg() + orgB := ids.MustNewOrg() + for _, o := range []struct { + id ids.OrgID + slug string + }{{orgA, "a"}, {orgB, "b"}} { + if _, err := s.Orgs().Create(ctx, o.id, o.slug, o.slug); err != nil { + t.Fatalf("create org %s: %v", o.slug, err) + } + } + node := ids.MustNewNode() + if err := s.Nodes().Upsert(ctx, &store.Node{ + ID: node, Multiaddrs: []string{"/dns4/x/tcp/4001/p2p/Q"}, RPCURL: "http://k:5001", + }); err != nil { + t.Fatalf("upsert node: %v", err) + } + + const sharedCID = "bafy-shared-dataset" + + // Org A pins first. + pinA := ids.MustNewPin() + if err := s.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Create(txCtx, &store.Pin{ + RequestID: pinA, OrgID: orgA, CID: sharedCID, Status: store.PinStatusPinned, + }); err != nil { + return err + } + if _, err := tx.Pins().InsertPlacement(txCtx, pinA, node, 1); err != nil { + return err + } + return tx.Pins().IncRefcount(txCtx, node, sharedCID) + }); err != nil { + t.Fatalf("seed org A pin: %v", err) + } + + // Org B pins the same CID onto the same node. + pinB := ids.MustNewPin() + if err := s.Tx(ctx, func(txCtx context.Context, tx store.Store) error { + if err := tx.Pins().Create(txCtx, &store.Pin{ + RequestID: pinB, OrgID: orgB, CID: sharedCID, Status: store.PinStatusPinned, + }); err != nil { + return err + } + if _, err := tx.Pins().InsertPlacement(txCtx, pinB, node, 1); err != nil { + return err + } + return tx.Pins().IncRefcount(txCtx, node, sharedCID) + }); err != nil { + t.Fatalf("seed org B pin: %v", err) + } + + // Delete org A's pin. In production pin.Service.Delete would walk + // placements and publish unpin jobs; the consumer decrements the + // refcount. We simulate that path directly here to test the store + // semantic without spinning up a full scheduler. + if err := s.Pins().Delete(ctx, orgA, pinA); err != nil { + t.Fatalf("delete org A pin: %v", err) + } + remaining, err := s.Pins().DecRefcount(ctx, node, sharedCID) + if err != nil { + t.Fatalf("dec after org A delete: %v", err) + } + if remaining != 1 { + t.Errorf("refcount after first delete = %d, want 1", remaining) + } + + // Org B's pin + placement must still exist. + if _, err := s.Pins().Get(ctx, orgB, pinB); err != nil { + t.Errorf("org B pin should survive org A's delete: %v", err) + } + if _, err := s.Pins().GetPlacement(ctx, pinB, node); err != nil { + t.Errorf("org B placement should survive: %v", err) + } + + // Delete org B's pin → refcount goes to 0 → row removed. + if err := s.Pins().Delete(ctx, orgB, pinB); err != nil { + t.Fatalf("delete org B pin: %v", err) + } + remaining, err = s.Pins().DecRefcount(ctx, node, sharedCID) + if err != nil { + t.Fatalf("dec after org B delete: %v", err) + } + if remaining != 0 { + t.Errorf("refcount after second delete = %d, want 0", remaining) + } + if err := s.Pins().DeleteRefcountIfZero(ctx, node, sharedCID); err != nil { + t.Fatalf("DeleteRefcountIfZero: %v", err) + } + + // A subsequent DecRefcount should now fail with ErrNotFound — row is gone. + if _, err := s.Pins().DecRefcount(ctx, node, sharedCID); !errors.Is(err, store.ErrNotFound) { + t.Errorf("expected ErrNotFound after refcount row removed, got %v", err) + } +} diff --git a/test/rls_integration_test.go b/test/rls_integration_test.go new file mode 100644 index 0000000..602077c --- /dev/null +++ b/test/rls_integration_test.go @@ -0,0 +1,102 @@ +//go:build integration + +package test + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "anchorage/internal/pkg/ids" +) + +// TestRLSTenantIsolation is the belt-and-suspenders check behind +// anchorage's RLS policies. It connects directly via pgxpool (bypassing +// the Store's GUC-setting Tx), inserts a pin with anchorage.org_id set +// to org A, then reads back under three GUC states: +// +// 1. No GUC set → RLS denies (zero rows). +// 2. GUC set to a different org → RLS denies (zero rows). +// 3. GUC set to the inserting org → RLS allows (one row). +// +// If RLS is ever disabled by accident (e.g., a migration drops a +// policy), this test flips red immediately. +func TestRLSTenantIsolation(t *testing.T) { + s, pool, cleanup := startPostgresWithPool(t) + defer cleanup() + ctx := context.Background() + + // Seed two orgs via the regular Store. Orgs is not an RLS-gated + // table; pins is. + orgA := ids.MustNewOrg() + orgB := ids.MustNewOrg() + if _, err := s.Orgs().Create(ctx, orgA, "a", "A"); err != nil { + t.Fatalf("create org A: %v", err) + } + if _, err := s.Orgs().Create(ctx, orgB, "b", "B"); err != nil { + t.Fatalf("create org B: %v", err) + } + + // Insert a pin for org A inside a tx with anchorage.org_id set to A. + pinA := ids.MustNewPin() + if err := txWithOrg(ctx, pool, orgA.String(), func(ctx context.Context, tx pgxpool.Tx) error { + _, err := tx.Exec(ctx, + `INSERT INTO pins (request_id, org_id, cid, status) VALUES ($1, $2, 'bafy-rls', 'pinned')`, + pinA, orgA, + ) + return err + }); err != nil { + t.Fatalf("insert: %v", err) + } + + // Case 1: no GUC. current_setting('anchorage.org_id', true) returns + // NULL and the RLS policy `org_id = ` is false for every row. + count := mustCountPins(t, ctx, pool, "") + if count != 0 { + t.Errorf("no-GUC count = %d, want 0 (RLS should hide)", count) + } + + // Case 2: GUC set to org B. + count = mustCountPins(t, ctx, pool, orgB.String()) + if count != 0 { + t.Errorf("org-B-GUC count = %d, want 0 (RLS should hide org A)", count) + } + + // Case 3: GUC set to org A → row visible. + count = mustCountPins(t, ctx, pool, orgA.String()) + if count != 1 { + t.Errorf("org-A-GUC count = %d, want 1", count) + } +} + +// txWithOrg runs fn inside a transaction with anchorage.org_id bound +// (or unbound if org==""). Commits on nil error, rolls back otherwise. +func txWithOrg(ctx context.Context, pool *pgxpool.Pool, org string, fn func(context.Context, pgxpool.Tx) error) error { + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + if org != "" { + if _, err := tx.Exec(ctx, "SET LOCAL anchorage.org_id = $1", org); err != nil { + return err + } + } + if err := fn(ctx, tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +func mustCountPins(t *testing.T, ctx context.Context, pool *pgxpool.Pool, org string) int { + t.Helper() + var n int + err := txWithOrg(ctx, pool, org, func(ctx context.Context, tx pgxpool.Tx) error { + return tx.QueryRow(ctx, "SELECT COUNT(*) FROM pins").Scan(&n) + }) + if err != nil { + t.Fatalf("count (org=%q): %v", org, err) + } + return n +}