canned-prompts/service/README.md
tegwick f41d1b705f CANP-WP-0006 T06: container image and smoke checks
Two-stage python:3.12-slim build with no toolchain in the runtime layer,
running non-root (uid 10001) and writing nothing to disk — its state is the
database. reference/ is a real build input, because the service delegates
validation to it so the two cannot disagree about what a valid package is.

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 asserts what can be known about a running service: liveness,
readiness, fleet health shape, migration revision, and that the index and
registry are queryable. stdlib only, so it runs inside the runtime image;
non-zero exit, so a deployment gate can call it directly.

Verified by running it, not by inspection: the container starts, all six checks
pass against it, the reference CLI installs a package from it over HTTP, and
the checks fail correctly against a wrong --expect-migration — so
migration-at-head is a real check rather than a decorative one. Without
--expect-migration the check can only confirm the schema is stamped at all, and
says so rather than implying it verified the head.

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 rapp.yaml would pin does not exist yet. The image was built locally
and verified, but never pushed; that digest exists only once the image is
published to the fleet registry, which needs credentials and is outward-facing
enough not to do unasked. The follow-on sequence for rapp-canned-prompts is
recorded in the workplan.

CANP-WP-0006 is finished. Service tests 33, reference 105.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 21:25:48 +02:00

160 lines
7.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# canned-prompts service
The hosted registry and index (`CANP-WP-0006`). Product ownership lives here
alongside the specification and the reference CLI; deployment and operation will
belong to `rapp-canned-prompts` once there is an image digest to pin.
This service **hosts** packages. It does not execute them — `INTENT.md`'s
deliberate boundary holds, so rendering stays deterministic and a `derive`
default remains a declaration the service does not satisfy.
`reference/` is deliberately untouched by this. It is the format's conformance
witness and stays dependency-light; the service is a separate consumer of the
same package semantics.
## Stack
FastAPI, SQLAlchemy, Alembic, PostgreSQL — matching `state-hub` and
`sbom-nexus`.
```bash
cd service
uv venv && uv pip install -e ".[dev]"
.venv/bin/python -m pytest -q
export CANNED_PROMPTS_DATABASE_URL=postgresql+psycopg://...
.venv/bin/alembic upgrade head
.venv/bin/uvicorn canned_prompts_service.api:create_app --factory
```
`CANNED_PROMPTS_DATABASE_URL` has **no default**. A service that silently falls
back to a local database when its real one is misconfigured is worse than one
that refuses to start.
## Health surface
`RailianceAppDeploymentGuide.md` requires unauthenticated `/healthz` and
`/readyz`; `/state/health` matches the rest of the fleet.
| Endpoint | Answers | Fails when |
|---|---|---|
| `/healthz` | is the process up | never — deliberately checks nothing else, so a database blip does not restart pods |
| `/readyz` | can it serve | no database configured, unreachable, or schema not migrated |
| `/state/health` | fleet-shaped status | same as `/readyz`, reported as `degraded` |
Connectivity and schema are checked separately. Both mean not-ready, but they
send an operator to different places — credentials and network, or an unrun
migration — so collapsing them into one message would send people to the wrong
one.
## Tenancy
Every table holding owned data carries a `tenant` key from migration `0001`,
per `business-app-service-contract_v0.1` § 1.3. The service is deployed
single-tenant today; the key is present so a later consolidation is a data copy
rather than a rewrite, and no query may assume it is the only tenant (§ 1.4).
Uniqueness is `(tenant, registry, package_id, version)` — registry-scoped
because identity is (§ 3.2), and tenant-scoped so two tenants may legitimately
hold the same id.
## Schema
| Table | Holds |
|---|---|
| `package_versions` | one immutable `<registry>:<id>@<version>`, its manifest, and indexed discovery fields |
| `package_files` | the package's files as content, not parsed into rows |
| `index_entries` | how a version arrived here (§ 20.3): source, method, `included_at` never overwritten, `last_seen_at` |
## Read API
Routes address packages with the format's own reference syntax
(`<registry>:<id>@<version>`, § 3.2 and § 17) rather than a second one invented
for HTTP. A package id contains `/`, so the reference is captured as a greedy
path and parsed; each route keeps a distinct prefix so the greediness cannot
swallow a neighbouring segment.
| Route | Returns |
|---|---|
| `GET /packages?q=&registry=` | search over id, name, summary and tags |
| `GET /packages/{id}` | the versions of an id |
| `GET /packages/{id}@{version}` | one manifest |
| `GET /archives/{id}@{version}` | the package's files, text or base64 |
| `GET /index` | § 20.3 entries: source, method, `included_at`, `last_seen_at` |
A bare id present in more than one registry returns **409 with the candidates**,
never a guess (§ 3.2). 409 rather than 300 because the request is answerable —
once the caller says which registry they meant.
Asking for a package without naming a version applies § 17.1's selector rules,
so a prerelease is never chosen implicitly.
## Publish API
`POST /packages` takes `{registry, source, files}`, where `files` is the same
shape `GET /archives` returns — so an archive round-trips into a publish
without translation, and a mirror is a GET followed by a POST.
Validation is the reference implementation's, applied to the posted files
materialized in a temporary directory. Only reserved paths and
manifest-referenced files are stored (§ 2); path traversal is refused.
Re-publishing identical content is accepted, different content under the same
`<id>@<version>` is 409 (§ 17).
### What identity means here
A single shared bearer token (`CANNED_PROMPTS_PUBLISH_TOKEN`), proving the
caller is **the operator of this service** — not per-publisher identity. Every
holder of the token is indistinguishable.
With no token configured the service is **read-only**. That is the correct
default rather than an inconvenience: § 20.1 asks a registry to refuse
publication into a closed namespace it does not consider the publisher to own,
and an unauthenticated service considers nobody to own anything.
Namespace claims (§ 20.1) live in `namespace_claims` and are **enforced** here,
which a filesystem registry cannot do at all — but only as precisely as the
identity behind them. A `closed` namespace is protected from anonymous callers;
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` 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.
**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.