diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e46dff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.env +.env.* +config/ +.proofline/ +.github/ +.pytest_cache/ +.ruff_cache/ +.venv/ +docs/ +scripts/ +tests/ +temp/ +__pycache__/ +*.egg-info/ +*.pyc diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml new file mode 100644 index 0000000..200bf29 --- /dev/null +++ b/.github/workflows/publish-image.yml @@ -0,0 +1,128 @@ +name: Publish verified container image + +on: + push: + branches: + - main + tags: + - "v*" + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Check out exact source commit + uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install repository checks + run: python -m pip install ".[dev]" + + - name: Run repository lint + run: ruff check . + + - name: Run repository tests + run: pytest + + - name: Create excluded synthetic canary + run: | + python - <<'PY' + from pathlib import Path + Path(".env.ci-canary").write_text("TREX_SECRET_CANARY_DO_NOT_SHIP\n", encoding="ascii") + PY + + - name: Build source image once + run: | + docker build \ + --provenance=false \ + --build-arg "SOURCE_COMMIT=${GITHUB_SHA}" \ + --tag "trex-core-ci:${GITHUB_SHA}" \ + . + + - name: Smoke built image + run: python scripts/image_release.py smoke --image "trex-core-ci:${GITHUB_SHA}" + + - name: Record tested image ID + run: | + python scripts/image_release.py record-image-id \ + --image "trex-core-ci:${GITHUB_SHA}" \ + --output trex-core-image.id + + - name: Export tested image + run: docker save --output trex-core-image.tar "trex-core-ci:${GITHUB_SHA}" + + - name: Scan all canary surfaces + run: | + set +e + python scripts/image_release.py scan \ + --image "trex-core-ci:${GITHUB_SHA}" \ + --archive trex-core-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}" + + - name: Record tested archive checksum + run: | + python scripts/image_release.py checksum \ + --archive trex-core-image.tar \ + --output trex-core-image.tar.sha256 + + - name: Upload immutable tested image artifact + uses: actions/upload-artifact@v4 + with: + name: tested-image-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: error + overwrite: false + retention-days: 7 + path: | + trex-core-image.tar + trex-core-image.tar.sha256 + trex-core-image.id + scripts/image_release.py + + publish: + needs: verify + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.publish.outputs.image }} + digest: ${{ steps.publish.outputs.digest }} + source_commit: ${{ steps.publish.outputs.source_commit }} + steps: + - name: Download tested image artifact + uses: actions/download-artifact@v4 + with: + name: tested-image-${{ github.run_id }}-${{ github.run_attempt }} + path: tested-image + + - name: Verify, load, and publish without rebuilding + id: publish + env: + GITHUB_ACTOR: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python tested-image/image_release.py publish \ + --archive tested-image/trex-core-image.tar \ + --checksum tested-image/trex-core-image.tar.sha256 \ + --image-id tested-image/trex-core-image.id \ + --local-image "trex-core-ci:${GITHUB_SHA}" \ + --commit "${GITHUB_SHA}" \ + --ref "${GITHUB_REF}" diff --git a/.proofline/criteria/ac-0004.md b/.proofline/criteria/ac-0004.md new file mode 100644 index 0000000..1ed714f --- /dev/null +++ b/.proofline/criteria/ac-0004.md @@ -0,0 +1,16 @@ +--- +id: "ac-0004" +status: active +--- + +# 검증 성공 이미지에 한정된 GHCR 게시 + +## Criterion + +`main` 또는 `v*` tag push의 정확한 source commit에 대해 repository가 정의한 검증 gate, image build, built-container smoke와 tested-image artifact 생성·전달·checksum·load 검증이 모두 성공한 경우에만 GitHub Actions가 registry login/push 단계에 진입해야 한다. 이 push 전 gate 중 하나라도 실패하면 registry write 없이 fail-closed 해야 한다. push 이후에만 반환되는 digest가 누락되거나 형식이 잘못되면 job은 nonzero로 종료하고 성공 output·summary를 남기지 않아야 하며, 이미 발생한 registry write를 발생하지 않은 것으로 보고해서는 안 된다. + +## Verification + +- 성공 조건: synthetic workflow fixture에서 모든 push 전 gate가 성공한 경우에만 publish job의 login/push가 실행 가능하고, push가 반환한 유효한 digest가 확인된 경우에만 성공 output과 summary가 생성된다. +- 실패·경계 조건: repository 검증, build, smoke, artifact 생성·upload·download, checksum 비교 또는 load 실패는 login/push 전 nonzero로 종료된다. push 후 digest 누락·형식 오류는 job nonzero와 성공 output·summary 부재로 이어진다. +- 검사 방법: workflow parser 기반 구조 테스트와 publication helper의 recording-runner 단위 테스트로 push 전/후 실패 시점을 분리해 검증하고, `publish.needs` 및 `packages: write`의 publish-job 한정을 정적 검사한다. 실제 최초 GHCR 실행은 별도 승인된 `main` 반영 이후 integration/release evidence로 확인한다. diff --git a/.proofline/criteria/ac-0005.md b/.proofline/criteria/ac-0005.md new file mode 100644 index 0000000..8fedef3 --- /dev/null +++ b/.proofline/criteria/ac-0005.md @@ -0,0 +1,16 @@ +--- +id: "ac-0005" +status: active +--- + +# 검증 이미지와 게시 이미지의 동일성 + +## Criterion + +verify job은 한 번 빌드하여 built-container smoke를 통과한 `trex-core` 이미지를 archive로 export하고 archive bytes의 SHA-256을 기록한 뒤 image archive와 checksum을 immutable workflow artifact로 전달해야 한다. publish job은 download한 archive의 SHA-256을 load 전에 비교하고 일치하는 archive만 load·tag·push해야 한다. publish job에서 source를 다시 checkout하거나 `docker build` 또는 동등한 재빌드를 수행해서는 안 된다. + +## Verification + +- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다. +- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다. +- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다. diff --git a/.proofline/criteria/ac-0006.md b/.proofline/criteria/ac-0006.md new file mode 100644 index 0000000..ee611b7 --- /dev/null +++ b/.proofline/criteria/ac-0006.md @@ -0,0 +1,16 @@ +--- +id: "ac-0006" +status: active +--- + +# stable SHA tag와 immutable digest 추적성 + +## Criterion + +모든 성공 게시 이미지는 전체 40자리 lowercase hexadecimal source commit에서 결정적으로 파생된 앞 12자리 lowercase SHA tag와 전체 commit 값을 담은 `org.opencontainers.image.revision` label을 가져야 한다. SHA tag는 source를 찾기 위한 stable selector이며 배포·rollback의 불변 정본은 registry가 반환한 `sha256` digest다. `v*` tag event에서는 같은 loaded image에 해당 Git tag를 추가하지만 이동 가능한 `latest` tag는 게시하지 않아야 하며, workflow output 또는 summary는 GHCR image reference, 64-hex digest와 전체 source commit을 함께 기록해야 한다. + +## Verification + +- 성공 조건: `main` event는 source commit 앞 12자리 SHA tag를, `v*` event는 같은 loaded image의 SHA tag와 release tag를 생성한다. OCI revision과 summary/output의 image reference·`sha256` digest·전체 source commit이 해당 실행 입력 및 push 결과와 일치한다. +- 실패·경계 조건: source commit 형식 오류, SHA tag 파생 불일치, 빈/형식 오류 digest, source commit 불일치, release tag용 별도 build, `latest` tag 생성 또는 OCI revision 누락은 성공 output·summary를 만들 수 없다. digest 오류가 push 이후 발견되면 job은 nonzero로 종료한다. +- 검사 방법: synthetic `main`/`v*` event와 유효·무효 commit/digest를 publication helper에 입력하는 단위 테스트, Docker image label inspection, workflow의 tag/summary 정적 검사 및 `latest` 생성 금지 회귀 검사를 실행한다. diff --git a/.proofline/criteria/ac-0007.md b/.proofline/criteria/ac-0007.md new file mode 100644 index 0000000..680259c --- /dev/null +++ b/.proofline/criteria/ac-0007.md @@ -0,0 +1,16 @@ +--- +id: "ac-0007" +status: active +--- + +# built-container package 및 entrypoint smoke + +## Criterion + +GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에서 `python -c "import trex_core"`와 `trex-core-daemon --help`, `trex-core --help`, `trex-core-reset --help`를 각각 실행하여 package와 세 console entrypoint가 설치되어 있음을 입증해야 한다. 각 probe는 exit code `0`이어야 하며 daemon setup, migration, DB·RabbitMQ·Redis·portfolio-hub·KIS 연결, 실제 주문 또는 credential 해석을 수행해서는 안 된다. + +## Verification + +- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 네 probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. +- 실패·경계 조건: package import 실패, entrypoint 누락, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다. +- 검사 방법: built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 네 command, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하고 CI에서 실제 local image smoke를 실행한다. diff --git a/.proofline/criteria/ac-0008.md b/.proofline/criteria/ac-0008.md new file mode 100644 index 0000000..fc28f50 --- /dev/null +++ b/.proofline/criteria/ac-0008.md @@ -0,0 +1,16 @@ +--- +id: "ac-0008" +status: active +--- + +# 운영 설정과 자격증명의 이미지 비포함 + +## Criterion + +`trex-core` Dockerfile은 package 설치에 필요한 `pyproject.toml`과 `trex_core/`만 application source로 copy하고 repository 전체를 copy하지 않아야 한다. `.dockerignore`는 최소한 `.git`, `.env`, `.env.*`, `config/`와 `.proofline/`을 build context에서 제외해야 한다. image filesystem·history·inspect metadata와 export archive에는 운영 per-instance TOML, `.env`, credential, token, private key, 실제 connection string 또는 synthetic canary marker가 없어야 한다. daemon의 필수 config와 secret은 runtime에 read-only mount, secret 또는 environment로만 주입하고, 문서는 digest pin과 이전 digest rollback을 안내하며 `trex-core-reset`을 자동 rollback 절차에 포함하지 않아야 한다. + +## Verification + +- 성공 조건: 임시 build context의 제외 대상 `.env.ci-canary`에 고정 marker `TREX_SECRET_CANARY_DO_NOT_SHIP`을 둔 상태로 이미지를 빌드해도 marker가 image filesystem, `docker history --no-trunc`, `docker image inspect` 결과와 exported image archive 어디에서도 발견되지 않는다. Dockerfile에는 `COPY .`가 없고 runtime 문서는 외부 주입과 digest rollback 경계를 설명한다. +- 실패·경계 조건: 필수 ignore path 누락, repository 전체 copy, synthetic canary 또는 금지 파일·값이 검사 surface에 존재하면 FAIL이다. helper의 locally captured stdout/stderr에 canary가 노출되거나 rollback 안내가 DB reset·자동 data mutation을 실행하도록 제시해도 FAIL이다. +- 검사 방법: Dockerfile·`.dockerignore` contract test, synthetic canary가 있는 임시 context의 실제 image build, filesystem/history/inspect/export archive scan, helper의 local output 검사와 runtime documentation contract test를 실행한다. 실제 credential이나 실제 운영 connection string은 fixture·명령·evidence에 사용하지 않는다. diff --git a/.proofline/identities.json b/.proofline/identities.json index d8e2967..55489e2 100644 --- a/.proofline/identities.json +++ b/.proofline/identities.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "next_line_number": 2, - "next_ac_number": 4 + "next_line_number": 3, + "next_ac_number": 9 } diff --git a/.proofline/lines/line-0002/dcy-0002.md b/.proofline/lines/line-0002/dcy-0002.md new file mode 100644 index 0000000..93ad2af --- /dev/null +++ b/.proofline/lines/line-0002/dcy-0002.md @@ -0,0 +1,57 @@ +--- +id: "dcy-0002" +status: confirmed +--- + +# trex-core 검증 컨테이너 이미지의 GHCR 게시 + +## Problem + +`trex-core`는 Python 패키지와 daemon entrypoint를 제공하지만 저장소 자체에는 운영자가 가져다 실행할 수 있는 컨테이너 이미지 정의와 GitHub 게시 자동화가 없다. 따라서 특정 source commit으로부터 어떤 이미지가 만들어졌는지, 그 이미지가 repository 검증과 컨테이너 검사를 통과했는지, 운영자가 어떤 불변 식별자로 같은 이미지를 가져와야 하는지에 대한 공통 배포 계약이 없다. + +`kis-data-hub`는 GitHub Actions에서 source 검증, 이미지 빌드, built-container 검사, 검증 이미지 artifact 전달, GHCR 게시를 분리하고 source commit·tag·digest를 기록한다. 요청자는 `trex-core`도 같은 방식을 채택하고 그 배포 방식을 일회성 구현 메모가 아니라 장기 active Acceptance Criteria로 보존하기로 결정했다. + +기대 상태는 `trex-core`의 정확한 source commit에서 만든 컨테이너가 repository 및 built-image 검증을 모두 통과한 경우에만 `ghcr.io/fourblocks2020/trex-core`에 게시되고, 운영자가 source commit과 digest를 역추적하며 불변 이미지로 배포·롤백할 수 있는 것이다. + +## Evidence + +- 현재 기준 `trex-core` source commit은 `c0f36946a3a06ec0d2bde2e9f40686d602754f50`이고 `main`은 `origin/main`과 동기화되어 있다. `git status --short --branch`는 변경 전 `## main...origin/main`을 반환했다. +- `pyproject.toml:1-12,20-23`은 배포명 `trex-core`, Python `>=3.12`, runtime dependencies와 `trex-core-daemon`·`trex-core`·`trex-core-reset` entrypoint를 선언한다. +- `trex_core/runtime/daemon.py:562-585`에서 daemon은 `--config`를 필수로 받고 `--config-path` 또는 explicit path로 per-instance TOML을 읽는다. 따라서 운영 config와 credential을 이미지에 고정하지 않고 런타임에 주입하는 계약이 필요하다. +- 현재 저장소 검색과 파일 존재 검사에서 repository-root `Dockerfile`과 `.github/workflows/publish-image.yml`은 모두 존재하지 않았다. `.github/` workflow도 없다. +- `trex-sim/compose/dockerfiles/Dockerfile.trex-core:1-29`에는 simulation 전용 이미지 정의가 있으나 workspace-root build context, sim config, `/start.sh`에 결합되어 있고 `trex-sim` 격리 스택만을 위한 것이므로 `trex-core`의 독립 게시 이미지 계약을 대신하지 않는다. +- 비교 기준인 `kis-data-hub/.github/workflows/publish-image.yml:3-8,14-78`은 `main`과 `v*` push에서 test→build→built-container liveness→tested-image artifact 업로드를 수행한다. 같은 파일 `80-141`은 publish job이 artifact를 로드하고 GHCR에 commit SHA tag를 게시하며 tag event에서는 release tag를 추가하고 digest와 source commit을 기록한다. +- `kis-data-hub/Dockerfile:1-16`은 서비스의 Python version에 맞는 slim base에서 package를 설치하고 runtime command를 선언한다. 현재 `kis-data-hub` checkout은 `c38b975b37a44d93f3728389d47efcfe696bd895`이며 관련 게시 구현의 마지막 수정은 `77a3bcc1cc9d4304e82abdd9ca277786bd30c4ad`다. +- `HOME=/home/genichin gh run list --repo fourblocks2020/kis-data-hub --workflow publish-image.yml --limit 5`에서 최근 `main` push의 workflow 성공 실행 `30742432705`, `30742417884`, `30742101305`가 확인되어 비교 대상 방식이 GitHub에서 실제 실행됨을 확인했다. + +## Scope + +- AC admission 분류는 새로운 장기 active 동작을 정의하는 `create`다. 기존 `ac-0001`~`ac-0003`은 KIS 잔고 snapshot 완전성과 reconciliation 회귀를 소유하므로 수정하거나 이 배포 계약을 결합하지 않는다. +- repository-root에서 독립적으로 빌드되는 Python 3.12 기반 `trex-core` 컨테이너 이미지 계약을 정의한다. 이미지에는 package와 세 console entrypoint를 포함하되 운영 TOML, `.env`, credential, token, 실제 connection string은 포함하지 않는다. +- GitHub Actions는 정확한 source commit을 checkout하고 repository 검증을 통과한 뒤 이미지를 한 번 빌드한다. built-container 검사까지 통과한 동일 이미지를 artifact로 publish job에 전달하며 publish job은 이미지를 다시 빌드하지 않는다. +- 게시 대상은 `ghcr.io/fourblocks2020/trex-core`다. `main` push에는 source commit에서 파생된 불변 SHA tag를 게시하고 `v*` tag push에는 같은 이미지에 해당 release tag를 추가한다. 이동 가능한 `latest` tag는 게시하지 않는다. +- 게시 이미지에는 최소한 `org.opencontainers.image.revision`으로 전체 source commit을 기록하고, GitHub Actions summary 또는 output에 게시 tag, digest, source commit을 남긴다. +- repository 검증, image build, built-container 검사, artifact 전달 또는 digest 확인 중 하나라도 실패하면 publish 단계는 실패하거나 실행되지 않아야 하며 검증되지 않은 이미지를 정상 게시 결과로 보고하지 않는다. +- built-container 검사는 `trex-core`의 daemon 특성에 맞춰 package import와 세 console entrypoint의 실행 가능성을 확인하고, 운영 서비스·운영계좌·실제 credential을 사용하지 않는 deterministic synthetic 경계에서 수행한다. HTTP `/health`가 있는 `kis-data-hub`의 probe를 그대로 복제하지 않는다. +- runtime 사용 문서는 per-instance config와 secret을 외부에서 주입하는 방법, SHA tag 또는 digest pin, 이전 digest로의 rollback 원칙을 기록한다. 파괴적인 `trex-core-reset`은 자동 rollback 절차에 포함하지 않는다. +- 구현 검증은 repository-local test, workflow 정적/단위 검증, local Docker build와 built-image smoke까지 포함한다. GitHub `main` 반영과 최초 GHCR 게시 같은 외부 쓰기는 별도 승인 이후의 integration/release evidence로 구분한다. + +## Out of Scope + +- 운영 서버 또는 production Compose에서 이미지 pull·교체·재시작 수행 +- 실제 KIS 계좌, 운영 credential, 운영 PostgreSQL·RabbitMQ·Redis·portfolio-hub를 이용한 smoke test +- DB schema 변경, migration 실행, `trex-core-reset`, 운영 데이터 rollback +- `kis-data-hub`, `portfolio-hub`, 전략 daemon 또는 `trex-sim` 저장소 변경 +- multi-architecture image, public package visibility 변경, image signing·SBOM·provenance attestation 도입 +- 새로운 애플리케이션 health HTTP endpoint 추가 +- 특정 release version 결정, release tag 생성, 운영 배포 완료 또는 production 검증 주장 + +## Risks and Unknowns + +### Risks + +- publish job이 이미지를 다시 빌드하면 검증한 bytes와 게시한 bytes가 달라질 수 있다. 완화책은 verify job의 tested image를 artifact로 넘겨 publish job이 그대로 load·tag·push하도록 강제하는 것이다. +- 모든 `main` push가 게시 trigger이면 문서-only commit도 새 SHA image를 만든다. 이는 현재 `kis-data-hub`와 동일한 단순·일관된 정책으로 수용하고, 향후 비용이나 보존 문제가 관찰되면 path filter 또는 retention을 별도 변경으로 다룬다. +- daemon startup은 외부 서비스에 의존하므로 운영 의존성을 CI에 연결하면 credential 노출이나 비결정적 실패가 생길 수 있다. 완화책은 synthetic config와 local disposable dependencies 또는 외부 연결 없는 entrypoint smoke를 사용하고 실제 운영 연결을 금지하는 것이다. +- mutable `latest`나 version tag만 사용하면 source와 rollback 지점이 불명확해질 수 있다. 완화책은 모든 게시에서 commit SHA tag와 digest를 남기고 운영 배포에는 digest pin을 우선하는 것이다. +- 이미지에 운영 config나 secret이 복사되면 registry 접근자가 이를 회수할 수 있다. 완화책은 좁은 Docker build context, `.dockerignore`, 금지 파일 검사와 runtime-only 주입 계약이다. diff --git a/.proofline/lines/line-0002/line-0002.md b/.proofline/lines/line-0002/line-0002.md new file mode 100644 index 0000000..1408721 --- /dev/null +++ b/.proofline/lines/line-0002/line-0002.md @@ -0,0 +1,3 @@ +--- +id: "line-0002" +--- diff --git a/.proofline/lines/line-0002/req-0002.md b/.proofline/lines/line-0002/req-0002.md new file mode 100644 index 0000000..1559a3b --- /dev/null +++ b/.proofline/lines/line-0002/req-0002.md @@ -0,0 +1,50 @@ +--- +id: "req-0002" +status: approved +discovery: "dcy-0002" +criteria: + create: + - "ac-0004" + - "ac-0005" + - "ac-0006" + - "ac-0007" + - "ac-0008" + update: [] + retire: [] + satisfy: [] +--- + +# trex-core 검증 컨테이너 이미지의 GHCR 게시 + +## Objective + +정확한 `trex-core` source commit으로부터 만든 컨테이너가 repository 검증과 built-container 검사를 모두 통과한 경우에만 `ghcr.io/fourblocks2020/trex-core`에 게시되도록 하여, 운영자가 source commit·stable SHA tag·immutable digest를 역추적하고 같은 digest로 배포하거나 이전 digest로 롤백할 수 있게 한다. + +## Scope + +- repository-root `Dockerfile`과 제한된 build context로 Python 3.12 기반 이미지를 만들고 `trex-core-daemon`, `trex-core`, `trex-core-reset` console entrypoint를 설치한다. +- GitHub Actions는 `main` 및 `v*` tag push의 정확한 commit을 checkout하고 repository가 정의한 검증 gate, image build와 built-container smoke를 순서대로 수행한다. +- verify job에서 검사를 마친 이미지 archive와 그 SHA-256을 artifact로 전달하고, 별도 publish job은 checksum이 일치하는 artifact를 load하여 재빌드 없이 GHCR에 게시한다. +- 모든 성공 게시에는 전체 source commit에서 결정적으로 파생된 stable SHA tag와 OCI revision을 부여하고 registry가 반환한 immutable digest를 배포·rollback의 정본으로 기록한다. `v*` tag event는 같은 loaded image에 해당 release tag를 추가하며 `latest`는 만들지 않는다. +- workflow output 또는 summary에 게시 image reference, digest와 전체 source commit을 남긴다. +- Docker build와 built-container 검사는 운영 TOML, `.env`, credential, token, 실제 connection string 또는 운영 서비스를 사용하지 않는다. +- runtime 문서는 per-instance config·secret 외부 주입, SHA tag 탐색과 digest pin, 이전 digest rollback을 설명하되 파괴적인 reset을 자동 rollback으로 안내하지 않는다. + +## Constraints + +- repository 검증, image build, built-container smoke, tested-image artifact 생성·전달·checksum·load 검증 중 하나라도 실패하면 registry login/push 전에 중단한다. +- registry push 이후에만 얻을 수 있는 digest가 누락되거나 형식이 잘못되면 job을 nonzero로 종료하고 성공 output·summary를 남기지 않는다. 이미 발생한 registry write를 발생하지 않은 것으로 주장하지 않는다. +- publish job은 검증된 artifact를 다시 빌드할 수 없으며 `packages: write` 권한은 GHCR 게시가 필요한 최소 job 범위로 제한한다. +- runtime image와 검증은 `pyproject.toml`의 Python `>=3.12` 및 현재 console entrypoint 계약을 보존한다. +- built-container smoke는 package import와 무부작용 entrypoint probe로 한정하고 실제 주문, migration, daemon setup, 실제 KIS 계좌, 운영 credential 또는 production service에 연결하지 않는다. +- `--config`가 필수인 daemon 계약을 보존하고 운영 config를 image layer에 고정하지 않는다. +- GitHub `main` 반영과 최초 GHCR package write는 구현 검증과 분리된 외부 쓰기이며 별도 승인을 요구한다. + +## Non-Goals + +- 운영 서버·production Compose의 image 교체, 재시작 또는 배포 완료 +- release version 선택, release tag 생성, production 검증 또는 release 완료 주장 +- DB migration, `trex-core-reset`, 운영 데이터 rollback 자동화 +- 다른 TREX 저장소나 `trex-sim` 격리 stack 변경 +- 새로운 HTTP health endpoint, multi-architecture image, image signing, SBOM 또는 provenance attestation +- GHCR package visibility나 조직 보안정책 변경 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..691ceb1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ARG SOURCE_COMMIT +LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/trex-core" \ + org.opencontainers.image.revision="${SOURCE_COMMIT}" + +WORKDIR /opt/trex-core + +RUN test "${#SOURCE_COMMIT}" -eq 40 \ + && case "${SOURCE_COMMIT}" in \ + *[!0-9a-f]*) false ;; \ + *) true ;; \ + esac \ + || { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; } + +COPY pyproject.toml ./ +COPY trex_core/ ./trex_core/ + +RUN python -m pip install --no-cache-dir . \ + && useradd --create-home --uid 10001 trex + +USER 10001:10001 + +ENTRYPOINT ["trex-core-daemon"] diff --git a/docs/operations.md b/docs/operations.md index 7cdcd2d..2ec38ec 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -40,6 +40,13 @@ pkill -TERM -f trex-core-daemon - `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지. - 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN. +### 컨테이너 설정, 배포 및 rollback + +- 이미지는 per-instance TOML, `.env`, 자격증명 또는 연결 정보를 포함하지 않는다. 인스턴스 TOML은 read-only volume으로 mount하고 `trex-core-daemon --config `로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다. +- 게시된 `ghcr.io/fourblocks2020/trex-core:` tag는 source commit을 찾는 stable selector일 뿐이다. 배포 전 registry가 반환한 `ghcr.io/fourblocks2020/trex-core@sha256:<64-hex>` reference를 기록하고 workload는 이 digest로 pin한다. +- rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다. +- `trex-core-reset`은 파괴적인 DB 작업이며 image rollback이나 자동 rollback 절차에 절대로 포함하지 않는다. schema/data 변경의 복구는 image rollback과 분리된 승인 절차로 수행한다. + --- ## 2. 운영자 CLI diff --git a/pyproject.toml b/pyproject.toml index f669804..64bb8f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8", + "PyYAML>=6", "ruff>=0.4", ] diff --git a/scripts/image_release.py b/scripts/image_release.py new file mode 100644 index 0000000..ca764a7 --- /dev/null +++ b/scripts/image_release.py @@ -0,0 +1,465 @@ +#!/usr/bin/env python3 +"""Deterministic verification and publication helpers for the release image.""" + +from __future__ import annotations + +import argparse +import hashlib +import os +import re +import subprocess +import sys +import tarfile +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol, Sequence + +IMAGE = "ghcr.io/fourblocks2020/trex-core" +CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP" +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") +DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)") +IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +MAX_ARCHIVE_DEPTH = 8 +MAX_ARCHIVE_MEMBERS = 200_000 +MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024 +MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024 +MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024 + + +@dataclass(frozen=True) +class Result: + stdout: str = "" + stderr: str = "" + + +@dataclass +class _ScanBudget: + max_depth: int = MAX_ARCHIVE_DEPTH + max_members: int = MAX_ARCHIVE_MEMBERS + max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES + max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES + max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES + members: int = 0 + extracted_bytes: int = 0 + + def check_depth(self, depth: int) -> None: + if depth > self.max_depth: + _scan_limit_exceeded() + + def add_member(self, size: int) -> None: + self.members += 1 + if self.members > self.max_members: + _scan_limit_exceeded() + if size < 0 or size > self.max_member_bytes: + _scan_limit_exceeded() + self.extracted_bytes += size + if self.extracted_bytes > self.max_extracted_bytes: + _scan_limit_exceeded() + + +def _scan_limit_exceeded() -> None: + raise ValueError("archive scan resource limit exceeded") + + +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: + completed = subprocess.run( + command, + input=input_text, + text=True, + capture_output=True, + check=True, + timeout=timeout, + ) + return Result(completed.stdout, completed.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 release_tags(commit: str, ref: str) -> list[str]: + validate_commit(commit) + tags = [f"{IMAGE}:{commit[:12]}"] + if ref == "refs/heads/main": + return tags + prefix = "refs/tags/" + if ref.startswith(prefix) and ref.removeprefix(prefix).startswith("v"): + release = ref.removeprefix(prefix) + if not re.fullmatch(r"v[A-Za-z0-9._-]*", release): + raise ValueError("release ref contains unsupported tag characters") + tags.append(f"{IMAGE}:{release}") + return tags + raise ValueError("publication ref must be main or a v* tag") + + +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, checksum: Path) -> None: + checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii") + + +def verify_checksum(archive: Path, checksum: Path) -> None: + if not archive.is_file() or not checksum.is_file(): + raise ValueError("image archive or checksum evidence is missing") + fields = checksum.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") + + +def validate_image_id(image_id: str) -> str: + if not IMAGE_ID_RE.fullmatch(image_id): + raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters") + return image_id + + +def inspect_image_id(image: str, runner: Runner) -> str: + result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image]) + return validate_image_id(result.stdout.strip()) + + +def write_image_id(image: str, output: Path, runner: Runner) -> str: + image_id = inspect_image_id(image, runner) + output.write_text(f"{image_id}\n", encoding="ascii") + return image_id + + +def read_image_id(path: Path) -> str: + text = path.read_text(encoding="ascii") + if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text): + raise ValueError("tested image ID evidence has an invalid format") + return text.rstrip("\n") + + +def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str: + expected = read_image_id(expected_path) + actual = inspect_image_id(image, runner) + if actual != expected: + raise ValueError("loaded image ID does not match tested image ID") + return actual + + +SMOKE_PROBES: tuple[tuple[str, ...], ...] = ( + ("python", "-c", "import trex_core"), + ("trex-core-daemon", "--help"), + ("trex-core", "--help"), + ("trex-core-reset", "--help"), +) + + +def _ensure_result_clean(result: Result) -> None: + if CANARY in (result.stdout + result.stderr).encode(): + raise ValueError("canary marker found on a scanned surface") + + +def smoke_image(image: str, runner: Runner, timeout: int = 30) -> None: + for index, probe in enumerate(SMOKE_PROBES): + name = f"trex-core-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: + _ensure_result_clean(runner.run(command, timeout=timeout)) + except BaseException as error: + failure = error + try: + _ensure_result_clean( + runner.run(["docker", "rm", "--force", name], timeout=timeout) + ) + except BaseException as cleanup_error: + if failure is None: + raise RuntimeError("smoke container cleanup failed") from cleanup_error + if failure is not None: + raise RuntimeError(f"smoke probe {index + 1} failed") from failure + + +def _scan_stream(path: Path) -> None: + with path.open("rb") as stream: + overlap = b"" + while chunk := stream.read(1024 * 1024): + data = overlap + chunk + if CANARY in data: + raise ValueError("canary marker found on a scanned surface") + overlap = data[-(len(CANARY) - 1):] + + +def _scan_metadata(member: tarfile.TarInfo) -> None: + values = [member.name, member.linkname, member.uname, member.gname] + for key, value in member.pax_headers.items(): + values.extend((key, value)) + if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values): + raise ValueError("canary marker found on a scanned surface") + + +def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None: + budget = budget or _ScanBudget() + if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes): + _scan_limit_exceeded() + _scan_stream(path) + try: + archive = tarfile.open(path, mode="r:*") + except (tarfile.TarError, OSError, EOFError): + return + with archive: + budget.check_depth(depth) + for member in archive: + budget.add_member(member.size if member.isfile() else 0) + _scan_metadata(member) + if not member.isfile(): + continue + extracted = archive.extractfile(member) + if extracted is None: + continue + with tempfile.NamedTemporaryFile() as nested: + overlap = b"" + copied = 0 + while chunk := extracted.read(1024 * 1024): + copied += len(chunk) + if copied > member.size or copied > budget.max_member_bytes: + _scan_limit_exceeded() + data = overlap + chunk + if CANARY in data: + raise ValueError("canary marker found on a scanned surface") + nested.write(chunk) + overlap = data[-(len(CANARY) - 1):] + nested.flush() + _scan_tar(Path(nested.name), budget, depth + 1) + + +def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None: + for command in ( + ["docker", "history", "--no-trunc", image], + ["docker", "image", "inspect", image], + ): + _ensure_result_clean(runner.run(command, timeout=timeout)) + + container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout) + container_id = container.stdout.strip() + if not container_id: + raise RuntimeError("docker create returned no container id") + try: + _ensure_result_clean(container) + with tempfile.TemporaryDirectory() as directory: + filesystem = Path(directory) / "filesystem.tar" + _ensure_result_clean( + runner.run( + ["docker", "export", "--output", str(filesystem), container_id], + timeout=timeout, + ) + ) + _scan_tar(filesystem) + finally: + try: + _ensure_result_clean( + runner.run(["docker", "rm", "--force", container_id], timeout=timeout) + ) + except BaseException as error: + raise RuntimeError("canary scan container cleanup failed") from error + _scan_tar(saved_archive) + + +def assert_clean_output(paths: Sequence[Path]) -> None: + for path in paths: + _scan_stream(path) + + +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 is None: + return + 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, + ref: str, + actor: str, + token: str, + runner: Runner, + output_path: Path | None = None, + summary_path: Path | None = None, +) -> str: + tags = release_tags(commit, ref) + verify_checksum(archive, checksum) + expected_image_id = read_image_id(image_id) + loaded = runner.run(["docker", "load", "--input", str(archive)]) + load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()} + if f"Loaded image: {local_image}" not in load_lines: + raise ValueError("docker load did not report the expected local image") + actual_image_id = inspect_image_id(local_image, runner) + if actual_image_id != expected_image_id: + raise ValueError("loaded image ID does not match tested image ID") + revision = runner.run( + [ + "docker", + "image", + "inspect", + "--format", + "{{ index .Config.Labels \"org.opencontainers.image.revision\" }}", + local_image, + ] + ).stdout.strip() + if revision != commit: + raise ValueError("loaded image revision does not match source commit") + + if not actor or not token: + raise ValueError("registry credentials are missing") + for tag in tags: + runner.run(["docker", "tag", local_image, tag]) + runner.run( + ["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"], + input_text=token, + ) + digests = [] + for tag in tags: + result = runner.run(["docker", "push", tag]) + digests.append(_push_digest(result.stdout + "\n" + result.stderr)) + if len(set(digests)) != 1: + raise ValueError("published tags returned different registry digests") + + digest = digests[0] + reference = f"{IMAGE}@{digest}" + _append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n") + _append( + summary_path, + "## Published trex-core image\n\n" + f"- Image: `{reference}`\n" + f"- Digest: `{digest}`\n" + f"- Source commit: `{commit}`\n", + ) + return digest + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + commands = parser.add_subparsers(dest="command", required=True) + + smoke = commands.add_parser("smoke") + smoke.add_argument("--image", required=True) + smoke.add_argument("--timeout", type=int, default=30) + + 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_id = commands.add_parser("record-image-id") + record_id.add_argument("--image", required=True) + record_id.add_argument("--output", type=Path, required=True) + + verify_id = commands.add_parser("verify-image-id") + verify_id.add_argument("--image", required=True) + verify_id.add_argument("--expected", type=Path, required=True) + + clean = commands.add_parser("assert-clean-output") + clean.add_argument("paths", nargs="+", type=Path) + + publish = commands.add_parser("publish") + publish.add_argument("--archive", type=Path, required=True) + publish.add_argument("--checksum", type=Path, required=True) + publish.add_argument("--image-id", type=Path, required=True) + publish.add_argument("--local-image", required=True) + publish.add_argument("--commit", required=True) + publish.add_argument("--ref", required=True) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parser().parse_args(argv) + runner = SubprocessRunner() + if args.command == "smoke": + smoke_image(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 == "verify-image-id": + verify_image_id(args.image, args.expected, 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, + ref=args.ref, + actor=os.environ.get("GITHUB_ACTOR", ""), + token=os.environ.get("REGISTRY_TOKEN", ""), + runner=runner, + output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None, + summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None, + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + print(f"image release failed: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/tests/release/test_image_contracts.py b/tests/release/test_image_contracts.py new file mode 100644 index 0000000..8e596ba --- /dev/null +++ b/tests/release/test_image_contracts.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + + +REPO = Path(__file__).resolve().parents[2] +WORKFLOW_PATH = REPO / ".github" / "workflows" / "publish-image.yml" + + +def _workflow(): + return yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader) + + +def _step_index(steps, name): + return next(index for index, step in enumerate(steps) if step.get("name") == name) + + +def test_workflow_triggers_only_main_and_v_tags(): + workflow = _workflow() + assert set(workflow["on"]) == {"push"} + assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]} + + +def test_publish_is_gated_and_has_the_only_package_write_permission(): + workflow = _workflow() + verify = workflow["jobs"]["verify"] + publish = workflow["jobs"]["publish"] + + assert publish["needs"] == "verify" + assert publish["permissions"]["packages"] == "write" + assert "packages" not in workflow["permissions"] + assert "packages" not in verify["permissions"] + + +def test_verify_canary_build_smoke_export_scan_checksum_upload_contract(): + steps = _workflow()["jobs"]["verify"]["steps"] + names = [step["name"] for step in steps] + assert names.count("Create excluded synthetic canary") == 1 + assert names.count("Build source image once") == 1 + assert names.count("Scan all canary surfaces") == 1 + + canary = steps[_step_index(steps, "Create excluded synthetic canary")]["run"] + assert 'Path(".env.ci-canary")' in canary + assert "TREX_SECRET_CANARY_DO_NOT_SHIP" in canary + + scan = steps[_step_index(steps, "Scan all canary surfaces")]["run"] + assert "image_release.py scan" in scan + status_contract = '''>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}"''' + assert status_contract in scan + assert scan.count("scan_status=$?") == 1 + assert scan.rstrip().endswith('exit "${scan_status}"') + + assert _step_index(steps, "Run repository lint") < _step_index(steps, "Build source image once") + assert _step_index(steps, "Run repository tests") < _step_index(steps, "Build source image once") + ordered = [ + "Create excluded synthetic canary", + "Build source image once", + "Smoke built image", + "Export tested image", + "Scan all canary surfaces", + "Record tested archive checksum", + "Upload immutable tested image artifact", + ] + assert [_step_index(steps, name) for name in ordered] == sorted( + _step_index(steps, name) for name in ordered + ) + + +def test_tested_image_id_is_recorded_after_smoke_and_transferred_to_publish(): + workflow = _workflow() + verify_steps = workflow["jobs"]["verify"]["steps"] + publish_steps = workflow["jobs"]["publish"]["steps"] + + smoke_index = _step_index(verify_steps, "Smoke built image") + identity_index = _step_index(verify_steps, "Record tested image ID") + export_index = _step_index(verify_steps, "Export tested image") + assert smoke_index < identity_index < export_index + + identity = verify_steps[identity_index]["run"] + assert "image_release.py record-image-id" in identity + assert "--output trex-core-image.id" in identity + + upload = verify_steps[_step_index(verify_steps, "Upload immutable tested image artifact")]["with"] + assert "trex-core-image.id" in upload["path"].splitlines() + + publish = publish_steps[_step_index(publish_steps, "Verify, load, and publish without rebuilding")][ + "run" + ] + assert "--image-id tested-image/trex-core-image.id" in publish + + +def test_publish_job_has_only_download_then_verified_publish_helper(): + steps = _workflow()["jobs"]["publish"]["steps"] + assert len(steps) == 2 + assert "run" not in steps[0] + assert steps[0]["uses"].startswith("actions/download-artifact@") + + run_steps = [step for step in steps if "run" in step] + assert run_steps == [steps[1]] + expected_run = '''python tested-image/image_release.py publish \\ + --archive tested-image/trex-core-image.tar \\ + --checksum tested-image/trex-core-image.tar.sha256 \\ + --image-id tested-image/trex-core-image.id \\ + --local-image "trex-core-ci:${GITHUB_SHA}" \\ + --commit "${GITHUB_SHA}" \\ + --ref "${GITHUB_REF}"''' + assert steps[1]["run"].strip() == expected_run + + rendered = "\n".join(str(step) for step in steps).lower() + for forbidden in ( + "actions/checkout", + "docker login", + "docker push", + "docker build", + "buildx", + ): + assert forbidden not in rendered + + +def test_workflow_uses_full_sha_and_contains_no_latest_tag(): + text = WORKFLOW_PATH.read_text() + assert "${GITHUB_SHA}" in text + assert "github.sha" in text + assert "--provenance=false" in text + assert ":latest" not in text.lower() + + +def test_docker_context_contract(): + dockerfile = (REPO / "Dockerfile").read_text() + copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")] + assert copy_lines == ["COPY pyproject.toml ./", "COPY trex_core/ ./trex_core/"] + assert "org.opencontainers.image.revision" in dockerfile + assert "python:3.12" in dockerfile + + ignored = set((REPO / ".dockerignore").read_text().splitlines()) + assert {".git", ".env", ".env.*", "config/", ".proofline/"} <= ignored + + +def test_operator_documentation_preserves_rollback_boundaries(): + operations = (REPO / "docs" / "operations.md").read_text() + assert "read-only volume" in operations + assert "runtime environment" in operations + assert "sha256:<64-hex>" in operations + assert "이전" in operations and "digest" in operations + assert "`trex-core-reset`" in operations + assert "자동 rollback 절차에 절대로 포함하지 않는다" in operations diff --git a/tests/release/test_image_release.py b/tests/release/test_image_release.py new file mode 100644 index 0000000..1b8408e --- /dev/null +++ b/tests/release/test_image_release.py @@ -0,0 +1,564 @@ +from __future__ import annotations + +import gzip +import io +import subprocess +import tarfile +from pathlib import Path + +import pytest + +from scripts import image_release + + +COMMIT = "a" * 40 +DIGEST = "sha256:" + "b" * 64 +IMAGE_ID = "sha256:" + "c" * 64 + + +class RecordingRunner: + def __init__( + self, + *, + fail_on: tuple[str, ...] | None = None, + digest: str = DIGEST, + revision: str = COMMIT, + image_id: str = IMAGE_ID, + loaded_image: str = f"trex-core-ci:{COMMIT}", + ): + self.commands: list[tuple[list[str], str | None, int | None]] = [] + self.fail_on = fail_on + self.digest = digest + self.revision = revision + self.image_id = image_id + self.loaded_image = loaded_image + + 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.CalledProcessError(1, command) + if command[:2] == ["docker", "load"]: + return image_release.Result(f"Loaded image: {self.loaded_image}\n") + if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command: + return image_release.Result(self.image_id + "\n") + if command[:3] == ["docker", "image", "inspect"]: + return image_release.Result(self.revision + "\n") + if command[:2] == ["docker", "push"]: + return image_release.Result(f"digest: {self.digest} size: 1234\n") + if command[:2] == ["docker", "create"]: + return image_release.Result("container-id\n") + return image_release.Result() + + +def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]: + archive = tmp_path / "trex-core-image.tar" + checksum = tmp_path / "trex-core-image.tar.sha256" + image_id = tmp_path / "trex-core-image.id" + archive.write_bytes(b"tested image") + image_release.write_checksum(archive, checksum) + image_id.write_text(IMAGE_ID + "\n", encoding="ascii") + return archive, checksum, image_id + + +def test_release_tags_are_deterministic_and_never_latest(): + main = image_release.release_tags(COMMIT, "refs/heads/main") + release = image_release.release_tags(COMMIT, "refs/tags/v1.2.3") + + assert main == [f"{image_release.IMAGE}:{COMMIT[:12]}"] + assert release == [*main, f"{image_release.IMAGE}:v1.2.3"] + assert all(not tag.endswith(":latest") for tag in main + release) + + +@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""]) +def test_invalid_source_commit_is_rejected(commit): + with pytest.raises(ValueError): + image_release.release_tags(commit, "refs/heads/main") + + +def test_smoke_uses_exact_isolated_probes_and_cleans_containers(): + runner = RecordingRunner() + image_release.smoke_image("candidate", runner, timeout=17) + + runs = [call for call in runner.commands if call[0][:2] == ["docker", "run"]] + removals = [call for call in runner.commands if call[0][:2] == ["docker", "rm"]] + assert len(runs) == len(removals) == 4 + assert [call[0][call[0].index("--entrypoint") + 1 :] for call in runs] == [ + ["python", "candidate", "-c", "import trex_core"], + ["trex-core-daemon", "candidate", "--help"], + ["trex-core", "candidate", "--help"], + ["trex-core-reset", "candidate", "--help"], + ] + assert all("--network" in call[0] and "none" in call[0] for call in runs) + assert all("--read-only" in call[0] and "--cap-drop" in call[0] for call in runs) + assert all(call[2] == 17 for call in runs + removals) + assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs) + + +def test_smoke_failure_propagates_after_cleanup(): + runner = RecordingRunner(fail_on=("docker", "run")) + with pytest.raises(RuntimeError, match="smoke probe 1 failed"): + image_release.smoke_image("candidate", runner) + assert [call[0][:3] for call in runner.commands] == [ + ["docker", "run", "--name"], + ["docker", "rm", "--force"], + ] + + +def test_smoke_fails_if_captured_probe_output_contains_canary(): + class LeakingRunner(RecordingRunner): + def run(self, command, *, input_text=None, timeout=None): + result = super().run(command, input_text=input_text, timeout=timeout) + if list(command)[:2] == ["docker", "run"]: + return image_release.Result(image_release.CANARY.decode()) + return result + + runner = LeakingRunner() + with pytest.raises(RuntimeError, match="smoke probe 1 failed"): + image_release.smoke_image("candidate", runner) + assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"] + + +def test_mutated_archive_stops_before_load_or_registry(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + archive.write_bytes(b"mutated") + runner = RecordingRunner() + + with pytest.raises(ValueError, match="checksum mismatch"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert runner.commands == [] + + +def test_load_failure_stops_before_login_and_push(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + runner = RecordingRunner(fail_on=("docker", "load")) + + with pytest.raises(subprocess.CalledProcessError): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands) + + +def test_revision_mismatch_stops_before_login_and_push(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + runner = RecordingRunner(revision="c" * 40) + + with pytest.raises(ValueError, match="revision does not match"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands) + + +def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + runner = RecordingRunner(image_id="sha256:" + "d" * 64) + + with pytest.raises(ValueError, match="image ID does not match"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert not any( + call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"]) + for call in runner.commands + ) + + +def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + runner = RecordingRunner(loaded_image="unrelated:image", image_id=IMAGE_ID) + + with pytest.raises(ValueError, match="did not report the expected local image"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert not any( + call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"]) + for call in runner.commands + ) + + +@pytest.mark.parametrize( + "invalid_id", + ["", "sha256:" + "c" * 63, "sha256:" + "C" * 64, "sha512:" + "c" * 64], +) +def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id): + archive, checksum, image_id = _artifact(tmp_path) + image_id.write_text(invalid_id, encoding="ascii") + runner = RecordingRunner() + + with pytest.raises(ValueError, match="image ID evidence"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + ) + assert runner.commands == [] + + +def test_record_image_id_writes_strict_inspected_id(tmp_path): + output = tmp_path / "trex-core-image.id" + runner = RecordingRunner() + assert image_release.write_image_id("candidate", output, runner) == IMAGE_ID + assert output.read_text(encoding="ascii") == IMAGE_ID + "\n" + assert runner.commands == [ + (["docker", "image", "inspect", "--format", "{{.Id}}", "candidate"], None, None) + ] + + +def test_valid_publish_records_digest_only_after_push(tmp_path): + archive, checksum, image_id = _artifact(tmp_path) + output = tmp_path / "output" + summary = tmp_path / "summary" + runner = RecordingRunner() + + digest = image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/tags/v2.0.0", + actor="actor", + token="fake-token", + runner=runner, + output_path=output, + summary_path=summary, + ) + + commands = [call[0][:2] for call in runner.commands] + assert commands.index(["docker", "load"]) < commands.index(["docker", "login"]) + assert commands.count(["docker", "push"]) == 2 + assert digest == DIGEST + assert f"digest={DIGEST}" in output.read_text() + assert COMMIT in output.read_text() + assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text() + + +@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64]) +def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest): + archive, checksum, image_id = _artifact(tmp_path) + output = tmp_path / "output" + summary = tmp_path / "summary" + runner = RecordingRunner(digest=bad_digest) + + with pytest.raises(ValueError, match="valid digest"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/heads/main", + actor="actor", + token="fake-token", + runner=runner, + output_path=output, + summary_path=summary, + ) + assert any(call[0][:2] == ["docker", "push"] for call in runner.commands) + assert not output.exists() + assert not summary.exists() + + +def test_release_second_push_failure_preserves_nonzero_without_success_evidence(tmp_path): + class SecondPushFailsRunner(RecordingRunner): + def run(self, command, *, input_text=None, timeout=None): + result = super().run(command, input_text=input_text, timeout=timeout) + pushes = [call for call in self.commands if call[0][:2] == ["docker", "push"]] + if list(command)[:2] == ["docker", "push"] and len(pushes) == 2: + return image_release.Result("digest unavailable\n") + return result + + archive, checksum, image_id = _artifact(tmp_path) + output = tmp_path / "output" + summary = tmp_path / "summary" + runner = SecondPushFailsRunner() + + with pytest.raises(ValueError, match="valid digest"): + image_release.publish_image( + archive=archive, + checksum=checksum, + image_id=image_id, + local_image=f"trex-core-ci:{COMMIT}", + commit=COMMIT, + ref="refs/tags/v2.0.0", + actor="actor", + token="fake-token", + runner=runner, + output_path=output, + summary_path=summary, + ) + assert len([call for call in runner.commands if call[0][:2] == ["docker", "push"]]) == 2 + assert not output.exists() + assert not summary.exists() + + +def test_canary_is_found_inside_nested_export_archive(tmp_path): + layer = io.BytesIO() + with tarfile.open(fileobj=layer, mode="w") as archive: + payload = image_release.CANARY + member = tarfile.TarInfo("app/data") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + saved = tmp_path / "image.tar" + with tarfile.open(saved, mode="w") as archive: + payload = layer.getvalue() + member = tarfile.TarInfo("layer.tar") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + with pytest.raises(ValueError, match="canary marker found"): + image_release._scan_tar(saved) + + +def test_canary_is_found_inside_compressed_digest_named_blob(tmp_path): + inner = io.BytesIO() + with tarfile.open(fileobj=inner, mode="w") as archive: + payload = image_release.CANARY + member = tarfile.TarInfo("app/data") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + saved = tmp_path / "oci-image.tar" + with tarfile.open(saved, mode="w") as archive: + payload = gzip.compress(inner.getvalue()) + member = tarfile.TarInfo("blobs/sha256/deadbeef") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + with pytest.raises(ValueError, match="canary marker found"): + image_release._scan_tar(saved) + + +def test_canary_is_found_in_symlink_metadata_inside_compressed_digest_blob(tmp_path): + inner = io.BytesIO() + with tarfile.open(fileobj=inner, mode="w") as archive: + member = tarfile.TarInfo("app/link") + member.type = tarfile.SYMTYPE + member.linkname = image_release.CANARY.decode() + archive.addfile(member) + + saved = tmp_path / "oci-image.tar" + with tarfile.open(saved, mode="w") as archive: + payload = gzip.compress(inner.getvalue()) + member = tarfile.TarInfo("blobs/sha256/deadbeef") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + with pytest.raises(ValueError, match="canary marker found"): + image_release._scan_tar(saved) + + +@pytest.mark.parametrize( + "pax_headers", + [ + {"comment": image_release.CANARY.decode()}, + {image_release.CANARY.decode(): "safe"}, + ], +) +def test_canary_is_found_in_pax_metadata_inside_compressed_digest_blob(tmp_path, pax_headers): + inner = io.BytesIO() + with tarfile.open(fileobj=inner, mode="w", format=tarfile.PAX_FORMAT) as archive: + member = tarfile.TarInfo("app/data") + member.pax_headers = pax_headers + archive.addfile(member) + + saved = tmp_path / "oci-image.tar" + with tarfile.open(saved, mode="w") as archive: + payload = gzip.compress(inner.getvalue()) + member = tarfile.TarInfo("blobs/sha256/deadbeef") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + with pytest.raises(ValueError, match="canary marker found"): + image_release._scan_tar(saved) + + +@pytest.mark.parametrize("field", ["uname", "gname"]) +def test_canary_is_found_in_owner_metadata_inside_compressed_digest_blob(tmp_path, field): + inner = io.BytesIO() + with tarfile.open(fileobj=inner, mode="w") as archive: + member = tarfile.TarInfo("app/data") + setattr(member, field, image_release.CANARY.decode()) + archive.addfile(member) + + saved = tmp_path / "oci-image.tar" + with tarfile.open(saved, mode="w") as archive: + payload = gzip.compress(inner.getvalue()) + member = tarfile.TarInfo("blobs/sha256/deadbeef") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + with pytest.raises(ValueError, match="canary marker found"): + image_release._scan_tar(saved) + + +def test_archive_scan_rejects_excessive_nesting_depth(tmp_path): + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w"): + pass + nested = payload.getvalue() + for _ in range(4): + wrapped = io.BytesIO() + with tarfile.open(fileobj=wrapped, mode="w") as archive: + member = tarfile.TarInfo("blob") + member.size = len(nested) + archive.addfile(member, io.BytesIO(nested)) + nested = wrapped.getvalue() + path = tmp_path / "deep.tar" + path.write_bytes(nested) + + with pytest.raises(ValueError, match="archive scan resource limit exceeded") as error: + image_release._scan_tar(path, image_release._ScanBudget(max_depth=2)) + assert image_release.CANARY.decode() not in str(error.value) + + +def test_archive_scan_rejects_excessive_aggregate_members(tmp_path): + path = tmp_path / "members.tar" + with tarfile.open(path, mode="w") as archive: + for index in range(3): + member = tarfile.TarInfo(f"directory-{index}") + member.type = tarfile.DIRTYPE + archive.addfile(member) + + with pytest.raises(ValueError, match="archive scan resource limit exceeded"): + image_release._scan_tar(path, image_release._ScanBudget(max_members=2)) + + +def test_archive_scan_rejects_excessive_aggregate_extracted_bytes(tmp_path): + path = tmp_path / "aggregate.tar" + with tarfile.open(path, mode="w") as archive: + payload = b"123456" + member = tarfile.TarInfo("payload") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + budget = image_release._ScanBudget(max_extracted_bytes=5, max_member_bytes=10) + with pytest.raises(ValueError, match="archive scan resource limit exceeded"): + image_release._scan_tar(path, budget) + + +def test_archive_scan_rejects_excessive_per_member_bytes(tmp_path): + path = tmp_path / "member.tar" + with tarfile.open(path, mode="w") as archive: + payload = b"123456" + member = tarfile.TarInfo("payload") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + budget = image_release._ScanBudget(max_extracted_bytes=100, max_member_bytes=5) + with pytest.raises(ValueError, match="archive scan resource limit exceeded"): + image_release._scan_tar(path, budget) + + +def test_archive_scan_enforces_top_level_input_bytes_and_accepts_boundary(tmp_path): + path = tmp_path / "input.tar" + with tarfile.open(path, mode="w"): + pass + input_size = path.stat().st_size + + image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size)) + with pytest.raises(ValueError, match="archive scan resource limit exceeded"): + image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size - 1)) + + +def test_canary_failure_message_does_not_disclose_marker(tmp_path): + output = tmp_path / "captured-output" + output.write_bytes(image_release.CANARY) + with pytest.raises(ValueError) as error: + image_release.assert_clean_output([output]) + assert image_release.CANARY.decode() not in str(error.value) + + +def test_canary_scan_checks_metadata_filesystem_archive_and_cleanup(tmp_path): + class ScanRunner(RecordingRunner): + def run(self, command, *, input_text=None, timeout=None): + result = super().run(command, input_text=input_text, timeout=timeout) + if list(command)[:2] == ["docker", "export"]: + output = Path(command[command.index("--output") + 1]) + with tarfile.open(output, mode="w"): + pass + return result + + saved = tmp_path / "image.tar" + with tarfile.open(saved, mode="w"): + pass + runner = ScanRunner() + + image_release.scan_image("candidate", saved, runner) + + commands = [call[0] for call in runner.commands] + assert ["docker", "history", "--no-trunc", "candidate"] in commands + assert ["docker", "image", "inspect", "candidate"] in commands + assert ["docker", "create", "--network", "none", "candidate"] in commands + assert any(command[:2] == ["docker", "export"] for command in commands) + assert ["docker", "rm", "--force", "container-id"] in commands + + +def test_canary_in_create_stderr_still_cleans_container(tmp_path): + class LeakingCreateRunner(RecordingRunner): + def run(self, command, *, input_text=None, timeout=None): + result = super().run(command, input_text=input_text, timeout=timeout) + if list(command)[:2] == ["docker", "create"]: + return image_release.Result("container-id\n", image_release.CANARY.decode()) + return result + + saved = tmp_path / "image.tar" + with tarfile.open(saved, mode="w"): + pass + runner = LeakingCreateRunner() + + with pytest.raises(ValueError, match="canary marker found"): + image_release.scan_image("candidate", saved, runner) + assert runner.commands[-1][0] == ["docker", "rm", "--force", "container-id"] diff --git a/uv.lock b/uv.lock index 6929899..ee5dc92 100644 --- a/uv.lock +++ b/uv.lock @@ -207,6 +207,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "redis" version = "8.0.0" @@ -256,6 +302,7 @@ dependencies = [ [package.optional-dependencies] dev = [ { name = "pytest" }, + { name = "pyyaml" }, { name = "ruff" }, ] @@ -266,6 +313,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "python-dotenv", specifier = ">=1.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6" }, { name = "redis", specifier = ">=5" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, ]