Merge pull request #54 from fourblocks2020/feat/line-0002-ghcr-image-publication

feat(release): add verified GHCR image publication
This commit is contained in:
2026-08-10 14:51:41 +09:00
committed by GitHub
18 changed files with 1598 additions and 2 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.env
.env.*
config/
.proofline/
.github/
.pytest_cache/
.ruff_cache/
.venv/
docs/
scripts/
tests/
temp/
__pycache__/
*.egg-info/
*.pyc
+128
View File
@@ -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}"
+16
View File
@@ -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로 확인한다.
+16
View File
@@ -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가 호출되지 않음을 검증한다.
+16
View File
@@ -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` 생성 금지 회귀 검사를 실행한다.
+16
View File
@@ -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를 실행한다.
+16
View File
@@ -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에 사용하지 않는다.
+2 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"next_line_number": 2,
"next_ac_number": 4
"next_line_number": 3,
"next_ac_number": 9
}
+57
View File
@@ -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 주입 계약이다.
+3
View File
@@ -0,0 +1,3 @@
---
id: "line-0002"
---
+50
View File
@@ -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나 조직 보안정책 변경
+24
View File
@@ -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"]
+7
View File
@@ -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 <mounted-path>`로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다.
- 게시된 `ghcr.io/fourblocks2020/trex-core:<source-commit-first-12>` 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
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.optional-dependencies]
dev = [
"pytest>=8",
"PyYAML>=6",
"ruff>=0.4",
]
+465
View File
@@ -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
+153
View File
@@ -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
+564
View File
@@ -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"]
Generated
+48
View File
@@ -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" },
]