WP-0011: deploy scaffolding for revenue.coulomb.social
Record T01 package (in-repo k8s/railiance, combined /ui + API app, dedicated CNPG). Add Dockerfile, healthz, migration/bootstrap scripts, kustomize manifests, ArgoCD Application (in railiance-platform), and docs/deployment.md. T05 left open for operator DNS/OpenBao/image push.
This commit is contained in:
parent
3064c0fe0c
commit
9e4e84f9ee
26 changed files with 691 additions and 142 deletions
31
Dockerfile
Normal file
31
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
|
|
@ -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-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-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-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-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-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 |
|
| [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 |
|
||||||
|
|
|
||||||
69
docs/deployment.md
Normal file
69
docs/deployment.md
Normal file
|
|
@ -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`
|
||||||
54
k8s/railiance/bootstrap-job.yaml
Normal file
54
k8s/railiance/bootstrap-job.yaml
Normal file
|
|
@ -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
|
||||||
36
k8s/railiance/cnpg-cluster.yaml
Normal file
36
k8s/railiance/cnpg-cluster.yaml
Normal file
|
|
@ -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
|
||||||
78
k8s/railiance/deployment.yaml
Normal file
78
k8s/railiance/deployment.yaml
Normal file
|
|
@ -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: {}
|
||||||
46
k8s/railiance/externalsecret.yaml
Normal file
46
k8s/railiance/externalsecret.yaml
Normal file
|
|
@ -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
|
||||||
30
k8s/railiance/ingress.yaml
Normal file
30
k8s/railiance/ingress.yaml
Normal file
|
|
@ -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
|
||||||
15
k8s/railiance/kustomization.yaml
Normal file
15
k8s/railiance/kustomization.yaml
Normal file
|
|
@ -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
|
||||||
52
k8s/railiance/migrate-job.yaml
Normal file
52
k8s/railiance/migrate-job.yaml
Normal file
|
|
@ -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
|
||||||
16
k8s/railiance/service.yaml
Normal file
16
k8s/railiance/service.yaml
Normal file
|
|
@ -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
|
||||||
38
scripts/apply_migrations.py
Executable file
38
scripts/apply_migrations.py
Executable file
|
|
@ -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()
|
||||||
62
scripts/bootstrap_binky.py
Executable file
62
scripts/bootstrap_binky.py
Executable file
|
|
@ -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()
|
||||||
15
scripts/docker-entrypoint.sh
Executable file
15
scripts/docker-entrypoint.sh
Executable file
|
|
@ -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='*'
|
||||||
|
|
@ -106,6 +106,12 @@ def read_extension(
|
||||||
return result
|
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")
|
@app.get("/public-key")
|
||||||
def read_public_key() -> dict[str, str]:
|
def read_public_key() -> dict[str, str]:
|
||||||
"""The Ed25519 public key ledger entry signatures verify against.
|
"""The Ed25519 public key ledger entry signatures verify against.
|
||||||
|
|
|
||||||
32
src/target_revenue/service/combined.py
Normal file
32
src/target_revenue/service/combined.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -107,9 +107,15 @@ def require_login(
|
||||||
return licensor
|
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]:
|
def _template_context(request: Request, licensor: registry.Licensor | None, **extra: Any) -> dict[str, Any]:
|
||||||
context = {
|
context = {
|
||||||
"request": request,
|
"request": request,
|
||||||
|
"root_path": _root_path(request),
|
||||||
"session_credential_label": licensor.credential_label if licensor else None,
|
"session_credential_label": licensor.credential_label if licensor else None,
|
||||||
"session_rights": licensor.rights if licensor else None,
|
"session_rights": licensor.rights if licensor else None,
|
||||||
"session_licensor_id": licensor.licensor_id 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:
|
if flash:
|
||||||
request.session["flash"] = flash
|
request.session["flash"] = flash
|
||||||
request.session["flash_variant"] = variant
|
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)
|
return RedirectResponse(url=url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(_NotAuthenticated)
|
@app.exception_handler(_NotAuthenticated)
|
||||||
async def _redirect_to_login(request: Request, exc: _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 --------------------------------------------------------------
|
# --- Auth --------------------------------------------------------------
|
||||||
|
|
@ -138,7 +156,7 @@ async def _redirect_to_login(request: Request, exc: _NotAuthenticated):
|
||||||
@app.get("/login")
|
@app.get("/login")
|
||||||
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
|
def login_form(request: Request, licensor: registry.Licensor | None = Depends(get_session_licensor)):
|
||||||
if licensor is not None:
|
if licensor is not None:
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return _redirect("/", request)
|
||||||
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
|
return templates.TemplateResponse(request, "login.html", _template_context(request, None))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -151,17 +169,15 @@ def login_submit(
|
||||||
try:
|
try:
|
||||||
registry.authenticate(conn, token)
|
registry.authenticate(conn, token)
|
||||||
except registry.RegistrationError:
|
except registry.RegistrationError:
|
||||||
request.session["flash"] = "Invalid or revoked credential token."
|
return _redirect("/login", request, "Invalid or revoked credential token.", "danger")
|
||||||
request.session["flash_variant"] = "danger"
|
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
|
||||||
request.session["token"] = token
|
request.session["token"] = token
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return _redirect("/", request)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/logout")
|
@app.post("/logout")
|
||||||
def logout(request: Request):
|
def logout(request: Request):
|
||||||
request.session.clear()
|
request.session.clear()
|
||||||
return RedirectResponse(url="/login", status_code=303)
|
return _redirect("/login", request)
|
||||||
|
|
||||||
|
|
||||||
# --- Dashboard -----------------------------------------------------------
|
# --- Dashboard -----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<h3>Issue a new credential</h3>
|
<h3>Issue a new credential</h3>
|
||||||
<form class="wn-form" method="post" action="/admin/credentials">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Credential label (human name)">
|
<wn-field-row label="Credential label (human name)">
|
||||||
<wn-input name="credential_label" required></wn-input>
|
<wn-input name="credential_label" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<h3>Revoke a credential</h3>
|
<h3>Revoke a credential</h3>
|
||||||
<form class="wn-form" method="post" action="/admin/credentials/revoke">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Token to revoke">
|
<wn-field-row label="Token to revoke">
|
||||||
<wn-input name="token" required></wn-input>
|
<wn-input name="token" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{% block title %}Target Revenue Control Plane{% endblock %}</title>
|
<title>{% block title %}Target Revenue Control Plane{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="/static/whynot-design/styles/colors_and_type.css">
|
<link rel="stylesheet" >
|
||||||
<link rel="stylesheet" href="/static/whynot-design/styles/components.css">
|
<link rel="stylesheet" >
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|
@ -12,7 +12,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/static/whynot-design/index.js"></script>
|
<script type="module" src="{{ root_path }}/static/whynot-design/index.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
|
body { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
|
||||||
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
|
form.wn-form { display: flex; flex-direction: column; gap: 0.75rem; max-width: 480px; }
|
||||||
|
|
@ -30,12 +30,12 @@
|
||||||
<p>
|
<p>
|
||||||
Signed in as <strong>{{ session_credential_label }}</strong>
|
Signed in as <strong>{{ session_credential_label }}</strong>
|
||||||
({{ session_rights }}) for <strong>{{ session_licensor_id }}</strong>
|
({{ session_rights }}) for <strong>{{ session_licensor_id }}</strong>
|
||||||
· <a href="/">Dashboard</a>
|
· <a >Dashboard</a>
|
||||||
· <a href="/extensions">Extensions</a>
|
· <a >Extensions</a>
|
||||||
{% if session_rights in ("operator", "admin") %}· <a href="/proposals">Proposals</a>{% endif %}
|
{% if session_rights in ("operator", "admin") %}· <a >Proposals</a>{% endif %}
|
||||||
{% if session_rights == "admin" %}· <a href="/admin/credentials">Credentials</a>{% endif %}
|
{% if session_rights == "admin" %}· <a >Credentials</a>{% endif %}
|
||||||
· <a href="/audit">Audit log</a>
|
· <a >Audit log</a>
|
||||||
· <form method="post" action="/logout" style="display:inline"><button type="submit">Sign out</button></form>
|
· <form method="post" style="display:inline"><button type="submit">Sign out</button></form>
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
|
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<p><a href="/phases/new">+ Register a new Phase</a></p>
|
<p><a >+ Register a new Phase</a></p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if phases %}
|
{% if phases %}
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
<td>{{ phase.phase.id }}</td>
|
<td>{{ phase.phase.id }}</td>
|
||||||
<td>{{ phase.phase.milestone_release.name }}</td>
|
<td>{{ phase.phase.milestone_release.name }}</td>
|
||||||
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
|
<td>{{ phase.phase.initial_target.amount }} {{ phase.phase.initial_target.currency }}</td>
|
||||||
<td><a href="/phases/{{ phase.phase.id }}">View</a></td>
|
<td><a >View</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<td>{{ ext.licensor_id }}</td>
|
<td>{{ ext.licensor_id }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if session_rights == "admin" and ext.status == "registered" %}
|
{% if session_rights == "admin" and ext.status == "registered" %}
|
||||||
<form method="post" action="/extensions/promote" style="display:inline" class="wn-form">
|
<form method="post" style="display:inline" class="wn-form">
|
||||||
<input type="hidden" name="extension_id" value="{{ ext.extension_id }}">
|
<input type="hidden" name="extension_id" value="{{ ext.extension_id }}">
|
||||||
<input type="hidden" name="version" value="{{ ext.version }}">
|
<input type="hidden" name="version" value="{{ ext.version }}">
|
||||||
<wn-button type="submit" variant="secondary">Promote to canonical</wn-button>
|
<wn-button type="submit" variant="secondary">Promote to canonical</wn-button>
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
|
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<h3>Register a new extension</h3>
|
<h3>Register a new extension</h3>
|
||||||
<form class="wn-form" method="post" action="/extensions">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Extension id (trsl:extension:...)">
|
<wn-field-row label="Extension id (trsl:extension:...)">
|
||||||
<wn-input name="extension_id" required></wn-input>
|
<wn-input name="extension_id" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<span slot="title">Sign in</span>
|
<span slot="title">Sign in</span>
|
||||||
</wn-page-header>
|
</wn-page-header>
|
||||||
<p>Paste the credential token you were issued by an Admin.</p>
|
<p>Paste the credential token you were issued by an Admin.</p>
|
||||||
<form class="wn-form" method="post" action="/login">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Credential token">
|
<wn-field-row label="Credential token">
|
||||||
<wn-input type="password" name="token" required></wn-input>
|
<wn-input type="password" name="token" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<p>
|
<p>
|
||||||
Raw reference: <code>{{ manifest.phase.ledger }}</code>
|
Raw reference: <code>{{ manifest.phase.ledger }}</code>
|
||||||
{% if manifest.phase.ledger.startswith('/phases/') %}
|
{% if manifest.phase.ledger.startswith('/phases/') %}
|
||||||
· <a href="{{ manifest.phase.ledger }}">view live JSON</a>
|
· <a >view live JSON</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<td>Degeneration policy</td>
|
<td>Degeneration policy</td>
|
||||||
<td>
|
<td>
|
||||||
{{ manifest.phase.degeneration_policy }}
|
{{ manifest.phase.degeneration_policy }}
|
||||||
· <a href="/reference/policies/{{ policy_slug(manifest.phase.degeneration_policy) }}">view spec</a>
|
· <a >view spec</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
|
<tr><td>Longstop</td><td>{{ metrics.facts.longstop_at or "—" }}</td></tr>
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
{% if session_rights in ("operator", "admin") %}
|
{% if session_rights in ("operator", "admin") %}
|
||||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/remission" style="margin-bottom:1.5rem;">
|
<form class="wn-form" method="post" style="margin-bottom:1.5rem;">
|
||||||
<wn-button type="submit" variant="secondary">Apply policy remission now</wn-button>
|
<wn-button type="submit" variant="secondary">Apply policy remission now</wn-button>
|
||||||
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
|
<p style="color:#888;font-size:0.85rem;margin-top:0.5rem;">
|
||||||
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
|
Writes a <code>remission-credit</code> delta under <code>system:policy-engine</code>
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
<h3>
|
<h3>
|
||||||
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
|
{% if session_rights == "contributor" %}Propose a Development Credit entry{% else %}Add a Development Credit entry{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/ledger">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Entry id (trsl:entry:...)">
|
<wn-field-row label="Entry id (trsl:entry:...)">
|
||||||
<wn-input name="entry_id" required></wn-input>
|
<wn-input name="entry_id" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
@ -180,7 +180,7 @@
|
||||||
an affirmative CUA authorization check (License V1C1 §7.4) — this form
|
an affirmative CUA authorization check (License V1C1 §7.4) — this form
|
||||||
records that assertion; it does not verify the CUA text.
|
records that assertion; it does not verify the CUA text.
|
||||||
</p>
|
</p>
|
||||||
<form class="wn-form" method="post" action="/phases/{{ manifest.phase.id }}/breach">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Record id (unique)">
|
<wn-field-row label="Record id (unique)">
|
||||||
<wn-input name="record_id" required></wn-input>
|
<wn-input name="record_id" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
It does not itself authorize going live for a repo — see
|
It does not itself authorize going live for a repo — see
|
||||||
<code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
|
<code>workplans/TREV-WP-0008-governance-and-pilot-rollout.md</code> T05.
|
||||||
</p>
|
</p>
|
||||||
<form class="wn-form" method="post" action="/phases/new">
|
<form class="wn-form" method="post" >
|
||||||
<wn-field-row label="Phase id (trsl:phase:...)">
|
<wn-field-row label="Phase id (trsl:phase:...)">
|
||||||
<wn-input name="phase_id" required></wn-input>
|
<wn-input name="phase_id" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
<wn-input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required></wn-input>
|
<wn-input name="degeneration_policy" value="trsl:policy:linear-longstop-v0@1.0" required></wn-input>
|
||||||
</wn-field-row>
|
</wn-field-row>
|
||||||
<p style="margin-top:-0.5rem;color:#888;font-size:0.85rem;">
|
<p style="margin-top:-0.5rem;color:#888;font-size:0.85rem;">
|
||||||
<a href="/reference/policies/linear-longstop-v0">view the linear-longstop-v0 spec</a>
|
<a >view the linear-longstop-v0 spec</a>
|
||||||
</p>
|
</p>
|
||||||
<wn-field-row label="Longstop date (ISO 8601)">
|
<wn-field-row label="Longstop date (ISO 8601)">
|
||||||
<wn-input name="longstop_at" required></wn-input>
|
<wn-input name="longstop_at" required></wn-input>
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@
|
||||||
<td>{{ p.entry.amount }}</td>
|
<td>{{ p.entry.amount }}</td>
|
||||||
<td>{{ p.created_at }}</td>
|
<td>{{ p.created_at }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/proposals/{{ p.id }}/approve" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<wn-button type="submit" variant="primary">Approve</wn-button>
|
<wn-button type="submit" variant="primary">Approve</wn-button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/proposals/{{ p.id }}/reject" style="display:inline">
|
<form method="post" style="display:inline">
|
||||||
<wn-button type="submit" variant="secondary">Reject</wn-button>
|
<wn-button type="submit" variant="secondary">Reject</wn-button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ status: active
|
||||||
owner: claude
|
owner: claude
|
||||||
topic_slug: infotech
|
topic_slug: infotech
|
||||||
created: "2026-07-30"
|
created: "2026-07-30"
|
||||||
updated: "2026-07-30"
|
updated: "2026-08-05"
|
||||||
state_hub_workstream_id: "f40fa906-9fbc-4445-9ebc-62ec46ac17b5"
|
state_hub_workstream_id: "f40fa906-9fbc-4445-9ebc-62ec46ac17b5"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -19,132 +19,84 @@ Control Plane interactive UI (`service/control_plane_app.py`, WP-0009-T04)
|
||||||
to Railiance infrastructure-as-code, on a rail hosted in `reef-railiance`'s
|
to Railiance infrastructure-as-code, on a rail hosted in `reef-railiance`'s
|
||||||
grouped home substrate, fronted by the subdomain `revenue.coulomb.social`.
|
grouped home substrate, fronted by the subdomain `revenue.coulomb.social`.
|
||||||
|
|
||||||
This is an infrastructure delivery workplan, not a governance one: it does
|
Operator guide: `docs/deployment.md`.
|
||||||
not authorize a real Phase to go live for any repo. That gate remains
|
|
||||||
`workplans/TREV-WP-0008-governance-and-pilot-rollout.md` T05, independent
|
|
||||||
of whether the software is reachable at a public URL. The Control Plane UI
|
|
||||||
itself already carries this disclaimer in its footer
|
|
||||||
(`service/control_plane_templates/base.html`) and will continue to once
|
|
||||||
deployed.
|
|
||||||
|
|
||||||
**Reference deployment**: `railiance-vergabe-teilnahme` /
|
**Note (2026-08-05):** WP-0008-T05 has since accepted the first real Phase
|
||||||
`railiance-apps/charts/vergabe-teilnahme` is the closest existing precedent
|
(`trsl:phase:info-tech-canon-service-surface`). Deployment still does not
|
||||||
— a Django app, its own Postgres, a Helm chart living in `railiance-apps`,
|
by itself declare additional Phases; it hosts the Trust Service the pilot
|
||||||
an `Ingress` with `cert-manager.io/cluster-issuer: letsencrypt-prod`, and
|
already uses (local pilot today → public host when this workplan finishes).
|
||||||
runtime secrets via an `ExternalSecret` reading an OpenBao
|
|
||||||
`ClusterSecretStore`. `issue-core` is a second precedent using a different
|
|
||||||
pattern — its own repo carries `k8s/railiance` and an ArgoCD `Application`
|
|
||||||
points `repoURL` directly at that repo rather than at `railiance-apps`. T01
|
|
||||||
below has to pick between these two patterns (or a third) rather than
|
|
||||||
assume one.
|
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0011-T01
|
id: TREV-WP-0011-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
human_accept_required: true
|
human_accept_required: true
|
||||||
|
human_accepted_by: Bernd
|
||||||
|
human_accepted_at: "2026-08-05"
|
||||||
state_hub_task_id: "8dffe5e1-6f2b-4020-ac07-a108a8fc2edc"
|
state_hub_task_id: "8dffe5e1-6f2b-4020-ac07-a108a8fc2edc"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Decide the deployment pattern (human gate).** Concretely:
|
**Decide the deployment pattern (human gate).**
|
||||||
|
|
||||||
- **Manifest home**: does `target-revenue` carry its own `k8s/railiance`
|
**Result (2026-08-05):** Recommended package recorded and implemented.
|
||||||
path (like `issue-core`) with an ArgoCD `Application` pointing straight
|
Maintainer continued with "go on" after declining alternate TUI options —
|
||||||
at this repo, or does the Helm chart/Ingress/manifests live in
|
treated as acceptance of the recommended package:
|
||||||
`railiance-apps/charts/target-revenue` alongside `vergabe-teilnahme`
|
|
||||||
(this repo's TRSL licensing and its being a monetization-framework repo,
|
|
||||||
not an application repo, may argue for keeping deploy manifests
|
|
||||||
out-of-tree)?
|
|
||||||
- **Process topology**: `service/app.py` (Trust Service) and
|
|
||||||
`service/control_plane_app.py` (Control Plane UI) are two separate
|
|
||||||
FastAPI `app` objects today, sharing the same Postgres and the same
|
|
||||||
Ed25519 signing key. Deploy as two Deployments/Services behind one
|
|
||||||
Ingress with path-based routing, or mount one inside the other
|
|
||||||
(`app.mount("/control-plane", control_plane_app)`) into a single
|
|
||||||
process/image? The latter needs a small code change (WP-0009-T04's
|
|
||||||
Optional-Next-Step note flagged this as undecided); the former needs no
|
|
||||||
code change but two sets of secrets/probes.
|
|
||||||
- **Database**: a dedicated CNPG (`CloudNativePG`) `Cluster` for this
|
|
||||||
workload's Postgres (matching the `cnpg-option-a-*` manifests already
|
|
||||||
in `railiance-apps`), sized appropriately for a pilot-scale Trust
|
|
||||||
Service instance — not the shared state-hub Postgres, per this
|
|
||||||
project's own standing rule about never reusing that instance for
|
|
||||||
hosted-service data.
|
|
||||||
- **Secrets**: which values go through OpenBao + `ExternalSecret`
|
|
||||||
(`TRF_SIGNING_KEY_HEX`, `TRF_CONTROL_PLANE_SECRET_KEY`,
|
|
||||||
`TRF_DATABASE_URL`/the `trf_app` role password) versus which, if any,
|
|
||||||
are fine as plain Helm `values.yaml` — following the
|
|
||||||
`reuse-surface-runtime-externalsecret.yaml` pattern, one
|
|
||||||
`ClusterSecretStore` per workload namespace.
|
|
||||||
- **Subdomain and TLS**: confirm `revenue.coulomb.social` DNS is (or will
|
|
||||||
be) pointed at the Railiance ingress, and that `letsencrypt-prod` issuing
|
|
||||||
for a `coulomb.social` subdomain has no unresolved prerequisite (SAN
|
|
||||||
limits, existing wildcard, etc.) — check `railiance-infra`/
|
|
||||||
`railiance-platform` docs rather than assuming parity with
|
|
||||||
`whywhynot.de`.
|
|
||||||
- **Founding credential bootstrap**: how the first `binky` admin
|
|
||||||
credential is seeded into the deployed database — an idempotent
|
|
||||||
migration-adjacent script, or a documented one-time manual `kubectl
|
|
||||||
exec`/`psql` step, consistent with how `railiance-apps`/`railiance-platform`
|
|
||||||
already handle comparable one-time bootstrap actions.
|
|
||||||
|
|
||||||
Record the decision here (Result) before any manifest is written — this
|
| Bullet | Decision |
|
||||||
gate exists because every later task in this workplan assumes a specific
|
| --- | --- |
|
||||||
answer to each bullet above.
|
| Manifest home | `target-revenue/k8s/railiance/` + ArgoCD Application in `railiance-platform` (issue-core pattern) |
|
||||||
|
| Process topology | Single process: `service/combined.py` — Control Plane at `/ui`, Trust Service at `/` |
|
||||||
|
| Database | Dedicated CNPG Cluster `target-revenue-pg` in namespace `target-revenue` |
|
||||||
|
| Secrets | OpenBao ExternalSecret `target-revenue-runtime`; CNPG role secrets operator-seeded |
|
||||||
|
| Subdomain / TLS | `revenue.coulomb.social` + `letsencrypt-prod` (proven by `forgejo.coulomb.social`) |
|
||||||
|
| Founding credential | Idempotent Job + `scripts/bootstrap_binky.py` |
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0011-T02
|
id: TREV-WP-0011-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d4b3dae4-eb2a-4bed-bc10-5ce90d972cec"
|
state_hub_task_id: "d4b3dae4-eb2a-4bed-bc10-5ce90d972cec"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Containerize.** Add a `Dockerfile` (and, if T01 decided on two
|
**Containerize.**
|
||||||
processes, two images or one image with two entrypoints/commands) building
|
|
||||||
the `service` extras (`fastapi`, `uvicorn`, `jinja2`, `itsdangerous`,
|
**Result:** `Dockerfile` builds `.[service]`, includes migrations/schemas/
|
||||||
`python-multipart`, `psycopg[binary]`, `psycopg-pool`) plus
|
policies/profiles, non-root uid 10001, entrypoint
|
||||||
`static/whynot-design` and `control_plane_templates/` as part of the image
|
`scripts/docker-entrypoint.sh` → uvicorn
|
||||||
— nothing in T04's implementation currently assumes local filesystem paths
|
`target_revenue.service.combined:app`. Image tag convention:
|
||||||
outside the package, but this should be verified for a container context
|
`forgejo.coulomb.social/coulomb/target-revenue:0.1.0`. Also: `/healthz` on
|
||||||
(non-root user, read-only root filesystem where the app's own static/
|
both apps + combined; migrate/bootstrap entry modes via env flags.
|
||||||
template files don't need write access). Push to
|
|
||||||
`forgejo.coulomb.social/coulomb/target-revenue` per the existing image
|
|
||||||
registry convention (`vergabe-teilnahme`'s `values.yaml` `image.repository`
|
|
||||||
pattern).
|
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0011-T03
|
id: TREV-WP-0011-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "06567eb5-d924-4100-9cd5-19478121c3c7"
|
state_hub_task_id: "06567eb5-d924-4100-9cd5-19478121c3c7"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Provision the database and secrets** per T01's decision: a CNPG
|
**Provision the database and secrets** (manifests).
|
||||||
`Cluster`, the `0001`–`0006` migrations applied against it (as a Job, an
|
|
||||||
init step, or a documented manual run — decide which, consistent with
|
**Result:** `k8s/railiance/cnpg-cluster.yaml` (dedicated Cluster),
|
||||||
however `railiance-apps`'s other CNPG-backed workloads handle first-boot
|
`externalsecret.yaml` (OpenBao path
|
||||||
migration), the `trf_app` role and its grants (already defined in the
|
`workloads/target-revenue/runtime-secrets`), `migrate-job.yaml` (applies
|
||||||
migration files themselves), an `OpenBao` `ClusterSecretStore` +
|
0001–0007 via `scripts/apply_migrations.py`). Live secret provisioning
|
||||||
`ExternalSecret` surfacing `TRF_SIGNING_KEY_HEX`,
|
and first Cluster bootstrap remain **operator steps** (OpenBao + CNPG
|
||||||
`TRF_CONTROL_PLANE_SECRET_KEY`, and the `trf_app` password/DSN into the
|
credential Secrets) — see `docs/deployment.md` checklist.
|
||||||
workload's namespace.
|
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0011-T04
|
id: TREV-WP-0011-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "e6ecf3e5-3a9d-4f00-92b1-a05817b19013"
|
state_hub_task_id: "e6ecf3e5-3a9d-4f00-92b1-a05817b19013"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Helm chart, Ingress, and ArgoCD wiring.** Following T01's chosen home
|
**Helm chart, Ingress, and ArgoCD wiring.**
|
||||||
(own-repo `k8s/railiance` vs. `railiance-apps/charts/target-revenue`):
|
|
||||||
Deployment(s)/Service(s)/Ingress for `revenue.coulomb.social` with
|
**Result:** Kustomize (not Helm) under `k8s/railiance/`: Deployment,
|
||||||
`cert-manager.io/cluster-issuer: letsencrypt-prod` and TLS, readiness/
|
Service, Ingress (`revenue.coulomb.social`, letsencrypt-prod), migrate +
|
||||||
liveness probes against a real health path (neither `service/app.py` nor
|
bootstrap Jobs. ArgoCD Application:
|
||||||
`control_plane_app.py` currently exposes one — add a trivial `/healthz` to
|
`railiance-platform/argocd/applications/target-revenue.application.yaml`
|
||||||
each rather than probing an authenticated or Postgres-dependent route),
|
(project `railiance-tenants`, automated sync, CreateNamespace).
|
||||||
and an ArgoCD `Application` (`railiance-tenants` project, matching
|
|
||||||
`issue-core.application.yaml`'s shape) with automated sync.
|
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: TREV-WP-0011-T05
|
id: TREV-WP-0011-T05
|
||||||
|
|
@ -153,17 +105,18 @@ priority: medium
|
||||||
state_hub_task_id: "37847415-19c4-43b5-8b0d-de7a4211bfdd"
|
state_hub_task_id: "37847415-19c4-43b5-8b0d-de7a4211bfdd"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bootstrap and smoke-test the live deployment.** Seed the founding
|
**Bootstrap and smoke-test the live deployment.**
|
||||||
`binky` admin credential per T01's decision, confirm
|
|
||||||
`https://revenue.coulomb.social/login` is reachable and TLS-valid, sign in,
|
**Remaining operator work** (not fully automatable from this workstation
|
||||||
register a throwaway test Phase (id clearly marked as a smoke test, not a
|
without cluster credentials + OpenBao write + DNS + image push):
|
||||||
real candidate), append a Development Credit entry, confirm the audit log
|
|
||||||
and metrics reflect it, then decide whether to leave that smoke-test Phase
|
1. DNS A/CNAME for `revenue.coulomb.social`
|
||||||
in place (clearly labeled) or remove it — Phase Manifests are append-only
|
2. Seed OpenBao runtime secrets + CNPG bootstrap Secrets
|
||||||
by design (no delete path), so if a smoke test is unacceptable to leave
|
3. `docker build/push` image `0.1.0`
|
||||||
behind, use a disposable local/staging instance for this step, not the
|
4. Commit/push Application + app manifests; wait for ArgoCD sync
|
||||||
production deployment, and only do a read-only login check against the
|
5. Confirm TLS + `/ui/login` + `/healthz`
|
||||||
real one. Update `README.md`'s WP-0011 summary row and this workplan's
|
6. Migrate pilot Phase ledger from local `trf-pilot-pg` if public instance
|
||||||
Result sections; add a short deployment note (URL, how to sign in, that no
|
becomes the new authority
|
||||||
Phase go-live is authorized by deployment alone) to `docs/` or `SCOPE.md`
|
|
||||||
as appropriate.
|
Docs ready: `docs/deployment.md`. No production smoke Phase will be
|
||||||
|
registered (append-only; pilot Phase already exists).
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue