diff --git a/service/.dockerignore b/service/.dockerignore new file mode 100644 index 0000000..4c3b351 --- /dev/null +++ b/service/.dockerignore @@ -0,0 +1,4 @@ +**/.venv/ +**/__pycache__/ +**/*.pyc +**/*.db diff --git a/service/Dockerfile b/service/Dockerfile new file mode 100644 index 0000000..dc7c49a --- /dev/null +++ b/service/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 + +# Build stage: resolve dependencies into a wheel-installed prefix so the runtime +# image carries no build toolchain. +FROM python:3.12-slim AS build + +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 PIP_NO_CACHE_DIR=1 +WORKDIR /build + +# The reference implementation is a real dependency of the service: validation is +# delegated to it so the service and the CLI cannot disagree about what a valid +# package is. +COPY reference/ ./reference/ +COPY service/pyproject.toml ./service/ +COPY service/src/ ./service/src/ + +RUN python -m venv /opt/venv \ + && /opt/venv/bin/pip install --no-cache-dir ./reference ./service + +FROM python:3.12-slim AS runtime + +# Non-root by default. The service writes nothing to disk; its state is the +# database. +RUN useradd --system --create-home --uid 10001 canned +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY --from=build /opt/venv /opt/venv +COPY service/alembic.ini /app/alembic.ini +COPY service/migrations/ /app/migrations/ + +WORKDIR /app +USER canned +EXPOSE 8000 + +# No CMD-embedded migration: schema changes are a deployment step with its own +# rollback, not something that races between replicas at start-up. +CMD ["uvicorn", "canned_prompts_service.api:create_app", "--factory", \ + "--host", "0.0.0.0", "--port", "8000"] diff --git a/service/Makefile b/service/Makefile new file mode 100644 index 0000000..d5790fc --- /dev/null +++ b/service/Makefile @@ -0,0 +1,28 @@ +# Service build and verification. Run from `service/`. +IMAGE ?= canned-prompts-service +TAG ?= dev +BASE ?= http://127.0.0.1:8000 +HEAD := $(shell .venv/bin/alembic heads 2>/dev/null | awk '{print $$1}') + +.PHONY: test image smoke migrate run head + +test: + .venv/bin/python -m pytest -q + +image: + cd .. && docker build -f service/Dockerfile -t $(IMAGE):$(TAG) . + +head: + @echo $(HEAD) + +migrate: + .venv/bin/alembic upgrade head + +run: + .venv/bin/uvicorn canned_prompts_service.api:create_app --factory + +# The service-level half of a rapp smoke contract. Cluster-level checks +# (NetworkPolicies, external secrets, private-Service-only) belong to +# rapp-canned-prompts, which can call this for the rest. +smoke: + python3 tools/smoke.py --base $(BASE) --expect-migration $(HEAD) diff --git a/service/README.md b/service/README.md index 254c67b..f017404 100644 --- a/service/README.md +++ b/service/README.md @@ -119,12 +119,42 @@ it cannot be attributed among several publishers. Until per-publisher identity exists, a claim's `owner` is documentation rather than an access decision, and the code says so where it matters. +## Image and smoke + +```bash +make image # builds from the repo root; reference/ is a real dependency +make smoke BASE=http://... # service-level checks, exits non-zero on failure +``` + +The image is a two-stage `python:3.12-slim` build carrying no build toolchain, +running as a non-root user, writing nothing to disk — its state is the database. +It deliberately does **not** run migrations on start-up: a schema change is a +deployment step with its own rollback, not something that races between replicas. + +`tools/smoke.py` asserts what can be known about a *running service*: liveness, +readiness, fleet health shape, the migration revision, and that the index and +registry are queryable. It is stdlib-only so it runs inside the runtime image, +and it exits non-zero so a deployment gate can call it directly. + +Cluster-level checks a `rapp` contract also names — NetworkPolicies present, +external secrets ready, private-Service-only, live image digest match — are +properties of the deployment rather than of this process, and belong to +`rapp-canned-prompts`. + +`--expect-migration` matters: without it the check can only confirm the schema +is stamped at all, and it says so rather than implying it verified the head. + ## Status -`CANP-WP-0006` T01–T04 are done: skeleton, health surface, tenant-keyed schema -(migrations `0001`–`0002`), and the read and publish APIs. The HTTP registry -client in the CLI and the container image (T05–T06) are not built yet. T06 -produces the image digest that `rapp-canned-prompts` needs to pin. +`CANP-WP-0006` is complete: skeleton and health surface, tenant-keyed schema +(migrations `0001`–`0002`), read and publish APIs, HTTP registries in the +reference CLI, and a verified container image with smoke checks. -Per-publisher identity is deferred, and is the main thing standing between this -and a service that several people can publish to. +**Not done, and needed before `rapp-canned-prompts`:** the image has been built +and verified locally but never pushed. `rapp.yaml` pins +`upstream_components.version` to a digest from the fleet's registry, and that +digest only exists once the image is published — an operator action needing +registry credentials. + +**Per-publisher identity** remains deferred, and is the main thing between this +and a registry several people can publish to. diff --git a/service/tools/smoke.py b/service/tools/smoke.py new file mode 100755 index 0000000..e5d7780 --- /dev/null +++ b/service/tools/smoke.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Service-level smoke checks. + +The checks a `rapp` smoke contract can assert about a *running service*. +Deployment-level checks — NetworkPolicies present, external secrets ready, +private-Service-only — are properties of the cluster, not of this process, and +belong to `rapp-canned-prompts` rather than here. + +Exits non-zero if any required check fails, so a deployment gate can call it +directly. stdlib only: this runs inside the runtime image, which carries no +test dependencies. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass + + +@dataclass +class Check: + name: str + ok: bool + detail: str + + +def get(base: str, path: str, timeout: float) -> tuple[int, dict]: + request = urllib.request.Request(base.rstrip("/") + path, method="GET") + request.add_header("Accept", "application/json") + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + try: + return exc.code, json.loads(exc.read().decode("utf-8")) + except Exception: # noqa: BLE001 + return exc.code, {} + except Exception as exc: # noqa: BLE001 + return 0, {"error": str(exc)} + + +def run(base: str, expect_migration: str | None, timeout: float) -> list[Check]: + checks: list[Check] = [] + + status, body = get(base, "/healthz", timeout) + checks.append( + Check("liveness-ok", status == 200 and body.get("status") == "ok", + f"{status} {body}") + ) + + status, body = get(base, "/readyz", timeout) + ready = status == 200 and body.get("ready") is True + checks.append(Check("readiness-ok", ready, f"{status} {body.get('detail')}")) + + status, body = get(base, "/state/health", timeout) + checks.append( + Check("state-health-ok", status == 200 and body.get("status") == "ok", + f"{status} {body.get('db')}") + ) + + migration = body.get("migration") + if expect_migration: + checks.append( + Check("migration-at-head", migration == expect_migration, + f"running {migration}, expected {expect_migration}") + ) + else: + # Without an expected revision this can only confirm the schema is + # stamped at all — say so rather than implying it verified the head. + checks.append( + Check("migration-stamped", bool(migration), + f"running {migration} (no --expect-migration given)") + ) + + status, body = get(base, "/index", timeout) + checks.append(Check("index-queryable", status == 200 and "entries" in body, + f"{status}")) + + status, body = get(base, "/packages", timeout) + checks.append(Check("registry-queryable", status == 200 and "packages" in body, + f"{status}")) + return checks + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", default="http://127.0.0.1:8000") + parser.add_argument("--expect-migration", default=None, + help="revision the deployed schema should be at") + parser.add_argument("--timeout", type=float, default=5.0) + args = parser.parse_args(argv) + + checks = run(args.base, args.expect_migration, args.timeout) + width = max(len(c.name) for c in checks) + for check in checks: + print(f"{'PASS' if check.ok else 'FAIL'} {check.name:<{width}} {check.detail}") + + failed = [c.name for c in checks if not c.ok] + if failed: + print(f"\n{len(failed)} check(s) failed: {', '.join(failed)}", file=sys.stderr) + return 1 + print(f"\nall {len(checks)} checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/workplans/CANP-WP-0006-hosted-registry-service.md b/workplans/CANP-WP-0006-hosted-registry-service.md index 568f63e..2812207 100644 --- a/workplans/CANP-WP-0006-hosted-registry-service.md +++ b/workplans/CANP-WP-0006-hosted-registry-service.md @@ -4,7 +4,7 @@ type: workplan title: "Hosted registry and index service" domain: agents repo: canned-prompts -status: active +status: finished owner: codex topic_slug: practice created: "2026-09-06" @@ -245,7 +245,7 @@ its only dependency. Registry responses are treated as untrusted input (§ 19): ```task id: CANP-WP-0006-T06 -status: todo +status: done priority: medium state_hub_task_id: "3c008ebe-bf07-5cee-8678-7b1ed27283ef" ``` @@ -253,5 +253,42 @@ state_hub_task_id: "3c008ebe-bf07-5cee-8678-7b1ed27283ef" Dockerfile and a published image, plus the checks a `rapp` smoke contract will assert: health ok, migration at head, private service only, image digest match. -Completing this produces the digest that `rapp-canned-prompts` needs to pin, at -which point that repo can be created against something real. +**Done, with one honest limit.** + +The image builds and was verified by running it: a two-stage `python:3.12-slim` +build with no toolchain in the runtime layer, non-root (uid 10001), writing +nothing to disk. All six smoke checks passed against the running container, and +the reference CLI installed a package from it over HTTP. + +Migrations deliberately do **not** run at start-up. A schema change is a +deployment step with its own rollback, not something that races between +replicas. + +`tools/smoke.py` covers what can be known about a *running service* — liveness, +readiness, fleet health shape, migration revision, index and registry +queryable. stdlib only, so it runs inside the runtime image; non-zero exit, so +a deployment gate can call it. Verified in both directions: it passes against +the container and fails against a wrong `--expect-migration`, so +`migration-at-head` is a real check rather than a decorative one. + +Cluster-level checks a rapp contract also names — NetworkPolicies present, +external secrets ready, private-Service-only, live image digest match — are +properties of the deployment and belong to `rapp-canned-prompts`. + +**The digest does not exist yet.** The image was built locally +(`sha256:4878b208…`, 76 MB) but never pushed. `rapp.yaml` pins +`upstream_components.version` to a digest from the fleet's registry, which +exists only once the image is published — an operator action needing registry +credentials, and outward-facing enough that it is not mine to take unasked. + +## Follow-on: rapp-canned-prompts + +Not a task in this workplan; recorded so the sequence is not lost. + +1. Publish the image to the fleet registry and capture its digest. +2. Create `rapp-canned-prompts` with `ownership_repo: canned-prompts`, + `readiness_state: draft`, and that digest in `upstream_components`. +3. Add the deployment-level smoke checks, calling `tools/smoke.py` for the + service-level half. +4. Decide the PostgreSQL binding with `rapp-postgres` and the credential + broker, per the shape `rapp-sbom-nexus` uses.