feat(release): add verified image publication pipeline

This commit is contained in:
2026-08-15 14:52:01 +09:00
parent f7b33784ab
commit fd080496b3
47 changed files with 907 additions and 22 deletions
+23
View File
@@ -0,0 +1,23 @@
**
!pyproject.toml
!uv.lock
!README.md
!app/
!app/**
# Defense in depth; the allowlist above is authoritative.
.git
.github
.stagepilot
.vendor
.claude
.env
.env.*
**/.env
docs
scripts
tests
temp
**/__pycache__
*.pyc
*.egg-info
+101
View File
@@ -0,0 +1,101 @@
name: Publish verified PortfolioHub image
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
jobs:
verify:
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Reject every event except a strict version tag push
env:
RELEASE_EVENT: ${{ github.event_name }}
RELEASE_REF: ${{ github.ref }}
RELEASE_SHA: ${{ github.sha }}
run: |
python - <<'PY'
import os, re
assert os.environ["RELEASE_EVENT"] == "push"
assert re.fullmatch(r"refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)", os.environ["RELEASE_REF"])
assert re.fullmatch(r"[0-9a-f]{40}", os.environ["RELEASE_SHA"])
PY
- name: Check out exact tagged source commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 1
- run: python scripts/image_release.py validate-checkout --actual "$(git rev-parse HEAD)" --expected "${GITHUB_SHA}"
- run: python scripts/image_release.py validate-event --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}" --commit "${GITHUB_SHA}"
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install "uv==0.11.14"
- run: uv sync --extra dev --locked
- run: uv run pytest -q
- name: Honest scoped lint
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
- name: Record exact source migration inventory
run: uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
- name: Create excluded synthetic canary
run: printf '%s\n' 'PORTFOLIO_HUB_SECRET_CANARY_DO_NOT_SHIP' > .env.ci-canary
- name: Build exact source image once
run: docker build --provenance=false --build-arg "SOURCE_COMMIT=${GITHUB_SHA}" --tag "portfolio-hub-ci:${GITHUB_SHA}" .
- name: Smoke installed payload, health, migration and isolated PostgreSQL
run: python scripts/image_release.py smoke --image "portfolio-hub-ci:${GITHUB_SHA}" --inventory migration-inventory.txt --timeout 180
- name: Record tested image ID
run: python scripts/image_release.py record-image-id --image "portfolio-hub-ci:${GITHUB_SHA}" --output portfolio-hub-image.id
- name: Export tested image without rebuilding
run: docker save --output portfolio-hub-image.tar "portfolio-hub-ci:${GITHUB_SHA}"
- name: Scan filesystem, history, inspect, archive metadata and outputs
run: |
set +e
python scripts/image_release.py scan --image "portfolio-hub-ci:${GITHUB_SHA}" --archive portfolio-hub-image.tar >canary-scan.stdout 2>canary-scan.stderr
scan_status=$?
set -e
python scripts/image_release.py assert-clean-output canary-scan.stdout canary-scan.stderr
exit "${scan_status}"
- run: python scripts/image_release.py checksum --archive portfolio-hub-image.tar --output portfolio-hub-image.tar.sha256
- uses: actions/upload-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
retention-days: 7
path: |
portfolio-hub-image.tar
portfolio-hub-image.tar.sha256
portfolio-hub-image.id
migration-inventory.txt
scripts/image_release.py
publish:
needs: verify
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/download-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
path: tested-image
- name: Load same archive, tag and publish without rebuilding
id: publish
env:
GITHUB_ACTOR: ${{ github.actor }}
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python tested-image/scripts/image_release.py publish \
--archive tested-image/portfolio-hub-image.tar \
--checksum tested-image/portfolio-hub-image.tar.sha256 \
--image-id tested-image/portfolio-hub-image.id \
--local-image "portfolio-hub-ci:${GITHUB_SHA}" \
--commit "${GITHUB_SHA}" --event "${GITHUB_EVENT_NAME}" --ref "${GITHUB_REF}"
+27
View File
@@ -0,0 +1,27 @@
name: Verify container release contracts
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: python -m pip install "uv==0.11.14"
- run: uv sync --extra dev --locked
- run: uv run pytest -q
- name: Honest scoped lint for release additions
run: uv run ruff check --select F,I app/db/migrations.py scripts/image_release.py tests/release
- name: Validate container contracts without registry writes
run: uv run pytest -q tests/release
+26
View File
@@ -0,0 +1,26 @@
# syntax=docker/dockerfile:1
FROM ghcr.io/astral-sh/uv:0.11.14 AS uv
FROM python:3.11-slim AS build
ARG SOURCE_COMMIT
WORKDIR /opt/portfolio-hub
RUN test "${#SOURCE_COMMIT}" -eq 40 && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) false;; *) true;; esac \
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
COPY --from=uv /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock README.md ./
COPY app/ ./app/
RUN uv sync --locked --no-dev --no-editable
FROM python:3.11-slim AS runtime
ARG SOURCE_COMMIT
LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/portfolio-hub" \
org.opencontainers.image.revision="${SOURCE_COMMIT}"
RUN test "${#SOURCE_COMMIT}" -eq 40 && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) false;; *) true;; esac \
&& groupadd --gid 10001 portfoliohub \
&& useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin portfoliohub \
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
WORKDIR /opt/portfolio-hub
COPY --from=build /opt/portfolio-hub/.venv ./.venv
ENV PATH="/opt/portfolio-hub/.venv/bin:${PATH}" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
USER 10001:10001
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
View File
View File
+22 -9
View File
@@ -2,19 +2,33 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from importlib import resources
import psycopg
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
import psycopg
from app.core.config import Settings from app.core.config import Settings
def migration_root():
"""Return the packaged Alembic resource tree for source and wheel installs."""
return resources.files("app.db.alembic")
def migration_inventory() -> tuple[str, ...]:
"""Return the deterministic, non-empty migration revision inventory."""
versions = migration_root().joinpath("versions")
inventory = tuple(sorted(item.name for item in versions.iterdir() if item.name.endswith(".py") and item.name != "__init__.py"))
if not inventory:
raise RuntimeError("packaged Alembic migration inventory is empty")
return inventory
def get_alembic_config(settings: Settings) -> Config: def get_alembic_config(settings: Settings) -> Config:
project_root = Path(__file__).resolve().parents[2] root = migration_root()
config = Config(str(project_root / "alembic.ini")) config = Config(str(root.joinpath("alembic.ini")))
config.set_main_option("script_location", str(project_root / "migrations")) config.set_main_option("script_location", str(root))
config.set_main_option("sqlalchemy.url", settings.active_database_url.replace("%", "%%")) config.set_main_option("sqlalchemy.url", settings.active_database_url.replace("%", "%%"))
# migrations/env.py must use this explicit settings object instead of the # migrations/env.py must use this explicit settings object instead of the
# process-global cached Settings. This keeps programmatic callers (notably # process-global cached Settings. This keeps programmatic callers (notably
@@ -73,10 +87,9 @@ def _connect(settings: Settings) -> psycopg.Connection:
def _has_alembic_version_table(settings: Settings) -> bool: def _has_alembic_version_table(settings: Settings) -> bool:
with _connect(settings) as conn: with _connect(settings) as conn, conn.cursor() as cur:
with conn.cursor() as cur: cur.execute("SELECT to_regclass('public.alembic_version')")
cur.execute("SELECT to_regclass('public.alembic_version')") return cur.fetchone()[0] is not None
return cur.fetchone()[0] is not None
def _has_legacy_portfolio_schema(settings: Settings) -> bool: def _has_legacy_portfolio_schema(settings: Settings) -> bool:
+56
View File
@@ -0,0 +1,56 @@
# PortfolioHub container publication and operator handoff
## Publication is not deployment
Only a requester-authorized strict `vX.Y.Z` tag push may publish. The workflow builds once and gives the same tested image exactly two selectors:
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
- `ghcr.io/fourblocks2020/portfolio-hub:vX.Y.Z`
It never publishes `latest`. The registry digest is the immutable artifact identity:
```text
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
```
Publication does **not** deploy, place, replace, start, stop, or restart a container in any environment. This repository change adds no production Compose, Kubernetes, Helm, systemd, Dockge, or other deployment configuration. An authorized deployment system and operator must consume the digest separately.
## Operator handoff contract
A successful publication hands the operator or deployment system only these verified inputs:
- exact `vX.Y.Z` source tag;
- full source commit and OCI `org.opencontainers.image.revision`;
- immutable registry digest;
- matching commit-prefix and version selectors;
- successful tested-image workflow evidence.
Before any separately authorized use, verify that the digest and OCI revision identify the intended source. The publication workflow itself does not claim runtime deployment or production readiness.
## Required pre-start migration contract
Database migration is a separate, explicit pre-start operation using the exact image digest and runtime-injected production settings:
1. execute `pfhub migrate-db` as a one-shot operation;
2. stop on nonzero migration result;
3. start the application only after migration succeeds;
4. evaluate HTTP liveness and database readiness separately.
The image's default application command does not run migrations. Do not run migration implicitly from every application replica. Production migration execution remains outside this implementation and requires separate authorization.
## Runtime security inputs
The image contract provides:
- default UID/GID `10001:10001`;
- explicit command `uvicorn app.main:app --host 0.0.0.0 --port 8000`;
- no application-required writable data path;
- migration resources installed inside the Python package.
A deployment system should enforce a read-only root filesystem, dropped capabilities, no-new-privileges, and only a bounded temporary filesystem when needed. PostgreSQL credentials, token configuration, and `CREDENTIAL_ENCRYPTION_KEY` are runtime secrets; they must never be baked into the image or publication artifact. Secret injection and writable-mount configuration belong to the separately authorized deployment environment, not this repository change.
## Image and database rollback boundary
Rollback identity is a previously verified immutable image digest whose OCI revision is known. This document defines that identity boundary only; it does not add or execute rollback automation.
Image rollback is **not** database rollback. Selecting older application bytes does not reverse Alembic migrations, schema or data changes, tokens, or external side effects. Database restore, downgrade, or forward-fix requires a separately reviewed and authorized operational procedure.
+5
View File
@@ -28,6 +28,7 @@ dev = [
"httpx>=0.27.0,<1.0.0", "httpx>=0.27.0,<1.0.0",
"pre-commit>=3.8.0,<4.0.0", "pre-commit>=3.8.0,<4.0.0",
"pytest>=8.3.0,<9.0.0", "pytest>=8.3.0,<9.0.0",
"ruff>=0.12.0,<1.0.0",
] ]
[tool.pytest.ini_options] [tool.pytest.ini_options]
@@ -40,3 +41,7 @@ package-dir = {"" = "."}
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["app*"] include = ["app*"]
[tool.setuptools.package-data]
"app.db.alembic" = ["alembic.ini", "script.py.mako"]
"app.db.alembic.versions" = ["*.py"]
+358
View File
@@ -0,0 +1,358 @@
#!/usr/bin/env python3
"""Fail-closed PortfolioHub image verification and publication helpers."""
from __future__ import annotations
import argparse
import hashlib
import io
import json
import os
import re
import subprocess
import sys
import tarfile
import tempfile
import time
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
IMAGE = "ghcr.io/fourblocks2020/portfolio-hub"
CANARY = b"PORTFOLIO_HUB_SECRET_CANARY_DO_NOT_SHIP"
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|$)")
@dataclass(frozen=True)
class Result:
stdout: str = ""
stderr: str = ""
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:
result = subprocess.run(command, input=input_text, text=True, capture_output=True,
check=True, timeout=timeout)
return Result(result.stdout, result.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 validate_checkout_identity(actual: str, expected: str) -> str:
validate_commit(actual); validate_commit(expected)
if actual != expected:
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:
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/")
def release_tags(event_name: str, ref: str, commit: str) -> tuple[str, str]:
version = validate_release_event(event_name, ref, commit)
return f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{version}"
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, output: Path) -> str:
checksum = sha256_file(archive)
output.write_text(f"{checksum} {archive.name}\n", encoding="ascii")
return checksum
def verify_checksum(archive: Path, checksum_path: Path) -> str:
if not archive.is_file() or not checksum_path.is_file():
raise ValueError("image archive or checksum evidence is missing")
fields = checksum_path.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")
return fields[0]
def _clean(result: Result) -> Result:
if CANARY in (result.stdout + result.stderr).encode():
raise ValueError("canary marker found on a scanned surface")
return 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_image_id(image: str, runner: Runner) -> str:
value = inspect_value(image, "{{.Id}}", runner)
if not IMAGE_ID_RE.fullmatch(value):
raise ValueError("image ID has an invalid format")
return value
def inspect_revision(image: str, runner: Runner) -> str:
return inspect_value(image, '{{ index .Config.Labels "org.opencontainers.image.revision" }}', 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")
return image_id
def read_image_id(path: Path) -> str:
value = path.read_text(encoding="ascii").strip()
if not IMAGE_ID_RE.fullmatch(value):
raise ValueError("tested image ID evidence has an invalid format")
return value
def _error_text(error: subprocess.CalledProcessError) -> str:
return "\n".join(str(value or "") for value in (error.stdout, error.stderr)).lower()
def _remove(command: list[str], absent: tuple[str, ...], runner: Runner, timeout: int) -> None:
try:
_clean(runner.run(command, timeout=timeout))
except subprocess.CalledProcessError as error:
if not any(marker in _error_text(error) for marker in absent):
raise
def _cleanup(names: list[str], network: str | None, volume: str | None,
runner: Runner, timeout: int) -> None:
errors: list[BaseException] = []
for name in reversed(names):
try:
_remove(["docker", "rm", "--force", name], ("no such container",), runner, timeout)
except BaseException as error: errors.append(error)
if network:
try:
_remove(["docker", "network", "rm", network], ("not found", "no such network"), runner, timeout)
except BaseException as error: errors.append(error)
if volume:
try:
_remove(["docker", "volume", "rm", "--force", volume], ("no such volume", "not found"), runner, timeout)
except BaseException as error: errors.append(error)
if errors:
raise RuntimeError("image smoke cleanup failed") from errors[0]
def smoke_installed_image(image: str, expected_inventory: tuple[str, ...], runner: Runner,
timeout: int = 30) -> None:
if not expected_inventory:
raise ValueError("expected migration inventory must be non-empty")
inventory_code = (
"import json; from app.db.migrations import migration_inventory; "
f"expected={json.dumps(list(expected_inventory))}; actual=list(migration_inventory()); "
"assert actual == expected, (actual, expected)"
)
probes = (("python", "-I", "-c", "import app"), ("pfhub", "--help"),
("python", "-I", "-c", inventory_code))
for index, probe in enumerate(probes):
name = f"portfolio-hub-install-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: _clean(runner.run(command, timeout=timeout))
except BaseException as error: failure = error
try: _cleanup([name], None, None, runner, timeout)
except BaseException as error:
if failure is None: failure = error
if failure: raise RuntimeError(f"install smoke probe {index + 1} failed") from failure
def _wait(command: list[str], runner: Runner, deadline: float, description: str) -> Result:
last: BaseException | None = None
while time.monotonic() < deadline:
try: return _clean(runner.run(command, timeout=5))
except (OSError, subprocess.SubprocessError) as error:
last = error; time.sleep(1)
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."""
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}"
network = f"portfolio-hub-smoke-{suffix}"; volume = f"portfolio-hub-db-{suffix}"
names: list[str] = []
deadline = time.monotonic() + timeout
failure: BaseException | None = None; result: dict = {}
db_env = ["-e", "PORTFOLIOHUB_DB_ENABLED=true", "-e", "TIMESCALEDB_HOST=postgres",
"-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))"
try:
runner.run(["docker", "network", "create", "--internal", network], timeout=10)
runner.run(["docker", "volume", "create", volume], timeout=10)
names.append(plain)
runner.run(["docker", "run", "-d", "--name", plain, "--network", network,
"--network-alias", "plain", "--read-only", "--cap-drop", "ALL",
"--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
"-e", "PORTFOLIOHUB_DB_ENABLED=false", image], timeout=20)
plain_result = _wait(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
image, "-c", http_probe.replace("TARGET", "plain")], runner, deadline, "DB-disabled /health")
plain_payload = json.loads(plain_result.stdout)
if not isinstance(plain_payload, dict) or plain_payload.get("db_enabled") is not False:
raise RuntimeError("DB-disabled health payload has the wrong shape")
names.append(db)
runner.run(["docker", "run", "-d", "--name", db, "--network", network,
"--network-alias", "postgres", "-e", "POSTGRES_DB=smoke", "-e", "POSTGRES_USER=smoke",
"-e", "POSTGRES_PASSWORD=synthetic-db-only", "--mount", f"type=volume,src={volume},dst=/var/lib/postgresql/data",
"postgres:16-alpine"], timeout=30)
_wait(["docker", "exec", db, "pg_isready", "-U", "smoke", "-d", "smoke"], runner, deadline, "PostgreSQL")
names.append(migrate)
runner.run(["docker", "run", "--name", migrate, "--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")
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",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=16m", *db_env, image], timeout=20)
app_result = _wait(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
image, "-c", http_probe.replace("TARGET", "app")], runner, deadline, "DB-enabled /health")
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")
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}
except BaseException as error: failure = error
try: _cleanup(names, network, volume, runner, 15)
except BaseException as error:
if failure is None: failure = error
if failure:
if isinstance(failure, (KeyboardInterrupt, SystemExit)): raise failure
raise RuntimeError("isolated PortfolioHub startup smoke failed") from failure
return result
def _scan_bytes(data: bytes) -> None:
if CANARY in data: raise ValueError("canary marker found on a scanned surface")
def _scan_tar(data: bytes, nested: bool) -> None:
_scan_bytes(data)
try:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as archive:
for member in archive:
metadata = [member.name, member.linkname, member.uname, member.gname]
for key, value in member.pax_headers.items(): metadata.extend((key, value))
_scan_bytes("\n".join(metadata).encode(errors="replace"))
if member.isfile() and (source := archive.extractfile(member)):
payload = source.read(); _scan_bytes(payload)
if nested: _scan_tar(payload, False)
except (tarfile.TarError, OSError, EOFError): pass
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 90) -> None:
for command in (["docker", "history", "--no-trunc", image], ["docker", "image", "inspect", image]):
_clean(runner.run(command, timeout=timeout))
# Docker can create the container successfully and still return output that
# fails the canary scan. Retain its identity before scanning the complete
# result so every post-create failure is cleanup-protected.
created = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
container = created.stdout.strip().splitlines()[0] if created.stdout.strip() else ""
if not container:
_clean(created)
raise RuntimeError("docker create returned no container ID")
failure: BaseException | None = None
try:
_clean(created)
with tempfile.TemporaryDirectory() as directory:
filesystem = Path(directory) / "filesystem.tar"
_clean(runner.run(["docker", "export", "--output", str(filesystem), container], timeout=timeout))
_scan_tar(filesystem.read_bytes(), False)
except BaseException as error:
failure = error
try:
_cleanup([container], None, None, runner, timeout)
except BaseException as error:
if failure is None:
failure = error
if failure:
raise failure
_scan_tar(saved_archive.read_bytes(), True)
def assert_clean_output(paths: Sequence[Path]) -> None:
for path in paths: _scan_bytes(path.read_bytes())
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:
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, event_name: str, ref: str, actor: str, token: str,
runner: Runner, output_path: Path | None = None,
summary_path: Path | None = None) -> str:
tags = release_tags(event_name, ref, commit)
archive_sha = verify_checksum(archive, checksum); expected_id = read_image_id(image_id)
loaded = _clean(runner.run(["docker", "load", "--input", str(archive)]))
if f"Loaded image: {local_image}" not in (loaded.stdout + loaded.stderr).splitlines():
raise ValueError("docker load did not report the expected 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")
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:
pushed = _clean(runner.run(["docker", "push", tag])); digests.append(_push_digest(pushed.stdout + "\n" + pushed.stderr))
if len(set(digests)) != 1: raise ValueError("published selectors returned different registry digests")
digest = digests[0]; immutable = f"{IMAGE}@{digest}"
evidence = (f"image={immutable}\ndigest={digest}\nsource_commit={commit}\nimage_id={actual_id}\n"
f"archive_sha256={archive_sha}\nsource_selector={tags[0]}\nversion_selector={tags[1]}\n")
_append(output_path, evidence)
_append(summary_path, "## Published PortfolioHub image\n\n" + "".join(f"- {line}\n" for line in evidence.splitlines()))
return digest
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)
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)
checksum = commands.add_parser("checksum"); checksum.add_argument("--archive", type=Path, required=True); checksum.add_argument("--output", type=Path, required=True)
record = commands.add_parser("record-image-id"); record.add_argument("--image", required=True); record.add_argument("--output", type=Path, required=True)
clean = commands.add_parser("assert-clean-output"); clean.add_argument("paths", nargs="+", type=Path)
publish = commands.add_parser("publish")
for option in ("archive", "checksum", "image-id"): publish.add_argument(f"--{option}", type=Path, required=True)
for option in ("local-image", "commit", "event", "ref"): publish.add_argument(f"--{option}", required=True)
return parser
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)
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)
smoke_installed_image(args.image, inventory, runner, args.timeout); startup_smoke(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 == "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, event_name=args.event,
ref=args.ref, actor=os.environ.get("GITHUB_ACTOR", ""),
token=os.environ.get("REGISTRY_TOKEN", ""), runner=runner,
output_path=Path(value) if (value := os.environ.get("GITHUB_OUTPUT")) else None,
summary_path=Path(value) if (value := 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
+181
View File
@@ -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)
+68
View File
@@ -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
+7 -7
View File
@@ -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: def test_migration_rev_a_file_exists() -> None:
import os 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] 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}" 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: def test_migration_rev_b_file_exists() -> None:
import os 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] 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}" 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: def test_migration_rev_a_creates_assets_table() -> None:
import os 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) 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() content = open(os.path.join(migrations_dir, rev_a)).read()
assert "create_table" in content 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: def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
import os 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) 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() content = open(os.path.join(migrations_dir, rev_b)).read()
assert "drop_column" in content 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: def test_migration_rev_b_has_data_migration_insert() -> None:
import os 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) 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() content = open(os.path.join(migrations_dir, rev_b)).read()
assert "INSERT INTO assets" in content 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: def test_migration_assets_tag_ids_file_exists() -> None:
import os 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] 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}" 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: def test_migration_assets_tag_ids_adds_column_and_gin_index() -> None:
import os 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) 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() content = open(os.path.join(migrations_dir, rev)).read()
# AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets # AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets
+6 -6
View File
@@ -486,7 +486,7 @@ def test_migration_ac3_portfolio_tag_ids_populated() -> None:
def test_migration_ac4_portfolio_tags_dropped() -> None: def test_migration_ac4_portfolio_tags_dropped() -> None:
"""REQ-006 AC-4: 마이그레이션 후 portfolio_tags 테이블이 존재하지 않는다 (migration script 검증).""" """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) 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() content = open(os.path.join(migrations_dir, rev_file)).read()
assert "DROP TABLE" in content 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: def test_migration_0019_tags_table_exists() -> None:
"""REQ-001 AC-1: tags 테이블 생성 마이그레이션 파일이 존재한다.""" """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] files = [f for f in os.listdir(migrations_dir) if "0019" in f]
assert len(files) == 1 assert len(files) == 1
def test_migration_0019_creates_tags_table() -> None: def test_migration_0019_creates_tags_table() -> None:
"""REQ-001 AC-1: revision 0019가 tags 테이블을 생성한다.""" """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) 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() content = open(os.path.join(migrations_dir, rev_file)).read()
assert '"tags"' in content 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: def test_migration_0020_portfolios_tag_ids_column() -> None:
"""REQ-002/003 AC-1: revision 0020이 portfolios.tag_ids 컬럼과 GIN 인덱스를 추가한다.""" """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) 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() content = open(os.path.join(migrations_dir, rev_file)).read()
assert "tag_ids" in content 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: def test_migration_0021_data_migration_exists() -> None:
"""REQ-006: revision 0021 데이터 마이그레이션 파일이 존재한다.""" """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] files = [f for f in os.listdir(migrations_dir) if "0021" in f]
assert len(files) == 1 assert len(files) == 1
def test_migration_0021_has_admin_verification() -> None: def test_migration_0021_has_admin_verification() -> None:
"""REQ-006 AC-5: revision 0021이 admin ID 존재 검증 로직을 포함한다.""" """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) 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() content = open(os.path.join(migrations_dir, rev_file)).read()
assert "admin_user_id" in content assert "admin_user_id" in content
Generated
+27
View File
@@ -611,6 +611,7 @@ dev = [
{ name = "httpx" }, { name = "httpx" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "ruff" },
] ]
[package.metadata] [package.metadata]
@@ -625,6 +626,7 @@ requires-dist = [
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" },
{ name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2.0.0" },
{ name = "rich", specifier = ">=13.9.0,<14.0.0" }, { name = "rich", specifier = ">=13.9.0,<14.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.0,<1.0.0" },
{ name = "typer", specifier = ">=0.12.5,<1.0.0" }, { name = "typer", specifier = ">=0.12.5,<1.0.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0,<1.0.0" },
] ]
@@ -970,6 +972,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" }, { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
] ]
[[package]]
name = "ruff"
version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" },
{ url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" },
{ url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" },
{ url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" },
{ url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" },
{ url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" },
{ url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" },
{ url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" },
{ url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" },
{ url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" },
{ url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" },
{ url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" },
{ url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" },
{ url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" },
{ url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"