diff --git a/Dockerfile b/Dockerfile index 6260523..adaa2ad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,11 @@ FROM python:3.12-slim ARG SOURCE_COMMIT +ARG SOURCE_VERSION LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/trex-core" \ - org.opencontainers.image.revision="${SOURCE_COMMIT}" + org.opencontainers.image.revision="${SOURCE_COMMIT}" \ + org.opencontainers.image.version="${SOURCE_VERSION}" \ + org.opencontainers.image.title="trex-core" WORKDIR /opt/trex-core @@ -16,6 +19,9 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \ COPY pyproject.toml uv.lock ./ COPY trex_core/ ./trex_core/ +RUN test "$(python -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" = "${SOURCE_VERSION}" \ + || { echo "SOURCE_VERSION must match pyproject.toml" >&2; exit 1; } + RUN python -m pip install --no-cache-dir "uv==0.11.14" \ && uv sync --locked --no-dev --no-editable \ && useradd --create-home --uid 10001 trex diff --git a/docs/operations.md b/docs/operations.md index 2682771..0687dd4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -40,6 +40,13 @@ pkill -TERM -f trex-core-daemon - `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지. - 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN. +### Release image build + +Release tag의 clean checkout에서 `./scripts/build_release_image.sh trex-core:`을 +실행한다. 이 경로는 project version과 image tag를 대조하고 OCI version/revision labels, +non-root user 및 container `--version`을 검증한다. 전체 명령과 registry 사용법은 +[`docs/ops/release-image-build.md`](ops/release-image-build.md)를 따른다. + ### 배포/복구/수동 재기동 provenance gate (issue #87) 배포, 복구, 수동 stop/start/recreate 전에 아래 gate를 실행해 **검증 가능한 release record**를 남긴다. diff --git a/docs/ops/release-image-build.md b/docs/ops/release-image-build.md new file mode 100644 index 0000000..1ca8beb --- /dev/null +++ b/docs/ops/release-image-build.md @@ -0,0 +1,25 @@ +# Release image build + +운영팀은 release tag의 clean checkout에서 아래 명령으로 이미지를 빌드한다. + +```bash +git checkout v0.7.0 +./scripts/build_release_image.sh trex-core:0.7.0 +``` + +스크립트는 `pyproject.toml`의 canonical version과 image tag가 일치하는지 확인하고 현재 +commit을 OCI `org.opencontainers.image.revision` label로, package version을 +`org.opencontainers.image.version` label로 기록한다. Dirty checkout, version이 다른 image +tag, label 불일치, non-root user 불일치 또는 container `--version` smoke test 실패 시 종료 +코드 0을 반환하지 않는다. + +Registry 경로를 사용할 때도 version tag를 유지한다. + +```bash +./scripts/build_release_image.sh registry.example/trex-core:0.7.0 +docker push registry.example/trex-core:0.7.0 +``` + +배포 전 `docker image inspect`에서 나온 revision과 image digest를 +[`release-provenance-standard.md`](release-provenance-standard.md)의 PRE/POST gate에 기록한다. +이 저장소는 registry publication을 자동화하지 않으므로 push 대상과 권한은 운영팀이 관리한다. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 39fb792..c08d03b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "trex-core" -version = "0.6.0" +version = "0.7.0" description = "Shared core for the trex product family; first domain: slot lifecycle (storage-agnostic pure domain)" requires-python = ">=3.12" dependencies = [ diff --git a/scripts/build_release_image.sh b/scripts/build_release_image.sh new file mode 100755 index 0000000..7ee9281 --- /dev/null +++ b/scripts/build_release_image.sh @@ -0,0 +1,37 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo_root" + +version=$(python3 -c 'import pathlib,tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])') +image_ref=${1:-trex-core:$version} +source_commit=$(git rev-parse HEAD) + +if [ -n "$(git status --porcelain)" ]; then + echo "release image build requires a clean checkout" >&2 + exit 2 +fi +case "$image_ref" in + *:"$version") ;; + *) echo "image reference must use the project version tag :$version" >&2; exit 2 ;; +esac + +docker build \ + --build-arg "SOURCE_COMMIT=$source_commit" \ + --build-arg "SOURCE_VERSION=$version" \ + --tag "$image_ref" \ + . + +actual_revision=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$image_ref") +actual_version=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$image_ref") +actual_user=$(docker image inspect --format '{{ .Config.User }}' "$image_ref") + +[ "$actual_revision" = "$source_commit" ] +[ "$actual_version" = "$version" ] +[ "$actual_user" = "10001:10001" ] +docker run --rm "$image_ref" --version + +printf 'image=%s\nversion=%s\nrevision=%s\nimage_id=%s\n' \ + "$image_ref" "$actual_version" "$actual_revision" \ + "$(docker image inspect --format '{{ .Id }}' "$image_ref")" \ No newline at end of file diff --git a/tests/adapters/ledger/test_portfolio_hub_ledger.py b/tests/adapters/ledger/test_portfolio_hub_ledger.py index 0ce32bb..f3f9ccd 100644 --- a/tests/adapters/ledger/test_portfolio_hub_ledger.py +++ b/tests/adapters/ledger/test_portfolio_hub_ledger.py @@ -94,7 +94,8 @@ def test_client_errors_are_permanent(status): def test_outcome_parses_cash_quantity_after_on_new(): """dcy-018 / REQ-017: option D authoritative cash/quantity from 201 response.""" def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(201, json={"cash_balance_after": 300000.0, + return httpx.Response(201, json={"status": "new", + "cash_balance_after": 300000.0, "asset_quantity_after": 10}) out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade()) @@ -102,12 +103,21 @@ def test_outcome_parses_cash_quantity_after_on_new(): assert out.cash_balance_after == 300000.0 and out.asset_quantity_after == 10.0 -def test_outcome_parses_after_on_duplicate(): +@pytest.mark.parametrize( + "duplicate_marker", + [ + {"status": "duplicate"}, # portfolio-hub canonical response + {"duplicate": True}, # backward-compatible legacy response + ], +) +def test_outcome_parses_after_on_duplicate(duplicate_marker): """duplicate(200) also carries current authoritative values (OQ-2 b).""" def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"duplicate": True, - "cash_balance_after": 50000.0, - "asset_quantity_after": 3}) + return httpx.Response(200, json={ + **duplicate_marker, + "cash_balance_after": 50000.0, + "asset_quantity_after": 3, + }) out = PortfolioHubLedger("https://portfolio-hub.test", client=_client(handler)).record_trade(_trade()) assert out.result is LedgerWriteResult.DUPLICATE_OK diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..3cf81cf --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,30 @@ +import subprocess +import sys +import tomllib +from pathlib import Path + +from trex_core import __version__ + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_runtime_version_matches_project_metadata(): + project = tomllib.loads((ROOT / "pyproject.toml").read_text()) + assert __version__ == project["project"]["version"] + + +def test_all_console_modules_display_the_release_version(): + for module, command in ( + ("trex_core.runtime.cli", "trex-core"), + ("trex_core.runtime.daemon", "trex-core-daemon"), + ("trex_core.runtime.reset", "trex-core-reset"), + ): + result = subprocess.run( + [sys.executable, "-m", module, "--version"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ) + assert result.stdout.strip() == f"{command} {__version__}" \ No newline at end of file diff --git a/trex_core/__init__.py b/trex_core/__init__.py index f33a2e1..aa90922 100644 --- a/trex_core/__init__.py +++ b/trex_core/__init__.py @@ -1 +1,5 @@ """trex-core — shared core for the trex product family.""" + +from trex_core.version import __version__ + +__all__ = ["__version__"] diff --git a/trex_core/adapters/ledger/portfolio_hub.py b/trex_core/adapters/ledger/portfolio_hub.py index 3598ef3..caf79bf 100644 --- a/trex_core/adapters/ledger/portfolio_hub.py +++ b/trex_core/adapters/ledger/portfolio_hub.py @@ -4,7 +4,8 @@ Live portfolio-hub contract (verified against openapi, REQ-017 CHG-04): POST /v1/portfolios/{portfolio_id}/ledger/trades body keys: side, quantity, price, fee_amount, tax_amount, executed_at, execution_id, order_id - - 2xx (201) => recorded (or duplicate accepted idempotently) + - 201/status=new => recorded + - 200/status=duplicate => duplicate accepted idempotently - 409 => LedgerConflictError (operator resolution) - 404/400/422 => LedgerPermanentError (dcy-015/REQ-017 — retry can't fix: portfolio not registered / malformed trade) @@ -94,18 +95,18 @@ class PortfolioHubLedger: raise LedgerRetryableError(f"portfolio-hub network error: {exc!s}") from exc if 200 <= resp.status_code < 300: - # orbit convention: response body may carry a "duplicate" flag when - # the server has already recorded this execution_id idempotently. + # portfolio-hub returns status="duplicate" for an idempotent replay. + # Keep the former boolean marker compatible with older deployments. try: body = resp.json() if resp.content else {} except ValueError: body = {} if not isinstance(body, dict): body = {} - result = ( - LedgerWriteResult.DUPLICATE_OK if body.get("duplicate") is True - else LedgerWriteResult.NEW + is_duplicate = ( + body.get("status") == "duplicate" or body.get("duplicate") is True ) + result = LedgerWriteResult.DUPLICATE_OK if is_duplicate else LedgerWriteResult.NEW # dcy-018 / REQ-017 (option D, portfolio-hub#8): root flat authoritative # cash/quantity after the write — present on new(201) + duplicate(200). return LedgerWriteOutcome( diff --git a/trex_core/runtime/cli.py b/trex_core/runtime/cli.py index 253e92c..c30551f 100644 --- a/trex_core/runtime/cli.py +++ b/trex_core/runtime/cli.py @@ -55,6 +55,7 @@ from trex_core.application.alarm_emitter import AlarmEmitter from trex_core.application.ledger_outbox import LedgerWriter, OutboxOutcome from trex_core.application.ledger_port import LedgerTrade from trex_core.application.ledger_skip import ledger_skip_alarm_fields, publish_ledger_skip_alarm +from trex_core.version import __version__ from trex_core.runtime.config import ( DaemonConfig, DaemonInstanceConfig, @@ -563,6 +564,7 @@ def _add_intent_guards(p) -> None: def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="trex-core", description="trex-core admin CLI") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") sub = parser.add_subparsers(dest="cmd", required=True) p_pause = sub.add_parser("pause", help="Pause a portfolio_id") diff --git a/trex_core/runtime/daemon.py b/trex_core/runtime/daemon.py index 78c5051..0d3f677 100644 --- a/trex_core/runtime/daemon.py +++ b/trex_core/runtime/daemon.py @@ -600,8 +600,10 @@ def main() -> None: # pragma: no cover - process entrypoint import argparse from trex_core.runtime.logging_setup import configure_logging + from trex_core.version import __version__ configure_logging(level=logging.INFO) # REQ-084: ISO8601(KST)+level+json+cid parser = argparse.ArgumentParser(prog="trex-core-daemon") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument( "--config", required=True, help="per-instance TOML name (resolved within --config-path) or explicit " diff --git a/trex_core/runtime/reset.py b/trex_core/runtime/reset.py index 02ba540..1a26545 100644 --- a/trex_core/runtime/reset.py +++ b/trex_core/runtime/reset.py @@ -160,6 +160,7 @@ def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI she from trex_core.runtime.config import DaemonConfig, resolve_config_toml from trex_core.runtime.logging_setup import configure_logging + from trex_core.version import __version__ configure_logging(level=logging.INFO) # REQ-084 parser = argparse.ArgumentParser( @@ -168,6 +169,7 @@ def main(argv: Optional[list[str]] = None) -> int: # pragma: no cover - CLI she "the DB to the asset-centric schema (dry-run by default). " "Destructive and DB-wide — back up first.", ) + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument("--config", required=True, help="instance TOML name/path (DSN source)") parser.add_argument("--config-path", default=None) parser.add_argument("--env-file", default=None) diff --git a/trex_core/version.py b/trex_core/version.py new file mode 100644 index 0000000..ebfb515 --- /dev/null +++ b/trex_core/version.py @@ -0,0 +1,5 @@ +"""Installed package version exposed to runtime entry points.""" + +from importlib.metadata import version + +__version__ = version("trex-core") \ No newline at end of file diff --git a/uv.lock b/uv.lock index b9f0348..7405f5c 100644 --- a/uv.lock +++ b/uv.lock @@ -289,7 +289,7 @@ wheels = [ [[package]] name = "trex-core" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "httpx" },