canned-prompts/service/tools/smoke.py
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

111 lines
3.8 KiB
Python
Executable file

#!/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())