Three defects the test suite could not have caught, because each needed a real cluster, a mounted secret, or a live PostgreSQL. env.py read database_url rather than resolved_database_url. `configured` was true because a file was set, and the field it then read was empty — so Alembic received an empty URL and the migration could never run in the cluster. The value is also now escaped for ConfigParser interpolation, since a `%` in a generated password would otherwise raise at credential rotation, which is the worst time to find out. SET ROLE opened an implicit transaction that Alembic then nested inside rather than owning, so it never committed and leaving the connection block rolled everything back. Alembic logged "Running upgrade" for every revision against a database that stayed empty. SET ROLE is session-scoped, so committing immediately ends the implicit transaction without discarding the role. A missing optional publish-token file was treated as a hard failure. The absence is the documented read-only posture — the secret is mounted optional and deliberately not issued — so treating it as a fault turned an intended state into a 500 rather than the 503 that explains it. `required` now separates the two cases: a missing database URL still fails loudly, because there the silence would hide a real fault. Migrations also assume the durable owner role rather than creating objects as the leased migration login, per the rapp-postgres database-owner boundary. The role name is validated against an identifier pattern because SET ROLE cannot be parameterised. Service tests 36 -> 47. 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
191 lines
8.6 KiB
Markdown
191 lines
8.6 KiB
Markdown
# 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
|
||
```
|
||
|
||
Credentials may arrive as **files** rather than environment variables —
|
||
`CANNED_PROMPTS_DATABASE_URL_FILE` and `CANNED_PROMPTS_PUBLISH_TOKEN_FILE` —
|
||
which is how they are supplied in the cluster. An env var holding a password is
|
||
visible in `kubectl describe`, in crash dumps, and to anything that can read
|
||
`/proc`; a mounted secret should stay a file. When both forms are set the file
|
||
wins, because a rotated secret must take effect rather than be shadowed by a
|
||
stale env var.
|
||
|
||
`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=®istry=` | 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.
|
||
|
||
## Deployed
|
||
|
||
Running on railiance01 since 2026-09-08 via `rapp-canned-prompts`, image tag
|
||
0.1.4, schema at alembic `0002`, read-only by design.
|
||
|
||
Four defects surfaced during that first rollout that the test suite could not
|
||
have caught, because each needed a real cluster, a mounted secret, or a live
|
||
PostgreSQL:
|
||
|
||
- `env.py` read `database_url` rather than `resolved_database_url`, so the
|
||
migration could not run where the credential is a file;
|
||
- `SET ROLE` opened an implicit transaction that Alembic then **nested inside
|
||
rather than owning**, so every revision logged as applied and was rolled
|
||
back — success reported against an empty database;
|
||
- a missing *optional* publish-token file was treated as a hard failure, so the
|
||
documented read-only posture returned 500 instead of an explanatory 503;
|
||
- and separately in the rapp, an egress NetworkPolicy that did not select the
|
||
migration Job at all.
|
||
|
||
Each now has a regression test. The transaction one is the most worth knowing:
|
||
touching an Alembic connection before Alembic does changes who owns the
|
||
transaction, and the failure is silent in both directions.
|
||
|
||
## 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.
|