Self-documenting Endpoint Access Mediator — a single unified HTTP endpoint that proxies to multiple authenticated backend services, injecting the required secret into each request server-side (from OpenBao) before forwarding it on, then passing the response back. Calling agents never see the secret; malformed requests are guided toward the correct shape via a self-describing OpenAPI spec instead of a bare error.
docs/notes/— features, constraints, design decisionsdocs/research/— external reference material and prior artdocs/plan/plan.md— complete application plan
seam serve [flags]seam serve takes fifteen configuration flags. All fifteen are listed here and all fifteen have a SEAM_* environment counterpart (see Environment Variables); the two lists describe the same set.
--caller-port(default:8080) - Port for the caller-facing listener--operator-port(default:8081) - Port for the operator-only listener
--base-url(default:http://localhost:8080) - Base URL for the caller-facing interface--spec-dir(default:./spec) - Directory containing local OpenAPI spec files
--fragment-mode(default:false) - Enable fragment merge mode (routes are read from--fragments-dir; see Fragment directory and hot-reload scope)--schema-path(default:./spec/route-fragment-schema.json) - Path to the route-fragment JSON schema for validation--fragments-dir(default:./fragments) - Directory containing OpenAPI fragment files--enable-hot-reload(default:false) - Enable file-watch hot reload of route fragments
--capture-enabled(default:false) - Enable HTTP request/response capture for corpus collection--corpus-dir(default:corpus) - Directory to store captured corpus files
--upstream-ca-dir(default:/etc/gateway/upstream-ca) - Directory for upstream CA bundles--allowlist-file(default: none) - Path to the upstream host allowlist
--vault-base-dir(default:rs-manager/rs-manager/seam/routes) - Base directory thatx-vault-pathmust nestx-seam-ownerunder--max-replayable-request-bytes(default:1048576) - Maximum request body size buffered for replay, in bytes--max-buffered-response-bytes(default:1048576) - Maximum decoded response body size held for whole-response scrubbing, in bytes (see the note below)
In-cluster refusal (upstream trust). When both KUBERNETES_SERVICE_HOST and KUBERNETES_PORT are set, SEAM treats the process as running in-cluster and strips operator-supplied overrides of the two upstream-trust paths: a custom --upstream-ca-dir / SEAM_UPSTREAM_CA_DIR is refused with a warning and /etc/gateway/upstream-ca is used instead, and the allowlist is always the operator-mounted /etc/gateway/allowlist.yaml — a supplied --allowlist-file / SEAM_UPSTREAM_ALLOWLIST can never replace that mounted control inside a pod. The refusal applies to supplied values only; the defaults are exactly those in-cluster paths. Outside a cluster both flags accept any path. The precedence is mounted control > flag > environment variable > default: an explicit flag already beats SEAM_UPSTREAM_CA_DIR / SEAM_UPSTREAM_ALLOWLIST before the boundary runs, so what gets refused in-cluster is the flag's value.
Operator-visible failure modes (in-cluster upstream trust).
- Refused override. Each refused value is announced at startup as
[config] WARNING: --upstream-ca-dir is refused in-cluster; using /etc/gateway/upstream-ca(likewise for--allowlist-file). The warning names the flag and the mounted path that replaces it and never echoes the refused value; startup continues on the mounted controls. This is expected in a Deployment that still carries a developer-eraSEAM_UPSTREAM_*variable — remove the variable to silence it. - Mounted allowlist unavailable. If
/etc/gateway/allowlist.yamlis missing or unreadable at startup, SEAM logs[Allowlist] Failed to load allowlist from /etc/gateway/allowlist.yaml: …and continues fail-closed:/readyzwithholds readiness (allowlistdependency down, 503), and every upstream dispatch is refused withallowlist_parse_failed. The enforcer is built once at startup — hot-reload watch events on the allowlist mount drive the route-reload pipeline only — so restoring the mount takes effect after a pod restart. - Mounted CA bundle unavailable. If a route's
x-upstream-tls.caBundlenames a file absent from/etc/gateway/upstream-ca, dispatches for that route fail with 503proxy_creation_failedplus a request ID; the pod log carries the cause naming the exact missing path under the mount (failed to read CA bundle /etc/gateway/upstream-ca/<name>: …), which the response body deliberately omits. CA clients are built lazily on a route's first dispatch and failed builds are not cached, so restoring the bundle recovers on the next dispatch without a restart.
In fragment mode (--fragment-mode / SEAM_FRAGMENT_MODE) every route SEAM serves is merged from one directory, resolved once at startup:
- an explicitly passed
--fragments-dir, - otherwise a non-empty
SEAM_FRAGMENTS_DIR(an empty value counts as unset), - otherwise the built-in default
./fragments.
That resolved directory is authoritative. --spec-dir never influences which fragments are loaded — it only selects the static-spec location in non-fragment mode. <spec-dir>/fragments.d is not the fragment location by default: it survives only as a legacy fallback applied when the resolved directory is empty, which the serve path cannot reach (the flag default is non-empty) — it exists for an explicitly passed --fragments-dir= and for direct loader-API callers. seam lint and seam diff resolve --fragments-dir with their own flag-over-environment rule (see lint / diff explicit-flag tracking) and never consult --spec-dir.
When --enable-hot-reload / SEAM_HOT_RELOAD_ENABLED is on, the watcher roots at that same resolved directory: it watches the directory itself plus each immediate subdirectory (the per-service mounts a ConfigMap projection creates), together with the allowlist file's directory and the upstream CA directory when configured. A missing fragments directory is logged and simply means nothing is watched — not a startup error — and a directory created after startup is picked up only on restart. A watch event re-reads the directory captured at startup: changing SEAM_FRAGMENTS_DIR in a running process's environment never redirects a reload. Each reload re-walks the tree, re-merges, and atomically swaps the route table (in-flight requests finish on the old table); a re-merge whose hash matches the current spec is a no-op. Schema validation runs at startup only — a reload does not re-validate fragments against --schema-path, so seam lint remains the gate for fragment structure.
Every serve configuration flag can also be set via an environment variable with the SEAM_ prefix — the table covers all fifteen flags above:
| Variable | Flag | Default |
|---|---|---|
SEAM_CALLER_PORT |
--caller-port |
8080 |
SEAM_OPERATOR_PORT |
--operator-port |
8081 |
SEAM_BASE_URL |
--base-url |
http://localhost:8080 |
SEAM_SPEC_DIR |
--spec-dir |
./spec |
SEAM_FRAGMENT_MODE |
--fragment-mode |
false |
SEAM_SCHEMA_PATH |
--schema-path |
./spec/route-fragment-schema.json |
SEAM_CAPTURE_ENABLED |
--capture-enabled |
false |
SEAM_CORPUS_DIR |
--corpus-dir |
corpus |
SEAM_FRAGMENTS_DIR |
--fragments-dir |
./fragments |
SEAM_UPSTREAM_CA_DIR |
--upstream-ca-dir |
/etc/gateway/upstream-ca (override refused in-cluster) |
SEAM_UPSTREAM_ALLOWLIST |
--allowlist-file |
none (override refused in-cluster) |
SEAM_VAULT_BASE_DIR |
--vault-base-dir |
rs-manager/rs-manager/seam/routes |
SEAM_MAX_REPLAYABLE_REQUEST_BYTES |
--max-replayable-request-bytes |
1048576 |
SEAM_MAX_BUFFERED_RESPONSE_BYTES |
--max-buffered-response-bytes |
1048576 |
SEAM_HOT_RELOAD_ENABLED |
--enable-hot-reload |
false |
The pairing rule is mechanical — SEAM_ plus the flag name upper-cased with dashes as underscores — and thirteen of the fifteen variables follow it. Two are paired by meaning instead and break the derivation: SEAM_UPSTREAM_ALLOWLIST maps to --allowlist-file (not SEAM_ALLOWLIST_FILE, keeping the allowlist's upstream identity in the variable name), and SEAM_HOT_RELOAD_ENABLED maps to --enable-hot-reload (not SEAM_ENABLE_HOT_RELOAD). Where the rule and the table disagree, the table is authoritative.
--max-buffered-response-bytes bounds only how much of a response SEAM may hold in memory for whole-body secret scrubbing; it is never a scrubbability limit and never rejects a response. A response whose declared Content-Length is at or under the cap is scrubbed whole and returned with its (recomputed) Content-Length. A response over the cap — or with no declared length, or whose decoded size exceeds the cap even when its encoded size does not — is scrubbed incrementally with bounded memory and streamed to the caller chunked (Content-Length removed) with the same status, headers, trailers, and content-encoding; nothing is truncated. A non-positive value falls back to the default. The two size caps are independent knobs: this one governs responses only, --max-replayable-request-bytes governs request replay only, and tuning one never moves the other.
Flags win over the environment. An explicitly passed command-line flag takes precedence over a non-empty corresponding SEAM_* variable. Environment values fill flags that were not passed, and built-in defaults fill everything left unset. This gives operators an environment-based deployment default while preserving an explicit CLI override. seam healthcheck follows the same rule for SEAM_CALLER_PORT, so an explicit probe flag wins while an environment-only port override is still honoured.
A variable set to the empty string counts as unset: the flag value survives.
- Integer variables (
*_PORT,*_BYTES) parse as an optional sign followed by digits. Leading whitespace is skipped and anything after the integer prefix is ignored:SEAM_CALLER_PORT=8080abcconfigures8080, and0x10configures0. A value with no leading integer is rejected — the previous environment/default value is kept and a warning is logged. An explicit flag still wins without parsing the environment value. There is no range validation at configuration time: an out-of-range port such as-5or99999is applied and fails later, when the listener binds. - Boolean variables (
SEAM_FRAGMENT_MODE,SEAM_CAPTURE_ENABLED,SEAM_HOT_RELOAD_ENABLED) recognize exactlytrueand1, lowercase. Any other non-empty value — includingTRUE,yes,0andfalse— means false when the environment supplies the setting; an explicit flag still wins.SEAM_HOT_RELOAD_ENABLEDis deliberately asymmetric: onlytrue/1changes an unset flag, so an environment value can enable hot reload but cannot override an explicit flag. SEAM_VAULT_BASE_DIRis whitespace-trimmed, fills an omitted flag, and falls back to the shared default when neither names a prefix.
seam lint and seam diff follow the same flag-over-environment rule for SEAM_FRAGMENTS_DIR, SEAM_SCHEMA_PATH and SEAM_UPSTREAM_ALLOWLIST: the environment fills the corresponding flag only while it is still at its default. (Corollary: passing the default value explicitly, e.g. --fragments-dir ./fragments, is indistinguishable from omitting the flag.) seam import reads no SEAM_* configuration.
Other SEAM_* variables (SEAM_OPENBAO_ADDR, SEAM_OPENBAO_SA_TOKEN_PATH, SEAM_TEST_IDENTITY_MODE, …) are server-runtime knobs, not CLI configuration.
Probe the caller-facing liveness endpoint. This is what the container image's HEALTHCHECK invokes — the runtime image is FROM scratch and has no shell, so the probe must be a real subcommand.
seam healthcheck [--caller-port <port>] [--timeout <duration>]--caller-port(default:8080) - Port of the caller-facing listener to probe--timeout(default:2s) - Probe timeout
Issues GET http://127.0.0.1:<port>/_seam/healthz and requires HTTP 200. SEAM_CALLER_PORT fills the port only when --caller-port was not passed explicitly — the same flag-over-environment rule as serve (see Precedence (serve)) — so a port override configured on the Deployment is honoured without beating an explicit flag.
Exit codes: 0 the gateway answered 200; 1 the probe failed or timed out; 2 invalid usage.
Validate route fragments against route-fragment-schema.json plus SEAM's structural checks: the x-seam-owner chain (owner must match the fragment's parent directory and be nested by x-vault-path), authored x-api-version shape and placement, reserved control-plane paths, upstream URLs (well-formed absolute http(s), no IP-literal hosts, membership of the operator allowlist when one is supplied), transport acknowledgements (plaintext upstreams, insecureSkipVerify, unscrubbable responses), and route guards (quota unit mismatches, breaker disagreements across same-origin routes). Spec and structural violations are errors; acknowledgement items that need human review are warnings.
seam lint [flags] [path ...]--fragments-dir,--fragments(default:./fragments) - Directory containing route fragments--schema-path,--schema(default:./spec/route-fragment-schema.json) - Path toroute-fragment-schema.json--upstream-allowlist,--upstream-allowlist-path,--allowlist-path,--allowlist(default: none) - Operator-owned upstream-host allowlist; absent is inert--json- Emit a machine-readable JSON report (LintReport) instead of text
Positional arguments select what is linted: none means the --fragments-dir directory, a single directory means that directory, and one or more file paths means exactly those files. Flags may also appear after positional paths, so shell-expanded globs behave as expected:
seam lint # lint ./fragments
seam lint fragments/github-api # lint one fragment directory
seam lint fragments/*/fragment.yaml --json # lint explicit files, JSON reportSEAM_FRAGMENTS_DIR, SEAM_SCHEMA_PATH and SEAM_UPSTREAM_ALLOWLIST fill the corresponding flag only while it is still at its default (see lint / diff explicit-flag tracking).
Exit codes: 0 lint passed (warnings allowed); 1 at least one error finding; 2 usage or setup failure — unknown flag, unreadable path, a schema that does not compile, or an unwritable report.
Merge the current route fragments into a spec and compare it against a base version — by default the fragments at git HEAD, extracted with git archive. Use it to see which routes a fragment change adds, removes, or modifies before pushing.
seam diff [flags]--fragments-dir,-f(default:./fragments) - Directory containing route fragments--base,-b(default: gitHEAD'sfragments/) - Base directory to compare against--json,-j- Emit the machine-readableDiffResult(paths_added,paths_removed,paths_modified,summary.has_changes) instead of the text report--output,-o- Additionally write the merged current spec to this file--unified(default:true),--side-by-side- Presentation switches; the current writer emits the same structured change report for either
Behaviour worth knowing:
- Positional path arguments are rejected — pass
--fragments-dirinstead. - Outside a git repository (or if
git archivefails) there is no implicit base: pass--baseexplicitly or the command exits2. - A
--fragments-dirthat is mistyped or holds no fragments is refused (2) — "no fragments loaded" is not the same as "no changes". An empty base is allowed, with a warning that every current path will be reported as added. SEAM_FRAGMENTS_DIRfills--fragments-dironly while it is still at its default.
Exit codes: 0 the merged specs are identical; 1 changes detected; 2 setup failure — usage error, no resolvable base, no current fragments, or a load/merge/compare/write failure.
Fetch an OpenAPI 3.x (or Swagger 2.0) spec over HTTP(S) and generate a curatable bootstrap fragment. The generated file carries x-seam-schema: v1, x-seam-owner, x-upstream (derived from the spec URL as scheme://host[:port] — the path that located the spec document is not part of the upstream) and the imported paths. The credential and access decisions are deliberately left to the curator: the output reminds you to add x-vault-path, x-inject-as, x-required-scope and any TLS/cache knobs by hand. An http:// upstream emits x-upstream-plaintext: acknowledged with a loud note. Swagger 2.0 sources are accepted; their top-level definitions/parameters are carried across as components.schemas/components.parameters so $refs inside imported operations keep resolving. The command reads no SEAM_* configuration.
seam import --from-url <url> [flags]--from-url,-u(required) - URL of the OpenAPI spec to import; must behttporhttps--owner,-o(default: derived from the URL host) - Owner/service name for the fragment--output,-f(default:<owner>/fragment.yaml) - Output fragment file path--paths,-p- Comma-separated list of paths to import (default: all)--methods,-m- Comma-separated list of HTTP methods to import, case-insensitive (default: all)--filter-prefix- Only import paths with this prefix--strip-prefix- Strip this prefix from imported paths--add-prefix- Add this prefix to all imported paths--timeout(default:30s) - HTTP timeout for fetching the spec
# Whole spec: owner and output path are derived from the URL
seam import --from-url https://api.example.com/openapi.json
# -> owner api-example-com, written to api-example-com/fragment.yaml
# Curated import into the fragments tree
seam import -u https://internal.example.com/swagger.json \
--owner github-api --output fragments/github-api/fragment.yaml \
--filter-prefix /api/v1/ --strip-prefix /api/v1 --methods get,post
# Then validate — seam lint checks x-seam-owner against the parent directory name,
# so keep the fragment in a directory named after the owner
seam lint fragments/github-api/fragment.yamlExit codes: 0 fragment written; 1 no paths matched the filter criteria; 2 failure — missing or invalid --from-url, a non-http(s) scheme, a fetch/HTTP error, a spec that parses as neither JSON nor YAML, or an unwritable output path.
Turn one detection-only evaluator finding into a reviewed, linted fragment
change. The command defaults to a no-write plan. --apply writes only the
explicit --target after the real SEAM lint gate passes; it never runs Git,
kubectl, or a git-host API. A ConfigMap target requires --data-key, because
the evaluator's fragment_path is a locator rather than a writable path.
seam retirement-handoff \
--finding evaluator-finding.jsonl \
--target /path/to/declarative-config/k8s/rs-manager/seam/configmap-routes-legacy.yaml \
--data-key legacy.yaml \
--schema /path/to/SEAM/spec/route-fragment-schema.json
# Review the plan, then apply before committing the declarative-config change.
seam retirement-handoff \
--finding evaluator-finding.jsonl \
--target /path/to/declarative-config/k8s/rs-manager/seam/configmap-routes-legacy.yaml \
--data-key legacy.yaml \
--schema /path/to/SEAM/spec/route-fragment-schema.json \
--apply
# After commit/push and ArgoCD reconciliation, wait for the reload counter.
seam retirement-handoff --observe-only \
--observe-url http://seam-operator.example/health/upstreamsThe command inserts x-seam-deprecated at the fragment root, refuses to
overwrite an existing marker, and leaves the target unchanged when lint
fails. After the operator commits and pushes the manifest, ArgoCD reconciles
it; the observed reload is the proof that SEAM picked up the change. If a
caller returns, revert that declarative-config landing commit with ordinary
git revert; the same hot-reload observation confirms the marker disappeared.
lint, diff and import manage fragments; the differential replay harness in tools/diffharness is the conformance gate that decides whether a fragment may ship at all. It replays a captured corpus of real request/response pairs against both the incumbent proxy and SEAM, then compares the responses for equivalence — a service's fragment does not ship, and its migration prose is not deleted, until its corpus passes the replay. The tools live in their own Go module and have their own README; this section covers the workflow, the harness README covers the full corpus format and comparison rules.
Build both tools from the harness module:
cd tools/diffharness
go build -o seam-capture ./cmd/seam-capture
go build -o seam-replay ./cmd/seam-replayseam-capture \
--incumbent https://service.example.com \
--service service \
--corpus service-corpus.json \
--listen :8080A transparent proxy that forwards every request to --incumbent and records the exchange (status, headers, body) into the --corpus JSON file. Credential-bearing headers are redacted before the corpus is written; the matching secret references are added to the corpus by hand afterwards (see Secret-reference rules). Capture can be turned off with --capture-enabled=false (or SEAM_CAPTURE_ENABLED=false) while keeping transparent forwarding, and bypassed per request with an X-Seam-Capture-Skip header — use it for health checks. Exit status: 0 on a clean shutdown (corpus saved); 2 missing required flags or an invalid --incumbent; a listen/save failure aborts with a logged fatal error.
seam-replay \
--incumbent https://service.example.com \
--seam http://localhost:9000 \
--corpus service-corpus.json \
--secrets service-secrets.local.json \
--report service-report.jsonInputs
--corpus(required) - Captured corpus JSON (schema: seam-diff-corpus/v1)--incumbent,--seam(required) - Base URLs; every corpus entry is replayed against both--secrets(optional) - JSON file mapping each corpus secret ref to its literal value; falls back to the environment per the rules below--ignore-header(repeatable) - Additional headers excluded from comparison, on top of the volatile defaults (Date,Server,X-Request-Id,Set-Cookie,ETag)--verbose- Log each replay as it runs
Outputs - a human-readable summary on stdout, and with --report a JSON report (passCount/failCount/skipCount plus per-entry verdicts and diffs). The JSON report is the conformance evidence attached to a cutover review.
Exit status: 0 every replayable entry passed; 1 at least one entry FAILED or a fatal setup error (unreadable or invalid corpus, no replayable entries, unwritable report); 2 missing required flags.
- A corpus carries references, never values. Each entry's
secretsarray names avault:<path>ref, and the ref must nest under the enforced vault base (rs-manager/rs-manager/seam/routesby default; setSEAM_VAULT_BASE_DIRto match a capture taken under a different base). A ref outside the base is rejected when the corpus loads, not at replay. - Values are resolved in memory at replay time, file over environment: the
--secretsfile is consulted first, thenSEAM_DIFF_SECRET_<REF>, where<REF>is the ref upper-cased with every run of non-[A-Z0-9_]characters collapsed to one_— sovault:rs-manager/rs-manager/seam/routes/argocd-ro/ro-tokenresolves fromSEAM_DIFF_SECRET_VAULT_RS_MANAGER_RS_MANAGER_SEAM_ROUTES_ARGOCD_RO_RO_TOKEN. - Keep the
--secretsfile out of git — the harness convention names it*.local.json. Populate it from your secret store for the duration of a run. - An unresolved ref skips its entry (
SKIP: unresolved secret ref) rather than failing it: a configuration gap must not turn a corpus red. - A secret that appears byte-identically in a SEAM response is a hard FAIL (
secretLeakedin the report). The leak check runs before the comparison and cannot be masked by expected-diff allowances. - Neither tool ever writes a secret value to disk — the corpus holds refs, the report holds verdicts.
seam serve# Via command-line flag
seam serve --capture-enabled --corpus-dir ./my-corpus
# Via environment variable
SEAM_CAPTURE_ENABLED=true SEAM_CORPUS_DIR=./my-corpus seam serveseam serve --caller-port 9000 --operator-port 9001When capture is enabled, you can check the status via the operator endpoint:
curl http://localhost:8081/_seam/capture/statusResponse:
{
"enabled": true,
"entry_count": 42,
"corpus_dir": "corpus"
}Trigger an immediate save of the corpus:
curl -X POST http://localhost:8081/_seam/capture/saveResponse:
{
"status": "saved",
"entry_count": 42
}SEAM includes a benchmark suite to measure performance characteristics.
Run all benchmarks with default settings:
make benchmarkStandard run (10s per benchmark, memory stats):
go test -bench=. -benchmem ./benches/...CPU profiling:
make benchmark-cpu
# Analyze with: go tool pprof benchmark-results/cpu.profMemory profiling:
make benchmark-mem
# Analyze with: go tool pprof benchmark-results/mem.profCI mode (JSON output for automated collection):
make benchmark-ci
# Results: benchmark-results/latest.jsonBenchmark output shows:
BenchmarkProxyForwarding/GET-8 1000000 1234 ns/op 512 B/op 8 allocs/op
1000000: Iterations executed1234 ns/op: Nanoseconds per operation512 B/op: Bytes allocated per operation8 allocs/op: Number of memory allocations per operation
For detailed benchmark documentation, see benches/README.md.
Capture a baseline for a benchmark category, then compare later runs against
it. The check exits non-zero when a metric regresses by more than 10% (or the
THRESHOLD supplied by the caller).
make benchmark-save-baseline TYPE=throughput
make benchmark-check-regression TYPE=throughput
make benchmark-check-regression TYPE=memory THRESHOLD=15make buildmake testmake depsPart of jedarden.com
This GitHub repo is a read-only mirror of git.ardenone.com/jedarden/SEAM — issues and PRs are welcome here either way.