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}"
|
||||
+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