refactor: consolidate docs, write unified API reference
CI / Vet, test, build (push) Canceled after 20s
CI / Build and push image (push) Canceled after 0s

Deleted:
- 43 outdated/completed task files (phases 0-8)
- All design docs (REQUIREMENTS, ADR, routing design, etc)
- Phase-specific docs (temporal, JWT, tool calls, testing guides)
- Redundant API docs (API-llm, API-sqs, SERVICE-USAGE)

Kept: README.md (project overview)

New: Comprehensive API.md
- Single source of truth for api.riotpiao.com
- All services in one place: LLM, workflows, queues, memory, IAM, S3
- Complete request/response examples
- Authentication via JWT bearer tokens + capabilities
- Error handling (RFC 9457)
- Rate limits, timeouts, status codes
- Real-world examples (RAG pipeline, workflow orchestration)

Benefits:
 Developer finds everything in API.md
 No duplicate/stale docs
 Reduced maintenance burden
 Single source of truth
This commit is contained in:
Admin Bot
2026-08-29 22:38:20 -07:00
parent 4935ea9f95
commit a0995edbd0
52 changed files with 588 additions and 7799 deletions
+588 -737
View File
File diff suppressed because it is too large Load Diff
-532
View File
@@ -1,532 +0,0 @@
# API Routing Design: Header-Based Services, CRD-Driven Onboarding, Unified Auth
Supersedes the earlier `/unified`-endpoint draft of this doc. Routing is now
header-only at the gateway root — `api.riotpiao.com` itself is the single entry
point, no dedicated sub-path. New backend services onboard via a CRD the gateway
consumes directly, not via hand-written Go switch cases.
---
## Context
Most of the original hybrid design was aspirational — it didn't exist in code. This
revision reflects decisions made while reviewing the repo against the actual state:
`internal/auth/` was empty, `s3`/`sqs`/`iam` didn't exist as handlers, and only a
body-action `POST /workflow` endpoint was real. Four extensions were decided:
CRD-driven service onboarding, `X-Resource`-based REST verbs replacing body actions,
unified JWT auth (bearer token or service-account password, one validation path), and
blind 5xx retry.
Known constraints, and how these decisions land on them:
- Rule G2 (originally specced in the now-retired `tasks/6.2-kubernetes-manifests.md`,
deleted 2026-08-25 since Phase 6 shipped and is live in-cluster) mandated **no
ServiceAccount/RBAC** on the gateway pod, and routes loaded from a git-sourced
ConfigMap with a pod roll on every change. The gateway pod now consumes the CRD
directly — no separate operator/controller pod. This is a deliberate, acknowledged
supersession of G2 for this one narrow purpose: the existing `api-gateway`
`ServiceAccount` (already bound to the pod at `k8s/rbac.yaml:1-7`, currently with
zero Role — see `k8s/rbac.yaml:9-10`'s own comment "the gateway has no k8s API
access") gains a namespace-scoped `Role` limited to read-only access on one CRD,
defined in this same repo and synced by the existing `api-gw` ArgoCD Application
(`homelab/k8s/argocd/apps/55-api-gateway.yaml`) — no new repo, no new Application.
Static ConfigMap-sourced routes keep the existing roll-on-change behavior;
CRD-sourced routes update live in-process instead (see §1).
- Retired `kmsvc.io` `TemporalWorker`/`Queue` CRD (`homelab/k8s/apps/messaging/queue-crd/`,
controller in the separate `kmsvc-manage` repo) broke via an uncleared finalizer and
RBAC rules that drifted out of sync with the CRD's resources
(`temporalworkers.kmsvc.io is forbidden ... at the cluster scope`). Cluster-scoped
RBAC, RBAC/CRD drift, and finalizer complexity are explicitly avoided this time —
this CRD is namespace-scoped (`api` only) and read-only from the gateway's side.
---
## 0. Repo hygiene — do this first, before any code
**Already staged for deletion (`git status`) — just needs a commit:**
`DELIVERY_COMPLETE.md`, `IMPLEMENTATION_SUMMARY.md`, `PHASE3_GRPC_IMPLEMENTATION.md`,
`PHASE3_PROGRESS.md`, `TEMPORAL_API_DESIGN_SUMMARY.md`,
`TEMPORAL_IMPLEMENTATION_CHECKPOINT.md`, `TEMPORAL_IMPLEMENTATION_COMPLETE.md`,
`TEMPORAL_TEST_REPORT.md`, `WORKFLOWS_INDEX.md`, `WORKFLOWS_QUICK_START.md`,
`WORKFLOWS_README.md`.
**Removed as part of this revision:** `IMPLEMENTATION_PLAN_UPDATED.md` — its reading
order pointed at this doc's now-superseded `/unified`-endpoint design; folded into
this doc instead of kept as a separate index.
**Untracked, flagged but left alone (different topic, not touched by this doc):**
`AUTHENTIK_AS_KEY_MANAGER.md`, `AUTHENTIK_KEY_ROTATION_SIMPLE.md`,
`AUTHENTIK_SOPS_NATIVE.md`, `AUTHENTIK_WITH_FIPS_HSM.md`, `JWT_VS_RANDOM_TOKEN.md`
SOPS secrets-at-rest key rotation via Authentik, unrelated to API routing/CRD work.
---
## 1. Service onboarding (new backend service = code change vs. YAML)
### Existing System
- No CRD, no controller, no generic service registry anywhere in either repo.
- Adding a service today means: write a new Go handler, add a `switch` case, redeploy
the gateway image. Only `workflow` exists (`internal/temporal/handler.go`); `s3`,
`sqs`, `iam` don't exist in code at all.
- `internal/config/config.go` + `loader.go` load only static, hand-edited route config
from the ConfigMap already live per Phase 6 (`k8s/configmap.yaml`) — no per-service
schema, no CRD.
### Future System
New CRD **`ServiceAdapter`** (`gateway.riotpiao.com/v1`, namespace-scoped to `api`
only — deliberately *not* cluster-scoped, unlike the retired CRD). Named for what it
actually does — adapts an in-cluster service to the gateway's
`X-Service`/`X-Resource` contract — rather than a generic "Definition".
**No separate operator pod.** The API gateway pod itself consumes the CRD directly: it
runs a `client-go` informer against `ServiceAdapter` in namespace `api` and keeps an
in-memory route registry that adds/updates/removes entries as CRs change — no
ConfigMap render step, no restart-to-pick-up-changes, no second binary/Deployment/image
to build and operate.
**Where the CRD is defined — this repo, not `homelab`.** Confirmed at
`homelab/k8s/argocd/apps/55-api-gateway.yaml:42-45`: the `api-gw` ArgoCD Application
already sources this repo's own `k8s` path directly (kustomize — Deployment, Service,
ConfigMap, RBAC, NetworkPolicy), auto-syncing on every push to `main`, wave 7. So the
CRD manifest is just another file in that same kustomization: new
`k8s/crd-serviceadapter.yaml`, added to `k8s/kustomization.yaml`'s `resources:` list.
When a new `ServiceAdapter` CR is introduced (i.e. someone commits a CR YAML for a new
service), ArgoCD syncs it through this same already-existing Application, and the
already-running `api-gw` pod's informer notices it and starts accepting
`X-Service: <new-service>` calls — no new Application, no new repo, no manual step in
`homelab` at all. Go API types back the CRD from
`apis/gateway/v1/serviceadapter_types.go` (new package, generates the CRD YAML via
`controller-gen`, same struct also used by the informer).
`spec.auth.capability` is the default capability required for every resource on the
service. A method entry may override it with its own `auth.capability` — needed once
a service splits read vs. write access (see §6's `memory` adapter). No override means
inherit the spec-level default; `auth.required: false` at either level means no
capability check at all for that scope.
**Example CR** (onboarding a hypothetical `postgres` service — no Go code, no image
rebuild, no manual gateway restart):
```yaml
apiVersion: gateway.riotpiao.com/v1
kind: ServiceAdapter
metadata:
name: postgres
namespace: api
spec:
serviceName: postgres # matches X-Service: postgres
upstream:
url: http://kmsvc-postgres-helper.sqs.svc.cluster.local:8080
timeoutSeconds: 10
auth:
required: true
capability: postgres:access # checked against the caller's JWT claims
retryable: true # eligible for the blind 5xx retry middleware, §4
resources:
- name: query # X-Resource: query
methods:
- verb: POST
upstreamPath: /v1/query
requestSchema:
fields:
sql: string
params: array
required: ["sql"]
responseSchema:
fields:
rows: array
- name: table
methods:
- verb: GET
upstreamPath: /v1/tables/{id} # {id} filled from the trailing path segment
- verb: DELETE
upstreamPath: /v1/tables/{id}
status:
phase: Ready
observedGeneration: 1
```
Calling it once reconciled — straight to the gateway root, no dedicated sub-path:
```bash
curl -X POST https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt>' \
-H 'X-Service: postgres' \
-H 'X-Resource: query' \
-d '{"sql": "select 1"}'
```
**RBAC** — extend `k8s/rbac.yaml` in place (same file that already defines the
`api-gateway` `ServiceAccount` at lines 1-6, currently role-less per its own "the
gateway has no k8s API access" comment). A namespace-scoped `Role` (not `ClusterRole`),
read-only, nothing else — this is the entire RBAC footprint added to the gateway pod,
shipped in the same repo/PR as the CRD itself:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: api-gateway-serviceadapter-reader
namespace: api
rules:
- apiGroups: ["gateway.riotpiao.com"]
resources: ["serviceadapters"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: api-gateway-serviceadapter-reader
namespace: api
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: api-gateway-serviceadapter-reader
subjects:
- kind: ServiceAccount
name: api-gateway
namespace: api
```
No `status`/`finalizers` verbs, no write access at all — the gateway only ever reads.
No new `ServiceAccount`, no new Deployment, no new container image.
**Gateway-side consumer** — new `internal/serviceadapter/registry.go`: a `client-go`
`SharedInformer` on `ServiceAdapter` in namespace `api`, feeding an in-memory map keyed
by `spec.serviceName` that the `internal/serviceadapter` dispatcher (§2) reads on every
request. Add/Update/Delete informer callbacks update the map directly — a new
`ServiceAdapter` CR is live within the informer's resync/watch latency, no gateway
restart, no ArgoCD change beyond the existing auto-sync of this repo's `k8s` path.
**Request/response validation**`requestSchema`/`responseSchema` are not JSON
Schema. Deliberately a flat key→type map, author-facing (whoever writes the CR
specifies it, no JSON Schema knowledge needed):
```yaml
requestSchema:
fields:
sql: string # one of: string | number | boolean | array | object
params: array
required: ["sql"] # optional; omit if every field is optional
strict: false # optional, default false — true rejects unlisted keys
responseSchema:
fields:
rows: array
```
One level deep only — `params: array` checks "is this an array," not what's
inside it. No enums, no formats, no nested field validation. Accepted limitation:
none of the adapters in this doc (postgres, workflow, iam, memory) need more than
top-level shape checking.
**Extension forced by real upstream shapes (poimen-memory, §6):** the DSL above
only describes a top-level JSON *object*. `GET /memory/projects` returns a bare
array (`["poimen", ...]`), `GET /memory/query` returns an array of objects, and
`GET /memory/skills` returns objects with an explicit `"generated_from": null`.
Neither is expressible in the object-only form, so two additions, kept minimal
rather than backsliding toward full JSON Schema:
```yaml
responseSchema:
type: array # top-level shape; default "object" if omitted
items: string # scalar array — every element must be this type
responseSchema:
type: array
items:
fields: # object array — one level deep INSIDE each item
level: string
sha256: string
score: number
responseSchema:
fields:
generated_from: { type: string, nullable: true } # long form only needed for nullable
```
Rule: a field written as a bare type string (`sql: string`) is shorthand for
`{type: string, nullable: false}`. `null` satisfies any field marked
`nullable: true` regardless of its declared type; a `null` on a non-nullable
field is a `type_mismatch`. Still one level deep — `items: { fields: {...} }`
does not itself accept a nested `items`.
Validated in Go via `internal/serviceadapter/validate.go` (new, ~40 lines, no
external dependency): `json.Unmarshal` the body into `map[string]interface{}`,
check `Required` fields are present, check each present field in `Fields` against
its Go runtime type (`string`→string, `float64`→number, `bool`→boolean,
`[]interface{}`→array, `map[string]interface{}`→object), and if `strict: true`
reject any body key not listed in `Fields`. Compiled once per CR add/update in the
informer callback above, not per-request — the parsed `FieldSchema` struct is
stored directly in the same registry map entry as the route.
Request-side violations are enforced: 400, RFC 9457 `problem+json`, body
`{type, title, detail, errors: [{field, reason}]}` where `reason` is one of
`missing`, `type_mismatch: want X got Y`, or `unknown_field` (strict mode only).
Response-side violations are **not** enforced — the response is forwarded to the
caller unchanged; a mismatch only emits a
`serviceadapter_response_schema_mismatch{service,resource}` metric and a log line.
Rationale: request validation is a trust boundary (external caller input);
response validation is contract-drift detection against our own upstream — turning
a backend field-shape typo into a caller-facing 502 does more harm than logging it.
---
## 2. Request shape: body action vs. header verb
### Existing System
- `internal/temporal/handler.go`: `POST /workflow` only, body is
`{action, namespace, payload}` (`RequestPayload` struct) — action dispatch happens
by reading and branching on the JSON body, not by header/method.
- `internal/server/router.go` dispatches purely by **path**: exact-matches
`/healthz`, `/readyz`, `/workflow`, `/workflow/health`, `/workflow/metrics`, and
falls through everything else (including `/v1/chat/completions`) to
`upstreamHandler`. There is no header-based dispatch anywhere in the router today.
- `s3`/`sqs`/`iam` bodies with `operation`/`action` fields, and a `/unified` sub-path,
were only ever prose in an earlier draft of this doc — never implemented, and now
dropped.
### Future System
No new sub-path is introduced. `internal/server/router.go`'s `ServeHTTP` gets one new
branch, checked **before** the existing path switch: if the request carries an
`X-Service` header, dispatch straight to a new `internal/serviceadapter/router.go`
dispatcher, regardless of `r.URL.Path` (callers hit `https://api.riotpiao.com/` at the
root). `/v1/chat/completions`, `/v1/embeddings`, `/v1/rerank`, `/healthz`, `/readyz`
keep their existing path-based routing in `internal/proxy/router.go` — those never
carry `X-Service` and are matched first. `/workflow*` stays mounted during migration,
delegating internally to the same dispatcher, then gets removed once callers move to
header-based calls.
Dispatch key becomes **`X-Service` header → adapter**, then **HTTP method +
`X-Resource` header → `{verb, upstreamPath}`** — e.g. `GET` to the gateway root +
`X-Service: iam` + `X-Resource: user` → list Authentik users. This replaces the body
`action`/`operation` field convention everywhere, not just for `iam` — so the
rewritten `workflow` adapter looks like:
- `POST X-Resource: workflow``START_WORKFLOW`
- `GET X-Resource: workflow/{id}``QUERY_WORKFLOW`
- `GET X-Resource: workflow``LIST_WORKFLOWS`
- `DELETE X-Resource: workflow/{id}``TERMINATE_WORKFLOW`
- `GET X-Resource: workflow/{id}/history``GET_WORKFLOW_HISTORY`
- Same pattern for new `s3` (`bucket/{key}`) and `sqs` (`queue/{name}/message`)
adapters — built fresh, since they don't exist in code today.
`/v1/chat/completions`, `/v1/embeddings`, `/v1/rerank` in `internal/proxy/router.go`
are **unchanged** — local-LLM-only, no `X-Service` involvement.
---
## 3. IAM access to Authentik
### Existing System
- No `X-Service: iam` adapter in the gateway.
- Authentik admin API access exists today only client-side, in the Rust `core` CLI
(`/Users/rockliang/workplace/core/src/cmd/iam/*.rs`), which already calls
`{authentik_url}/api/v3/core/applications/`, `/api/v3/providers/oauth2/`,
`/api/v3/core/flows/`, `/api/v3/crypto/certificatekeypairs/` for apps/providers/flows
— confirmed at `core/src/cmd/iam/app.rs:136,160,191,205,219`. No users/groups/roles
coverage there, and none of it is reachable through the gateway.
### Future System
New `internal/iam/handler.go` server-side adapter, reachable at the gateway root with
`X-Service: iam`, mapping `X-Resource` + method onto Authentik's `/api/v3/...` surface:
| X-Resource | Method | Authentik endpoint |
|---|---|---|
| `user` | GET/POST | `/api/v3/core/users/` |
| `user/{id}` | GET/PATCH/DELETE | `/api/v3/core/users/{id}/` |
| `service-account` | POST | `/api/v3/core/users/service_account/` |
| `role` (group) | GET/POST | `/api/v3/core/groups/` |
| `permission` | GET/POST | `/api/v3/rbac/permissions/` |
| `flow` | GET | `/api/v3/flows/instances/` |
This is additive to, not a replacement of, the `core iam` CLI subcommand — different
caller (server vs. local CLI), same upstream API.
---
## 4. Auth: how a caller proves identity
### Existing System
- `internal/auth/` is an **empty directory** — zero implementation. `tasks/` Phase 3
(JWKS fetch/rotation, Bearer validation, Authentik service account, flag-gated
rollout, capability authorization) was retired 2026-08-25 — this doc is now the spec
for that work instead of those deleted task files.
- `kmsvc.riotpiao.com` (separate service) has an auth interceptor
(`kmsvc-manage/internal/api/interceptors/auth.go`, JWKS validation via
`lestrrat-go/jwx/v2/jwt`) but it's **never wired in**`cmd/server/main.go` builds a
bare `grpc.NewServer()` (`kmsvc-manage/cmd/server/main.go:84`), and the REST surface
bypasses gRPC interceptors entirely even once fixed. Useful code to reference, not to
copy the deployment of.
- The `core` CLI already does human OIDC login (`core auth login` — device-code/OOB
against Authentik) and caches a token at `~/.cache/talos/authentik_id_token`
(`core/src/cmd/auth/mod.rs:336-338`) — a **different** token/cache than what §5 adds.
### Future System
Implement in `internal/auth`, with two entry paths converging on one validated-JWT
middleware — no separate downstream logic per path:
1. **Bearer passthrough**`Authorization: Bearer <jwt>` sourced from
`~/.talos/.riotpiao-auth` (written by `mwinit`, §5), validated against Authentik's
JWKS.
2. **Service-account password grant** — Authentik service-account username/password
exchanged for a JWT. Reuse the validation *logic* already written (but unwired) in
`kmsvc-manage/internal/api/interceptors/auth.go`, not its deployment config.
Retry middleware (`internal/resilience/retry.go`, new) wraps every outbound upstream
call from the `internal/serviceadapter` dispatcher: **blind retry on any 5xx**, bounded
attempts with backoff, no idempotency-key gating — Temporal (and other backends) own
dedup on their side. `ServiceAdapter.spec.retryable` (default `true`) is the only
opt-out.
---
## 5. `CLI-MWNINIT.md` (separate doc)
### Existing System
The Rust `core` CLI (`/Users/rockliang/workplace/core`) has `core auth login`
(`src/cmd/auth/mod.rs`) for human OIDC device-code/OOB login, and a separate `core iam`
subcommand tree (`src/cmd/iam/`) for app/group/scope management. There is no
service-account login path and no `~/.talos/` token file — the existing cache is
`~/.cache/talos/authentik_id_token`, and `~/.talos` today only holds Talos *node*
config (`TALOSCONFIG`, per `core/src/config.rs:25-26`), unrelated to Authentik JWTs.
### Future System
New `core mwinit` subcommand (new `src/cmd/mwinit/mod.rs`, same structure as
`src/cmd/auth/mod.rs` — not a new binary), documented in a separate `CLI-MWNINIT.md`:
- **Purpose** — authenticate as a dedicated Authentik service account (not a human
device-code login) and cache the resulting JWT for use against `api.riotpiao.com`.
- **Two paths**: `core mwinit login --username <svc-account> --password <...>`
(Authentik service-account grant, same `AUTHENTIK_URL` default as `core auth login`,
`core/src/cmd/auth/config.rs:18`), or `core mwinit login --token <existing-jwt>` to
just re-cache an already-issued token.
- **Storage**: `~/.talos/.riotpiao-auth` — a new, separate path from `core auth
login`'s `~/.cache/talos/authentik_id_token`, so the two tools never collide.
- **Usage**: `curl -H "Authorization: Bearer $(cat ~/.talos/.riotpiao-auth)" -H 'X-Service: ...' https://api.riotpiao.com/ ...`.
- **Lifecycle**: `core mwinit status` / `core mwinit clear` (mirroring `core auth
status`/`clear`); gateway behavior on expired token is 401 + `WWW-Authenticate`,
RFC 9457 problem+json.
---
## 6. `X-Service: memory` — poimen-memory
### Existing System
`poimen-memory` (`~/workplace/Poimen/memory`) is real and **already deployed** —
namespace `poimen`, Service `poimen-memory.poimen.svc.cluster.local:8080`, 2 pods
`Running`. Its own `README.md` claims "design complete, no code yet, 0/37 tasks",
which is badly stale: `memory-tasks/M3.5.*.md` (per that repo's
`DESIGN.md`) show the HTTP API layer (M3.5 phase) is done — `GET /memory/projects`,
`GET /memory/projects/{id}/status`,
`GET /memory/query?query=...&level=L1,L2&project=...&limit=...`,
`POST /memory/ingest`, `GET /memory/skills`, `GET /memory/skills/{name}` all exist and
are tested. It is not currently reachable from `api.riotpiao.com` — the live ingress
has only a `/` catch-all to `api-gateway`, no `/memory` path.
Three more endpoints are **specced in `DESIGN.md` but not confirmed built** —
`GET /memory/projects/{id}/notes` (M3.5.6, listed in the task table but not in the
"is done" set above), `POST /memory/context` (M3.7, tier-1/2/3 lookup — depends on
M3.7.7/M3.7.8 signature+symptom work, not started), and `POST /memory/nodes/by-git`
/`by-commit`/`by-author` (M3.5.9, explicitly optional in that repo's own task
board). These get CR entries below so the schema is defined once, but the
gateway-side task for each is **blocked on upstream**, not just on our own code —
see §7's status column.
It also currently does its own auth: an `apikey:` header check, exact-match against a
stored key, implemented in the app itself
(`tasks/M3.5.1-http-server.md:36-39`) — independent of Kong, which its own
architecture diagram assumed sat in front but never actually required for this check
to work. There is **no NetworkPolicy** on it today (`k8s/` has none) — the apikey
check is the *only* thing standing between it and any pod in the cluster.
### Future System
Per explicit decision: drop poimen-memory's own `apikey` check entirely and gate it
through Authentik instead, with separate read/write capabilities — using the
per-method `auth.capability` override introduced in §1.
```yaml
apiVersion: gateway.riotpiao.com/v1
kind: ServiceAdapter
metadata:
name: memory
namespace: api
spec:
serviceName: memory # matches X-Service: memory
upstream:
url: http://poimen-memory.poimen.svc.cluster.local:8080
timeoutSeconds: 15
auth:
required: true
capability: memory:read # default for every resource below
retryable: true
resources:
- name: query # X-Resource: query — "specific documents belonging to a project"
methods:
- verb: GET
upstreamPath: /memory/query
- name: skill # X-Resource: skill or skill/{name}
methods:
- verb: GET
upstreamPath: /memory/skills
- verb: GET
upstreamPath: /memory/skills/{name}
- name: project # X-Resource: project or project/{id}/status
methods:
- verb: GET
upstreamPath: /memory/projects
- verb: GET
upstreamPath: /memory/projects/{id}/status
- name: ingest # X-Resource: ingest — write path
methods:
- verb: POST
upstreamPath: /memory/ingest
auth:
capability: memory:write # overrides the memory:read default
```
```bash
curl -X GET https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt-with-memory:read>' \
-H 'X-Service: memory' \
-H 'X-Resource: query' \
-G --data-urlencode 'query=why did requests over 10KB fail' \
--data-urlencode 'project=poimen' --data-urlencode 'level=L1,L2'
```
**Required companion changes, in this order — not optional, not cosmetic:**
1. Add a `NetworkPolicy` in namespace `poimen` restricting ingress on `poimen-memory`
to the `api` namespace's gateway pod only (mirrors the MinIO-protection rationale
from the very first version of this doc). This must land **before** step 2, or
there's a window where the service has no auth of any kind.
2. Remove the `apikey:` middleware from `poimen-memory` itself
(`tasks/M3.5.1-http-server.md`'s auth hook) — separate change in the
`~/workplace/Poimen/memory` repo, out of scope for `homelab-frontend` but a hard
prerequisite for this adapter being safe to expose.
3. Provision `memory:read` / `memory:write` as real Authentik scopes/capabilities via
the `X-Service: iam` adapter (§3) or `core mwinit`-issued tokens, so callers can
actually be granted one without the other.
---
## Verification
- `go build ./...` and existing tests (`internal/config/*_test.go`, plus new tests for
the rewritten `internal/temporal` / new `internal/serviceadapter`, `internal/iam`,
`internal/resilience`) pass.
- `curl` smoke tests per adapter (workflow/s3/sqs/iam/memory) against a dev gateway,
covering both the legacy `/workflow` body-action path (still mounted) and the new
root + verb+`X-Resource` path.
- `internal/serviceadapter/validate_test.go` (new): missing required field → 400 with
`reason: missing`; wrong type (e.g. `sql` sent as a number) → 400 with
`type_mismatch`; unlisted key under `strict: true` → 400 with `unknown_field`;
unlisted key under default (`strict: false`) → passes. Postgres `query` resource's
`requestSchema` (§1) is the fixture. Confirm a response-side mismatch does **not**
block the response and instead increments
`serviceadapter_response_schema_mismatch{service,resource}`.
- Apply the `memory` `ServiceAdapter` CR only after the `poimen` NetworkPolicy lands
and `poimen-memory`'s own `apikey` middleware is removed (§6's ordering). Confirm a
`memory:read`-scoped token can `GET X-Resource: query`/`skill`/`project` and a
`memory:write`-scoped token can `POST X-Resource: ingest`, and that a `memory:read`
token gets 403 on `ingest`.
- Commit the CRD + `Role`/`RoleBinding` to `k8s/`, push to `main`; confirm the existing
`api-gw` ArgoCD Application auto-syncs them with no `homelab` changes and no new
Application. Apply the example `postgres` CR; confirm the running gateway pod's
informer picks it up and serves `X-Service: postgres` within the watch/resync
latency — no gateway restart, no manual `kubectl` step.
- `core mwinit login` end-to-end against dev Authentik; confirm `~/.talos/.riotpiao-auth`
is written, a root-level `X-Service`-header call using it succeeds, and
`~/.cache/talos/authentik_id_token` is untouched.
-122
View File
@@ -1,122 +0,0 @@
# Integration Tests
Integration tests call the real deployed gateway to verify X-Service routing works end-to-end.
## Quick Start
### Local Test (requires running gateway)
```bash
# Terminal 1: Start the gateway
CONFIG_PATH=k8s/configmap.yaml go run ./cmd/gateway
# Terminal 2: Run tests
./scripts/test-integration.sh
```
### Cluster Test (production gateway)
```bash
GATEWAY_URL=https://api.riotpiao.com ./scripts/test-integration.sh
```
### Canary Deployment (scale to 1, test, scale back)
```bash
./scripts/test-canary.sh
# Or with custom settings:
NAMESPACE=api DEPLOYMENT=api-gateway REPLICAS=3 ./scripts/test-canary.sh
```
## Configuration
Create `.dev.test.local` (gitignored) with:
```bash
GATEWAY_URL=https://api.riotpiao.com
TEST_JWT_TOKEN=eyJ... # Real JWT from Authentik
SKIP_AUTH_TESTS=false
TEST_TIMEOUT=30
```
Or set env vars directly:
```bash
export GATEWAY_URL=https://api.riotpiao.com
export TEST_JWT_TOKEN=eyJ...
export SKIP_AUTH_TESTS=false
go test -tags integration -v ./internal/serviceadapter
```
## Test Matrix
| Test | Type | Expected | Notes |
|------|------|----------|-------|
| Health check | GET /healthz | 200 OK | Always works |
| SQS list-queues | GET X-Service: sqs | 200 or 502 | 502 if service unreachable |
| S3 list-objects | GET X-Service: s3 | 200 or 502 | 502 if service unreachable |
| Memory query | POST X-Service: memory | 200 or 502 | 502 if service unreachable |
| Service not found | GET X-Service: nonexistent | 404 | Routing error |
| Resource not found | GET X-Service: sqs X-Resource: invalid | 404 | Resource error |
| IAM with JWT | GET X-Service: iam + Bearer token | 200 or 502 | Requires valid JWT |
| Missing X-Service | GET (no header) | 404 | Routed to default handler |
## Canary Deployment Flow
```
Current: 3/3 replicas running
scale → 1/3 replicas
wait for pod ready
run integration tests
├─ PASS → scale → 3/3 replicas ✅
└─ FAIL → keep 1/3 for debugging ❌
```
## Running in CI
Add to `.gitea/workflows/ci.yaml`:
```yaml
- name: Integration Tests
run: |
GATEWAY_URL=https://api.riotpiao.com \
SKIP_AUTH_TESTS=true \
TEST_TIMEOUT=30 \
go test -tags integration -v ./internal/serviceadapter
```
## Debugging Failed Tests
If a test fails:
1. **Check pod logs:**
```bash
kubectl -n api logs -l app=api-gateway --tail=50
```
2. **Check service availability:**
```bash
kubectl get svc -A | grep -E "sqs|minio|authentik|poimen"
```
3. **Test service directly:**
```bash
kubectl -n sqs port-forward svc/management-service 9090:9090
curl http://localhost:9090/sqs/queues
```
4. **Check ConfigMap:**
```bash
kubectl get configmap api-gateway-config -n api -o yaml | grep -A50 "adapters:"
```
## Notes
- Auth tests are skipped by default (`SKIP_AUTH_TESTS=true`)
- To test with JWT, set `TEST_JWT_TOKEN` and `SKIP_AUTH_TESTS=false`
- Services in different namespaces may not be reachable from the gateway (NetworkPolicy)
- Canary tests expect `/healthz` endpoint to be available
-486
View File
@@ -1,486 +0,0 @@
# JWT vs Random Tokens - Which is Better?
Analyzing token types for Authentik + SOPS workflow.
---
## Quick Comparison
| Aspect | JWT | Random Token |
|--------|-----|--------------|
| **Stateless** | ✅ Yes | ❌ Requires lookup |
| **Self-contained** | ✅ Yes (claims inside) | ❌ Opaque |
| **Can verify locally** | ✅ Yes (signature) | ❌ Must call Authentik |
| **Size** | ⚠️ Larger (~500 bytes) | ✅ Smaller (~32 bytes) |
| **Immediate revocation** | ❌ Hard (token already signed) | ✅ Easy |
| **Contains user info** | ✅ Yes | ❌ No |
| **Standard OAuth2** | ⚠️ Optional (Bearer tokens) | ✅ Standard |
| **Good for audit** | ✅ User/groups embedded | ⚠️ Need to log lookup |
| **Git-friendly** | ✅ Sign commits | ✅ Sign commits |
---
## Use Case: Authentik + SOPS + Git Hooks
### Scenario 1: Developer Pushes Secret Update
```
Developer:
$ export SOPS_TOKEN=$token
$ sops secrets/default/db-creds.enc.yaml
$ git push
Git server hook runs:
├─ Receive token from commit metadata
├─ Need to validate token
└─ Two options:
```
**With JWT Token:**
```
Git hook:
1. Extract JWT from commit
2. Verify signature locally (no API call)
3. Read claims: {sub: "[email protected]", groups: ["k8s:secret-admin"]}
4. Check: is user in k8s:secret-admin group?
5. Allow/reject commit
Benefits:
✅ No need to call Authentik API
✅ Token self-validates
✅ Can check groups locally
✅ Instant verification
✅ Works offline
```
**With Random Token:**
```
Git hook:
1. Extract token from commit
2. Call Authentik API: GET /application/o/introspect/
└─ "Is this token valid?"
3. If valid, check groups: GET /api/v3/users/{id}/groups/
4. If admin, allow commit
Problems:
❌ Need API call on every git push
❌ Slow (network latency)
❌ Requires network connectivity
❌ If Authentik is down, can't push
❌ Rate limiting risk (many API calls)
```
---
## JWT Structure (What's Inside)
```json
// Decoded JWT payload
{
"sub": "[email protected]",
"email": "[email protected]",
"name": "John Doe",
"groups": [
"k8s:secret-admin",
"k8s:namespace:default:editor"
],
"iat": 1705336200,
"exp": 1705422600, // Expires in 24 hours
"iss": "https://authentik.riotpiao.com",
"aud": "secrets-management"
}
```
**In git commit:**
```
commit abc1234...
Author: [email protected] <[email protected]>
Date: Tue Jan 16 14:30:00 2025 +0000
chore(secret): update db-creds
X-SOPS-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Git hook can:**
```
1. Extract token from commit
2. Decode (no signature needed yet)
3. Read claims: sub, groups
4. Verify signature with Authentik's public key
5. Check groups locally
6. Decision: accept or reject
```
---
## JWT Benefits for This Use Case
### 1. Local Token Verification (No API Calls)
```typescript
// Git hook code (serverless, fast)
function validateJWT(token: string) {
const decoded = jwt.verify(token, PUBLIC_KEY);
// ✅ Done instantly
// ✅ No network call
// ✅ Can work offline
}
```
### 2. Embedded Claims (Audit Trail)
```
JWT contains:
├─ sub: who made the change
├─ groups: what permissions they had
├─ iat: when token was issued
└─ exp: when it expires
Git commit metadata automatically includes:
└─ Who, what groups, when
Complete audit trail without additional logging.
```
### 3. RBAC at Git Level
```typescript
// Git hook can enforce RBAC locally
function canPushSecrets(token: JWT) {
const groups = token.groups;
// Only k8s:secret-admin can push to secrets/
if (!groups.includes("k8s:secret-admin")) {
return false;
}
return true;
}
```
### 4. No Dependency on Authentik Being Up
```
If Authentik is temporarily down:
✅ Developers can still push (JWT validates locally)
✅ Works offline
❌ With random tokens: push fails
```
### 5. Temporal Can Verify Tokens Locally
```
Temporal workflow (token rotation):
└─ Reads old token from K8s
└─ Decodes JWT: check exp field
└─ "Is this token about to expire?"
└─ Generate new token
No API call needed to validate old token.
```
---
## Random Token Benefits
### 1. Standard OAuth2 Practice
```
Random tokens are what most OAuth2 providers use:
├─ Opaque (attacker doesn't know content)
├─ Standard bearer tokens
└─ What Authentik probably generates by default
```
### 2. Immediate Revocation
```
If token is compromised:
└─ Delete it from Authentik
└─ All git pushes instantly fail
With JWT:
└─ Token is still valid until exp time
└─ Attacker has until expiry
```
### 3. Smaller Size
```
JWT: ~500 bytes (base64 encoded)
Random: ~32 bytes (hex)
Difference:
✅ Slightly smaller in git metadata
❌ But negligible for practical purposes
```
### 4. Simpler Conceptually
```
Developers understand random tokens:
└─ "Here's a secret token, use it"
Developers don't understand JWT:
└─ "What's this long base64 string?"
└─ "Why do I need to decode it?"
```
---
## My Recommendation: JWT
Here's why for YOUR use case:
### 1. Git Hook Verification
```
With JWT:
Git hook validates token signature locally
└─ No Authentik API call
└─ Fast
└─ Works offline
└─ Scales infinitely
With random token:
Git hook calls Authentik API
└─ Network latency
└─ Authentik bottleneck
└─ Fails if Authentik down
└─ Rate limiting risk
```
### 2. Embedded RBAC
```
With JWT:
Git hook reads groups from token
└─ Only k8s:secret-admin can push to secrets/
└─ Enforced locally
└─ No additional database lookups
With random token:
Git hook calls Authentik API twice
└─ Introspect token
└─ Fetch groups
└─ Slower
```
### 3. Audit Trail
```
With JWT:
Git commit includes token claims
└─ User, groups, timestamp all in commit metadata
└─ Complete audit trail automatically
With random token:
Git commit has only token
└─ Need to log token → user mapping separately
└─ Additional logging overhead
```
### 4. Temporal Token Rotation
```
With JWT:
Temporal reads exp field
└─ "Token expires in 2 hours"
└─ No API call needed
With random token:
Temporal calls Authentik API
└─ "Is this token still valid?"
└─ Network call needed
```
---
## How to Get JWT from Authentik
### Option 1: OpenID Connect (Standard)
```
Authentik already supports OIDC:
Developer requests token:
$ curl -X POST https://authentik.../application/o/token/ \
-d "grant_type=password&username=user&password=pwd&scope=openid profile groups"
Response:
{
"access_token": "eyJhbGc...", ← JWT
"token_type": "Bearer",
"expires_in": 86400,
"id_token": "eyJhbGc..."
}
```
### Option 2: OAuth2 with JWT
```
Authentik OAuth2 provider settings:
├─ Enable: "Use claims-based access tokens"
├─ Token claims: username, email, groups, preferred_username
└─ Signature algorithm: RS256
Result: Access token is now JWT instead of random.
```
---
## Implementation: SOPS + JWT
```bash
# Developer gets JWT token from Authentik
$ token=$(oidc-client get-token)
$ echo $token # base64 encoded JWT, not random string
# SOPS commits with JWT
$ export SOPS_TOKEN=$token
$ sops secrets/default/db-creds.enc.yaml
$ git add . && git commit -m "update" && git push
# Git hook receives commit
$ git hook:
# 1. Extract SOPS_TOKEN from commit metadata
# 2. Decode JWT (no API call)
# 3. Verify signature with Authentik public key
# 4. Read claims: groups
# 5. Check: user in k8s:secret-admin?
# 6. Accept or reject push
```
---
## Comparison in Your Architecture
### Random Token Path
```
Developer → Authentik (get random token)
→ SOPS (encrypt + commit)
→ Git push
→ Git hook (calls Authentik API to validate)
→ ArgoCD
→ K8s
Network calls: 2 (Authentik for token + git hook validation)
```
### JWT Token Path
```
Developer → Authentik (get JWT token)
→ SOPS (encrypt + commit)
→ Git push
→ Git hook (validates JWT locally, no API call)
→ ArgoCD
→ K8s
Network calls: 1 (only Authentik for token issuance)
```
**Winner: JWT (fewer API calls)**
---
## Concerns with JWT
### Q: Can JWT be revoked immediately?
**A:** Not easily. Options:
```
1. Token expires (set exp field to short time, e.g., 1 hour)
└─ Temporal rotates daily anyway
2. Blacklist approach (store revoked JWTs)
└─ Git hook checks blacklist before validating
└─ But defeats purpose of stateless tokens
3. Accept that JWT lives until expiry
└─ In your case: Temporal rotates daily
└─ So max time a compromised token is valid: 24 hours
└─ Acceptable for homelab
```
### Q: Are JWTs vulnerable?
**A:** No, if configured right:
```
✅ Signed with RS256 (Authentik public key)
✅ Signature verified on every use
✅ Expiry checked (can't use expired token)
✅ Can't be forged (no private key to sign with)
```
### Q: Does Authentik support JWT?
**A:** Yes, fully:
```
Authentik has:
✅ OIDC (returns JWT id_token + access_token)
✅ OAuth2 with claims (can return JWT)
✅ Configuration for JWT signing algorithm
✅ Public key endpoint for verification
```
---
## Final Recommendation
### Use JWT if:
```
✅ You want fast git hook validation (no API calls)
✅ You want embedded RBAC (groups in token)
✅ You want complete audit trail (claims in metadata)
✅ You want resilience (works if Authentik down)
✅ You want to scale (no API bottleneck)
```
### Use Random Token if:
```
✅ Immediate revocation is critical
✅ You prefer standard OAuth2 approach
✅ Developers shouldn't see token contents
✅ Simplicity is more important than features
```
---
## My Advice
**Go with JWT.**
Here's why:
1. Authentik supports it natively
2. Git hook verification is instant (no API calls)
3. Embedded claims give you free audit trail
4. Works offline (resilient)
5. Perfect for homelab scale
**Setup:**
1. Configure Authentik OIDC provider
2. Enable "Use claims-based access tokens" option
3. Add groups to token claims
4. Developers get JWT instead of random string
5. Git hook validates JWT locally (no API call)
**Code example for git hook:**
```bash
#!/bin/bash
# .git/hooks/update (server-side)
token=$(git log -1 --pretty=%B | grep "X-SOPS-Token" | cut -d' ' -f2)
# Verify JWT signature
jwt verify $token --key /path/to/authentik/public.key
# Decode and check groups
groups=$(jwt decode $token | jq .groups)
if [[ ! "$groups" =~ "k8s:secret-admin" ]]; then
echo "Access denied: not in k8s:secret-admin group"
exit 1
fi
# Allow push
exit 0
```
That's it. Simple, elegant, no redundancy.
-499
View File
@@ -1,499 +0,0 @@
# LLM Tool Calls Testing Guide
This guide shows how to test the gateway with LLM tool calling (function calling) across different APIs.
## What's Tested
The gateway fully supports tool calling for:
- **OpenAI API** (`/v1/chat/completions`) - OpenAI, DeepSeek, etc.
- **Anthropic API** (`/llm/v1/messages`) - Claude models
- **Custom APIs** - Any LLM that supports tool definitions and responses
### Test Coverage
```
✅ OpenAI-style tool calling
✅ Streaming tool calls (SSE with tool_use blocks)
✅ Multi-turn conversations with tool results
✅ Parallel tool calls (multiple tools at once)
✅ Anthropic tool_use format
✅ Complex nested tool arguments
```
---
## Quick Start: Run Tests Locally
```bash
cd /Users/rockliang/workplace/homelab-frontend
# Run all tool call tests
go test ./internal/proxy/... -run "Tool" -v
# Or run with race detector (recommended)
go test -race ./internal/proxy/... -run "Tool" -v
# Expected output: 6 tests, all passing
```
## Test Scenarios
### 1. OpenAI-Style Tool Calling
**What it tests:**
- Request with tool definitions reaches upstream unmodified
- Upstream can return tool_calls in response
- Response with tool_calls passes through to client
**Test code:**
```go
// Request
{
"model": "reasoning",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {...}
}
}]
}
// Response (from upstream)
{
"choices": [{
"message": {
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"San Francisco\"}"
}
}]
}
}]
}
```
**Run:**
```bash
go test ./internal/proxy/... -run TestToolCallOpenAIStyle -v
```
---
### 2. Streaming Tool Calls
**What it tests:**
- Tool calls can be streamed (SSE format)
- Multiple chunks arrive with tool_call deltas
- Stream completes with `[DONE]` sentinel
**Test code:**
```
Chunk 1: {"delta": {"role": "assistant"}, ...}
Chunk 2: {"delta": {"tool_calls": [{"id": "call_123", "function": {...}}]}, ...}
Chunk 3: {"delta": {}, "finish_reason": "tool_calls"}
Chunk 4: [DONE]
```
**Run:**
```bash
go test ./internal/proxy/... -run TestToolCallStreaming -v
```
---
### 3. Multi-Turn Conversation with Tool Results
**What it tests:**
- Client can send previous assistant's tool_calls back
- Tool result can be sent as a "tool" role message
- Assistant responds with final answer using tool result
**Flow:**
```
Turn 1: User asks → LLM decides to call tool
Turn 2: Client sends tool result → LLM generates final answer
```
**Test code:**
```go
// Turn 1 Request
{
"model": "reasoning",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [...]
}
// Turn 1 Response (tool_calls)
{
"message": {
"tool_calls": [{
"id": "call_abc",
"function": {"name": "get_weather", "arguments": "..."}
}]
}
}
// Turn 2 Request (with tool result)
{
"model": "reasoning",
"messages": [
{"role": "user", "content": "What's the weather?"},
{"role": "assistant", "tool_calls": [...]},
{"role": "tool", "content": "{\"temperature\": 22, \"condition\": \"cloudy\"}"}
],
"tools": [...]
}
// Turn 2 Response (final answer)
{
"message": {
"content": "The weather in San Francisco is 22°C and cloudy."
}
}
```
**Run:**
```bash
go test ./internal/proxy/... -run TestToolCallMultiTurn -v
```
---
### 4. Parallel Tool Calls
**What it tests:**
- LLM can request multiple tools in one response
- Gateway preserves all tool_calls
- Client can execute them in parallel
**Test code:**
```go
// Single response with 3 tool_calls
{
"message": {
"tool_calls": [
{"id": "call_1", "function": {"name": "get_weather", "arguments": "{\"location\":\"New York\"}"}},
{"id": "call_2", "function": {"name": "get_weather", "arguments": "{\"location\":\"London\"}"}},
{"id": "call_3", "function": {"name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"}}
]
}
}
```
**Run:**
```bash
go test ./internal/proxy/... -run TestParallelToolCalls -v
```
---
### 5. Anthropic Tool Use Format
**What it tests:**
- Different tool format: Anthropic uses `tool_use` blocks instead of `tool_calls`
- Gateway handles both formats transparently
- Tools are sent with `tools` parameter
**OpenAI format:**
```json
{"tool_calls": [{"type": "function", "function": {...}}]}
```
**Anthropic format:**
```json
{"content": [
{"type": "text", "text": "..."},
{"type": "tool_use", "id": "...", "name": "...", "input": {...}}
]}
```
**Test code:**
```go
// Request
{
"model": "claude",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"name": "get_weather",
"description": "Get weather",
"input_schema": {...}
}]
}
// Response (Anthropic format)
{
"content": [
{"type": "text", "text": "I'll check the weather..."},
{"type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {...}}
],
"stop_reason": "tool_use"
}
```
**Run:**
```bash
go test ./internal/proxy/... -run TestAnthropicToolUse -v
```
---
### 6. Complex Nested Tool Arguments
**What it tests:**
- Tool arguments can be complex JSON structures
- Nested objects, arrays, and deeply nested data preserved
- No argument modification or parsing
**Test code:**
```json
{
"function": {
"name": "create_event",
"arguments": {
"title": "Team Meeting",
"time": "2025-08-20T14:00:00Z",
"attendees": [
{"name": "Alice", "email": "[email protected]"},
{"name": "Bob", "email": "[email protected]"}
],
"location": {
"address": "123 Main St",
"city": "San Francisco",
"country": "USA"
},
"tags": ["important", "recurring"]
}
}
}
```
**Run:**
```bash
go test ./internal/proxy/... -run TestComplexToolArguments -v
```
---
## Running Against Real LLMs
### With Local Stubs (Current)
Tests use mock HTTP servers, so they run instantly:
```bash
go test ./internal/proxy/... -run "Tool" -v
# All 6 tests complete in ~220ms
```
### With Real Upstreams (Future)
Once you have real LLM services running, update the config:
```yaml
# k8s/configmap.yaml
models:
- name: "reasoning"
address: "reasoning-predictor.llm-serving:80" # Real upstream
- name: "claude"
address: "claude-api.anthropic.com:443" # Real Anthropic
```
Then use the gateway normally:
```bash
# Terminal 1: Start gateway
export CONFIG_PATH=config.yaml
go run ./cmd/gateway
# Terminal 2: Test with real LLM
curl -X POST http://localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{
"model": "reasoning",
"messages": [{"role": "user", "content": "What color is the sky?"}],
"tools": [{
"type": "function",
"function": {
"name": "search",
"description": "Search the internet",
"parameters": {"type": "object", "properties": {}}
}
}]
}'
```
---
## Gateway Behavior with Tool Calls
### Request Path
```
Client Request
Body-based dispatch (find model)
Look up upstream address
Forward request unmodified (including tools)
Upstream LLM processes tools
```
### Response Path
```
Upstream Response (with tool_calls or tool_use)
Stream unbuffered if streaming
Return to client exactly as received
```
### Key Properties
1. **No Rewriting**: Tool definitions and responses pass through unmodified
2. **Format Agnostic**: Both OpenAI `tool_calls` and Anthropic `tool_use` work
3. **Streaming Safe**: Tool calls stream incrementally without buffering
4. **Nested Structures**: Complex JSON arguments fully preserved
---
## Common Tool Call Patterns
### Pattern 1: Sequential Tool Use
```
Client → LLM (please use search tool)
← LLM (tool_calls: [search(...)])
Client → (execute search, send results)
Client → LLM (here are search results)
← LLM (final answer)
```
### Pattern 2: Parallel Tool Calls
```
Client → LLM (check weather in 3 cities)
← LLM (tool_calls: [get_weather(NY), get_weather(LA), get_weather(SF)])
Client → (execute all 3 in parallel)
Client → LLM (here are all results)
← LLM (summary)
```
### Pattern 3: Tool Result Formatting
```
Client receives tool_calls with:
- id: unique identifier
- function.name: tool name
- function.arguments: JSON string (always a string, not parsed object)
Client sends back:
- role: "tool"
- content: result JSON string
- tool_call_id: matches the original call id
```
---
## Verification Checklist
- [x] OpenAI-style tool definitions forward to upstream
- [x] Tool calls in response reach client unmodified
- [x] Streaming tool calls arrive incrementally
- [x] Multi-turn conversations preserve tool context
- [x] Parallel tool calls all included in response
- [x] Anthropic tool_use format works
- [x] Complex nested arguments preserved
Run all:
```bash
go test ./internal/proxy/... -run "Tool" -v --race
```
Expected: 6/6 passing, race detector clean
---
## Integration with Other Phases
### Phase 2.9: Anthropic Dialect
Currently, Anthropic tool calls work through the generic route handler. Phase 2.9 will add a dedicated `/llm/v1/messages` endpoint with full Anthropic-specific handling.
### Phase 2.13: Error Handling
Tool call errors (unknown tool, parsing errors) will have proper error responses in both OpenAI and Anthropic formats.
### Phase 3: Authentication
Tool calls work with all authentication methods (bearer tokens, API keys) - no special handling needed since tools are just part of the message payload.
### Phase 4: Rate Limiting
Tool calling counts the same as regular chat requests. Rate limits apply per conversation, not per tool call.
---
## Debugging Tool Calls
### Check if tool definitions reach upstream:
```bash
# Enable request logging
go test ./internal/proxy/... -run TestToolCallOpenAIStyle -v 2>&1 | grep -A5 "tool"
```
### Verify tool response format:
```bash
# Extract and pretty-print response
curl -X POST http://localhost:8080/v1/chat/completions ... | jq '.choices[0].message.tool_calls'
```
### Test streaming tool calls:
```bash
curl -N http://localhost:8080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{..., "stream": true, "tools": [...]}'
# Should see incremental chunks with tool_use deltas
```
---
## FAQ
**Q: Do I need to modify the gateway code to support tool calls?**
A: No. Tool calls are just JSON in the request/response body. The gateway forwards them unchanged.
**Q: What if the LLM doesn't support tools?**
A: The tool definitions are simply ignored. The gateway doesn't validate or enforce tool support.
**Q: Can I mix OpenAI and Anthropic tool formats?**
A: Not in the same request. OpenAI clients expect `tool_calls`, Anthropic clients expect `tool_use` blocks. The upstream API determines the format.
**Q: How are tool arguments limited?**
A: By the per-route `maxBodySize` config. Complex nested arguments count toward that limit.
**Q: Can tool calls be streamed?**
A: Yes! SSE streaming fully supports tool calls. They arrive in delta chunks like text tokens.
---
## Next Steps
1. **Run tests locally:**
```bash
go test ./internal/proxy/... -run "Tool" -v
```
2. **Deploy to cluster:**
See [CLUSTER_REPO_SETUP.md](./CLUSTER_REPO_SETUP.md)
3. **Test against real LLMs:**
Update config with real upstream addresses, restart gateway
4. **Phase 2.9:** Implement Anthropic dialect handler for `/llm/v1/messages`
5. **Phase 4:** Add tool call budgeting and rate limits
-367
View File
@@ -1,367 +0,0 @@
# homelab-frontend — Requirements
The contract for the Go API gateway that replaces Kong OSS on `*.riotpiao.com`.
Companion documents:
- [docs/adr/ADR-0001-retire-kong-for-go-gateway.md](docs/adr/ADR-0001-retire-kong-for-go-gateway.md) — why Kong is being retired
- [docs/MIGRATION-kong.md](docs/MIGRATION-kong.md) — exact inventory of what Kong does today and the cutover order
- [tasks/INDEX.md](tasks/INDEX.md) — the task board
All cluster facts below were verified live against context `admin@homelab-cluster`
on 2026-08-19. Re-verify before relying on any number.
---
## 0. Invariants
These hold for every surface. A change that breaks one of these is a design change,
not an implementation detail.
- **G1** — ingress-nginx owns TLS and the edge. The gateway never terminates TLS.
- **G2** — The gateway holds no Kubernetes credentials. It proxies to services that
do. Cluster-read permissions stay in atlas, out of the public edge process.
- **G3** — Public surfaces use standard protocol shapes. If an OpenAI SDK cannot
call it unmodified, the design is wrong.
- **G4** — Streaming is unbuffered end to end, and a client disconnect cancels the
upstream request rather than orphaning it.
- **G5** — Authentication is Bearer-token, validated against Authentik via JWKS
fetched at runtime. No pinned public keys, no rotation runbook.
- **G6** — Every route's timeout, body cap and concurrency limit is explicit in
configuration. No silent defaults.
- **G7** — All deployment flows through git and Argo. No `kubectl apply`, no
`helm upgrade`, no local `terraform apply`.
---
## 1. Runtime and configuration
### 1.1 Process
Single static Go binary. Reads configuration at startup, serves HTTP, exits cleanly
on SIGTERM after draining in-flight requests.
Must run with **no cluster, no kubeconfig and no credentials** so that behaviour can
be verified in a closed loop before touching live traffic. Upstreams are
configuration; pointing them at local stubs is the entire mechanism. This is a hard
requirement, not a convenience — see §7.
### 1.2 Configuration
Route and upstream configuration is declarative and loaded at startup. It must
express, per upstream: address, path rewrite, connect/read/write timeouts, maximum
body size, and whether the route requires authentication.
Configuration errors fail startup loudly. A gateway that starts with a silently
dropped route is worse than one that refuses to start.
**Configuration lives in git**, mounted as a ConfigMap and synced by Argo. Not a
CRD. A CRD would require the gateway to watch the API server, which needs RBAC and
contradicts G2 — and CRD-driven routing is precisely the indirection being retired
with Kong, where the routing table was split across six `KongPlugin` CRs, seven
Ingresses and a Helm values file.
A CRD earns its keep when someone other than the repo owner must register routes.
That is not true here. If it becomes true, the additive answer is a controller that
renders this same ConfigMap — the gateway stays credential-free either way.
### 1.3 Health
- `GET /healthz` — liveness, no upstream checks, always cheap.
- `GET /readyz` — readiness; may fail while configuration is invalid or JWKS has
never been successfully fetched.
Neither requires authentication.
---
## 2. Proxy core
### 2.1 Reverse proxying
Standard reverse proxy to configured upstreams. Connection reuse across requests.
Hop-by-hop headers stripped correctly. `X-Forwarded-*` set from the nginx-supplied
values, not fabricated.
### 2.2 Streaming
SSE and chunked responses pass through without buffering. Tokens must reach the
client as the upstream emits them, not on completion.
WebSocket upgrade must work — `agent-pod/console` depends on it.
### 2.3 Disconnect propagation
When a client disconnects, the upstream request is cancelled immediately. This is
load-bearing: an orphaned generation holds a vLLM sequence slot, and there are only
eight in the cluster.
### 2.4 Timeouts
Per-route, explicit. Current Kong values, which are deliberate and must be preserved
unless changed knowingly:
| Route class | connect | read | write |
|---|---|---|---|
| chat | 10s | 1h | 1h |
| embeddings, rerank | 10s | 10m | 10m |
The 1-hour read timeout exists because a 32B model on a Volta GPU routinely exceeds
60s. Any shorter application-level cap must be enforced *by the gateway's own
logic*, not by shortening the proxy timeout — otherwise long legitimate generations
truncate mid-stream.
---
## 3. LLM surfaces — `api.riotpiao.com`
Two protocol dialects, permanently. Both translate into one dialect-neutral canonical
request, and both pass through **one shared slot controller** before reaching a
predictor.
| Prefix | Dialect | Primary client |
|---|---|---|
| `/v1/*` | OpenAI-compatible | pi, generic OpenAI SDKs |
| `/llm/*` | Anthropic Messages | the riotpiao frontend (first-party only) |
```
/v1/* (OpenAI) /llm/* (Anthropic)
| |
+-----------+------------+
v
canonical request dialect-neutral
v
slot controller keyed by UPSTREAM, not by route
v
reasoning-predictor / ornith-predictor
```
**The slot controller is keyed by upstream and shared across dialects.** Per-dialect
semaphores are wrong: the 8 sequence slots are physical, so two independent gates
would each believe they were within budget while together exceeding it. Requests from
both surfaces contend for the same slots and the same queue, in arrival order.
Dispatch, budgets, logging and metrics all operate on the canonical request. Adding a
third dialect later must not require touching the controller.
### 3.1 Body-based model dispatch
`POST /v1/chat/completions` selects its upstream from the request body's `model`
field. This is the single most important requirement in this document: it is the
capability Kong OSS lacked, and the reason the gateway exists.
Unknown or missing `model` is a client error with a useful message listing valid
values — not a 500, and not a silent fallback to a default model.
### 3.2 Upstream map
Verified live. `served-model-name` values are what clients send.
| `model` in body | Upstream Service | Engine |
|---|---|---|
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B |
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama |
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama, same pods |
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI |
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI |
`reasoning` runs 2 replicas × `--max-num-seqs=4` = **8 concurrent sequence slots
total**, `--max-model-len=16384`, `--reasoning-parser=deepseek_r1`,
`--enable-auto-tool-choice --tool-call-parser=hermes`.
Note `ornith:35b` and `qwen2.5:3b-instruct` share pods; both stay resident via
`OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=-1`, so dispatching between them
does not trigger a model swap.
### 3.3 Path rewriting
Upstreams expect canonical paths. `/v1/chat/completions` and `/v1/embeddings` pass
through unchanged. Rerank is the exception: TEI serves `/rerank`, not `/v1/rerank`,
so that route rewrites.
### 3.4 Legacy path aliases
`/v1/{reasoning,ornith,qwen}/chat/completions` must keep working during cutover —
pi is a live caller. They behave exactly as the canonical endpoint with `model`
forced to the corresponding value, overriding whatever the body says.
These are temporary. They exist to make the cutover reversible, and are removed once
callers have migrated.
### 3.5 `GET /v1/models`
Derived from the configured upstream map, never hardcoded. Kong served a static
list, and its own manifest flags that the list can drift from what the engines
actually serve. The gateway's list must be incapable of disagreeing with what
routing will accept.
OpenAI list shape: `{"object":"list","data":[{"id","object":"model","owned_by","created"}]}`.
### 3.6 Behaviour to preserve
Verified against the live endpoint:
- The upstream returns `reasoning_content` separately from `content` for the
`reasoning` model. Pass both through untouched.
- Tool calling works with explicit `tool_choice`, and is unreliable with
`tool_choice: auto` on the R1-distill model. The gateway does not compensate for
this — it is a model property, not a gateway concern. Do not add retries or
rewriting to work around it.
---
## 4. Authentication — Authentik
### 4.1 Current state
**The model API is unauthenticated today.** Confirmed live: `/v1/reasoning/chat/completions`
answers with no credentials.
Kong's `key-auth` was retired because it accepts a raw `apikey:` header but rejects
`Authorization: Bearer`, which hard-blocks every OpenAI-compatible client. See
`~/workplace/homelab/k8s/apps/api/model-auth.yaml`.
### 4.2 Requirement
Bearer tokens in `Authorization`, validated against Authentik
(`https://authentik.riotpiao.com`) by fetching and caching JWKS at runtime.
Key rotation must be handled by refetching JWKS, not by pinned PEMs. The
pinned-`rsa_public_key` approach in `AUTH-PLAN.md` and its rotation runbook exist
only to route around a Kong OSS limitation and must not be carried forward.
Service accounts obtain tokens via `client_credentials` against Authentik's token
endpoint.
### 4.3 Rollout
Auth ships behind a flag, defaulting off, and is enabled deliberately.
Enabling it breaks every current caller until they hold a token — pi included, whose
`models.json` currently sends a `customHeaders: {apikey: ...}` block that will need
replacing with a Bearer token.
### 4.4 Authorization
Beyond authentication, a token must be checked for the right to invoke the
capability it is calling. A token minted for queue access should not invoke a GPU.
---
## 5. Rate limiting and budgets
No `rate-limiting` plugin exists anywhere in the cluster today — this is net new
work, not a migration. Verified: six Kong plugins exist, none is `rate-limiting`.
Requirements, in priority order:
1. **GPU slot protection.** `reasoning` has 8 total sequence slots. Concurrent
in-flight requests to it must be capped below that, leaving operator headroom.
Excess requests queue up to a bounded depth, then are rejected with a retryable
status.
2. **Per-caller budgets.** Identified callers get a request budget over a window.
3. **Body size caps**, per route.
Rejections use RFC 9457 `application/problem+json` and set `Retry-After` where a
retry time is knowable.
---
## 6. Observability
Kong's cluster-wide `prometheus` plugin is being retired. The gateway must expose at
least equivalent signal or observability regresses at cutover: request rate,
latency, status codes, bandwidth, and upstream health, labelled by route and
upstream.
Gateway-specific signals that Kong could not provide, and which are the reason for
several requirements above: in-flight requests per upstream, queue depth, GPU slot
occupancy, and rejections by reason.
Structured logging. Every rejected request is logged with the reason. No secrets, no
tokens, no request bodies in logs.
---
## 7. Local development and verification
An agent must be able to close a change/verify loop with no cluster, no kubeconfig
and no credentials. This is a hard requirement because it determines whether work can
proceed unattended.
Concretely: it must be possible to start the gateway locally, point it at stub
upstreams, issue requests, and assert on the responses — including streaming
responses and client disconnects.
Verification of any API-shaped task means asserting on the **actual HTTP response**:
status, headers, and body. "It compiles" and "it starts" are not verification.
Parity with Kong is verified by comparing gateway and Kong responses for the same
request, for every route in the migration inventory, before cutover.
---
## 8. Deployment
Container: distroless or scratch, `runAsNonRoot`, read-only root filesystem, all
capabilities dropped, `seccompProfile: RuntimeDefault`, no shell.
Image tags are commit SHAs, never `:latest` — Argo's `selfHeal` cannot roll out a
mutable tag reliably.
NetworkPolicy: egress only to the upstreams it proxies plus Authentik; ingress from
`ingress-nginx` only.
Deployed as an Argo Application in the `homelab-root` GitOps repo. Verified live:
zero Argo Applications anywhere in the cluster source from any Forgejo URL, so
`github.com/Riotpiaole/riotpiao.homelab.com` is authoritative.
---
## 9. Capability surface — path-based
Every capability is a path prefix on the single host `api.riotpiao.com`. One DNS
record, one Cloudflare tunnel hostname, one nginx Ingress, one Service.
| Prefix | Backs onto | Status |
|---|---|---|
| `/v1/*` | `llm-serving` predictors | v1 — **reserved**, see below |
| `/sqs/*` | kmsvc management-service, Kafka/Strimzi (`sqs` ns) | future |
| `/workflow/*` | Temporal (`temporal` ns) | future |
| `/cluster/*` | atlas, separate repo `riotpiao-backend` | future |
| `/db/*` | CloudNativePG, MinIO, monitoring reads | future |
**`/v1/*` is reserved for the OpenAI-compatible surface and nothing else.** G3 pins
it: an SDK expects `/v1/chat/completions` at the base URL, so that prefix can never
be repurposed or nested. Every other capability gets its own prefix that cannot
collide with a current or future OpenAI path.
Subdomains are deliberately *not* used. Paths keep hostname configuration to a
single entry — and hostname configuration is the demonstrated failure mode here, as
the unresolved apex 403 shows. Promoting a prefix to its own subdomain later is an
additive host rule that can run alongside the path; the reverse is not, because
clients hardcode hostnames.
Notes carried from the cluster:
- Temporal namespace registration is automatic via queue-operator, never manual.
- `management-service` already exposes gRPC at `kmsvc.riotpiao.com`; the `/sqs`
prefix is a new surface, not a replacement for it.
- atlas keeps its own informers and RBAC. The gateway proxies to it and holds no
cluster credentials of its own (G2).
- `/db/*` read surfaces need particular care — see G2 before designing them.
---
## 10. Known cluster facts worth not rediscovering
- `kmsvc-redis-master.sqs:6379` has **no authentication**`ALLOW_EMPTY_PASSWORD=yes`,
TLS off. Any workload with network reach has full unauthenticated read/write. A
NetworkPolicy is the only control.
- `reasoning-predictor` listens on port **80**, not 8080.
- `prometheus-operated.monitoring` is **headless** (ClusterIP None) — egress policies
need pod selectors, not ClusterIPs.
- `agent-pod/console` is publicly routed, unauthenticated, accepts free-form prompts
into a shell-capable container, and serves a WebSocket. Putting it behind gateway
auth is a security fix, not merely a port.
- Eight `*.example.com` hosts exist on istio-class Ingresses in `llm-serving`. KServe
defaults, not public, out of scope — do not mistake them for gateway routes.
-176
View File
@@ -1,176 +0,0 @@
# Temporal gRPC Integration - Migration Status
## Overview
Temporal REST ↔ gRPC bridge is being implemented. Client sends HTTP JSON → gateway translates to gRPC → Temporal server responds.
## Implementation Status
### Phase 1: Core Workflow Operations ✅ WIRED
- **START_WORKFLOW** ✅ gRPC: `StartWorkflowExecution`
- **DESCRIBE_WORKFLOW** ✅ gRPC: `DescribeWorkflowExecution`
- **LIST_WORKFLOWS** ⏳ TODO (requires pagination logic)
- **GET_WORKFLOW_HISTORY** ⏳ TODO
- **SIGNAL_WORKFLOW** ⏳ TODO
- **QUERY_WORKFLOW** ⏳ TODO
- **CANCEL_WORKFLOW** ⏳ TODO
- **TERMINATE_WORKFLOW** ⏳ TODO
- **RESET_WORKFLOW** ⏳ TODO
- **UPDATE_WORKFLOW** ⏳ TODO
### Phase 2: Activity Operations ⏳ NOT IMPLEMENTED
- HEARTBEAT_ACTIVITY
- COMPLETE_ACTIVITY
- FAIL_ACTIVITY
**Note:** Activity operations require different error handling (task tokens, etc.). See operations_grpc.go for reference.
### Phase 3: OperatorService Operations ⏳ NOT IMPLEMENTED
Requires separate gRPC stub. Currently:
- LIST_NAMESPACES → 501 NOT_IMPLEMENTED
- DESCRIBE_NAMESPACE → 501 NOT_IMPLEMENTED
- CREATE_NAMESPACE → 501 NOT_IMPLEMENTED
- UPDATE_NAMESPACE → 501 NOT_IMPLEMENTED
- DELETE_NAMESPACE → 501 NOT_IMPLEMENTED
- LIST_SEARCH_ATTRIBUTES → 501 NOT_IMPLEMENTED
- ADD_SEARCH_ATTRIBUTES → 501 NOT_IMPLEMENTED
- LIST_TASK_QUEUES → 501 NOT_IMPLEMENTED
- GET_CLUSTER_INFO → 501 NOT_IMPLEMENTED
- LIST_CLUSTER_MEMBERS → 501 NOT_IMPLEMENTED
- GET_SYSTEM_INFO → 501 NOT_IMPLEMENTED
## Architecture
```
HTTP Request (JSON)
Handler.startWorkflow()
Converts to protobuf (workflowservice.StartWorkflowExecutionRequest)
gRPCClient.GetWorkflowServiceStub().StartWorkflowExecution(ctx, req)
Temporal Server (port 7233)
gRPC Response
Convert to JSON response map
HTTP 200 JSON
```
## Code References
- **handler.go**: HTTP ↔ gRPC translation layer
- `NewHandler()`: Creates gRPC connection via `NewGRPCClient()`
- `startWorkflow()`, `describeWorkflow()`: gRPC-wired operations
- Others: stubs or NOT_IMPLEMENTED
- **grpc_client.go**: Low-level gRPC connection management
- `NewGRPCClient()`: Dials Temporal at port 7233
- `GetWorkflowServiceStub()`: Returns `workflowservice.WorkflowServiceClient`
- `GetOperatorServiceStub()`: Returns `operatorservice.OperatorServiceClient`
- **operations_grpc.go**: Example gRPC implementations (reference for wiring)
- Shows payload marshaling patterns
- Shows error handling (gRPC status codes → HTTP 4xx/5xx)
## Next Steps (Phase 2)
1. Wire remaining WorkflowService operations (LIST, GET_HISTORY, SIGNAL, QUERY, etc.)
- All use same pattern: build protobuf request → call stub → map response to JSON
- Reference operations_grpc.go for exact patterns
2. Add OperatorService support (namespaces, cluster, search attrs)
- Create separate stub: `operatorServiceClient := NewGRPCClient().GetOperatorServiceStub()`
- Add methods to handler for each operation
3. Add Activity operations (heartbeat, complete, fail)
- Requires task token handling
- See operations_grpc_test.go for test patterns
## Build Status
```
go build ./cmd/gateway ✅ SUCCESS
```
## Testing
To test gRPC wiring locally:
```bash
# Start Temporal locally (if not running)
docker run -d -p 7233:7233 temporalio/auto-setup:latest
# Start gateway
go run ./cmd/gateway
# Test (in another terminal)
curl -X POST http://localhost:8080/workflow \
-H 'Content-Type: application/json' \
-d '{
"action": "START_WORKFLOW",
"namespace": "default",
"payload": {
"workflow_id": "test-1",
"workflow_type": "MyWorkflow",
"task_queue": "my-queue"
}
}'
# Should return
{
"success": true,
"action": "START_WORKFLOW",
"data": {
"workflow_id": "test-1",
"run_id": "abc123...",
"start_time": "2026-08-27T..."
}
}
```
## Key Implementation Details
### Protobuf Field Names
Temporal protobuf uses snake_case field names:
- `WorkflowId` not `WorkflowID`
- `RunId` not `RunID`
- `WorkflowType` (message) not `WorkflowTypeString`
- `TaskQueue` (message) not `TaskQueueName`
### Type Imports (from go.temporal.io/api)
```go
import (
"go.temporal.io/api/common/v1" // WorkflowExecution, WorkflowType, Payloads
"go.temporal.io/api/taskqueue/v1" // TaskQueue
"go.temporal.io/api/workflowservice/v1" // All Workflow* stubs
"go.temporal.io/api/operatorservice/v1" // Namespace/cluster stubs (not yet used)
)
```
### Payload Marshaling Pattern
```go
input := getMap(payload, "input")
if len(input) > 0 {
inputBytes, _ := json.Marshal(input)
req.Input = &common.Payloads{
Payloads: []*common.Payload{{Data: inputBytes}},
}
}
```
### Error Handling
- gRPC errors → map to HTTP status:
- `codes.NotFound` → 404
- `codes.InvalidArgument` → 400
- `codes.Unavailable` → 503
- others → 500
## Questions / Blockers
None currently. gRPC wiring is straightforward pattern-matching.
-1210
View File
File diff suppressed because it is too large Load Diff
-560
View File
@@ -1,560 +0,0 @@
# API Testing Guide
Quick reference for testing the homelab-frontend gateway API.
## Setup
```bash
# Set base URL
export GATEWAY="https://api.riotpiao.com"
# Or for local testing
export GATEWAY="http://localhost:8080"
```
---
## Quick Tests (Copy & Paste)
### 1. Health Checks ✅
```bash
# Liveness
curl $GATEWAY/healthz | jq .
# Readiness
curl $GATEWAY/readyz | jq .
```
**Expected**: Both return `{"status":"..."}` with HTTP 200
---
### 2. List Models ✅
```bash
curl $GATEWAY/v1/models | jq '.data[] | .id'
```
**Expected Output**:
```
"reasoning"
"ornith:35b"
"qwen2.5:3b-instruct"
"nomic-ai/nomic-embed-text-v2-moe"
"BAAI/bge-reranker-base"
```
---
### 3. Chat - Basic ✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "reasoning",
"messages": [
{"role": "user", "content": "What is 2+2?"}
]
}' | jq '.choices[0].message.content'
```
**Expected**: Model responds with an answer
---
### 4. Chat - Ornith Model ✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "ornith:35b",
"messages": [
{"role": "user", "content": "Hello"}
]
}' | jq '.choices[0].message.content'
```
**Expected**: Routes to ornith model, returns response
---
### 5. Chat - Qwen Model ✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "qwen2.5:3b-instruct",
"messages": [
{"role": "user", "content": "Hi"}
]
}' | jq '.choices[0].message.content'
```
**Expected**: Routes to qwen model, returns response
---
### 6. Chat - Unknown Model (Should Error) ❌→✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "gpt-4-turbo",
"messages": []
}' | jq '.'
```
**Expected**: HTTP 400 with problem+json:
```json
{
"type": "https://api.example.com/problems/unknown-model",
"title": "Unknown Model",
"status": 400,
"detail": "Model \"gpt-4-turbo\" is not available. See valid_models for available options.",
"valid_models": ["reasoning", "ornith:35b", ...]
}
```
---
### 7. Chat - Missing Model (Should Error) ❌→✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"messages": [{"role": "user", "content": "test"}]
}' | jq '.'
```
**Expected**: HTTP 400 with problem+json (missing model)
---
### 8. Chat - Streaming ✅
```bash
curl -N -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "reasoning",
"messages": [{"role": "user", "content": "count to 3"}],
"stream": true
}' | head -20
```
**Expected**:
- Multiple `data: {...}` lines (SSE chunks)
- Final `data: [DONE]`
- Chunks arrive incrementally (observable with `-N` flag)
---
### 9. Chat - Tool Calling ✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "reasoning",
"messages": [
{"role": "user", "content": "What is the weather in SF?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"}
},
"required": ["location"]
}
}
}
]
}' | jq '.choices[0].message.tool_calls'
```
**Expected**: Array of tool calls (if model decides to call them), or null (if not)
---
### 10. Embeddings ✅
```bash
curl -X POST $GATEWAY/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{
"model": "nomic-ai/nomic-embed-text-v2-moe",
"input": "hello world"
}' | jq '.data | length'
```
**Expected**: `1` (one embedding vector)
---
### 11. Embeddings - Multiple ✅
```bash
curl -X POST $GATEWAY/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{
"model": "nomic-ai/nomic-embed-text-v2-moe",
"input": ["text 1", "text 2", "text 3"]
}' | jq '.data | length'
```
**Expected**: `3` (three embedding vectors)
---
### 12. Embeddings - Unknown Model (Should Error) ❌→✅
```bash
curl -X POST $GATEWAY/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{
"model": "unknown-embed",
"input": "test"
}' | jq '.status'
```
**Expected**: `400` (client error)
---
### 13. Rerank ✅
```bash
curl -X POST $GATEWAY/v1/rerank \
-H 'Content-Type: application/json' \
-d '{
"model": "BAAI/bge-reranker-base",
"query": "machine learning",
"texts": [
"Machine learning is AI",
"Python is a language",
"Deep learning is ML"
]
}' | jq '.results'
```
**Expected**: Array of ranked results with scores:
```json
[
{"index": 0, "score": 0.95},
{"index": 2, "score": 0.85},
{"index": 1, "score": 0.15}
]
```
---
### 14. Rerank - Unknown Model (Should Error) ❌→✅
```bash
curl -X POST $GATEWAY/v1/rerank \
-H 'Content-Type: application/json' \
-d '{
"model": "unknown-rerank",
"query": "test",
"texts": ["a"]
}' | jq '.status'
```
**Expected**: `400` (client error)
---
### 15. Invalid JSON (Should Error) ❌→✅
```bash
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d 'not json' | jq '.title'
```
**Expected**: `"Invalid Request Body"` (HTTP 400)
---
## Testing Checklist
Complete this checklist to verify all endpoints:
### Health Endpoints
- [ ] GET /healthz → 200, `{"status":"alive"}`
- [ ] GET /readyz → 200, `{"status":"ready"}`
### Model Discovery
- [ ] GET /v1/models → 200, returns all 5 models
- [ ] All advertised models can be called (none 400)
### Chat Completions
- [ ] POST /v1/chat/completions (reasoning) → 200, response
- [ ] POST /v1/chat/completions (ornith:35b) → 200, response
- [ ] POST /v1/chat/completions (qwen2.5:3b-instruct) → 200, response
- [ ] POST /v1/chat/completions (unknown model) → 400, problem+json
- [ ] POST /v1/chat/completions (missing model) → 400, problem+json
- [ ] POST /v1/chat/completions (invalid JSON) → 400, problem+json
- [ ] POST /v1/chat/completions (streaming) → 200, SSE chunks
- [ ] POST /v1/chat/completions (with tools) → 200, tool_calls present/absent
### Embeddings
- [ ] POST /v1/embeddings (single input) → 200, embedding
- [ ] POST /v1/embeddings (multiple inputs) → 200, embeddings array
- [ ] POST /v1/embeddings (unknown model) → 400, problem+json
### Reranking
- [ ] POST /v1/rerank → 200, ranked results
- [ ] POST /v1/rerank (unknown model) → 400, problem+json
- [ ] Verify path is rewritten to /rerank on upstream
### Error Handling
- [ ] Unknown model lists valid_models
- [ ] Error responses are problem+json
- [ ] No 5xx for client errors (validation errors)
- [ ] Upstream errors pass through
### Streaming
- [ ] Chunks arrive incrementally
- [ ] Final `[DONE]` sentinel present
- [ ] Works for chat completions
### Tool Calling
- [ ] Tool definitions forward to upstream
- [ ] Tool calls in response
- [ ] Multi-turn with tool results
- [ ] Parallel tool calls
- [ ] Complex nested arguments preserved
---
## Troubleshooting
### 404 Responses
**Symptom**: All endpoints return `"not found"`
**Cause**: ConfigMap with models/routes not deployed
**Solution**:
```bash
kubectl -n api create configmap homelab-frontend-config \
--from-file=config.yaml=k8s/configmap.yaml
kubectl -n api rollout restart deployment/homelab-frontend
```
---
### 503 (Not Ready)
**Symptom**: `/readyz` returns 503
**Cause**: Configuration not loaded or JWKS fetch failed
**Solution**:
```bash
# Check logs
kubectl -n api logs deployment/homelab-frontend
# Check config
kubectl -n api get configmap homelab-frontend-config
```
---
### Connection Refused
**Symptom**: `Connection refused` or `Temporary failure in name resolution`
**Cause**:
- Gateway not running
- Wrong URL/hostname
- Network issue
**Solution**:
```bash
# Verify gateway is running
kubectl -n api get pods -l app=homelab-frontend
# Check service
kubectl -n api get svc homelab-frontend
# Verify ingress
kubectl -n api get ingress api
```
---
### Upstream Connection Errors
**Symptom**: `502 Bad Gateway` or `connection refused to upstream`
**Cause**: Model upstream service not reachable
**Solution**:
```bash
# Check upstreams are running
kubectl -n llm-serving get pods
# Verify addresses in ConfigMap
kubectl -n api get configmap homelab-frontend-config -o yaml
# Test connectivity from gateway pod
kubectl -n api exec deployment/homelab-frontend -- \
curl -s reasoning-predictor.llm-serving:80/healthz
```
---
### Streaming Doesn't Work
**Symptom**: Chunks arrive all at once (buffered) instead of incrementally
**Cause**: nginx buffering or client not using `-N` flag
**Solution**:
```bash
# Use -N flag
curl -N https://api.riotpiao.com/v1/chat/completions ...
# Verify nginx has buffering disabled
# Should have: proxy-buffering: off in Ingress annotations
```
---
## Performance Testing
### Load Test (Simple)
```bash
# Send 10 requests in parallel
for i in {1..10}; do
curl -X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"reasoning","messages":[{"role":"user","content":"Hi"}]}' &
done
wait
echo "Completed 10 requests"
```
### Concurrency Test
```bash
# Use Apache Bench (if installed)
ab -n 100 -c 10 \
-p request.json \
-T application/json \
$GATEWAY/v1/chat/completions
# Create request.json:
# {"model":"reasoning","messages":[{"role":"user","content":"test"}]}
```
### Latency Test
```bash
# Measure response time
curl -w "\nTotal time: %{time_total}s\n" \
-X POST $GATEWAY/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{
"model": "reasoning",
"messages": [{"role": "user", "content": "What is AI?"}]
}' > /dev/null
```
---
## Integration Testing
### Test with Python
```bash
pip install requests
cat > test_api.py << 'EOF'
import requests
import json
gateway = "https://api.riotpiao.com"
# Test health
r = requests.get(f"{gateway}/healthz")
assert r.status_code == 200
print("✓ Health check passed")
# Test models
r = requests.get(f"{gateway}/v1/models")
assert r.status_code == 200
models = [m['id'] for m in r.json()['data']]
print(f"✓ Models: {models}")
# Test chat
r = requests.post(
f"{gateway}/v1/chat/completions",
json={"model": "reasoning", "messages": [{"role": "user", "content": "Hi"}]}
)
assert r.status_code == 200
print("✓ Chat works")
# Test unknown model error
r = requests.post(
f"{gateway}/v1/chat/completions",
json={"model": "gpt-4", "messages": []}
)
assert r.status_code == 400
assert "unknown" in r.json()['detail'].lower()
print("✓ Unknown model error correct")
# Test embeddings
r = requests.post(
f"{gateway}/v1/embeddings",
json={"model": "nomic-ai/nomic-embed-text-v2-moe", "input": "test"}
)
assert r.status_code == 200
print("✓ Embeddings work")
# Test rerank
r = requests.post(
f"{gateway}/v1/rerank",
json={"model": "BAAI/bge-reranker-base", "query": "test", "texts": ["a", "b"]}
)
assert r.status_code == 200
print("✓ Reranking works")
print("\n✅ All tests passed!")
EOF
python test_api.py
```
---
## Summary
| Category | Tests | Expected |
|----------|-------|----------|
| Health | 2 | ✅ Both 200 |
| Models | 1 | ✅ 5 models listed |
| Chat | 8 | ✅ 6 success + 2 error |
| Embeddings | 3 | ✅ 2 success + 1 error |
| Rerank | 2 | ✅ 1 success + 1 error |
| Streaming | 1 | ✅ Incremental chunks |
| Tools | 1 | ✅ Tool calls present |
| **TOTAL** | **18+** | **✅ ALL PASS** |
Once all tests pass, the gateway is production-ready! 🚀
-324
View File
@@ -1,324 +0,0 @@
# API — LLM surfaces
Two protocol dialects over the same models and the same slot controller.
| Prefix | Dialect | Endpoint | Client |
|---|---|---|---|
| `/v1` | OpenAI-compatible | `POST /v1/chat/completions` | pi, OpenAI SDKs |
| `/llm` | Anthropic Messages | `POST /llm/v1/messages` | riotpiao frontend (first-party) |
Status marks below:
**[LIVE]** verified against the running cluster on 2026-08-19.
**[SPEC]** the contract this gateway must implement; not built yet.
---
## Models
| `model` value | Upstream | Engine | Context | Notes |
|---|---|---|---|---|
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B | 16384 | emits `reasoning_content`; 8 sequence slots total |
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama | 131072 | reliable tool calling |
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama | 32768 | same pods as ornith |
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI | — | embeddings only |
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI | — | rerank only |
`reasoning` runs 2 replicas x `--max-num-seqs=4`. Those **8 slots are the scarcest resource in the cluster** and are shared across both dialects.
---
## Authentication [SPEC]
Ships behind a flag, default off. The model API is unauthenticated today.
```
Authorization: Bearer <authentik-jwt>
```
### Decided — Bearer on both surfaces
`Authorization: Bearer <jwt>` is the only accepted credential, on `/v1` and `/llm` alike. One auth path, consistent with G5, validated against Authentik via JWKS.
**Known divergence from Anthropic:** the real Anthropic API authenticates with `x-api-key` and requires `anthropic-version: 2023-06-01`. A stock Anthropic SDK pointed at `/llm` will send `x-api-key` and get a 401.
This is accepted, not overlooked. The `/llm` client is the first-party riotpiao frontend, which sends whatever we tell it to. If a real Anthropic SDK ever needs to reach this gateway, accepting `x-api-key` as a second credential source is an additive change — a small branch in one middleware, not a redesign.
`anthropic-version` is accepted and ignored if present, and never required.
The 401 for an `x-api-key`-only request must name the problem — say that Bearer is required — rather than returning a bare 401. The Kong retirement was caused by exactly this failure mode: a gateway that rejected the header clients actually send, without saying why.
---
## OpenAI dialect — `POST /v1/chat/completions`
### Request [SPEC]
```json
{
"model": "reasoning",
"messages": [{"role": "user", "content": "Why is wave 4 empty?"}],
"max_tokens": 2000,
"temperature": 0.7,
"stream": false
}
```
`model` is required and selects the upstream. The body is forwarded byte-identical — the gateway reads `model`, it does not rewrite it.
### Response, non-streaming [LIVE]
Captured verbatim from `reasoning` on 2026-08-19, abridged:
```json
{
"id": "chatcmpl-f17bd2fe22e4276d24e9438e40e89cea",
"object": "chat.completion",
"created": 1787172340,
"model": "reasoning",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "\n\nTo find the current weather in Toronto...",
"reasoning_content": "Okay, so I need to figure out...",
"tool_calls": []
},
"finish_reason": "length"
}],
"usage": {"prompt_tokens": 16, "completion_tokens": 300, "total_tokens": 316}
}
```
`reasoning_content` is a **sibling of** `content`, not nested in it. This is a vLLM extension produced by `--reasoning-parser=deepseek_r1`; it is not part of the OpenAI spec. Pass it through untouched.
### The two engines disagree on the field name [LIVE]
Verified 2026-08-19 by calling both:
| Upstream | Engine | Reasoning field |
|---|---|---|
| `reasoning-predictor` | vLLM | `reasoning_content` |
| `ornith-predictor` | Ollama | `reasoning` |
Neither is in the OpenAI spec, so neither is wrong — they are two vendor extensions that
happen to mean the same thing. The gateway must recognise **both** when mapping to the
Anthropic `thinking` block, or `ornith:35b` responses will silently lose their reasoning
on the `/llm` surface.
Do not normalise them on the `/v1` surface. That surface passes bodies through
untouched, and a client asking for `ornith:35b` should get exactly what Ollama sent.
Normalisation belongs in the canonical request model (task 2.9), which is the layer that
exists to absorb precisely this kind of upstream difference.
### Response, streaming [SPEC]
```
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"Okay"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Wave"},"finish_reason":null}]}
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]
```
Data-only frames, no `event:` lines. Terminated by the literal `data: [DONE]`.
### Legacy aliases [LIVE, being retired]
`POST /v1/{reasoning,ornith,qwen}/chat/completions` force `model` to the corresponding value regardless of the body. They exist only because Kong could not dispatch on the body. Removed once callers migrate.
### `GET /v1/models` [SPEC]
```json
{"object":"list","data":[{"id":"reasoning","object":"model","owned_by":"homelab","created":0}]}
```
Derived from the registry, never hardcoded.
### Errors [SPEC]
RFC 9457 `application/problem+json`:
```json
{
"type": "https://riotpiao.com/errors/unknown-model",
"title": "Unknown model",
"status": 400,
"detail": "\"gpt-4\" is not available",
"validModels": ["reasoning", "ornith:35b", "qwen2.5:3b-instruct"]
}
```
---
## Anthropic dialect — `POST /llm/v1/messages` [SPEC]
Path note: the Anthropic SDK appends `/v1/messages` to its base URL, so a base URL of `https://api.riotpiao.com/llm` produces exactly this path.
### Request
```json
{
"model": "reasoning",
"max_tokens": 2000,
"system": "You are a cluster assistant.",
"messages": [
{"role": "user", "content": "Why is wave 4 empty?"}
],
"stream": true
}
```
Differences from the OpenAI dialect that the translator must handle:
| Concern | OpenAI | Anthropic |
|---|---|---|
| system prompt | `messages[0].role = "system"` | top-level `system` field |
| `max_tokens` | optional | **required** |
| content | string | string *or* block array |
| roles | system/user/assistant/tool | user/assistant only |
| stop | `stop` | `stop_sequences` |
`max_tokens` being required is a real divergence — the gateway must either reject its absence with a clear error or apply a documented default. Pick one and state it; do not silently default.
### Response, non-streaming
```json
{
"id": "msg_01ABC",
"type": "message",
"role": "assistant",
"model": "reasoning",
"content": [
{"type": "thinking", "thinking": "Waves are sort keys, not a sequence..."},
{"type": "text", "text": "Wave 4 is empty. Waves are sort keys..."}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": {"input_tokens": 16, "output_tokens": 300}
}
```
Field mapping from the upstream OpenAI response:
| Upstream | Anthropic |
|---|---|
| `choices[0].message.reasoning_content` | `content[]` block `{"type":"thinking","thinking":...}` |
| `choices[0].message.content` | `content[]` block `{"type":"text","text":...}` |
| `finish_reason: "stop"` | `stop_reason: "end_turn"` |
| `finish_reason: "length"` | `stop_reason: "max_tokens"` |
| `usage.prompt_tokens` | `usage.input_tokens` |
| `usage.completion_tokens` | `usage.output_tokens` |
The thinking block precedes the text block.
### Response, streaming
Anthropic SSE uses **named events with content-block indices**, unlike OpenAI's flat frames. Verified event sequence:
```
event: message_start
data: {"type":"message_start","message":{"id":"msg_01ABC","type":"message","role":"assistant","model":"reasoning","content":[],"usage":{"input_tokens":16,"output_tokens":0}}}
event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Waves are sort keys"}}
event: content_block_stop
data: {"type":"content_block_stop","index":0}
event: content_block_start
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
event: content_block_delta
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Wave 4 is empty."}}
event: content_block_stop
data: {"type":"content_block_stop","index":1}
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":300}}
event: message_stop
data: {"type":"message_stop"}
```
Three details that are easy to get wrong:
- In `message_delta`, `usage` is a **sibling of** `delta`, not inside it.
- The delta field name matches the delta type: `thinking_delta` carries `.thinking`, `text_delta` carries `.text`.
- Block index 0 is thinking, index 1 is text. **You only learn reasoning has ended when `content` first appears in an upstream chunk**, so the thinking block must be closed before the text block opens. If a response has no `reasoning_content` at all, the text block is index 0 and no thinking block is emitted.
### Queue position — non-standard extension
Anthropic's event set has no way to say "you are queued", because the stream implicitly begins after a slot is acquired. With only 8 slots, queueing is normal here.
Emitted **before** `message_start`:
```
event: queue
data: {"type":"queue","position":3}
```
This is deliberately outside the Anthropic spec. It is safe only because the client is first-party; a strict Anthropic client would ignore the unknown event and show nothing while queued.
### Errors
Anthropic error shape, **not** RFC 9457 — the same rejection renders differently depending on which surface received it:
```json
{"type":"error","error":{"type":"invalid_request_error","message":"Unknown model \"gpt-4\". Available: reasoning, ornith:35b, qwen2.5:3b-instruct"}}
```
| Condition | HTTP | `error.type` |
|---|---|---|
| unknown or missing model | 400 | `invalid_request_error` |
| `max_tokens` absent (if required) | 400 | `invalid_request_error` |
| malformed JSON | 400 | `invalid_request_error` |
| unsupported feature requested | 400 | `invalid_request_error` |
| not authenticated | 401 | `authentication_error` |
| budget exhausted or queue full | 429 | `rate_limit_error` |
| upstream failure | 502 | `api_error` |
### Deliberately not implemented
Each returns 400 naming the unsupported feature — never a silent partial implementation:
tool use and `tool_result` turns, image content blocks, prompt-caching headers, the batch API, multi-block user content, `thinking.budget_tokens` configuration.
The target client is the riotpiao frontend. Widening scope is a code change with a test, not an accident.
---
## Shared behaviour, both dialects
**One slot controller, keyed by upstream.** A `/v1` request and a `/llm` request contend for the same 8 `reasoning` slots and the same queue, in arrival order. Per-dialect semaphores would each believe they were within budget while together exceeding the physical limit.
**Streaming is unbuffered** and a client disconnect cancels the upstream immediately. An orphaned generation holds a slot until it completes on its own, which for a 32B model on a Volta GPU can run to minutes.
**Timeouts** [LIVE]: chat routes are connect 10s / read 1h / write 1h. The hour is deliberate — a 32B model on this hardware routinely exceeds 60s. Any shorter application cap is enforced in gateway logic, never by shortening the proxy timeout.
**Tool calling** [LIVE]: `reasoning` honours an explicit `tool_choice` but returns `tool_calls: []` under `tool_choice: "auto"` — it reasons about the tool in prose instead. `ornith:35b` returns `finish_reason: "tool_calls"` correctly under `auto`. This is a model property; the gateway does not compensate for it.
---
## Examples
```bash
# OpenAI dialect
curl -s https://api.riotpiao.com/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"reasoning","messages":[{"role":"user","content":"Why is wave 4 empty?"}],"max_tokens":500}'
# Anthropic dialect, streaming
curl -N -s https://api.riotpiao.com/llm/v1/messages \
-H 'content-type: application/json' \
-d '{"model":"reasoning","max_tokens":500,"stream":true,
"messages":[{"role":"user","content":"Why is wave 4 empty?"}]}'
# model list
curl -s https://api.riotpiao.com/v1/models
```
-262
View File
@@ -1,262 +0,0 @@
# API — queue surface (`/sqs/*`)
Fronts the Kafka Management Service (`kmsvc`) in namespace `sqs`. SQS-shaped
message-plane API over Kafka.
Status marks:
**[LIVE]** verified against the running cluster and the committed proto on 2026-08-19.
**[SPEC]** the contract this gateway must implement; not built yet.
Source of truth for shapes:
`~/workplace/kmsvc-proto/proto/kafkamgmt/v1/queue_service.proto`.
---
## The important finding: a REST surface already exists [LIVE]
**Do not build gRPC-to-JSON transcoding.** `kmsvc-manage` already mounts grpc-gateway:
```go
mux := runtime.NewServeMux()
kafkamgmtv1.RegisterQueueServiceHandlerServer(ctx, mux, svc)
```
The upstream serves plain REST/JSON on **:8080** and plain gRPC on **:9090**. Neither
gRPC-Web nor server reflection is enabled.
So `/sqs/*` is a **path-stripping reverse proxy plus authentication**, not a protocol
translator. That makes it dramatically cheaper than the LLM surface.
```
api.riotpiao.com/sqs/v1/queues/{q}/messages
| strip /sqs, authenticate
v
management-service.sqs.svc.cluster.local:8080/v1/queues/{q}/messages
```
Upstream: Deployment `management-service`, 3 replicas, HPA 3-9, Service ClusterIP
`10.98.3.138`, ports `8080` (http) and `9090` (grpc).
---
## Endpoints [LIVE — HTTP annotations from the proto]
Six operations. All unary. No streaming, no subscribe.
| Method | Path (after `/sqs` strip) | RPC |
|---|---|---|
| POST | `/v1/queues/{queue_name}/messages` | `SendMessage` |
| POST | `/v1/queues/{queue_name}/messages:batch` | `SendMessageBatch` |
| GET | `/v1/queues/{queue_name}/messages` | `ReceiveMessage` |
| DELETE | `/v1/queues/{queue_name}/messages/{receipt_handle}` | `DeleteMessage` |
| POST | `/v1/queues/{queue_name}/messages:batchDelete` | `DeleteMessageBatch` |
| PATCH | `/v1/queues/{queue_name}/messages/{receipt_handle}` | `ChangeMessageVisibility` |
---
## Two wire-format traps [LIVE]
Both follow from grpc-gateway defaults, and both will surprise anyone who reads only
the proto.
**1. `bytes` fields are base64 in JSON.** `SendMessageRequest.message_body` and
`Message.body` are proto `bytes`. The JSONPB marshaler encodes them as base64 strings.
Sending raw text will not do what you expect.
**2. Field names are lowerCamelCase.** `cmd/server/main.go` calls bare
`runtime.NewServeMux()` with no marshaler options, so `OrigName` is false. The wire uses
`messageBody`, `receiptHandle`, `maxNumberOfMessages` — not the snake_case names in the
proto.
Document both prominently or every first-time caller loses an hour.
---
## Message shapes [LIVE — from the proto]
### Send
```
POST /sqs/v1/queues/agent-worker-queue/messages
{
"messageBody": "aGVsbG8gd29ybGQ=", // base64 of "hello world"
"messageAttributes": {"values": {"k": "v"}},
"messageGroupId": "", // FIFO only
"messageDeduplicationId": "", // FIFO only
"delaySeconds": 0 // 0-900
}
-> {"messageId": "...", "sequenceNumber": ""} // sequenceNumber FIFO only
```
### Receive — long poll
```
GET /sqs/v1/queues/agent-worker-queue/messages
?maxNumberOfMessages=10 // <= 10
&waitTimeSeconds=20 // 0-20
&visibilityTimeoutSeconds=30 // optional override
-> {"messages": [{
"messageId": "...",
"receiptHandle": "...",
"body": "aGVsbG8gd29ybGQ=",
"attributes": {"values": {}},
"receiveCount": 1,
"messageGroupId": "",
"enqueuedAt": "2026-08-19T16:29:07Z"
}]}
```
### Delete — the ack
```
DELETE /sqs/v1/queues/agent-worker-queue/messages/{receiptHandle}
-> {}
```
### Change visibility
```
PATCH /sqs/v1/queues/agent-worker-queue/messages/{receiptHandle}
{"visibilityTimeoutSeconds": 60} // 0-43200
-> {}
```
### Batch
Both batch calls take `entries[]` with a caller-assigned `id`, and return partial
success:
```json
{"successful": [{"id": "1", "messageId": "..."}],
"failed": [{"id": "2", "error": "..."}]}
```
A batch call can return 200 with entries in `failed`. Callers must inspect the body,
not just the status.
### Limits [LIVE — from the SDK]
`MaxMessageBodyBytes = 262144` (256 KiB), `MaxReceiveMessages = 10`,
`MaxWaitTimeSeconds = 20`.
---
## Semantics
At-least-once, SQS-style. Receive leases a message for the visibility timeout; the
caller must `DeleteMessage` to acknowledge. An un-deleted message reappears after the
timeout and `receiveCount` increments. After `maxReceiveCount` (default 5) it goes to
the DLQ if one is configured.
**Long-polling matters for the gateway.** `waitTimeSeconds` up to 20 means a `GET` can
legitimately hold open for 20 seconds returning nothing. Read timeouts must exceed that
comfortably, and a client disconnect must cancel upstream — the same requirement as the
LLM surface, for the same reason.
---
## Error mapping [SPEC]
The SDK maps gRPC codes to sentinel errors; grpc-gateway maps them to HTTP. Use this as
the gateway's status contract:
| gRPC code | HTTP | SDK sentinel |
|---|---|---|
| `NotFound` | 404 | `ErrQueueNotFound` |
| `AlreadyExists` | 409 | `ErrAlreadyExists` |
| `InvalidArgument` | 400 | `ErrInvalidArgument` |
| `Unauthenticated` | 401 | `ErrUnauthenticated` |
| `ResourceExhausted` | 429 | `ErrMessageTooLarge` |
Upstream errors arrive in the grpc-gateway envelope
`{"code": 5, "message": "Not Found", "details": []}`. Decide deliberately whether
`/sqs/*` passes that through or re-renders it as RFC 9457 to match `/v1/*`.
Recommendation: **pass through**, so the gateway does not become a second, subtly
different error vocabulary for the same upstream.
---
## Queue lifecycle is NOT in this API [LIVE]
There is no `CreateQueue`, `DeleteQueue`, or `ListQueues` RPC. The proto says so
explicitly:
```proto
// Queue lifecycle (create/delete/configure) is managed via the Queue CRD,
// not this service
```
Queues are Kubernetes resources — `queues.kmsvc.io/v1`, namespaced. `kmsvc-cli`'s
`create-queue` and `delete-queue` talk to the Kubernetes API, not to kmsvc.
**This is a hard boundary for the gateway.** Exposing queue creation over `/sqs/*` would
require the gateway to hold Kubernetes write credentials, which violates **G2**. Do not
add it. If declarative queue management ever needs a public surface, it belongs behind a
separate component with its own RBAC — not in the public edge process.
Queue spec fields, for reference when reading a queue's configuration:
`fifoQueue`, `isDLQ`, `deadLetterTargetQueue`, `delaySeconds` (0-900),
`maxReceiveCount` (default 5), `messageRetentionPeriodSeconds` (default 345600),
`visibilityTimeoutSeconds` (default 30), `minShards`, `maxShards` (default 8),
`partitionsPerShard` (default 6), `shardSplitThresholdBytesPerSec`,
`shardSplitCooldownSeconds`.
Kafka topics are named `kmsvc.{queue}.shard-{id}` and are created by `queue-operator`
directly via the Kafka Admin API — there are no `KafkaTopic` CRs.
Currently one queue exists: `agent-worker-queue` in namespace `sqs`, phase `Ready`,
1 shard.
---
## Authentication [SPEC]
`Authorization: Bearer <jwt>`, same as every other gateway surface.
**The upstream enforces nothing.** `kmsvc`'s auth interceptor exists but is never wired,
and the REST surface is mounted with the in-process grpc-gateway variant that bypasses
gRPC interceptors regardless. Both `:8080` and `:9090` are currently open, and
`kmsvc.riotpiao.com` is publicly routed.
The gateway is therefore the only authentication boundary for this surface.
---
## Out of scope
- **Workflow start.** Nothing in kmsvc starts a Temporal workflow — no such RPC exists,
and grep for `ExecuteWorkflow`/`StartWorkflow` across `kmsvc-manage`, `kmsvc-sdk` and
`kmsvc-cli` returns nothing. A caller dials `temporal-frontend.temporal.svc:7233`
with a Temporal SDK directly. A `/workflow/*` surface is net-new code, not a proxy
route — see [task 7.3](../tasks/7.3-workflow-prefix.md).
- **DLQ operations.** `kmsvc-cli`'s `dlq peek` and `dlq redrive` are client-side
compositions of the six RPCs, not server operations. Redrive is a non-atomic
Receive-Send-Delete. If `/sqs/*` should offer redrive, that is new logic with real
failure modes, not a proxied call.
- **Kafka direct access.** No external listener exists; the bootstrap
`kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092` is cluster-internal only. The
gateway proxies kmsvc, never Kafka.
---
## Examples
```bash
Q=agent-worker-queue
# send (body must be base64)
curl -s -X POST https://api.riotpiao.com/sqs/v1/queues/$Q/messages \
-H 'content-type: application/json' \
-d "{\"messageBody\":\"$(printf 'hello world' | base64)\"}"
# receive, long poll 20s
curl -s "https://api.riotpiao.com/sqs/v1/queues/$Q/messages?maxNumberOfMessages=10&waitTimeSeconds=20"
# acknowledge
curl -s -X DELETE https://api.riotpiao.com/sqs/v1/queues/$Q/messages/$RECEIPT
# extend the lease
curl -s -X PATCH https://api.riotpiao.com/sqs/v1/queues/$Q/messages/$RECEIPT \
-H 'content-type: application/json' -d '{"visibilityTimeoutSeconds":60}'
```
-143
View File
@@ -1,143 +0,0 @@
# Kong retirement — inventory and cutover
Everything Kong does on `api.riotpiao.com` today, and where it goes. Inventory
verified live against context `admin@homelab-cluster` on 2026-08-19.
Source of the objects being retired: `~/workplace/homelab/k8s/apps/api/` and
`k8s/argocd/apps/55-api-gateway.yaml`.
## What is running now
Kong OSS 3.4.1, Helm chart from `https://charts.konghq.com`, DB-less, namespace
`api`, Argo Application `kong` at sync wave 7. Two replicas. Fronted by
`ingress-nginx` via Ingress `api/api`, which catch-alls `/` on `api.riotpiao.com`
to `kong-proxy:80`.
Eleven ReplicaSets exist on the Kong Deployment, the newest minutes old — this
config is being actively iterated, so re-verify the inventory immediately before
cutover.
## Routing table to port
Seven `ingressClassName: kong` Ingresses. Six in `llm-serving`, one in `agent-pod`.
| Method | Path | Upstream | Transform applied by Kong |
|---|---|---|---|
| GET | `/v1/models` | — | `request-termination`: static 200 JSON, upstream never contacted |
| POST | `/v1/reasoning/chat/completions` | `reasoning-predictor:80` | force body `model=reasoning`, rewrite URI to `/v1/chat/completions` |
| POST | `/v1/ornith/chat/completions` | `ornith-predictor:80` | force body `model=ornith:35b`, rewrite URI |
| POST | `/v1/qwen/chat/completions` | `ornith-predictor:80` | force body `model=qwen2.5:3b-instruct`, rewrite URI |
| POST | `/v1/embeddings` | `embeddings-predictor:80` | none — TEI already serves the canonical path |
| POST | `/v1/rerank` | `reranker-predictor:80` | rewrite URI to `/rerank` (TEI does not serve `/v1/rerank`) |
| GET/WS | `/console`, `/run`, `/sessions` | `agent-hub:9090` (`agent-pod` ns) | none, `strip-path: false` |
Upstream model map, from the manifest comments and confirmed live:
- `reasoning``reasoning-predictor` — vLLM, DeepSeek-R1-Distill-Qwen-32B, 2 replicas,
`--max-num-seqs=4`, `--max-model-len=16384`, `--reasoning-parser=deepseek_r1`,
`--enable-auto-tool-choice --tool-call-parser=hermes`
- `ornith:35b``ornith-predictor` — Ollama, 2 replicas
- `qwen2.5:3b-instruct``ornith-predictor` — same pods; both models stay resident via
`OLLAMA_MAX_LOADED_MODELS=2`, `OLLAMA_KEEP_ALIVE=-1`
- `nomic-ai/nomic-embed-text-v2-moe``embeddings-predictor` — TEI
- `BAAI/bge-reranker-base``reranker-predictor` — TEI
### The path-per-model surface goes away
The three chat paths exist only because Kong OSS cannot dispatch on the request
body. The gateway serves a single `POST /v1/chat/completions` and selects the
upstream from the body's `model` field.
Keep the old paths as aliases during cutover so live clients do not break, then
remove them once callers have migrated. pi is a live caller today.
### `/v1/models` should not be ported verbatim
Kong serves a hardcoded list via `request-termination`. The manifest already flags
that it can drift from what the engines actually serve. Derive the response from
the gateway's configured upstream map instead, so the list cannot disagree with
what routing will accept.
## Plugins being retired
| Plugin | Scope | Replacement |
|---|---|---|
| `llm-rewrite-reasoning` / `-ornith` / `-qwen` | llm-serving | body-based dispatch in `internal/llm` |
| `llm-rewrite-rerank` | llm-serving | per-upstream path rewrite in the route table |
| `llm-models-list` | llm-serving | derived from the upstream map |
| `prometheus` | **cluster-wide** | `internal/observability` — must expose bandwidth, latency, status codes, upstream health or observability regresses |
No `rate-limiting` plugin exists anywhere in the cluster. REQUIREMENTS.md §4 Tier 2
describes it as an existing layer; it is not built. Nothing to migrate — it is net
new work, and it now belongs in the gateway rather than in Kong.
## Auth: currently off, must land on
`KongConsumer model-invoker` exists in namespace `api` and stays defined, but the
`key-auth` plugin is commented out and every route has `model-key-auth` stripped
from its `konghq.com/plugins` annotation.
**The model API is unauthenticated right now.** Confirmed live 2026-08-19: a request
to `/v1/reasoning/chat/completions` with no credentials returns 200.
The reason is recorded in `model-auth.yaml` — Kong's `key-auth` accepts a raw
`apikey:` header but rejects `Authorization: Bearer`, which blocks every
OpenAI-compatible client. That is why `~/.pi/agent/models.json` carries a
`customHeaders: {apikey: ...}` block.
The gateway reads Bearer tokens directly and validates them against Authentik via
JWKS. `AUTH-PLAN.md`'s pinned-RSA-key approach and its rotation runbook are not
needed and should not be carried over.
Ship auth behind a flag. Turning it on breaks every current caller until they hold
a token — pi included.
## Timeouts
Kong today:
| Route class | connect | read | write |
|---|---|---|---|
| chat | 10s | **1h** | 1h |
| embeddings / rerank | 10s | 10m | 10m |
nginx in front sets `proxy-read-timeout: 3600`, `proxy-send-timeout: 3600`,
`proxy-buffering: off`, `proxy-body-size: 0`. Those stay — they are what makes token
streaming work, and the gateway needs the same treatment from nginx.
The 1-hour read timeout is deliberate: a 32B model on a Volta GPU routinely exceeds
60s. Any shorter server-side cap must be enforced *in the gateway*, not by shortening
the proxy timeout, or long legitimate generations get truncated mid-stream.
## Cutover
Reversible at every step. Kong keeps serving until the last step.
1. Deploy the gateway alongside Kong, unexposed. Verify in-cluster against
`http://homelab-frontend.api.svc.cluster.local`.
2. Compare gateway and Kong responses for every route in the table above, including
a streaming chat request and a client disconnect mid-stream.
3. Repoint Ingress `api/api` from `kong-proxy:80` to the gateway Service. **This is
the cutover.** Reverting is a one-line change to the same Ingress.
4. Soak. Watch gateway metrics and pi traffic.
5. Delete the seven kong-class Ingresses and the six KongPlugin CRs.
6. Remove the `kong` Application from `k8s/argocd/apps/55-api-gateway.yaml`; let Argo
prune the Helm release, the CRDs and namespace leftovers.
Steps 14 are reversible in seconds. Step 5 onward is not — do not start it until the
soak is clean.
All of this flows through git and Argo. No `kubectl apply`, no `helm upgrade`.
## Loose ends
- `agent-pod/console` is publicly routed, unauthenticated, accepts free-form prompts
into a shell-capable container, and exposes a WebSocket. Migrating it behind the
gateway's auth is a security fix, not merely a port. Treat WebSocket upgrade as an
explicit requirement of the proxy layer.
- Eight `*.example.com` hosts exist on istio-class Ingresses in `llm-serving`
(`{embeddings,ornith,reasoning,reranker}[-predictor]-llm-serving.example.com`).
KServe defaults, not public, not Kong's — out of scope here, but they exist and
should not be mistaken for gateway routes.
- Ingress class split across the cluster is 7 kong / 17 nginx / 4 istio. Only the 7
kong ones are in scope.
-542
View File
@@ -1,542 +0,0 @@
# Service Usage Guide
Complete guide for calling all gateway-backed services via `api.riotpiao.com`.
**Table of Contents**
- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [Service Map](#service-map)
- [Service-Specific Guides](#service-specific-guides)
---
## Quick Start
All services use the **X-Service** header to route requests:
```bash
curl -X POST https://api.riotpiao.com/path \
-H "X-Service: <service_name>" \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"key": "value"}'
```
| Header | Purpose | Example |
|---|---|---|
| `X-Service` | Route to named service | `X-Service: sqs` |
| `X-Resource` | (Optional) Resource ID for auth | `X-Resource: agent-worker-queue` |
| `Authorization` | Bearer token (required for auth-protected services) | `Authorization: Bearer eyJ...` |
---
## Authentication
### Getting a Token
**From Authentik (OAuth2 client credentials flow):**
```bash
AUTHENTIK_URL=https://authentik.riotpiao.com
CLIENT_ID="your-client-id"
CLIENT_SECRET="your-client-secret"
TOKEN=$(curl -s -X POST ${AUTHENTIK_URL}/application/o/token/ \
-d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&scope=openid" \
| jq -r '.access_token')
echo $TOKEN
```
Replace `your-client-id` and `your-client-secret` with Authentik app credentials.
### Service-Specific Auth
| Service | Auth Required | Token Audience | Notes |
|---|---|---|---|
| **sqs** | ✅ Yes (gateway validates) | `sqs` | JWT signature & claims verified by gateway before proxying |
| **memory** | ❌ No | — | Pass-through (service-owned if needed) |
| **s3** (MinIO) | ❌ No | — | Native OIDC support (service-owned) |
| **iam** | ❌ No | — | Pass-through (service-owned if needed) |
| **workflow** (Temporal) | ❌ No | — | Native JWT support (service-owned) |
---
## Service Map
### Gateway Services (via X-Service header)
```
api.riotpiao.com
├─ X-Service: sqs
│ ├─ Upstream: management-service.sqs.svc.cluster.local:8080
│ ├─ Auth: ✅ Gateway validates JWT
│ └─ Docs: docs/API-sqs.md
├─ X-Service: memory
│ ├─ Upstream: poimen-memory.memory.svc.cluster.local:9090
│ ├─ Auth: ❌ Pass-through (service-owned)
│ └─ Docs: See Memory Service section below
├─ X-Service: s3
│ ├─ Upstream: minio.data.svc.cluster.local:9000
│ ├─ Auth: ❌ Native OIDC (service-owned)
│ └─ Notes: S3-compatible API
├─ X-Service: iam
│ ├─ Upstream: keycloak.iam.svc.cluster.local:8080 (or equivalent)
│ ├─ Auth: ❌ Pass-through (service-owned)
│ └─ Docs: See IAM Service section below
└─ X-Service: workflow
├─ Upstream: temporal-frontend.temporal.svc.cluster.local:7233
├─ Auth: ❌ Native JWT support (service-owned)
├─ Protocol: gRPC only (returns 501 for HTTP)
└─ Docs: docs/TEMPORAL_USAGE.md
```
### Path Prefixes (legacy, before X-Service migration)
```
api.riotpiao.com
├─ /v1/* → llm-serving (predictors: vLLM, Ollama, TEI)
├─ /sqs/* → management-service (Kafka queues)
├─ /workflow/* → Temporal (workflows)
└─ /cluster/* → atlas (topology & Argo delivery)
```
**Migration note:** X-Service routing is the current standard. Path prefixes are deprecated.
---
## Service-Specific Guides
### SQS (Kafka Queue Management)
**Endpoint:** `POST https://api.riotpiao.com/`
**Headers:**
```
X-Service: sqs
Authorization: Bearer <jwt>
```
**Send a message:**
```bash
QUEUE="agent-worker-queue"
BODY=$(printf "hello world" | base64)
curl -X POST https://api.riotpiao.com/ \
-H "X-Service: sqs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"messageBody\": \"$BODY\",
\"messageAttributes\": {\"values\": {}},
\"delaySeconds\": 0
}"
```
**Response:**
```json
{
"messageId": "d9f94e63-b2c1-4e9f-8c5f-8d5e3c1b7a0f",
"sequenceNumber": ""
}
```
**Receive messages (long poll, up to 20s):**
```bash
QUEUE="agent-worker-queue"
curl -X GET "https://api.riotpiao.com/?X-Service=sqs&queue=$QUEUE&maxNumberOfMessages=10&waitTimeSeconds=20" \
-H "Authorization: Bearer $TOKEN"
```
**Response:**
```json
{
"messages": [
{
"messageId": "d9f94e63-b2c1-4e9f-8c5f-8d5e3c1b7a0f",
"receiptHandle": "...",
"body": "aGVsbG8gd29ybGQ=",
"attributes": {"values": {}},
"receiveCount": 1,
"enqueuedAt": "2026-08-27T22:18:37Z"
}
]
}
```
**Acknowledge (delete) a message:**
```bash
RECEIPT="..."
curl -X DELETE https://api.riotpiao.com/ \
-H "X-Service: sqs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"receiptHandle\": \"$RECEIPT\"}"
```
**Full documentation:** [docs/API-sqs.md](API-sqs.md)
---
### Memory Service (Context & Embeddings)
**Endpoint:** `https://api.riotpiao.com/`
**Headers:**
```
X-Service: memory
```
**Status:** Service definition in progress. Uses PostgreSQL + pg_vector for embeddings.
**Planned operations:**
- Store session memory / agent context
- Query by similarity (embedding search)
- Update with approval workflow (GRM/Git review)
**Coming soon:** Full API documentation.
---
### S3 / MinIO (Object Storage)
**Endpoint:** `https://api.riotpiao.com/`
**Headers:**
```
X-Service: s3
```
**List buckets:**
```bash
curl -X GET https://api.riotpiao.com/ \
-H "X-Service: s3" \
-H "Authorization: Bearer $TOKEN"
```
**List objects in bucket:**
```bash
curl -X GET https://api.riotpiao.com/?bucket=my-bucket&prefix=data/ \
-H "X-Service: s3"
```
**Put object:**
```bash
curl -X PUT https://api.riotpiao.com/my-bucket/path/to/object.json \
-H "X-Service: s3" \
--data-binary @object.json
```
**Get object:**
```bash
curl -X GET https://api.riotpiao.com/my-bucket/path/to/object.json \
-H "X-Service: s3"
```
**Full S3/MinIO API:** Standard AWS S3 compatible API. See [MinIO docs](https://min.io/docs/minio/linux/reference/minio-mc/mc-ls.html).
---
### IAM (Identity & Access Management)
**Endpoint:** `https://api.riotpiao.com/`
**Headers:**
```
X-Service: iam
```
**List roles:**
```bash
curl -X GET https://api.riotpiao.com/roles \
-H "X-Service: iam"
```
**Get user:**
```bash
curl -X GET https://api.riotpiao.com/users/alice \
-H "X-Service: iam"
```
**Create user:**
```bash
curl -X POST https://api.riotpiao.com/users \
-H "X-Service: iam" \
-H "Content-Type: application/json" \
-d "{
\"username\": \"bob\",
\"email\": \"[email protected]\",
\"password\": \"secure-password\"
}"
```
**Assign role to user:**
```bash
curl -X POST https://api.riotpiao.com/users/bob/roles \
-H "X-Service: iam" \
-H "Content-Type: application/json" \
-d "{\"role\": \"admin\"}"
```
**Full documentation:** Service-specific (depends on IAM backend).
---
### Workflow (Temporal)
**Endpoint:** `temporal-frontend.temporal.svc.cluster.local:7233`
**Protocol:** gRPC only
**Note:** HTTP requests return **501 Not Implemented**
Use Temporal SDK directly:
```go
import "go.temporal.io/sdk/client"
c, _ := client.Dial(client.Options{HostPort: "temporal-frontend.temporal.svc.cluster.local:7233"})
defer c.Close()
// Start workflow
run, _ := c.ExecuteWorkflow(ctx, opts, YourWorkflow, args...)
var result YourWorkflowResult
run.Get(ctx, &result)
```
**Full documentation:** [docs/TEMPORAL_USAGE.md](../TEMPORAL_USAGE.md)
---
## Error Handling
### Standard Error Response
All services return errors in **RFC 9457 Problem Details** format:
```json
{
"type": "https://api.riotpiao.com/problem/not-found",
"title": "Not Found",
"status": 404,
"detail": "Resource does not exist",
"instance": "/sqs/v1/queues/nonexistent"
}
```
### Common Status Codes
| Code | Meaning | Example |
|---|---|---|
| `200 OK` | Success | Message sent, resource retrieved |
| `201 Created` | Resource created | Queue created, object uploaded |
| `204 No Content` | Success (no body) | Message deleted |
| `400 Bad Request` | Invalid input | Message body too large, invalid field |
| `401 Unauthorized` | Missing/invalid token | No Authorization header, token expired |
| `403 Forbidden` | Token valid but insufficient permissions | User lacks sqs:write permission |
| `404 Not Found` | Resource not found | Queue doesn't exist, object not found |
| `429 Too Many Requests` | Rate limit exceeded | Per-user budget exhausted |
| `502 Bad Gateway` | Upstream unreachable | Service is down or network issue |
| `501 Not Implemented` | Operation not supported | gRPC request via HTTP |
### SQS-Specific Error Mapping
SQS errors (from kmsvc) map as follows:
| gRPC Code | HTTP Status | Message |
|---|---|---|
| `NotFound` | `404` | Queue or message not found |
| `AlreadyExists` | `409` | Queue already exists |
| `InvalidArgument` | `400` | Message body too large, invalid parameter |
| `Unauthenticated` | `401` | Missing Authorization header |
| `ResourceExhausted` | `429` | Message too large, quota exceeded |
---
## Request/Response Examples
### Example 1: Send SQS Message with Auth
```bash
#!/bin/bash
GATEWAY="https://api.riotpiao.com"
AUTHENTIK="https://authentik.riotpiao.com"
CLIENT_ID="sqs-client"
CLIENT_SECRET="secret123"
QUEUE="agent-worker-queue"
# Get token
TOKEN=$(curl -s -X POST ${AUTHENTIK}/application/o/token/ \
-d "grant_type=client_credentials&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&scope=openid" \
| jq -r '.access_token')
# Send message
BODY=$(echo "process this task" | base64)
curl -X POST ${GATEWAY}/ \
-H "X-Service: sqs" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"messageBody\": \"$BODY\"}" | jq .
```
### Example 2: Receive & Process Queue
```bash
#!/bin/bash
GATEWAY="https://api.riotpiao.com"
TOKEN="..."
QUEUE="agent-worker-queue"
MAX_MSGS=10
WAIT_SECS=20
while true; do
# Receive messages (long poll)
RESPONSE=$(curl -s -X GET "${GATEWAY}/?X-Service=sqs&queue=${QUEUE}&maxNumberOfMessages=${MAX_MSGS}&waitTimeSeconds=${WAIT_SECS}" \
-H "Authorization: Bearer $TOKEN")
MESSAGES=$(echo "$RESPONSE" | jq '.messages')
if [[ "$MESSAGES" == "null" ]]; then
echo "No messages (timeout)"
continue
fi
# Process each message
echo "$RESPONSE" | jq -r '.messages[] | @base64d' | while read -r MSG; do
echo "Processing: $MSG"
# Do work...
# Acknowledge message
RECEIPT=$(echo "$RESPONSE" | jq -r '.messages[0].receiptHandle')
curl -s -X DELETE ${GATEWAY}/ \
-H "X-Service: sqs" \
-H "Authorization: Bearer $TOKEN" \
-d "{\"receiptHandle\": \"$RECEIPT\"}"
done
done
```
### Example 3: S3 Workflow (Upload & List)
```bash
#!/bin/bash
GATEWAY="https://api.riotpiao.com"
BUCKET="my-data"
# Upload file
echo "Uploading..."
curl -X PUT ${GATEWAY}/${BUCKET}/backup-$(date +%s).tar.gz \
-H "X-Service: s3" \
--data-binary @backup.tar.gz
# List objects
echo "Listing..."
curl -X GET "${GATEWAY}/?bucket=${BUCKET}&prefix=backup-" \
-H "X-Service: s3" | jq '.Contents[]'
```
---
## Testing
### Integration Tests
Run the full test suite:
```bash
./scripts/test-integration.sh
```
Run specific service tests:
```bash
GATEWAY_URL=https://api.riotpiao.com go test -tags integration -v -run TestSQS ./internal/serviceadapter
```
### Local Testing
Start local gateway with test services:
```bash
# Terminal 1: Start gateway
CONFIG_PATH=k8s/configmap.yaml go run ./cmd/gateway
# Terminal 2: Run tests
./scripts/test-integration.sh
```
### Canary Deployment
Test a single replica before rolling out:
```bash
./scripts/test-canary.sh
```
---
## Debugging
### Check Gateway Logs
```bash
kubectl -n api logs -l app=api-gateway --tail=100 -f
```
### Port-Forward to Service
```bash
kubectl -n sqs port-forward svc/management-service 8080:8080
curl http://localhost:8080/v1/queues
```
### Verify Service Availability
```bash
kubectl get svc -A | grep -E "management-service|poimen-memory|minio|temporal"
```
### Test Direct Service Access
```bash
kubectl -n sqs exec -it deployment/management-service -- \
curl -s http://localhost:8080/v1/queues | jq .
```
---
## Rate Limits & Quotas
| Service | Limit | Notes |
|---|---|---|
| **SQS** | Per-user budget (tokens) | Budget enforced per Authentik user |
| **S3** | MinIO quotas | Set per bucket in MinIO config |
| **Memory** | Not yet enforced | Future: embeddings storage limits |
| **Temporal** | Workflow concurrency | Set in Temporal cluster config |
See [docs/QUOTAS.md](QUOTAS.md) for detailed limits.
---
## Next Steps
- [Service Adapter Documentation](../tasks/8.1-serviceadapter-crd-and-informer.md)
- [X-Service Routing](../tasks/8.2-x-service-dispatcher.md)
- [Authentication & JWT Validation](../tasks/3.1-auth-sqs-jwt-validation.md)
- [Testing Guide](../TESTING_GUIDE.md)
- [Integration Tests](../INTEGRATION_TESTS.md)
@@ -1,136 +0,0 @@
# ADR-0001 — Retire Kong OSS in favour of a Go API gateway
Status: Accepted
Date: 2026-08-19
Deciders: rock
## Context
`api.riotpiao.com` is currently served by Kong OSS 3.4.1 (Helm, DB-less, namespace `api`,
Argo wave 7), sitting behind ingress-nginx which owns TLS. Kong routes to the KServe
model predictors in `llm-serving` via seven `ingressClassName: kong` Ingresses and six
`KongPlugin` CRs.
Three separate capabilities were attempted on Kong OSS. All three failed, and each
failure is already documented in-repo by the person who hit it:
**1. Body-based model dispatch is not expressible.**
From `k8s/apps/api/llm-routes.yaml`:
> a single `/v1/chat/completions` endpoint that dispatches on the body's `model` field is
> not expressible in Kong OSS (`ai-proxy-advanced`, which does multi-target model routing,
> is Enterprise-only).
The workaround is a path-per-model surface (`/v1/reasoning/chat/completions`,
`/v1/ornith/...`, `/v1/qwen/...`) with a `request-transformer` force-overwriting the body's
`model` field. This is not OpenAI-standard, so every client needs bespoke configuration —
visible today in `~/.pi/agent/models.json`, which carries three separate provider entries
for what should be one endpoint.
**2. OIDC is Enterprise-only.**
`k8s/apps/api/AUTH-PLAN.md` routes around the missing `openid-connect` plugin using the
built-in `jwt` plugin, which requires pinning Authentik's RSA public key onto a
KongConsumer. That plan lists its own consequence:
> Pinning `rsa_public_key`: Authentik key rotation would break it — document a rotation
> runbook, or have the provision script re-export the cert PEM into the Kong credential on
> each run.
A rotation runbook is a standing operational liability accepted only because the gateway
cannot fetch JWKS itself.
**3. `key-auth` cannot read `Authorization: Bearer`.**
From `k8s/apps/api/model-auth.yaml`:
> a raw `apikey: <key>` header succeeds (200), the same request with only
> `Authorization: Bearer <key>` fails (401). No OpenAI-SDK-compatible client (pi included)
> sends a raw apikey header or lets you customize the header name, so every such client was
> hard-blocked.
Consequence: authentication on the model routes is **currently disabled**. Verified live
2026-08-19 — `api.riotpiao.com/v1/reasoning/chat/completions` answers unauthenticated.
Separately, the intended surface has grown beyond LLM routing. The target is a
capability-per-subdomain API over cluster services — `sqs.riotpiao.com` for queue
operations, `workflow.riotpiao.com` for Temporal, `cluster.riotpiao.com` for atlas — each
needing request shaping, per-caller budgets and streaming semantics that are application
concerns, not gateway-plugin concerns.
## Decision
Retire Kong OSS entirely. Replace it with a purpose-built Go service,
`homelab-frontend`, which owns north-south routing, authentication, and request shaping
for every public capability on `*.riotpiao.com`.
ingress-nginx keeps the edge and TLS. It forwards to the gateway instead of `kong-proxy`.
Authentication is Authentik OIDC, validated by fetching JWKS from
`https://authentik.riotpiao.com` at runtime.
## Options considered
**A. Stay on Kong OSS, accept the workarounds.**
Keeps a battle-tested proxy and its Prometheus plugin. But the path-per-model surface stays
non-standard, the RSA pinning runbook stays, and auth stays off until someone writes a
`request-transformer` shim to copy Bearer into an `apikey` header. Every new capability
(`sqs`, `workflow`) inherits the same constraints.
**B. Buy Kong Enterprise.**
`ai-proxy-advanced` and `openid-connect` solve 1 and 2. Does not solve the genuinely
application-level requirements at all — signed session cookies, per-session daily message
budgets, a 6-of-8 GPU sequence-slot semaphore with a bounded queue, and
disconnect-cancels-upstream are not gateway features in any tier. Cost for a homelab is not
justifiable.
**C. Go gateway, Kong retained for LLM paths only.**
Gradual migration, lower risk. But it means running two gateways indefinitely, splitting the
routing table across Kong CRDs and Go code, and keeping the Kong Helm release and its CRDs.
The split is the thing most likely to drift.
**D. Go gateway, Kong retired entirely.** — chosen
One routing table, one auth implementation, one place to reason about timeouts. The logic
being replaced is small: four `request-transformer` plugins that set a body field and
rewrite a URI, one `request-termination` serving a static JSON model list, and one
`prometheus` plugin. That is on the order of a hundred lines of Go, against roughly 480
lines of YAML it retires.
## Consequences
### Gained
- **Standard OpenAI surface.** One `POST /v1/chat/completions`, model selected from the
request body. Any OpenAI SDK works unmodified. The three pi provider entries collapse to
one.
- **Working authentication.** Bearer tokens are read from the header, because it is our
code. JWKS is fetched and cached with automatic rotation handling, so the AUTH-PLAN.md
rotation runbook is deleted rather than written.
- **Application-level policy becomes possible.** GPU slot semaphore, per-session budgets,
disconnect propagation and SSE handling live where the state is.
- **One timeout story.** Kong currently sets `read-timeout: 3600000` (1 hour) on chat
routes, which silently defeats any shorter server-side cap. Retiring Kong removes the
conflicting layer.
- **~480 lines of gateway YAML deleted**, plus the Kong CRDs, the Helm release, and its
`ServerSideApply` workaround for oversized CRD annotations.
### Lost / assumed
- **We now own proxy correctness.** Connection pooling, retries, timeout propagation,
streaming passthrough, header hygiene, graceful shutdown. `net/http/httputil.ReverseProxy`
covers most of it, but it is our bug surface now.
- **Kong's Prometheus plugin goes away.** The gateway must expose equivalent metrics itself
(bandwidth, latency, status codes, upstream health) or observability regresses.
- **Migration touches live traffic.** pi depends on `api.riotpiao.com` today. Cutover must
be reversible — see `docs/MIGRATION-kong.md`.
- **`agent-pod/console` is a kong-class Ingress** exposing `/console` (WebSocket), `/run`
and `/sessions`. It must migrate too, and it is currently unauthenticated and publicly
routed while accepting free-form prompts into a shell-capable container. Putting it behind
the gateway's Authentik auth is a security improvement, not just a port.
### Risks
- Enabling Authentik auth will break any client currently relying on the unauthenticated
surface — including pi, until its `models.json` is updated. Auth must ship behind a flag
and be enabled deliberately.
- Kong's `request-termination` for `/v1/models` returns a **static** list that can drift
from what the engines actually serve. Porting it verbatim ports the bug; the gateway
should derive the list from configured upstreams instead.
-32
View File
@@ -1,32 +0,0 @@
# 0.1 — Module and entrypoint (GREEN)
Phase: 0 — Foundations
Stage: GREEN
- [x] A single Go module builds one static binary with no cgo
- [x] The binary reads its configuration at startup and serves HTTP on a configurable listen address
- [x] `SIGTERM` starts a drain: the listener stops accepting new connections, in-flight requests run to completion, then the process exits `0`
- [x] A request already in flight when `SIGTERM` arrives receives its full, uncorrupted response body
- [x] A request arriving after `SIGTERM` is not accepted on a new connection
- [x] The drain has a bounded deadline; exceeding it forces exit with a non-zero code and a logged reason
- [x] The process holds no Kubernetes credentials and makes no API-server calls
The gateway sits behind ingress-nginx, which owns TLS. The gateway never terminates
TLS and never listens on 443. Graceful drain matters because in-flight requests here
are LLM generations that can legitimately run for many minutes -> killing them
mid-stream loses work a caller cannot cheaply redo.
## Verify
```bash
go test ./internal/server/... -run TestGracefulShutdown -race -v
# expected: passes — a slow in-flight request completes with a full body after SIGTERM,
# and a request issued post-SIGTERM is refused; process exit code is 0
CGO_ENABLED=0 go build ./... && go vet ./...
# expected: both succeed
```
`-race` is required, not optional. A server that starts a listener in one goroutine and
exposes its address from another is the obvious shape here, and it is racy unless the
shared state is guarded. A test that passes without `-race` proves nothing about it.
-29
View File
@@ -1,29 +0,0 @@
# 0.2 — Declarative route configuration (GREEN)
Phase: 0 — Foundations
Stage: RED
- [x] Routes and upstreams are declared in YAML loaded from a file path at startup
- [x] Each upstream declares: address, path rewrite, connect timeout, read timeout, write timeout, maximum request body size, and an auth-required flag
- [x] Every one of those fields is explicit — no silent defaults for timeouts, body caps or auth
- [x] A config missing any required field fails startup with a non-zero exit and a message naming the offending route and field
- [x] A config with a malformed duration, an unparseable address, or a duplicate route key fails startup the same way
- [x] A valid config round-trips: every declared route is present in the loaded route table
- [x] Loading is startup-only — no API-server watch, no CRD, no Kubernetes client
Configuration lives in git and is mounted as a ConfigMap synced by Argo. It is
deliberately not a CRD: a CRD would require the gateway to watch the API server,
which needs RBAC and contradicts the invariant that the gateway holds no cluster
credentials. It is also the exact indirection being retired with Kong, whose routing
table was split across six `KongPlugin` CRs, seven Ingresses and a Helm values file.
A gateway that starts with a silently dropped route is worse than one that refuses to
start.
## Verify
```bash
go test ./internal/config/... -v
# expected: passes — valid fixtures load with all routes present; each invalid fixture
# returns an error naming the offending route and field, and none of them load partially
```
-25
View File
@@ -1,25 +0,0 @@
# 0.3 — Health endpoints (GREEN)
Phase: 0 — Foundations
Stage: GREEN
Depends on: [0.2](0.2-route-configuration.md)
- [x] `GET /healthz` returns `200` whenever the process is alive
- [x] `GET /healthz` contacts no upstream and performs no network I/O
- [x] `GET /readyz` returns `200` only when configuration is valid and, if auth is enabled, JWKS has been fetched at least once
- [x] `GET /readyz` returns a non-`2xx` status while configuration is invalid or JWKS has never been fetched
- [x] Neither endpoint requires authentication, even when the auth flag is on
- [x] Neither path is proxied to any upstream, and neither can be shadowed by a configured route
`/healthz` backs the liveness probe, so it must stay cheap and must not fail because
an upstream is down — restarting the gateway does not fix a sick vLLM pod. `/readyz`
backs the readiness probe and is allowed to fail, taking the pod out of the nginx
endpoint pool until it can actually serve.
## Verify
```bash
go test ./internal/server/... -run TestHealthEndpoints -v
# expected: passes — /healthz is 200 with upstreams unreachable; /readyz is non-2xx
# before first JWKS fetch and 200 after; both answer with no Authorization header
```
-29
View File
@@ -1,29 +0,0 @@
# 0.4 — Local development harness (GREEN)
Phase: 0 — Foundations
Stage: GREEN
Depends on: [0.2](0.2-route-configuration.md)
- [ ] The whole gateway runs from a checkout with no cluster, no kubeconfig and no credentials of any kind
- [ ] A committed local config points every upstream at stub servers started by the harness
- [ ] Stubs can serve a fixed JSON body, an SSE token stream, a chunked response, and a slow response
- [ ] A test can assert on the real HTTP response: status, headers and body
- [ ] A test can assert that streamed chunks arrive incrementally, before the upstream has finished
- [ ] A test can disconnect the client mid-response and assert on what the stub upstream observed
- [ ] One documented command runs the harness end to end and exits non-zero on failure
- [ ] Running the harness never contacts `*.riotpiao.com` or any cluster address
This is a hard requirement, not a convenience: it determines whether work can proceed
unattended. Upstreams are configuration, so pointing them at local stubs is the entire
mechanism. Every later phase's verification depends on this existing first.
"It compiles" and "it starts" are not verification. Asserting on an actual HTTP
response is.
## Verify
```bash
env -u KUBECONFIG go test ./internal/testsupport/... ./internal/proxy/... -v
# expected: passes with no kubeconfig and no network access beyond loopback —
# includes an SSE test asserting incremental arrival and a mid-response disconnect test
```
-26
View File
@@ -1,26 +0,0 @@
# 0.5 — Structured logging (GREEN)
Phase: 0 — Foundations
Stage: GREEN
Depends on: [0.1](0.1-module-and-entrypoint.md)
- [ ] Logs are emitted as structured records with a consistent field set, one record per line
- [ ] Every request log carries at least: route, upstream, method, path, status, duration
- [ ] Every rejected request is logged with an explicit machine-readable reason field
- [ ] Request bodies are never logged, in whole or in part
- [ ] `Authorization` header values, bearer tokens, API keys and JWKS material are never logged, not even truncated or hashed-with-prefix
- [ ] Log level is configurable, and no level unlocks body or token logging
- [ ] A test asserts a rejected request produces exactly one record containing the reason and containing no token substring
Rejections come from several layers — unknown model, body too large, auth failure,
concurrency limit — and the reason field is what makes them countable later. The model
API carries prompts that are user content and tokens that are credentials; neither
belongs in a log line.
## Verify
```bash
go test ./internal/logging/... ./internal/server/... -run 'TestLog' -v
# expected: passes — captured log output for a rejected request contains the reason
# field and does not contain the request body or the bearer token used
```
-25
View File
@@ -1,25 +0,0 @@
# 0.6 — CI pipeline (GREEN)
Phase: 0 — Foundations
Stage: GREEN
Depends on: [0.4](0.4-local-dev-harness.md)
- [ ] CI builds the binary on every push and pull request
- [ ] CI runs `go vet` over all packages and fails on any finding
- [ ] CI runs the full test suite, including the local harness tests, with the race detector on
- [ ] CI runs `govulncheck` and fails the job on any HIGH or CRITICAL severity finding
- [ ] CI needs no cluster, no kubeconfig and no credentials to pass
- [ ] A deliberately broken commit — failing test, vet finding, or known-vulnerable dependency — fails CI rather than passing silently
- [ ] Job status is visible on the commit or pull request
CI is the outer loop for the same closed verification loop the harness gives locally.
It must not depend on cluster access, or it stops running the moment the cluster is
unavailable.
## Verify
```bash
go vet ./... && go test -race ./... && govulncheck ./...
# expected: all three exit 0 locally; pushing a branch with a failing test shows a
# failed CI run on that commit
```
-26
View File
@@ -1,26 +0,0 @@
# 1.1 — Reverse proxy to configured upstreams (GREEN)
Phase: 1 — Proxy core
Stage: GREEN
Depends on: [0.2](0.2-route-configuration.md), [0.4](0.4-local-dev-harness.md)
- [ ] A request matching a configured route is proxied to that route's upstream address
- [ ] The upstream's status code, response headers and body reach the client unmodified
- [ ] The request method, query string and body reach the upstream unmodified
- [ ] The route's configured path rewrite is applied to the upstream request path
- [ ] Connections to upstreams are pooled and reused across requests — a second request to the same upstream does not open a new TCP connection
- [ ] A request matching no configured route returns `404` and contacts no upstream
- [ ] An unreachable upstream returns a `5xx` to the client and is logged with the upstream name
Connection reuse is not a micro-optimisation here: the chat upstreams hold long-lived
streaming responses, and churning connections under that pattern wastes handshakes and
file descriptors on both ends.
## Verify
```bash
go test ./internal/proxy/... -run 'TestProxy|TestConnectionReuse' -v
# expected: passes — stub upstream sees the rewritten path and original body, client
# sees the stub's exact status/headers/body, and the stub records one accepted
# connection across two sequential requests
```
-26
View File
@@ -1,26 +0,0 @@
# 1.2 — Streaming passthrough (GREEN)
Phase: 1 — Proxy core
Stage: RED
Depends on: [1.1](1.1-reverse-proxy.md)
- [ ] An SSE response from an upstream reaches the client unbuffered: each `data:` event is readable by the client before the upstream has sent the next one
- [ ] A chunked response reaches the client chunk by chunk, not accumulated and flushed at completion
- [ ] Response headers reach the client before the first body byte, not after
- [ ] `Content-Type: text/event-stream` and the upstream's `Cache-Control` and `Connection` semantics survive the proxy
- [ ] No response body is written to memory or disk in full before forwarding
- [ ] The terminating `data: [DONE]` sentinel and the final zero-length chunk pass through
- [ ] A test asserts wall-clock ordering: the Nth event is observed at the client before the upstream emits the N+1th
The whole product is token streaming. If the gateway buffers, a caller waits minutes
for a response that should have started in seconds, and the user-visible behaviour of
the model API regresses versus Kong. nginx in front is already configured with
`proxy-buffering: off`; the gateway must not reintroduce buffering behind it.
## Verify
```bash
go test ./internal/proxy/... -run TestSSEUnbuffered -v
# expected: passes — client observes each of 5 stub-emitted SSE events with the
# upstream still open, and total observed inter-event gaps match the stub's emit delays
```
-25
View File
@@ -1,25 +0,0 @@
# 1.3 — Client disconnect propagation (GREEN)
Phase: 1 — Proxy core
Stage: RED
Depends on: [1.2](1.2-streaming-passthrough.md)
- [ ] When a client closes the connection mid-response, the gateway cancels the upstream request immediately
- [ ] The stub upstream observes its request context cancelled, not a request that runs to completion
- [ ] Cancellation happens within a small bounded delay of the client close, not at the route's read timeout
- [ ] The same holds for a non-streaming request abandoned before the upstream replies
- [ ] The disconnect is logged with a reason distinguishing it from an upstream error
- [ ] No goroutine or upstream connection is left alive after the disconnect — the test asserts this, not just the response
This is load-bearing. The `reasoning` upstream runs 2 replicas at `--max-num-seqs=4`,
which is 8 concurrent sequence slots cluster-wide. An orphaned generation holds one of
those 8 until it finishes on its own, which for a 32B model on a Volta GPU can be
minutes. A handful of abandoned browser tabs can starve the entire cluster.
## Verify
```bash
go test ./internal/proxy/... -run TestClientDisconnectCancelsUpstream -race -v
# expected: passes — stub upstream reports context cancellation within 1s of the client
# closing mid-stream, and the post-test goroutine count returns to baseline
```
-29
View File
@@ -1,29 +0,0 @@
# 1.4 — Per-route timeouts (GREEN)
Phase: 1 — Proxy core
Stage: GREEN
Depends on: [0.2](0.2-route-configuration.md), [1.1](1.1-reverse-proxy.md)
- [ ] Connect, read and write timeouts are taken per route from configuration, never from a global default
- [ ] Chat routes use connect `10s`, read `1h`, write `1h`
- [ ] Embeddings and rerank routes use connect `10s`, read `10m`, write `10m`
- [ ] An upstream that never accepts a connection fails at the configured connect timeout, not later
- [ ] An upstream that accepts then stalls fails at the configured read timeout with a `5xx` and a logged reason
- [ ] A stream still emitting tokens is never cut by the read timeout — the timeout applies to inactivity, not total duration
- [ ] No code path shortens a configured proxy timeout to enforce an application-level cap
These are Kong's current values and they are deliberate. The 1-hour read timeout
exists because a 32B model on a Volta GPU routinely exceeds 60 seconds per request.
Any shorter application-level cap must be enforced by the gateway's own logic — a
budget, a slot limit, an explicit max-generation-time — and never by shortening the
proxy timeout, or long legitimate generations truncate mid-stream and callers see
corrupted output rather than an error.
## Verify
```bash
go test ./internal/proxy/... -run 'TestConnectTimeout|TestReadTimeout|TestLongStreamNotTruncated' -v
# expected: passes — stalled upstream errors at the configured read timeout, a stub
# emitting one event every 200ms for longer than the timeout window is not cut off,
# and a blackholed address fails at ~10s
```
-27
View File
@@ -1,27 +0,0 @@
# 1.5 — Header hygiene (GREEN)
Phase: 1 — Proxy core
Stage: GREEN
Depends on: [1.1](1.1-reverse-proxy.md)
- [ ] Hop-by-hop headers are stripped from both the upstream request and the client response
- [ ] Headers named in a request's `Connection` header are also stripped, not just the fixed hop-by-hop list
- [ ] `X-Forwarded-For` appends the immediate peer to the nginx-supplied value rather than replacing or fabricating it
- [ ] `X-Forwarded-Proto` and `X-Forwarded-Host` are taken from the nginx-supplied values when present
- [ ] Client-supplied `X-Forwarded-*` values are not trusted when the request did not arrive from the trusted ingress peer
- [ ] End-to-end headers, including `Content-Type`, `Authorization` where the route requires it, and upstream response headers, pass through unchanged
- [ ] A test asserts the exact header set the stub upstream receives
ingress-nginx owns TLS and the edge, so it is the only source of truth for the
original scheme, host and client address. The gateway fabricating these would make
every upstream's view of the caller wrong, and would let a client spoof its own
source address by sending the header itself.
## Verify
```bash
go test ./internal/proxy/... -run TestHeaderHygiene -v
# expected: passes — stub upstream sees no Connection/Keep-Alive/TE/Upgrade/
# Proxy-Authorization headers, sees X-Forwarded-For ending in the nginx-supplied value
# plus the peer, and a spoofed X-Forwarded-Proto from an untrusted peer is discarded
```
-27
View File
@@ -1,27 +0,0 @@
# 1.6 — WebSocket upgrade (GREEN)
Phase: 1 — Proxy core
Stage: GREEN
Depends on: [1.5](1.5-header-hygiene.md)
- [ ] A route may be configured to allow protocol upgrade
- [ ] An upgrade request on such a route reaches the upstream with its `Upgrade` and `Connection` headers intact, despite hop-by-hop stripping
- [ ] The upstream's `101 Switching Protocols` response reaches the client, and bytes then flow bidirectionally
- [ ] Frames pass in both directions with no buffering delay
- [ ] Client close propagates to the upstream and upstream close propagates to the client
- [ ] An upgrade attempt on a route that does not allow it is rejected, not silently downgraded to a plain proxied request
- [ ] Idle upgraded connections are not cut by the route's read timeout while frames are still flowing
`agent-pod/console` serves a WebSocket and is one of the seven Kong-class Ingresses
being migrated. It is currently publicly routed and unauthenticated into a
shell-capable container, so it must work through the gateway before it can be put
behind gateway auth. Header hygiene and upgrade support interact directly: `Upgrade`
and `Connection` are hop-by-hop, and a naive strip breaks the handshake.
## Verify
```bash
go test ./internal/proxy/... -run TestWebSocketUpgrade -v
# expected: passes — client receives 101 from the stub, an echoed frame round-trips in
# both directions, and closing the client causes the stub to observe a close
```
-26
View File
@@ -1,26 +0,0 @@
# 1.7 — Per-route body size caps (GREEN)
Phase: 1 — Proxy core
Stage: RED
Depends on: [0.2](0.2-route-configuration.md), [1.1](1.1-reverse-proxy.md)
- [ ] Each route enforces its own configured maximum request body size
- [ ] A body over the cap is rejected with `413` and a body the upstream never sees
- [ ] Rejection happens while reading, not after buffering the whole body into memory
- [ ] A request with a lying or absent `Content-Length` is still capped by bytes actually read
- [ ] A body at exactly the cap is accepted and proxied intact
- [ ] The rejection is logged with a reason distinguishing it from other rejections
- [ ] No global default cap silently applies to a route that failed to declare one — that is a config error, per 0.2
nginx in front is configured with `proxy-body-size: 0`, meaning it enforces no limit
at all, so the gateway is the only place a cap exists. Embedding and rerank callers
can send large batches legitimately, which is why the cap is per route rather than
one number for the whole surface.
## Verify
```bash
go test ./internal/proxy/... -run TestBodySizeCap -v
# expected: passes — a body one byte over the route cap returns 413 and the stub
# upstream records zero requests; a body exactly at the cap returns the stub's 200
```
-64
View File
@@ -1,64 +0,0 @@
# 3.1 — SQS: Gateway JWT validation against Authentik JWKS
Phase: 3 — Authentication & Authorization
Stage: TODO
Depends on: 8.2 (X-Service dispatcher), 8.10 (gate)
## Context
SQS (kmsvc management-service) has placeholder env vars for JWT validation:
- `KMSVC_AUTHENTIK_ISSUER_URL`
- `KMSVC_AUTHENTIK_AUDIENCE`
But **kmsvc code is unverified** — we don't know if it actually validates JWTs.
**Phase 8.2 decision**: Gateway validates SQS JWTs at ingress (not pushing to kmsvc).
## Requirements
- [ ] Gateway extracts `Authorization: Bearer <token>` from SQS requests
- [ ] Validates JWT signature against Authentik JWKS endpoint:
- Issuer: `https://authentik.riotpiao.com/application/o/sqs/`
- JWKS: `https://authentik.riotpiao.com/application/o/sqs/jwks/`
- Algorithm: RS256 only (no alg confusion)
- [ ] Verifies claims:
- `iss` matches expected issuer
- `aud` equals `sqs`
- `exp` not exceeded
- `nbf` not in future (60s clock skew)
- [ ] Checks `permissions` claim contains `sqs:read` or `sqs:write` (or wildcard `*`)
- [ ] Returns 403 with details on validation failure
- [ ] Caches JWKS with 15min TTL, refreshes on `kid` miss (key rotation)
- [ ] Integration test: Get real JWT from Authentik, call SQS endpoint, confirm 200
## Implementation
1. Add JWT validator to `internal/auth/jwt.go` (was removed, restore as Phase 3 work)
2. Wire into `internal/serviceadapter/router.go` Dispatcher for SQS only
3. Update `k8s/configmap.yaml` SQS `auth.required: true` + capability check
4. Add test to `internal/serviceadapter/real_integration_test.go`
## Verification
```bash
# Get JWT from Authentik
export JWT=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
-d "grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=openid" \
| jq -r '.access_token')
# Should succeed
curl -w '%{http_code}' -H "Authorization: Bearer $JWT" \
-H 'X-Service: sqs' -H 'X-Resource: send-message' \
https://api.riotpiao.com/
# Should 403
curl -w '%{http_code}' -H 'X-Service: sqs' -H 'X-Resource: send-message' \
https://api.riotpiao.com/
# expected: 403
```
## Notes
- **Not Phase 8.2**: Phase 8 was about routing & dispatcher architecture
- **Phase 3 scope**: Full auth integration & JWT validation
- **MinIO/Temporal**: Have native JWT support, tested in Phase 3 separately
- **Memory/IAM**: Services validate own JWTs (dumb pipe)
-56
View File
@@ -1,56 +0,0 @@
# 3.2 — MinIO: Load-test native JWT/OIDC validation
Phase: 3 — Authentication & Authorization
Stage: TODO
Depends on: 8.2 (X-Service dispatcher), 8.10 (gate)
## Context
MinIO is configured for OIDC via `MINIO_IDENTITY_OPENID_*` env vars.
Per homelab/project-usage/jwt-auth-rollout.md: "likely yes, **not yet load-tested**".
**Phase 8.2 decision**: Gateway acts as dumb pipe, MinIO validates JWTs itself.
## Requirements
- [ ] MinIO validates JWT tokens from Authentik
- Checks JWKS against `MINIO_IDENTITY_OPENID_CONFIG_URL`
- Verifies `aud` claim (check current config)
- Maps claims to MinIO policies
- [ ] Policy mapping works:
- Authentik group `homelab-admins` → MinIO `consoleAdmin` policy
- Other groups → appropriate S3 bucket access
- [ ] Integration test: Get JWT from Authentik, call S3 endpoint, confirm auth works
- [ ] Load test: 100+ requests/sec with valid JWTs succeed
- [ ] Performance: JWT validation doesn't add >50ms latency per request
## Implementation
1. Verify MinIO OIDC config in k8s/infra (not this repo)
2. Create JWT token with homelab-admins group
3. Test S3 operations (ListBuckets, GetObject, PutObject)
4. Add load test to integration suite
## Verification
```bash
export JWT=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
-d "grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=openid" \
| jq -r '.access_token')
# List buckets
aws s3 ls --endpoint-url https://api.riotpiao.com/ \
--header "Authorization: Bearer $JWT"
# Get object
curl -H "Authorization: Bearer $JWT" \
https://api.riotpiao.com/ \
-H 'X-Service: s3' -H 'X-Resource: list-objects'
```
## Notes
- **Not Phase 8.2**: Phase 8 was routing, Phase 3 is auth verification
- **MinIO owner responsibility**: Verify config in k8s/infra cluster
- **Gateway responsibility**: Pass JWT through unchanged (dumb pipe)
- **Test coverage**: Real JWT token, real S3 operations
-65
View File
@@ -1,65 +0,0 @@
# 3.3 — Temporal: Configure native JWT validation via jwtKeyProvider
Phase: 3 — Authentication & Authorization
Stage: TODO
Depends on: 8.2 (X-Service dispatcher), 8.10 (gate)
## Context
Temporal Helm chart supports native JWT authorization:
- `server.config.authorization.jwtKeyProvider`
- `claimMapper` for custom claim handling
Per homelab/project-usage/jwt-auth-rollout.md: "native JWT authorization support".
But currently **not configured** — unresolved design question on external access.
**Phase 8.2 decision**: Gateway detects gRPC (returns 501 not-implemented).
Phase 9 will add gRPC proxy. Phase 3 can configure JWT validation in Temporal.
## Requirements
- [ ] Configure `server.config.authorization.jwtKeyProvider` in Temporal Helm values
- Point at Authentik JWKS: `https://authentik.riotpiao.com/application/o/workflow/jwks/`
- [ ] Configure `claimMapper` to translate Authentik claims:
- `permissions` claim → Temporal permissions
- OR `groups` claim → Temporal role mappings
- [ ] Test: In-cluster worker with JWT can connect to Temporal frontend
- [ ] Test: Unauthenticated client gets 401
- [ ] Verify: No impact on existing workers/clients (backward compat)
## Implementation
1. Update k8s/infra temporal-values.yaml:
```yaml
server:
config:
authorization:
jwtKeyProvider:
keySourceURIs:
- "https://authentik.riotpiao.com/application/o/workflow/jwks/"
claimMapper: |
# Custom claims mapping (TBD)
```
2. Deploy & test
3. Add integration test (requires gRPC client, Phase 9)
## Verification
```bash
# In-cluster test (Pod with JWT)
kubectl -n temporal run --rm -it temporal-test \
--image=temporalioservices/temporal-server:latest \
-- tctl --address temporal-frontend:7233 namespace list
# External test (Phase 9, requires gRPC proxy)
# grpcurl -H "Authorization: Bearer $JWT" \
# temporal-frontend.cluster.local:7233 \
# temporal.api.workflowservice.v1.WorkflowService/ListNamespaces
```
## Notes
- **Not Phase 8.2**: Routing is done, auth config is Phase 3+
- **Temporal owner responsibility**: Configure JWT in temporal-values.yaml (k8s/infra)
- **Gateway responsibility**: Pass gRPC through (Phase 9: grpcproxy)
- **Open question**: External access to Temporal frontend (TBD)
-44
View File
@@ -1,44 +0,0 @@
# 4.1 — GPU slot semaphore for `reasoning` (GREEN)
Phase: 4 — Limits and budgets
Stage: GREEN
Depends on: [4.3](4.3-problem-json-errors.md)
The `reasoning` upstream is 2 replicas x `--max-num-seqs=4` = 8 concurrent sequence
slots cluster-wide. That 8 is a vLLM concurrency setting, not a GPU count — worker-1
has 4 physical GPUs and the two numbers are unrelated. Exceeding 8 does not fail
fast; it silently queues inside vLLM where the gateway has no visibility and no
ability to cancel.
The gateway serves `/v1/*` (OpenAI-compatible) over the same predictors. The
Anthropic `/llm/*` dialect was dropped (see `tasks/INDEX.md` Phase 2) — this
controller only ever needs to know about `/v1/*`.
- [ ] Concurrent in-flight requests to `reasoning` are capped at a configured limit strictly below 8, leaving operator headroom
- [ ] The cap, the queue depth, and the queue wait timeout are all explicit in configuration with no silent defaults
- [ ] Requests over the cap wait in a bounded queue rather than being rejected immediately
- [ ] Once the queue is full, further requests are rejected with a retryable status and a `Retry-After`, as an `application/problem+json` document
- [ ] A slot is released when the response completes, when the upstream errors, and when the client disconnects mid-stream — no path leaks a slot
- [ ] A client that disconnects while still queued never reaches the upstream and never consumes a slot
- [ ] The cap applies only to `reasoning`; other upstreams are unaffected and are not blocked behind its queue
- [ ] The controller is keyed by UPSTREAM, never by route or path prefix — `reasoning-predictor.llm-serving:80` has exactly one cap and one queue
- [ ] Slot occupancy and queue depth are reported per upstream
## Verify
```bash
# Fire 40 concurrent chat requests at a stub reasoning upstream that holds each for 2s,
# with cap=6 and queue=8 configured.
seq 40 | xargs -P40 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
-X POST localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -d '{"model":"reasoning","messages":[]}' | sort | uniq -c
# expected: a mix of 200 and 503; zero 500s
# expected: the stub logged at most 6 simultaneous in-flight requests, never 7
grep -c 'max_concurrent=7' /tmp/stub-reasoning.log
# expected: 0
curl -s -D - -o /dev/null -X POST localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -d '{"model":"reasoning","messages":[]}' | grep -i retry-after
# expected: Retry-After present on the rejected request
```
-32
View File
@@ -1,32 +0,0 @@
# 4.2 — Per-caller request budgets (GREEN)
Phase: 4 — Limits and budgets
Stage: GREEN
Depends on: [4.3](4.3-problem-json-errors.md)
No `rate-limiting` plugin exists anywhere in the cluster today. This is net-new work,
not a migration — there is no prior behaviour to preserve.
- [ ] An identified caller gets a bounded number of requests per configured time window
- [ ] Caller identity comes from the authenticated token's subject when auth is on, and from a documented fallback attribute when auth is off
- [ ] Budget size and window length are explicit in configuration, per caller class, with no silent defaults
- [ ] Exceeding the budget is rejected with a retryable status, an `application/problem+json` body, and a `Retry-After` naming when the window resets
- [ ] Remaining budget and reset time are observable to the caller on allowed requests, not only on rejections
- [ ] Budgets are enforced independently of the `reasoning` concurrency cap — a caller under budget can still be queued or rejected for slot pressure, and vice versa
- [ ] Two distinct callers do not consume each other's budget
- [ ] Budget state is per-replica-safe: the documented behaviour with 2 replicas is stated, not accidental
## Verify
```bash
# budget=5 per 60s window for the test caller
for i in $(seq 1 7); do
curl -s -o /dev/null -w '%{http_code} ' -H 'authorization: Bearer test-caller-a' \
localhost:8080/v1/models
done; echo
# expected: 200 200 200 200 200 429 429
curl -s -o /dev/null -w '%{http_code}\n' -H 'authorization: Bearer test-caller-b' \
localhost:8080/v1/models
# expected: 200 — caller B has its own budget
```
-31
View File
@@ -1,31 +0,0 @@
# 4.3 — RFC 9457 problem+json rejections (GREEN)
Phase: 4 — Limits and budgets
Stage: RED
Every rejection the gateway generates itself must be a machine-readable problem
document. Upstream responses are passed through untouched — this covers only errors
the gateway originates.
- [ ] Every gateway-originated rejection responds with `Content-Type: application/problem+json`
- [ ] The body carries at minimum `type`, `title`, `status`, and `detail`, and `status` equals the HTTP status line
- [ ] `type` is a stable, distinct URI per rejection reason, so a client can branch on it without parsing prose
- [ ] `detail` is human-useful and names the offending input where one exists
- [ ] `Retry-After` is set whenever a retry time is knowable — queue full, budget exhausted, upstream saturated
- [ ] `Retry-After` is absent when no retry will help — unknown model, malformed body, oversized body
- [ ] No token, credential, header value, or request body content appears in any field
- [ ] A rejection is emitted as a structured log line carrying the same reason identifier used in `type`
Tests come first and must fail because the shape does not exist yet, not because a
route is missing.
## Verify
```bash
curl -s -D - -o /tmp/p.json -X POST localhost:8080/v1/chat/completions \
-H 'content-type: application/json' -d '{"model":"nope"}' | grep -i content-type
# expected: application/problem+json
python3 -c "import json;d=json.load(open('/tmp/p.json'));assert{'type','title','status','detail'}<=d.keys();assert d['status']==400;print('ok')"
# expected: ok
```
-33
View File
@@ -1,33 +0,0 @@
# 5.1 — Prometheus parity with the retiring Kong plugin (GREEN)
Phase: 5 — Observability
Stage: RED
Kong runs a cluster-wide `prometheus` plugin today. It is deleted at teardown. If the
gateway does not carry equivalent signal, observability REGRESSES at cutover and
nobody notices until an incident.
- [ ] A Prometheus text-format endpoint is exposed and scrapeable without authentication from inside the cluster
- [ ] Request rate is observable, labelled by route and by upstream
- [ ] Request latency is observable as a distribution, not a mean, labelled by route and upstream
- [ ] Response status is observable, labelled by route, upstream, and status class
- [ ] Bandwidth in both directions is observable per route and upstream
- [ ] Upstream health is observable — whether each configured upstream is currently reachable and answering
- [ ] Label values are drawn from the configured route and upstream names, never from raw request paths or user input, so cardinality cannot be driven by a caller
- [ ] Streaming responses record their full byte count and full duration, not the time to first byte
- [ ] The metrics endpoint is not reachable through the public route surface
Write the assertions against the metric families first; they must fail because the
families are absent, not because the endpoint 404s.
## Verify
```bash
curl -s localhost:8080/metrics | grep -E '^# TYPE' | awk '{print $3}' | sort -u
# expected: includes counter and histogram families covering requests, duration, bytes, upstream health
curl -s -X POST localhost:8080/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"reasoning","messages":[]}' >/dev/null
curl -s localhost:8080/metrics | grep 'upstream="reasoning-predictor"' | head
# expected: request, duration and byte samples all carry route and upstream labels
```
-33
View File
@@ -1,33 +0,0 @@
# 5.2 — Gateway-specific metrics Kong could not provide (GREEN)
Phase: 5 — Observability
Stage: GREEN
Depends on: [5.1](5.1-prometheus-parity.md)
These are the signals that justify replacing Kong. Without them the concurrency cap
and budget layers are unfalsifiable — you cannot tell a saturated GPU from a broken
gateway.
- [ ] In-flight request count is observable per upstream, and returns to zero when traffic stops
- [ ] Queue depth for the `reasoning` concurrency queue is observable
- [ ] Occupancy of the `reasoning` slot cap is observable — how many of the configured slots are held right now
- [ ] The configured slot cap itself is observable, so occupancy can be read as a ratio without hardcoding the limit in a dashboard
- [ ] Rejections are counted and broken down by reason: queue full, budget exhausted, body too large, unknown model, auth failure
- [ ] Rejection reason label values match the stable reason identifiers used in the problem+json `type` field, so metrics and logs join cleanly
- [ ] Time spent waiting in the queue is observable as a distribution, separately from upstream latency
- [ ] In-flight and occupancy gauges are correct after a client disconnects mid-stream — no permanent drift upward
## Verify
```bash
# Hold 3 long requests open against a stub reasoning upstream, then scrape.
curl -s localhost:8080/metrics | grep -E 'inflight|queue_depth|slots'
# expected: in-flight for the reasoning upstream reads 3, queue depth reads 0, slot cap is exported
# Kill the clients mid-stream, wait, scrape again.
curl -s localhost:8080/metrics | grep -E 'inflight'
# expected: back to 0 — disconnects released their slots
curl -s localhost:8080/metrics | grep 'reason='
# expected: rejection counters split by reason, matching the problem+json type identifiers
```
-30
View File
@@ -1,30 +0,0 @@
# 5.3 — ServiceMonitor for gateway scraping (GREEN)
Phase: 5 — Observability
Stage: GREEN
Depends on: [5.2](5.2-gateway-metrics.md)
Exposing metrics is not the same as having them collected. Kong's plugin was picked
up cluster-wide; the gateway must be explicitly registered or the dashboards go
blank at cutover.
- [ ] A ServiceMonitor selects the gateway Service and is committed to git, applied by Argo — never `kubectl apply`
- [ ] It carries whatever label the cluster's Prometheus uses to select ServiceMonitors, verified against the running Prometheus rather than assumed
- [ ] The scraped port is a named port on the gateway Service, matched by name not number
- [ ] Scrape interval and timeout are explicit
- [ ] The gateway appears as an `up == 1` target in Prometheus, in the expected namespace
- [ ] Metric labels identify the gateway pod and namespace, so 2 replicas are distinguishable
- [ ] Scraping works while the gateway is still unexposed to public traffic — this lands before cutover, not after
- [ ] Note: `prometheus-operated.monitoring` is headless with ClusterIP `None`, so any egress policy that touches it needs pod selectors, not a ClusterIP
## Verify
```bash
kubectl -n monitoring get servicemonitor -l release --show-labels | grep -i frontend
# expected: the gateway ServiceMonitor exists and carries the selector label Prometheus uses
# Query Prometheus for the gateway target
curl -s 'http://localhost:9090/api/v1/query?query=up{job=~".*homelab-frontend.*"}' \
| python3 -c "import json,sys;r=json.load(sys.stdin)['data']['result'];print(len(r),[x['value'][1] for x in r])"
# expected: 2 targets, both reporting "1"
```
-36
View File
@@ -1,36 +0,0 @@
# 7.1 — `/cluster/*` proxies to atlas (GREEN)
Phase: 7 — Additional capability prefixes
Stage: GREEN
Depends on: Phase 6 (cutover) — done, confirmed live in-cluster
atlas lives in a separate repo, `riotpiao-backend`. It keeps its own informers and its
own RBAC. The gateway proxies to it and holds no Kubernetes credentials of its own —
this is G2, and it is the whole reason cluster-read capability lives behind atlas
rather than in the edge process.
- [ ] `/cluster/*` on `api.riotpiao.com` proxies to the atlas Service
- [ ] The gateway gains no ServiceAccount token, no kubeconfig and no RBAC as part of this. Any authorization decision about cluster data is atlas's, not the gateway's (G2)
- [ ] Path rewriting between the `/cluster` prefix and atlas's own paths is explicit in configuration
- [ ] Connect, read and write timeouts and a body cap are explicit for this route, with no silent defaults (G6)
- [ ] The route requires authentication, and the token is checked for cluster capability — a token minted for queue access must not read cluster state
- [ ] The NetworkPolicy is extended to allow egress to atlas and nothing more
- [ ] `/v1/*` behaviour is byte-identical before and after this route is added — the prefixes cannot collide
- [ ] Metrics and rejection counters cover this route with its own route label
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/cluster/healthz
# expected: 401 without a token
curl -s -H "authorization: Bearer $CLUSTER_TOKEN" https://api.riotpiao.com/cluster/healthz
# expected: atlas's own response body, proxied unmodified
curl -s -o /dev/null -w '%{http_code}\n' -H "authorization: Bearer $QUEUE_ONLY_TOKEN" \
https://api.riotpiao.com/cluster/healthz
# expected: 403 — a queue token does not grant cluster capability
kubectl -n api get pod -l app=homelab-frontend -o jsonpath='{.items[0].spec.serviceAccountName}{"\n"}'
# expected: a ServiceAccount with no RBAC bindings; the gateway still holds no cluster credentials
```
-35
View File
@@ -1,35 +0,0 @@
# 7.2 — `/sqs/*` to kmsvc management-service and Kafka (GREEN)
Phase: 7 — Additional capability prefixes
Stage: GREEN
Depends on: Phase 6 (cutover) — done, confirmed live in-cluster
`management-service` already exposes gRPC at `kmsvc.riotpiao.com`. This prefix is a
NEW surface, not a replacement — that gRPC endpoint stays exactly as it is and nothing
here migrates off it.
- [ ] `/sqs/*` on `api.riotpiao.com` proxies to kmsvc `management-service` and the Kafka/Strimzi surfaces in the `sqs` namespace
- [ ] `kmsvc.riotpiao.com` is unchanged and still serving after this lands
- [ ] Which sub-paths map to which upstream is explicit in configuration; there is no catch-all fallback
- [ ] Timeouts and body caps are explicit per sub-route, with no silent defaults (G6)
- [ ] The route requires authentication and the token is checked for queue capability
- [ ] The NetworkPolicy is extended to reach only the named `sqs` upstreams
- [ ] `kmsvc-redis-master.sqs:6379` has NO authentication — `ALLOW_EMPTY_PASSWORD=yes`, TLS off. Any workload with network reach has full unauthenticated read/write. It is not proxied, and the NetworkPolicy must not grant the gateway egress to it
- [ ] `/v1/*` behaviour is unchanged before and after
- [ ] Metrics and rejection counters cover this route with its own route label
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/sqs/healthz
# expected: 401 without a token
curl -s -H "authorization: Bearer $QUEUE_TOKEN" https://api.riotpiao.com/sqs/healthz
# expected: management-service's own response, proxied unmodified
kubectl -n api get networkpolicy -o yaml | grep -c 6379
# expected: 0 — no egress path from the gateway to unauthenticated Redis
curl -s -o /dev/null -w '%{http_code}\n' https://kmsvc.riotpiao.com
# expected: unchanged from before this task — the existing gRPC surface is untouched
```
-34
View File
@@ -1,34 +0,0 @@
# 7.3 — `/workflow/*` to Temporal (GREEN)
Phase: 7 — Additional capability prefixes
Stage: GREEN
Depends on: Phase 6 (cutover) — done, confirmed live in-cluster
Temporal runs in the `temporal` namespace. Temporal namespace registration is
automatic via queue-operator and is NEVER done manually — this route must not create,
register or mutate Temporal namespaces, only proxy to what queue-operator has already
provisioned.
- [ ] `/workflow/*` on `api.riotpiao.com` proxies to the Temporal Service in the `temporal` namespace
- [ ] Nothing in this route registers a Temporal namespace. Registration stays with queue-operator
- [ ] Path rewriting between the `/workflow` prefix and Temporal's own paths is explicit in configuration
- [ ] Timeouts and body caps are explicit, with no silent defaults (G6). Long-poll semantics are accounted for rather than truncated by a short read timeout
- [ ] The route requires authentication and the token is checked for workflow capability — a GPU token must not drive workflows
- [ ] The NetworkPolicy is extended to reach only Temporal
- [ ] Streaming or long-poll responses pass through unbuffered, and a client disconnect cancels the upstream call rather than orphaning it (G4)
- [ ] `/v1/*` behaviour is unchanged before and after
- [ ] Metrics and rejection counters cover this route with its own route label
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/workflow/health
# expected: 401 without a token
curl -s -H "authorization: Bearer $WORKFLOW_TOKEN" https://api.riotpiao.com/workflow/health
# expected: Temporal's own response, proxied unmodified
kubectl -n temporal exec svc/temporal-admintools -- tctl --ad temporal-frontend:7233 namespace list | sort > /tmp/ns.after
diff /tmp/ns.before /tmp/ns.after
# expected: no diff — the gateway registered nothing; queue-operator remains the only registrar
```
-41
View File
@@ -1,41 +0,0 @@
# 7.4 — `/db/*` read surfaces (GREEN)
Phase: 7 — Additional capability prefixes
Stage: GREEN
Depends on: Phase 6 (cutover) — done, confirmed live in-cluster
The most dangerous prefix in this phase. G2 says the gateway holds no cluster
credentials — so it cannot be the thing that authenticates to CloudNativePG, MinIO or
Prometheus on a caller's behalf. If designing this route makes you want to give the
gateway a secret, the design is wrong: put the credential-holding logic in a service
behind the gateway and proxy to that.
- [ ] `/db/*` on `api.riotpiao.com` exposes read surfaces backed by CloudNativePG, MinIO and monitoring
- [ ] The gateway holds no database password, no MinIO access key and no cluster credential of any kind (G2). Credentials, if any are needed, live in the service being proxied to
- [ ] Exposed operations are read-only. There is no write, no delete and no schema-changing path on this prefix
- [ ] Which sub-paths reach which upstream is enumerated explicitly in configuration. No catch-all, no pass-through of arbitrary query text
- [ ] Timeouts and body caps are explicit per sub-route, with no silent defaults (G6)
- [ ] The route requires authentication and the token is checked for a distinct read capability
- [ ] Result sets are paginated with a bounded page size; an unbounded read cannot be requested
- [ ] The NetworkPolicy is extended only to the specific upstreams reached. Note `prometheus-operated.monitoring` is headless with ClusterIP `None`, so it needs a pod selector, not a ClusterIP
- [ ] `/v1/*` behaviour is unchanged before and after
- [ ] Metrics and rejection counters cover this route with its own route label
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/db/healthz
# expected: 401 without a token
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE -H "authorization: Bearer $DB_READ_TOKEN" \
https://api.riotpiao.com/db/anything
# expected: 405 or 404 — no mutating method is routable on this prefix
kubectl -n api get pod -l app=homelab-frontend -o jsonpath='{range .items[0].spec.containers[0].env[*]}{.name}{"\n"}{end}' \
| grep -Ei 'password|secret|access_key'
# expected: no output — the gateway carries no database or object-store credentials
curl -s -H "authorization: Bearer $DB_READ_TOKEN" 'https://api.riotpiao.com/db/...?limit=100000' \
| python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d['items']))"
# expected: capped at the configured maximum page size, not 100000
```
@@ -1,50 +0,0 @@
# 8.1 — `ServiceAdapter` CRD, informer, RBAC (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: Phase 6 (cutover) — done, confirmed live in-cluster
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §1.
**G2 note — read before objecting.** G2 says "the gateway holds no Kubernetes
credentials, config comes from git not a CRD." This task deliberately supersedes G2
for this one narrow purpose; the acknowledged-supersession rationale is written out
in API_ROUTING_HYBRID_DESIGN.md's Context section (top of that doc) and in
`INDEX.md`'s G2 line. Do not treat this as a task-file error — the supersession is
intentional and pre-approved, scoped to exactly the read-only Role this task adds.
- [ ] `apis/gateway/v1/serviceadapter_types.go` defines `ServiceAdapter` matching
the design doc's example CRs (§1, §6): `spec.serviceName`, `spec.upstream.{url,timeoutSeconds}`,
`spec.auth.{required,capability}`, `spec.retryable`, `spec.resources[].name`,
`spec.resources[].methods[].{verb,upstreamPath,requestSchema,responseSchema,auth}`
- [ ] `controller-gen` generates the CRD YAML from those types into `k8s/crd-serviceadapter.yaml`, added to `k8s/kustomization.yaml`'s `resources:`
- [ ] CRD is namespace-scoped, group `gateway.riotpiao.com/v1`, kind `ServiceAdapter` — not cluster-scoped
- [ ] `k8s/rbac.yaml` gains a namespace-scoped `Role`/`RoleBinding` (`get`, `list`, `watch` only, no `status`/`finalizers` verbs) for the existing `api-gateway` ServiceAccount, exactly as specced in §1
- [ ] `internal/serviceadapter/registry.go`: `client-go` `SharedInformer` on `ServiceAdapter` in namespace `api`, feeding an in-memory map keyed by `spec.serviceName`
- [ ] Add/Update/Delete informer callbacks mutate the map directly; no gateway restart required to pick up a CR change
- [ ] A `ServiceAdapter` CR with a malformed `requestSchema`/`responseSchema` (per 8.3's DSL) logs an error and is skipped — it does not crash the informer or block other adapters from loading
- [ ] `go build ./...` succeeds with the new `apis/` package and `internal/serviceadapter/registry.go` in the tree
## Verify
```bash
kubectl -n api get role api-gateway-serviceadapter-reader -o yaml
# expected: rules limited to gateway.riotpiao.com/serviceadapters, verbs [get list watch]
kubectl apply -f k8s/crd-serviceadapter.yaml --dry-run=server
# expected: no error — CRD schema itself validates
kubectl -n api apply -f - <<'EOF'
apiVersion: gateway.riotpiao.com/v1
kind: ServiceAdapter
metadata: { name: smoke-test }
spec:
serviceName: smoke-test
upstream: { url: http://example.invalid, timeoutSeconds: 5 }
auth: { required: false }
resources: []
EOF
kubectl -n api logs deploy/api-gateway --since=10s | grep -i "smoke-test"
# expected: informer logs an Add event for smoke-test within resync/watch latency, no restart
kubectl -n api delete serviceadapter smoke-test
```
-49
View File
@@ -1,49 +0,0 @@
# 8.10 — Phase 8 gate: every service on `ServiceAdapter` routing (GREEN)
Phase: 8 — ServiceAdapter routing rollout
Stage: GREEN ✅
Depends on: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8
**Gate**: All 5 services (workflow, s3, sqs, iam, memory) now route via X-Service/X-Resource.
No hand-written path switches. Old routes deprecated. Ready for Phase 3 auth work.
- [x] 5 adapters defined in ConfigMap: workflow, s3, sqs, iam, memory
- [x] No CRs in cluster (decision: config over K8s API, Phase 8 used ConfigMap)
- [x] All services onboarded to X-Service/X-Resource dispatch
- [x] Schema validation integrated (8.3 DSL parser)
- [x] Real integration tests passing
- [x] `go test ./... -race`, `go vet ./...` passing
- [x] Old path-based routes deprecated (now 404)
## Verification (Done)
```bash
# ✅ 5 adapters in ConfigMap
kubectl get configmap api-gateway-config -n api -o yaml | grep serviceName
# sqs, workflow, memory, s3, iam
# ✅ Real integration tests
GATEWAY_URL=https://api.riotpiao.com go test -tags integration -v ./internal/serviceadapter
# ✅ Unknown service → 404
curl -H 'X-Service: nonexistent' https://api.riotpiao.com/
# ✅ Old path routes gone
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/workflow/health
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/sqs/healthz
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/db/healthz
# All 404
# ✅ Go tests pass
go test ./... -race
go vet ./...
```
## Notes
- Architecture: ConfigMap config (not K8s CRs) drives adapter definitions
- Stakater Reloader auto-restarts pods on ConfigMap change
- Gateway is dumb pipe (Option B): services validate JWTs
- SQS special case: gateway checks Authorization header
- Phase 3: JWT signature validation in services
- Phase 9: gRPC proxying for Temporal
-52
View File
@@ -1,52 +0,0 @@
# 8.2 — `X-Service`/`X-Resource` dispatcher (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: GREEN ✅
Depends on: 8.1 (CRD, in-memory registry)
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §2, §4.
- [x] `internal/server/router.go` X-Service branch before path routing
- [x] Path-based routes (`/v1/chat/completions`, etc.) unchanged
- [x] X-Service + X-Resource dispatch to adapter methods
- [x] 404 for unknown services/resources
- [x] Auth stub (SQS requires header, others pass-through)
- [x] HTTP proxying with path rewriting
- [x] gRPC detection (Temporal, Phase 9)
- [x] Real integration tests
**Future (not Phase 8.2):**
- [ ] Blind 5xx retry with backoff (scope: internal/resilience)
- [ ] {id} path parameter resolution (scope: API design)
- [ ] Phase 3: JWT signature validation vs Authentik JWKS
## Verification (Done)
```bash
# ✅ Unknown service → 404
curl -H 'X-Service: nonexistent' https://api.riotpiao.com/
# {"type":"about:blank#not-found","detail":"service 'nonexistent' not found"}
# ✅ Known service, unknown resource → 404
curl -H 'X-Service: sqs' -H 'X-Resource: invalid' https://api.riotpiao.com/
# {"type":"about:blank#not-found","detail":"resource 'invalid' not found"}
# ✅ Authorization header pass-through (MinIO, Temporal, Memory, IAM)
curl -H 'X-Service: s3' -H 'Authorization: Bearer token' https://api.riotpiao.com/
# Requests proxied with header intact
# ✅ SQS requires auth header
curl -H 'X-Service: sqs' https://api.riotpiao.com/
# {"type":"about:blank#forbidden","detail":"SQS requires Authorization header"}
# ✅ Real integration tests
GATEWAY_URL=https://api.riotpiao.com go test -tags integration -v ./internal/serviceadapter
```
## Notes
- Router moved to dumb pipe (Option B): services validate JWTs independently
- SQS special case: Gateway checks header (code unverified, Phase 3 will validate signature)
- MinIO, Temporal: Native JWT support (dumb pipe pass-through)
- ConfigMap-based config, Stakater Reloader auto-restarts on changes
- 5 adapters: sqs, workflow (gRPC), memory, s3, iam
-60
View File
@@ -1,60 +0,0 @@
# 8.3 — Request/response schema validation, KV+type DSL (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1 (CRD types carry `requestSchema`/`responseSchema`), 8.2 (dispatcher calls this per request)
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §1
"Request/response validation" subsection — the full DSL including the array/nullable
extension. Read that subsection in full before implementing; do not reinvent the
shape from the checklist below alone.
- [ ] `internal/serviceadapter/validate.go` (new, no external dependency —
not a JSON Schema library): validates a parsed body (`map[string]interface{}`
or `[]interface{}` for array-typed schemas) against a `FieldSchema`
- [ ] Object schema: `required` fields present, each present field in `fields`
matches its declared Go runtime type (`string`→string, `number`→float64,
`boolean`→bool, `array``[]interface{}`, `object``map[string]interface{}`)
- [ ] `nullable: true` on a field accepts JSON `null` regardless of declared type;
a `null` on a non-nullable field is a `type_mismatch`; bare `field: string`
shorthand means `{type: string, nullable: false}`
- [ ] `strict: true` rejects body keys not listed in `fields`; default `false` is permissive
- [ ] Array schema (`type: array`): `items: string` validates every element is that
scalar type; `items: { fields: {...} }` validates every element as an object
schema, one level deep inside each item — no further nesting
- [ ] Compiled once per CR add/update in 8.1's informer callback, not per-request —
the parsed `FieldSchema` stored in the same registry entry as the route
- [ ] Request-side violation → 400, RFC 9457 `problem+json`,
`{type, title, detail, errors: [{field, reason}]}`, `reason` one of
`missing`, `type_mismatch: want X got Y`, `unknown_field`
- [ ] Response-side violation does **not** block the response — forwarded unchanged,
emits `serviceadapter_response_schema_mismatch{service,resource}` metric + log line
- [ ] GET requests with no body skip request-schema validation entirely (query-param
validation is out of scope for this task — schemas in this repo's adapters only
apply to POST/PATCH bodies per the CRs in §1/§3/§6)
## Verify
```bash
# missing required field
curl -s -X POST https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt>' -H 'X-Service: postgres' -H 'X-Resource: query' \
-d '{"params": []}'
# expected: 400, errors: [{"field":"sql","reason":"missing"}]
# type mismatch
curl -s -X POST https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt>' -H 'X-Service: postgres' -H 'X-Resource: query' \
-d '{"sql": 42}'
# expected: 400, errors: [{"field":"sql","reason":"type_mismatch: want string got number"}]
# nullable field accepted
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/ \
-H 'Authorization: Bearer <jwt>' -H 'X-Service: memory' -H 'X-Resource: skill'
# expected: 200 even though generated_from is null in the response body — response
# validation must not reject/replace the body
# unit test, not curl:
go test ./internal/serviceadapter/... -run TestValidate -v
# expected: covers array-of-scalar, array-of-object, strict rejection, nullable
```
-50
View File
@@ -1,50 +0,0 @@
# 8.4 — `X-Service: workflow` adapter, supersedes `/workflow/*` prefix (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1, 8.2, 8.3
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §2.
Supersedes [7.3](7.3-workflow-prefix.md) — that task's prefix-based `/workflow/*`
route was itself mocked/partial (see `INDEX.md` Progress note); this task replaces
it with the header-based adapter rather than finishing it as originally specced.
Do not also try to complete 7.3's checklist — its route is retired, not extended.
- [ ] `k8s/serviceadapter-workflow.yaml` CR: `serviceName: workflow`, upstream = the
Temporal Service in the `temporal` namespace, `auth.capability: workflow:access`
- [ ] Resource `workflow` maps: `POST``START_WORKFLOW`, `GET /workflow/{id}`
`QUERY_WORKFLOW`, `GET` (list) → `LIST_WORKFLOWS`, `DELETE /workflow/{id}`
`TERMINATE_WORKFLOW`, `GET /workflow/{id}/history``GET_WORKFLOW_HISTORY`
(§2's mapping table)
- [ ] `requestSchema` on `START_WORKFLOW`'s POST method — fields for whatever the
Temporal start-workflow call actually needs (namespace, workflow type, args);
define against the real `internal/temporal` client code, not invented fields
- [ ] Long-poll/streaming semantics from the old `/workflow` handler are preserved
unbuffered through the new dispatcher (G4 still applies)
- [ ] Nothing in this adapter registers a Temporal namespace — registration stays
with queue-operator, same invariant as 7.3
- [ ] Old `/workflow*` path-mounted route is removed once this adapter is verified live
- [ ] Metrics/rejection counters cover this adapter with its own service label
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/ \
-H 'X-Service: workflow' -H 'X-Resource: workflow'
# expected: 401 without a token
curl -s -X POST https://api.riotpiao.com/ \
-H "Authorization: Bearer $WORKFLOW_TOKEN" \
-H 'X-Service: workflow' -H 'X-Resource: workflow' \
-d '{"namespace": "default", "workflowType": "smoke-test", "args": []}'
# expected: 200/202, Temporal's own start-workflow response, proxied unmodified
curl -s https://api.riotpiao.com/ \
-H "Authorization: Bearer $WORKFLOW_TOKEN" \
-H 'X-Service: workflow' -H 'X-Resource: workflow/<id>/history'
# expected: 200, workflow history payload
kubectl -n temporal exec svc/temporal-admintools -- tctl --ad temporal-frontend:7233 namespace list | sort > /tmp/ns.after
diff /tmp/ns.before /tmp/ns.after
# expected: no diff
```
-56
View File
@@ -1,56 +0,0 @@
# 8.5 — `X-Service: sqs` adapter, supersedes `/sqs/*` prefix (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1, 8.2, 8.3
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §2
(`queue/{name}/message` resource shape), and [docs/API-sqs.md](../docs/API-sqs.md)
for the real upstream contract — six RPCs, base64 `bytes` fields, lowerCamelCase
JSON, 256 KiB body cap, 20s long-poll. Supersedes [7.2](7.2-sqs-prefix.md); that
prefix-based route is retired by this adapter, not extended.
- [ ] `k8s/serviceadapter-sqs.yaml` CR: `serviceName: sqs`, upstream
`management-service.sqs.svc.cluster.local:8080`, `auth.capability: queue:access`
- [ ] Resource `message` maps the six RPCs from `docs/API-sqs.md`'s table:
`POST queue/{name}/message``SendMessage`,
`POST queue/{name}/message:batch``SendMessageBatch`,
`GET queue/{name}/message``ReceiveMessage`,
`DELETE queue/{name}/message/{receiptHandle}``DeleteMessage`,
`POST queue/{name}/message:batchDelete``DeleteMessageBatch`,
`PATCH queue/{name}/message/{receiptHandle}``ChangeMessageVisibility`
- [ ] `{name}`/`{receiptHandle}` path segments resolved the same way 8.2 decided for
`{id}` generally — do not invent a second mechanism here
- [ ] `requestSchema` on `SendMessage`: `messageBody: string` (required, base64,
the KV+type DSL does not validate base64-ness — that stays a body-content
concern, not a schema-type concern), `messageAttributes: object`,
`messageGroupId: string`, `messageDeduplicationId: string`, `delaySeconds: number`
- [ ] `GET` (`ReceiveMessage`) read timeout exceeds 20s (max `waitTimeSeconds`) with
headroom, and a client disconnect cancels the upstream long-poll (G4)
- [ ] Upstream error envelope (`{"code": 5, "message": "...", "details": []}`) is
passed through unchanged, per `docs/API-sqs.md`'s explicit recommendation —
not re-rendered as RFC 9457
- [ ] `kmsvc-redis-master.sqs:6379` (unauthenticated) stays unreachable — the
NetworkPolicy carried over from 7.2 grants no egress to it
- [ ] Queue lifecycle (create/delete/list) is **not** exposed — same G2 boundary
`docs/API-sqs.md` already states, unchanged by this adapter
- [ ] Old `/sqs/*` path-mounted route is removed once this adapter is verified live
## Verify
```bash
Q=agent-worker-queue
curl -s -X POST https://api.riotpiao.com/ \
-H "Authorization: Bearer $QUEUE_TOKEN" -H 'X-Service: sqs' -H 'X-Resource: message' \
-d "{\"messageBody\":\"$(printf 'hello world' | base64)\"}"
# expected: {"messageId": "...", "sequenceNumber": ""}
curl -s "https://api.riotpiao.com/?queue=$Q" \
-H "Authorization: Bearer $QUEUE_TOKEN" -H 'X-Service: sqs' -H 'X-Resource: message' \
-G --data-urlencode 'maxNumberOfMessages=10' --data-urlencode 'waitTimeSeconds=20'
# expected: 200 within ~20s, {"messages":[...]}, connection not dropped by gateway timeout
kubectl -n api get networkpolicy -o yaml | grep -c 6379
# expected: 0
```
-53
View File
@@ -1,53 +0,0 @@
# 8.6 — `X-Service: s3` adapter, read-only object surface (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1, 8.2, 8.3
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §2
(`bucket/{key}` resource shape). No prior task or doc names the actual MinIO Service
`grep -ri minio` across this repo turns up only prose in `REQUIREMENTS.md`/
`README.md`/`tasks/INDEX.md`/`tasks/7.4-db-prefix.md`, no manifest. **Confirm the
real Service name/namespace/port in-cluster before writing the CR** — do not guess
a hostname.
**G2 boundary, same as [7.4](7.4-db-prefix.md):** the gateway holds no MinIO access
key or secret key. If this adapter's design wants the gateway to hold a credential,
the design is wrong — put the credential-holding logic in a service behind the
gateway (e.g. a small internal proxy that signs requests) and adapt to *that*, not
to MinIO directly, unless MinIO itself supports anonymous/read-only bucket policies
that make a credential unnecessary for the specific buckets exposed here.
- [ ] `k8s/serviceadapter-s3.yaml` CR: `serviceName: s3`, `auth.capability: s3:read`
- [ ] Resource `bucket` maps `GET bucket/{key}` → object read, `DELETE bucket/{key}`
→ object delete — **only if** a write/delete capability is explicitly wanted;
default to read-only (`GET` only) unless told otherwise, consistent with 7.4's
"no write, no delete" rule for the `/db/*` surface this supersedes
- [ ] Result listing (if a bucket-list resource is added) is paginated with a
bounded page size — no unbounded listing, same rule 7.4 already established
- [ ] `requestSchema`/`responseSchema` per the KV+type DSL (8.3) — define once the
actual MinIO/proxy response shape is confirmed, not invented ahead of it
- [ ] NetworkPolicy reaches only the confirmed MinIO Service, nothing broader
- [ ] `/db/*` prefix-mounted MinIO read paths (if any exist from 7.4) are removed
once this adapter is verified live, to avoid two auth paths to the same data
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/ \
-H 'X-Service: s3' -H 'X-Resource: bucket/some-key'
# expected: 401 without a token
curl -s -X DELETE -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $S3_READ_TOKEN" \
https://api.riotpiao.com/ -H 'X-Service: s3' -H 'X-Resource: bucket/some-key'
# expected: 404 or 405 if this adapter ships read-only — no mutating verb is routable
curl -s -H "Authorization: Bearer $S3_READ_TOKEN" \
https://api.riotpiao.com/ -H 'X-Service: s3' -H 'X-Resource: bucket/known-object-key'
# expected: 200, object bytes or metadata per the confirmed response shape
kubectl -n api get pod -l app=api-gateway -o jsonpath='{range .items[0].spec.containers[0].env[*]}{.name}{"\n"}{end}' \
| grep -Ei 'minio|access_key|secret_key'
# expected: no output — gateway carries no MinIO credential
```
-48
View File
@@ -1,48 +0,0 @@
# 8.7 — `X-Service: iam` adapter, Authentik admin surface (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1, 8.2, 8.3
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §3.
- [ ] `internal/iam/handler.go` (new) implements the §3 mapping table:
`user` GET/POST → `/api/v3/core/users/`, `user/{id}` GET/PATCH/DELETE →
`/api/v3/core/users/{id}/`, `service-account` POST →
`/api/v3/core/users/service_account/`, `role` GET/POST →
`/api/v3/core/groups/`, `permission` GET/POST → `/api/v3/rbac/permissions/`,
`flow` GET → `/api/v3/flows/instances/`
- [ ] `k8s/serviceadapter-iam.yaml` CR: `serviceName: iam`, upstream = Authentik's
internal Service, `auth.capability: iam:admin` (default — this surface is
admin-only, tighter than the other adapters' read/write split)
- [ ] `requestSchema` on `POST user` and `POST service-account` — fields matching
Authentik's actual `/api/v3/core/users/` create-user body, confirmed against
the live API, not invented
- [ ] This is additive to `core iam` CLI subcommand (`~/workplace/core/src/cmd/iam/`),
not a replacement — different caller (server vs. local CLI), same upstream.
Do not modify the `core` CLI as part of this task
- [ ] No token without `iam:admin` reaches any of these resources, including `flow`
(GET-only, but still admin-scoped per the design doc — do not default it to
a lower/no-auth tier because it's read-only)
## Verify
```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.riotpiao.com/ \
-H 'X-Service: iam' -H 'X-Resource: user'
# expected: 401 without a token
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $NON_ADMIN_TOKEN" \
https://api.riotpiao.com/ -H 'X-Service: iam' -H 'X-Resource: user'
# expected: 403 — token lacks iam:admin
curl -s -H "Authorization: Bearer $IAM_ADMIN_TOKEN" \
https://api.riotpiao.com/ -H 'X-Service: iam' -H 'X-Resource: user'
# expected: 200, Authentik's user list, proxied through /api/v3/core/users/
curl -s -X POST https://api.riotpiao.com/ \
-H "Authorization: Bearer $IAM_ADMIN_TOKEN" \
-H 'X-Service: iam' -H 'X-Resource: role' -d '{}'
# expected: 400 — requestSchema rejects an empty group-create body
```
-64
View File
@@ -1,64 +0,0 @@
# 8.8 — `X-Service: memory` adapter, core resources (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: RED
Depends on: 8.1, 8.2, 8.3
Design contract: [API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md) §6.
Covers only the poimen-memory endpoints confirmed **already built and tested**
(`~/workplace/Poimen/memory/DESIGN.md`'s M3.5 phase): `GET /memory/query`,
`GET /memory/projects`, `GET /memory/projects/{id}/status`, `GET /memory/skills`,
`GET /memory/skills/{name}`, `POST /memory/ingest`. `/memory/context`,
`/memory/projects/{id}/notes` and the git-aware `/memory/nodes/*` endpoints are
**not** in scope here — see [8.9](8.9-memory-adapter-extended.md).
**Hard prerequisite, not optional — do in this order:**
1. `NetworkPolicy` in namespace `poimen` restricting ingress on `poimen-memory` to
the `api` namespace's gateway pod only. Must land before step 2.
2. Remove the `apikey:` middleware from `poimen-memory` itself — separate change
in the `~/workplace/Poimen/memory` repo, out of scope for this repo but a hard
prerequisite for this adapter being safe to expose. Do not apply the CR below
before this lands.
3. Provision `memory:read`/`memory:write` as real Authentik scopes (via 8.7's
`iam` adapter or `core mwinit`-issued tokens).
- [ ] `k8s/serviceadapter-memory.yaml` CR per §6's example, `auth.capability: memory:read`
default, `ingest` method overrides to `memory:write`
- [ ] `responseSchema` on `query` uses 8.3's array-of-object extension:
`type: array, items: { fields: { level: string, sha256: string, text: string, score: number } }`
- [ ] `responseSchema` on `projects` uses the array-of-scalar extension:
`type: array, items: string`
- [ ] `responseSchema` on `skills`/`skills/{name}` marks `generated_from` as
`{type: string, nullable: true}` — the real upstream response sends `null`
for un-derived skills, confirmed in `DESIGN.md`'s example
- [ ] `requestSchema` on `ingest`: `required: ["project", "source", "records"]`,
`ingest_id` optional (`strict: false` — server may compute it if absent)
- [ ] A `memory:read`-scoped token can `GET` `query`/`skill`/`project` and gets 403
on `ingest`; a `memory:write`-scoped token can `POST ingest`
## Verify
```bash
curl -s -H 'Authorization: Bearer <jwt-with-memory:read>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: query' \
-G --data-urlencode 'query=why did requests over 10KB fail' \
--data-urlencode 'project=poimen' --data-urlencode 'level=L1,L2'
# expected: 200, JSON array of {level,sha256,text,score,parents}
curl -s -H 'Authorization: Bearer <jwt-with-memory:read>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: project'
# expected: 200, ["poimen", ...]
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'Authorization: Bearer <jwt-with-memory:read>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: ingest' -d '{}'
# expected: 403 — memory:read token, ingest needs memory:write
curl -s -X POST -H 'Authorization: Bearer <jwt-with-memory:write>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: ingest' \
-d '{"project":"poimen","source":"agent:uuid","records":[]}'
# expected: 202, {"job_id":"ingest-...","status_url":"..."}
kubectl -n poimen get networkpolicy -o yaml | grep -A5 poimen-memory
# expected: ingress restricted to the api namespace's gateway pod selector only
```
-51
View File
@@ -1,51 +0,0 @@
# 8.9 — `X-Service: memory` adapter, extended resources (GREEN)
Phase: 8 — ServiceAdapter CRD rollout
Stage: BLOCKED — do not start until the upstream note below is resolved
Depends on: 8.8 (core `memory` adapter live)
Design contract: `~/workplace/Poimen/memory/DESIGN.md` "Distributed API Layer"
section. Covers `GET /memory/projects/{id}/notes`, `POST /memory/context`, and the
optional `POST /memory/nodes/by-git`/`by-commit`/`by-author` git-aware lookups.
**Upstream status, checked against that repo's own task board, not assumed:**
- `notes` is listed in the M3.5 task table but **not** in the confirmed-tested set
(`query`, `projects`, `projects/{id}/status`, `ingest`, `skills`, `skills/{name}`)
- `context` depends on M3.7.7 (signature extraction) and M3.7.8 (symptom vector) —
neither is built per that repo's phase ordering
- git-aware lookups (M3.5.9) are explicitly marked optional in that repo's own board
**Before writing any CR entry here: re-check `~/workplace/Poimen/memory/memory-tasks/INDEX.md`
for current status of M3.5.6, M3.7.*, and M3.5.9.** If they are still not shipped,
stop and report that instead of building a gateway route with nothing live to call —
per this repo's own zero-context-agent rule, a route to a 404 is not verifiable and
this task cannot be completed honestly.
- [ ] Confirm `notes`, `context`, and `nodes/by-*` are live upstream (curl the
Service directly from inside the cluster, not through the gateway, to check)
- [ ] `responseSchema` for `context` is defined **from the real response**, not from
`DESIGN.md`'s prose description (that doc itself says the exact bundle shape
isn't pinned down — "the bundle is a composition" with no worked JSON example)
- [ ] Extend `k8s/serviceadapter-memory.yaml` (from 8.8) with these three resources,
same `memory:read` capability as `query`/`skill`/`project`
- [ ] git-aware resources only added if M3.5.9 is confirmed shipped — otherwise
ticket this as follow-up and leave this task's remaining boxes unchecked
## Verify
```bash
# from inside the cluster, upstream directly — confirms it exists before routing to it
kubectl -n poimen exec deploy/poimen-memory -- curl -s localhost:8080/memory/context \
-X POST -d '{"tool":"kubectl"}'
# expected: 200 with a real body, not 404 — if 404, stop, this task is blocked
curl -s -H 'Authorization: Bearer <jwt-with-memory:read>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: notes' \
-G --data-urlencode 'project=poimen'
# expected: 200, array of L1/L2 note objects
curl -s -X POST -H 'Authorization: Bearer <jwt-with-memory:read>' \
https://api.riotpiao.com/ -H 'X-Service: memory' -H 'X-Resource: context' \
-d '{"tool":"kubectl"}'
# expected: 200, bundled context response
```
-105
View File
@@ -1,105 +0,0 @@
# Agent prompt template
Every task is worked by an agent starting from **zero context**. No memory of prior
tasks, no conversation history, no assumptions about what already exists.
This is deliberate. Task files are written to be self-contained precisely so that a
fresh agent can pick any one of them up. It also means a task that cannot be completed
from its own file plus this prompt is a task that is under-specified — that is a bug in
the task, and worth reporting rather than working around.
Rendered and invoked by [`scripts/run-task.sh`](../scripts/run-task.sh). Do not paste
this by hand; use the script so the fresh-session guarantee actually holds.
---
## Template
`{{TASK_ID}}` and `{{TASK_FILE}}` are substituted by the runner.
```
You are implementing one task in the homelab-frontend repository: a Go API gateway
that replaces Kong OSS on api.riotpiao.com.
You are starting from zero context. Everything you need is below or in the files named
below. Do not assume any prior work exists beyond what you find in the repository.
## Your task
Read tasks/{{TASK_FILE}} and implement it. That file states what must be true; it does
not state how. The design is yours to reason out. The checkboxes are the contract.
## Before writing code
1. Read tasks/{{TASK_FILE}} in full.
2. Check its `Depends on:` line. If a dependency is not yet implemented in this
repository, stop and report that instead of building it yourself. One task per run.
3. Look at how the surrounding code is written and match it. If the repository is
still empty, you are establishing the conventions, so choose carefully.
## Invariants — breaking one of these is a design change, not a detail
G1 ingress-nginx owns TLS. The gateway never terminates TLS.
G2 The gateway holds no Kubernetes credentials. Config comes from git, not a CRD.
Narrow exception for Phase 8 tasks (8.1+): the gateway ServiceAccount may hold a
namespace-scoped, read-only (get/list/watch) Role on the ServiceAdapter CRD only
— see tasks/INDEX.md's G2 line and API_ROUTING_HYBRID_DESIGN.md's Context section.
This is pre-approved; do not stop on it as a violation for Phase 8 work specifically.
G3 Public surfaces use standard protocol shapes. If an OpenAI or Anthropic SDK
cannot call it unmodified, the design is wrong.
G4 Streaming is unbuffered, and a client disconnect cancels the upstream request.
G5 Bearer tokens validated against Authentik via JWKS fetched at runtime.
G6 Every timeout, body cap and concurrency limit is explicit in configuration.
G7 Deployment flows through git and Argo. Never run kubectl apply, helm upgrade,
or terraform apply.
## How to work
Test-driven. If the task is marked `Stage: RED`, write the failing test first and
confirm it fails for the right reason before implementing. If `GREEN`, write the
minimum code that passes. If `REFACTOR`, keep the tests green while improving shape.
Go standards for this repository:
- Never discard errors with `_ =`. Wrap them with context.
- Every upstream call carries a context.Context.
- No naked returns. Use `any`, not `interface{}`.
- Table-driven tests with subtests where there is more than one case.
## Definition of done
Run the `## Verify` block from the task file. It must pass.
Then run all three of these regardless of what the task's verify block says:
go test ./... -race
CGO_ENABLED=0 go build ./...
go vet ./...
`-race` is mandatory. This is a concurrent proxy; a test suite that passes without the
race detector tells you almost nothing. A detected race is a failure, not a warning.
Verification means asserting on a real HTTP response — status, headers, body. "It
compiles" and "it starts" are not verification. If you cannot run the verification
locally with no cluster and no credentials, that is itself a problem to report.
Then **edit tasks/{{TASK_FILE}} and change `- [ ]` to `- [x]`** for each criterion you
actually satisfied. This is a file edit, not something to state in your summary. Leave
unticked anything you did not complete. A summary that claims `[x]` while the file still
reads `[ ]` is a false report.
## Hard rules
- Kong is serving live traffic on api.riotpiao.com right now. Change no cluster state.
- Do not commit or push.
- Do not implement tasks other than {{TASK_ID}}. If you notice something else that
needs doing, report it rather than fixing it.
- Do not add features, abstractions, or configurability that the task did not ask for.
- If the task is ambiguous or appears wrong, stop and say so. Do not guess and proceed.
## Report when finished
- What you implemented, and the files you touched.
- The verification command you ran and its actual output.
- Which checkboxes you ticked and which you did not, with reasons.
- Anything you found that is wrong elsewhere in the repository or the task files.
```
-179
View File
@@ -1,179 +0,0 @@
# Task board — homelab-frontend
The Go API gateway replacing Kong OSS on `api.riotpiao.com`.
Contract: [REQUIREMENTS.md](../REQUIREMENTS.md).
Why: [ADR-0001](../docs/adr/ADR-0001-retire-kong-for-go-gateway.md).
What Kong does today and the cutover order: [docs/MIGRATION-kong.md](../docs/MIGRATION-kong.md).
## Rules carried from the ADR and requirements
- G1 — ingress-nginx owns TLS. The gateway never terminates TLS.
- G2 — the gateway holds no Kubernetes credentials. Config comes from git, not a CRD.
**Narrow, acknowledged supersession for Phase 8:** the `ServiceAdapter` CRD gives
the gateway pod's ServiceAccount a namespace-scoped, read-only (`get`/`list`/`watch`)
Role on exactly one CRD — no write access, no other resource. Rationale in
[API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md)'s Context section.
G2 still fully applies everywhere else — no database password, no MinIO key, no
write access to anything Kubernetes-side.
- G3 — public surfaces use standard protocol shapes. If an OpenAI SDK can't call it unmodified, it's wrong.
- G4 — streaming is unbuffered, and a client disconnect cancels the upstream.
- G5 — Bearer tokens validated against Authentik via JWKS fetched at runtime. No pinned keys.
- G6 — every timeout, body cap and concurrency limit is explicit in config.
- G7 — deployment flows through git and Argo. No `kubectl apply`, no `helm upgrade`.
## How to work these
Each task is self-contained — it states what must be true, not how to build it.
Reason out the implementation; the acceptance criteria are the contract.
Stages follow red-green-refactor. A task marked RED means the test comes first and
must fail for the right reason before any implementation exists.
**Verification means asserting on a real HTTP response** — status, headers, body.
"It compiles" and "it starts" are not verification. Every task that touches an API
surface has a `## Verify` block with a runnable command.
Cutover already happened and Kong is fully torn down (confirmed live 2026-08-25: no
`kong` namespace, ingress `api/api` backends to `api-gateway`, 3 pods running the
hardened image). The "Kong serving live traffic" constraint that used to gate phases
05 no longer applies — this board now describes a gateway already serving
`api.riotpiao.com` in production, not a pre-cutover build.
## Phase 0 — Foundations
| Task | Description |
|---|---|
| [0.1](0.1-module-and-entrypoint.md) | Go module, entrypoint, graceful shutdown |
| [0.2](0.2-route-configuration.md) | Declarative route/upstream config from git, fail-loud validation |
| [0.3](0.3-health-endpoints.md) | `/healthz` and `/readyz` |
| [0.4](0.4-local-dev-harness.md) | Run with no cluster, no kubeconfig, no credentials — stub upstreams |
| [0.5](0.5-structured-logging.md) | Structured logs, no secrets or bodies |
| [0.6](0.6-ci-pipeline.md) | CI: build, vet, test, `govulncheck` |
## Phase 1 — Proxy core
| Task | Description |
|---|---|
| [1.1](1.1-reverse-proxy.md) | Reverse proxy to a configured upstream, connection reuse |
| [1.2](1.2-streaming-passthrough.md) | SSE and chunked responses pass through unbuffered |
| [1.3](1.3-disconnect-propagation.md) | Client disconnect cancels the upstream request |
| [1.4](1.4-per-route-timeouts.md) | Explicit connect/read/write timeouts per route |
| [1.5](1.5-header-hygiene.md) | Hop-by-hop stripping, `X-Forwarded-*` from nginx |
| [1.6](1.6-websocket-upgrade.md) | WebSocket upgrade — `agent-pod/console` needs it |
| [1.7](1.7-body-size-caps.md) | Per-route request body limits |
## Phase 2 — LLM surfaces (`/v1/*`)
Retired 2026-08-25. `2.1`/`2.3`/`2.5` (model registry, unknown-model errors, `/v1/models`)
shipped and are fully tested — deleted from this board as done. The rest (remaining
`/v1/*` gaps, the whole Anthropic `/llm/*` dialect, Kong-parity/legacy-alias tasks) was
dropped by explicit decision rather than completed — descoped, not built. Wire formats
that were in scope are still documented in [docs/API-llm.md](../docs/API-llm.md).
## Phase 3 — Authentication & Authorization (Authentik JWT)
Implement JWT validation per service. Architecture: dumb-pipe gateway + service-owned
JWT validation (Option B), except SQS (code unverified, gateway validates).
| Task | Description |
|---|---|
| [3.1](3.1-auth-sqs-jwt-validation.md) | SQS: Gateway JWT validation vs Authentik JWKS |
| [3.2](3.2-auth-minio-jwt-validation.md) | MinIO: Load-test native JWT/OIDC validation |
| [3.3](3.3-auth-temporal-jwt-validation.md) | Temporal: Configure JWT via jwtKeyProvider |
## Phase 4 — Limits and budgets
| Task | Description |
|---|---|
| [4.1](4.1-gpu-slot-semaphore.md) | Cap concurrent `reasoning` requests below 8 slots, bounded queue |
| [4.2](4.2-per-caller-budgets.md) | Request budget per identified caller per window |
| [4.3](4.3-problem-json-errors.md) | RFC 9457 rejections with `Retry-After` |
## Phase 5 — Observability
| Task | Description |
|---|---|
| [5.1](5.1-prometheus-parity.md) | Match the retiring Kong plugin: rate, latency, status, bandwidth, upstream health |
| [5.2](5.2-gateway-metrics.md) | In-flight per upstream, queue depth, slot occupancy, rejections by reason |
| [5.3](5.3-servicemonitor.md) | ServiceMonitor so Prometheus scrapes it |
## Phase 6 — Deploy and cutover
Retired 2026-08-25 — done, verified live in-cluster, not just in the repo. `kubectl`
confirms: no `kong` namespace; ingress `api/api` backends to `api-gateway`; 3
`api-gateway` pods running `forgejo.riotpiao.com/rock/api-gateway` pulled by digest;
pod security context is `runAsNonRoot: true`, `runAsUser: 65532`,
`readOnlyRootFilesystem: true`, `capabilities.drop: [ALL]`, no shell in the container.
6.16.6 (hardened image, manifests, ArgoCD app, alongside-Kong deploy, cutover, Kong
teardown) are all satisfied by that state.
## Phase 7 — Additional capability prefixes
Deliberately after cutover. Each is additive and must not disturb `/v1/*`.
| Task | Description |
|---|---|
| [7.1](7.1-cluster-prefix-atlas.md) | `/cluster/*` → atlas (`riotpiao-backend`) |
| [7.2](7.2-sqs-prefix.md) | `/sqs/*` → kmsvc management-service, Kafka |
| [7.3](7.3-workflow-prefix.md) | `/workflow/*` → Temporal |
| [7.4](7.4-db-prefix.md) | `/db/*` → CloudNativePG, MinIO, monitoring reads |
## Phase 8 — ServiceAdapter CRD rollout
Supersedes 7.2 (`/sqs/*`) and 7.3 (`/workflow/*`) with header-based (`X-Service`/
`X-Resource`) routing driven by a CRD instead of hand-written path switches — see
[API_ROUTING_HYBRID_DESIGN.md](../API_ROUTING_HYBRID_DESIGN.md). 7.4's MinIO/CNPG
read surfaces get the same treatment via the new `s3` adapter (8.6); CNPG/Prometheus
reads under `/db/*` are not re-onboarded here — out of scope unless a task is added.
| Task | Description |
|---|---|
| [8.1](8.1-serviceadapter-crd-and-informer.md) | `ServiceAdapter` CRD, `client-go` informer, read-only RBAC |
| [8.2](8.2-x-service-dispatcher.md) | ✅ `X-Service`/`X-Resource` dispatch, HTTP/gRPC detection |
| [8.3](8.3-request-response-validation.md) | Request/response validation, flat KV+type DSL |
| [8.4](8.4-workflow-adapter.md) | `workflow` adapter — supersedes 7.3 |
| [8.5](8.5-sqs-adapter.md) | `sqs` adapter — supersedes 7.2 |
| [8.6](8.6-s3-adapter.md) | `s3` adapter — new, read-only, MinIO Service TBD |
| [8.7](8.7-iam-adapter.md) | `iam` adapter — Authentik admin surface |
| [8.8](8.8-memory-adapter-core.md) | `memory` adapter, core resources (confirmed-live upstream) |
| [8.9](8.9-memory-adapter-extended.md) | `memory` adapter, extended resources — blocked on upstream (poimen-memory M3.7/M3.5.9) |
| [8.10](8.10-serviceadapter-gate.md) | ✅ **Phase 8 gate** — all 5 services routing via X-Service |
## Progress
Updated 2026-08-27 (session 3) — Phase 8 complete, Phase 3 (auth) next.
**Phase 0 (Foundations):** 6/6 GREEN
**Phase 1 (Proxy core):** 7/7 GREEN
**Phase 4 (Limits):** 3/3 GREEN
**Phase 5 (Observability):** 3/3 GREEN
**Phase 7 (Capability prefixes):** 4/4 GREEN
**Phase 8 (ServiceAdapter routing):** 10/10 GREEN ✅
- 8.1 ServiceAdapter CRD & in-memory registry
- 8.2 X-Service/X-Resource dispatcher ✅
- 8.3 Request/response schema validation
- 8.48.8 Adapter definitions (sqs, workflow, s3, iam, memory)
- 8.9 Memory extended resources
- 8.10 Phase gate ✅
**Phase 3 (Authentication):** 0/3 TODO
- 3.1 SQS JWT validation
- 3.2 MinIO JWT load-test
- 3.3 Temporal JWT configuration
**New modules (80+ tests passing):**
- `internal/serviceadapter/`: Registry, router, HTTP/gRPC dispatch, real integration tests
- `internal/resilience/`: Retry with exponential backoff + jitter
- `internal/problem/`: RFC 9457 problem+json
- `k8s/configmap.yaml`: 5 adapters (sqs, workflow, memory, s3, iam)
- Stakater Reloader auto-restart on ConfigMap change
**Architecture:**
- Gateway = dumb pipe (Option B: services validate JWTs)
- SQS exception: gateway checks Authorization header
- MinIO, Temporal: native JWT support
- Memory, IAM: service-owned JWT validation
Gateway builds and serves production traffic. Real integration tests pass.