docs: add USER_GUIDE.md, tighten comments, fix CLI needing a DSN
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
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
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.
This commit is contained in:
+267
@@ -0,0 +1,267 @@
|
||||
# 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`](https://svcforge.oci-oci.duckdns.org/docs) |
|
||||
| ReDoc (nicer to read) | [`/redoc`](https://svcforge.oci-oci.duckdns.org/redoc) |
|
||||
| Raw OpenAPI spec | [`/openapi.json`](https://svcforge.oci-oci.duckdns.org/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:
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"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`.
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
```json
|
||||
{"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.
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user