495 lines
17 KiB
Python
495 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Deterministic verification and publication helpers for the release image."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Protocol, Sequence
|
|
|
|
IMAGE = "ghcr.io/fourblocks2020/trex-core"
|
|
CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP"
|
|
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
|
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
|
|
IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
DEFAULT_MIGRATION_DIR = (
|
|
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
|
)
|
|
MAX_ARCHIVE_DEPTH = 8
|
|
MAX_ARCHIVE_MEMBERS = 200_000
|
|
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
|
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
|
|
MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Result:
|
|
stdout: str = ""
|
|
stderr: str = ""
|
|
|
|
|
|
@dataclass
|
|
class _ScanBudget:
|
|
max_depth: int = MAX_ARCHIVE_DEPTH
|
|
max_members: int = MAX_ARCHIVE_MEMBERS
|
|
max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES
|
|
max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES
|
|
max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES
|
|
members: int = 0
|
|
extracted_bytes: int = 0
|
|
|
|
def check_depth(self, depth: int) -> None:
|
|
if depth > self.max_depth:
|
|
_scan_limit_exceeded()
|
|
|
|
def add_member(self, size: int) -> None:
|
|
self.members += 1
|
|
if self.members > self.max_members:
|
|
_scan_limit_exceeded()
|
|
if size < 0 or size > self.max_member_bytes:
|
|
_scan_limit_exceeded()
|
|
self.extracted_bytes += size
|
|
if self.extracted_bytes > self.max_extracted_bytes:
|
|
_scan_limit_exceeded()
|
|
|
|
|
|
def _scan_limit_exceeded() -> None:
|
|
raise ValueError("archive scan resource limit exceeded")
|
|
|
|
|
|
class Runner(Protocol):
|
|
def run(
|
|
self,
|
|
command: Sequence[str],
|
|
*,
|
|
input_text: str | None = None,
|
|
timeout: int | None = None,
|
|
) -> Result: ...
|
|
|
|
|
|
class SubprocessRunner:
|
|
def run(
|
|
self,
|
|
command: Sequence[str],
|
|
*,
|
|
input_text: str | None = None,
|
|
timeout: int | None = None,
|
|
) -> Result:
|
|
completed = subprocess.run(
|
|
command,
|
|
input=input_text,
|
|
text=True,
|
|
capture_output=True,
|
|
check=True,
|
|
timeout=timeout,
|
|
)
|
|
return Result(completed.stdout, completed.stderr)
|
|
|
|
|
|
def validate_commit(commit: str) -> str:
|
|
if not COMMIT_RE.fullmatch(commit):
|
|
raise ValueError("source commit must be 40 lowercase hexadecimal characters")
|
|
return commit
|
|
|
|
|
|
def release_tags(commit: str, ref: str) -> list[str]:
|
|
validate_commit(commit)
|
|
tags = [f"{IMAGE}:{commit[:12]}"]
|
|
if ref == "refs/heads/main":
|
|
return tags
|
|
prefix = "refs/tags/"
|
|
if ref.startswith(prefix) and ref.removeprefix(prefix).startswith("v"):
|
|
release = ref.removeprefix(prefix)
|
|
if not re.fullmatch(r"v[A-Za-z0-9._-]*", release):
|
|
raise ValueError("release ref contains unsupported tag characters")
|
|
tags.append(f"{IMAGE}:{release}")
|
|
return tags
|
|
raise ValueError("publication ref must be main or a v* tag")
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as source:
|
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def write_checksum(archive: Path, checksum: Path) -> None:
|
|
checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii")
|
|
|
|
|
|
def verify_checksum(archive: Path, checksum: Path) -> None:
|
|
if not archive.is_file() or not checksum.is_file():
|
|
raise ValueError("image archive or checksum evidence is missing")
|
|
fields = checksum.read_text(encoding="ascii").strip().split()
|
|
if len(fields) != 2 or fields[1] != archive.name or not re.fullmatch(r"[0-9a-f]{64}", fields[0]):
|
|
raise ValueError("checksum evidence has an invalid format")
|
|
if sha256_file(archive) != fields[0]:
|
|
raise ValueError("image archive checksum mismatch")
|
|
|
|
|
|
def validate_image_id(image_id: str) -> str:
|
|
if not IMAGE_ID_RE.fullmatch(image_id):
|
|
raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters")
|
|
return image_id
|
|
|
|
|
|
def inspect_image_id(image: str, runner: Runner) -> str:
|
|
result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])
|
|
return validate_image_id(result.stdout.strip())
|
|
|
|
|
|
def write_image_id(image: str, output: Path, runner: Runner) -> str:
|
|
image_id = inspect_image_id(image, runner)
|
|
output.write_text(f"{image_id}\n", encoding="ascii")
|
|
return image_id
|
|
|
|
|
|
def read_image_id(path: Path) -> str:
|
|
text = path.read_text(encoding="ascii")
|
|
if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text):
|
|
raise ValueError("tested image ID evidence has an invalid format")
|
|
return text.rstrip("\n")
|
|
|
|
|
|
def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str:
|
|
expected = read_image_id(expected_path)
|
|
actual = inspect_image_id(image, runner)
|
|
if actual != expected:
|
|
raise ValueError("loaded image ID does not match tested image ID")
|
|
return actual
|
|
|
|
|
|
SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
|
|
("python", "-c", "import trex_core"),
|
|
("trex-core-daemon", "--help"),
|
|
("trex-core", "--help"),
|
|
("trex-core-reset", "--help"),
|
|
)
|
|
|
|
|
|
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
|
|
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
|
|
if not names:
|
|
raise ValueError("canonical migration inventory is empty")
|
|
return names
|
|
|
|
|
|
def migration_inventory_code(expected: tuple[str, ...]) -> str:
|
|
return (
|
|
"from trex_core.adapters.persistence.migrate import migration_files;"
|
|
f"expected={expected!r};"
|
|
"actual=tuple(path.name for path in migration_files());"
|
|
"assert expected and actual == expected, 'installed migration inventory mismatch'"
|
|
)
|
|
|
|
|
|
def _ensure_result_clean(result: Result) -> None:
|
|
if CANARY in (result.stdout + result.stderr).encode():
|
|
raise ValueError("canary marker found on a scanned surface")
|
|
|
|
|
|
def smoke_image(
|
|
image: str,
|
|
runner: Runner,
|
|
timeout: int = 30,
|
|
migration_dir: Path = DEFAULT_MIGRATION_DIR,
|
|
) -> None:
|
|
expected_migrations = canonical_migration_names(migration_dir)
|
|
probes = (
|
|
*SMOKE_PROBES,
|
|
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
|
|
)
|
|
for index, probe in enumerate(probes):
|
|
name = f"trex-core-smoke-{os.getpid()}-{index}"
|
|
command = [
|
|
"docker",
|
|
"run",
|
|
"--name",
|
|
name,
|
|
"--network",
|
|
"none",
|
|
"--read-only",
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--security-opt",
|
|
"no-new-privileges",
|
|
"--pids-limit",
|
|
"64",
|
|
"--tmpfs",
|
|
"/tmp:rw,noexec,nosuid,size=16m",
|
|
"--entrypoint",
|
|
probe[0],
|
|
image,
|
|
*probe[1:],
|
|
]
|
|
failure: BaseException | None = None
|
|
try:
|
|
_ensure_result_clean(runner.run(command, timeout=timeout))
|
|
except BaseException as error:
|
|
failure = error
|
|
try:
|
|
_ensure_result_clean(
|
|
runner.run(["docker", "rm", "--force", name], timeout=timeout)
|
|
)
|
|
except BaseException as cleanup_error:
|
|
if failure is None:
|
|
raise RuntimeError("smoke container cleanup failed") from cleanup_error
|
|
if failure is not None:
|
|
raise RuntimeError(f"smoke probe {index + 1} failed") from failure
|
|
|
|
|
|
def _scan_stream(path: Path) -> None:
|
|
with path.open("rb") as stream:
|
|
overlap = b""
|
|
while chunk := stream.read(1024 * 1024):
|
|
data = overlap + chunk
|
|
if CANARY in data:
|
|
raise ValueError("canary marker found on a scanned surface")
|
|
overlap = data[-(len(CANARY) - 1):]
|
|
|
|
|
|
def _scan_metadata(member: tarfile.TarInfo) -> None:
|
|
values = [member.name, member.linkname, member.uname, member.gname]
|
|
for key, value in member.pax_headers.items():
|
|
values.extend((key, value))
|
|
if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values):
|
|
raise ValueError("canary marker found on a scanned surface")
|
|
|
|
|
|
def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None:
|
|
budget = budget or _ScanBudget()
|
|
if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes):
|
|
_scan_limit_exceeded()
|
|
_scan_stream(path)
|
|
try:
|
|
archive = tarfile.open(path, mode="r:*")
|
|
except (tarfile.TarError, OSError, EOFError):
|
|
return
|
|
with archive:
|
|
budget.check_depth(depth)
|
|
for member in archive:
|
|
budget.add_member(member.size if member.isfile() else 0)
|
|
_scan_metadata(member)
|
|
if not member.isfile():
|
|
continue
|
|
extracted = archive.extractfile(member)
|
|
if extracted is None:
|
|
continue
|
|
with tempfile.NamedTemporaryFile() as nested:
|
|
overlap = b""
|
|
copied = 0
|
|
while chunk := extracted.read(1024 * 1024):
|
|
copied += len(chunk)
|
|
if copied > member.size or copied > budget.max_member_bytes:
|
|
_scan_limit_exceeded()
|
|
data = overlap + chunk
|
|
if CANARY in data:
|
|
raise ValueError("canary marker found on a scanned surface")
|
|
nested.write(chunk)
|
|
overlap = data[-(len(CANARY) - 1):]
|
|
nested.flush()
|
|
_scan_tar(Path(nested.name), budget, depth + 1)
|
|
|
|
|
|
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None:
|
|
for command in (
|
|
["docker", "history", "--no-trunc", image],
|
|
["docker", "image", "inspect", image],
|
|
):
|
|
_ensure_result_clean(runner.run(command, timeout=timeout))
|
|
|
|
container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
|
|
container_id = container.stdout.strip()
|
|
if not container_id:
|
|
raise RuntimeError("docker create returned no container id")
|
|
try:
|
|
_ensure_result_clean(container)
|
|
with tempfile.TemporaryDirectory() as directory:
|
|
filesystem = Path(directory) / "filesystem.tar"
|
|
_ensure_result_clean(
|
|
runner.run(
|
|
["docker", "export", "--output", str(filesystem), container_id],
|
|
timeout=timeout,
|
|
)
|
|
)
|
|
_scan_tar(filesystem)
|
|
finally:
|
|
try:
|
|
_ensure_result_clean(
|
|
runner.run(["docker", "rm", "--force", container_id], timeout=timeout)
|
|
)
|
|
except BaseException as error:
|
|
raise RuntimeError("canary scan container cleanup failed") from error
|
|
_scan_tar(saved_archive)
|
|
|
|
|
|
def assert_clean_output(paths: Sequence[Path]) -> None:
|
|
for path in paths:
|
|
_scan_stream(path)
|
|
|
|
|
|
def _push_digest(output: str) -> str:
|
|
digests = set(DIGEST_RE.findall(output))
|
|
if len(digests) != 1:
|
|
raise ValueError("registry push did not return exactly one valid digest")
|
|
return digests.pop()
|
|
|
|
|
|
def _append(path: Path | None, text: str) -> None:
|
|
if path is None:
|
|
return
|
|
with path.open("a", encoding="utf-8") as destination:
|
|
destination.write(text)
|
|
|
|
|
|
def publish_image(
|
|
*,
|
|
archive: Path,
|
|
checksum: Path,
|
|
image_id: Path,
|
|
local_image: str,
|
|
commit: str,
|
|
ref: str,
|
|
actor: str,
|
|
token: str,
|
|
runner: Runner,
|
|
output_path: Path | None = None,
|
|
summary_path: Path | None = None,
|
|
) -> str:
|
|
tags = release_tags(commit, ref)
|
|
verify_checksum(archive, checksum)
|
|
expected_image_id = read_image_id(image_id)
|
|
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
|
load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()}
|
|
if f"Loaded image: {local_image}" not in load_lines:
|
|
raise ValueError("docker load did not report the expected local image")
|
|
actual_image_id = inspect_image_id(local_image, runner)
|
|
if actual_image_id != expected_image_id:
|
|
raise ValueError("loaded image ID does not match tested image ID")
|
|
revision = runner.run(
|
|
[
|
|
"docker",
|
|
"image",
|
|
"inspect",
|
|
"--format",
|
|
"{{ index .Config.Labels \"org.opencontainers.image.revision\" }}",
|
|
local_image,
|
|
]
|
|
).stdout.strip()
|
|
if revision != commit:
|
|
raise ValueError("loaded image revision does not match source commit")
|
|
|
|
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,
|
|
)
|
|
digests = []
|
|
for tag in tags:
|
|
result = runner.run(["docker", "push", tag])
|
|
digests.append(_push_digest(result.stdout + "\n" + result.stderr))
|
|
if len(set(digests)) != 1:
|
|
raise ValueError("published tags returned different registry digests")
|
|
|
|
digest = digests[0]
|
|
reference = f"{IMAGE}@{digest}"
|
|
_append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n")
|
|
_append(
|
|
summary_path,
|
|
"## Published trex-core image\n\n"
|
|
f"- Image: `{reference}`\n"
|
|
f"- Digest: `{digest}`\n"
|
|
f"- Source commit: `{commit}`\n",
|
|
)
|
|
return digest
|
|
|
|
|
|
def _parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser()
|
|
commands = parser.add_subparsers(dest="command", required=True)
|
|
|
|
smoke = commands.add_parser("smoke")
|
|
smoke.add_argument("--image", required=True)
|
|
smoke.add_argument("--timeout", type=int, default=30)
|
|
|
|
scan = commands.add_parser("scan")
|
|
scan.add_argument("--image", required=True)
|
|
scan.add_argument("--archive", type=Path, required=True)
|
|
|
|
checksum = commands.add_parser("checksum")
|
|
checksum.add_argument("--archive", type=Path, required=True)
|
|
checksum.add_argument("--output", type=Path, required=True)
|
|
|
|
record_id = commands.add_parser("record-image-id")
|
|
record_id.add_argument("--image", required=True)
|
|
record_id.add_argument("--output", type=Path, required=True)
|
|
|
|
verify_id = commands.add_parser("verify-image-id")
|
|
verify_id.add_argument("--image", required=True)
|
|
verify_id.add_argument("--expected", type=Path, required=True)
|
|
|
|
clean = commands.add_parser("assert-clean-output")
|
|
clean.add_argument("paths", nargs="+", type=Path)
|
|
|
|
publish = commands.add_parser("publish")
|
|
publish.add_argument("--archive", type=Path, required=True)
|
|
publish.add_argument("--checksum", type=Path, required=True)
|
|
publish.add_argument("--image-id", type=Path, required=True)
|
|
publish.add_argument("--local-image", required=True)
|
|
publish.add_argument("--commit", required=True)
|
|
publish.add_argument("--ref", required=True)
|
|
return parser
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
args = _parser().parse_args(argv)
|
|
runner = SubprocessRunner()
|
|
if args.command == "smoke":
|
|
smoke_image(args.image, runner, args.timeout)
|
|
elif args.command == "scan":
|
|
scan_image(args.image, args.archive, runner)
|
|
elif args.command == "checksum":
|
|
write_checksum(args.archive, args.output)
|
|
elif args.command == "record-image-id":
|
|
write_image_id(args.image, args.output, runner)
|
|
elif args.command == "verify-image-id":
|
|
verify_image_id(args.image, args.expected, runner)
|
|
elif args.command == "assert-clean-output":
|
|
assert_clean_output(args.paths)
|
|
elif args.command == "publish":
|
|
publish_image(
|
|
archive=args.archive,
|
|
checksum=args.checksum,
|
|
image_id=args.image_id,
|
|
local_image=args.local_image,
|
|
commit=args.commit,
|
|
ref=args.ref,
|
|
actor=os.environ.get("GITHUB_ACTOR", ""),
|
|
token=os.environ.get("REGISTRY_TOKEN", ""),
|
|
runner=runner,
|
|
output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None,
|
|
summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
|
|
print(f"image release failed: {error}", file=sys.stderr)
|
|
raise SystemExit(1) from error
|