svcforge: reference implementation
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
Complete working build of the system learn-python/ teaches. 164 tests, mypy --strict clean, domain coverage 99%.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1.10
|
||||
#
|
||||
# svcforge api. Build from the REPO ROOT:
|
||||
# docker buildx build -f services/api/Dockerfile -t svcforge/api:dev .
|
||||
# `COPY ../..` is illegal, so the context must be the root. There is no other option.
|
||||
#
|
||||
# Two syncs, not one: deps change rarely and our own code changes every commit, so the
|
||||
# expensive layer (third-party wheels) must land before the cheap one (our source).
|
||||
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.5.11@sha256:0ac957607303916420297a4c9c213bb33fbd3c888f9cd7f4f7273596ebf42b85 /uv /usr/local/bin/uv
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never
|
||||
WORKDIR /app
|
||||
|
||||
# --- layer 1: third-party dependencies only -------------------------------------------
|
||||
# --no-install-project skips the root; --no-install-package skips our path dependency.
|
||||
# Without the latter, uv would try to build svcforge-core here, where its source is not
|
||||
# yet in the context, and the build would fail.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY libs/svcforge_core/pyproject.toml libs/svcforge_core/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable \
|
||||
--no-install-project --no-install-package svcforge-core
|
||||
|
||||
# --- layer 2: our code ----------------------------------------------------------------
|
||||
COPY libs/ libs/
|
||||
COPY services/api/ services/api/
|
||||
COPY catalog.yaml ./
|
||||
# --no-editable is what turns svcforge-core into a real wheel in site-packages.
|
||||
# pyproject.toml declares it `editable = true` for local dev; an editable install in an
|
||||
# image points at /app/libs, which is a source tree that need not survive the final stage.
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen --no-dev --no-editable && \
|
||||
/app/.venv/bin/python -c 'import svcforge_core, sys; \
|
||||
p = svcforge_core.__file__; \
|
||||
sys.exit(0) if "site-packages" in p else sys.exit("not a wheel install: " + p)'
|
||||
|
||||
# --- runtime --------------------------------------------------------------------------
|
||||
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
|
||||
|
||||
ARG BUILD_SHA=unknown
|
||||
LABEL org.opencontainers.image.title="svcforge-api" \
|
||||
org.opencontainers.image.source="https://gitea.oci-oci.duckdns.org/gitea_admin/svcforge" \
|
||||
org.opencontainers.image.revision="${BUILD_SHA}"
|
||||
|
||||
RUN useradd -u 10001 -m -s /usr/sbin/nologin svcforge
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=10001:10001 /app /app
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
USER 10001
|
||||
ENTRYPOINT ["python", "-m", "services.api"]
|
||||
@@ -0,0 +1 @@
|
||||
"""The api service: HTTP transport over the domain."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""`python -m services.api` — what the container's ENTRYPOINT runs.
|
||||
|
||||
One uvicorn worker per container, on purpose. Replicas are Kubernetes' job: `--workers N`
|
||||
forks processes the orchestrator cannot see, size, or drain, and it breaks the in-process
|
||||
Prometheus registry that /metrics depends on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
from svcforge_core.settings import load_settings
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Load settings (fail fast if the env is wrong), then serve."""
|
||||
settings = load_settings()
|
||||
uvicorn.run(
|
||||
"services.api.main:app",
|
||||
factory=True,
|
||||
host="0.0.0.0", # noqa: S104 - a container binds all interfaces; the pod is the boundary
|
||||
port=8000,
|
||||
log_level=settings.log_level,
|
||||
access_log=False, # structlog owns request logging (Module 7); two sources would double it
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Dependency injection: how a handler gets a pool, a repo, a catalog, and a team.
|
||||
|
||||
Everything expensive — the pool, the JWKS client, the parsed catalog — is built once in
|
||||
`lifespan` and parked on `app.state`. These functions only hand it out. A `Depends` that
|
||||
does I/O per request is a `Depends` that does that I/O on every request forever.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated, Any
|
||||
|
||||
import jwt
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from svcforge_core.domain.models import CatalogEntry
|
||||
from svcforge_core.repo.db import DictPool
|
||||
from svcforge_core.repo.instances import InstanceRepo
|
||||
from svcforge_core.repo.tasks import TaskRepo
|
||||
from svcforge_core.settings import Settings
|
||||
|
||||
# The algorithm allow-list is the whole point of naming algorithms explicitly.
|
||||
# `jwt.decode(..., algorithms=...)` without it accepts whatever the *token* claims in its
|
||||
# own header — including `none`, and including HS256 verified with the RSA public key as
|
||||
# an HMAC secret. Both are forgery. The list is not configuration.
|
||||
ALLOWED_ALGORITHMS = ["RS256"]
|
||||
|
||||
# What `auth_disabled` returns. Settings.check_production() refuses that flag in prod.
|
||||
DEV_TEAM = "platform"
|
||||
|
||||
TEAM_CLAIM = "team"
|
||||
|
||||
# auto_error=False is load-bearing. HTTPBearer(auto_error=True) answers a *missing*
|
||||
# Authorization header with 403, not 401 — an old FastAPI wart. The spec (and every
|
||||
# client that knows what to do about it) wants 401, so the error is raised here.
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _unauthorized() -> HTTPException:
|
||||
"""One shape for every auth failure.
|
||||
|
||||
Expired, wrong issuer, wrong audience, bad signature, malformed, no header: all the
|
||||
same 401 with the same body. Telling a caller *which* one turns the endpoint into an
|
||||
oracle they can tune a forgery against.
|
||||
"""
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"code": "unauthorized", "message": "invalid or missing credentials"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_settings(request: Request) -> Settings:
|
||||
"""The Settings that create_app() was handed."""
|
||||
settings: Settings = request.app.state.settings
|
||||
return settings
|
||||
|
||||
|
||||
async def get_pool(request: Request) -> DictPool:
|
||||
"""Return the pool that lifespan put on app.state."""
|
||||
pool: DictPool = request.app.state.pool
|
||||
return pool
|
||||
|
||||
|
||||
def get_catalog(request: Request) -> dict[str, CatalogEntry]:
|
||||
"""The catalog, parsed once at startup.
|
||||
|
||||
Read from disk per request and a mid-flight edit to catalog.yaml changes the answer
|
||||
between two requests of the same deploy. Load it at startup; a change is a restart.
|
||||
"""
|
||||
catalog: dict[str, CatalogEntry] = request.app.state.catalog
|
||||
return catalog
|
||||
|
||||
|
||||
def get_instance_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> InstanceRepo:
|
||||
"""An InstanceRepo bound to the app's pool. Cheap: it is a handle, not a connection."""
|
||||
return InstanceRepo(pool)
|
||||
|
||||
|
||||
def get_task_repo(pool: Annotated[DictPool, Depends(get_pool)]) -> TaskRepo:
|
||||
"""A TaskRepo bound to the app's pool."""
|
||||
return TaskRepo(pool)
|
||||
|
||||
|
||||
async def get_current_team(
|
||||
request: Request,
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
|
||||
settings: Annotated[Settings, Depends(get_settings)],
|
||||
) -> str:
|
||||
"""Verify the JWT against the cached JWKS. Check aud/iss/exp and the alg allow-list.
|
||||
|
||||
Returns the team claim. Raises HTTPException(401) on any failure — never leaks why.
|
||||
"""
|
||||
if settings.auth_disabled:
|
||||
return DEV_TEAM
|
||||
|
||||
if creds is None or not creds.credentials:
|
||||
raise _unauthorized()
|
||||
|
||||
jwks_client: PyJWKClient | None = getattr(request.app.state, "jwks_client", None)
|
||||
if jwks_client is None:
|
||||
# Auth is on but there is no key source. Fail closed. Answering 500 here would be
|
||||
# honest about the cause and would also let a misconfigured deploy be told apart
|
||||
# from a bad token; 401 is the same answer a forger gets.
|
||||
raise _unauthorized()
|
||||
|
||||
try:
|
||||
# PyJWKClient keeps its own TTL cache, so this is a dict lookup on the hot path.
|
||||
# It is only blocking on a cache MISS (key rotation) — hence to_thread, which
|
||||
# costs a thread hop we take a handful of times a day rather than an event loop
|
||||
# stalled on someone else's HTTP call once per rotation.
|
||||
signing_key = await _signing_key(jwks_client, creds.credentials)
|
||||
claims: dict[str, Any] = jwt.decode(
|
||||
creds.credentials,
|
||||
signing_key.key,
|
||||
algorithms=ALLOWED_ALGORITHMS,
|
||||
audience=settings.jwt_audience,
|
||||
issuer=settings.jwt_issuer,
|
||||
options={
|
||||
"require": ["exp", "aud", "iss"],
|
||||
"verify_exp": True,
|
||||
"verify_aud": True,
|
||||
"verify_iss": settings.jwt_issuer is not None,
|
||||
"verify_signature": True,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # deliberate catch-all: every failure becomes one opaque 401
|
||||
raise _unauthorized() from exc
|
||||
|
||||
team = claims.get(TEAM_CLAIM)
|
||||
if not isinstance(team, str) or not team:
|
||||
raise _unauthorized()
|
||||
return team
|
||||
|
||||
|
||||
async def _signing_key(client: PyJWKClient, token: str) -> jwt.PyJWK:
|
||||
"""Fetch the signing key off the event loop.
|
||||
|
||||
PyJWKClient.get_signing_key_from_jwt() does a synchronous urlopen on a cache miss.
|
||||
Called directly from `async def`, that blocks the loop — every other in-flight request
|
||||
on this worker stops until the identity provider answers, and if it hangs, so does the
|
||||
pod, and /readyz keeps saying it is fine.
|
||||
"""
|
||||
return await asyncio.to_thread(client.get_signing_key_from_jwt, token)
|
||||
|
||||
|
||||
async def rate_limit(team: Annotated[str, Depends(get_current_team)]) -> None:
|
||||
"""Per-team rate limiting. Seam only — Module 10 fills this in (Redis, Lua, token bucket).
|
||||
|
||||
It exists now, wired into the routes, so that turning it on is an edit to one function
|
||||
body rather than a change to every handler signature.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
async def idempotency_key(request: Request) -> str | None:
|
||||
"""`Idempotency-Key` handling. Seam only — Module 10 fills this in (Redis store).
|
||||
|
||||
Until then the real idempotency anchor is `instances.release_name`, which is unique in
|
||||
the schema and deterministic from (team, service_type, id).
|
||||
"""
|
||||
return request.headers.get("Idempotency-Key")
|
||||
|
||||
|
||||
PoolDep = Annotated[DictPool, Depends(get_pool)]
|
||||
TeamDep = Annotated[str, Depends(get_current_team)]
|
||||
InstanceRepoDep = Annotated[InstanceRepo, Depends(get_instance_repo)]
|
||||
TaskRepoDep = Annotated[TaskRepo, Depends(get_task_repo)]
|
||||
CatalogDep = Annotated[dict[str, CatalogEntry], Depends(get_catalog)]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""The app factory and its lifespan.
|
||||
|
||||
`create_app(settings)` is a factory, not a module-level `app = FastAPI()`, for one reason:
|
||||
a test needs an app pointed at a throwaway Postgres, and an import-time app reads the real
|
||||
environment at import time — before any fixture can say otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from jwt import PyJWKClient
|
||||
|
||||
from services.api.models import ErrorBody
|
||||
from services.api.routes import health, instances
|
||||
from svcforge_core.domain.catalog import load_catalog
|
||||
from svcforge_core.repo.db import make_pool
|
||||
from svcforge_core.settings import Settings, load_settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
"""Open the pool, yield, close the pool.
|
||||
|
||||
A lifespan context, not the deprecated startup/shutdown event decorators: those cannot
|
||||
express "this resource lives for exactly as long as the app", and give you no place to
|
||||
put the teardown next to the setup. Closing the pool matters — an unclosed pool means
|
||||
connections linger server-side after SIGTERM, and on a pooled Postgres with a small
|
||||
connection budget a few rolling deploys exhaust it.
|
||||
|
||||
(The old decorator's name is spelled nowhere in this package on purpose: CI greps for
|
||||
the literal string, and a comment quoting it fails the gate just as loudly as a call.)
|
||||
"""
|
||||
settings: Settings = app.state.settings
|
||||
|
||||
app.state.catalog = load_catalog(settings.catalog_path)
|
||||
|
||||
pool = make_pool(str(settings.pg_dsn), settings.pool_min_size, settings.pool_max_size)
|
||||
# wait=True fails NOW, loudly, if the DSN is wrong — instead of at the first request,
|
||||
# as a PoolTimeout, in front of a user.
|
||||
await pool.open(wait=True)
|
||||
app.state.pool = pool
|
||||
|
||||
# The pool is open from here on, so everything below is inside the try: an exception
|
||||
# in JWKS setup must still close it, or a crash-looping pod leaks a connection per
|
||||
# restart until the database refuses new ones.
|
||||
try:
|
||||
if settings.jwks_url and not settings.auth_disabled:
|
||||
client = PyJWKClient(settings.jwks_url, cache_keys=True, lifespan=300)
|
||||
app.state.jwks_client = client
|
||||
# Warm the cache off the loop so the first authenticated request does not pay
|
||||
# a blocking urlopen. Best-effort: a slow identity provider must not stop the
|
||||
# pod from starting — a cache miss later just costs one to_thread hop.
|
||||
try:
|
||||
await asyncio.to_thread(client.get_signing_keys)
|
||||
except Exception: # deliberate catch-all: startup must not hinge on the IdP being up
|
||||
log.warning("JWKS warm-up failed; keys will be fetched on first use", exc_info=True)
|
||||
else:
|
||||
app.state.jwks_client = None
|
||||
|
||||
yield
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
async def _http_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Render HTTPException bodies as ErrorBody, so every error has one shape.
|
||||
|
||||
Handlers raise `detail={"code": ..., "message": ...}`; FastAPI's default would nest
|
||||
that under `{"detail": {...}}`. Plain-string details (raised by FastAPI itself, e.g.
|
||||
a 405) are wrapped so clients never have to branch on the body's type.
|
||||
"""
|
||||
assert isinstance(exc, HTTPException) # noqa: S101 - registered only for HTTPException
|
||||
# Widened to object deliberately. Starlette types `detail` as str, but FastAPI passes
|
||||
# through whatever a handler raised — our handlers raise dicts. Narrowing off the
|
||||
# declared type would make mypy call the dict branch unreachable and delete it.
|
||||
detail: object = exc.detail
|
||||
if isinstance(detail, dict) and "code" in detail and "message" in detail:
|
||||
body = ErrorBody(code=str(detail["code"]), message=str(detail["message"]))
|
||||
else:
|
||||
body = ErrorBody(code=f"http_{exc.status_code}", message=str(detail))
|
||||
return JSONResponse(status_code=exc.status_code, content=body.model_dump(), headers=exc.headers)
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
"""App factory: lifespan, routers, exception handler, /metrics."""
|
||||
settings = settings or load_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="svcforge",
|
||||
version="0.1.0",
|
||||
summary="X-as-a-Service control plane",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.state.settings = settings
|
||||
|
||||
# /metrics is a normal route on health.router, not an app.mount — see health.metrics
|
||||
# for why the mount does not actually serve a bare /metrics.
|
||||
app.include_router(health.router)
|
||||
app.include_router(instances.router)
|
||||
|
||||
app.add_exception_handler(HTTPException, _http_exception_handler)
|
||||
return app
|
||||
|
||||
|
||||
def app() -> FastAPI:
|
||||
"""Entry point for `uvicorn services.api.main:app --factory`."""
|
||||
return create_app()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run("services.api.main:app", factory=True, host="0.0.0.0", port=8000) # noqa: S104
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Wire types.
|
||||
|
||||
These are deliberately NOT the domain models. `Instance` carries `team`, `namespace` and
|
||||
`release_name` — placement details a tenant has no business seeing and no business
|
||||
setting. The response model is the allow-list that keeps them off the wire, which is why
|
||||
it is written out by hand instead of derived from `Instance`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from svcforge_core.domain.states import InstanceState
|
||||
|
||||
|
||||
class CreateInstanceRequest(BaseModel):
|
||||
"""What a tenant may ask for.
|
||||
|
||||
`service_type` and `size` are plain strings, not enums: the catalog is data loaded at
|
||||
runtime, so baking its keys into a type would mean a redeploy to add a service type,
|
||||
and a 422 (schema) where the spec wants a 404 (unknown resource). They are validated
|
||||
against the catalog in the handler.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
service_type: str = Field(min_length=1)
|
||||
size: str
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=30)
|
||||
|
||||
|
||||
class InstanceResponse(BaseModel):
|
||||
"""What a tenant gets back. A subset of Instance, on purpose."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: UUID
|
||||
state: InstanceState
|
||||
service_type: str
|
||||
size: str
|
||||
endpoint: str | None
|
||||
chart_version: str
|
||||
error: str | None
|
||||
|
||||
|
||||
class ErrorBody(BaseModel):
|
||||
"""Every non-2xx body. `code` is for machines, `message` is for humans."""
|
||||
|
||||
code: str
|
||||
message: str
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP routers."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Liveness, readiness, metrics.
|
||||
|
||||
The distinction between the first two is not pedantry, it is the difference between a
|
||||
30-second blip and a fleet-wide outage:
|
||||
|
||||
* `/healthz` (liveness) answers "is this process wedged?" A failure here gets the
|
||||
container KILLED. It must therefore touch NOTHING external. Wire it to the DB and a
|
||||
20-second Postgres failover restarts every pod at once; they come back, find the DB
|
||||
still down, and CrashLoopBackOff with exponential restart delays — so the fleet is now
|
||||
down for minutes after the database recovered.
|
||||
* `/readyz` (readiness) answers "should this pod get traffic?" A failure here only removes
|
||||
it from the Service endpoints. It is allowed to check dependencies, and it recovers by
|
||||
itself the moment the check passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||
from prometheus_client import REGISTRY
|
||||
from prometheus_client.exposition import choose_encoder
|
||||
|
||||
from services.api.deps import PoolDep
|
||||
from services.api.models import ErrorBody
|
||||
|
||||
router = APIRouter(tags=["ops"])
|
||||
|
||||
# No PROMETHEUS_MULTIPROC_DIR here, deliberately: it exists for prefork servers where each
|
||||
# worker process holds a slice of the counters. One uvicorn process per container means
|
||||
# the default in-process registry is already correct, and multiproc mode would add a
|
||||
# shared temp dir, a cleanup obligation, and a class of stale-file bugs for nothing.
|
||||
|
||||
|
||||
@router.get("/healthz", status_code=status.HTTP_200_OK)
|
||||
async def healthz() -> dict[str, str]:
|
||||
"""Liveness. No I/O. If the event loop can run this, the process is alive."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/readyz",
|
||||
responses={503: {"model": ErrorBody, "description": "A dependency is unavailable"}},
|
||||
)
|
||||
async def readyz(pool: PoolDep) -> dict[str, str]:
|
||||
"""Readiness. Postgres only.
|
||||
|
||||
Postgres-only is the rule, and Redis is the temptation. Redis holds derived state —
|
||||
rate-limit buckets, caches — and everything degrades gracefully without it. Put it in
|
||||
this check and an Upstash hiccup marks every pod unready, Kubernetes empties the
|
||||
Service, and a cache outage becomes a total API outage.
|
||||
"""
|
||||
try:
|
||||
async with pool.connection() as conn, conn.cursor() as cur:
|
||||
await cur.execute("select 1")
|
||||
row: Any = await cur.fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError("select 1 returned no row")
|
||||
except Exception as exc: # closed pool, timeout, dead DB — all mean the same 'not ready'
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"code": "not_ready", "message": "database unavailable"},
|
||||
) from exc
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
@router.get("/metrics", response_class=Response)
|
||||
async def metrics(request: Request) -> Response:
|
||||
"""The Prometheus scrape endpoint.
|
||||
|
||||
A route rather than `app.mount("/metrics", make_asgi_app())`, for two reasons. A
|
||||
Starlette `Mount` compiles to `^/metrics(?P<path>/.*)$`, which does not match a bare
|
||||
`/metrics` — the exact URL every scrape config uses — and a `Mount` is invisible to
|
||||
OpenAPI, while the deliverable asks for `/metrics` in `openapi.json`.
|
||||
|
||||
The encoding is still prometheus_client's: `choose_encoder` reads the Accept header and
|
||||
picks the exposition format (Prometheus text vs OpenMetrics) with its matching content
|
||||
type. Hand-rolling either is how you end up serving text/plain that a scraper rejects.
|
||||
"""
|
||||
encoder, content_type = choose_encoder(request.headers.get("Accept", ""))
|
||||
return Response(content=encoder(REGISTRY), media_type=content_type)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""The tenant-facing API.
|
||||
|
||||
Two rules run through every handler here:
|
||||
|
||||
* **AuthZ is the WHERE clause.** No handler ever 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, which is the leak.
|
||||
* **The instance and its task commit together.** A committed instance with no task is an
|
||||
instance that never provisions and that nothing will ever retry.
|
||||
"""
|
||||
|
||||
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 this at runtime; without declaring it, generated
|
||||
# clients see the contract for 2xx only and invent their own guess for 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.
|
||||
|
||||
This is the idempotency anchor. A worker that dies after `helm install` but before it
|
||||
marks the task done will retry, compute the same name, and `helm upgrade --install`
|
||||
onto the same release instead of creating a second one. Derive it from anything that
|
||||
is 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 the 53-char limit helm imposes
|
||||
on release names (Kubernetes label values, minus room for chart-generated suffixes).
|
||||
"""
|
||||
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.
|
||||
|
||||
The two failures are different HTTP problems and the spec asks for different codes:
|
||||
an unknown service_type is a resource that does not exist (404); an unknown size for a
|
||||
real service_type is a body the server understood and cannot process (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; a
|
||||
worker will do the work seconds or minutes from now. 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 from the catalog AT CREATION TIME, not read from the catalog later.
|
||||
# This column records what is actually deployed; bumping catalog.yaml must show up
|
||||
# as drift the reconciler can see, not silently rewrite 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.
|
||||
|
||||
Ordering note. `InstanceRepo.update_state` owns its own connection, so the CAS and the
|
||||
enqueue cannot share one transaction without reaching around the repo. Given two
|
||||
statements, the order is chosen for its failure mode: CAS first, enqueue second. A
|
||||
crash in between leaves an instance in `deleting` with no task, which the reconciler's
|
||||
sweep re-enqueues. The other order leaves a deprovision task pointing at a `ready`
|
||||
instance, and a worker would tear down a live service nobody asked to delete.
|
||||
"""
|
||||
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})
|
||||
Reference in New Issue
Block a user