feat(container): harden v0.1.1 publication
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user