Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee1af6ef49 | ||
|
|
fe2dd3d15e | ||
|
|
c292df0a4b | ||
|
|
e6a90800ec | ||
|
|
acfaab7fd4 |
@@ -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"
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user