Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02bfa60299 | ||
|
|
ec912fcde8 | ||
|
|
75e55fde9c | ||
|
|
ee1af6ef49 | ||
|
|
fe2dd3d15e | ||
|
|
c292df0a4b | ||
|
|
e6a90800ec | ||
|
|
acfaab7fd4 | ||
|
|
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,69 @@
|
||||
name: Verify published PortfolioHub image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Strict release tag (for example v0.1.1)"
|
||||
required: true
|
||||
type: string
|
||||
commit:
|
||||
description: "Exact 40-character lowercase source commit"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Validate immutable selectors and image contract
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
COMMIT: ${{ inputs.commit }}
|
||||
IMAGE: ghcr.io/fourblocks2020/portfolio-hub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
|
||||
short_sha="${COMMIT:0:12}"
|
||||
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
|
||||
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
|
||||
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
|
||||
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
|
||||
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
|
||||
test "$version_digest" = "$source_digest"
|
||||
|
||||
docker pull "$IMAGE@$version_digest" >/dev/null
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
|
||||
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/portfolio-hub
|
||||
|
||||
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
|
||||
echo "mutable latest selector exists" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "digest=$version_digest"
|
||||
echo "version_selector=$IMAGE:$VERSION"
|
||||
echo "source_selector=$IMAGE:$short_sha"
|
||||
echo "immutable=$IMAGE@$version_digest"
|
||||
{
|
||||
echo "## Verified published PortfolioHub image"
|
||||
echo
|
||||
echo "- digest: \`$version_digest\`"
|
||||
echo "- version selector: \`$IMAGE:$VERSION\`"
|
||||
echo "- source selector: \`$IMAGE:$short_sha\`"
|
||||
echo "- source commit: \`$COMMIT\`"
|
||||
echo "- architecture: \`linux/amd64\`"
|
||||
echo "- runtime user: \`10001:10001\`"
|
||||
echo "- latest: absent"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
일반 사용자와 admin은 `/v1/strategies`의 목록·상세 조회에서 active 전략만 받을 수 있어야 하며, inactive 전략은 목록에서 제외하고 상세에서 전략 본문 대신 `409`를 반환하고 미존재 전략은 `404`를 반환해야 한다.
|
||||
일반 사용자와 admin은 `/v1/strategies`의 목록·상세 조회에서 active 전략만 받을 수 있어야 하며, 각 응답은 안정적인 ID, 사용자 표시용 `nick`, 내부 전략명 `name`, config, status와 revision을 반환해야 한다. inactive 전략은 목록에서 제외하고 상세에서 전략 본문 대신 `409`를 반환하며 미존재 전략은 `404`를 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: active 전략은 목록과 상세에서 안정적인 ID, 이름, config, status와 revision을 반환한다.
|
||||
- 성공 조건: active 전략은 목록과 상세에서 같은 ID, `nick`, `name`, config, status와 revision을 반환하며 `nick`과 `name`이 서로 달라도 각각 보존된다.
|
||||
- 실패·경계 조건: inactive 전략은 목록에서 제외되고 일반 상세 조회는 전략 본문 대신 `409`를 반환하며, 미존재 ID는 `404`다.
|
||||
- 검사 방법: 일반·admin token의 목록/상세 API 회귀 테스트로 active/inactive/missing을 각각 검사한다.
|
||||
- 현재 구현 평가: working tree의 `app/api/v1_strategies.py`와 `tests/test_v1_strategies.py`가 이 계약을 실행 가능하게 검증하며 scoped 재실행에 포함되어 PASS했다.
|
||||
- 검사 방법: 일반·admin token의 목록/상세 API 회귀 테스트로 `nick`과 `name`의 독립 보존 및 active/inactive/missing을 각각 검사한다.
|
||||
- 현재 구현 평가: `app/api/v1_strategies.py`와 `tests/test_v1_strategies.py`가 목록·상세 `nick` 응답과 active/inactive 경계를 실행 가능하게 검증하며 scoped 재실행에 포함되어 PASS했다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
admin은 `/v1/admin/strategies`에서 active·inactive 전체 전략을 조회하고 생성·수정·상태 변경·미참조 삭제할 수 있어야 하며, 비-admin 사용자의 모든 관리 mutation은 `403`이어야 한다.
|
||||
admin은 `/v1/admin/strategies`에서 active·inactive 전체 전략을 `nick`, `name`, config, status와 revision으로 조회하고 생성·수정·상태 변경·미참조 삭제할 수 있어야 하며, 비-admin 사용자의 모든 관리 mutation은 `403`이어야 한다. 동일 `(name, config)` POST는 effective `nick`과 status까지 같을 때만 idempotent 성공해야 하고 하나라도 다르면 기존 row를 바꾸지 않고 `409`를 반환하며, 기존 전략의 변경은 PATCH만 수행해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: admin CRUD와 상태 변경이 영속화되고 관리 조회가 active·inactive를 모두 반환한다.
|
||||
- 실패·경계 조건: 일반 사용자 또는 권한 부족 token의 create/update/delete는 변경 없이 `403`이다.
|
||||
- 검사 방법: 역할별 token으로 admin 목록·생성·PATCH·DELETE와 비-admin 거부를 API 테스트로 검사한다.
|
||||
- 현재 구현 평가: working tree의 모든 admin route가 공통 `require_admin` dependency를 사용하고 admin CRUD tests 및 비-admin 관리 GET `403` test가 scoped 재실행에서 PASS했다. 비-admin POST/PATCH/DELETE 각각의 독립 negative test는 현재 없어 mutation별 executable coverage는 `PARTIAL`이다.
|
||||
- 성공 조건: admin CRUD와 상태 변경이 영속화되고 관리 조회가 active·inactive의 `nick`, `name`, config, status와 revision을 반환하며 완전히 같은 POST 재시도는 기존 row와 revision을 유지한다.
|
||||
- 실패·경계 조건: 동일 `(name, config)`지만 effective `nick` 또는 status가 다른 POST는 `409`와 무변경이고, 일반 사용자 또는 권한 부족 token의 create/update/delete도 변경 없이 `403`이다.
|
||||
- 검사 방법: 역할별 token으로 admin 목록·생성·PATCH·DELETE, 동일 POST 재시도, 충돌 POST의 row·revision 무변경과 비-admin mutation 거부를 API 테스트로 검사한다.
|
||||
- 현재 구현 평가: admin route의 공통 `require_admin`, CRUD와 `nick` 응답 테스트는 존재한다. 다만 현재 create service는 동일 `(name, config)` row의 `nick`이 다르면 POST로 기존 row를 변경할 수 있고 비-admin POST/PATCH/DELETE 각각의 독립 negative test도 없어 이 AC는 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
admin PATCH는 기존 전략 row의 ID를 유지하면서 요청된 `name`·`config`·`status`의 실질적 변경을 원자적으로 반영하고 revision을 증가시켜야 하며, 모든 참조 포트폴리오의 이후 portfolio-hub API 조회는 commit된 최신 설정과 revision을 반환해야 한다.
|
||||
`nick`은 전략의 독립 저장 identity이며 trim 후 비어 있지 않고 전략 간 유일해야 한다. 생성에서 `nick`을 생략하면 기존 호환성을 위한 결정적 기본값을 만들고 explicit null 또는 blank는 거부해야 하며, PATCH에서 `nick`을 생략하면 `name`·`config` 변경과 관계없이 기존 값을 보존해야 한다. admin PATCH는 기존 전략 row의 ID를 유지하면서 요청된 `nick`·`name`·`config`·`status`의 실질적 변경을 원자적으로 반영하고 revision을 증가시켜야 하며, 모든 참조 포트폴리오의 이후 portfolio-hub API 조회는 commit된 최신 `nick`, 설정과 revision을 반환해야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 실질적 PATCH 뒤 ID는 동일하고 revision은 단조 증가하며 derived `nick`과 name/config가 같은 committed state에서 계산되고 참조 포트폴리오 조회에 최신 값이 나타난다.
|
||||
- 실패·경계 조건: no-op PATCH는 revision을 올리지 않고, 동시 partial PATCH도 서로의 최신 필드를 잃거나 불일치한 `nick`을 만들지 않는다.
|
||||
- 검사 방법: API/repository 회귀와 실제 PostgreSQL 동시 partial PATCH 시험으로 ID, revision, name/config/nick 및 포트폴리오 응답을 검사한다.
|
||||
- 현재 구현 평가: `FOR UPDATE` 기반 update와 revision 응답 테스트가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에 동시 partial PATCH 검증이 포함됐다.
|
||||
- 성공 조건: 사용자 지정 `nick` 생성과 `nick` 단독 PATCH 뒤 ID는 동일하고 revision은 단조 증가하며, `name`·`config`만 바꾼 PATCH는 기존 `nick`을 보존하고 참조 포트폴리오 조회에 최신 `nick`, 설정과 revision이 나타난다.
|
||||
- 실패·경계 조건: explicit null·blank·중복 `nick`은 무변경으로 거부되고 no-op PATCH는 revision을 올리지 않으며, 동시 partial PATCH도 명시적으로 바꾸지 않은 `nick`이나 다른 최신 필드를 잃지 않는다.
|
||||
- 검사 방법: API/repository 회귀와 실제 PostgreSQL 동시 partial PATCH 시험으로 omission/null/blank/중복, ID, revision, `nick` 보존·변경, name/config/status 및 포트폴리오 read-back을 검사한다.
|
||||
- 현재 구현 평가: `FOR UPDATE` 기반 update, 사용자 지정·trim·null/blank·중복·no-op·revision 테스트와 생략된 `nick` 보존 PostgreSQL 시험이 존재한다. 포트폴리오 `strategy_nick` read-back을 직접 단언하는 테스트는 없어 그 검증은 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성에는 active `strategy_id`가 필수이고 수정도 ID-only여야 하며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부하는 동시에 응답의 `active_strategy_id`·`strategy_name`·`strategy_params`·`strategy_revision`은 유지되어야 한다.
|
||||
`/v1/portfolios`와 legacy `/portfolios`의 생성에는 active `strategy_id`가 필수이고 수정도 ID-only여야 하며, 요청의 `strategy_name`·`strategy_params`는 `422`로 거부해야 한다. v1 `PortfolioItem` 응답은 현재 참조 전략의 `active_strategy_id`·`strategy_nick`·`strategy_name`·`strategy_params`·`strategy_revision`을 같은 committed row에서 반환해야 하고 참조가 없거나 해석할 수 없으면 `strategy_nick`은 `null`이어야 하며, legacy 응답의 기존 호환 필드는 유지되어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 명시한 active ID로만 포트폴리오가 생성·수정되고 응답에 현재 전략 identity·이름·config·revision이 나타난다.
|
||||
- 성공 조건: 명시한 active ID로만 포트폴리오가 생성·수정되고 v1 create/PATCH/list 응답에 현재 전략 ID·`nick`·이름·config·revision이 나타나며 참조가 없으면 `strategy_nick`은 `null`이다.
|
||||
- 실패·경계 조건: strategy ID 누락, 제거 필드 전달 또는 malformed 요청은 새 전략 row를 만들지 않고 `422`다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: schemas와 양쪽 portfolio API가 ID-only 입력 및 `extra="forbid"` 계약을 구현하고 scoped portfolio/CLI 회귀는 PASS했다. 다만 제거된 `strategy_name`·`strategy_params` 각각의 `422`와 응답 호환 필드 전체를 직접 단언하는 전용 negative/response test는 현재 없어 해당 executable coverage는 `PARTIAL`이다.
|
||||
- 검사 방법: v1·legacy create/update schema와 저장소 row count, v1 create/PATCH/list의 `strategy_nick` read-back, unbound `null` 및 기존 응답 호환 필드를 API/CLI 회귀로 검사한다.
|
||||
- 현재 구현 평가: schemas와 양쪽 portfolio API가 ID-only 입력 및 `extra="forbid"` 계약을 구현하고 v1 mapper가 `strategy_nick`을 반환한다. 다만 `strategy_nick` read-back, 제거된 `strategy_name`·`strategy_params` 각각의 `422`와 응답 호환 필드 전체를 직접 단언하는 전용 test가 없어 executable coverage는 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
전략 조회·선택·관리 API는 미존재 `404`, inactive 신규 선택과 참조 중 삭제 `409`, 제거 필드·malformed 입력 `422`, 권한 부족 `403`을 서로 구분해 반환해야 한다.
|
||||
전략 조회·선택·관리 API는 미존재 `404`, inactive 신규 선택·참조 중 삭제·중복 `nick` 및 충돌하는 동일 `(name, config)` POST는 `409`, 제거 필드·malformed 입력·explicit null 또는 trim 후 blank `nick`은 `422`, 권한 부족은 `403`으로 서로 구분해 반환해야 한다. create의 `nick` 생략은 호환 기본값 생성을 허용하고 PATCH의 `nick` 생략은 기존 값을 보존하며 모든 거부 요청은 row와 revision을 변경하지 않아야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 각 오류 조건이 승인된 상태 코드와 안정적인 비민감 오류 응답으로 구분된다.
|
||||
- 실패·경계 조건: 권한 부족을 `404`로, inactive를 `422`로, malformed 입력을 묵시적으로 무시하는 등 오류 클래스가 합쳐지지 않는다.
|
||||
- 검사 방법: 조건별 독립 API negative test로 status code와 무변경을 검사한다.
|
||||
- 현재 구현 평가: `tests/test_v1_strategies.py`와 `tests/test_v1_portfolios_crud.py`의 negative matrix가 scoped 재실행에서 PASS했다.
|
||||
- 성공 조건: omission, explicit null, blank, duplicate와 충돌 POST가 각각 승인된 성공 또는 오류 경계로 처리되고 오류 응답은 안정적이며 비민감하다.
|
||||
- 실패·경계 조건: 권한 부족을 `404`로, inactive·중복·충돌을 `422`로, null·blank를 묵시적 omission으로 합치거나 거부 요청이 row 또는 revision을 바꾸면 실패한다.
|
||||
- 검사 방법: 조건별 독립 API negative test로 status code, row identity, `nick`, status와 revision의 무변경을 검사한다.
|
||||
- 현재 구현 평가: `tests/test_v1_strategies.py`는 omission, explicit null, blank와 duplicate `nick` 경계를 검사하고 기존 negative matrix도 scoped 재실행에서 PASS했다. 충돌하는 동일 `(name, config)` POST의 `409`·무변경 계약은 현재 구현·테스트에 없어 이 부분은 `PARTIAL`이다.
|
||||
|
||||
@@ -7,11 +7,11 @@ status: active
|
||||
|
||||
## Criterion
|
||||
|
||||
전략 PATCH, 포트폴리오 전략 선택, 전략 상태 변경·삭제와 거래 저장이 경합해도 최신 전략 참조와 revision은 직렬화 가능하게 보존되고, stale portfolio snapshot은 현재 전략 선택을 되살리거나 덮어쓰지 않으며 portfolio mutation과 ledger 기록은 원자적이어야 한다.
|
||||
전략의 `nick`·`name`·`config`·`status` partial PATCH, 포트폴리오 전략 선택, 전략 상태 변경·삭제와 거래 저장이 경합해도 명시적으로 변경하지 않은 독립 catalog field, 최신 전략 참조와 revision은 직렬화 가능하게 보존되고, stale portfolio snapshot은 현재 전략 선택을 되살리거나 덮어쓰지 않으며 portfolio mutation과 ledger 기록은 원자적이어야 한다.
|
||||
|
||||
## Verification
|
||||
|
||||
- 성공 조건: 동시 partial PATCH는 모든 변경과 단조 revision을 보존하고, A snapshot 후 B 선택·A 삭제·stale 거래 저장에서도 B 참조와 A 삭제가 유지되며 portfolio UPDATE와 ledger INSERT가 함께 commit된다.
|
||||
- 성공 조건: `nick` PATCH와 name/config/status PATCH의 양방향 동시 실행은 모든 독립 변경과 단조 revision을 보존하고, A snapshot 후 B 선택·A 삭제·stale 거래 저장에서도 B 참조와 A 삭제가 유지되며 portfolio UPDATE와 ledger INSERT가 함께 commit된다.
|
||||
- 실패·경계 조건: stale write가 삭제된 A를 복원하거나 B를 잃지 않고, transaction 실패 시 portfolio와 ledger가 부분 commit되지 않는다.
|
||||
- 검사 방법: 실제 PostgreSQL에서 동시 PATCH, select-vs-inactivate/delete, stale BUY/SELL snapshot과 transaction rollback 시나리오를 실행한다.
|
||||
- 현재 구현 평가: repository transaction/row-lock 경계와 integration tests가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에서 동시 PATCH, 선택-vs-delete, stale BUY snapshot과 migration 시나리오를 확인했다. 독립 source-time QC도 PASS를 기록한다. 다만 portfolio UPDATE 성공 후 ledger INSERT 실패를 의도적으로 주입해 rollback을 관찰하는 fault-injection test는 현재 없어 그 세부 검증은 `PARTIAL`이다.
|
||||
- 검사 방법: 실제 PostgreSQL에서 `nick`과 다른 catalog field의 양방향 동시 PATCH, select-vs-inactivate/delete, stale BUY/SELL snapshot과 transaction rollback 시나리오를 실행한다.
|
||||
- 현재 구현 평가: repository transaction/row-lock 경계와 integration tests가 존재하며, 2026-08-15 실제 PostgreSQL integration 재실행 `4 passed`에서 name/config 동시 PATCH 중 기존 `nick` 보존, 선택-vs-delete, stale BUY snapshot과 migration 시나리오를 확인했다. 명시적 `nick` PATCH와 다른 field PATCH의 양방향 경합 및 portfolio UPDATE 성공 후 ledger INSERT 실패 fault-injection test는 없어 해당 검증은 `PARTIAL`이다.
|
||||
|
||||
+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"]
|
||||
@@ -108,6 +108,7 @@ def _portfolio_to_item(
|
||||
# REQ-055: 전략·시장 메타데이터 additive 필드
|
||||
active_strategy_id=portfolio.active_strategy_id,
|
||||
strategy_name=portfolio.strategy_name,
|
||||
strategy_nick=portfolio.strategy_nick,
|
||||
strategy_params=portfolio.strategy_params,
|
||||
strategy_revision=portfolio.strategy_revision,
|
||||
markets=portfolio.markets,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import NoReturn
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
|
||||
@@ -11,7 +12,11 @@ from app.core.config import Settings, get_settings
|
||||
from app.domain.models import StrategySet
|
||||
from app.repositories.factory import get_portfolio_repository_from_settings
|
||||
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
|
||||
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
|
||||
from app.schemas.v1 import (
|
||||
StrategyCreateRequest,
|
||||
StrategyResponse,
|
||||
StrategyUpdateRequest,
|
||||
)
|
||||
from app.services.strategy_set_service import (
|
||||
StrategyConflictError,
|
||||
StrategyInactiveError,
|
||||
@@ -44,6 +49,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
||||
parsed = {}
|
||||
return StrategyResponse(
|
||||
strategy_id=strategy.id,
|
||||
nick=strategy.nick,
|
||||
name=strategy.name,
|
||||
config=parsed if isinstance(parsed, dict) else {},
|
||||
status=strategy.status,
|
||||
@@ -53,7 +59,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
|
||||
)
|
||||
|
||||
|
||||
def _raise_strategy_error(exc: ValueError) -> None:
|
||||
def _raise_strategy_error(exc: ValueError) -> NoReturn:
|
||||
if isinstance(exc, StrategyNotFoundError):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
|
||||
@@ -129,14 +135,18 @@ def create_admin_strategy(
|
||||
) -> StrategyResponse:
|
||||
service, repository = _get_service(settings)
|
||||
try:
|
||||
strategy = service.create(
|
||||
CreateStrategySetRequest(
|
||||
name=body.name,
|
||||
config=_canonical_config(body.config),
|
||||
status=body.status,
|
||||
try:
|
||||
strategy = service.create(
|
||||
CreateStrategySetRequest(
|
||||
name=body.name,
|
||||
nick=body.nick,
|
||||
config=_canonical_config(body.config),
|
||||
status=body.status,
|
||||
)
|
||||
)
|
||||
)
|
||||
return _response(strategy)
|
||||
return _response(strategy)
|
||||
except ValueError as exc:
|
||||
_raise_strategy_error(exc)
|
||||
finally:
|
||||
_close_repository(repository)
|
||||
|
||||
@@ -155,6 +165,7 @@ def update_admin_strategy(
|
||||
strategy_id,
|
||||
UpdateStrategySetRequest(
|
||||
name=body.name,
|
||||
nick=body.nick,
|
||||
config=_canonical_config(body.config) if body.config is not None else None,
|
||||
status=body.status,
|
||||
),
|
||||
|
||||
@@ -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.3"
|
||||
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:
|
||||
|
||||
@@ -120,6 +120,7 @@ class Portfolio:
|
||||
profit_buffer: float = 0.0
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None # REQ-055: strategy_sets.name JOIN 결과
|
||||
strategy_nick: str | None = None # REQ-055: strategy_sets.nick JOIN 결과
|
||||
strategy_params: dict[str, Any] = field(default_factory=dict) # REQ-055: strategy_sets.config 파싱 결과
|
||||
strategy_revision: int | None = None
|
||||
markets: list[str] = field(default_factory=list) # REQ-055: assets.markets JOIN 결과
|
||||
|
||||
@@ -243,7 +243,14 @@ class PortfolioRepository(Protocol):
|
||||
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet: ...
|
||||
def create_strategy_set(
|
||||
self,
|
||||
name: str,
|
||||
config: str,
|
||||
*,
|
||||
nick: str | None = None,
|
||||
status: str = "active",
|
||||
) -> StrategySet: ...
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None: ...
|
||||
|
||||
@@ -254,6 +261,7 @@ class PortfolioRepository(Protocol):
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
nick: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet: ...
|
||||
|
||||
@@ -73,7 +73,8 @@ _SELECT_PORTFOLIO = """
|
||||
ss.name,
|
||||
ss.config,
|
||||
a.markets,
|
||||
ss.revision
|
||||
ss.revision,
|
||||
ss.nick
|
||||
FROM portfolios p
|
||||
JOIN assets a ON p.asset_id = a.asset_id
|
||||
LEFT JOIN strategy_sets ss ON p.active_strategy_id = ss.id
|
||||
@@ -572,6 +573,8 @@ class PostgresPortfolioRepository:
|
||||
_markets_raw = row[21] if len(row) > 21 else None
|
||||
markets: list[str] = list(_markets_raw) if _markets_raw else []
|
||||
strategy_revision = int(row[22]) if len(row) > 22 and row[22] is not None else None
|
||||
# REQ-055: strategy_nick (row[23] = ss.nick from LEFT JOIN strategy_sets)
|
||||
strategy_nick: str | None = row[23] if len(row) > 23 else None
|
||||
|
||||
return Portfolio(
|
||||
portfolio_id=row[0],
|
||||
@@ -595,6 +598,7 @@ class PostgresPortfolioRepository:
|
||||
tag_ids=[str(t) for t in row[17]] if len(row) > 17 and row[17] is not None else [],
|
||||
display_name=row[18] if len(row) > 18 else None,
|
||||
strategy_name=strategy_name,
|
||||
strategy_nick=strategy_nick,
|
||||
strategy_params=strategy_params,
|
||||
strategy_revision=strategy_revision,
|
||||
markets=markets,
|
||||
@@ -1360,28 +1364,38 @@ class PostgresPortfolioRepository:
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self.create_strategy_set(name, config)
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
|
||||
def create_strategy_set(
|
||||
self,
|
||||
name: str,
|
||||
config: str,
|
||||
*,
|
||||
nick: str | None = None,
|
||||
status: str = "active",
|
||||
) -> StrategySet:
|
||||
self.ensure_schema()
|
||||
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
|
||||
with self._connect().cursor() as cur:
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO strategy_sets (id, name, config, nick, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (name, config) DO NOTHING
|
||||
""",
|
||||
(new_id, name, config, nick, status),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, config, nick, status, revision, created_at, updated_at
|
||||
FROM strategy_sets
|
||||
WHERE name = %s AND config = %s
|
||||
""",
|
||||
(name, config),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
final_nick = nick or f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
|
||||
try:
|
||||
with self._connect().cursor() as cur:
|
||||
new_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO strategy_sets (id, name, config, nick, status)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (name, config) DO NOTHING
|
||||
""",
|
||||
(new_id, name, config, final_nick, status),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, config, nick, status, revision, created_at, updated_at
|
||||
FROM strategy_sets
|
||||
WHERE name = %s AND config = %s
|
||||
""",
|
||||
(name, config),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError("동일한 nick 전략이 이미 존재합니다") from exc
|
||||
return self._row_to_strategy_set(row)
|
||||
|
||||
def get_strategy_set(self, id: str) -> StrategySet | None:
|
||||
@@ -1414,6 +1428,7 @@ class PostgresPortfolioRepository:
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
nick: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet:
|
||||
@@ -1435,15 +1450,16 @@ class PostgresPortfolioRepository:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
current = self._row_to_strategy_set(row)
|
||||
final_name = name if name is not None else current.name
|
||||
final_nick = nick if nick is not None else current.nick
|
||||
final_config = config if config is not None else current.config
|
||||
final_status = status if status is not None else current.status
|
||||
if (
|
||||
final_name == current.name
|
||||
and final_nick == current.nick
|
||||
and final_config == current.config
|
||||
and final_status == current.status
|
||||
):
|
||||
return current
|
||||
nick = f"{final_name}:{hashlib.sha1(final_config.encode('utf-8')).hexdigest()[:12]}"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE strategy_sets
|
||||
@@ -1456,11 +1472,11 @@ class PostgresPortfolioRepository:
|
||||
WHERE id = %s
|
||||
RETURNING id, name, config, nick, status, revision, created_at, updated_at
|
||||
""",
|
||||
(final_name, final_config, nick, final_status, id),
|
||||
(final_name, final_config, final_nick, final_status, id),
|
||||
)
|
||||
updated = cur.fetchone()
|
||||
except psycopg.errors.UniqueViolation as exc:
|
||||
raise ValueError("동일한 name/config 전략이 이미 존재합니다") from exc
|
||||
raise ValueError("동일한 name/config 또는 nick 전략이 이미 존재합니다") from exc
|
||||
if updated is None:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
return self._row_to_strategy_set(updated)
|
||||
|
||||
@@ -186,6 +186,7 @@ class CreateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: str
|
||||
status: Literal["active", "inactive"] = "active"
|
||||
|
||||
@@ -194,6 +195,7 @@ class UpdateStrategySetRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = None
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: str | None = None
|
||||
status: Literal["active", "inactive"] | None = None
|
||||
|
||||
|
||||
+25
-1
@@ -6,7 +6,7 @@ from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -286,6 +286,7 @@ class PortfolioItem(BaseModel):
|
||||
# REQ-055: 전략·시장 메타데이터 additive 필드
|
||||
active_strategy_id: str | None = None
|
||||
strategy_name: str | None = None
|
||||
strategy_nick: str | None = None
|
||||
strategy_params: dict[str, Any] = Field(default_factory=dict)
|
||||
strategy_revision: int | None = Field(default=None, ge=1)
|
||||
markets: list[str] = Field(default_factory=list)
|
||||
@@ -305,6 +306,7 @@ StrategyStatus = Literal["active", "inactive"]
|
||||
|
||||
class StrategyResponse(BaseModel):
|
||||
strategy_id: str
|
||||
nick: str
|
||||
name: str
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus
|
||||
@@ -317,17 +319,39 @@ class StrategyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(..., min_length=1)
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
status: StrategyStatus = "active"
|
||||
|
||||
@field_validator("nick")
|
||||
@classmethod
|
||||
def normalize_nick(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
raise ValueError("nick must not be null")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("nick must not be blank")
|
||||
return normalized
|
||||
|
||||
|
||||
class StrategyUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str | None = Field(default=None, min_length=1)
|
||||
nick: str | None = Field(default=None, min_length=1)
|
||||
config: dict[str, Any] | None = None
|
||||
status: StrategyStatus | None = None
|
||||
|
||||
@field_validator("nick")
|
||||
@classmethod
|
||||
def normalize_nick(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
raise ValueError("nick must not be null")
|
||||
normalized = value.strip()
|
||||
if not normalized:
|
||||
raise ValueError("nick must not be blank")
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def require_change(self) -> "StrategyUpdateRequest":
|
||||
if not self.model_fields_set:
|
||||
|
||||
@@ -46,26 +46,49 @@ class StrategySetService:
|
||||
return self.get(strategy_set_id, active_only=True)
|
||||
|
||||
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
|
||||
create_strategy = cast(
|
||||
Callable[..., StrategySet] | None,
|
||||
getattr(self._repository, "create_strategy_set", None),
|
||||
)
|
||||
if create_strategy is not None:
|
||||
strategy = create_strategy(payload.name, payload.config, status=payload.status)
|
||||
else:
|
||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
if payload.status != strategy.status:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(status=payload.status),
|
||||
try:
|
||||
create_strategy = cast(
|
||||
Callable[..., StrategySet] | None,
|
||||
getattr(self._repository, "create_strategy_set", None),
|
||||
)
|
||||
return strategy
|
||||
if create_strategy is not None:
|
||||
strategy = create_strategy(
|
||||
payload.name,
|
||||
payload.config,
|
||||
nick=payload.nick,
|
||||
status=payload.status,
|
||||
)
|
||||
else:
|
||||
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
|
||||
if payload.nick is not None and payload.nick != strategy.nick:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(nick=payload.nick),
|
||||
)
|
||||
if payload.status != strategy.status:
|
||||
strategy = self.update(
|
||||
strategy.id,
|
||||
UpdateStrategySetRequest(status=payload.status),
|
||||
)
|
||||
return strategy
|
||||
except StrategyConflictError:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise StrategyConflictError(str(exc)) from exc
|
||||
|
||||
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
|
||||
try:
|
||||
if payload.nick is None:
|
||||
return self._repository.update_strategy_set(
|
||||
strategy_set_id,
|
||||
name=payload.name,
|
||||
config=payload.config,
|
||||
status=payload.status,
|
||||
)
|
||||
return self._repository.update_strategy_set(
|
||||
strategy_set_id,
|
||||
name=payload.name,
|
||||
nick=payload.nick,
|
||||
config=payload.config,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# PortfolioHub v0.1.3 container contract
|
||||
|
||||
## Publication is not deployment
|
||||
|
||||
Only a requester-authorized push of the strict tag `v0.1.3` 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.3`
|
||||
|
||||
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.3`. 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.3 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.3 | `0.1.3` | 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.3 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.3 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.3`.
|
||||
|
||||
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.
|
||||
|
||||
v0.1.3 introduces no schema migration: the packaged inventory remains exactly 29 migration files and the single head remains `20260814_0029`. Operators already at `0029` must not expect or manufacture a new revision for this patch release.
|
||||
|
||||
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.3 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.
|
||||
|
||||
The strategy catalog API remains additive: strategy responses expose `nick`, admin create/update requests may set it, and portfolio responses expose the current strategy as `strategy_nick`. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.3 displays `strategy_nick`, but that dependency does not resolve or waive the separate caller blockers below.
|
||||
|
||||
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.3"
|
||||
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.3"\n')
|
||||
assert image_release.project_release_version(project) == "v0.1.3"
|
||||
assert image_release.validate_release_event("push", "refs/tags/v0.1.3", COMMIT, "v0.1.3") == "v0.1.3"
|
||||
with pytest.raises(ValueError, match="project version"):
|
||||
image_release.validate_release_event("push", "refs/tags/v0.1.4", COMMIT, "v0.1.3")
|
||||
|
||||
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,100 @@
|
||||
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.3' not in verify
|
||||
|
||||
|
||||
def test_release_version_and_migration_contract_are_v013_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.3"' in project
|
||||
assert 'app_version: str = "0.1.3"' 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",
|
||||
"PortfolioHub v0.1.3 container contract",
|
||||
"v0.1.3 introduces no schema migration",
|
||||
"trex-web v0.2.3 displays `strategy_nick`",
|
||||
):
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ PostgreSQL endpoint. Normal unit-test environments skip this module.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -73,9 +72,12 @@ def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, st
|
||||
return strategy.id, asset_id, owner_user_id
|
||||
|
||||
|
||||
def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
|
||||
def test_concurrent_strategy_patch_preserves_existing_nick() -> None:
|
||||
owner = _repo()
|
||||
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
|
||||
initial = owner.get_strategy_set(strategy_id)
|
||||
assert initial is not None
|
||||
initial_nick = initial.nick
|
||||
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
|
||||
barrier = threading.Barrier(3)
|
||||
errors: list[BaseException] = []
|
||||
@@ -114,8 +116,7 @@ def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
|
||||
assert final.name == renamed
|
||||
assert final.config == '{"window":21}'
|
||||
assert final.revision == 3
|
||||
expected_nick = f"{final.name}:{hashlib.sha1(final.config.encode()).hexdigest()[:12]}"
|
||||
assert final.nick == expected_nick
|
||||
assert final.nick == initial_nick
|
||||
portfolio_id = str(uuid.uuid4())
|
||||
owner.create_portfolio(
|
||||
portfolio_id,
|
||||
@@ -288,7 +289,7 @@ def test_alembic_upgrade_is_repeatable_on_fresh_postgres_database() -> None:
|
||||
)
|
||||
for _ in range(2):
|
||||
subprocess.run(
|
||||
[str(_ROOT / ".venv/bin/python"), "-m", "alembic", "upgrade", "head"],
|
||||
[str(_ROOT / ".venv/bin/pfhub"), "migrate-db"],
|
||||
cwd=_ROOT,
|
||||
env=env,
|
||||
check=True,
|
||||
|
||||
@@ -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
|
||||
|
||||
+128
-9
@@ -43,19 +43,29 @@ class FakeStrategyRepo:
|
||||
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
|
||||
return self.create_strategy_set(name, config)
|
||||
|
||||
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
|
||||
def create_strategy_set(
|
||||
self,
|
||||
name: str,
|
||||
config: str,
|
||||
*,
|
||||
nick: str | None = None,
|
||||
status: str = "active",
|
||||
) -> StrategySet:
|
||||
existing = next(
|
||||
(item for item in self.strategies.values() if item.name == name and item.config == config),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
return existing
|
||||
final_nick = nick or f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}"
|
||||
if any(item.nick == final_nick for item in self.strategies.values()):
|
||||
raise ValueError("동일한 nick 전략이 이미 존재합니다")
|
||||
now = datetime.now(timezone.utc)
|
||||
item = StrategySet(
|
||||
id=f"created-{len(self.strategies)}",
|
||||
name=name,
|
||||
config=config,
|
||||
nick=f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}",
|
||||
nick=final_nick,
|
||||
status=status,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -68,6 +78,7 @@ class FakeStrategyRepo:
|
||||
id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
nick: str | None = None,
|
||||
config: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> StrategySet:
|
||||
@@ -75,19 +86,27 @@ class FakeStrategyRepo:
|
||||
if item is None:
|
||||
raise ValueError("전략을 찾을 수 없습니다")
|
||||
final_name = name if name is not None else item.name
|
||||
final_nick = nick if nick is not None else item.nick
|
||||
final_config = config if config is not None else item.config
|
||||
if any(
|
||||
other.id != id and other.name == final_name and other.config == final_config
|
||||
for other in self.strategies.values()
|
||||
):
|
||||
raise ValueError("동일한 name/config 전략이 이미 존재합니다")
|
||||
if any(other.id != id and other.nick == final_nick for other in self.strategies.values()):
|
||||
raise ValueError("동일한 nick 전략이 이미 존재합니다")
|
||||
final_status = status if status is not None else item.status
|
||||
if (final_name, final_config, final_status) == (item.name, item.config, item.status):
|
||||
if (final_name, final_nick, final_config, final_status) == (
|
||||
item.name,
|
||||
item.nick,
|
||||
item.config,
|
||||
item.status,
|
||||
):
|
||||
return item
|
||||
item.name = final_name
|
||||
item.nick = final_nick
|
||||
item.config = final_config
|
||||
item.status = final_status
|
||||
item.nick = f"{final_name}:{hashlib.sha1(final_config.encode()).hexdigest()[:12]}"
|
||||
item.revision += 1
|
||||
item.updated_at = datetime.now(timezone.utc)
|
||||
return item
|
||||
@@ -126,9 +145,13 @@ def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> Non
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
response = _client().get("/v1/strategies", headers=_auth("read-token"))
|
||||
active = _client().get("/v1/strategies/active-1", headers=_auth("read-token"))
|
||||
inactive = _client().get("/v1/strategies/inactive-1", headers=_auth("read-token"))
|
||||
assert response.status_code == 200
|
||||
assert [item["strategy_id"] for item in response.json()] == ["active-1"]
|
||||
assert response.json()[0]["nick"] == "target_value:base"
|
||||
assert active.status_code == 200
|
||||
assert active.json()["nick"] == "target_value:base"
|
||||
assert inactive.status_code == 409
|
||||
|
||||
|
||||
@@ -141,32 +164,128 @@ def test_admin_catalog_requires_admin_and_reports_missing_strategy() -> None:
|
||||
assert missing.status_code == 404
|
||||
|
||||
|
||||
def test_admin_create_and_patch_preserve_id_and_revision_semantics() -> None:
|
||||
def test_admin_create_custom_nick_and_patch_nick_only_preserve_id_and_increment_revision() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "momentum", "config": {"window": 20}},
|
||||
json={"name": "momentum", "nick": "mom-20", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
strategy_id = created.json()["strategy_id"]
|
||||
changed = _client().patch(
|
||||
f"/v1/admin/strategies/{strategy_id}",
|
||||
json={"name": "momentum-v2"},
|
||||
json={"nick": "mom-primary"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
no_op = _client().patch(
|
||||
f"/v1/admin/strategies/{strategy_id}",
|
||||
json={"name": "momentum-v2"},
|
||||
json={"nick": "mom-primary"},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["nick"] == "mom-20"
|
||||
assert created.json()["revision"] == 1
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["strategy_id"] == strategy_id
|
||||
assert changed.json()["nick"] == "mom-primary"
|
||||
assert changed.json()["revision"] == 2
|
||||
assert no_op.json()["revision"] == 2
|
||||
assert repo.strategies[strategy_id].nick.startswith("momentum-v2:")
|
||||
assert repo.strategies[strategy_id].nick == "mom-primary"
|
||||
|
||||
|
||||
def test_admin_create_trims_nick() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "momentum", "nick": " mom-20 ", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 201
|
||||
assert created.json()["nick"] == "mom-20"
|
||||
|
||||
|
||||
def test_admin_create_and_patch_reject_whitespace_only_nick() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "momentum", "nick": " \t ", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
changed = _client().patch(
|
||||
"/v1/admin/strategies/active-1",
|
||||
json={"nick": " \n "},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 422
|
||||
assert changed.status_code == 422
|
||||
assert repo.strategies["active-1"].nick == "target_value:base"
|
||||
|
||||
|
||||
def test_admin_patch_omitting_nick_preserves_custom_nick_when_name_and_config_change() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "momentum", "nick": "mom-custom", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
strategy_id = created.json()["strategy_id"]
|
||||
changed = _client().patch(
|
||||
f"/v1/admin/strategies/{strategy_id}",
|
||||
json={"name": "momentum-v2", "config": {"window": 30}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert changed.status_code == 200
|
||||
assert changed.json()["nick"] == "mom-custom"
|
||||
assert changed.json()["revision"] == 2
|
||||
|
||||
|
||||
def test_admin_create_omitting_nick_retains_legacy_generated_value() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "legacy", "config": {"window": 20}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
canonical_config = '{"window":20}'
|
||||
expected = f"legacy:{hashlib.sha1(canonical_config.encode()).hexdigest()[:12]}"
|
||||
assert created.status_code == 201
|
||||
assert created.json()["nick"] == expected
|
||||
|
||||
|
||||
def test_admin_create_and_patch_reject_explicit_null_nick_without_mutation() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
created = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "legacy-null", "nick": None, "config": {}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
changed = _client().patch(
|
||||
"/v1/admin/strategies/active-1",
|
||||
json={"nick": None},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert created.status_code == 422
|
||||
assert changed.status_code == 422
|
||||
assert set(repo.strategies) == {"active-1", "inactive-1"}
|
||||
assert repo.strategies["active-1"].nick == "target_value:base"
|
||||
assert repo.strategies["active-1"].revision == 1
|
||||
|
||||
|
||||
def test_admin_duplicate_nick_returns_conflict() -> None:
|
||||
repo = FakeStrategyRepo()
|
||||
with _repos(repo):
|
||||
duplicate = _client().post(
|
||||
"/v1/admin/strategies",
|
||||
json={"name": "duplicate", "nick": "target_value:base", "config": {}},
|
||||
headers=_auth("admin-token"),
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
|
||||
|
||||
def test_admin_create_inactive_starts_at_revision_one() -> None:
|
||||
|
||||
@@ -592,7 +592,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "portfoliohub"
|
||||
version = "0.1.0"
|
||||
version = "0.1.3"
|
||||
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