feat(container): harden v0.1.1 publication

This commit is contained in:
2026-08-15 15:14:36 +09:00
parent fd080496b3
commit 9c8baac70b
14 changed files with 287 additions and 72 deletions
+69 -9
View File
@@ -13,6 +13,7 @@ import sys
import tarfile
import tempfile
import time
import tomllib
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
@@ -24,6 +25,8 @@ COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
VERSION_REF_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
EXPECTED_MIGRATION_COUNT = 29
EXPECTED_ALEMBIC_HEAD = "20260814_0029"
@dataclass(frozen=True)
class Result:
@@ -52,11 +55,24 @@ def validate_checkout_identity(actual: str, expected: str) -> str:
raise ValueError("checked-out HEAD does not match the tagged source commit")
return actual
def validate_release_event(event_name: str, ref: str, commit: str) -> str:
def project_release_version(project: Path) -> str:
value = tomllib.loads(project.read_text(encoding="utf-8"))["project"]["version"]
version = f"v{value}"
if not VERSION_REF_RE.fullmatch(f"refs/tags/{version}"):
raise ValueError("project version must be strict semantic version X.Y.Z")
return version
def validate_release_event(
event_name: str, ref: str, commit: str, expected_version: str | None = None
) -> str:
validate_commit(commit)
if event_name != "push" or not VERSION_REF_RE.fullmatch(ref):
raise ValueError("publication requires a push of a strict vX.Y.Z tag")
return ref.removeprefix("refs/tags/")
version = ref.removeprefix("refs/tags/")
if expected_version is not None and version != expected_version:
raise ValueError("release tag does not match the project version")
return version
def release_tags(event_name: str, ref: str, commit: str) -> tuple[str, str]:
version = validate_release_event(event_name, ref, commit)
@@ -92,6 +108,10 @@ def _clean(result: Result) -> Result:
def inspect_value(image: str, template: str, runner: Runner) -> str:
return _clean(runner.run(["docker", "image", "inspect", "--format", template, image])).stdout.strip()
def inspect_container_value(container: str, template: str, runner: Runner) -> str:
return _clean(runner.run(["docker", "container", "inspect", "--format", template, container])).stdout.strip()
def inspect_image_id(image: str, runner: Runner) -> str:
value = inspect_value(image, "{{.Id}}", runner)
if not IMAGE_ID_RE.fullmatch(value):
@@ -101,6 +121,10 @@ def inspect_image_id(image: str, runner: Runner) -> str:
def inspect_revision(image: str, runner: Runner) -> str:
return inspect_value(image, '{{ index .Config.Labels "org.opencontainers.image.revision" }}', runner)
def inspect_version(image: str, runner: Runner) -> str:
return inspect_value(image, '{{ index .Config.Labels "org.opencontainers.image.version" }}', runner)
def write_image_id(image: str, output: Path, runner: Runner) -> str:
image_id = inspect_image_id(image, runner)
output.write_text(image_id + "\n", encoding="ascii")
@@ -144,6 +168,10 @@ def smoke_installed_image(image: str, expected_inventory: tuple[str, ...], runne
timeout: int = 30) -> None:
if not expected_inventory:
raise ValueError("expected migration inventory must be non-empty")
if len(expected_inventory) != EXPECTED_MIGRATION_COUNT:
raise ValueError(f"expected exactly {EXPECTED_MIGRATION_COUNT} migrations")
if not expected_inventory[-1].startswith(EXPECTED_ALEMBIC_HEAD):
raise ValueError(f"migration inventory must end at {EXPECTED_ALEMBIC_HEAD}")
inventory_code = (
"import json; from app.db.migrations import migration_inventory; "
f"expected={json.dumps(list(expected_inventory))}; actual=list(migration_inventory()); "
@@ -173,10 +201,11 @@ def _wait(command: list[str], runner: Runner, deadline: float, description: str)
raise RuntimeError(f"timed out waiting for {description}") from last
def startup_smoke(image: str, runner: Runner, timeout: int = 120) -> dict:
"""Run DB-disabled health, then isolated PostgreSQL migration, ping, and health."""
"""Run isolated health/OpenAPI, repeated migration, readiness, persistence and SIGTERM."""
suffix = f"{os.getpid()}-{time.time_ns()}"
plain = f"portfolio-hub-plain-{suffix}"; db = f"portfolio-hub-db-{suffix}"
migrate = f"portfolio-hub-migrate-{suffix}"; app = f"portfolio-hub-app-{suffix}"
migrate = f"portfolio-hub-migrate-{suffix}"; migrate_again = f"portfolio-hub-migrate-again-{suffix}"
app = f"portfolio-hub-app-{suffix}"; app_again = f"portfolio-hub-app-again-{suffix}"
network = f"portfolio-hub-smoke-{suffix}"; volume = f"portfolio-hub-db-{suffix}"
names: list[str] = []
deadline = time.monotonic() + timeout
@@ -185,8 +214,10 @@ def startup_smoke(image: str, runner: Runner, timeout: int = 120) -> dict:
"-e", "TIMESCALEDB_PORT=5432", "-e", "TIMESCALEDB_NAME=smoke",
"-e", "TIMESCALEDB_USER=smoke", "-e", "TIMESCALEDB_PASSWORD=synthetic-db-only",
"-e", "TIMESCALEDB_SSLMODE=disable", "-e", "CREDENTIAL_ENCRYPTION_KEY=bnVsbC1vbmx5LXN5bnRoZXRpYy1rZXktMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwPQ=="]
http_probe = "import json,urllib.request; d=json.load(urllib.request.urlopen('http://TARGET:8000/health')); assert d['status']=='ok'; print(json.dumps(d,sort_keys=True))"
http_probe = "import json,urllib.request; d=json.load(urllib.request.urlopen('http://TARGET:8000/health')); assert d['status']=='ok'; o=json.load(urllib.request.urlopen('http://TARGET:8000/openapi.json')); required={'/auth/login','/v1/user_info','/v1/portfolios','/v1/strategies','/v1/portfolios/{portfolio_id}/ledger/trades'}; assert required <= set(o['paths']), required-set(o['paths']); print(json.dumps(d,sort_keys=True))"
try:
architecture = inspect_value(image, "{{.Architecture}}", runner)
if architecture != "amd64": raise RuntimeError(f"image architecture must be amd64, got {architecture!r}")
runner.run(["docker", "network", "create", "--internal", network], timeout=10)
runner.run(["docker", "volume", "create", volume], timeout=10)
names.append(plain)
@@ -208,10 +239,19 @@ def startup_smoke(image: str, runner: Runner, timeout: int = 120) -> dict:
names.append(migrate)
runner.run(["docker", "run", "--name", migrate, "--network", network, *db_env,
"--entrypoint", "pfhub", image, "migrate-db"], timeout=60)
names.append(migrate_again)
runner.run(["docker", "run", "--name", migrate_again, "--network", network, *db_env,
"--entrypoint", "pfhub", image, "migrate-db"], timeout=60)
check = "import psycopg; c=psycopg.connect(host='postgres',dbname='smoke',user='smoke',password='synthetic-db-only'); r=c.execute(\"select version_num from alembic_version\").fetchone(); assert r and r[0]; assert c.execute('select 1').fetchone()==(1,); print(r[0])"
head = _clean(runner.run(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
image, "-c", check], timeout=30)).stdout.strip()
if not head: raise RuntimeError("Alembic head/DB ping returned no revision")
if head != EXPECTED_ALEMBIC_HEAD:
raise RuntimeError(f"Alembic head must be {EXPECTED_ALEMBIC_HEAD}, got {head!r}")
readiness = _clean(runner.run(["docker", "run", "--rm", "--network", network, *db_env,
"--entrypoint", "pfhub", image, "--format", "json", "health-check"], timeout=30))
readiness_payload = json.loads(readiness.stdout)
if readiness_payload.get("database_revision") != EXPECTED_ALEMBIC_HEAD:
raise RuntimeError("readiness did not report the packaged Alembic head")
names.append(app)
runner.run(["docker", "run", "-d", "--name", app, "--network", network, "--network-alias", "app",
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
@@ -221,9 +261,24 @@ def startup_smoke(image: str, runner: Runner, timeout: int = 120) -> dict:
app_payload = json.loads(app_result.stdout)
if not isinstance(app_payload, dict) or app_payload.get("db_enabled") is not True:
raise RuntimeError("DB-enabled health payload has the wrong shape")
_clean(runner.run(["docker", "stop", "--time", "10", app], timeout=20))
exit_code = inspect_container_value(app, "{{.State.ExitCode}}", runner)
if exit_code != "0": raise RuntimeError(f"SIGTERM shutdown exit code was {exit_code!r}")
names.append(app_again)
runner.run(["docker", "run", "-d", "--name", app_again, "--network", network,
"--network-alias", "app-again", "--read-only", "--cap-drop", "ALL",
"--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
*db_env, image], timeout=20)
repeated_result = _wait(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
image, "-c", http_probe.replace("TARGET", "app-again")], runner, deadline,
"restarted DB-enabled /health and OpenAPI")
if json.loads(repeated_result.stdout).get("db_enabled") is not True:
raise RuntimeError("persistent restart health payload has the wrong shape")
user = inspect_value(image, "{{.Config.User}}", runner)
if user not in {"10001", "10001:10001"}: raise RuntimeError("image default user must be 10001")
result = {"plain_health": "ok", "db_health": "ok", "db_ping": "ok", "alembic_head": head}
result = {"architecture": architecture, "plain_health": "ok", "db_health": "ok",
"db_readiness": "ok", "db_ping": "ok", "alembic_head": head,
"repeated_migration": "ok", "persistent_restart": "ok", "sigterm": "graceful"}
except BaseException as error: failure = error
try: _cleanup(names, network, volume, runner, 15)
except BaseException as error:
@@ -304,6 +359,9 @@ def publish_image(*, archive: Path, checksum: Path, image_id: Path, local_image:
actual_id = inspect_image_id(local_image, runner)
if actual_id != expected_id: raise ValueError("loaded image ID does not match tested image ID")
if inspect_revision(local_image, runner) != commit: raise ValueError("loaded image revision does not match tagged source commit")
version = validate_release_event(event_name, ref, commit)
if inspect_version(local_image, runner) != version:
raise ValueError("loaded image version label does not match release tag")
if not actor or not token: raise ValueError("registry credentials are missing")
for tag in tags: runner.run(["docker", "tag", local_image, tag])
runner.run(["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"], input_text=token)
@@ -320,7 +378,7 @@ def publish_image(*, archive: Path, checksum: Path, image_id: Path, local_image:
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(); commands = parser.add_subparsers(dest="command", required=True)
event = commands.add_parser("validate-event"); event.add_argument("--event", required=True); event.add_argument("--ref", required=True); event.add_argument("--commit", required=True)
event = commands.add_parser("validate-event"); event.add_argument("--event", required=True); event.add_argument("--ref", required=True); event.add_argument("--commit", required=True); event.add_argument("--project", type=Path)
checkout = commands.add_parser("validate-checkout"); checkout.add_argument("--actual", required=True); checkout.add_argument("--expected", required=True)
smoke = commands.add_parser("smoke"); smoke.add_argument("--image", required=True); smoke.add_argument("--inventory", type=Path, required=True); smoke.add_argument("--timeout", type=int, default=120)
scan = commands.add_parser("scan"); scan.add_argument("--image", required=True); scan.add_argument("--archive", type=Path, required=True)
@@ -334,7 +392,9 @@ def _parser() -> argparse.ArgumentParser:
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv); runner = SubprocessRunner()
if args.command == "validate-event": validate_release_event(args.event, args.ref, args.commit)
if args.command == "validate-event":
expected = project_release_version(args.project) if args.project else None
validate_release_event(args.event, args.ref, args.commit, expected)
elif args.command == "validate-checkout": validate_checkout_identity(args.actual, args.expected)
elif args.command == "smoke":
inventory = tuple(line for line in args.inventory.read_text().splitlines() if line)