{"openapi":"3.1.0","info":{"title":"svcforge","summary":"X-as-a-Service control plane","description":"\nProvision managed service instances into Kubernetes. The catalog offers Elasticsearch,\nRedis and Postgres, plus two deliberately tiny entries — `podinfo` and `nginx` — for\nexercising the platform where there is no room for the real thing.\n\n## Authentication\n\nEvery `/v1` route needs a bearer JWT: `Authorization: Bearer <token>`. The token is\nverified against the configured JWKS (RS256), and its `team` claim decides which instances\nyou can see. **Authorisation is a WHERE clause** — asking for another team's instance\nreturns `404`, not `403`, so the API never confirms that an id you cannot access exists.\n\n## Writes are asynchronous\n\n`POST` and `DELETE` return **202 Accepted**, not 201/204. They enqueue work and return\nimmediately; nothing is provisioned yet when you get the response. Poll\n`GET /v1/instances/{id}` and watch `state`.\n\n## Instance lifecycle\n\n    requested -> provisioning -> ready\n                                  |\n                                  v\n                              deleting -> deleted\n\n`failed` is reachable from `requested` and `provisioning` when a provision exhausts its\nretries. A `ready` instance whose release vanished is re-provisioned automatically by the\nreconciler, so `ready` is the only state that carries a usable `endpoint`.\n\n## Errors\n\nEvery non-2xx body is the same shape — `{\"code\": ..., \"message\": ...}` — including the\n404s and 405s raised by the framework itself. `code` is stable and meant for machines;\n`message` is for humans.\n","version":"0.1.0"},"paths":{"/healthz":{"get":{"tags":["ops"],"summary":"Healthz","description":"Liveness. No I/O. If the event loop can run this, the process is alive.","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Healthz Healthz Get"}}}}}}},"/readyz":{"get":{"tags":["ops"],"summary":"Readyz","description":"Readiness. Postgres only.\n\nRedis is the temptation and stays out: it holds derived state that degrades gracefully,\nso checking it here would let an Upstash hiccup mark every pod unready, empty the\nService, and turn a cache outage into a total API outage.","operationId":"readyz_readyz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Readyz Readyz Get"}}}},"503":{"description":"A dependency is unavailable","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/metrics":{"get":{"tags":["ops"],"summary":"Metrics","description":"The Prometheus scrape endpoint.\n\nA route rather than `app.mount(\"/metrics\", make_asgi_app())`: a Starlette `Mount`\ncompiles to `^/metrics(?P<path>/.*)$`, which does not match the bare `/metrics` every\nscrape config uses, and a Mount is invisible to OpenAPI.\n\nThe encoding stays prometheus_client's — `choose_encoder` reads Accept and picks the\nexposition format with its matching content type. Hand-rolling it serves text/plain a\nscraper rejects.","operationId":"metrics_metrics_get","responses":{"200":{"description":"Successful Response"}}}},"/v1/instances":{"post":{"tags":["instances"],"summary":"Create Instance","description":"Accept a provisioning request. 202, never 201.\n\nNothing is provisioned when this returns: the row exists and a task is queued, and a\nworker does the work seconds or minutes later. 201 Created would be a lie about a\nresource that does not exist yet, and clients would stop polling.","operationId":"create_instance_v1_instances_post","security":[{"HTTPBearer":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInstanceRequest"}}}},"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceResponse"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"No such instance, or not this team's","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Instance is not in a state that allows this","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"The body is well-formed but cannot be processed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}},"get":{"tags":["instances"],"summary":"List Instances","description":"The caller's instances, newest first. Bounded: no endpoint returns 'all rows'.","operationId":"list_instances_v1_instances_get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":200,"minimum":1,"default":50,"title":"Limit"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/InstanceResponse"},"title":"Response List Instances V1 Instances Get"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"No such instance, or not this team's","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Instance is not in a state that allows this","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"The body is well-formed but cannot be processed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/v1/instances/{instance_id}":{"get":{"tags":["instances"],"summary":"Get Instance","description":"404 if the repo returns None. A wrong-team id is a 404, not a 403.","operationId":"get_instance_v1_instances__instance_id__get","security":[{"HTTPBearer":[]}],"parameters":[{"name":"instance_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Instance Id"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceResponse"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"No such instance, or not this team's","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Instance is not in a state that allows this","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"The body is well-formed but cannot be processed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}},"delete":{"tags":["instances"],"summary":"Delete Instance","description":"state -> deleting, enqueue deprovision. 202: the helm uninstall has not happened yet.\n\n`InstanceRepo.update_state` owns its own connection, so the CAS and the enqueue cannot\nshare a transaction without reaching around the repo. Given two statements, the order is\nchosen for its failure mode: a crash between CAS and enqueue leaves an instance in\n`deleting` with no task, which the reconciler's sweep re-enqueues. The reverse would\nleave a deprovision task on a `ready` instance and tear down a live service.","operationId":"delete_instance_v1_instances__instance_id__delete","security":[{"HTTPBearer":[]}],"parameters":[{"name":"instance_id","in":"path","required":true,"schema":{"type":"string","format":"uuid","title":"Instance Id"}}],"responses":{"202":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstanceResponse"}}}},"401":{"description":"Missing or invalid credentials","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"404":{"description":"No such instance, or not this team's","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"409":{"description":"Instance is not in a state that allows this","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}},"422":{"description":"The body is well-formed but cannot be processed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}}},"components":{"schemas":{"CreateInstanceRequest":{"properties":{"service_type":{"type":"string","minLength":1,"title":"Service Type","description":"A service type in the catalog, e.g. `elasticsearch`, `redis`, `postgres`, `podinfo`, `nginx`. Unknown values return 404."},"size":{"type":"string","title":"Size","description":"A size the catalog defines for that service type, e.g. `small`. Unknown values return 422."},"ttl_days":{"anyOf":[{"type":"integer","maximum":30.0,"minimum":1.0},{"type":"null"}],"title":"Ttl Days","description":"Delete the instance automatically after this many days. Omit for no expiry."}},"additionalProperties":false,"type":"object","required":["service_type","size"],"title":"CreateInstanceRequest","description":"What a tenant may ask for.\n\n`service_type` and `size` are plain strings, not enums: the catalog is data loaded at\nruntime, so baking its keys into a type would mean a redeploy to add a service type and\na 422 where the spec wants a 404. The handler validates them against the catalog.","examples":[{"service_type":"redis","size":"small","ttl_days":7}]},"ErrorBody":{"properties":{"code":{"type":"string","title":"Code","description":"Stable machine-readable identifier for the failure."},"message":{"type":"string","title":"Message","description":"Human-readable detail. Do not parse this."}},"type":"object","required":["code","message"],"title":"ErrorBody","description":"Every non-2xx body. `code` is for machines, `message` is for humans.","examples":[{"code":"unknown_service_type","message":"no such service_type: mongodb"}]},"InstanceResponse":{"properties":{"id":{"type":"string","format":"uuid","title":"Id","description":"Poll `GET /v1/instances/{id}` with this to watch the state change."},"state":{"$ref":"#/components/schemas/InstanceState","description":"Lifecycle state. Only `ready` carries a usable endpoint."},"service_type":{"type":"string","title":"Service Type"},"size":{"type":"string","title":"Size"},"endpoint":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Endpoint","description":"In-cluster DNS name. Null until the instance is `ready`."},"chart_version":{"type":"string","title":"Chart Version","description":"The chart version actually deployed, written only after helm succeeds."},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Why the last attempt failed. Null unless `state` is `failed`."}},"type":"object","required":["id","state","service_type","size","endpoint","chart_version","error"],"title":"InstanceResponse","description":"What a tenant gets back. A subset of Instance, on purpose.","examples":[{"chart_version":"20.6.2","endpoint":"http://acme-redis-0f8b7d3e.tenant-acme.svc.cluster.local","id":"0f8b7d3e-1c2a-4f5b-9e6d-7a8b9c0d1e2f","service_type":"redis","size":"small","state":"ready"}]},"InstanceState":{"type":"string","enum":["requested","provisioning","ready","deleting","deleted","failed"],"title":"InstanceState","description":"Lifecycle of a provisioned service instance."}},"securitySchemes":{"HTTPBearer":{"type":"http","scheme":"bearer"}}},"tags":[{"name":"instances","description":"Create, inspect and delete service instances. All writes are 202 + poll."},{"name":"ops","description":"Liveness, readiness and Prometheus metrics. Unauthenticated, and not part of the tenant API surface."}]}