CANP-WP-0006 T01-T02: service skeleton and tenant-keyed schema
The foundation of the hosted registry, in canned-prompts so rapp.yaml gets ownership_repo: canned-prompts — the sbom-nexus shape, where product ownership stays out of the operations repo. Stack matches state-hub and sbom-nexus: FastAPI, SQLAlchemy, Alembic, PostgreSQL, in service/ with its own environment. reference/ is deliberately untouched: it is the format's conformance witness and stays dependency-light, and the service is a separate consumer of the same package semantics. 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 per RailianceAppDeploymentGuide.md: unauthenticated /healthz and /readyz, plus /state/health for fleet consistency. /healthz deliberately checks nothing beyond the process being up, so a database blip does not restart pods; /readyz asks the database something it can fail to answer. Migration 0001 creates package_versions, package_files and index_entries, every one carrying a tenant key per business-app-service-contract section 1.3 — the service is single-tenant today, and the key is present so a later consolidation is a data copy rather than a rewrite. A test asserts every table in the metadata is tenant-keyed, so adding an unkeyed table fails the suite rather than being discovered at consolidation time. Uniqueness is (tenant, registry, package_id, version): registry-scoped because identity is, tenant-scoped so two tenants may hold the same id. The schema keeps the format's three things distinct — an immutable package version, its files as content rather than parsed rows, and an index entry recording how a version arrived here. Fixes a bug its own test caught: check_readiness first caught every failure in one except and reported "database unreachable", so an unmigrated but perfectly reachable database sent an operator to credentials and networking when the fix was alembic upgrade. Connectivity and schema are now checked separately. Service tests 11 passing; reference tests unaffected at 99. 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
This commit is contained in:
parent
65a86acdc2
commit
ae52931be5
15 changed files with 701 additions and 3 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -8,3 +8,7 @@
|
|||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
|
||||
# service
|
||||
service/.venv/
|
||||
service/*.db
|
||||
|
|
|
|||
73
service/README.md
Normal file
73
service/README.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# 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` |
|
||||
|
||||
## Status
|
||||
|
||||
`CANP-WP-0006` T01 and T02 are done: skeleton, health surface, tenant-keyed
|
||||
schema and migration `0001`. The read API, publish API, HTTP registry client and
|
||||
container image (T03–T06) are not built yet.
|
||||
26
service/alembic.ini
Normal file
26
service/alembic.ini
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[alembic]
|
||||
script_location = migrations
|
||||
prepend_sys_path = src
|
||||
|
||||
[loggers]
|
||||
keys = root
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
49
service/migrations/env.py
Normal file
49
service/migrations/env.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Alembic environment.
|
||||
|
||||
The database URL comes from settings, never from alembic.ini, so a migration
|
||||
cannot be run against a different database than the service uses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from canned_prompts_service.db import Base
|
||||
from canned_prompts_service.settings import get_settings
|
||||
from canned_prompts_service import models # noqa: F401 — registers the tables
|
||||
|
||||
config = context.config
|
||||
target_metadata = Base.metadata
|
||||
|
||||
settings = get_settings()
|
||||
if settings.configured:
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
20
service/migrations/script.py.mako
Normal file
20
service/migrations/script.py.mako
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"""tenant-keyed package store
|
||||
|
||||
Every table holding owned data carries `tenant` from this, the first migration,
|
||||
per business-app-service-contract_v0.1 section 1.3 — so a later consolidation is
|
||||
a data copy rather than a rewrite, and no business logic may assume it is the
|
||||
only tenant (section 1.4).
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0001'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('index_entries',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('registry', sa.String(length=128), nullable=False),
|
||||
sa.Column('package_id', sa.String(length=512), nullable=False),
|
||||
sa.Column('version', sa.String(length=64), nullable=False),
|
||||
sa.Column('source', sa.Text(), nullable=False),
|
||||
sa.Column('method', sa.String(length=32), nullable=False),
|
||||
sa.Column('declared_author', sa.String(length=256), nullable=True),
|
||||
sa.Column('declared_source', sa.Text(), nullable=True),
|
||||
sa.Column('license', sa.String(length=128), nullable=True),
|
||||
sa.Column('included_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_index_entry')
|
||||
)
|
||||
op.create_table('package_versions',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('registry', sa.String(length=128), nullable=False),
|
||||
sa.Column('package_id', sa.String(length=512), nullable=False),
|
||||
sa.Column('version', sa.String(length=64), nullable=False),
|
||||
sa.Column('name', sa.String(length=256), nullable=False),
|
||||
sa.Column('summary', sa.Text(), nullable=False),
|
||||
sa.Column('package_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('license', sa.String(length=128), nullable=True),
|
||||
sa.Column('tags', sa.Text(), nullable=False),
|
||||
sa.Column('manifest', sa.Text(), nullable=False),
|
||||
sa.Column('content_digest', sa.String(length=71), nullable=False),
|
||||
sa.Column('published_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('tenant', 'registry', 'package_id', 'version', name='uq_package_version')
|
||||
)
|
||||
op.create_index('ix_package_versions_tenant_id', 'package_versions', ['tenant', 'package_id'], unique=False)
|
||||
op.create_table('package_files',
|
||||
sa.Column('id', sa.BigInteger(), autoincrement=True, nullable=False),
|
||||
sa.Column('tenant', sa.String(length=64), nullable=False),
|
||||
sa.Column('package_version_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('path', sa.String(length=1024), nullable=False),
|
||||
sa.Column('content', sa.LargeBinary(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['package_version_id'], ['package_versions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('package_version_id', 'path', name='uq_package_file_path')
|
||||
)
|
||||
op.create_index('ix_package_files_tenant', 'package_files', ['tenant'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index('ix_package_files_tenant', table_name='package_files')
|
||||
op.drop_table('package_files')
|
||||
op.drop_index('ix_package_versions_tenant_id', table_name='package_versions')
|
||||
op.drop_table('package_versions')
|
||||
op.drop_table('index_entries')
|
||||
# ### end Alembic commands ###
|
||||
25
service/pyproject.toml
Normal file
25
service/pyproject.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "canned-prompts-service"
|
||||
version = "0.1.0"
|
||||
description = "Hosted registry and index service for Canned Prompt Format packages"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"sqlalchemy>=2.0.0",
|
||||
"alembic>=1.14.0",
|
||||
"pydantic>=2.10.0",
|
||||
"pydantic-settings>=2.7.0",
|
||||
"psycopg[binary]>=3.2.0",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "httpx>=0.28.0"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/canned_prompts_service"]
|
||||
11
service/src/canned_prompts_service/__init__.py
Normal file
11
service/src/canned_prompts_service/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"""Hosted registry and index service for Canned Prompt Format packages.
|
||||
|
||||
Product ownership lives here, in `canned-prompts`, alongside the specification
|
||||
and the reference CLI. Deployment and operation belong to `rapp-canned-prompts`.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
62
service/src/canned_prompts_service/api.py
Normal file
62
service/src/canned_prompts_service/api.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""HTTP surface.
|
||||
|
||||
`RailianceAppDeploymentGuide.md` requires unauthenticated `/healthz` and
|
||||
`/readyz`; `/state/health` matches `state-hub` and `sbom-nexus`.
|
||||
|
||||
Liveness and readiness answer different questions and must not be aliased:
|
||||
`/healthz` says the process is up, `/readyz` says it can serve — which means
|
||||
asking the database something it can fail to answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Response
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
from . import __version__
|
||||
from .db import check_readiness, make_engine
|
||||
from .settings import Settings, get_settings
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None, engine: Engine | None = None) -> FastAPI:
|
||||
settings = settings or get_settings()
|
||||
if engine is None and settings.configured:
|
||||
engine = make_engine(settings.database_url)
|
||||
|
||||
app = FastAPI(title="canned-prompts registry", version=__version__)
|
||||
app.state.settings = settings
|
||||
app.state.engine = engine
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Liveness: the process is running. Deliberately checks nothing else."""
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/readyz")
|
||||
def readyz(response: Response) -> dict[str, Any]:
|
||||
"""Readiness: the service can actually serve requests."""
|
||||
readiness = check_readiness(app.state.engine)
|
||||
if not readiness.ready:
|
||||
response.status_code = 503
|
||||
return {
|
||||
"ready": readiness.ready,
|
||||
"detail": readiness.detail,
|
||||
"migration": readiness.migration,
|
||||
}
|
||||
|
||||
@app.get("/state/health")
|
||||
def state_health(response: Response) -> dict[str, Any]:
|
||||
readiness = check_readiness(app.state.engine)
|
||||
if not readiness.ready:
|
||||
response.status_code = 503
|
||||
return {
|
||||
"status": "ok" if readiness.ready else "degraded",
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"db": "connected" if readiness.ready else readiness.detail,
|
||||
"migration": readiness.migration,
|
||||
}
|
||||
|
||||
return app
|
||||
64
service/src/canned_prompts_service/db.py
Normal file
64
service/src/canned_prompts_service/db.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""Database session and readiness.
|
||||
|
||||
Readiness asks the database a question it can actually fail to answer. A probe
|
||||
that cannot fail is a liveness probe wearing the wrong name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def make_engine(database_url: str) -> Engine:
|
||||
# future=True is the 2.0 default; pool_pre_ping keeps a recycled connection
|
||||
# from surfacing as a request error after a database restart.
|
||||
return create_engine(database_url, pool_pre_ping=True, future=True)
|
||||
|
||||
|
||||
def make_session_factory(engine: Engine) -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Readiness:
|
||||
ready: bool
|
||||
detail: str
|
||||
migration: str | None = None
|
||||
|
||||
|
||||
def check_readiness(engine: Engine | None) -> Readiness:
|
||||
if engine is None:
|
||||
return Readiness(False, "no database configured")
|
||||
|
||||
# Connectivity and schema are checked separately on purpose. Both mean "not
|
||||
# ready", but they tell an operator to do different things: one is
|
||||
# credentials or network, the other is an unrun migration. Collapsing them
|
||||
# into one message sends people to the wrong place.
|
||||
try:
|
||||
connection = engine.connect()
|
||||
except Exception as exc: # noqa: BLE001 — any failure means not ready
|
||||
return Readiness(False, f"database unreachable: {type(exc).__name__}")
|
||||
|
||||
with connection:
|
||||
try:
|
||||
connection.execute(text("select 1"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return Readiness(False, f"database unreachable: {type(exc).__name__}")
|
||||
try:
|
||||
revision = connection.execute(
|
||||
text("select version_num from alembic_version")
|
||||
).scalar_one_or_none()
|
||||
except Exception: # noqa: BLE001 — reachable, but no alembic_version table
|
||||
return Readiness(False, "schema not migrated")
|
||||
|
||||
if revision is None:
|
||||
return Readiness(False, "schema not migrated")
|
||||
return Readiness(True, "ok", revision)
|
||||
125
service/src/canned_prompts_service/models.py
Normal file
125
service/src/canned_prompts_service/models.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Store schema.
|
||||
|
||||
Tenant keying, from the first migration onward, per
|
||||
`business-app-service-contract_v0.1` § 1.3. Every table holding owned data
|
||||
carries `tenant`, and no query may assume it is the only tenant (§ 1.4). The
|
||||
service is deployed single-tenant today; the key is here so a later
|
||||
consolidation is a data copy rather than a rewrite.
|
||||
|
||||
The format's three distinct things stay distinct:
|
||||
|
||||
- a **package version** is immutable content addressed by `<id>@<version>`
|
||||
(§ 17), scoped to a registry because identity is registry-scoped (§ 3.2);
|
||||
- an **index entry** records how a version arrived in this store (§ 20.3);
|
||||
- **files** are content, stored as blobs rather than parsed into rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
LargeBinary,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
def utcnow() -> dt.datetime:
|
||||
return dt.datetime.now(dt.timezone.utc)
|
||||
|
||||
|
||||
class PackageVersion(Base):
|
||||
"""One immutable `<registry>:<id>@<version>` within one tenant."""
|
||||
|
||||
__tablename__ = "package_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant", "registry", "package_id", "version", name="uq_package_version"
|
||||
),
|
||||
Index("ix_package_versions_tenant_id", "tenant", "package_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
registry: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
package_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# Indexed manifest fields, for discovery without opening every package.
|
||||
name: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
summary: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
package_type: Mapped[str] = mapped_column(String(32), nullable=False, default="template")
|
||||
license: Mapped[str | None] = mapped_column(String(128))
|
||||
tags: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
# The manifest as published, so a consumer sees exactly what was stored.
|
||||
manifest: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
content_digest: Mapped[str] = mapped_column(String(71), nullable=False)
|
||||
published_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
|
||||
files: Mapped[list["PackageFile"]] = relationship(
|
||||
back_populates="package_version", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PackageFile(Base):
|
||||
"""A file belonging to a package version. Content, not parsed rows."""
|
||||
|
||||
__tablename__ = "package_files"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("package_version_id", "path", name="uq_package_file_path"),
|
||||
Index("ix_package_files_tenant", "tenant"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
package_version_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("package_versions.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
content: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
|
||||
package_version: Mapped[PackageVersion] = relationship(back_populates="files")
|
||||
|
||||
|
||||
class IndexEntry(Base):
|
||||
"""How a package version arrived in this store (§ 20.3).
|
||||
|
||||
Store metadata, deliberately separate from the package: `included_at` is
|
||||
first arrival and is never overwritten; a re-record updates `last_seen_at`.
|
||||
"""
|
||||
|
||||
__tablename__ = "index_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant", "registry", "package_id", "version", name="uq_index_entry"
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
tenant: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
registry: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
package_id: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
version: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
source: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
method: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
declared_author: Mapped[str | None] = mapped_column(String(256))
|
||||
declared_source: Mapped[str | None] = mapped_column(Text)
|
||||
license: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
included_at: Mapped[dt.datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
last_seen_at: Mapped[dt.datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
27
service/src/canned_prompts_service/settings.py
Normal file
27
service/src/canned_prompts_service/settings.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Service configuration.
|
||||
|
||||
Every value is settable from the environment so the container needs no config
|
||||
file. `database_url` has no default on purpose: a service that silently falls
|
||||
back to a local database when its real one is misconfigured is worse than one
|
||||
that refuses to start.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="CANNED_PROMPTS_", extra="ignore")
|
||||
|
||||
database_url: str = ""
|
||||
tenant: str = "default"
|
||||
service_name: str = "canned-prompts"
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.database_url)
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
94
service/tests/test_health.py
Normal file
94
service/tests/test_health.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Health surface.
|
||||
|
||||
The point of these tests is the negative cases. An endpoint that returns 200
|
||||
under every condition tells an orchestrator nothing, and the failure is silent
|
||||
exactly when it matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from canned_prompts_service.api import create_app
|
||||
from canned_prompts_service.db import check_readiness, make_engine
|
||||
from canned_prompts_service.settings import Settings
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def migrated_url(tmp_path: Path) -> str:
|
||||
url = f"sqlite:///{tmp_path / 'svc.db'}"
|
||||
config = Config(str(ROOT / "alembic.ini"))
|
||||
config.set_main_option("script_location", str(ROOT / "migrations"))
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
command.upgrade(config, "head")
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def ready_client(tmp_path: Path) -> TestClient:
|
||||
url = migrated_url(tmp_path)
|
||||
return TestClient(create_app(Settings(database_url=url), make_engine(url)))
|
||||
|
||||
|
||||
def test_healthz_is_up_without_a_database() -> None:
|
||||
"""Liveness must not depend on the database, or a DB blip restarts pods."""
|
||||
client = TestClient(create_app(Settings(), None))
|
||||
assert client.get("/healthz").json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_readyz_fails_without_a_database() -> None:
|
||||
client = TestClient(create_app(Settings(), None))
|
||||
response = client.get("/readyz")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["ready"] is False
|
||||
assert "no database" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_readyz_fails_when_the_database_is_unreachable(tmp_path: Path) -> None:
|
||||
engine = make_engine("sqlite:////nonexistent/dir/does-not-exist.db")
|
||||
client = TestClient(create_app(Settings(database_url="x"), engine))
|
||||
response = client.get("/readyz")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["ready"] is False
|
||||
|
||||
|
||||
def test_readyz_fails_when_the_schema_is_not_migrated(tmp_path: Path) -> None:
|
||||
"""Reachable but unmigrated is not ready — it would 500 on the first query."""
|
||||
engine = make_engine(f"sqlite:///{tmp_path / 'empty.db'}")
|
||||
client = TestClient(create_app(Settings(database_url="x"), engine))
|
||||
response = client.get("/readyz")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "schema not migrated"
|
||||
|
||||
|
||||
def test_readyz_reports_the_migration_when_ready(ready_client: TestClient) -> None:
|
||||
body = ready_client.get("/readyz").json()
|
||||
assert body["ready"] is True
|
||||
assert body["migration"] == "0001"
|
||||
|
||||
|
||||
def test_state_health_matches_the_fleet_shape(ready_client: TestClient) -> None:
|
||||
body = ready_client.get("/state/health").json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "canned-prompts"
|
||||
assert body["db"] == "connected"
|
||||
assert body["migration"] == "0001"
|
||||
|
||||
|
||||
def test_state_health_degrades_rather_than_lying() -> None:
|
||||
client = TestClient(create_app(Settings(), None))
|
||||
response = client.get("/state/health")
|
||||
assert response.status_code == 503
|
||||
assert response.json()["status"] == "degraded"
|
||||
|
||||
|
||||
def test_settings_have_no_database_fallback() -> None:
|
||||
"""Falling back to a local database when misconfigured hides the mistake."""
|
||||
assert Settings().database_url == ""
|
||||
assert Settings().configured is False
|
||||
25
service/tests/test_schema.py
Normal file
25
service/tests/test_schema.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Schema obligations from business-app-service-contract_v0.1 § 1.3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from canned_prompts_service import models
|
||||
|
||||
|
||||
def test_every_owned_table_is_tenant_keyed() -> None:
|
||||
for table in models.Base.metadata.sorted_tables:
|
||||
assert "tenant" in table.c, f"{table.name} holds owned data without a tenant key"
|
||||
|
||||
|
||||
def test_uniqueness_is_scoped_by_tenant_and_registry() -> None:
|
||||
"""Identity is registry-scoped (§ 3.2); two tenants may hold the same id."""
|
||||
constraint = next(
|
||||
c for c in models.PackageVersion.__table__.constraints
|
||||
if getattr(c, "name", "") == "uq_package_version"
|
||||
)
|
||||
assert [c.name for c in constraint.columns] == ["tenant", "registry", "package_id", "version"]
|
||||
|
||||
|
||||
def test_index_entry_records_arrival_not_authorship() -> None:
|
||||
columns = models.IndexEntry.__table__.c
|
||||
assert "included_at" in columns and "last_seen_at" in columns
|
||||
assert "source" in columns and "method" in columns
|
||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
|||
title: "Hosted registry and index service"
|
||||
domain: agents
|
||||
repo: canned-prompts
|
||||
status: proposed
|
||||
status: active
|
||||
owner: codex
|
||||
topic_slug: practice
|
||||
created: "2026-09-06"
|
||||
|
|
@ -51,7 +51,7 @@ the service does not satisfy.
|
|||
|
||||
```task
|
||||
id: CANP-WP-0006-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6e4178f0-a431-59de-8bd1-fb5b2de95302"
|
||||
```
|
||||
|
|
@ -68,11 +68,22 @@ Keep `reference/` untouched. It is the format's conformance witness and must
|
|||
stay dependency-light; the service is a separate consumer of the same package
|
||||
semantics.
|
||||
|
||||
**Done.** `service/` with a FastAPI factory, pydantic-settings config, and the
|
||||
three endpoints. `CANNED_PROMPTS_DATABASE_URL` has no default, so a
|
||||
misconfigured service refuses to start rather than quietly using a local
|
||||
database.
|
||||
|
||||
**Bug found by its own test.** `check_readiness` first caught every failure in
|
||||
one `except` and reported "database unreachable". An unmigrated but perfectly
|
||||
reachable database therefore reported a connection problem — sending an
|
||||
operator to credentials and networking when the fix was `alembic upgrade`.
|
||||
Connectivity and schema are now checked separately.
|
||||
|
||||
## Tenant-keyed schema and first migration
|
||||
|
||||
```task
|
||||
id: CANP-WP-0006-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "bc0e4a49-63b1-5fa1-9ec9-5ed0f505baa1"
|
||||
```
|
||||
|
|
@ -94,6 +105,11 @@ them:
|
|||
|
||||
Store package files as content, not as rows per file.
|
||||
|
||||
**Done.** Migration `0001` creates `package_versions`, `package_files` and
|
||||
`index_entries`; a test asserts every table in the metadata carries `tenant`, so
|
||||
adding an unkeyed table fails the suite rather than being noticed at
|
||||
consolidation time. Uniqueness is `(tenant, registry, package_id, version)`.
|
||||
|
||||
## Read API
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue