diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..61c14da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Target Revenue Trust Service + Control Plane (WP-0011). +# Image: forgejo.coulomb.social/coulomb/target-revenue +FROM python:3.12-slim AS runtime + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + HOME=/home/app + +RUN useradd --create-home --home-dir /home/app --uid 10001 app + +WORKDIR /src +COPY pyproject.toml README.md LICENSE ./ +COPY src/target_revenue ./src/target_revenue +COPY migrations ./migrations +COPY schemas ./schemas +COPY specs/policies ./specs/policies +COPY specs/profiles ./specs/profiles + +RUN pip install --no-cache-dir --index-url https://pypi.org/simple ".[service]" \ + && chown -R app:app /src + +COPY scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +COPY scripts/apply_migrations.py /usr/local/bin/apply_migrations.py +COPY scripts/bootstrap_binky.py /usr/local/bin/bootstrap_binky.py +RUN chmod +x /usr/local/bin/docker-entrypoint.sh \ + /usr/local/bin/apply_migrations.py \ + /usr/local/bin/bootstrap_binky.py + +USER app +EXPOSE 8000 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/README.md b/README.md index 774b2c4..3fd714c 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ The concept's §13 now defines a **Global Contingency Share Determination Rule** | [TREV-WP-0008](workplans/TREV-WP-0008-governance-and-pilot-rollout.md) | Governance formalization + pilot rollout — **finished**, all 5 tasks done. **T05 accepted 2026-08-05**: first real Phase is `trsl:phase:info-tech-canon-service-surface` (`history/260805-T05-GoLive-info-tech-canon.md`). Org-wide TRSL LICENSE adoption (2026-07-30) was a prior, separate act | | [TREV-WP-0009](workplans/TREV-WP-0009-target-revenue-control-plane.md) | Target Revenue Control Plane — interactive UI for the `binky` tenant, incl. interactive Development Credit entry creation (`specs/TargetRevenueControlPlaneConcept.md`) — **finished**, all 4 tasks done. **T04 (interactive UI, `src/target_revenue/service/control_plane_app.py`) built on vendored `whynot-design` web components** rather than from scratch, per an explicit feasibility check | | [TREV-WP-0010](workplans/TREV-WP-0010-development-effort-calculator.md) | Development Effort Calculator — **finished**, all 3 tasks done. Applied to the three real pilot candidates (`history/260730-EffortCalculator-CandidateApplication.md`) — every calculator-derived Initial Target came out materially lower than the earlier hand-picked placeholders, two of three carrying explicit warnings recommending manual review | -| [TREV-WP-0011](workplans/TREV-WP-0011-railiance-reef-deployment.md) | Deploy Trust Service + Control Plane to the Railiance reef, fronted by `revenue.coulomb.social` — active; T01 (deployment-pattern decision, human gate) next. Deployment alone does not authorize a real Phase go-live — that remains WP-0008-T05 | +| [TREV-WP-0011](workplans/TREV-WP-0011-railiance-reef-deployment.md) | Deploy Trust Service + Control Plane to Railiance (`revenue.coulomb.social`) — **active**; T01–T04 done (Dockerfile, `k8s/railiance/`, ArgoCD Application). **T05** remains: operator DNS/OpenBao/image-push/sync + pilot data migration. Guide: [`docs/deployment.md`](docs/deployment.md) | | [TREV-WP-0012](workplans/TREV-WP-0012-phase-provenance-and-policy-modeling.md) | Phase provenance, ledger reference, and degeneration-policy modeling — **finished**, all 5 tasks done. Decisions (T02–T04) synthesized into [`specs/PhaseProvenanceSpecAddendum.md`](specs/PhaseProvenanceSpecAddendum.md) (T05) — **not yet accepted for implementation**; that's the document to discuss before any schema/UI work is filed as its own workplan | | [TREV-WP-0013](workplans/TREV-WP-0013-remission-credit-automation.md) | Remission Credit automation (degeneration policy execution) — **finished**, all 3 tasks done. Pure `plan_remission` + hosted apply with `system:policy-engine` actor; monthly UTC schedule; Control Plane forecast + apply button | | [TREV-WP-0014](workplans/TREV-WP-0014-control-plane-extensions-breach-attestation-ui.md) | Control Plane UI: Extension Registry, Breach Records, Conversion Attestation — **finished**, all 4 tasks done. Operator+ register extension / Admin canonicalize; breach form with anonymized-by-default; attestation on phase detail | diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..b822eea --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,69 @@ +# Deploying Target Revenue Trust Service + Control Plane + +**Workplan:** `workplans/TREV-WP-0011-railiance-reef-deployment.md` +**Public host (target):** `https://revenue.coulomb.social` +**Control Plane UI:** `https://revenue.coulomb.social/ui/` +**Trust Service API:** `https://revenue.coulomb.social/` (e.g. `/phases/...`, `/public-key`) + +This deployment does not by itself authorize new Phases beyond what +WP-0008-T05 already recorded. The first pilot Phase is +`trsl:phase:info-tech-canon-service-surface` +(`history/260805-T05-GoLive-info-tech-canon.md`). + +--- + +## T01 decision (2026-08-05) + +| Bullet | Decision | +| --- | --- | +| Manifest home | In-repo `k8s/railiance/` (issue-core pattern); ArgoCD Application in `railiance-platform` | +| Process topology | **Single process**: `combined.py` mounts Control Plane at `/ui`, Trust Service at `/` | +| Database | Dedicated CNPG Cluster `target-revenue-pg` in namespace `target-revenue` (not state-hub DB) | +| Secrets | OpenBao + ExternalSecret → `target-revenue-runtime`; CNPG role secrets operator-seeded | +| Subdomain / TLS | `revenue.coulomb.social`, `cert-manager.io/cluster-issuer: letsencrypt-prod` (same as `forgejo.coulomb.social`) | +| Founding credential | Idempotent Job `target-revenue-bootstrap-binky` (`scripts/bootstrap_binky.py`) | + +--- + +## Operator checklist (first bring-up) + +1. **DNS:** point `revenue.coulomb.social` at the Railiance Traefik/ingress (same path as other `*.coulomb.social` hosts on railiance01). +2. **OpenBao:** create KV path `workloads/target-revenue/runtime-secrets` with: + - `TRF_SIGNING_KEY_HEX` (64 hex chars) + - `TRF_CONTROL_PLANE_SECRET_KEY` + - `TRF_DATABASE_URL` (trf_app DSN to `target-revenue-pg-rw`) + - `TRF_MIGRATE_DATABASE_URL` (trf_owner DSN for migrate/bootstrap Jobs) +3. **CNPG bootstrap secrets** in namespace `target-revenue` (before Cluster reconciles): + - `target-revenue-pg-credentials` — keys as required by CNPG for owner `trf_owner` + - `target-revenue-trf-app-credentials` — password for managed role `trf_app` +4. **Image:** build and push + `forgejo.coulomb.social/coulomb/target-revenue:0.1.0` + (see Dockerfile; bump tag in Deployment + Jobs when releasing). +5. **ArgoCD:** apply + `railiance-platform/argocd/applications/target-revenue.application.yaml` + (once `main` contains `k8s/railiance/`). +6. **After migrate + bootstrap Jobs succeed:** pull founding token from bootstrap Job logs (`TOKEN=...`), store offline; sign in at `/ui/login`. +7. **Migrate pilot Phase data** from the local go-live instance (`trf-pilot-pg` / `:8765`) if that instance still holds the authoritative ledger — export Manifest + ledger JSON and re-register/append on the public instance (or promote the pilot DB dump deliberately). Do not leave two competing ledgers. + +--- + +## Local pilot instance (already used for T05) + +| Item | Value | +| --- | --- | +| API | `http://127.0.0.1:8765` | +| Postgres container | `trf-pilot-pg` / volume `trf-pilot-pgdata` / port `55432` | +| Token file | `~/.config/target-revenue/pilot-binky.token` | + +```bash +python scripts/trf_onboard.py status \ + --url http://127.0.0.1:8765 \ + --phase-id trsl:phase:info-tech-canon-service-surface +``` + +--- + +## Health + +- Combined / standalone: `GET /healthz` → `{"status":"ok"}` (no DB dependency) +- Control Plane when mounted: `GET /ui/healthz` diff --git a/k8s/railiance/bootstrap-job.yaml b/k8s/railiance/bootstrap-job.yaml new file mode 100644 index 0000000..73b91f2 --- /dev/null +++ b/k8s/railiance/bootstrap-job.yaml @@ -0,0 +1,54 @@ +# Idempotent founding binky admin credential (WP-0011-T01/T05). +# Safe to re-run: no-op when a binky credential already exists. +# Capture TOKEN= from job logs once on first bootstrap; store in a password +# manager — never commit. +apiVersion: batch/v1 +kind: Job +metadata: + name: target-revenue-bootstrap-binky + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: bootstrap + annotations: + argocd.argoproj.io/sync-wave: "2" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + ttlSecondsAfterFinished: 86400 + backoffLimit: 2 + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: bootstrap + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: bootstrap + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + env: + - name: TRF_BOOTSTRAP_BINKY + value: "1" + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_MIGRATE_DATABASE_URL + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/railiance/cnpg-cluster.yaml b/k8s/railiance/cnpg-cluster.yaml new file mode 100644 index 0000000..e153794 --- /dev/null +++ b/k8s/railiance/cnpg-cluster.yaml @@ -0,0 +1,36 @@ +# Dedicated CNPG Cluster for Target Revenue Trust Service pilot data (WP-0011). +# Co-located in the target-revenue workload namespace so one ArgoCD Application +# can own the full stack (issue-core pattern + database). +# +# Pre-req secrets in namespace target-revenue (OpenBao ExternalSecret or +# operator-seeded): target-revenue-pg-credentials, target-revenue-trf-app-credentials. +# See docs/deployment.md. +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: target-revenue-pg + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue-pg + app.kubernetes.io/part-of: target-revenue + app.kubernetes.io/component: database + annotations: + argocd.argoproj.io/sync-wave: "-2" +spec: + instances: 1 + imageName: ghcr.io/cloudnative-pg/postgresql:16 + storage: + size: 5Gi + bootstrap: + initdb: + database: target_revenue + owner: trf_owner + secret: + name: target-revenue-pg-credentials + managed: + roles: + - name: trf_app + ensure: present + login: true + passwordSecret: + name: target-revenue-trf-app-credentials diff --git a/k8s/railiance/deployment.yaml b/k8s/railiance/deployment.yaml new file mode 100644 index 0000000..d6796c3 --- /dev/null +++ b/k8s/railiance/deployment.yaml @@ -0,0 +1,78 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + argocd.argoproj.io/sync-wave: "3" +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: target-revenue + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: target-revenue + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8000 + env: + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_DATABASE_URL + - name: TRF_SIGNING_KEY_HEX + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_SIGNING_KEY_HEX + - name: TRF_CONTROL_PLANE_SECRET_KEY + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_CONTROL_PLANE_SECRET_KEY + readinessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: http + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + requests: + cpu: 50m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/k8s/railiance/externalsecret.yaml b/k8s/railiance/externalsecret.yaml new file mode 100644 index 0000000..f471265 --- /dev/null +++ b/k8s/railiance/externalsecret.yaml @@ -0,0 +1,46 @@ +# Runtime secrets for Trust Service + Control Plane (WP-0011). +# +# OpenBao path (platform KV v2): workloads/target-revenue/runtime-secrets +# Fields: +# TRF_SIGNING_KEY_HEX — 64 hex chars Ed25519 seed (32 bytes) +# TRF_CONTROL_PLANE_SECRET_KEY — session cookie signing secret +# TRF_DATABASE_URL — psycopg DSN for trf_app role +# TRF_MIGRATE_DATABASE_URL — DSN for trf_owner (migrate/bootstrap Jobs) +# +# Pre-reqs: ESO + ClusterSecretStore openbao (railiance-platform); +# OpenBao policy granting read of the path above. +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: target-revenue-runtime + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + refreshInterval: 1h + secretStoreRef: + name: openbao + kind: ClusterSecretStore + target: + name: target-revenue-runtime + creationPolicy: Owner + data: + - secretKey: TRF_SIGNING_KEY_HEX + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_SIGNING_KEY_HEX + - secretKey: TRF_CONTROL_PLANE_SECRET_KEY + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_CONTROL_PLANE_SECRET_KEY + - secretKey: TRF_DATABASE_URL + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_DATABASE_URL + - secretKey: TRF_MIGRATE_DATABASE_URL + remoteRef: + key: workloads/target-revenue/runtime-secrets + property: TRF_MIGRATE_DATABASE_URL diff --git a/k8s/railiance/ingress.yaml b/k8s/railiance/ingress.yaml new file mode 100644 index 0000000..dfa95f8 --- /dev/null +++ b/k8s/railiance/ingress.yaml @@ -0,0 +1,30 @@ +# Public surface: revenue.coulomb.social (WP-0011). +# Trust Service API at / ; Control Plane UI at /ui/ (combined ASGI app). +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + argocd.argoproj.io/sync-wave: "4" +spec: + ingressClassName: traefik + rules: + - host: revenue.coulomb.social + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: target-revenue + port: + number: 80 + tls: + - hosts: + - revenue.coulomb.social + secretName: target-revenue-tls diff --git a/k8s/railiance/kustomization.yaml b/k8s/railiance/kustomization.yaml new file mode 100644 index 0000000..5de0d14 --- /dev/null +++ b/k8s/railiance/kustomization.yaml @@ -0,0 +1,15 @@ +# Target Revenue Trust Service + Control Plane on Railiance (WP-0011). +# Synced by ArgoCD Application target-revenue (path k8s/railiance). +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: target-revenue + +resources: + - cnpg-cluster.yaml + - externalsecret.yaml + - migrate-job.yaml + - bootstrap-job.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/k8s/railiance/migrate-job.yaml b/k8s/railiance/migrate-job.yaml new file mode 100644 index 0000000..7daf9b3 --- /dev/null +++ b/k8s/railiance/migrate-job.yaml @@ -0,0 +1,52 @@ +# One-shot migrations Job (WP-0011-T03). Re-run by deleting the Job or +# bumping the name suffix when a new migration ships. +apiVersion: batch/v1 +kind: Job +metadata: + name: target-revenue-migrate + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: migrate + annotations: + argocd.argoproj.io/sync-wave: "1" + argocd.argoproj.io/hook: Sync + argocd.argoproj.io/hook-delete-policy: BeforeHookCreation +spec: + ttlSecondsAfterFinished: 86400 + backoffLimit: 3 + template: + metadata: + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/component: migrate + spec: + restartPolicy: OnFailure + securityContext: + runAsNonRoot: true + runAsUser: 10001 + fsGroup: 10001 + containers: + - name: migrate + image: forgejo.coulomb.social/coulomb/target-revenue:0.1.0 + imagePullPolicy: IfNotPresent + env: + - name: TRF_RUN_MIGRATIONS + value: "1" + - name: TRF_DATABASE_URL + valueFrom: + secretKeyRef: + name: target-revenue-runtime + key: TRF_MIGRATE_DATABASE_URL + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi diff --git a/k8s/railiance/service.yaml b/k8s/railiance/service.yaml new file mode 100644 index 0000000..73eb039 --- /dev/null +++ b/k8s/railiance/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: target-revenue + namespace: target-revenue + labels: + app.kubernetes.io/name: target-revenue + app.kubernetes.io/part-of: target-revenue +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: target-revenue + ports: + - name: http + port: 80 + targetPort: http diff --git a/scripts/apply_migrations.py b/scripts/apply_migrations.py new file mode 100755 index 0000000..364cc0f --- /dev/null +++ b/scripts/apply_migrations.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Apply SQL migrations in order against TRF_DATABASE_URL (WP-0011). + +Idempotent migrations (IF NOT EXISTS / OR REPLACE). Safe to re-run. +Uses the admin/bootstrap DSN — typically the CNPG owner role, not trf_app. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import psycopg + +MIGRATIONS_DIR = Path(os.environ.get("TRF_MIGRATIONS_DIR", "/src/migrations")) + + +def main() -> None: + dsn = os.environ.get("TRF_DATABASE_URL") or os.environ.get("TRF_MIGRATE_DATABASE_URL") + if not dsn: + print("TRF_DATABASE_URL or TRF_MIGRATE_DATABASE_URL is required", file=sys.stderr) + sys.exit(1) + files = sorted(MIGRATIONS_DIR.glob("*.sql")) + if not files: + print(f"no migrations in {MIGRATIONS_DIR}", file=sys.stderr) + sys.exit(1) + with psycopg.connect(dsn) as conn: + for path in files: + print(f"applying {path.name} ...") + conn.execute(path.read_text(encoding="utf-8")) + conn.commit() + print(f" ok {path.name}") + print(f"applied {len(files)} migration(s)") + + +if __name__ == "__main__": + main() diff --git a/scripts/bootstrap_binky.py b/scripts/bootstrap_binky.py new file mode 100755 index 0000000..cb91995 --- /dev/null +++ b/scripts/bootstrap_binky.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Idempotent founding `binky` admin credential bootstrap (WP-0011-T01). + +If no active binky credential exists, issues one labeled ``founding-admin`` +with admin rights and prints the token once to stdout (and optionally +writes it to TRF_BOOTSTRAP_TOKEN_FILE). Re-runs are no-ops when a +credential already exists — they print the existing label, not a new token. +""" + +from __future__ import annotations + +import os +import sys + +import psycopg + +from target_revenue import registry + + +def main() -> None: + dsn = os.environ.get("TRF_DATABASE_URL") + if not dsn: + print("TRF_DATABASE_URL is required", file=sys.stderr) + sys.exit(1) + label = os.environ.get("TRF_BOOTSTRAP_LABEL", "founding-admin") + with psycopg.connect(dsn) as conn: + row = conn.execute( + """ + SELECT token, credential_label, rights + FROM licensors + WHERE licensor_id = 'binky' AND revoked_at IS NULL + ORDER BY created_at ASC + LIMIT 1 + """ + ).fetchone() + if row is not None: + token, existing_label, rights = row + print( + f"binky already has active credential label={existing_label!r} " + f"rights={rights!r} — bootstrap no-op (token not re-printed)" + ) + conn.commit() + return + cred = registry.issue_sub_credential( + conn, + licensor_id="binky", + credential_label=label, + rights="admin", + issued_by="bootstrap_binky", + ) + conn.commit() + print(f"issued binky credential label={cred.credential_label!r}") + print(f"TOKEN={cred.token}") + out = os.environ.get("TRF_BOOTSTRAP_TOKEN_FILE") + if out: + with open(out, "w", encoding="utf-8") as f: + f.write(cred.token) + print(f"wrote token to {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100755 index 0000000..75e4410 --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Container entrypoint (WP-0011): combined Trust Service + Control Plane. +set -e +# Optional: run migrations when TRF_RUN_MIGRATIONS=1 (used by migrate Job). +if [ "${TRF_RUN_MIGRATIONS:-0}" = "1" ]; then + exec python /usr/local/bin/apply_migrations.py +fi +if [ "${TRF_BOOTSTRAP_BINKY:-0}" = "1" ]; then + exec python /usr/local/bin/bootstrap_binky.py +fi +exec python -m uvicorn target_revenue.service.combined:app \ + --host 0.0.0.0 \ + --port 8000 \ + --proxy-headers \ + --forwarded-allow-ips='*' diff --git a/src/target_revenue/service/app.py b/src/target_revenue/service/app.py index 8de7ef2..8403f93 100644 --- a/src/target_revenue/service/app.py +++ b/src/target_revenue/service/app.py @@ -106,6 +106,12 @@ def read_extension( return result +@app.get("/healthz") +def healthz() -> dict[str, str]: + """Liveness/readiness probe target (WP-0011) — no auth, no DB.""" + return {"status": "ok"} + + @app.get("/public-key") def read_public_key() -> dict[str, str]: """The Ed25519 public key ledger entry signatures verify against. diff --git a/src/target_revenue/service/combined.py b/src/target_revenue/service/combined.py new file mode 100644 index 0000000..f59bf3c --- /dev/null +++ b/src/target_revenue/service/combined.py @@ -0,0 +1,32 @@ +"""Single-process ASGI app: Trust Service + Control Plane (WP-0011). + +Both FastAPI apps remain independently importable for local/dev +(`app.py`, `control_plane_app.py`). This module composes them for the +container entrypoint: + +- ``GET /healthz`` — liveness/readiness (no auth, no DB) +- ``/ui/*`` — Control Plane (session UI) +- ``/*`` — Trust Service public/API surface + +Ingress may also expose path-based routing; the in-process mount is the +default so a single Service port works without Traefik strip-prefix. +""" + +from __future__ import annotations + +from fastapi import FastAPI + +from .app import app as trust_service_app +from .control_plane_app import app as control_plane_app + +app = FastAPI(title="Target Revenue — Trust Service + Control Plane", version="0.1.0") + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + return {"status": "ok"} + + +# More specific mount first. +app.mount("/ui", control_plane_app) +app.mount("/", trust_service_app) diff --git a/src/target_revenue/service/control_plane_app.py b/src/target_revenue/service/control_plane_app.py index 91225a3..402d646 100644 --- a/src/target_revenue/service/control_plane_app.py +++ b/src/target_revenue/service/control_plane_app.py @@ -107,9 +107,15 @@ def require_login( return licensor +def _root_path(request: Request) -> str: + """URL prefix when mounted under combined.py ``/ui`` (WP-0011).""" + return request.scope.get("root_path", "") or "" + + def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]: context = { "request": request, + "root_path": _root_path(request), "session_credential_label": licensor.credential_label if licensor else None, "session_rights": licensor.rights if licensor else None, "session_licensor_id": licensor.licensor_id if licensor else None, @@ -124,12 +130,24 @@ def _redirect(url: str, request: Request, flash: str | None = None, variant: str if flash: request.session["flash"] = flash request.session["flash_variant"] = variant + # Prefix relative Control Plane paths when mounted under /ui. + if url.startswith("/") and not url.startswith("//"): + url = _root_path(request) + url return RedirectResponse(url=url, status_code=303) @app.exception_handler(_NotAuthenticated) async def _redirect_to_login(request: Request, exc: _NotAuthenticated): - return RedirectResponse(url="/login", status_code=303) + return RedirectResponse(url=_root_path(request) + "/login", status_code=303) + + +# --- Health (WP-0011) ------------------------------------------------------- + + +@app.get("/healthz") +def healthz() -> dict[str, str]: + """Liveness/readiness probe target — no auth, no DB.""" + return {"status": "ok"} # --- Auth -------------------------------------------------------------- @@ -138,7 +156,7 @@ async def _redirect_to_login(request: Request, exc: _NotAuthenticated): @app.get("/login") def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)): if licensor is not None: - return RedirectResponse(url="/", status_code=303) + return _redirect("/", request) return templates.TemplateResponse(request, "login.html", _template_context(request, None)) @@ -151,17 +169,15 @@ def login_submit( try: registry.authenticate(conn, token) except registry.RegistrationError: - request.session["flash"] = "Invalid or revoked credential token." - request.session["flash_variant"] = "danger" - return RedirectResponse(url="/login", status_code=303) + return _redirect("/login", request, "Invalid or revoked credential token.", "danger") request.session["token"] = token - return RedirectResponse(url="/", status_code=303) + return _redirect("/", request) @app.post("/logout") def logout(request: Request): request.session.clear() - return RedirectResponse(url="/login", status_code=303) + return _redirect("/login", request) # --- Dashboard ----------------------------------------------------------- diff --git a/src/target_revenue/service/control_plane_templates/admin_credentials.html b/src/target_revenue/service/control_plane_templates/admin_credentials.html index 6669383..f5c1f3f 100644 --- a/src/target_revenue/service/control_plane_templates/admin_credentials.html +++ b/src/target_revenue/service/control_plane_templates/admin_credentials.html @@ -13,7 +13,7 @@ {% endif %}