5 Commits
Author SHA1 Message Date
genichin ee1af6ef49 fix(api): reject explicit null strategy nick
Publish verified PortfolioHub image / publish (push) Canceled after 0s
Publish verified PortfolioHub image / verify (push) Canceled after 0s
2026-08-16 00:03:25 +09:00
genichin fe2dd3d15e test(postgres): use runtime migration command 2026-08-15 23:51:17 +09:00
genichin c292df0a4b chore(release): harden nick contract for v0.1.2 2026-08-15 23:46:57 +09:00
genichin e6a90800ec feat(strategy): expose configurable nick contract 2026-08-15 23:38:18 +09:00
genichin acfaab7fd4 ci(container): verify published immutable image 2026-08-15 15:40:25 +09:00
15 changed files with 358 additions and 83 deletions
@@ -0,0 +1,69 @@
name: Verify published PortfolioHub image
on:
workflow_dispatch:
inputs:
version:
description: "Strict release tag (for example v0.1.1)"
required: true
type: string
commit:
description: "Exact 40-character lowercase source commit"
required: true
type: string
permissions:
contents: read
packages: read
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- name: Validate immutable selectors and image contract
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
VERSION: ${{ inputs.version }}
COMMIT: ${{ inputs.commit }}
IMAGE: ghcr.io/fourblocks2020/portfolio-hub
run: |
set -euo pipefail
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
short_sha="${COMMIT:0:12}"
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
test "$version_digest" = "$source_digest"
docker pull "$IMAGE@$version_digest" >/dev/null
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.version"}}')" = "$VERSION"
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/portfolio-hub
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
echo "mutable latest selector exists" >&2
exit 1
fi
echo "digest=$version_digest"
echo "version_selector=$IMAGE:$VERSION"
echo "source_selector=$IMAGE:$short_sha"
echo "immutable=$IMAGE@$version_digest"
{
echo "## Verified published PortfolioHub image"
echo
echo "- digest: \`$version_digest\`"
echo "- version selector: \`$IMAGE:$VERSION\`"
echo "- source selector: \`$IMAGE:$short_sha\`"
echo "- source commit: \`$COMMIT\`"
echo "- architecture: \`linux/amd64\`"
echo "- runtime user: \`10001:10001\`"
echo "- latest: absent"
} >> "$GITHUB_STEP_SUMMARY"
+20 -9
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
from typing import NoReturn
from fastapi import APIRouter, Depends, HTTPException, Response, status
@@ -11,7 +12,11 @@ from app.core.config import Settings, get_settings
from app.domain.models import StrategySet
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.portfolio import CreateStrategySetRequest, UpdateStrategySetRequest
from app.schemas.v1 import StrategyCreateRequest, StrategyResponse, StrategyUpdateRequest
from app.schemas.v1 import (
StrategyCreateRequest,
StrategyResponse,
StrategyUpdateRequest,
)
from app.services.strategy_set_service import (
StrategyConflictError,
StrategyInactiveError,
@@ -44,6 +49,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
parsed = {}
return StrategyResponse(
strategy_id=strategy.id,
nick=strategy.nick,
name=strategy.name,
config=parsed if isinstance(parsed, dict) else {},
status=strategy.status,
@@ -53,7 +59,7 @@ def _response(strategy: StrategySet) -> StrategyResponse:
)
def _raise_strategy_error(exc: ValueError) -> None:
def _raise_strategy_error(exc: ValueError) -> NoReturn:
if isinstance(exc, StrategyNotFoundError):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
if isinstance(exc, (StrategyInactiveError, StrategyInUseError, StrategyConflictError)):
@@ -129,14 +135,18 @@ def create_admin_strategy(
) -> StrategyResponse:
service, repository = _get_service(settings)
try:
strategy = service.create(
CreateStrategySetRequest(
name=body.name,
config=_canonical_config(body.config),
status=body.status,
try:
strategy = service.create(
CreateStrategySetRequest(
name=body.name,
nick=body.nick,
config=_canonical_config(body.config),
status=body.status,
)
)
)
return _response(strategy)
return _response(strategy)
except ValueError as exc:
_raise_strategy_error(exc)
finally:
_close_repository(repository)
@@ -155,6 +165,7 @@ def update_admin_strategy(
strategy_id,
UpdateStrategySetRequest(
name=body.name,
nick=body.nick,
config=_canonical_config(body.config) if body.config is not None else None,
status=body.status,
),
+1 -1
View File
@@ -18,7 +18,7 @@ if env_file.exists():
class Settings(BaseSettings):
app_name: str = "PortfolioHub"
app_version: str = "0.1.1"
app_version: str = "0.1.2"
environment: str = "development"
allowed_origins_raw: str = Field(default="http://localhost:3000", validation_alias="PORTFOLIOHUB_ALLOWED_ORIGINS")
bootstrap_username: str = Field(default="", validation_alias="PORTFOLIOHUB_BOOTSTRAP_USERNAME")
+9 -1
View File
@@ -243,7 +243,14 @@ class PortfolioRepository(Protocol):
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet: ...
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet: ...
def create_strategy_set(
self,
name: str,
config: str,
*,
nick: str | None = None,
status: str = "active",
) -> StrategySet: ...
def get_strategy_set(self, id: str) -> StrategySet | None: ...
@@ -254,6 +261,7 @@ class PortfolioRepository(Protocol):
id: str,
*,
name: str | None = None,
nick: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet: ...
+36 -24
View File
@@ -1360,28 +1360,38 @@ class PostgresPortfolioRepository:
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self.create_strategy_set(name, config)
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
def create_strategy_set(
self,
name: str,
config: str,
*,
nick: str | None = None,
status: str = "active",
) -> StrategySet:
self.ensure_schema()
nick = f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
with self._connect().cursor() as cur:
new_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO strategy_sets (id, name, config, nick, status)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, config) DO NOTHING
""",
(new_id, name, config, nick, status),
)
cur.execute(
"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE name = %s AND config = %s
""",
(name, config),
)
row = cur.fetchone()
final_nick = nick or f"{name}:{hashlib.sha1(config.encode('utf-8')).hexdigest()[:12]}"
try:
with self._connect().cursor() as cur:
new_id = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO strategy_sets (id, name, config, nick, status)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (name, config) DO NOTHING
""",
(new_id, name, config, final_nick, status),
)
cur.execute(
"""
SELECT id, name, config, nick, status, revision, created_at, updated_at
FROM strategy_sets
WHERE name = %s AND config = %s
""",
(name, config),
)
row = cur.fetchone()
except psycopg.errors.UniqueViolation as exc:
raise ValueError("동일한 nick 전략이 이미 존재합니다") from exc
return self._row_to_strategy_set(row)
def get_strategy_set(self, id: str) -> StrategySet | None:
@@ -1414,6 +1424,7 @@ class PostgresPortfolioRepository:
id: str,
*,
name: str | None = None,
nick: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
@@ -1435,15 +1446,16 @@ class PostgresPortfolioRepository:
raise ValueError("전략을 찾을 수 없습니다")
current = self._row_to_strategy_set(row)
final_name = name if name is not None else current.name
final_nick = nick if nick is not None else current.nick
final_config = config if config is not None else current.config
final_status = status if status is not None else current.status
if (
final_name == current.name
and final_nick == current.nick
and final_config == current.config
and final_status == current.status
):
return current
nick = f"{final_name}:{hashlib.sha1(final_config.encode('utf-8')).hexdigest()[:12]}"
cur.execute(
"""
UPDATE strategy_sets
@@ -1456,11 +1468,11 @@ class PostgresPortfolioRepository:
WHERE id = %s
RETURNING id, name, config, nick, status, revision, created_at, updated_at
""",
(final_name, final_config, nick, final_status, id),
(final_name, final_config, final_nick, final_status, id),
)
updated = cur.fetchone()
except psycopg.errors.UniqueViolation as exc:
raise ValueError("동일한 name/config 전략이 이미 존재합니다") from exc
raise ValueError("동일한 name/config 또는 nick 전략이 이미 존재합니다") from exc
if updated is None:
raise ValueError("전략을 찾을 수 없습니다")
return self._row_to_strategy_set(updated)
+2
View File
@@ -186,6 +186,7 @@ class CreateStrategySetRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str
nick: str | None = Field(default=None, min_length=1)
config: str
status: Literal["active", "inactive"] = "active"
@@ -194,6 +195,7 @@ class UpdateStrategySetRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = None
nick: str | None = Field(default=None, min_length=1)
config: str | None = None
status: Literal["active", "inactive"] | None = None
+24 -1
View File
@@ -6,7 +6,7 @@ from datetime import date, datetime
from decimal import Decimal
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# ---------------------------------------------------------------------------
@@ -305,6 +305,7 @@ StrategyStatus = Literal["active", "inactive"]
class StrategyResponse(BaseModel):
strategy_id: str
nick: str
name: str
config: dict[str, Any] = Field(default_factory=dict)
status: StrategyStatus
@@ -317,17 +318,39 @@ class StrategyCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(..., min_length=1)
nick: str | None = Field(default=None, min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
status: StrategyStatus = "active"
@field_validator("nick")
@classmethod
def normalize_nick(cls, value: str | None) -> str | None:
if value is None:
raise ValueError("nick must not be null")
normalized = value.strip()
if not normalized:
raise ValueError("nick must not be blank")
return normalized
class StrategyUpdateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str | None = Field(default=None, min_length=1)
nick: str | None = Field(default=None, min_length=1)
config: dict[str, Any] | None = None
status: StrategyStatus | None = None
@field_validator("nick")
@classmethod
def normalize_nick(cls, value: str | None) -> str | None:
if value is None:
raise ValueError("nick must not be null")
normalized = value.strip()
if not normalized:
raise ValueError("nick must not be blank")
return normalized
@model_validator(mode="after")
def require_change(self) -> "StrategyUpdateRequest":
if not self.model_fields_set:
+36 -13
View File
@@ -46,26 +46,49 @@ class StrategySetService:
return self.get(strategy_set_id, active_only=True)
def create(self, payload: CreateStrategySetRequest) -> StrategySet:
create_strategy = cast(
Callable[..., StrategySet] | None,
getattr(self._repository, "create_strategy_set", None),
)
if create_strategy is not None:
strategy = create_strategy(payload.name, payload.config, status=payload.status)
else:
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
if payload.status != strategy.status:
strategy = self.update(
strategy.id,
UpdateStrategySetRequest(status=payload.status),
try:
create_strategy = cast(
Callable[..., StrategySet] | None,
getattr(self._repository, "create_strategy_set", None),
)
return strategy
if create_strategy is not None:
strategy = create_strategy(
payload.name,
payload.config,
nick=payload.nick,
status=payload.status,
)
else:
strategy = self._repository.get_or_create_strategy_set(payload.name, payload.config)
if payload.nick is not None and payload.nick != strategy.nick:
strategy = self.update(
strategy.id,
UpdateStrategySetRequest(nick=payload.nick),
)
if payload.status != strategy.status:
strategy = self.update(
strategy.id,
UpdateStrategySetRequest(status=payload.status),
)
return strategy
except StrategyConflictError:
raise
except ValueError as exc:
raise StrategyConflictError(str(exc)) from exc
def update(self, strategy_set_id: str, payload: UpdateStrategySetRequest) -> StrategySet:
try:
if payload.nick is None:
return self._repository.update_strategy_set(
strategy_set_id,
name=payload.name,
config=payload.config,
status=payload.status,
)
return self._repository.update_strategy_set(
strategy_set_id,
name=payload.name,
nick=payload.nick,
config=payload.config,
status=payload.status,
)
+14 -10
View File
@@ -1,11 +1,11 @@
# PortfolioHub v0.1.1 container contract
# PortfolioHub v0.1.2 container contract
## Publication is not deployment
Only a requester-authorized push of the strict tag `v0.1.1` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
Only a requester-authorized push of the strict tag `v0.1.2` can publish this revision. The workflow checks that the tag equals `[project].version`, checks out `github.sha`, runs the complete Python suite, builds one `linux/amd64` image from `uv.lock`, and tests those exact bytes before publication. It gives the same image exactly two selectors:
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.1.1`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.1.2`
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
@@ -13,9 +13,9 @@ It never publishes `latest`. Both selectors must report the same registry digest
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
```
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.1.1`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
OCI labels are fail-closed build inputs: `org.opencontainers.image.source`, the full 40-character `org.opencontainers.image.revision`, and strict `org.opencontainers.image.version=v0.1.2`. Publication does **not** deploy, replace, start, stop, or restart a production service. This repository provides no production Compose, Kubernetes, Helm, systemd, or Dockge configuration.
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.1.1 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
The Dockerfile frontend, Python base image, uv image, and GitHub Actions are digest/commit pinned. The workflow intentionally uses `--provenance=false` to preserve the tested `docker save` archive and build-once/no-rebuild identity. Registry-attached provenance, SBOM, signing, and attestations are a nonblocking v0.1.2 supply-chain gap; they must not be added by rebuilding a different image in the publish job.
## Image runtime
@@ -66,19 +66,19 @@ Values are read at process start; changing any value requires a container restar
| `PORTFOLIOHUB_BOOTSTRAP_PASSWORD` | optional; empty | **yes** | bootstrap password; inject temporarily and remove after first successful start |
| `PORTFOLIOHUB_ENVIRONMENT` | optional; `development` | no | reported environment metadata |
| `PORTFOLIOHUB_APP_NAME` | optional; `PortfolioHub` | no | application/OpenAPI display name |
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.1.1 | `0.1.1` | no | health/OpenAPI version metadata |
| `PORTFOLIOHUB_APP_VERSION` | do not override for v0.1.2 | `0.1.2` | no | health/OpenAPI version metadata |
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.1.1 application does not open Redis or Loki connections. They are not production dependencies of this image.
`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_PASSWORD`, `LOKI_HOST`, `LOKI_PORT`, and `LOKI_ENABLED` are still parsed for source compatibility but the inspected v0.1.2 application does not open Redis or Loki connections. They are not production dependencies of this image.
### Legacy and sibling mapping
The existing database variable names remain `TIMESCALEDB_*`; v0.1.1 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
The existing database variable names remain `TIMESCALEDB_*`; v0.1.2 deliberately does not invent a `DATABASE_URL` alias. Existing `PORTFOLIOHUB_{READ,TRADE_WRITE,ADMIN}_TOKENS` Bearer-token behavior remains compatible. `trex-core`'s `TREX_PORTFOLIO_HUB_BASE_URL` and TOML `portfolio_hub_token`, and `trex-web`'s server-only `PORTFOLIO_HUB_API_BASE_URL`, are **caller-side** settings: point them to `http://portfolio-hub:8000` (or the equivalent service URL) and do not inject them into this container.
## Liveness and readiness
- **Liveness:** unauthenticated `GET /health` must return HTTP 200 with `status=ok`. It is intentionally process-only and does not query PostgreSQL. `db_enabled` only reports configuration; it is not readiness.
- **Readiness:** run `pfhub --format json health-check` with the same runtime environment. With DB enabled it performs a repository ping, reads `alembic_version` without migration, compares it with the single packaged Alembic head, and exits nonzero on connectivity, missing schema, multiple heads, or revision mismatch.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.1.1`.
- **API identity:** `GET /openapi.json` is stable for smoke verification. Health version is `0.1.2`.
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
@@ -97,13 +97,17 @@ The installed image contains exactly **29** migration files and one Alembic head
Migration `0029` is additive/idempotent around the strategy catalog: it preserves existing strategy IDs and portfolio references, reuses the canonical `target_value`/`{}` strategy, and backfills catalog data. Before migration, inspect duplicate or invalid strategy references and take a backup. After migration, verify strategy listing and portfolio active-strategy fields before resuming writes.
v0.1.2 introduces no schema migration: the packaged inventory remains exactly 29 migration files and the single head remains `20260814_0029`. Operators already at `0029` must not expect or manufacture a new revision for this patch release.
A pre-Alembic legacy schema is detected only when all three tables `strategy_sets`, `portfolios`, and `portfolio_ledger` exist; the CLI stamps `0027` and then runs real upgrades `0028` and `0029`. This compatibility path must be used only after backup and schema review. A partially matching schema is not auto-stamped and must fail for operator investigation.
Image rollback is **not** database rollback. Selecting older application bytes does not reverse schema/data changes, tokens, or side effects. Database restore, downgrade, or forward-fix requires separate review and authorization; no automatic downgrade is provided.
## API verification and known caller blockers
v0.1.1 preserves Bearer authentication and the caller route surface observed in sibling source. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 29-file migration inventory, isolated PostgreSQL migration twice, readiness at `0029`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
v0.1.2 preserves Bearer authentication and the caller route surface observed in sibling source. The release gate runs the complete portfolio-hub source test suite (including auth success/failure, strategy catalog/data, portfolio and ledger APIs), then verifies the built image's health/OpenAPI surface, exact 29-file migration inventory, isolated PostgreSQL migration twice, readiness at `0029`, persistent-volume restart, non-root/read-only operation, secret canary absence, and graceful SIGTERM. Publication consumes the tested archive without rebuilding it.
The strategy catalog API change is additive: strategy responses expose `nick`, and admin create/update requests may set it. Existing clients may omit `nick`; supplied values are trimmed and blank-only values are rejected. trex-web v0.2.2 requires this backend contract, but that dependency does not resolve or waive the separate caller blockers below.
Route presence is **not** a positive cross-repository caller-compatibility claim. Direct source comparison found the following unresolved consumers, which the requester explicitly separated as BLOCKED follow-up rather than adding legacy shims to portfolio-hub:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portfoliohub"
version = "0.1.1"
version = "0.1.2"
description = "PortfolioHub API for portfolio management and ledger access"
readme = "README.md"
requires-python = ">=3.11"
+4 -4
View File
@@ -65,11 +65,11 @@ def test_checkout_identity_is_exact():
def test_project_version_must_match_release_tag(tmp_path):
project = tmp_path / "pyproject.toml"
project.write_text('[project]\nversion = "0.1.1"\n')
assert image_release.project_release_version(project) == "v0.1.1"
assert image_release.validate_release_event("push", "refs/tags/v0.1.1", COMMIT, "v0.1.1") == "v0.1.1"
project.write_text('[project]\nversion = "0.1.2"\n')
assert image_release.project_release_version(project) == "v0.1.2"
assert image_release.validate_release_event("push", "refs/tags/v0.1.2", COMMIT, "v0.1.2") == "v0.1.2"
with pytest.raises(ValueError, match="project version"):
image_release.validate_release_event("push", "refs/tags/v0.1.2", COMMIT, "v0.1.1")
image_release.validate_release_event("push", "refs/tags/v0.1.3", COMMIT, "v0.1.2")
def test_installed_smoke_has_import_cli_exact_inventory_and_cleanup():
inventory = tuple(
+7 -4
View File
@@ -47,10 +47,10 @@ def test_workflow_actions_are_commit_pinned_and_verify_version_is_project_derive
assert len(revision) == 40 and all(char in "0123456789abcdef" for char in revision)
verify = VERIFY.read_text()
assert "tomllib" in verify and 'RELEASE_VERSION=${RELEASE_VERSION}' in verify
assert 'RELEASE_VERSION=v0.1.1' not in verify
assert 'RELEASE_VERSION=v0.1.2' not in verify
def test_release_version_and_migration_contract_are_v011_head_0029():
def test_release_version_and_migration_contract_are_v012_head_0029():
project = (REPO / "pyproject.toml").read_text()
config = (REPO / "app/core/config.py").read_text()
migrations = sorted(
@@ -58,8 +58,8 @@ def test_release_version_and_migration_contract_are_v011_head_0029():
for path in (REPO / "app/db/alembic/versions").glob("*.py")
if path.name != "__init__.py"
)
assert 'version = "0.1.1"' in project
assert 'app_version: str = "0.1.1"' in config
assert 'version = "0.1.2"' in project
assert 'app_version: str = "0.1.2"' in config
assert len(migrations) == 29
assert migrations[-1].startswith("20260814_0029")
@@ -85,6 +85,9 @@ def test_operations_separate_publication_deployment_migration_and_db_rollback():
"Image rollback is **not** database rollback",
"read-only root filesystem",
"10001:10001",
"PortfolioHub v0.1.2 container contract",
"v0.1.2 introduces no schema migration",
"trex-web v0.2.2 requires this backend contract",
):
assert value in text
for forbidden in (
@@ -6,7 +6,6 @@ PostgreSQL endpoint. Normal unit-test environments skip this module.
from __future__ import annotations
import hashlib
import os
import subprocess
import threading
@@ -73,9 +72,12 @@ def _prepare_catalog_fixture(repo: PostgresPortfolioRepository) -> tuple[str, st
return strategy.id, asset_id, owner_user_id
def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
def test_concurrent_strategy_patch_preserves_existing_nick() -> None:
owner = _repo()
strategy_id, asset_id, owner_user_id = _prepare_catalog_fixture(owner)
initial = owner.get_strategy_set(strategy_id)
assert initial is not None
initial_nick = initial.nick
renamed = f"concurrent-renamed-{uuid.uuid4().hex}"
barrier = threading.Barrier(3)
errors: list[BaseException] = []
@@ -114,8 +116,7 @@ def test_concurrent_strategy_patch_keeps_derived_nick_consistent() -> None:
assert final.name == renamed
assert final.config == '{"window":21}'
assert final.revision == 3
expected_nick = f"{final.name}:{hashlib.sha1(final.config.encode()).hexdigest()[:12]}"
assert final.nick == expected_nick
assert final.nick == initial_nick
portfolio_id = str(uuid.uuid4())
owner.create_portfolio(
portfolio_id,
@@ -288,7 +289,7 @@ def test_alembic_upgrade_is_repeatable_on_fresh_postgres_database() -> None:
)
for _ in range(2):
subprocess.run(
[str(_ROOT / ".venv/bin/python"), "-m", "alembic", "upgrade", "head"],
[str(_ROOT / ".venv/bin/pfhub"), "migrate-db"],
cwd=_ROOT,
env=env,
check=True,
+128 -9
View File
@@ -43,19 +43,29 @@ class FakeStrategyRepo:
def get_or_create_strategy_set(self, name: str, config: str) -> StrategySet:
return self.create_strategy_set(name, config)
def create_strategy_set(self, name: str, config: str, *, status: str = "active") -> StrategySet:
def create_strategy_set(
self,
name: str,
config: str,
*,
nick: str | None = None,
status: str = "active",
) -> StrategySet:
existing = next(
(item for item in self.strategies.values() if item.name == name and item.config == config),
None,
)
if existing is not None:
return existing
final_nick = nick or f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}"
if any(item.nick == final_nick for item in self.strategies.values()):
raise ValueError("동일한 nick 전략이 이미 존재합니다")
now = datetime.now(timezone.utc)
item = StrategySet(
id=f"created-{len(self.strategies)}",
name=name,
config=config,
nick=f"{name}:{hashlib.sha1(config.encode()).hexdigest()[:12]}",
nick=final_nick,
status=status,
created_at=now,
updated_at=now,
@@ -68,6 +78,7 @@ class FakeStrategyRepo:
id: str,
*,
name: str | None = None,
nick: str | None = None,
config: str | None = None,
status: str | None = None,
) -> StrategySet:
@@ -75,19 +86,27 @@ class FakeStrategyRepo:
if item is None:
raise ValueError("전략을 찾을 수 없습니다")
final_name = name if name is not None else item.name
final_nick = nick if nick is not None else item.nick
final_config = config if config is not None else item.config
if any(
other.id != id and other.name == final_name and other.config == final_config
for other in self.strategies.values()
):
raise ValueError("동일한 name/config 전략이 이미 존재합니다")
if any(other.id != id and other.nick == final_nick for other in self.strategies.values()):
raise ValueError("동일한 nick 전략이 이미 존재합니다")
final_status = status if status is not None else item.status
if (final_name, final_config, final_status) == (item.name, item.config, item.status):
if (final_name, final_nick, final_config, final_status) == (
item.name,
item.nick,
item.config,
item.status,
):
return item
item.name = final_name
item.nick = final_nick
item.config = final_config
item.status = final_status
item.nick = f"{final_name}:{hashlib.sha1(final_config.encode()).hexdigest()[:12]}"
item.revision += 1
item.updated_at = datetime.now(timezone.utc)
return item
@@ -126,9 +145,13 @@ def test_public_catalog_excludes_inactive_and_inactive_lookup_conflicts() -> Non
repo = FakeStrategyRepo()
with _repos(repo):
response = _client().get("/v1/strategies", headers=_auth("read-token"))
active = _client().get("/v1/strategies/active-1", headers=_auth("read-token"))
inactive = _client().get("/v1/strategies/inactive-1", headers=_auth("read-token"))
assert response.status_code == 200
assert [item["strategy_id"] for item in response.json()] == ["active-1"]
assert response.json()[0]["nick"] == "target_value:base"
assert active.status_code == 200
assert active.json()["nick"] == "target_value:base"
assert inactive.status_code == 409
@@ -141,32 +164,128 @@ def test_admin_catalog_requires_admin_and_reports_missing_strategy() -> None:
assert missing.status_code == 404
def test_admin_create_and_patch_preserve_id_and_revision_semantics() -> None:
def test_admin_create_custom_nick_and_patch_nick_only_preserve_id_and_increment_revision() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "config": {"window": 20}},
json={"name": "momentum", "nick": "mom-20", "config": {"window": 20}},
headers=_auth("admin-token"),
)
strategy_id = created.json()["strategy_id"]
changed = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2"},
json={"nick": "mom-primary"},
headers=_auth("admin-token"),
)
no_op = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2"},
json={"nick": "mom-primary"},
headers=_auth("admin-token"),
)
assert created.status_code == 201
assert created.json()["nick"] == "mom-20"
assert created.json()["revision"] == 1
assert changed.status_code == 200
assert changed.json()["strategy_id"] == strategy_id
assert changed.json()["nick"] == "mom-primary"
assert changed.json()["revision"] == 2
assert no_op.json()["revision"] == 2
assert repo.strategies[strategy_id].nick.startswith("momentum-v2:")
assert repo.strategies[strategy_id].nick == "mom-primary"
def test_admin_create_trims_nick() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "nick": " mom-20 ", "config": {"window": 20}},
headers=_auth("admin-token"),
)
assert created.status_code == 201
assert created.json()["nick"] == "mom-20"
def test_admin_create_and_patch_reject_whitespace_only_nick() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "nick": " \t ", "config": {"window": 20}},
headers=_auth("admin-token"),
)
changed = _client().patch(
"/v1/admin/strategies/active-1",
json={"nick": " \n "},
headers=_auth("admin-token"),
)
assert created.status_code == 422
assert changed.status_code == 422
assert repo.strategies["active-1"].nick == "target_value:base"
def test_admin_patch_omitting_nick_preserves_custom_nick_when_name_and_config_change() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "momentum", "nick": "mom-custom", "config": {"window": 20}},
headers=_auth("admin-token"),
)
strategy_id = created.json()["strategy_id"]
changed = _client().patch(
f"/v1/admin/strategies/{strategy_id}",
json={"name": "momentum-v2", "config": {"window": 30}},
headers=_auth("admin-token"),
)
assert changed.status_code == 200
assert changed.json()["nick"] == "mom-custom"
assert changed.json()["revision"] == 2
def test_admin_create_omitting_nick_retains_legacy_generated_value() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "legacy", "config": {"window": 20}},
headers=_auth("admin-token"),
)
canonical_config = '{"window":20}'
expected = f"legacy:{hashlib.sha1(canonical_config.encode()).hexdigest()[:12]}"
assert created.status_code == 201
assert created.json()["nick"] == expected
def test_admin_create_and_patch_reject_explicit_null_nick_without_mutation() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
created = _client().post(
"/v1/admin/strategies",
json={"name": "legacy-null", "nick": None, "config": {}},
headers=_auth("admin-token"),
)
changed = _client().patch(
"/v1/admin/strategies/active-1",
json={"nick": None},
headers=_auth("admin-token"),
)
assert created.status_code == 422
assert changed.status_code == 422
assert set(repo.strategies) == {"active-1", "inactive-1"}
assert repo.strategies["active-1"].nick == "target_value:base"
assert repo.strategies["active-1"].revision == 1
def test_admin_duplicate_nick_returns_conflict() -> None:
repo = FakeStrategyRepo()
with _repos(repo):
duplicate = _client().post(
"/v1/admin/strategies",
json={"name": "duplicate", "nick": "target_value:base", "config": {}},
headers=_auth("admin-token"),
)
assert duplicate.status_code == 409
def test_admin_create_inactive_starts_at_revision_one() -> None:
Generated
+1 -1
View File
@@ -592,7 +592,7 @@ wheels = [
[[package]]
name = "portfoliohub"
version = "0.1.1"
version = "0.1.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },