Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d64079f583 | ||
|
|
63ff4881b0 | ||
|
|
6e10f71374 | ||
|
|
9c8baac70b | ||
|
|
fd080496b3 |
@@ -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
|
||||
@@ -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@11d5960a326750d5838078e36cf38b85af677262 # 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}" --project pyproject.toml
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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 --platform linux/amd64 --provenance=false --build-arg "SOURCE_COMMIT=${GITHUB_SHA}" --build-arg "RELEASE_VERSION=${GITHUB_REF_NAME}" --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@ea165f8d65b6e75b540449e92b4886f43607fa02 # 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@d3f86a106a0bac45b974a628896c90dbdf5c8093 # 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}"
|
||||
@@ -0,0 +1,38 @@
|
||||
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@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 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
|
||||
- name: Build and smoke exact pull-request or main source image
|
||||
run: |
|
||||
uv run python -c 'from app.db.migrations import migration_inventory; print("\n".join(migration_inventory()))' > migration-inventory.txt
|
||||
printf '%s\n' 'PORTFOLIO_HUB_SECRET_CANARY_DO_NOT_SHIP' > .env.ci-canary
|
||||
RELEASE_VERSION="v$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
|
||||
docker build --platform linux/amd64 --provenance=false \
|
||||
--build-arg "SOURCE_COMMIT=${GITHUB_SHA}" --build-arg "RELEASE_VERSION=${RELEASE_VERSION}" \
|
||||
--tag "portfolio-hub-ci:${GITHUB_SHA}" .
|
||||
uv run python scripts/image_release.py smoke --image "portfolio-hub-ci:${GITHUB_SHA}" --inventory migration-inventory.txt --timeout 180
|
||||
docker save --output portfolio-hub-image.tar "portfolio-hub-ci:${GITHUB_SHA}"
|
||||
uv run python scripts/image_release.py scan --image "portfolio-hub-ci:${GITHUB_SHA}" --archive portfolio-hub-image.tar
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1@sha256:ecfaec9ed6d810b56388c508f4121597bfbba70d41a6dfeee4d8cad5f295fc32
|
||||
FROM ghcr.io/astral-sh/uv:0.11.14@sha256:1025398289b62de8269e70c45b91ffa37c373f38118d7da036fb8bb8efc85d97 AS uv
|
||||
FROM python:3.11-slim@sha256:a630a63cdb314e2d138a2fca3e375e319e8568346ffafac5b980f888630ac4f1 AS build
|
||||
ARG SOURCE_COMMIT
|
||||
ARG RELEASE_VERSION
|
||||
WORKDIR /opt/portfolio-hub
|
||||
RUN test "${#SOURCE_COMMIT}" -eq 40 && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) false;; *) true;; esac \
|
||||
&& printf '%s' "${RELEASE_VERSION}" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
|
||||
|| { echo "SOURCE_COMMIT and strict RELEASE_VERSION=vX.Y.Z are required" >&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@sha256:a630a63cdb314e2d138a2fca3e375e319e8568346ffafac5b980f888630ac4f1 AS runtime
|
||||
ARG SOURCE_COMMIT
|
||||
ARG RELEASE_VERSION
|
||||
LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/portfolio-hub" \
|
||||
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
|
||||
org.opencontainers.image.version="${RELEASE_VERSION}"
|
||||
RUN test "${#SOURCE_COMMIT}" -eq 40 && case "${SOURCE_COMMIT}" in *[!0-9a-f]*) false;; *) true;; esac \
|
||||
&& printf '%s' "${RELEASE_VERSION}" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' \
|
||||
&& groupadd --gid 10001 portfoliohub \
|
||||
&& useradd --uid 10001 --gid 10001 --create-home --shell /usr/sbin/nologin portfoliohub \
|
||||
|| { echo "SOURCE_COMMIT and strict RELEASE_VERSION=vX.Y.Z are required" >&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"]
|
||||
@@ -5,9 +5,13 @@ from __future__ import annotations
|
||||
import typer
|
||||
|
||||
from app.cli.core.utils import print_json
|
||||
from app.db.migrations import bootstrap_or_upgrade_to_head
|
||||
from app.db.migrations import (
|
||||
bootstrap_or_upgrade_to_head,
|
||||
current_revision,
|
||||
head_revision,
|
||||
)
|
||||
from app.repositories.factory import build_portfolio_db_config
|
||||
from app.repositories.postgres import PostgresPortfolioRepository, create_database_if_missing, database_exists
|
||||
from app.repositories.postgres import create_database_if_missing, database_exists
|
||||
|
||||
|
||||
def initialize_database(*, settings, maintenance_db: str = "postgres") -> dict[str, object]:
|
||||
@@ -90,6 +94,15 @@ def register_health_commands(app: typer.Typer) -> None:
|
||||
}
|
||||
if hasattr(repository, "ping"):
|
||||
repository.ping()
|
||||
if app_context.settings.db_enabled:
|
||||
database_revision = current_revision(app_context.settings)
|
||||
expected_revision = head_revision(app_context.settings)
|
||||
health["database_revision"] = database_revision
|
||||
health["expected_revision"] = expected_revision
|
||||
if database_revision != expected_revision:
|
||||
raise RuntimeError(
|
||||
f"database revision {database_revision!r} is not packaged head {expected_revision!r}"
|
||||
)
|
||||
except Exception as exc:
|
||||
app_context.console.print(f"health-check failed: {exc}")
|
||||
raise typer.Exit(code=1) from exc
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ if env_file.exists():
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_name: str = "PortfolioHub"
|
||||
app_version: str = "0.1.0"
|
||||
app_version: str = "0.1.1"
|
||||
environment: str = "development"
|
||||
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
|
||||
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
|
||||
|
||||
+34
-16
@@ -2,19 +2,34 @@
|
||||
|
||||
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 alembic.script import ScriptDirectory
|
||||
|
||||
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
|
||||
@@ -47,15 +62,19 @@ def bootstrap_or_upgrade_to_head(settings: Settings) -> str:
|
||||
|
||||
|
||||
def current_revision(settings: Settings) -> str | None:
|
||||
output: list[str] = []
|
||||
"""Read the database revision without applying or stamping migrations."""
|
||||
with _connect(settings) as conn, conn.cursor() as cur:
|
||||
cur.execute("SELECT version_num FROM alembic_version")
|
||||
row = cur.fetchone()
|
||||
return str(row[0]) if row else None
|
||||
|
||||
def capture(message: str, *_args, **_kwargs) -> None:
|
||||
if message.strip():
|
||||
output.append(message.strip())
|
||||
|
||||
config = get_alembic_config(settings)
|
||||
command.current(config, verbose=False)
|
||||
return output[-1] if output else None
|
||||
def head_revision(settings: Settings) -> str:
|
||||
"""Return the single packaged Alembic head without touching the database."""
|
||||
heads = ScriptDirectory.from_config(get_alembic_config(settings)).get_heads()
|
||||
if len(heads) != 1:
|
||||
raise RuntimeError(f"expected one Alembic head, found {len(heads)}")
|
||||
return heads[0]
|
||||
|
||||
|
||||
def _connect(settings: Settings) -> psycopg.Connection:
|
||||
@@ -73,10 +92,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:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# PortfolioHub v0.1.1 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.1.1` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
|
||||
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
|
||||
- `ghcr.io/fourblocks2020/portfolio-hub:v0.1.1`
|
||||
|
||||
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
|
||||
|
||||
```text
|
||||
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
|
||||
```
|
||||
|
||||
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.1.1`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
|
||||
|
||||
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.1.1 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
|
||||
|
||||
## Image runtime
|
||||
|
||||
- platform: `linux/amd64`;
|
||||
- process: exec-form `uvicorn app.main:app --host 0.0.0.0 --port 8000` as PID 1;
|
||||
- network: HTTP on fixed container port `8000` (configure only the host/service mapping, not the container port);
|
||||
- identity: numeric UID/GID `10001:10001`;
|
||||
- filesystem: no application data path is written; the root filesystem may be read-only;
|
||||
- temporary space: a bounded `/tmp` tmpfs is safe but not required by normal application behavior;
|
||||
- shutdown: send `SIGTERM`, allow at least 10 seconds for Uvicorn graceful shutdown, then escalate only under the deployment platform's policy;
|
||||
- restart: application replicas may use the platform's normal failure restart policy; the one-shot migration must use restart `no` and must never loop after failure.
|
||||
|
||||
Recommended runtime restrictions are a read-only root filesystem, all capabilities dropped, `no-new-privileges`, a bounded PID limit, and no Docker socket or host filesystem mounts. No build argument, label, layer, or image environment contains an application secret.
|
||||
|
||||
## Environment contract
|
||||
|
||||
Values are read at process start; changing any value requires a container restart. Empty optional values mean the shown default/disabled behavior.
|
||||
|
||||
### Production database and secrets
|
||||
|
||||
| Variable | Production contract | Default | Secret | Effect |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `PORTFOLIOHUB_DB_ENABLED` | required; `true` for persistent production | `false` | no | Selects PostgreSQL instead of in-memory repository |
|
||||
| `TIMESCALEDB_HOST` | required when DB enabled | `localhost` | no | PostgreSQL host |
|
||||
| `TIMESCALEDB_PORT` | optional | `5432` | no | PostgreSQL port |
|
||||
| `TIMESCALEDB_NAME` | required when DB enabled | `portfoliohub` | no | database name |
|
||||
| `TIMESCALEDB_USER` | required when DB enabled | `portfoliohub` | usually sensitive | database user |
|
||||
| `TIMESCALEDB_PASSWORD` | required by production DB policy | empty | **yes** | database password |
|
||||
| `TIMESCALEDB_SSLMODE` | optional; set explicitly in production | `prefer` | no | psycopg SSL mode |
|
||||
| `TIMESCALEDB_CONNECT_TIMEOUT_SEC` | optional | `10` | no | connection timeout |
|
||||
| `TIMESCALEDB_APPLICATION_NAME` | optional | `portfoliohub` | no | PostgreSQL application name |
|
||||
| `CREDENTIAL_ENCRYPTION_KEY` | required when DB enabled | empty | **yes** | Fernet key for stored broker credentials; startup fails closed if absent |
|
||||
|
||||
### Authentication, bootstrap, and HTTP behavior
|
||||
|
||||
| Variable | Required/default | Secret | Effect |
|
||||
| --- | --- | --- | --- |
|
||||
| `PORTFOLIOHUB_ALLOWED_ORIGINS` | optional; `http://localhost:3000` | no | comma-separated CORS origins; set the trex-web origin in production |
|
||||
| `PORTFOLIOHUB_ROOT_PATH` | optional; empty | no | reverse-proxy ASGI root path; does not rewrite the actual route paths |
|
||||
| `PORTFOLIOHUB_READ_TOKENS` | optional; empty | **yes** | comma-separated legacy read service tokens |
|
||||
| `PORTFOLIOHUB_TRADE_WRITE_TOKENS` | optional; empty | **yes** | comma-separated legacy ledger-write service tokens |
|
||||
| `PORTFOLIOHUB_ADMIN_TOKENS` | optional; empty | **yes** | comma-separated legacy admin service tokens |
|
||||
| `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS` | optional; `12` | no | login session lifetime |
|
||||
| `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS` | optional; `365` | no | retained client-token default |
|
||||
| `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS` | optional; `365` | no | bot-token default |
|
||||
| `PORTFOLIOHUB_BOOTSTRAP_USERNAME` | optional; empty | no | with password, creates the first admin only when no users exist |
|
||||
| `PORTFOLIOHUB_BOOTSTRAP_EMAIL` | optional; empty | personal data | bootstrap user email |
|
||||
| `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
|
||||
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
|
||||
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name |
|
||||
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.1.1 | `0.1.1` | no | health/OpenAPI version metadata |
|
||||
|
||||
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.1.1 application does not open Redis or Loki connections. They are not production dependencies of this image.
|
||||
|
||||
### Legacy and sibling mapping
|
||||
|
||||
The existing database variable names remain `TIMESCALEDB_*`; v0.1.1 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
|
||||
|
||||
## Liveness and readiness
|
||||
|
||||
- **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness.
|
||||
- **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch.
|
||||
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.1.1`.
|
||||
|
||||
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
|
||||
|
||||
## One-shot migration and 0028 -> 0029 policy
|
||||
|
||||
The installed image contains exactly **29** migration files and one Alembic head, `20260814_0029`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
|
||||
|
||||
1. back up the database and record the current immutable image digest and `alembic_version`;
|
||||
2. require the normal upgrade origin `20260715_0028`; for an older revision, review and apply the full ordered chain first;
|
||||
3. stop writes or otherwise establish an operator-approved maintenance boundary;
|
||||
4. execute `pfhub migrate-db` once with restart disabled;
|
||||
5. stop rollout on any nonzero result and preserve sanitized logs;
|
||||
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260814_0029`;
|
||||
7. start application replicas and verify `/health`, `/openapi.json`, authenticated caller paths, and strategy data;
|
||||
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0029`, but it is verification—not an application-start hook.
|
||||
|
||||
Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes.
|
||||
|
||||
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades `0028` and `0029`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
|
||||
|
||||
Image rollback is **not** database rollback. Selecting older application bytes does not reverse schema/data changes, tokens, or side effects. Database restore, downgrade, or forward-fix requires separate review and authorization; no automatic downgrade is provided.
|
||||
|
||||
## API verification and known caller blockers
|
||||
|
||||
v0.1.1 preserves Bearer authentication and the caller route surface observed in sibling source. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 29-file migration inventory, isolated PostgreSQL migration twice, readiness at `0029`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
|
||||
|
||||
Route presence is **not** a positive cross-repository caller-compatibility claim. Direct source comparison found the following unresolved consumers, which the requester explicitly separated as BLOCKED follow-up rather than adding legacy shims to portfolio-hub:
|
||||
|
||||
- trex-web omits required `strategy_id` when creating a portfolio, so that mutation currently returns `422`;
|
||||
- trex-web can omit required `account_ref` when creating or rotating credentials, so those requests currently return `422`;
|
||||
- trex-web sends daemon activation state through the general PATCH route, which can return `200` without changing activation; it must use the activate/deactivate routes;
|
||||
- portfolio-hub returns singular `broker` from `/v1/user_info`, while trex-web types `brokers[]`;
|
||||
- portfolio-hub represents duplicate ledger success as `status="duplicate"`, while trex-core currently checks `duplicate=true`, so the core result classification is inconsistent.
|
||||
|
||||
Do not claim trex-core/trex-web compatibility or switch production callers until those consumer changes and cross-repository tests are completed.
|
||||
+9
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfoliohub"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "PortfolioHub API for portfolio management and ledger access"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -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]
|
||||
@@ -35,8 +36,15 @@ addopts = ["tests"]
|
||||
testpaths = ["tests"]
|
||||
norecursedirs = ["temp", ".git", ".venv"]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
known-first-party = ["app", "scripts"]
|
||||
|
||||
[tool.setuptools]
|
||||
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"]
|
||||
|
||||
Executable
+418
@@ -0,0 +1,418 @@
|
||||
#!/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
|
||||
import tomllib
|
||||
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|$)")
|
||||
EXPECTED_MIGRATION_COUNT = 29
|
||||
EXPECTED_ALEMBIC_HEAD = "20260814_0029"
|
||||
|
||||
@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 project_release_version(project: Path) -> str:
|
||||
value = tomllib.loads(project.read_text(encoding="utf-8"))["project"]["version"]
|
||||
version = f"v{value}"
|
||||
if not VERSION_REF_RE.fullmatch(f"refs/tags/{version}"):
|
||||
raise ValueError("project version must be strict semantic version X.Y.Z")
|
||||
return version
|
||||
|
||||
|
||||
def validate_release_event(
|
||||
event_name: str, ref: str, commit: str, expected_version: str | None = None
|
||||
) -> str:
|
||||
validate_commit(commit)
|
||||
if event_name != "push" or not VERSION_REF_RE.fullmatch(ref):
|
||||
raise ValueError("publication requires a push of a strict vX.Y.Z tag")
|
||||
version = ref.removeprefix("refs/tags/")
|
||||
if expected_version is not None and version != expected_version:
|
||||
raise ValueError("release tag does not match the project version")
|
||||
return version
|
||||
|
||||
def release_tags(event_name: str, ref: str, commit: str) -> tuple[str, str]:
|
||||
version = validate_release_event(event_name, ref, commit)
|
||||
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_container_value(container: str, template: str, runner: Runner) -> str:
|
||||
return _clean(runner.run(["docker", "container", "inspect", "--format", template, container])).stdout.strip()
|
||||
|
||||
def inspect_image_id(image: str, runner: Runner) -> str:
|
||||
value = inspect_value(image, "{{.Id}}", runner)
|
||||
if not IMAGE_ID_RE.fullmatch(value):
|
||||
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 inspect_version(image: str, runner: Runner) -> str:
|
||||
return inspect_value(image, '{{ index .Config.Labels "org.opencontainers.image.version" }}', runner)
|
||||
|
||||
def write_image_id(image: str, output: Path, runner: Runner) -> str:
|
||||
image_id = inspect_image_id(image, runner)
|
||||
output.write_text(image_id + "\n", encoding="ascii")
|
||||
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")
|
||||
if len(expected_inventory) != EXPECTED_MIGRATION_COUNT:
|
||||
raise ValueError(f"expected exactly {EXPECTED_MIGRATION_COUNT} migrations")
|
||||
if not expected_inventory[-1].startswith(EXPECTED_ALEMBIC_HEAD):
|
||||
raise ValueError(f"migration inventory must end at {EXPECTED_ALEMBIC_HEAD}")
|
||||
inventory_code = (
|
||||
"import json; from app.db.migrations import migration_inventory; "
|
||||
f"expected={json.dumps(list(expected_inventory))}; actual=list(migration_inventory()); "
|
||||
"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 isolated health/OpenAPI, repeated migration, readiness, persistence and SIGTERM."""
|
||||
suffix = f"{os.getpid()}-{time.time_ns()}"
|
||||
plain = f"portfolio-hub-plain-{suffix}"; db = f"portfolio-hub-db-{suffix}"
|
||||
migrate = f"portfolio-hub-migrate-{suffix}"; migrate_again = f"portfolio-hub-migrate-again-{suffix}"
|
||||
app = f"portfolio-hub-app-{suffix}"; app_again = f"portfolio-hub-app-again-{suffix}"
|
||||
network = f"portfolio-hub-smoke-{suffix}"; volume = f"portfolio-hub-db-{suffix}"
|
||||
names: list[str] = []
|
||||
deadline = time.monotonic() + timeout
|
||||
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'; o=json.load(urllib.request.urlopen('http://TARGET:8000/openapi.json')); required={'/auth/login','/v1/user_info','/v1/portfolios','/v1/strategies','/v1/portfolios/{portfolio_id}/ledger/trades'}; assert required <= set(o['paths']), required-set(o['paths']); print(json.dumps(d,sort_keys=True))"
|
||||
try:
|
||||
architecture = inspect_value(image, "{{.Architecture}}", runner)
|
||||
if architecture != "amd64": raise RuntimeError(f"image architecture must be amd64, got {architecture!r}")
|
||||
runner.run(["docker", "network", "create", "--internal", network], timeout=10)
|
||||
runner.run(["docker", "volume", "create", volume], timeout=10)
|
||||
names.append(plain)
|
||||
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)
|
||||
names.append(migrate_again)
|
||||
runner.run(["docker", "run", "--name", migrate_again, "--network", network, *db_env,
|
||||
"--entrypoint", "pfhub", image, "migrate-db"], timeout=60)
|
||||
check = "import psycopg; c=psycopg.connect(host='postgres',dbname='smoke',user='smoke',password='synthetic-db-only'); r=c.execute(\"select version_num from alembic_version\").fetchone(); assert r and r[0]; assert c.execute('select 1').fetchone()==(1,); print(r[0])"
|
||||
head = _clean(runner.run(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
|
||||
image, "-c", check], timeout=30)).stdout.strip()
|
||||
if head != EXPECTED_ALEMBIC_HEAD:
|
||||
raise RuntimeError(f"Alembic head must be {EXPECTED_ALEMBIC_HEAD}, got {head!r}")
|
||||
readiness = _clean(runner.run(["docker", "run", "--rm", "--network", network, *db_env,
|
||||
"--entrypoint", "pfhub", image, "--format", "json", "health-check"], timeout=30))
|
||||
readiness_payload = json.loads(readiness.stdout)
|
||||
if readiness_payload.get("database_revision") != EXPECTED_ALEMBIC_HEAD:
|
||||
raise RuntimeError("readiness did not report the packaged Alembic head")
|
||||
names.append(app)
|
||||
runner.run(["docker", "run", "-d", "--name", app, "--network", network, "--network-alias", "app",
|
||||
"--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
|
||||
"--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")
|
||||
_clean(runner.run(["docker", "stop", "--time", "10", app], timeout=20))
|
||||
exit_code = inspect_container_value(app, "{{.State.ExitCode}}", runner)
|
||||
if exit_code != "0": raise RuntimeError(f"SIGTERM shutdown exit code was {exit_code!r}")
|
||||
names.append(app_again)
|
||||
runner.run(["docker", "run", "-d", "--name", app_again, "--network", network,
|
||||
"--network-alias", "app-again", "--read-only", "--cap-drop", "ALL",
|
||||
"--security-opt", "no-new-privileges", "--tmpfs", "/tmp:rw,noexec,nosuid,size=16m",
|
||||
*db_env, image], timeout=20)
|
||||
repeated_result = _wait(["docker", "run", "--rm", "--network", network, "--entrypoint", "python",
|
||||
image, "-c", http_probe.replace("TARGET", "app-again")], runner, deadline,
|
||||
"restarted DB-enabled /health and OpenAPI")
|
||||
if json.loads(repeated_result.stdout).get("db_enabled") is not True:
|
||||
raise RuntimeError("persistent restart health payload has the wrong shape")
|
||||
user = inspect_value(image, "{{.Config.User}}", runner)
|
||||
if user not in {"10001", "10001:10001"}: raise RuntimeError("image default user must be 10001")
|
||||
result = {"architecture": architecture, "plain_health": "ok", "db_health": "ok",
|
||||
"db_readiness": "ok", "db_ping": "ok", "alembic_head": head,
|
||||
"repeated_migration": "ok", "persistent_restart": "ok", "sigterm": "graceful"}
|
||||
except BaseException as error: failure = error
|
||||
try: _cleanup(names, network, volume, runner, 15)
|
||||
except BaseException as error:
|
||||
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")
|
||||
version = validate_release_event(event_name, ref, commit)
|
||||
if inspect_version(local_image, runner) != version:
|
||||
raise ValueError("loaded image version label does not match release tag")
|
||||
if not actor or not token: raise ValueError("registry credentials are missing")
|
||||
for tag in tags: runner.run(["docker", "tag", local_image, tag])
|
||||
runner.run(["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"], input_text=token)
|
||||
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); event.add_argument("--project", type=Path)
|
||||
checkout = commands.add_parser("validate-checkout"); checkout.add_argument("--actual", required=True); checkout.add_argument("--expected", required=True)
|
||||
smoke = commands.add_parser("smoke"); smoke.add_argument("--image", required=True); smoke.add_argument("--inventory", type=Path, required=True); smoke.add_argument("--timeout", type=int, default=120)
|
||||
scan = commands.add_parser("scan"); scan.add_argument("--image", required=True); scan.add_argument("--archive", type=Path, required=True)
|
||||
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":
|
||||
expected = project_release_version(args.project) if args.project else None
|
||||
validate_release_event(args.event, args.ref, args.commit, expected)
|
||||
elif args.command == "validate-checkout": validate_checkout_identity(args.actual, args.expected)
|
||||
elif args.command == "smoke":
|
||||
inventory = tuple(line for line in args.inventory.read_text().splitlines() if line)
|
||||
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
|
||||
@@ -0,0 +1,198 @@
|
||||
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"] and "{{.Architecture}}" in command: return image_release.Result("amd64\n")
|
||||
if command[:3] == ["docker", "container", "inspect"] and "{{.State.ExitCode}}" in command: return image_release.Result("0\n")
|
||||
if command[:3] == ["docker", "image", "inspect"] and any("org.opencontainers.image.version" in part for part in command): return image_release.Result("v1.2.3\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("20260814_0029\n")
|
||||
if command[:3] == ["docker", "run", "--rm"] and "health-check" in command: return image_release.Result('{"database_revision":"20260814_0029"}\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_project_version_must_match_release_tag(tmp_path):
|
||||
project = tmp_path / "pyproject.toml"
|
||||
project.write_text('[project]\nversion = "0.1.1"\n')
|
||||
assert image_release.project_release_version(project) == "v0.1.1"
|
||||
assert image_release.validate_release_event("push", "refs/tags/v0.1.1", COMMIT, "v0.1.1") == "v0.1.1"
|
||||
with pytest.raises(ValueError, match="project version"):
|
||||
image_release.validate_release_event("push", "refs/tags/v0.1.2", COMMIT, "v0.1.1")
|
||||
|
||||
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
|
||||
inventory = tuple(
|
||||
[f"202601{i:02d}_{i:04d}_migration.py" for i in range(1, 29)]
|
||||
+ ["20260814_0029_strategy_catalog_v1.py"]
|
||||
)
|
||||
runner = RecordingRunner(); image_release.smoke_installed_image("candidate", inventory, 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 "20260814_0029" in runs[-1][-1]
|
||||
assert len([x for x in runner.commands if x[0][:3] == ["docker","rm","--force"]]) == 3
|
||||
|
||||
def _assert_exact_startup_cleanup(commands: list[list[str]]) -> None:
|
||||
attempted_names = {
|
||||
command[command.index("--name") + 1]
|
||||
for command in commands
|
||||
if command[:2] == ["docker", "run"] and "--name" in command
|
||||
}
|
||||
removed_names = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "rm", "--force"]
|
||||
}
|
||||
created_networks = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "network", "create"]
|
||||
}
|
||||
removed_networks = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "network", "rm"]
|
||||
}
|
||||
created_volumes = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:3] == ["docker", "volume", "create"]
|
||||
}
|
||||
removed_volumes = {
|
||||
command[-1]
|
||||
for command in commands
|
||||
if command[:4] == ["docker", "volume", "rm", "--force"]
|
||||
}
|
||||
assert attempted_names
|
||||
assert removed_names == attempted_names
|
||||
assert removed_networks == created_networks
|
||||
assert removed_volumes == created_volumes
|
||||
|
||||
|
||||
def test_wrong_shaped_health_cleans_exact_attempted_resources():
|
||||
class WrongShapeRunner(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
if list(command)[:3] == ["docker", "run", "--rm"] and "urllib.request" in list(command)[-1]:
|
||||
self.commands.append((list(command), kwargs.get("input_text"), kwargs.get("timeout")))
|
||||
return image_release.Result("[]")
|
||||
return super().run(command, **kwargs)
|
||||
runner = WrongShapeRunner()
|
||||
with pytest.raises(RuntimeError, match="startup smoke"):
|
||||
image_release.startup_smoke("candidate", runner, 17)
|
||||
commands = [x[0] for x in runner.commands]
|
||||
_assert_exact_startup_cleanup(commands)
|
||||
|
||||
|
||||
def test_timeout_cleans_exact_attempted_resources():
|
||||
runner = RecordingRunner(fail_on=("docker","run","-d"))
|
||||
with pytest.raises(RuntimeError): image_release.startup_smoke("candidate", runner, 17)
|
||||
commands = [x[0] for x in runner.commands]
|
||||
_assert_exact_startup_cleanup(commands)
|
||||
|
||||
|
||||
def test_scan_create_canary_failure_removes_exact_created_container(tmp_path):
|
||||
class CreateCanaryRunner(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
command = list(command)
|
||||
if command[:2] == ["docker", "create"]:
|
||||
self.commands.append((command, kwargs.get("input_text"), kwargs.get("timeout")))
|
||||
return image_release.Result(
|
||||
"created-container\n",
|
||||
image_release.CANARY.decode(),
|
||||
)
|
||||
return super().run(command, **kwargs)
|
||||
|
||||
archive = tmp_path / "portfolio-hub-image.tar"
|
||||
archive.write_bytes(b"unused")
|
||||
runner = CreateCanaryRunner()
|
||||
with pytest.raises(ValueError, match="canary"):
|
||||
image_release.scan_image("candidate", archive, runner, 17)
|
||||
removals = [
|
||||
command
|
||||
for command, _input, _timeout in runner.commands
|
||||
if command[:3] == ["docker", "rm", "--force"]
|
||||
]
|
||||
assert removals == [["docker", "rm", "--force", "created-container"]]
|
||||
|
||||
|
||||
def test_corrupt_archive_stops_before_registry_write(tmp_path):
|
||||
archive, checksum, image_id = artifacts(tmp_path); archive.write_bytes(b"corrupt"); runner = RecordingRunner()
|
||||
with pytest.raises(ValueError, match="checksum mismatch"):
|
||||
image_release.publish_image(archive=archive, checksum=checksum, image_id=image_id, local_image=LOCAL,
|
||||
commit=COMMIT,event_name="push",ref="refs/tags/v1.2.3",actor="a",token="t",runner=runner)
|
||||
assert runner.commands == []
|
||||
|
||||
def test_publish_loads_without_rebuild_and_records_digest(tmp_path):
|
||||
runner = RecordingRunner(); output = tmp_path/"output"; assert publish(tmp_path, runner, output_path=output) == DIGEST
|
||||
commands = [x[0][:2] for x in runner.commands]
|
||||
assert ["docker","build"] not in commands and commands.count(["docker","push"]) == 2
|
||||
assert COMMIT in output.read_text() and DIGEST in output.read_text() and ":latest" not in output.read_text()
|
||||
|
||||
def test_post_push_bad_digest_emits_no_success_evidence(tmp_path):
|
||||
class Bad(RecordingRunner):
|
||||
def run(self, command, **kwargs):
|
||||
result = super().run(command, **kwargs)
|
||||
return image_release.Result("pushed without digest") if list(command)[:2] == ["docker","push"] else result
|
||||
output=tmp_path/"output"
|
||||
with pytest.raises(ValueError, match="valid digest"): publish(tmp_path, Bad(), output_path=output)
|
||||
assert not output.exists()
|
||||
|
||||
@pytest.mark.parametrize("kind", ["payload","symlink","pax"])
|
||||
def test_canary_scan_covers_archive_payload_and_metadata(kind):
|
||||
data=io.BytesIO()
|
||||
with tarfile.open(fileobj=data, mode="w", format=tarfile.PAX_FORMAT) as archive:
|
||||
member=tarfile.TarInfo("item")
|
||||
if kind=="payload": payload=image_release.CANARY; member.size=len(payload); archive.addfile(member,io.BytesIO(payload))
|
||||
elif kind=="symlink": member.type=tarfile.SYMTYPE; member.linkname=image_release.CANARY.decode(); archive.addfile(member)
|
||||
else: member.pax_headers={"key":image_release.CANARY.decode()}; archive.addfile(member,io.BytesIO())
|
||||
with pytest.raises(ValueError, match="canary"): image_release._scan_tar(data.getvalue(), True)
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
assert "--platform linux/amd64" in verify and '--build-arg "RELEASE_VERSION=${GITHUB_REF_NAME}"' in verify
|
||||
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@sha256:")==2 and "uv sync --locked --no-dev --no-editable" in text
|
||||
assert "FROM ghcr.io/astral-sh/uv:0.11.14@sha256:" in text
|
||||
assert text.startswith("# syntax=docker/dockerfile:1@sha256:")
|
||||
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
|
||||
assert "org.opencontainers.image.version" in text and "ARG RELEASE_VERSION" in text
|
||||
|
||||
|
||||
def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derived():
|
||||
for workflow in (PUBLISH, VERIFY):
|
||||
for line in workflow.read_text().splitlines():
|
||||
if "uses: actions/" in line:
|
||||
revision = line.split("@", 1)[1].split()[0]
|
||||
assert len(revision) == 40 and all(char in "0123456789abcdef" for char in revision)
|
||||
verify = VERIFY.read_text()
|
||||
assert "tomllib" in verify and 'RELEASE_VERSION=${RELEASE_VERSION}' in verify
|
||||
assert 'RELEASE_VERSION=v0.1.1' not in verify
|
||||
|
||||
|
||||
def test_release_version_and_migration_contract_are_v011_head_0029():
|
||||
project = (REPO / "pyproject.toml").read_text()
|
||||
config = (REPO / "app/core/config.py").read_text()
|
||||
migrations = sorted(
|
||||
path.name
|
||||
for path in (REPO / "app/db/alembic/versions").glob("*.py")
|
||||
if path.name != "__init__.py"
|
||||
)
|
||||
assert 'version = "0.1.1"' in project
|
||||
assert 'app_version: str = "0.1.1"' in config
|
||||
assert len(migrations) == 29
|
||||
assert migrations[-1].startswith("20260814_0029")
|
||||
|
||||
def test_dockerignore_is_deny_by_default_required_inputs_only():
|
||||
lines=(REPO/".dockerignore").read_text().splitlines(); assert lines[0]=="**"
|
||||
assert [x for x in lines if x.startswith("!")] == ["!pyproject.toml","!uv.lock","!README.md","!app/","!app/**"]
|
||||
|
||||
def test_source_and_wheel_migration_inventory_are_exact_and_non_empty(tmp_path):
|
||||
source=sorted(x.name for x in (REPO/"app/db/alembic/versions").glob("*.py") if x.name!="__init__.py")
|
||||
assert source
|
||||
subprocess.run(["uv","build","--wheel","--out-dir",str(tmp_path)],cwd=REPO,check=True,capture_output=True,text=True)
|
||||
wheel=next(tmp_path.glob("*.whl"))
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
installed=sorted(Path(x).name for x in archive.namelist() if "/db/alembic/versions/" in x and x.endswith(".py") and not x.endswith("/__init__.py"))
|
||||
assert installed == source
|
||||
|
||||
def test_operations_separate_publication_deployment_migration_and_db_rollback():
|
||||
text = (REPO / "docs/container-operations.md").read_text()
|
||||
for value in (
|
||||
"@sha256:<64-lowercase-hex-digest>",
|
||||
"pfhub migrate-db",
|
||||
"Publication is not deployment",
|
||||
"Image rollback is **not** database rollback",
|
||||
"read-only root filesystem",
|
||||
"10001:10001",
|
||||
):
|
||||
assert value in text
|
||||
for forbidden in (
|
||||
"docker run -d",
|
||||
"docker compose up",
|
||||
"kubectl apply",
|
||||
"helm install",
|
||||
"systemctl restart",
|
||||
):
|
||||
assert forbidden not in text
|
||||
@@ -203,21 +203,21 @@ def test_same_user_can_create_two_portfolios_for_same_asset_id() -> None:
|
||||
|
||||
def test_migration_rev_a_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_a_files = [f for f in os.listdir(migrations_dir) if "0017" in f and "assets" in f]
|
||||
assert len(rev_a_files) == 1, f"rev-A migration (0017 assets) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_b_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b_files = [f for f in os.listdir(migrations_dir) if "0018" in f]
|
||||
assert len(rev_b_files) == 1, f"rev-B migration (0018) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_rev_a_creates_assets_table() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_a = next(f for f in os.listdir(migrations_dir) if "0017" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_a)).read()
|
||||
assert "create_table" in content
|
||||
@@ -230,7 +230,7 @@ def test_migration_rev_a_creates_assets_table() -> None:
|
||||
|
||||
def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "drop_column" in content
|
||||
@@ -241,7 +241,7 @@ def test_migration_rev_b_drops_symbol_and_market_columns() -> None:
|
||||
|
||||
def test_migration_rev_b_has_data_migration_insert() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev_b = next(f for f in os.listdir(migrations_dir) if "0018" in f)
|
||||
content = open(os.path.join(migrations_dir, rev_b)).read()
|
||||
assert "INSERT INTO assets" in content
|
||||
@@ -770,14 +770,14 @@ def test_portfolio_create_by_symbol() -> None:
|
||||
|
||||
def test_migration_assets_tag_ids_file_exists() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
matches = [f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f]
|
||||
assert len(matches) == 1, f"assets_tag_ids migration (0023) not found in {migrations_dir}"
|
||||
|
||||
|
||||
def test_migration_assets_tag_ids_adds_column_and_gin_index() -> None:
|
||||
import os
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "migrations", "versions")
|
||||
migrations_dir = os.path.join(os.path.dirname(__file__), "..", "app", "db", "alembic", "versions")
|
||||
rev = next(f for f in os.listdir(migrations_dir) if "0023" in f and "assets_tag_ids" in f)
|
||||
content = open(os.path.join(migrations_dir, rev)).read()
|
||||
# AC-1: tag_ids UUID[] NOT NULL DEFAULT '{}' on assets
|
||||
|
||||
+16
-1
@@ -783,14 +783,29 @@ def test_cli_write_command_can_abort() -> None:
|
||||
assert repo._portfolio is None
|
||||
|
||||
|
||||
def test_cli_health_check_reports_repository() -> None:
|
||||
def test_cli_health_check_reports_repository(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
monkeypatch.setattr(health_commands, "current_revision", lambda _settings: "20260814_0029")
|
||||
monkeypatch.setattr(health_commands, "head_revision", lambda _settings: "20260814_0029")
|
||||
|
||||
result = runner.invoke(runner.app, ["health-check"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "repository: FakeRepo" in result.stdout
|
||||
assert "database_revision: 20260814_0029" in result.stdout
|
||||
|
||||
|
||||
def test_cli_health_check_rejects_database_behind_packaged_head(monkeypatch) -> None:
|
||||
repo = FakeRepo(_portfolio())
|
||||
runner = build_runner(repo)
|
||||
monkeypatch.setattr(health_commands, "current_revision", lambda _settings: "20260715_0028")
|
||||
monkeypatch.setattr(health_commands, "head_revision", lambda _settings: "20260814_0029")
|
||||
|
||||
result = runner.invoke(runner.app, ["health-check"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "is not packaged head" in result.stdout
|
||||
|
||||
|
||||
def test_cli_init_db_reports_created(monkeypatch) -> None:
|
||||
|
||||
@@ -440,8 +440,8 @@ def test_create_without_user_fails() -> None:
|
||||
["portfolio", "create", "MY_PORT", "--asset-id", _TEST_ASSET_ID, "--strategy-id", "set-1", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--user" in result.output
|
||||
assert result.exit_code == 2
|
||||
assert repo._portfolios == []
|
||||
|
||||
|
||||
def test_create_with_unknown_user_fails() -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import patch
|
||||
|
||||
_MIGRATION_PATH = (
|
||||
Path(__file__).parents[1]
|
||||
/ "migrations/versions/20260814_0029_strategy_catalog_v1.py"
|
||||
/ "app/db/alembic/versions/20260814_0029_strategy_catalog_v1.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
|
||||
|
||||
@@ -592,7 +592,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "portfoliohub"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user