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
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.
215 lines
8.1 KiB
Python
215 lines
8.1 KiB
Python
"""The tenant-facing API.
|
|
|
|
Two rules run through every handler:
|
|
|
|
* **AuthZ is the WHERE clause.** No handler compares `inst.team` to the caller's team,
|
|
because the repo never returns another team's row to compare. A wrong-team id is a 404;
|
|
403 would confirm the id exists.
|
|
* **The instance and its task commit together.** A committed instance with no task never
|
|
provisions and nothing retries it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|
|
|
from services.api.deps import (
|
|
CatalogDep,
|
|
InstanceRepoDep,
|
|
PoolDep,
|
|
TaskRepoDep,
|
|
TeamDep,
|
|
idempotency_key,
|
|
rate_limit,
|
|
)
|
|
from services.api.models import CreateInstanceRequest, ErrorBody, InstanceResponse
|
|
from svcforge_core.domain.models import CatalogEntry, Instance, TaskKind
|
|
from svcforge_core.domain.states import IllegalTransition, InstanceState, transition
|
|
|
|
# Declared on the router so every error shape lands in openapi.json under ErrorBody. The
|
|
# exception handler already renders these at runtime; undeclared, a generated client sees
|
|
# the contract for 2xx only and guesses the rest.
|
|
ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
|
|
401: {"model": ErrorBody, "description": "Missing or invalid credentials"},
|
|
404: {"model": ErrorBody, "description": "No such instance, or not this team's"},
|
|
409: {"model": ErrorBody, "description": "Instance is not in a state that allows this"},
|
|
422: {"model": ErrorBody, "description": "The body is well-formed but cannot be processed"},
|
|
}
|
|
|
|
router = APIRouter(prefix="/v1/instances", tags=["instances"], responses=ERROR_RESPONSES)
|
|
|
|
|
|
def release_name_for(team: str, service_type: str, instance_id: UUID) -> str:
|
|
"""The helm release name. Deterministic, and `unique` in the schema.
|
|
|
|
The idempotency anchor: a worker that dies after `helm install` but before marking the
|
|
task done retries, computes the same name, and upgrades the same release instead of
|
|
creating a second one. Derive it from anything not already durable — a timestamp, a
|
|
random suffix, the retry count — and a retry provisions a duplicate.
|
|
|
|
Truncated to the uuid's first 8 chars to stay inside helm's 53-char release-name limit.
|
|
"""
|
|
return f"{team}-{service_type}-{str(instance_id)[:8]}"
|
|
|
|
|
|
def namespace_for(team: str) -> str:
|
|
"""One namespace per tenant. The blast radius of a bad chart is one team."""
|
|
return f"tenant-{team}"
|
|
|
|
|
|
def _resolve(catalog: dict[str, CatalogEntry], service_type: str, size: str) -> CatalogEntry:
|
|
"""Look up service_type + size, or raise the right 4xx.
|
|
|
|
Two different HTTP problems: an unknown service_type is a resource that does not exist
|
|
(404), an unknown size for a real one is a body understood and unprocessable (422).
|
|
"""
|
|
entry = catalog.get(service_type)
|
|
if entry is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={
|
|
"code": "unknown_service_type",
|
|
"message": f"no such service_type: {service_type}",
|
|
},
|
|
)
|
|
if size not in entry.sizes:
|
|
raise HTTPException(
|
|
status_code=422, # starlette renamed the 422 constant; the number never moved
|
|
detail={
|
|
"code": "unknown_size",
|
|
"message": f"{service_type} has no size {size!r}; available: {sorted(entry.sizes)}",
|
|
},
|
|
)
|
|
return entry
|
|
|
|
|
|
@router.post(
|
|
"",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
response_model=InstanceResponse,
|
|
dependencies=[Depends(rate_limit), Depends(idempotency_key)],
|
|
)
|
|
async def create_instance(
|
|
body: CreateInstanceRequest,
|
|
response: Response,
|
|
team: TeamDep,
|
|
pool: PoolDep,
|
|
instances: InstanceRepoDep,
|
|
tasks: TaskRepoDep,
|
|
catalog: CatalogDep,
|
|
) -> Instance:
|
|
"""Accept a provisioning request. 202, never 201.
|
|
|
|
Nothing is provisioned when this returns: the row exists and a task is queued, and a
|
|
worker does the work seconds or minutes later. 201 Created would be a lie about a
|
|
resource that does not exist yet, and clients would stop polling.
|
|
"""
|
|
entry = _resolve(catalog, body.service_type, body.size)
|
|
|
|
instance_id = uuid4()
|
|
now = datetime.now(UTC)
|
|
inst = Instance(
|
|
id=instance_id,
|
|
team=team,
|
|
service_type=body.service_type,
|
|
size=body.size,
|
|
state=InstanceState.REQUESTED,
|
|
namespace=namespace_for(team),
|
|
release_name=release_name_for(team, body.service_type, instance_id),
|
|
# Pinned at creation time, not read from the catalog later. The column records what
|
|
# is deployed, so bumping catalog.yaml shows up as drift the reconciler can see
|
|
# rather than silently rewriting history.
|
|
chart_version=entry.chart_version,
|
|
expires_at=now + timedelta(days=body.ttl_days) if body.ttl_days is not None else None,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
# The transaction. Both writes go through THIS conn, or the atomicity is theatre.
|
|
async with pool.connection() as conn, conn.transaction():
|
|
created = await instances.create(conn, inst)
|
|
await tasks.enqueue(conn, created.id, TaskKind.PROVISION)
|
|
|
|
response.headers["Location"] = f"/v1/instances/{created.id}"
|
|
return created
|
|
|
|
|
|
@router.get("/{instance_id}", response_model=InstanceResponse)
|
|
async def get_instance(
|
|
instance_id: UUID,
|
|
team: TeamDep,
|
|
instances: InstanceRepoDep,
|
|
) -> Instance:
|
|
"""404 if the repo returns None. A wrong-team id is a 404, not a 403."""
|
|
inst = await instances.get(instance_id, team)
|
|
if inst is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={"code": "not_found", "message": f"no such instance: {instance_id}"},
|
|
)
|
|
return inst
|
|
|
|
|
|
@router.get("", response_model=list[InstanceResponse])
|
|
async def list_instances(
|
|
team: TeamDep,
|
|
instances: InstanceRepoDep,
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
) -> list[Instance]:
|
|
"""The caller's instances, newest first. Bounded: no endpoint returns 'all rows'."""
|
|
return await instances.list(team, limit=limit)
|
|
|
|
|
|
@router.delete(
|
|
"/{instance_id}",
|
|
status_code=status.HTTP_202_ACCEPTED,
|
|
response_model=InstanceResponse,
|
|
dependencies=[Depends(rate_limit)],
|
|
)
|
|
async def delete_instance(
|
|
instance_id: UUID,
|
|
team: TeamDep,
|
|
instances: InstanceRepoDep,
|
|
tasks: TaskRepoDep,
|
|
) -> Instance:
|
|
"""state -> deleting, enqueue deprovision. 202: the helm uninstall has not happened yet.
|
|
|
|
`InstanceRepo.update_state` owns its own connection, so the CAS and the enqueue cannot
|
|
share a transaction without reaching around the repo. Given two statements, the order is
|
|
chosen for its failure mode: a crash between CAS and enqueue leaves an instance in
|
|
`deleting` with no task, which the reconciler's sweep re-enqueues. The reverse would
|
|
leave a deprovision task on a `ready` instance and tear down a live service.
|
|
"""
|
|
inst = await instances.get(instance_id, team)
|
|
if inst is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={"code": "not_found", "message": f"no such instance: {instance_id}"},
|
|
)
|
|
|
|
try:
|
|
target = transition(inst.state, InstanceState.DELETING)
|
|
except IllegalTransition as exc:
|
|
# Already deleting or already deleted. Not an error the tenant can fix.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={
|
|
"code": "illegal_transition",
|
|
"message": f"instance is {inst.state}; delete is not a legal transition",
|
|
},
|
|
) from exc
|
|
|
|
if not await instances.update_state(inst.id, expect=inst.state, to=target):
|
|
# Someone moved the row between the read and the CAS.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={"code": "conflict", "message": "instance changed concurrently; retry"},
|
|
)
|
|
await tasks.enqueue_standalone(inst.id, TaskKind.DEPROVISION)
|
|
|
|
return inst.model_copy(update={"state": target})
|