From fd080496b3ee2e09649c63419f5bae799e87a3c3 Mon Sep 17 00:00:00 2001 From: Justin Ko Date: Thu, 13 Aug 2026 16:23:30 +0900 Subject: [PATCH] feat(release): add verified image publication pipeline --- .dockerignore | 23 ++ .github/workflows/publish-image.yml | 101 +++++ .github/workflows/verify.yml | 27 ++ Dockerfile | 26 ++ app/db/alembic/__init__.py | 0 alembic.ini => app/db/alembic/alembic.ini | 0 {migrations => app/db/alembic}/env.py | 0 {migrations => app/db/alembic}/script.py.mako | 0 .../20260403_0001_initial_portfolio_schema.py | 0 .../20260403_0002_strategy_set_columns.py | 0 .../20260403_0003_portfolio_strategy_refs.py | 0 .../versions/20260403_0004_users_table.py | 0 ...260403_0005_auth_user_fields_and_tokens.py | 0 ...0403_0006_bot_principals_and_bot_tokens.py | 0 ...0403_0007_bot_principal_permission_only.py | 0 .../20260403_0008_portfolio_name_column.py | 0 .../20260410_0009_portfolio_comments_table.py | 0 .../20260413_0010_portfolio_owner_user_id.py | 0 ...260414_0011_bot_principal_owner_user_id.py | 0 .../versions/20260414_0012_portfolio_tags.py | 0 ...13_remove_bot_principals_service_tokens.py | 0 .../versions/20260502_0014_v1_api_schema.py | 0 .../20260511_0015_portfolio_uuid_migration.py | 0 .../versions/20260513_0016_daemon_model.py | 0 .../20260519_0017_create_assets_table.py | 0 ...0260519_0018_portfolios_asset_migration.py | 0 .../20260519_0019_tags_master_table.py | 0 ...20260519_0020_portfolios_tag_ids_column.py | 0 ...0519_0021_portfolio_tags_data_migration.py | 0 ...520_0022_service_token_unique_index_fix.py | 0 .../20260521_0023_assets_tag_ids_column.py | 0 ..._0024_ledger_trades_portfolio_id_rename.py | 0 .../20260605_0025_ledger_consolidation.py | 0 ..._0026_broker_credential_daemon_id_step1.py | 0 ..._0027_broker_credential_daemon_id_step2.py | 0 ...5_0028_portfolio_ledger_manual_metadata.py | 0 .../20260814_0029_strategy_catalog_v1.py | 0 app/db/alembic/versions/__init__.py | 0 app/db/migrations.py | 31 +- docs/container-operations.md | 56 +++ pyproject.toml | 5 + scripts/image_release.py | 358 ++++++++++++++++++ tests/release/test_image_release.py | 181 +++++++++ tests/release/test_release_contracts.py | 68 ++++ tests/test_asset_catalog.py | 14 +- tests/test_tags_master.py | 12 +- uv.lock | 27 ++ 47 files changed, 907 insertions(+), 22 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/publish-image.yml create mode 100644 .github/workflows/verify.yml create mode 100644 Dockerfile create mode 100644 app/db/alembic/__init__.py rename alembic.ini => app/db/alembic/alembic.ini (100%) rename {migrations => app/db/alembic}/env.py (100%) rename {migrations => app/db/alembic}/script.py.mako (100%) rename {migrations => app/db/alembic}/versions/20260403_0001_initial_portfolio_schema.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0002_strategy_set_columns.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0003_portfolio_strategy_refs.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0004_users_table.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0005_auth_user_fields_and_tokens.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0006_bot_principals_and_bot_tokens.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0007_bot_principal_permission_only.py (100%) rename {migrations => app/db/alembic}/versions/20260403_0008_portfolio_name_column.py (100%) rename {migrations => app/db/alembic}/versions/20260410_0009_portfolio_comments_table.py (100%) rename {migrations => app/db/alembic}/versions/20260413_0010_portfolio_owner_user_id.py (100%) rename {migrations => app/db/alembic}/versions/20260414_0011_bot_principal_owner_user_id.py (100%) rename {migrations => app/db/alembic}/versions/20260414_0012_portfolio_tags.py (100%) rename {migrations => app/db/alembic}/versions/20260423_0013_remove_bot_principals_service_tokens.py (100%) rename {migrations => app/db/alembic}/versions/20260502_0014_v1_api_schema.py (100%) rename {migrations => app/db/alembic}/versions/20260511_0015_portfolio_uuid_migration.py (100%) rename {migrations => app/db/alembic}/versions/20260513_0016_daemon_model.py (100%) rename {migrations => app/db/alembic}/versions/20260519_0017_create_assets_table.py (100%) rename {migrations => app/db/alembic}/versions/20260519_0018_portfolios_asset_migration.py (100%) rename {migrations => app/db/alembic}/versions/20260519_0019_tags_master_table.py (100%) rename {migrations => app/db/alembic}/versions/20260519_0020_portfolios_tag_ids_column.py (100%) rename {migrations => app/db/alembic}/versions/20260519_0021_portfolio_tags_data_migration.py (100%) rename {migrations => app/db/alembic}/versions/20260520_0022_service_token_unique_index_fix.py (100%) rename {migrations => app/db/alembic}/versions/20260521_0023_assets_tag_ids_column.py (100%) rename {migrations => app/db/alembic}/versions/20260601_0024_ledger_trades_portfolio_id_rename.py (100%) rename {migrations => app/db/alembic}/versions/20260605_0025_ledger_consolidation.py (100%) rename {migrations => app/db/alembic}/versions/20260628_0026_broker_credential_daemon_id_step1.py (100%) rename {migrations => app/db/alembic}/versions/20260628_0027_broker_credential_daemon_id_step2.py (100%) rename {migrations => app/db/alembic}/versions/20260715_0028_portfolio_ledger_manual_metadata.py (100%) rename {migrations => app/db/alembic}/versions/20260814_0029_strategy_catalog_v1.py (100%) create mode 100644 app/db/alembic/versions/__init__.py create mode 100644 docs/container-operations.md create mode 100755 scripts/image_release.py create mode 100644 tests/release/test_image_release.py create mode 100644 tests/release/test_release_contracts.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..24287f9 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..fe8fc48 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -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}" diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 0000000..16e9db0 --- /dev/null +++ b/.github/workflows/verify.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a060bbd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/app/db/alembic/__init__.py b/app/db/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/alembic.ini b/app/db/alembic/alembic.ini similarity index 100% rename from alembic.ini rename to app/db/alembic/alembic.ini diff --git a/migrations/env.py b/app/db/alembic/env.py similarity index 100% rename from migrations/env.py rename to app/db/alembic/env.py diff --git a/migrations/script.py.mako b/app/db/alembic/script.py.mako similarity index 100% rename from migrations/script.py.mako rename to app/db/alembic/script.py.mako diff --git a/migrations/versions/20260403_0001_initial_portfolio_schema.py b/app/db/alembic/versions/20260403_0001_initial_portfolio_schema.py similarity index 100% rename from migrations/versions/20260403_0001_initial_portfolio_schema.py rename to app/db/alembic/versions/20260403_0001_initial_portfolio_schema.py diff --git a/migrations/versions/20260403_0002_strategy_set_columns.py b/app/db/alembic/versions/20260403_0002_strategy_set_columns.py similarity index 100% rename from migrations/versions/20260403_0002_strategy_set_columns.py rename to app/db/alembic/versions/20260403_0002_strategy_set_columns.py diff --git a/migrations/versions/20260403_0003_portfolio_strategy_refs.py b/app/db/alembic/versions/20260403_0003_portfolio_strategy_refs.py similarity index 100% rename from migrations/versions/20260403_0003_portfolio_strategy_refs.py rename to app/db/alembic/versions/20260403_0003_portfolio_strategy_refs.py diff --git a/migrations/versions/20260403_0004_users_table.py b/app/db/alembic/versions/20260403_0004_users_table.py similarity index 100% rename from migrations/versions/20260403_0004_users_table.py rename to app/db/alembic/versions/20260403_0004_users_table.py diff --git a/migrations/versions/20260403_0005_auth_user_fields_and_tokens.py b/app/db/alembic/versions/20260403_0005_auth_user_fields_and_tokens.py similarity index 100% rename from migrations/versions/20260403_0005_auth_user_fields_and_tokens.py rename to app/db/alembic/versions/20260403_0005_auth_user_fields_and_tokens.py diff --git a/migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py b/app/db/alembic/versions/20260403_0006_bot_principals_and_bot_tokens.py similarity index 100% rename from migrations/versions/20260403_0006_bot_principals_and_bot_tokens.py rename to app/db/alembic/versions/20260403_0006_bot_principals_and_bot_tokens.py diff --git a/migrations/versions/20260403_0007_bot_principal_permission_only.py b/app/db/alembic/versions/20260403_0007_bot_principal_permission_only.py similarity index 100% rename from migrations/versions/20260403_0007_bot_principal_permission_only.py rename to app/db/alembic/versions/20260403_0007_bot_principal_permission_only.py diff --git a/migrations/versions/20260403_0008_portfolio_name_column.py b/app/db/alembic/versions/20260403_0008_portfolio_name_column.py similarity index 100% rename from migrations/versions/20260403_0008_portfolio_name_column.py rename to app/db/alembic/versions/20260403_0008_portfolio_name_column.py diff --git a/migrations/versions/20260410_0009_portfolio_comments_table.py b/app/db/alembic/versions/20260410_0009_portfolio_comments_table.py similarity index 100% rename from migrations/versions/20260410_0009_portfolio_comments_table.py rename to app/db/alembic/versions/20260410_0009_portfolio_comments_table.py diff --git a/migrations/versions/20260413_0010_portfolio_owner_user_id.py b/app/db/alembic/versions/20260413_0010_portfolio_owner_user_id.py similarity index 100% rename from migrations/versions/20260413_0010_portfolio_owner_user_id.py rename to app/db/alembic/versions/20260413_0010_portfolio_owner_user_id.py diff --git a/migrations/versions/20260414_0011_bot_principal_owner_user_id.py b/app/db/alembic/versions/20260414_0011_bot_principal_owner_user_id.py similarity index 100% rename from migrations/versions/20260414_0011_bot_principal_owner_user_id.py rename to app/db/alembic/versions/20260414_0011_bot_principal_owner_user_id.py diff --git a/migrations/versions/20260414_0012_portfolio_tags.py b/app/db/alembic/versions/20260414_0012_portfolio_tags.py similarity index 100% rename from migrations/versions/20260414_0012_portfolio_tags.py rename to app/db/alembic/versions/20260414_0012_portfolio_tags.py diff --git a/migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py b/app/db/alembic/versions/20260423_0013_remove_bot_principals_service_tokens.py similarity index 100% rename from migrations/versions/20260423_0013_remove_bot_principals_service_tokens.py rename to app/db/alembic/versions/20260423_0013_remove_bot_principals_service_tokens.py diff --git a/migrations/versions/20260502_0014_v1_api_schema.py b/app/db/alembic/versions/20260502_0014_v1_api_schema.py similarity index 100% rename from migrations/versions/20260502_0014_v1_api_schema.py rename to app/db/alembic/versions/20260502_0014_v1_api_schema.py diff --git a/migrations/versions/20260511_0015_portfolio_uuid_migration.py b/app/db/alembic/versions/20260511_0015_portfolio_uuid_migration.py similarity index 100% rename from migrations/versions/20260511_0015_portfolio_uuid_migration.py rename to app/db/alembic/versions/20260511_0015_portfolio_uuid_migration.py diff --git a/migrations/versions/20260513_0016_daemon_model.py b/app/db/alembic/versions/20260513_0016_daemon_model.py similarity index 100% rename from migrations/versions/20260513_0016_daemon_model.py rename to app/db/alembic/versions/20260513_0016_daemon_model.py diff --git a/migrations/versions/20260519_0017_create_assets_table.py b/app/db/alembic/versions/20260519_0017_create_assets_table.py similarity index 100% rename from migrations/versions/20260519_0017_create_assets_table.py rename to app/db/alembic/versions/20260519_0017_create_assets_table.py diff --git a/migrations/versions/20260519_0018_portfolios_asset_migration.py b/app/db/alembic/versions/20260519_0018_portfolios_asset_migration.py similarity index 100% rename from migrations/versions/20260519_0018_portfolios_asset_migration.py rename to app/db/alembic/versions/20260519_0018_portfolios_asset_migration.py diff --git a/migrations/versions/20260519_0019_tags_master_table.py b/app/db/alembic/versions/20260519_0019_tags_master_table.py similarity index 100% rename from migrations/versions/20260519_0019_tags_master_table.py rename to app/db/alembic/versions/20260519_0019_tags_master_table.py diff --git a/migrations/versions/20260519_0020_portfolios_tag_ids_column.py b/app/db/alembic/versions/20260519_0020_portfolios_tag_ids_column.py similarity index 100% rename from migrations/versions/20260519_0020_portfolios_tag_ids_column.py rename to app/db/alembic/versions/20260519_0020_portfolios_tag_ids_column.py diff --git a/migrations/versions/20260519_0021_portfolio_tags_data_migration.py b/app/db/alembic/versions/20260519_0021_portfolio_tags_data_migration.py similarity index 100% rename from migrations/versions/20260519_0021_portfolio_tags_data_migration.py rename to app/db/alembic/versions/20260519_0021_portfolio_tags_data_migration.py diff --git a/migrations/versions/20260520_0022_service_token_unique_index_fix.py b/app/db/alembic/versions/20260520_0022_service_token_unique_index_fix.py similarity index 100% rename from migrations/versions/20260520_0022_service_token_unique_index_fix.py rename to app/db/alembic/versions/20260520_0022_service_token_unique_index_fix.py diff --git a/migrations/versions/20260521_0023_assets_tag_ids_column.py b/app/db/alembic/versions/20260521_0023_assets_tag_ids_column.py similarity index 100% rename from migrations/versions/20260521_0023_assets_tag_ids_column.py rename to app/db/alembic/versions/20260521_0023_assets_tag_ids_column.py diff --git a/migrations/versions/20260601_0024_ledger_trades_portfolio_id_rename.py b/app/db/alembic/versions/20260601_0024_ledger_trades_portfolio_id_rename.py similarity index 100% rename from migrations/versions/20260601_0024_ledger_trades_portfolio_id_rename.py rename to app/db/alembic/versions/20260601_0024_ledger_trades_portfolio_id_rename.py diff --git a/migrations/versions/20260605_0025_ledger_consolidation.py b/app/db/alembic/versions/20260605_0025_ledger_consolidation.py similarity index 100% rename from migrations/versions/20260605_0025_ledger_consolidation.py rename to app/db/alembic/versions/20260605_0025_ledger_consolidation.py diff --git a/migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py b/app/db/alembic/versions/20260628_0026_broker_credential_daemon_id_step1.py similarity index 100% rename from migrations/versions/20260628_0026_broker_credential_daemon_id_step1.py rename to app/db/alembic/versions/20260628_0026_broker_credential_daemon_id_step1.py diff --git a/migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py b/app/db/alembic/versions/20260628_0027_broker_credential_daemon_id_step2.py similarity index 100% rename from migrations/versions/20260628_0027_broker_credential_daemon_id_step2.py rename to app/db/alembic/versions/20260628_0027_broker_credential_daemon_id_step2.py diff --git a/migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py b/app/db/alembic/versions/20260715_0028_portfolio_ledger_manual_metadata.py similarity index 100% rename from migrations/versions/20260715_0028_portfolio_ledger_manual_metadata.py rename to app/db/alembic/versions/20260715_0028_portfolio_ledger_manual_metadata.py diff --git a/migrations/versions/20260814_0029_strategy_catalog_v1.py b/app/db/alembic/versions/20260814_0029_strategy_catalog_v1.py similarity index 100% rename from migrations/versions/20260814_0029_strategy_catalog_v1.py rename to app/db/alembic/versions/20260814_0029_strategy_catalog_v1.py diff --git a/app/db/alembic/versions/__init__.py b/app/db/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/db/migrations.py b/app/db/migrations.py index 497f2e6..88033de 100644 --- a/app/db/migrations.py +++ b/app/db/migrations.py @@ -2,19 +2,33 @@ from __future__ import annotations -from pathlib import Path +from importlib import resources +import psycopg from alembic import command from alembic.config import Config -import psycopg 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: - project_root = Path(__file__).resolve().parents[2] - config = Config(str(project_root / "alembic.ini")) - config.set_main_option("script_location", str(project_root / "migrations")) + root = migration_root() + config = Config(str(root.joinpath("alembic.ini"))) + config.set_main_option("script_location", str(root)) config.set_main_option("sqlalchemy.url", settings.active_database_url.replace("%", "%%")) # migrations/env.py must use this explicit settings object instead of the # 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: - with _connect(settings) as conn: - with conn.cursor() as cur: - cur.execute("SELECT to_regclass('public.alembic_version')") - return cur.fetchone()[0] is not None + with _connect(settings) as conn, conn.cursor() as cur: + cur.execute("SELECT to_regclass('public.alembic_version')") + return cur.fetchone()[0] is not None def _has_legacy_portfolio_schema(settings: Settings) -> bool: diff --git a/docs/container-operations.md b/docs/container-operations.md new file mode 100644 index 0000000..cfe89c9 --- /dev/null +++ b/docs/container-operations.md @@ -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:` +- `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. diff --git a/pyproject.toml b/pyproject.toml index 6e1c579..7e31f10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "httpx>=0.27.0,<1.0.0", "pre-commit>=3.8.0,<4.0.0", "pytest>=8.3.0,<9.0.0", + "ruff>=0.12.0,<1.0.0", ] [tool.pytest.ini_options] @@ -40,3 +41,7 @@ package-dir = {"" = "."} [tool.setuptools.packages.find] include = ["app*"] + +[tool.setuptools.package-data] +"app.db.alembic" = ["alembic.ini", "script.py.mako"] +"app.db.alembic.versions" = ["*.py"] diff --git a/scripts/image_release.py b/scripts/image_release.py new file mode 100755 index 0000000..614da29 --- /dev/null +++ b/scripts/image_release.py @@ -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 diff --git a/tests/release/test_image_release.py b/tests/release/test_image_release.py new file mode 100644 index 0000000..056426d --- /dev/null +++ b/tests/release/test_image_release.py @@ -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) diff --git a/tests/release/test_release_contracts.py b/tests/release/test_release_contracts.py new file mode 100644 index 0000000..3708386 --- /dev/null +++ b/tests/release/test_release_contracts.py @@ -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 diff --git a/tests/test_asset_catalog.py b/tests/test_asset_catalog.py index 9162e66..2f475e2 100644 --- a/tests/test_asset_catalog.py +++ b/tests/test_asset_catalog.py @@ -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 diff --git a/tests/test_tags_master.py b/tests/test_tags_master.py index bb8761a..1739dc6 100644 --- a/tests/test_tags_master.py +++ b/tests/test_tags_master.py @@ -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 diff --git a/uv.lock b/uv.lock index aa23229..bd8f59d 100644 --- a/uv.lock +++ b/uv.lock @@ -611,6 +611,7 @@ dev = [ { name = "httpx" }, { name = "pre-commit" }, { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -625,6 +626,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0,<9.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0,<2.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 = "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" }, ] +[[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]] name = "shellingham" version = "1.5.4"