fix(release): package runtime SQL migrations
This commit is contained in:
@@ -7,10 +7,10 @@ status: active
|
||||
|
||||
## 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 해석을 수행해서는 안 된다.
|
||||
GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에서 `python -c "import trex_core"`와 `trex-core-daemon --help`, `trex-core --help`, `trex-core-reset --help`를 각각 실행하여 package와 세 console entrypoint가 설치되어 있음을 입증해야 한다. 또한 그 source commit에서 source-controlled canonical SQL migration filename inventory를 도출하고, image의 installed package runtime path에서 읽은 migration inventory가 non-empty이며 canonical inventory와 정확히 일치함을 입증해야 한다. 각 probe는 exit code `0`이어야 하며 daemon setup, migration 적용, DB·RabbitMQ·Redis·portfolio-hub·KIS 연결, 실제 주문 또는 config·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를 실행한다.
|
||||
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
|
||||
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
|
||||
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
|
||||
|
||||
@@ -46,6 +46,7 @@ pkill -TERM -f trex-core-daemon
|
||||
- 게시된 `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과 분리된 승인 절차로 수행한다.
|
||||
- v0.1.1부터 image smoke는 DB에 연결하거나 migration을 적용하지 않고, installed package의 SQL migration filename inventory가 해당 source commit의 non-empty canonical inventory와 정확히 일치하는지 검증한다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "trex-core"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
@@ -31,6 +31,9 @@ build-backend = "setuptools.build_meta"
|
||||
where = ["."]
|
||||
include = ["trex_core*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"trex_core.adapters.persistence" = ["sql/*.sql"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ 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}$")
|
||||
DEFAULT_MIGRATION_DIR = (
|
||||
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
||||
)
|
||||
MAX_ARCHIVE_DEPTH = 8
|
||||
MAX_ARCHIVE_MEMBERS = 200_000
|
||||
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
|
||||
@@ -174,13 +177,39 @@ SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
|
||||
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
|
||||
if not names:
|
||||
raise ValueError("canonical migration inventory is empty")
|
||||
return names
|
||||
|
||||
|
||||
def migration_inventory_code(expected: tuple[str, ...]) -> str:
|
||||
return (
|
||||
"from trex_core.adapters.persistence.migrate import migration_files;"
|
||||
f"expected={expected!r};"
|
||||
"actual=tuple(path.name for path in migration_files());"
|
||||
"assert expected and actual == expected, 'installed migration inventory mismatch'"
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
def smoke_image(
|
||||
image: str,
|
||||
runner: Runner,
|
||||
timeout: int = 30,
|
||||
migration_dir: Path = DEFAULT_MIGRATION_DIR,
|
||||
) -> None:
|
||||
expected_migrations = canonical_migration_names(migration_dir)
|
||||
probes = (
|
||||
*SMOKE_PROBES,
|
||||
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
|
||||
)
|
||||
for index, probe in enumerate(probes):
|
||||
name = f"trex-core-smoke-{os.getpid()}-{index}"
|
||||
command = [
|
||||
"docker",
|
||||
|
||||
@@ -14,6 +14,8 @@ from scripts import image_release
|
||||
COMMIT = "a" * 40
|
||||
DIGEST = "sha256:" + "b" * 64
|
||||
IMAGE_ID = "sha256:" + "c" * 64
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MIGRATION_DIR = REPO / "trex_core" / "adapters" / "persistence" / "sql"
|
||||
|
||||
|
||||
class RecordingRunner:
|
||||
@@ -78,16 +80,19 @@ def test_invalid_source_commit_is_rejected(commit):
|
||||
|
||||
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
||||
runner = RecordingRunner()
|
||||
image_release.smoke_image("candidate", runner, timeout=17)
|
||||
image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR)
|
||||
|
||||
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
|
||||
expected_migrations = tuple(sorted(path.name for path in MIGRATION_DIR.glob("*.sql")))
|
||||
assert expected_migrations
|
||||
assert len(runs) == len(removals) == 5
|
||||
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"],
|
||||
["python", "candidate", "-I", "-c", image_release.migration_inventory_code(expected_migrations)],
|
||||
]
|
||||
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)
|
||||
@@ -95,6 +100,26 @@ def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
||||
assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs)
|
||||
|
||||
|
||||
def test_smoke_rejects_empty_canonical_migration_inventory(tmp_path):
|
||||
with pytest.raises(ValueError, match="canonical migration inventory is empty"):
|
||||
image_release.smoke_image("candidate", RecordingRunner(), migration_dir=tmp_path)
|
||||
|
||||
|
||||
def test_migration_inventory_probe_failure_propagates_after_cleanup():
|
||||
class FifthProbeFailsRunner(RecordingRunner):
|
||||
def run(self, command, *, input_text=None, timeout=None):
|
||||
run_count = len([call for call in self.commands if call[0][:2] == ["docker", "run"]])
|
||||
if list(command)[:2] == ["docker", "run"] and run_count == 4:
|
||||
self.commands.append((list(command), input_text, timeout))
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
return super().run(command, input_text=input_text, timeout=timeout)
|
||||
|
||||
runner = FifthProbeFailsRunner()
|
||||
with pytest.raises(RuntimeError, match="smoke probe 5 failed"):
|
||||
image_release.smoke_image("candidate", runner, migration_dir=MIGRATION_DIR)
|
||||
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
|
||||
|
||||
|
||||
def test_smoke_failure_propagates_after_cleanup():
|
||||
runner = RecordingRunner(fail_on=("docker", "run"))
|
||||
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
MIGRATION_PACKAGE_PREFIX = "trex_core/adapters/persistence/sql/"
|
||||
|
||||
|
||||
def test_built_wheel_contains_exact_canonical_migration_inventory(tmp_path: Path):
|
||||
source = tmp_path / "source"
|
||||
source.mkdir()
|
||||
shutil.copy2(REPO / "pyproject.toml", source / "pyproject.toml")
|
||||
shutil.copytree(REPO / "trex_core", source / "trex_core")
|
||||
output = tmp_path / "dist"
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"build",
|
||||
"--wheel",
|
||||
"--no-build-logs",
|
||||
"--no-create-gitignore",
|
||||
"--out-dir",
|
||||
str(output),
|
||||
str(source),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
wheels = list(output.glob("*.whl"))
|
||||
assert len(wheels) == 1
|
||||
expected = sorted(
|
||||
path.relative_to(REPO).as_posix()
|
||||
for path in (REPO / MIGRATION_PACKAGE_PREFIX).glob("*.sql")
|
||||
)
|
||||
assert expected
|
||||
with zipfile.ZipFile(wheels[0]) as wheel:
|
||||
actual = sorted(
|
||||
name
|
||||
for name in wheel.namelist()
|
||||
if name.startswith(MIGRATION_PACKAGE_PREFIX) and name.endswith(".sql")
|
||||
)
|
||||
assert actual == expected
|
||||
Reference in New Issue
Block a user