Commit Graph
14 Commits
Author SHA1 Message Date
Admin Bot 4e8319289b fix(proxy): address review findings on admission control (#64)
CI / CI (pull_request) Successful in 33m12s
- tracing: responseWriter now forwards Flush and exposes Unwrap. Without it
  w.(http.Flusher) failed for every request passing the tracing middleware, so
  the parked-stream keep-alive never started: a parked streaming request wrote
  nothing for up to ADMISSION_MAX_PARK_SECONDS, which is exactly the client
  timeout the keep-alive was added to prevent.
- proxy: once the SSE status line is spent, ModifyResponse frames an upstream
  response >= 400 as an event: error instead of letting a raw error body land
  inside the stream, where a streaming client reads it as an empty completion
  and reports success. net/http keeps the first status, so the upstream status
  cannot be forwarded after the park has written one.
- engine_state: new Snapshot() returns the state and its tick channel under one
  lock. Get and Tick taken separately can miss the poll that lands between the
  two calls, parking a request for an extra interval.
- engine_state: observer_degraded now tracks current parsability rather than
  being raised once on the first poll and never cleared, which pinned it at 1
  after a single truncated response and never reported drift that appeared
  later. markDegraded only touches the exporter when the value changes.
- deployment: ADMISSION_MAX_INFLIGHT is set explicitly to 4, documenting the
  coupling to the engine's --max-num-seqs, instead of silently defaulting to
  VLLM_MAX_NUM_SEQS.
2026-09-30 19:43:58 +08:00
poimenandrock 369d51a5c2 feat(proxy): engine-wide LLM admission control via vLLM gauges (#65)
CI / CI (push) Successful in 41m20s
Closes #64

Replaces the per-pod concurrency semaphore as the effective cap with admission gated on the vLLM engine's own scheduler gauges.

## Why

`internal/proxy/proxy.go:119` built a semaphore of 4 slots *inside each gateway process*, and `api-gateway` runs 3 replicas, so the advertised cap was 12 while the engine's `--max-num-seqs` is 4. Nothing coordinated replicas, and the mismatch was invisible because the queue never rejected anything (`queue_full_requests_total 0`).

Measured on the engine while investigating a 10-minute TTFT:

- `vllm:cache_config_info{num_gpu_blocks="9527",block_size="16"}` = **152,432 tokens** of KV capacity for the whole engine. PP=2 splits layers, so both ranks hold KV for the same tokens — not 2x that.
- Uncached prefill runs at **262 tok/s** contended, 525 tok/s over 7d (`vllm:request_prefill_time_seconds_sum/count`).
- One request showed `llm_ttft_seconds_sum 590.981 / count 5` at the gateway and a `(480, 960]` s entry in `vllm:request_prefill_time_seconds_bucket`, with engine-side generation throughput at 0.0-0.1 tok/s throughout: a 65k-token prompt costs ~250 s of prefill and starves every other request while it runs.
- `vllm:num_preemptions_total = 0`; KV peaked at `0.5399`.

Reading the engine's own gauges is global by construction, needs no lock and no new datastore, and it also accounts for callers that never pass the gateway — namespace `poimen` is admitted directly on 80/8080 by `llm-serving-ingress`, and `memory/crates/mem-cli/src/kv_warmer.rs` is such a caller. A distributed lock would have been blind to that traffic.

## Changes

- **new `internal/proxy/engine_state.go`** — 2s scrape of `http://<addr>/metrics` on its own client (the proxy transports carry socket options tuned for inference), hand-rolled exposition parse, `Healthy=false` after 3 consecutive failed polls with `engine_state_stale_total` counted once at the transition, and a startup self-check that raises `observer_degraded` and logs at error level instead of degrading to fail-open silently.
- **admission gate** (`internal/proxy/scheduler.go`) — parks while `kv_cache_usage_perc >= 0.90` or `running + waiting >= ADMISSION_MAX_INFLIGHT`, bounded by `ADMISSION_MAX_PARK_SECONDS`, then `503 + Retry-After: 5`. Unknown or unhealthy state fails **open** on purpose: a metrics outage must not become an inference outage.
- **parked streaming heartbeat** (`internal/proxy/proxy.go`) — `stream: true` requests get 200 + `text/event-stream` and a `: keep-alive` comment every 15s while parked, so a client idle timeout does not kill a request that is legitimately waiting. Non-streaming requests park silently.
- **`internal/proxy/router.go`** — extract only the top-level `model` field with a `json.Decoder` token walk instead of unmarshalling the whole body into `map[string]interface{}` (that built the entire `messages`+`tools` tree per request, ~300 KB for a 65k-token prompt). `MaxBodySize` now comes from `MAX_BODY_BYTES`; the model path previously inherited the 100 MB default at `proxy.go:93` against a 512Mi container limit.
- **`cmd/gateway/main.go`** — `GOMAXPROCS` from the cgroup CPU quota (cgroup v2 `cpu.max`, v1 fallback). The container is limited to `500m` while Go defaulted to the host core count and was being CFS-throttled.
- **`k8s/deployment.yaml`** — `ADMISSION_MAX_PARK_SECONDS=120`, `ADMISSION_OBSERVE_MODELS=reasoning`, `VLLM_MAX_QUEUE_DEPTH=8`, `MAX_BODY_BYTES=1048576`; corrected the `llm-client` comment (the NetworkPolicy selects by namespace now, not by that label).
- **deleted `k8s/configmap.yaml`** — absent from `k8s/kustomization.yaml`, and the live ConfigMap of that name carries an unrelated key (`gw-config-fixed.yaml`) plus a stale routing table.
- **`internal/observability/`** — `admission_parked_total`, `admission_rejected_total{reason}`, `engine_state_stale_total`, and gauges `vllm_requests_running` / `vllm_kv_usage_perc` (the gateway's own reading, deliberately mirroring the vLLM series so a disagreement is visible) and `observer_degraded`. Names follow the existing convention, which carries no prefix.

Out of scope here, in `riotpiao-poimen/homelab#51`: the engine flags (`--max-num-partial-prefills`, `--max-long-partial-prefills`, `--long-prefill-token-threshold`, `--max-num-batched-tokens`, `--max-num-seqs`, `--gpu-memory-utilization`) and the four dashboard panels in `generate-dashboards.py`.

## Verification

- `go build ./...` clean, `go vet ./...` clean.
- `go test ./...` — 368 tests, 0 failures (baseline on `main` is 368/0).
- `structural-check.sh` on the changed files — 0 errors, 1 pre-existing warning (`:latest` image tag).
- `go.mod` / `go.sum` byte-identical: no new dependency.

Not yet verified: the real-engine load test (2x65k + 15x8k against `reasoning`). That run also produces the number for `ADMISSION_MAX_PARK_SECONDS`, which today is the provisional 120; retuning it is a follow-up since it is env-only and needs no rebuild.

## Accepted risks

- **No tests were added** for the observer or the gate (deliberate: production is the test bed). The mitigation is the startup self-check, which is production self-diagnosis rather than a test, plus the `observer_degraded` alert. A parse failure that outlives the first poll logs and surfaces as a metric; it does not silently disable gating without a signal.
- **The cap is soft, not hard.** Between polls each replica may admit more than one request, so there is no clean bound. Exposure is low (~11,500 requests over 7d ≈ 1.4/min) and the engine remains the hard limit — a race parks requests in vLLM's queue rather than over-committing KV.
- **`ADMISSION_MAX_INFLIGHT` is manually coupled to `--max-num-seqs`** and can drift silently. It defaults to `VLLM_MAX_NUM_SEQS`, and startup logs the effective value.
- The first scrape waits one interval, so the first ~2s of admission is ungated (fail-open). Scraping at `Start()` instead would make the gateway contact every observed backend before any request needs one, which is wasted work and a surprising side effect.

## Rollback

Revert this commit and redeploy. The admission path is fail-open, so the code can also be neutralised without reverting by unsetting `ADMISSION_OBSERVE_MODELS`. Separately, the orphaned cluster object can be removed by hand: `kubectl delete cm -n api api-gateway-config` (not a code change, deliberately left to a human).

---------
Reviewed-on: #65
Co-authored-by: poimen <[email protected]>
2026-09-30 10:56:10 +00:00
Admin Bot 3ca654513f fix(promoter): correct Forgejo registry service hostname
CI / CI (pull_request) Failing after 2m6s
forgejo-gitea-http.cicd.svc.cluster.local (headless svc, port 3000)
not forgejo-gitea (doesn't exist). Also wire REGISTRY_BASE env var
in deployment.yaml so it can be overridden without code change.
2026-09-28 12:34:54 +08:00
rockandAdmin Bot 23fff1006a feat(webhook): Forgejo package webhook → ArgoCD image promotion (#55)
CI / CI (push) Successful in 3m21s
Closes #54

Replaces ArgoCD Image Updater with webhook-driven promotion.

## What changed

| File | Change |
|---|---|
| `internal/webhook/promoter.go` | NEW — ImagePromoter + promotion table (10 entries) |
| `internal/webhook/promoter_test.go` | NEW — 13 tests |
| `internal/webhook/forgejo.go` | package event routed to ImagePromoter |
| `k8s/rbac.yaml` | NEW — ClusterRole image-promoter + ClusterRoleBinding |
| `k8s/deployment.yaml` | REGISTRY_PAT env var |
| `k8s/network-policy.yaml` | egress cicd:3000 + default:443 |
| `k8s/kustomization.yaml` | rbac.yaml added |

## homelab repo changes needed

See comment on issue #54 for exact YAML.

---------

Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: #55
2026-09-28 02:45:41 +00:00
rockandpoimen f2cff13629 fix(ci): relax namespace validation test + add webhook endpoint test (#30)
CI / CI (push) Failing after 18m58s
## Problem
CI integration test runs against the **live deployed gateway** (old image). The namespace validation test expects 400, but old image returns 404 → CI never promotes new image → chicken-and-egg.

## Fix
- Accept 400 or 404 for namespace test during rollout
- Add unit test confirming WorkflowAdapter returns 400 (passes locally)
- Add integration test for `POST /v1/webhooks/forgejo`

## Tests
- `go test ./internal/serviceadapter/... -run TestWorkflowListRequiresNamespace` passes locally

---------

Co-authored-by: Poimen <[email protected]>
Reviewed-on: #30
2026-09-16 03:08:20 +00:00
Admin Bot 2171424220 feat(webhook): Forgejo → Gotify notification bridge
CI / CI (pull_request) Failing after 21m42s
POST /v1/webhooks/forgejo receives Forgejo payloads, verifies HMAC
SHA256 signature, formats event into Gotify push notification.

Supported events: push, pull_request, issues, issue_comment,
pull_request_review_comment, release.

Env vars: GOTIFY_URL, GOTIFY_APP_TOKEN, FORGEJO_WEBHOOK_SECRET
Secret: gotify-webhook-secret in api namespace
2026-09-16 07:49:15 +09:00
poimenandrock 1c64d8ff0e feat(config): add tokenUrl, clientId, clientSecret to auth config (#17)
Closes homelab#12 (P3.7)

## Changes

- `AuthConfig`: added `TokenURL`, `ClientID`, `ClientSecret` fields
- `loader.go`: reads `tokenUrl`/`clientId` from YAML, `ClientSecret` from `AUTH_CLIENT_SECRET` env
- `deployment.yaml`: `AUTH_CLIENT_SECRET` from `api-gw-client-secret` Secret (optional)
- `gateway-config-secret.enc.yaml` + `configmap.yaml`: added `tokenUrl` and `clientId`

## Secret never in YAML

`clientSecret` deliberately omitted from YAML struct. Loaded from env only.

## Tests

3 tests: full config load, env-only secret, backward compat (missing fields = zero).

Co-authored-by: poimen <[email protected]>
2026-09-08 23:43:29 +00:00
Admin Bot 2bcf6c82fc fix: move gateway config from plaintext ConfigMap to SOPS-encrypted Secret 2026-09-05 00:28:45 -07:00
Admin Bot f9addf945d feat(tracing): add OpenTelemetry instrumentation to API gateway
CI / Vet, test, build (push) Successful in 3m43s
CI / Build and push image (push) Successful in 1m33s
- Add internal/tracing package with OTel tracer initialization
- HTTP middleware for server-side tracing (request/response attributes)
- Transport wrapper for client-side upstream call tracing
- Update proxy to use tracing transport
- Add OTEL_* env vars to k8s deployment

Traces flow: api-gateway -> otel-collector -> tempo -> grafana
2026-08-31 15:01:56 -07:00
Admin Bot 81e228e818 feat: add stakater reloader annotation for configmap auto-reload
CI / Vet, test, build (push) Successful in 2m5s
CI / Build and push image (push) Successful in 51s
2026-08-26 16:41:42 -07:00
Admin Bot 9c5fb0ce84 feat: load service adapters from ConfigMap, remove k8s API dependency
CI / Vet, test, build (push) Canceled after 2m10s
CI / Build and push image (push) Canceled after 0s
Adapters defined in config.yaml alongside routes and models.
Parsed by existing config loader, populated into registry at startup.
Removed: client-go deps, REST loader, informer, nginx proxy,
CiliumNetworkPolicy, apis/gateway/v1/ (duplicate types).
Kept: merged CI pipeline, imagePullPolicy Always, CA certs in Dockerfile.
2026-08-26 16:39:30 -07:00
Story Crater Bot 2aabd4288b fix: label gateway pods llm-client=true so upstreams accept them
llm-serving-default-deny admits only llm-client=true pods on 8080; without the
label every dispatch timed out with 502.
2026-08-20 00:06:23 -07:00
Story Crater Bot b0ce2fb67c feat: build and publish the gateway image via Forgejo Actions
- Dockerfile: multi-stage, distroless nonroot, CGO_ENABLED=0 static, commit
  SHA stamped via VERSION build arg.
- .forgejo/workflows/ci.yaml: Forgejo reads .forgejo/, not .github/, and the
  runner declares only the "docker" label. Verify job on every push; image
  build and push gated to main.
- Drop .github/workflows/ci.yml — this remote is Forgejo, so it never ran.
- deployment.yaml: image from the Forgejo registry, forgejo-registry pull
  secret, runAsUser 65532 to match distroless nonroot.
- kustomization.yaml: pin the tag in one place. Promoting a build is a
  one-line newTag bump, never :latest.
2026-08-19 21:48:11 -07:00
Story Crater BotandClaude Opus 5 058f11cf2b chore: initial commit of Go API gateway
CI / Test (push) Canceled after 0s
CI / Vet (push) Canceled after 0s
CI / Build (push) Canceled after 0s
CI / Security (govulncheck) (push) Canceled after 0s
Baseline for the Kong replacement on api.riotpiao.com. Brings the working
tree under version control for the first time: gateway source, the task
board that drives the agent runs, test fixtures, and K8s manifests.

Anchor the gateway ignore rule to the repo root. Unanchored, "gateway"
also matched the cmd/gateway/ source directory, so the program entrypoint
was excluded from every commit.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:54:34 -07:00