feat(release): add verified image publication pipeline
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import image_release
|
||||
|
||||
COMMIT = "a" * 40
|
||||
IMAGE_ID = "sha256:" + "b" * 64
|
||||
DIGEST = "sha256:" + "c" * 64
|
||||
LOCAL = f"portfolio-hub-ci:{COMMIT}"
|
||||
|
||||
class RecordingRunner:
|
||||
def __init__(self, fail_on=None):
|
||||
self.commands = []; self.fail_on = fail_on; self.push_count = 0
|
||||
def run(self, command, *, input_text=None, timeout=None):
|
||||
command = list(command); self.commands.append((command, input_text, timeout))
|
||||
if self.fail_on and command[:len(self.fail_on)] == list(self.fail_on):
|
||||
raise subprocess.TimeoutExpired(command, timeout or 0)
|
||||
if command[:2] == ["docker", "load"]: return image_release.Result(f"Loaded image: {LOCAL}\n")
|
||||
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command: return image_release.Result(IMAGE_ID + "\n")
|
||||
if command[:3] == ["docker", "image", "inspect"] and "{{.Config.User}}" in command: return image_release.Result("10001:10001\n")
|
||||
if command[:3] == ["docker", "image", "inspect"]: return image_release.Result(COMMIT + "\n")
|
||||
if command[:2] == ["docker", "push"]: self.push_count += 1; return image_release.Result(f"digest: {DIGEST} size: 1\n")
|
||||
if command[:2] == ["docker", "create"]: return image_release.Result("created-container\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "urllib.request" in command[-1]:
|
||||
enabled = "TARGET:8000" not in command[-1] and "plain" not in command[-1]
|
||||
return image_release.Result('{"status":"ok","db_enabled":' + str(enabled).lower() + '}')
|
||||
if command[:3] == ["docker", "run", "--rm"] and "alembic_version" in command[-1]: return image_release.Result("20260715_0028\n")
|
||||
return image_release.Result()
|
||||
|
||||
def artifacts(tmp_path: Path):
|
||||
archive = tmp_path / "portfolio-hub-image.tar"; archive.write_bytes(b"tested")
|
||||
checksum = tmp_path / "portfolio-hub-image.tar.sha256"; image_release.write_checksum(archive, checksum)
|
||||
image_id = tmp_path / "portfolio-hub-image.id"; image_id.write_text(IMAGE_ID + "\n")
|
||||
return archive, checksum, image_id
|
||||
|
||||
def publish(tmp_path, runner, **extra):
|
||||
archive, checksum, image_id = artifacts(tmp_path)
|
||||
values = dict(archive=archive, checksum=checksum, image_id=image_id, local_image=LOCAL,
|
||||
commit=COMMIT, event_name="push", ref="refs/tags/v1.2.3", actor="actor",
|
||||
token="synthetic", runner=runner); values.update(extra)
|
||||
return image_release.publish_image(**values)
|
||||
|
||||
def test_strict_tag_and_only_two_non_latest_selectors():
|
||||
assert image_release.release_tags("push", "refs/tags/v1.2.3", COMMIT) == (
|
||||
f"{image_release.IMAGE}:{COMMIT[:12]}", f"{image_release.IMAGE}:v1.2.3")
|
||||
|
||||
@pytest.mark.parametrize("event,ref", [("pull_request","refs/tags/v1.2.3"),("push","refs/heads/main"),("push","refs/tags/v1.2"),("workflow_dispatch","refs/tags/v1.2.3")])
|
||||
def test_non_strict_event_rejected(event, ref):
|
||||
with pytest.raises(ValueError, match="strict vX.Y.Z"): image_release.release_tags(event, ref, COMMIT)
|
||||
|
||||
def test_checkout_identity_is_exact():
|
||||
assert image_release.validate_checkout_identity(COMMIT, COMMIT) == COMMIT
|
||||
with pytest.raises(ValueError): image_release.validate_checkout_identity("d"*40, COMMIT)
|
||||
|
||||
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
|
||||
runner = RecordingRunner(); image_release.smoke_installed_image("candidate", ("001.py",), runner, 17)
|
||||
runs = [x[0] for x in runner.commands if x[0][:2] == ["docker","run"]]
|
||||
assert len(runs) == 3 and all("--read-only" in x and "none" in x for x in runs)
|
||||
assert "migration_inventory" in runs[-1][-1] and "001.py" in runs[-1][-1]
|
||||
assert len([x for x in runner.commands if x[0][:3] == ["docker","rm","--force"]]) == 3
|
||||
|
||||
def _assert_exact_startup_cleanup(commands: list[list[str]]) -> None:
|
||||
attempted_names = {
|
||||
command[command.index("--name") + 1]
|
||||
for command in commands
|
||||
if command[:2] == ["docker", "run"] and "--name" in command
|
||||
}
|
||||
removed_names = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "rm", "--force"]
|
||||
}
|
||||
created_networks = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "network", "create"]
|
||||
}
|
||||
removed_networks = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "network", "rm"]
|
||||
}
|
||||
created_volumes = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "volume", "create"]
|
||||
}
|
||||
removed_volumes = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:4] == ["docker", "volume", "rm", "--force"]
|
||||
}
|
||||
assert attempted_names
|
||||
assert removed_names == attempted_names
|
||||
assert removed_networks == created_networks
|
||||
assert removed_volumes == created_volumes
|
||||
|
||||
|
||||
def test_wrong_shaped_health_cleans_exact_attempted_resources():
|
||||
class WrongShapeRunner(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
if list(command)[:3] == ["docker", "run", "--rm"] and "urllib.request" in list(command)[-1]:
|
||||
self.commands.append((list(command), kwargs.get("input_text"), kwargs.get("timeout")))
|
||||
return image_release.Result("[]")
|
||||
return super().run(command, **kwargs)
|
||||
runner = WrongShapeRunner()
|
||||
with pytest.raises(RuntimeError, match="startup smoke"):
|
||||
image_release.startup_smoke("candidate", runner, 17)
|
||||
commands = [x[0] for x in runner.commands]
|
||||
_assert_exact_startup_cleanup(commands)
|
||||
|
||||
|
||||
def test_timeout_cleans_exact_attempted_resources():
|
||||
runner = RecordingRunner(fail_on=("docker","run","-d"))
|
||||
with pytest.raises(RuntimeError): image_release.startup_smoke("candidate", runner, 17)
|
||||
commands = [x[0] for x in runner.commands]
|
||||
_assert_exact_startup_cleanup(commands)
|
||||
|
||||
|
||||
def test_scan_create_canary_failure_removes_exact_created_container(tmp_path):
|
||||
class CreateCanaryRunner(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
command = list(command)
|
||||
if command[:2] == ["docker", "create"]:
|
||||
self.commands.append((command, kwargs.get("input_text"), kwargs.get("timeout")))
|
||||
return image_release.Result(
|
||||
"created-container\n",
|
||||
image_release.CANARY.decode(),
|
||||
)
|
||||
return super().run(command, **kwargs)
|
||||
|
||||
archive = tmp_path / "portfolio-hub-image.tar"
|
||||
archive.write_bytes(b"unused")
|
||||
runner = CreateCanaryRunner()
|
||||
with pytest.raises(ValueError, match="canary"):
|
||||
image_release.scan_image("candidate", archive, runner, 17)
|
||||
removals = [
|
||||
command
|
||||
for command, _input, _timeout in runner.commands
|
||||
if command[:3] == ["docker", "rm", "--force"]
|
||||
]
|
||||
assert removals == [["docker", "rm", "--force", "created-container"]]
|
||||
|
||||
|
||||
def test_corrupt_archive_stops_before_registry_write(tmp_path):
|
||||
archive, checksum, image_id = artifacts(tmp_path); archive.write_bytes(b"corrupt"); runner = RecordingRunner()
|
||||
with pytest.raises(ValueError, match="checksum mismatch"):
|
||||
image_release.publish_image(archive=archive, checksum=checksum, image_id=image_id, local_image=LOCAL,
|
||||
commit=COMMIT,event_name="push",ref="refs/tags/v1.2.3",actor="a",token="t",runner=runner)
|
||||
assert runner.commands == []
|
||||
|
||||
def test_publish_loads_without_rebuild_and_records_digest(tmp_path):
|
||||
runner = RecordingRunner(); output = tmp_path/"output"; assert publish(tmp_path, runner, output_path=output) == DIGEST
|
||||
commands = [x[0][:2] for x in runner.commands]
|
||||
assert ["docker","build"] not in commands and commands.count(["docker","push"]) == 2
|
||||
assert COMMIT in output.read_text() and DIGEST in output.read_text() and ":latest" not in output.read_text()
|
||||
|
||||
def test_post_push_bad_digest_emits_no_success_evidence(tmp_path):
|
||||
class Bad(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
result = super().run(command, **kwargs)
|
||||
return image_release.Result("pushed without digest") if list(command)[:2] == ["docker","push"] else result
|
||||
output=tmp_path/"output"
|
||||
with pytest.raises(ValueError, match="valid digest"): publish(tmp_path, Bad(), output_path=output)
|
||||
assert not output.exists()
|
||||
|
||||
@pytest.mark.parametrize("kind", ["payload","symlink","pax"])
|
||||
def test_canary_scan_covers_archive_payload_and_metadata(kind):
|
||||
data=io.BytesIO()
|
||||
with tarfile.open(fileobj=data, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
member=tarfile.TarInfo("item")
|
||||
if kind=="payload": payload=image_release.CANARY; member.size=len(payload); archive.addfile(member,io.BytesIO(payload))
|
||||
elif kind=="symlink": member.type=tarfile.SYMTYPE; member.linkname=image_release.CANARY.decode(); archive.addfile(member)
|
||||
else: member.pax_headers={"key":image_release.CANARY.decode()}; archive.addfile(member,io.BytesIO())
|
||||
with pytest.raises(ValueError, match="canary"): image_release._scan_tar(data.getvalue(), True)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
PUBLISH = REPO / ".github/workflows/publish-image.yml"
|
||||
VERIFY = REPO / ".github/workflows/verify.yml"
|
||||
|
||||
def test_lock_exists_and_is_fresh():
|
||||
assert (REPO/"uv.lock").is_file()
|
||||
subprocess.run(["uv","lock","--check"],cwd=REPO,check=True,capture_output=True,text=True)
|
||||
|
||||
def test_branch_verification_has_no_registry_write_authority_or_path():
|
||||
text=VERIFY.read_text(); assert "branches: [main]" in text and "pull_request:" in text
|
||||
for forbidden in ("packages: write","docker login","docker push","REGISTRY_TOKEN","image_release.py publish"):
|
||||
assert forbidden not in text
|
||||
assert "uv sync --extra dev --locked" in text and "uv run pytest -q" in text
|
||||
|
||||
def test_tag_publication_is_strict_single_build_handoff_without_latest():
|
||||
text=PUBLISH.read_text(); verify,publish=text.split("\n publish:",1)
|
||||
assert 'tags:\n - "v*.*.*"' in text and "validate-event" in text and "validate-checkout" in text
|
||||
assert "ref: ${{ github.sha }}" in text and text.count("docker build ")==1
|
||||
assert "docker build" not in publish and "packages: write" not in verify and "packages: write" in publish
|
||||
assert "tested-image/scripts/image_release.py publish" in publish and ":latest" not in text
|
||||
for name in ("portfolio-hub-image.tar","portfolio-hub-image.tar.sha256","portfolio-hub-image.id","migration-inventory.txt"):
|
||||
assert name in text
|
||||
|
||||
def test_dockerfile_python311_locked_non_root_explicit_uvicorn_and_resources():
|
||||
text=(REPO/"Dockerfile").read_text()
|
||||
assert text.count("FROM python:3.11-slim")==2 and "uv sync --locked --no-dev --no-editable" in text
|
||||
assert "USER 10001:10001" in text and 'CMD ["uvicorn", "app.main:app"' in text
|
||||
assert "COPY app/ ./app/" in text and "COPY ." not in text
|
||||
assert "org.opencontainers.image.source" in text and "org.opencontainers.image.revision" in text
|
||||
|
||||
def test_dockerignore_is_deny_by_default_required_inputs_only():
|
||||
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
|
||||
assert [x for x in lines if x.startswith("!")] == ["!pyproject.toml","!uv.lock","!README.md","!app/","!app/**"]
|
||||
|
||||
def test_source_and_wheel_migration_inventory_are_exact_and_non_empty(tmp_path):
|
||||
source=sorted(x.name for x in (REPO/"app/db/alembic/versions").glob("*.py") if x.name!="__init__.py")
|
||||
assert source
|
||||
subprocess.run(["uv","build","--wheel","--out-dir",str(tmp_path)],cwd=REPO,check=True,capture_output=True,text=True)
|
||||
wheel=next(tmp_path.glob("*.whl"))
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
installed=sorted(Path(x).name for x in archive.namelist() if "/db/alembic/versions/" in x and x.endswith(".py") and not x.endswith("/__init__.py"))
|
||||
assert installed == source
|
||||
|
||||
def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
text = (REPO / "docs/container-operations.md").read_text()
|
||||
for value in (
|
||||
"@sha256:<64-lowercase-hex-digest>",
|
||||
"pfhub migrate-db",
|
||||
"Publication is not deployment",
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
):
|
||||
assert value in text
|
||||
for forbidden in (
|
||||
"docker run -d",
|
||||
"docker compose up",
|
||||
"kubectl apply",
|
||||
"helm install",
|
||||
"systemctl restart",
|
||||
):
|
||||
assert forbidden not in text
|
||||
@@ -203,21 +203,21 @@ def test_same_user_can_create_two_portfolios_for_same_asset_id() -> None:
|
||||
|
||||
def test_migration_rev_a_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_a_files = [f for f in os.listdir(migrations_dir) if "0017" in f and "assets" in f]
|
||||
assert len(rev_a_files) == 1, f"rev-A migration (0017 assets) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_b_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b_files = [f for f in os.listdir(migrations_dir) if "0018" in f]
|
||||
assert len(rev_b_files) == 1, f"rev-B migration (0018) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_a_creates_assets_table() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_a = next(f for f in os.listdir(migrations_dir) if "0017" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_a)).read()
|
||||
assert "create_table" in content
|
||||
@@ -230,7 +230,7 @@ def test_migration_rev_a_creates_assets_table() -> None:
|
||||
|
||||
def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "drop_column" in content
|
||||
@@ -241,7 +241,7 @@ def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
|
||||
|
||||
def test_migration_rev_b_has_data_migration_insert() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "INSERT INTO assets" in content
|
||||
@@ -770,14 +770,14 @@ def test_portfolio_create_by_symbol() -> None:
|
||||
|
||||
def test_migration_assets_tag_ids_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
matches = [f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f]
|
||||
assert len(matches) == 1, f"assets_tag_ids migration (0023) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_assets_tag_ids_adds_column_and_gin_index() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev = next(f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f)
|
||||
content = open(os.path.join(migrations_dir, rev)).read()
|
||||
# AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets
|
||||
|
||||
@@ -486,7 +486,7 @@ def test_migration_ac3_portfolio_tag_ids_populated() -> None:
|
||||
|
||||
def test_migration_ac4_portfolio_tags_dropped() -> None:
|
||||
"""REQ-006 AC-4: 마이그레이션 후 portfolio_tags 테이블이 존재하지 않는다 (migration script 검증)."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "DROP TABLE" in content
|
||||
@@ -514,14 +514,14 @@ def test_migration_ac6_distinct_tag_count_matches() -> None:
|
||||
|
||||
def test_migration_0019_tags_table_exists() -> None:
|
||||
"""REQ-001 AC-1: tags 테이블 생성 마이그레이션 파일이 존재한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
files = [f for f in os.listdir(migrations_dir) if "0019" in f]
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_migration_0019_creates_tags_table() -> None:
|
||||
"""REQ-001 AC-1: revision 0019가 tags 테이블을 생성한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0019" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert '"tags"' in content
|
||||
@@ -534,7 +534,7 @@ def test_migration_0019_creates_tags_table() -> None:
|
||||
|
||||
def test_migration_0020_portfolios_tag_ids_column() -> None:
|
||||
"""REQ-002/003 AC-1: revision 0020이 portfolios.tag_ids 컬럼과 GIN 인덱스를 추가한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0020" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "tag_ids" in content
|
||||
@@ -544,14 +544,14 @@ def test_migration_0020_portfolios_tag_ids_column() -> None:
|
||||
|
||||
def test_migration_0021_data_migration_exists() -> None:
|
||||
"""REQ-006: revision 0021 데이터 마이그레이션 파일이 존재한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
files = [f for f in os.listdir(migrations_dir) if "0021" in f]
|
||||
assert len(files) == 1
|
||||
|
||||
|
||||
def test_migration_0021_has_admin_verification() -> None:
|
||||
"""REQ-006 AC-5: revision 0021이 admin ID 존재 검증 로직을 포함한다."""
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_file = next(f for f in os.listdir(migrations_dir) if "0021" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_file)).read()
|
||||
assert "admin_user_id" in content
|
||||
|
||||
Reference in New Issue
Block a user