Merge pull request #54 from fourblocks2020/feat/line-0002-ghcr-image-publication
feat(release): add verified GHCR image publication
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
config/
|
||||
.proofline/
|
||||
.github/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.venv/
|
||||
docs/
|
||||
scripts/
|
||||
tests/
|
||||
temp/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
@@ -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}"
|
||||
@@ -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로 확인한다.
|
||||
@@ -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가 호출되지 않음을 검증한다.
|
||||
@@ -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` 생성 금지 회귀 검사를 실행한다.
|
||||
@@ -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를 실행한다.
|
||||
@@ -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에 사용하지 않는다.
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"next_line_number": 2,
|
||||
"next_ac_number": 4
|
||||
"next_line_number": 3,
|
||||
"next_ac_number": 9
|
||||
}
|
||||
|
||||
@@ -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 주입 계약이다.
|
||||
@@ -0,0 +1,3 @@
|
||||
---
|
||||
id: "line-0002"
|
||||
---
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -14,6 +14,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8",
|
||||
"PyYAML>=6",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user