feat(container): harden v0.1.1 publication

This commit is contained in:
2026-08-15 15:14:36 +09:00
parent fd080496b3
commit 9c8baac70b
14 changed files with 287 additions and 72 deletions
+90 -30
View File
@@ -1,56 +1,116 @@
# PortfolioHub container publication and operator handoff
# PortfolioHub v0.1.1 container contract
## Publication is not deployment
Only a requester-authorized strict `vX.Y.Z` tag push may publish. The workflow builds once and gives the same tested image exactly two selectors:
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:
- `ghcr.io/fourblocks2020/portfolio-hub:<source-commit-first-12>`
- `ghcr.io/fourblocks2020/portfolio-hub:vX.Y.Z`
- `ghcr.io/fourblocks2020/portfolio-hub:v0.1.1`
It never publishes `latest`. The registry digest is the immutable artifact identity:
It never publishes `latest`. Both selectors must report the same registry digest; deployment and rollback must use the immutable identity:
```text
ghcr.io/fourblocks2020/portfolio-hub@sha256:<64-lowercase-hex-digest>
```
Publication does **not** deploy, place, replace, start, stop, or restart a container in any environment. This repository change adds no production Compose, Kubernetes, Helm, systemd, Dockge, or other deployment configuration. An authorized deployment system and operator must consume the digest separately.
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.
## Operator handoff contract
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.
A successful publication hands the operator or deployment system only these verified inputs:
## Image runtime
- exact `vX.Y.Z` source tag;
- full source commit and OCI `org.opencontainers.image.revision`;
- immutable registry digest;
- matching commit-prefix and version selectors;
- successful tested-image workflow evidence.
- platform: `linux/amd64`;
- process: exec-form `uvicorn app.main:app --host 0.0.0.0 --port 8000` as PID 1;
- network: HTTP on fixed container port `8000` (configure only the host/service mapping, not the container port);
- identity: numeric UID/GID `10001:10001`;
- filesystem: no application data path is written; the root filesystem may be read-only;
- temporary space: a bounded `/tmp` tmpfs is safe but not required by normal application behavior;
- shutdown: send `SIGTERM`, allow at least 10 seconds for Uvicorn graceful shutdown, then escalate only under the deployment platform's policy;
- restart: application replicas may use the platform's normal failure restart policy; the one-shot migration must use restart `no` and must never loop after failure.
Before any separately authorized use, verify that the digest and OCI revision identify the intended source. The publication workflow itself does not claim runtime deployment or production readiness.
Recommended runtime restrictions are a read-only root filesystem, all capabilities dropped, `no-new-privileges`, a bounded PID limit, and no Docker socket or host filesystem mounts. No build argument, label, layer, or image environment contains an application secret.
## Required pre-start migration contract
## Environment contract
Database migration is a separate, explicit pre-start operation using the exact image digest and runtime-injected production settings:
Values are read at process start; changing any value requires a container restart. Empty optional values mean the shown default/disabled behavior.
1. execute `pfhub migrate-db` as a one-shot operation;
2. stop on nonzero migration result;
3. start the application only after migration succeeds;
4. evaluate HTTP liveness and database readiness separately.
### Production database and secrets
The image's default application command does not run migrations. Do not run migration implicitly from every application replica. Production migration execution remains outside this implementation and requires separate authorization.
| Variable | Production contract | Default | Secret | Effect |
| --- | --- | --- | --- | --- |
| `PORTFOLIOHUB_DB_ENABLED` | required; `true` for persistent production | `false` | no | Selects PostgreSQL instead of in-memory repository |
| `TIMESCALEDB_HOST` | required when DB enabled | `localhost` | no | PostgreSQL host |
| `TIMESCALEDB_PORT` | optional | `5432` | no | PostgreSQL port |
| `TIMESCALEDB_NAME` | required when DB enabled | `portfoliohub` | no | database name |
| `TIMESCALEDB_USER` | required when DB enabled | `portfoliohub` | usually sensitive | database user |
| `TIMESCALEDB_PASSWORD` | required by production DB policy | empty | **yes** | database password |
| `TIMESCALEDB_SSLMODE` | optional; set explicitly in production | `prefer` | no | psycopg SSL mode |
| `TIMESCALEDB_CONNECT_TIMEOUT_SEC` | optional | `10` | no | connection timeout |
| `TIMESCALEDB_APPLICATION_NAME` | optional | `portfoliohub` | no | PostgreSQL application name |
| `CREDENTIAL_ENCRYPTION_KEY` | required when DB enabled | empty | **yes** | Fernet key for stored broker credentials; startup fails closed if absent |
## Runtime security inputs
### Authentication, bootstrap, and HTTP behavior
The image contract provides:
| Variable | Required/default | Secret | Effect |
| --- | --- | --- | --- |
| `PORTFOLIOHUB_ALLOWED_ORIGINS` | optional; `http://localhost:3000` | no | comma-separated CORS origins; set the trex-web origin in production |
| `PORTFOLIOHUB_ROOT_PATH` | optional; empty | no | reverse-proxy ASGI root path; does not rewrite the actual route paths |
| `PORTFOLIOHUB_READ_TOKENS` | optional; empty | **yes** | comma-separated legacy read service tokens |
| `PORTFOLIOHUB_TRADE_WRITE_TOKENS` | optional; empty | **yes** | comma-separated legacy ledger-write service tokens |
| `PORTFOLIOHUB_ADMIN_TOKENS` | optional; empty | **yes** | comma-separated legacy admin service tokens |
| `PORTFOLIOHUB_SESSION_TOKEN_TTL_HOURS` | optional; `12` | no | login session lifetime |
| `PORTFOLIOHUB_CLIENT_TOKEN_DEFAULT_TTL_DAYS` | optional; `365` | no | retained client-token default |
| `PORTFOLIOHUB_BOT_TOKEN_DEFAULT_TTL_DAYS` | optional; `365` | no | bot-token default |
| `PORTFOLIOHUB_BOOTSTRAP_USERNAME` | optional; empty | no | with password, creates the first admin only when no users exist |
| `PORTFOLIOHUB_BOOTSTRAP_EMAIL` | optional; empty | personal data | bootstrap user email |
| `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 |
- default UID/GID `10001:10001`;
- explicit command `uvicorn app.main:app --host 0.0.0.0 --port 8000`;
- no application-required writable data path;
- migration resources installed inside the Python package.
`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.
A deployment system should enforce a read-only root filesystem, dropped capabilities, no-new-privileges, and only a bounded temporary filesystem when needed. PostgreSQL credentials, token configuration, and `CREDENTIAL_ENCRYPTION_KEY` are runtime secrets; they must never be baked into the image or publication artifact. Secret injection and writable-mount configuration belong to the separately authorized deployment environment, not this repository change.
### Legacy and sibling mapping
## Image and database rollback boundary
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.
Rollback identity is a previously verified immutable image digest whose OCI revision is known. This document defines that identity boundary only; it does not add or execute rollback automation.
## Liveness and readiness
Image rollback is **not** database rollback. Selecting older application bytes does not reverse Alembic migrations, schema or data changes, tokens, or external side effects. Database restore, downgrade, or forward-fix requires a separately reviewed and authorized operational procedure.
- **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`.
Do not route traffic from a static `/health` success alone. Readiness checks never apply, stamp, or repair migrations.
## One-shot migration and 0028 -> 0029 policy
The installed image contains exactly **29** migration files and one Alembic head, `20260814_0029`. The application default command does not invoke Alembic. Migration is a separate pre-start operation using the exact same digest and database environment:
1. back up the database and record the current immutable image digest and `alembic_version`;
2. require the normal upgrade origin `20260715_0028`; for an older revision, review and apply the full ordered chain first;
3. stop writes or otherwise establish an operator-approved maintenance boundary;
4. execute `pfhub migrate-db` once with restart disabled;
5. stop rollout on any nonzero result and preserve sanitized logs;
6. run `pfhub --format json health-check` and require both `database_revision` and `expected_revision` to equal `20260814_0029`;
7. start application replicas and verify `/health`, `/openapi.json`, authenticated caller paths, and strategy data;
8. a repeated `pfhub migrate-db` is expected to be idempotent and remain at `0029`, but it is verification—not an application-start hook.
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.
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.
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:
- trex-web omits required `strategy_id` when creating a portfolio, so that mutation currently returns `422`;
- trex-web can omit required `account_ref` when creating or rotating credentials, so those requests currently return `422`;
- trex-web sends daemon activation state through the general PATCH route, which can return `200` without changing activation; it must use the activate/deactivate routes;
- portfolio-hub returns singular `broker` from `/v1/user_info`, while trex-web types `brokers[]`;
- portfolio-hub represents duplicate ledger success as `status="duplicate"`, while trex-core currently checks `duplicate=true`, so the core result classification is inconsistent.
Do not claim trex-core/trex-web compatibility or switch production callers until those consumer changes and cross-repository tests are completed.