The comment pass is prose-only: every distinct "why" is kept, the narration around it is not. Verified by AST-comparing each changed file against HEAD with docstrings stripped — only the two files below differ in executable code. Two real fixes fell out of the read-through: * The CLI documented itself as never touching the database, then called load_settings(), which requires SVCFORGE_PG_DSN. It refused to start without a Postgres URL it never opens. It now has its own two-field ClientSettings; the orphaned api_url/api_token are dropped from Settings, where nothing else read them. * repo/db.py had the DictRow alias comment and the ERROR_MAX_CHARS comment run together above the wrong symbol. USER_GUIDE.md is the caller-facing guide the README only gestured at: auth, catalog, every endpoint with curl, the lifecycle, the error table, rate limiting, the CLI, client generation, an end-to-end poll loop. It records two facts about the live deployment rather than documenting a flow nobody can run. SVCFORGE_JWKS_URL points at a realm with no IdP behind it, so the API logs "JWKS warm-up failed" at startup and every /v1 request is a 401. And `helm repo list` in the worker returns no repositories, so the three bitnamilegacy/ catalog entries cannot resolve at provision time; only the oci:// entries can. make lint clean, 76 unit + 111 integration tests pass.
9.7 KiB
svcforge — user guide
Ask for a managed service, get one. svcforge provisions Elasticsearch, Redis, Postgres and a couple of tiny test services into Kubernetes, one namespace per team, and keeps them matching what the database says they should be.
Base URL: https://svcforge.oci-oci.duckdns.org
| Swagger UI (send requests from the browser) | /docs |
| ReDoc (nicer to read) | /redoc |
| Raw OpenAPI spec | /openapi.json |
The spec is generated from the same models and routes the server runs, so it cannot drift from the implementation. Generate a client from it rather than hand-rolling one.
The three things that will surprise you
- Writes are asynchronous.
POSTandDELETEreturn 202 Accepted. Nothing is provisioned when you get the response — you have a row and a queued task. PollGET /v1/instances/{id}and watchstate. - Authorisation is a WHERE clause. Another team's instance returns 404, not 403. The API never confirms that an id you cannot access exists.
- Every non-2xx body is
{"code", "message"}— including the 404s and 405s raised by the framework itself. Never branch on the body's shape.
Getting a token
Every /v1 route needs Authorization: Bearer <jwt>. The token is verified against the
configured JWKS (RS256 only), and its team claim decides which instances you see.
aud, iss and exp are all required and all checked.
The public deployment currently issues no tokens.
SVCFORGE_JWKS_URLpoints athttps://auth.oci-oci.duckdns.org/realms/svcforge/..., and no identity provider is deployed there — the hostname resolves to the ingress, which answers with its default self-signed certificate. The API logsJWKS warm-up failedat startup and every/v1request returns401. Unauthenticated routes (/healthz,/readyz,/metrics,/docs,/openapi.json) work normally. To make the live API usable, either deploy an OIDC provider at that realm URL or repointauth.jwksUrl/auth.issuerindeploy/chart/values.yamlat one that exists.
Any OIDC provider works. With Keycloak, the client-credentials flow is:
TOKEN=$(curl -s -X POST \
https://auth.example.com/realms/svcforge/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id=svcforge-cli \
-d client_secret="$CLIENT_SECRET" | jq -r .access_token)
The provider must put a team claim in the token (a Keycloak protocol mapper, or the
equivalent) and set aud: svcforge. A token without a non-empty string team is a 401.
Running it locally instead
The fastest way to actually drive the API is to run it yourself with auth off:
export SVCFORGE_PG_DSN="postgresql://svcforge:svcforge@127.0.0.1:5432/svcforge"
export SVCFORGE_AUTH_DISABLED=true # refused unless SVCFORGE_ENVIRONMENT=local
uv run uvicorn services.api.main:app --factory
Every request is then team platform and no header is needed. check_production() refuses
this flag whenever SVCFORGE_ENVIRONMENT is anything but local, so it cannot escape a
laptop.
The catalog
service_type and size must both exist in the catalog. An unknown service_type is a
404; a real service type with an unknown size is a 422 that lists the valid sizes.
service_type |
size |
Memory request | Notes |
|---|---|---|---|
elasticsearch |
small, medium |
1Gi / 4Gi per replica | 1 or 3 replicas |
redis |
small, medium |
256Mi / 1Gi | 1 or 3 replicas |
postgres |
small, medium |
512Mi / 2Gi | 1 or 2 replicas |
podinfo |
small, medium |
16Mi / 32Mi | tiny, for exercising the platform |
nginx |
small, medium |
32Mi / 64Mi | tiny, for exercising the platform |
Use podinfo or nginx to exercise the control plane: they are single small pods and fit
on a node with no room for a real Elasticsearch. Both are pulled straight from an OCI
registry. The three larger entries reference a bitnamilegacy/ chart repo that the worker
image does not currently configure, so they will fail at provision time until it is added.
Endpoints
POST /v1/instances → 202
curl -X POST https://svcforge.oci-oci.duckdns.org/v1/instances \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"service_type": "podinfo", "size": "small", "ttl_days": 7}'
{
"id": "0f8b7d3e-1c2a-4f5b-9e6d-7a8b9c0d1e2f",
"state": "requested",
"service_type": "podinfo",
"size": "small",
"endpoint": null,
"chart_version": "6.7.1",
"error": null
}
The response carries a Location header pointing at the instance. ttl_days (1–30,
optional) deletes the instance automatically; omit it for no expiry. Unknown body fields
are rejected with a 422 rather than ignored.
GET /v1/instances/{id} → 200
The polling endpoint. Repeat until state is ready or failed.
curl -H "Authorization: Bearer $TOKEN" \
https://svcforge.oci-oci.duckdns.org/v1/instances/$ID
GET /v1/instances → 200
Your team's instances, newest first. ?limit= accepts 1–200 and defaults to 50. No
endpoint here returns "all rows".
DELETE /v1/instances/{id} → 202
curl -X DELETE -H "Authorization: Bearer $TOKEN" \
https://svcforge.oci-oci.duckdns.org/v1/instances/$ID
Moves the instance to deleting and queues the teardown; the helm uninstall has not
happened when this returns. Deleting something already deleting or deleted is a 409.
Unauthenticated
GET /healthz (liveness, no I/O) · GET /readyz (readiness, checks Postgres only) ·
GET /metrics (Prometheus exposition).
The lifecycle
requested ──> provisioning ──> ready ──> deleting ──> deleted
│ │
└──────────────┴──> failed
| State | What it means |
|---|---|
requested |
The row exists and a provision task is queued. |
provisioning |
A worker is running helm upgrade --install. |
ready |
The release is up. Only this state carries a usable endpoint. |
failed |
The provision exhausted its retries. error says why. |
deleting |
Teardown queued or running. |
deleted |
Terminal. |
endpoint is in-cluster DNS —
http://<release>.tenant-<team>.svc.cluster.local — reachable from inside the cluster, not
from your laptop.
A few things happen without you asking:
- Drift repair. If a
readyinstance's helm release disappears, the reconciler notices within ~60s and re-provisions it. You may seeready → failed → provisioning → ready. - TTL. An instance past
expires_atis torn down automatically. - Upgrades. When the catalog pins a newer chart version, instances are upgraded inside
their maintenance window, one at a time. Entries marked
security: trueskip the window.
A failed instance is not retried automatically — it needs a human.
Errors
Every non-2xx body is the same shape. code is stable and meant for machines; message is
for humans and must not be parsed.
{"code": "unknown_service_type", "message": "no such service_type: mongodb"}
| Status | code |
Cause |
|---|---|---|
| 401 | unauthorized |
Missing, expired, malformed or unverifiable token. Never says which. |
| 404 | unknown_service_type |
Not in the catalog. |
| 404 | not_found |
No such instance — or it belongs to another team. |
| 409 | illegal_transition |
e.g. deleting something already deleted. |
| 409 | conflict |
The row changed between the read and the write. Retry. |
| 422 | unknown_size |
The message lists the sizes that exist. |
| 422 | validation_error |
Malformed body: bad type, extra field, ttl_days out of range. |
| 429 | rate_limited |
Over the per-team budget. Honour Retry-After. |
| 503 | not_ready |
/readyz only: Postgres is unreachable. |
Rate limiting is per team, 60 requests/minute by default, and it fails open — if the limiter's Redis is down you are unmetered rather than refused. A 429 always means a real, counted overage.
The CLI
services/cli/ is a thin API client. It holds a URL and a token and never touches the
database.
export SVCFORGE_API_URL=https://svcforge.oci-oci.duckdns.org
export SVCFORGE_API_TOKEN="$TOKEN"
svcforge create podinfo --size small --ttl 7d --wait
svcforge list --state ready
svcforge status <instance-id>
svcforge delete <instance-id> --yes
--wait polls until the instance reaches ready or failed.
Generating a client
curl -s https://svcforge.oci-oci.duckdns.org/openapi.json > openapi.json
openapi-generator-cli generate -i openapi.json -g python -o ./client
The error models are declared on every route, so a generated client gets typed 401/404/409/
422 bodies rather than guessing. tests/integration/test_api.py pins the description, the
tags and the bearer security scheme, so these docs fail CI if they rot.
Polling, end to end
ID=$(curl -s -X POST https://svcforge.oci-oci.duckdns.org/v1/instances \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"service_type":"podinfo","size":"small","ttl_days":1}' | jq -r .id)
while :; do
BODY=$(curl -s -H "Authorization: Bearer $TOKEN" \
https://svcforge.oci-oci.duckdns.org/v1/instances/$ID)
STATE=$(jq -r .state <<<"$BODY")
echo "$STATE"
case "$STATE" in
ready) jq -r .endpoint <<<"$BODY"; break ;;
failed) jq -r .error <<<"$BODY"; exit 1 ;;
esac
sleep 5
done
Poll every few seconds, not every few milliseconds. A provision is a helm install against a
StatefulSet; podinfo takes seconds, Elasticsearch takes minutes.