Files
svcforge/USER_GUIDE.md
Nguyen Minh Phuc c53734d2bc
ci / lint (push) Successful in 33s
ci / types (push) Successful in 43s
ci / unit (push) Successful in 32s
ci / security (push) Successful in 57s
ci / dockerfile (push) Successful in 7s
ci / chart (push) Successful in 8s
ci / integration (push) Successful in 55s
ci / image (api) (push) Successful in 3m39s
ci / image (reconciler) (push) Successful in 2m53s
ci / image (worker) (push) Successful in 2m14s
ci / bump (push) Successful in 16s
docs: add USER_GUIDE.md, tighten comments, fix CLI needing a DSN
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.
2026-07-21 14:51:26 +00:00

9.7 KiB
Raw Permalink Blame History

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

  1. Writes are asynchronous. POST and DELETE return 202 Accepted. Nothing is provisioned when you get the response — you have a row and a queued task. Poll GET /v1/instances/{id} and watch state.
  2. Authorisation is a WHERE clause. Another team's instance returns 404, not 403. The API never confirms that an id you cannot access exists.
  3. 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_URL points at https://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 logs JWKS warm-up failed at startup and every /v1 request returns 401. 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 repoint auth.jwksUrl / auth.issuer in deploy/chart/values.yaml at 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 (130, 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 1200 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 ready instance's helm release disappears, the reconciler notices within ~60s and re-provisions it. You may see ready → failed → provisioning → ready.
  • TTL. An instance past expires_at is 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: true skip 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.