canned-prompts/service/README.md
tegwick 0fb7150956 Read credentials from files, not only the environment
Found by packaging the service for Railiance (rapp-canned-prompts). Every other
rapp in the fleet mounts its database credential as a file; this service could
only read CANNED_PROMPTS_DATABASE_URL from the environment, which would have
put a database password into kubectl describe, into crash dumps, and in reach
of anything able to read /proc.

Adds CANNED_PROMPTS_DATABASE_URL_FILE and CANNED_PROMPTS_PUBLISH_TOKEN_FILE. A
mounted secret stays 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, and
an unreadable secret file fails loudly rather than falling back to a value that
may be older.

Service tests 33 -> 36.

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:45:29 +02:00

168 lines
7.5 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
```
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=&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.