docs: preserve reviewed NetKingdom session artifacts
This commit is contained in:
parent
70fcd0a6f0
commit
3402090e5a
50 changed files with 7717 additions and 0 deletions
|
|
@ -0,0 +1,930 @@
|
|||
--- a/api/services/historical_projection.py
|
||||
+++ b/api/services/historical_projection.py
|
||||
@@ -72,6 +72,111 @@
|
||||
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
|
||||
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
+ "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
|
||||
+ "record_id": "RAIL-BS-WP-0002",
|
||||
+ "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0002",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
+ "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
|
||||
+ "record_id": "RAIL-BS-WP-0003",
|
||||
+ "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0003",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "ab166073-30a7-4702-a037-4091e8706e20",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
+ "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
|
||||
+ "record_id": "RAIL-BS-WP-0004",
|
||||
+ "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0004",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "08233868-d522-4117-bc4e-6c0f52545665",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T06",
|
||||
+ "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
}
|
||||
]''')
|
||||
|
||||
--- /dev/null
|
||||
+++ b/tests/test_cluster_historical_projection.py
|
||||
@@ -0,0 +1,67 @@
|
||||
+import shutil
|
||||
+import uuid
|
||||
+from pathlib import Path
|
||||
+import pytest
|
||||
+from api.services import forge_projection as fp
|
||||
+from api.services import historical_projection as hp
|
||||
+from tests.test_forge_projection import _FakeSession,_Repo,_Row,_TaskRow
|
||||
+
|
||||
+FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster'
|
||||
+@pytest.fixture
|
||||
+def bound(tmp_path):
|
||||
+ root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root)
|
||||
+ derived=fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
+ assert len(derived.workplans)==3
|
||||
+ rows=[];tasks=[]
|
||||
+ for parent in derived.workplans:
|
||||
+ row=_Row(parent.record_id.lower(),'finished',parent.relative_path);row.id=uuid.UUID(parent.uuid);rows.append(row)
|
||||
+ for source in parent.tasks:
|
||||
+ task=_TaskRow(source.record_id,status='done',workplan_id=row.id);task.id=uuid.UUID(source.uuid);tasks.append(task)
|
||||
+ assert len(tasks)==15
|
||||
+ return root,derived,rows,tasks
|
||||
+
|
||||
+def test_exact_three_sources_derive_only_frozen_15_task_uuids(bound):
|
||||
+ root,derived,rows,tasks=bound
|
||||
+ assert all(parent.historical_preservation for parent in derived.workplans)
|
||||
+ assert all(parent.uuid!=fp.derived_record_uuid(parent.record_id) for parent in derived.workplans)
|
||||
+ expected={item['uuid'] for entry in hp.BINDINGS if entry['repo']=='railiance-cluster' for item in entry['tasks']}
|
||||
+ assert {str(t.id) for t in tasks}==expected
|
||||
+
|
||||
+@pytest.mark.asyncio
|
||||
+async def test_existing_rows_are_entirely_unchanged_on_normal_receiving(bound):
|
||||
+ root,derived,rows,tasks=bound;before=[dict(x.__dict__) for x in rows+tasks]
|
||||
+ session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
+ outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
+ assert outcome.status=='noop' and not outcome.refused
|
||||
+ assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
+ assert not session.added and not session.deleted
|
||||
+
|
||||
+@pytest.mark.parametrize('defect',['missing-parent','missing-task','foreign-holder','nonterminal-task','nonterminal-parent','duplicate-current-label'])
|
||||
+@pytest.mark.asyncio
|
||||
+async def test_unsupported_actual_binding_refuses_before_any_mutation(bound,defect):
|
||||
+ root,derived,rows,tasks=bound
|
||||
+ if defect=='missing-parent':rows.pop()
|
||||
+ elif defect=='missing-task':tasks.pop()
|
||||
+ elif defect=='foreign-holder':tasks[0].workplan_id=uuid.uuid4()
|
||||
+ elif defect=='nonterminal-task':tasks[0].status='progress'
|
||||
+ elif defect=='duplicate-current-label':tasks.append(_TaskRow(tasks[0].record_id,status='done',workplan_id=tasks[0].workplan_id))
|
||||
+ else:rows[0].status='active'
|
||||
+ before=[dict(x.__dict__) for x in rows+tasks];session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
+ outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
+ assert outcome.status=='refused';assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
+ assert not session.added and not session.deleted
|
||||
+
|
||||
+@pytest.mark.parametrize('change',['hash-drift','missing','symlink','live-source','missing-source-uuid'])
|
||||
+def test_frozen_source_drift_refuses_derivation(bound,change):
|
||||
+ root,*_=bound;p=next((root/'workplans/archived').glob('*.md'));raw=p.read_bytes()
|
||||
+ if change=='missing':p.unlink()
|
||||
+ elif change=='symlink':p.unlink();target=root/'saved';target.write_bytes(raw);p.symlink_to(target)
|
||||
+ elif change=='live-source':p.write_bytes(raw.replace(b'status: finished',b'status: active',1))
|
||||
+ elif change=='missing-source-uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1))
|
||||
+ else:p.write_bytes(raw+b'\nunreviewed\n')
|
||||
+ with pytest.raises(fp.ForgeDeriveError,match='Historical source binding'):fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
+
|
||||
+def test_unknown_repository_never_trusts_frozen_source_uuids(bound):
|
||||
+ root,derived,*_=bound;other=fp.derive_from_checkout(root,'different-repo','candidate')
|
||||
+ assert not any(parent.historical_preservation for parent in other.workplans)
|
||||
+ assert not {parent.uuid for parent in derived.workplans}&{parent.uuid for parent in other.workplans}
|
||||
--- a/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
+++ b/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
@@ -4,10 +4,10 @@
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
-status: finished
|
||||
+status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
-updated: "2026-10-06"
|
||||
+updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
@@ -167,3 +167,73 @@
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
+
|
||||
+
|
||||
+## Cluster exact historical receiving successor, 2026-10-17
|
||||
+
|
||||
+This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
+Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
+closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
+registrar admission; never derive replacement identities or modify those
|
||||
+sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
+RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
+The local derived case is waiting; no live worker injection is authorized.
|
||||
+
|
||||
+## Prepare exact cluster source preservation and refusal cases
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T05
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b"
|
||||
+```
|
||||
+
|
||||
+Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
+Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
+missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
+source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
+expected ownership and terminal status. These are source and metadata checks,
|
||||
+not delivered receiving or a committed native trial.
|
||||
+
|
||||
+## Deliver reviewed cluster receiving successor before registrar admission
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T06
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [STATE-WP-0095-T05]
|
||||
+needs_human: true
|
||||
+intervention_note: "Review exact committed source, CI image and owner deployment proposal before rollout."
|
||||
+state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4"
|
||||
+```
|
||||
+
|
||||
+Use the normal CI image and Helm owner path after exact revision/image pin,
|
||||
+fresh headroom and reviewed dry-run/diff. API/MCP currently run main-7d99282;
|
||||
+Helm revision75 is the observed rollback baseline, requiring freshness before
|
||||
+execution. No deployment assent exists for this successor. Verify delivered
|
||||
+module hash, health, both readiness checks and expected migrated schema
|
||||
+c4d5e6f7a8b9; verify exact
|
||||
+three parents/15 tasks and rejection behavior without committed probe changes.
|
||||
+Only the verified return unblocks RMGR-WP-0019-T05. The full current image also carries inbox-feed migration c4d5e6f7a8b9,
|
||||
+although this receiver patch adds none. Helm rollback leaves that migration in
|
||||
+place; unrelated migration acceptance and old-image compatibility need normal
|
||||
+owner evidence before rollout approval. Do not run destructive default local
|
||||
+DB tests; use only exact disposable DB containment. Costs and monetary risk are
|
||||
+unknown pending exact rollout proposal; no zero-cost or autonomy grant is inferred.
|
||||
+
|
||||
+## Verify ordinary cluster receiving and immutable historical rows
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T07
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06]
|
||||
+state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
+```
|
||||
+
|
||||
+Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
+existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
+unretirement or retirement is authorized for these histories. A rollback first
|
||||
+fences cluster sync and restores registrar refusal before removing receiving
|
||||
+support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
+original TLS recovery or owner-native admission.
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md
|
||||
@@ -0,0 +1,177 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0002
|
||||
+type: workplan
|
||||
+title: "k3s and Kubernetes Platform Baseline"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: railiance
|
||||
+topic_slug: railiance
|
||||
+repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
|
||||
+state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
|
||||
+created: "2026-03-09"
|
||||
+updated: "2026-03-10"
|
||||
+completed: "2026-03-10"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# k3s and Kubernetes Platform Baseline
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Install k3s, Helm, and the baseline Kubernetes services on the converged
|
||||
+HostEurope node. This workplan picks up exactly where `railiance-hosts`
|
||||
+leaves off: a hardened, verified OS node that is ready for Kubernetes.
|
||||
+
|
||||
+## Pre-condition
|
||||
+
|
||||
+`railiance-infra` converge + Goss verify must pass before any task here
|
||||
+is executed:
|
||||
+
|
||||
+```bash
|
||||
+# In railiance-infra/
|
||||
+make converge
|
||||
+make verify # must exit 0
|
||||
+```
|
||||
+
|
||||
+## Boundary
|
||||
+
|
||||
+This repo owns everything from k3s upward. It must not re-configure items
|
||||
+defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
|
||||
+`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
|
||||
+
|
||||
+**Out of scope here:** platform services (PostgreSQL, storage, identity)
|
||||
+→ `railiance-platform`. Application deployments (Gitea, coulomb services)
|
||||
+→ `railiance-apps`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Ansible playbook: install k3s (server mode)
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Harden `ansible/bootstrap.yml` to a production-ready k3s install:
|
||||
+
|
||||
+- Use the official k3s install script pinned to a specific version
|
||||
+ (`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
|
||||
+- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
|
||||
+ (cluster-init enables embedded etcd for future HA expansion)
|
||||
+- Wait for node `Ready` before proceeding:
|
||||
+ ```bash
|
||||
+ k3s kubectl wait node --all --for=condition=Ready --timeout=120s
|
||||
+ ```
|
||||
+- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
|
||||
+
|
||||
+**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
|
||||
+and the control node (via kubeconfig).
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Helm installation
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add a task (or a role `roles/helm/`) that:
|
||||
+
|
||||
+1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
|
||||
+2. Verifies the checksum
|
||||
+3. Confirms `helm version` succeeds
|
||||
+
|
||||
+**Done when:** `helm version` succeeds on the HostEurope node.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Smoke test: k3s + Helm
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+- `k3s kubectl get nodes` → node in Ready state
|
||||
+- `helm version` exits 0
|
||||
+- CoreDNS pod running in `kube-system`
|
||||
+- Traefik ingress controller pod running (default in k3s)
|
||||
+
|
||||
+Run via:
|
||||
+```bash
|
||||
+ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
|
||||
+```
|
||||
+or directly over SSH if the kubeconfig is available locally.
|
||||
+
|
||||
+**Done when:** all assertions pass and the script exits 0.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Commit kubeconfig management notes
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Document in `docs/kubeconfig.md`:
|
||||
+
|
||||
+- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
|
||||
+- How to merge it into `~/.kube/config`
|
||||
+- How to switch context: `kubectl config use-context default`
|
||||
+- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
|
||||
+
|
||||
+**Done when:** doc written and committed.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — Add `make k3s-install` and `make smoke` targets
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add to Makefile (create one if none exists):
|
||||
+
|
||||
+```makefile
|
||||
+k3s-install: ## Install k3s and Helm on all inventory hosts
|
||||
+ ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
|
||||
+
|
||||
+smoke: ## Run Kubernetes smoke tests
|
||||
+ bash tests/smoke_kube.sh
|
||||
+```
|
||||
+
|
||||
+**Done when:** both targets work and are listed in `make help`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
|
||||
+- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
|
||||
+- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
|
||||
+- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
|
||||
+- k3s releases: https://github.com/k3s-io/k3s/releases
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md
|
||||
@@ -0,0 +1,196 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0003
|
||||
+type: workplan
|
||||
+title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+created: "2026-03-10"
|
||||
+updated: "2026-03-10"
|
||||
+state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
|
||||
+
|
||||
+## Summary
|
||||
+
|
||||
+On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
|
||||
+restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
|
||||
+entered CrashLoopBackOff and produced no logs. As a result Gitea's login
|
||||
+and all write operations hung indefinitely. The root page was still served
|
||||
+(from Valkey cache) which masked the failure.
|
||||
+
|
||||
+The fix was to patch a missing key in a Kubernetes secret. The root cause is
|
||||
+that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
|
||||
+populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
|
||||
+secret, even though the pgpool pod requires it at startup.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Timeline
|
||||
+
|
||||
+| Time (UTC) | Event |
|
||||
+|---|---|
|
||||
+| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
|
||||
+| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
|
||||
+| ~11:00 | User noticed Gitea login hanging; home page still loading |
|
||||
+| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
|
||||
+| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
|
||||
+| ~13:15 | Gitea fully operational |
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Root Cause
|
||||
+
|
||||
+The Bitnami `pgpool` container startup script reads the file
|
||||
+`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
|
||||
+`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
|
||||
+mount. That secret key was never created by the Helm chart, so the file did
|
||||
+not exist. The container exited immediately with no logs.
|
||||
+
|
||||
+The pod had been running for 20 days without a restart, so this gap was
|
||||
+never discovered during initial deployment.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Evidence
|
||||
+
|
||||
+```bash
|
||||
+# Secret was missing the pgpool-password key
|
||||
+sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
|
||||
+# data: keys were password, postgres-password, repmgr-password only
|
||||
+# pgpool-password was absent
|
||||
+
|
||||
+# pgpool pod describe showed 824 back-off restarts over 173 minutes
|
||||
+# No logs in either current or --previous output
|
||||
+sudo k3s kubectl logs -n default <pgpool-pod> --previous
|
||||
+# (empty)
|
||||
+
|
||||
+# Gitea process had zero TCP connections to PostgreSQL port 5432
|
||||
+# but many connections to Valkey port 6379
|
||||
+cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Immediate Fix Applied
|
||||
+
|
||||
+```bash
|
||||
+# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
|
||||
+sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
|
||||
+ --type='json' \
|
||||
+ -p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
|
||||
+
|
||||
+# Restart pgpool
|
||||
+sudo k3s kubectl delete pod -n default <pgpool-pod-name>
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Risk: Fix Will Be Lost on helm upgrade
|
||||
+
|
||||
+The patched secret is managed by Helm (annotation:
|
||||
+`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
|
||||
+secret from the chart template, which does not include `pgpool-password`,
|
||||
+and the bug will recur.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Add pgpool-password to Helm values
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
|
||||
+```
|
||||
+
|
||||
+Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
|
||||
+include the pgpool-password so it survives `helm upgrade`:
|
||||
+
|
||||
+```yaml
|
||||
+postgresql-ha:
|
||||
+ postgresql:
|
||||
+ pgpoolPassword: <value matching sr-check-password>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
|
||||
+without manual secret patching.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Add pgpool health check to smoke test
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+```bash
|
||||
+# All postgresql-ha pods Running
|
||||
+kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
|
||||
+
|
||||
+# pgpool specifically not in CrashLoopBackOff
|
||||
+kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
|
||||
+ -o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
|
||||
+```
|
||||
+
|
||||
+**Done when:** the smoke test catches a pgpool failure within 5 minutes.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Add HA failover test
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
|
||||
+```
|
||||
+
|
||||
+Create `tests/test_ha_failover.sh` that:
|
||||
+
|
||||
+1. Records Gitea login response time (baseline)
|
||||
+2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
|
||||
+3. Waits for repmgr to promote a replica (max 60s)
|
||||
+4. Asserts Gitea login POST still succeeds within 10s
|
||||
+5. Asserts pgpool pod is Running (not CrashLoopBackOff)
|
||||
+6. Asserts all postgresql pods return to Running
|
||||
+
|
||||
+This test must pass before any PostgreSQL HA deployment is considered done.
|
||||
+
|
||||
+**Done when:** script exits 0 against a live cluster.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Document the incident in docs/
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
|
||||
+```
|
||||
+
|
||||
+Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
|
||||
+timeline, root cause, and fix, so future operators understand what happened
|
||||
+and how to recover.
|
||||
+
|
||||
+**Done when:** doc committed and linked from `docs/README.md`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Bitnami postgresql-ha chart v16.2.2
|
||||
+- Gitea Helm chart v12.2.0
|
||||
+- Related decision: D3 (HA testing policy) in `DECISIONS.md`
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md
|
||||
@@ -0,0 +1,275 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0004
|
||||
+type: workplan
|
||||
+title: "Integrated Backup — S2 Kubernetes Runtime Layer"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+topic_slug: railiance
|
||||
+state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
|
||||
+created: "2026-02-25"
|
||||
+updated: "2026-03-26"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Integrated Backup — S2 Kubernetes Runtime Layer
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Implement the Q3 (Operability & Resilience) integrated backup for
|
||||
+railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state —
|
||||
+encrypted with age, written to a local directory on the server. No external
|
||||
+dependencies required.
|
||||
+
|
||||
+## Architecture (Decision D4)
|
||||
+
|
||||
+Each railiance repo implements its own backup for what it owns. No central
|
||||
+backup service. See `DECISIONS.md` D4 for full rationale.
|
||||
+
|
||||
+**Standard interface every railiance repo must provide:**
|
||||
+
|
||||
+```bash
|
||||
+make backup # encrypt + write to /opt/backup/railiance/<layer>/
|
||||
+make restore # restore from most recent local backup
|
||||
+```
|
||||
+
|
||||
+Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key).
|
||||
+Output: `/opt/backup/railiance/cluster/` on the server.
|
||||
+
|
||||
+## What S2 (railiance-cluster) owns and must back up
|
||||
+
|
||||
+| Asset | Why it matters |
|
||||
+|---|---|
|
||||
+| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets |
|
||||
+| Helm release values | Runtime values not in git (any manually applied overrides) |
|
||||
+| kubeconfig | Admin access to the cluster |
|
||||
+
|
||||
+**Not S2's responsibility:**
|
||||
+- Custodian State Hub DB → the-custodian owns this
|
||||
+- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern
|
||||
+- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this
|
||||
+- PostgreSQL data volumes → S3 (railiance-platform) owns this
|
||||
+
|
||||
+## Encryption
|
||||
+
|
||||
+Reuse the age public key from `.sops.yaml`:
|
||||
+
|
||||
+```bash
|
||||
+AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}')
|
||||
+tar -czf - <assets> | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age
|
||||
+```
|
||||
+
|
||||
+Decryption requires the private key at `~/.config/sops/age/keys.txt`
|
||||
+(same key used for `sops -d`). No additional key management needed.
|
||||
+
|
||||
+## Extension Point EP-RAIL-005
|
||||
+
|
||||
+Once all five OAS layers implement this interface, the custodian can
|
||||
+orchestrate a full-stack backup with:
|
||||
+
|
||||
+```bash
|
||||
+for repo in railiance-infra railiance-cluster railiance-platform \
|
||||
+ railiance-enablement railiance-apps; do
|
||||
+ make -C ~/$repo backup
|
||||
+done
|
||||
+```
|
||||
+
|
||||
+No special protocol needed — just the standard interface.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Define backup directory and encryption wrapper
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b"
|
||||
+```
|
||||
+
|
||||
+Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`):
|
||||
+
|
||||
+- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`)
|
||||
+- Encrypt each artifact with age using public key from `.sops.yaml`
|
||||
+- Write timestamp-named files: `etcd-<ts>.snap.age`, `helm-values-<ts>.tar.gz.age`, `kubeconfig-<ts>.yaml.age`
|
||||
+- Keep last 7 of each type
|
||||
+- Write `.last-backup` stamp
|
||||
+- Exit 0 on success, non-zero on any failure
|
||||
+- No network required
|
||||
+
|
||||
+Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based
|
||||
+custodian DB — wrong scope, not applicable to this server).
|
||||
+
|
||||
+**Done when:** `make backup` runs on COULOMBCORE without error and files
|
||||
+appear in `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Back up k3s state (SQLite hot backup)
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880"
|
||||
+```
|
||||
+
|
||||
+k3s has built-in etcd snapshot support:
|
||||
+
|
||||
+```bash
|
||||
+sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
+# Default location: /var/lib/rancher/k3s/server/db/snapshots/
|
||||
+```
|
||||
+
|
||||
+Add to the backup script: take a fresh snapshot, encrypt with age,
|
||||
+copy to `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+> **Note — verify etcd is in use before implementing:**
|
||||
+> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`.
|
||||
+> Without it, k3s uses SQLite and this command will fail.
|
||||
+> Verify first: `sudo k3s etcd-snapshot ls 2>&1`
|
||||
+
|
||||
+> **Note — sudo required:** etcd snapshot requires root. See T06 for how
|
||||
+> this is resolved (backup runs under root's crontab).
|
||||
+
|
||||
+**Done when:** backup includes a current etcd snapshot.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Back up Helm release values
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
|
||||
+```
|
||||
+
|
||||
+Capture current runtime Helm values for all releases:
|
||||
+
|
||||
+```bash
|
||||
+KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \
|
||||
+ jq -r '.[].name + " " + .namespace' | \
|
||||
+ while read name ns; do
|
||||
+ KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml
|
||||
+ done
|
||||
+```
|
||||
+
|
||||
+Tar and age-encrypt into `helm-values-<ts>.tar.gz.age`.
|
||||
+
|
||||
+> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable
|
||||
+> only by default. The backup script must either run as root (see T06) or k3s
|
||||
+> must be configured with `--write-kubeconfig-mode=644`. Running as root
|
||||
+> (via root crontab) is the chosen approach — no config change needed.
|
||||
+
|
||||
+**Done when:** backup includes a snapshot of all Helm release values.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Back up kubeconfig
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665"
|
||||
+```
|
||||
+
|
||||
+Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`)
|
||||
+into `kubeconfig-<ts>.yaml.age` in the backup directory.
|
||||
+
|
||||
+**Done when:** backup includes the encrypted kubeconfig.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — make restore target
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
|
||||
+```
|
||||
+
|
||||
+Add `tools/cmd/railiance-restore-s2` that decrypts and lists available
|
||||
+backups, with guided restore for the etcd snapshot case.
|
||||
+
|
||||
+Restore of etcd from snapshot:
|
||||
+```bash
|
||||
+sudo k3s server --cluster-reset \
|
||||
+ --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<name>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `make restore` prints available backups and a restore guide.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T06 — Install cron job and run restore drill
|
||||
+
|
||||
+```task
|
||||
+id: T06
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07"
|
||||
+```
|
||||
+
|
||||
+#### Solving the sudo problem
|
||||
+
|
||||
+The backup script needs root for two reasons:
|
||||
+- `k3s etcd-snapshot save` requires root
|
||||
+- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only
|
||||
+
|
||||
+**Solution: run the cron under root's crontab.**
|
||||
+
|
||||
+This is the correct pattern for system-level backup jobs. It avoids a
|
||||
+proliferating sudoers whitelist (one entry per command, brittle to maintain)
|
||||
+and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in
|
||||
+production. The backup writes to `/opt/backup/` which is root-owned anyway.
|
||||
+
|
||||
+Install the cron as root:
|
||||
+
|
||||
+```bash
|
||||
+sudo crontab -e
|
||||
+# Add:
|
||||
+0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1
|
||||
+```
|
||||
+
|
||||
+Note: use the absolute path to the repo — `~` does not expand reliably in
|
||||
+root's crontab unless HOME is set.
|
||||
+
|
||||
+Verify it is installed:
|
||||
+```bash
|
||||
+sudo crontab -l | grep railiance
|
||||
+```
|
||||
+
|
||||
+#### Restore drill
|
||||
+
|
||||
+Once T01–T04 are done, run a decrypt-and-verify drill:
|
||||
+
|
||||
+```bash
|
||||
+# Decrypt the etcd snapshot and verify it is a valid snapshot file
|
||||
+sudo age -d -i ~/.config/sops/age/keys.txt \
|
||||
+ /opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \
|
||||
+ | file -
|
||||
+
|
||||
+# Record the drill
|
||||
+echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \
|
||||
+ >> /opt/backup/railiance/cluster/restore-drill.log
|
||||
+```
|
||||
+
|
||||
+**Done when:** cron installed under root, drill completes without error,
|
||||
+log entry written.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Decision D4: Integrated backup per capability (`DECISIONS.md`)
|
||||
+- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement)
|
||||
+- OAS Q3: Operability & Resilience
|
||||
+- Extension point EP-RAIL-005: Custodian full-stack backup orchestration
|
||||
+- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"""Exact closed source bindings predating canonical Forge identity.
|
||||
|
||||
These are frozen historical source views, not aliases or UUID allocation rules.
|
||||
An altered/missing source requires explicit owner review; generic source UUIDs
|
||||
remain untrusted. Shared Activity views represent the one archived parent.
|
||||
"""
|
||||
from pathlib import Path
|
||||
from hashlib import sha256
|
||||
import json
|
||||
|
||||
BINDINGS = json.loads(r'''[
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/archived/260707-custodian-WP-0003-event-bridge.md",
|
||||
"sha256": "83ea8bcb151e003419b73c59dbd028f4a1ce3d1120a82e4a128a772503ff3b76",
|
||||
"record_id": "custodian-WP-0003",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0001-temporal-backbone.md",
|
||||
"sha256": "020e2eac973f2daa41c1687f8e867da9d521f945547e6aa82c87993b3ff6fc64",
|
||||
"record_id": "custodian-WP-0001",
|
||||
"uuid": "e22516cc-4fdf-4dad-a928-a9e04a9fa7c7",
|
||||
"parent_record_id": "custodian-WP-0001"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0002-triggers-ops.md",
|
||||
"sha256": "d8cbc61631c01b6f11a03a63f2fb6275fc853ff7f7459ce65973ed1a233daa01",
|
||||
"record_id": "custodian-WP-0002",
|
||||
"uuid": "3a4f47d9-8bc1-434e-acb4-bed5d4dacda0",
|
||||
"parent_record_id": "custodian-WP-0002"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003a-event-bridge-model-rules-registry.md",
|
||||
"sha256": "ffa6682e9ba3aaf559bd44203d561c90dea69bcf1b1e778209a7b209e6e5cdd3",
|
||||
"record_id": "custodian-WP-0003a",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003b-event-bridge-parser-workflow-triggers.md",
|
||||
"sha256": "4fc34e0f821e2df51e5c08c7ab34da35b65690381d01f0cfdf55d00c970b414c",
|
||||
"record_id": "custodian-WP-0003b",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0003c-event-bridge-context-integration.md",
|
||||
"sha256": "b6cd62b26515e509670ca39736b6bcec9633beedd46c9bd8f7598c06bac7276b",
|
||||
"record_id": "custodian-WP-0003c",
|
||||
"uuid": "b4eb45a9-69e3-4ab0-b00c-67a53c3117c5",
|
||||
"parent_record_id": "custodian-WP-0003"
|
||||
},
|
||||
{
|
||||
"repo": "activity-core",
|
||||
"path": "workplans/custodian-WP-0004-railiance-ops.md",
|
||||
"sha256": "20df02eea740e109c937130e7f8801a636fc0990ed8c6d2ced55659f8957adb1",
|
||||
"record_id": "custodian-WP-0004",
|
||||
"uuid": "759b1255-aa78-42b5-8fab-c5fcf168f5f4",
|
||||
"parent_record_id": "custodian-WP-0004"
|
||||
},
|
||||
{
|
||||
"repo": "the-custodian",
|
||||
"path": "workplans/ADHOC-2026-08-25-retired.md",
|
||||
"sha256": "344a1293dbb3a67a944eb34ca4a0337487d8f047226027a2107922346fbab2dd",
|
||||
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
|
||||
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
"sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
|
||||
"record_id": "RAIL-BS-WP-0002",
|
||||
"uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
|
||||
"parent_record_id": "RAIL-BS-WP-0002",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T05",
|
||||
"uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
"sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
|
||||
"record_id": "RAIL-BS-WP-0003",
|
||||
"uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
|
||||
"parent_record_id": "RAIL-BS-WP-0003",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "ab166073-30a7-4702-a037-4091e8706e20",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"repo": "railiance-cluster",
|
||||
"path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
"sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
|
||||
"record_id": "RAIL-BS-WP-0004",
|
||||
"uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
|
||||
"parent_record_id": "RAIL-BS-WP-0004",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "T01",
|
||||
"uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T02",
|
||||
"uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T03",
|
||||
"uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T04",
|
||||
"uuid": "08233868-d522-4117-bc4e-6c0f52545665",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T05",
|
||||
"uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
|
||||
"status": "done"
|
||||
},
|
||||
{
|
||||
"label": "T06",
|
||||
"uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
|
||||
"status": "done"
|
||||
}
|
||||
]
|
||||
}
|
||||
]''')
|
||||
|
||||
|
||||
def bound_sources(repo_root: Path, repo_slug: str):
|
||||
entries = [entry for entry in BINDINGS if entry["repo"] == repo_slug]
|
||||
result = []
|
||||
for entry in entries:
|
||||
path = repo_root / entry["path"]
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ValueError("Historical source binding missing or symlinked: " + entry["path"])
|
||||
data = path.read_bytes()
|
||||
if sha256(data).hexdigest() != entry["sha256"]:
|
||||
raise ValueError("Historical source binding changed: " + entry["path"])
|
||||
result.append((entry, data.decode("utf-8")))
|
||||
return result
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0002
|
||||
type: workplan
|
||||
title: "k3s and Kubernetes Platform Baseline"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: railiance
|
||||
topic_slug: railiance
|
||||
repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
|
||||
state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
|
||||
created: "2026-03-09"
|
||||
updated: "2026-03-10"
|
||||
completed: "2026-03-10"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# k3s and Kubernetes Platform Baseline
|
||||
|
||||
## Goal
|
||||
|
||||
Install k3s, Helm, and the baseline Kubernetes services on the converged
|
||||
HostEurope node. This workplan picks up exactly where `railiance-hosts`
|
||||
leaves off: a hardened, verified OS node that is ready for Kubernetes.
|
||||
|
||||
## Pre-condition
|
||||
|
||||
`railiance-infra` converge + Goss verify must pass before any task here
|
||||
is executed:
|
||||
|
||||
```bash
|
||||
# In railiance-infra/
|
||||
make converge
|
||||
make verify # must exit 0
|
||||
```
|
||||
|
||||
## Boundary
|
||||
|
||||
This repo owns everything from k3s upward. It must not re-configure items
|
||||
defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
|
||||
`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
|
||||
|
||||
**Out of scope here:** platform services (PostgreSQL, storage, identity)
|
||||
→ `railiance-platform`. Application deployments (Gitea, coulomb services)
|
||||
→ `railiance-apps`.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Ansible playbook: install k3s (server mode)
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Harden `ansible/bootstrap.yml` to a production-ready k3s install:
|
||||
|
||||
- Use the official k3s install script pinned to a specific version
|
||||
(`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
|
||||
- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
|
||||
(cluster-init enables embedded etcd for future HA expansion)
|
||||
- Wait for node `Ready` before proceeding:
|
||||
```bash
|
||||
k3s kubectl wait node --all --for=condition=Ready --timeout=120s
|
||||
```
|
||||
- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
|
||||
|
||||
**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
|
||||
and the control node (via kubeconfig).
|
||||
|
||||
---
|
||||
|
||||
### T02 — Helm installation
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Add a task (or a role `roles/helm/`) that:
|
||||
|
||||
1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
|
||||
2. Verifies the checksum
|
||||
3. Confirms `helm version` succeeds
|
||||
|
||||
**Done when:** `helm version` succeeds on the HostEurope node.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Smoke test: k3s + Helm
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Extend `tests/smoke_kube.sh` to assert:
|
||||
|
||||
- `k3s kubectl get nodes` → node in Ready state
|
||||
- `helm version` exits 0
|
||||
- CoreDNS pod running in `kube-system`
|
||||
- Traefik ingress controller pod running (default in k3s)
|
||||
|
||||
Run via:
|
||||
```bash
|
||||
ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
|
||||
```
|
||||
or directly over SSH if the kubeconfig is available locally.
|
||||
|
||||
**Done when:** all assertions pass and the script exits 0.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Commit kubeconfig management notes
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Document in `docs/kubeconfig.md`:
|
||||
|
||||
- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
|
||||
- How to merge it into `~/.kube/config`
|
||||
- How to switch context: `kubectl config use-context default`
|
||||
- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
|
||||
|
||||
**Done when:** doc written and committed.
|
||||
|
||||
---
|
||||
|
||||
### T05 — Add `make k3s-install` and `make smoke` targets
|
||||
|
||||
```task
|
||||
id: T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
|
||||
completed: "2026-03-10"
|
||||
```
|
||||
|
||||
Add to Makefile (create one if none exists):
|
||||
|
||||
```makefile
|
||||
k3s-install: ## Install k3s and Helm on all inventory hosts
|
||||
ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
|
||||
|
||||
smoke: ## Run Kubernetes smoke tests
|
||||
bash tests/smoke_kube.sh
|
||||
```
|
||||
|
||||
**Done when:** both targets work and are listed in `make help`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
|
||||
- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
|
||||
- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
|
||||
- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
|
||||
- k3s releases: https://github.com/k3s-io/k3s/releases
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0003
|
||||
type: workplan
|
||||
title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: tegwick
|
||||
created: "2026-03-10"
|
||||
updated: "2026-03-10"
|
||||
state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
|
||||
|
||||
## Summary
|
||||
|
||||
On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
|
||||
restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
|
||||
entered CrashLoopBackOff and produced no logs. As a result Gitea's login
|
||||
and all write operations hung indefinitely. The root page was still served
|
||||
(from Valkey cache) which masked the failure.
|
||||
|
||||
The fix was to patch a missing key in a Kubernetes secret. The root cause is
|
||||
that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
|
||||
populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
|
||||
secret, even though the pgpool pod requires it at startup.
|
||||
|
||||
---
|
||||
|
||||
## Timeline
|
||||
|
||||
| Time (UTC) | Event |
|
||||
|---|---|
|
||||
| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
|
||||
| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
|
||||
| ~11:00 | User noticed Gitea login hanging; home page still loading |
|
||||
| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
|
||||
| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
|
||||
| ~13:15 | Gitea fully operational |
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
The Bitnami `pgpool` container startup script reads the file
|
||||
`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
|
||||
`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
|
||||
mount. That secret key was never created by the Helm chart, so the file did
|
||||
not exist. The container exited immediately with no logs.
|
||||
|
||||
The pod had been running for 20 days without a restart, so this gap was
|
||||
never discovered during initial deployment.
|
||||
|
||||
---
|
||||
|
||||
## Evidence
|
||||
|
||||
```bash
|
||||
# Secret was missing the pgpool-password key
|
||||
sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
|
||||
# data: keys were password, postgres-password, repmgr-password only
|
||||
# pgpool-password was absent
|
||||
|
||||
# pgpool pod describe showed 824 back-off restarts over 173 minutes
|
||||
# No logs in either current or --previous output
|
||||
sudo k3s kubectl logs -n default <pgpool-pod> --previous
|
||||
# (empty)
|
||||
|
||||
# Gitea process had zero TCP connections to PostgreSQL port 5432
|
||||
# but many connections to Valkey port 6379
|
||||
cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Immediate Fix Applied
|
||||
|
||||
```bash
|
||||
# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
|
||||
sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
|
||||
--type='json' \
|
||||
-p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
|
||||
|
||||
# Restart pgpool
|
||||
sudo k3s kubectl delete pod -n default <pgpool-pod-name>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Risk: Fix Will Be Lost on helm upgrade
|
||||
|
||||
The patched secret is managed by Helm (annotation:
|
||||
`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
|
||||
secret from the chart template, which does not include `pgpool-password`,
|
||||
and the bug will recur.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Add pgpool-password to Helm values
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
|
||||
```
|
||||
|
||||
Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
|
||||
include the pgpool-password so it survives `helm upgrade`:
|
||||
|
||||
```yaml
|
||||
postgresql-ha:
|
||||
postgresql:
|
||||
pgpoolPassword: <value matching sr-check-password>
|
||||
```
|
||||
|
||||
**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
|
||||
without manual secret patching.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Add pgpool health check to smoke test
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
|
||||
```
|
||||
|
||||
Extend `tests/smoke_kube.sh` to assert:
|
||||
|
||||
```bash
|
||||
# All postgresql-ha pods Running
|
||||
kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
|
||||
|
||||
# pgpool specifically not in CrashLoopBackOff
|
||||
kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
|
||||
-o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
|
||||
```
|
||||
|
||||
**Done when:** the smoke test catches a pgpool failure within 5 minutes.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Add HA failover test
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
|
||||
```
|
||||
|
||||
Create `tests/test_ha_failover.sh` that:
|
||||
|
||||
1. Records Gitea login response time (baseline)
|
||||
2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
|
||||
3. Waits for repmgr to promote a replica (max 60s)
|
||||
4. Asserts Gitea login POST still succeeds within 10s
|
||||
5. Asserts pgpool pod is Running (not CrashLoopBackOff)
|
||||
6. Asserts all postgresql pods return to Running
|
||||
|
||||
This test must pass before any PostgreSQL HA deployment is considered done.
|
||||
|
||||
**Done when:** script exits 0 against a live cluster.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Document the incident in docs/
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
|
||||
```
|
||||
|
||||
Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
|
||||
timeline, root cause, and fix, so future operators understand what happened
|
||||
and how to recover.
|
||||
|
||||
**Done when:** doc committed and linked from `docs/README.md`.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Bitnami postgresql-ha chart v16.2.2
|
||||
- Gitea Helm chart v12.2.0
|
||||
- Related decision: D3 (HA testing policy) in `DECISIONS.md`
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
---
|
||||
id: RAIL-BS-WP-0004
|
||||
type: workplan
|
||||
title: "Integrated Backup — S2 Kubernetes Runtime Layer"
|
||||
domain: financials
|
||||
repo: railiance-cluster
|
||||
status: finished
|
||||
owner: tegwick
|
||||
topic_slug: railiance
|
||||
state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
|
||||
created: "2026-02-25"
|
||||
updated: "2026-03-26"
|
||||
---
|
||||
|
||||
> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
|
||||
# Integrated Backup — S2 Kubernetes Runtime Layer
|
||||
|
||||
## Goal
|
||||
|
||||
Implement the Q3 (Operability & Resilience) integrated backup for
|
||||
railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state —
|
||||
encrypted with age, written to a local directory on the server. No external
|
||||
dependencies required.
|
||||
|
||||
## Architecture (Decision D4)
|
||||
|
||||
Each railiance repo implements its own backup for what it owns. No central
|
||||
backup service. See `DECISIONS.md` D4 for full rationale.
|
||||
|
||||
**Standard interface every railiance repo must provide:**
|
||||
|
||||
```bash
|
||||
make backup # encrypt + write to /opt/backup/railiance/<layer>/
|
||||
make restore # restore from most recent local backup
|
||||
```
|
||||
|
||||
Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key).
|
||||
Output: `/opt/backup/railiance/cluster/` on the server.
|
||||
|
||||
## What S2 (railiance-cluster) owns and must back up
|
||||
|
||||
| Asset | Why it matters |
|
||||
|---|---|
|
||||
| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets |
|
||||
| Helm release values | Runtime values not in git (any manually applied overrides) |
|
||||
| kubeconfig | Admin access to the cluster |
|
||||
|
||||
**Not S2's responsibility:**
|
||||
- Custodian State Hub DB → the-custodian owns this
|
||||
- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern
|
||||
- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this
|
||||
- PostgreSQL data volumes → S3 (railiance-platform) owns this
|
||||
|
||||
## Encryption
|
||||
|
||||
Reuse the age public key from `.sops.yaml`:
|
||||
|
||||
```bash
|
||||
AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}')
|
||||
tar -czf - <assets> | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age
|
||||
```
|
||||
|
||||
Decryption requires the private key at `~/.config/sops/age/keys.txt`
|
||||
(same key used for `sops -d`). No additional key management needed.
|
||||
|
||||
## Extension Point EP-RAIL-005
|
||||
|
||||
Once all five OAS layers implement this interface, the custodian can
|
||||
orchestrate a full-stack backup with:
|
||||
|
||||
```bash
|
||||
for repo in railiance-infra railiance-cluster railiance-platform \
|
||||
railiance-enablement railiance-apps; do
|
||||
make -C ~/$repo backup
|
||||
done
|
||||
```
|
||||
|
||||
No special protocol needed — just the standard interface.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### T01 — Define backup directory and encryption wrapper
|
||||
|
||||
```task
|
||||
id: T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b"
|
||||
```
|
||||
|
||||
Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`):
|
||||
|
||||
- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`)
|
||||
- Encrypt each artifact with age using public key from `.sops.yaml`
|
||||
- Write timestamp-named files: `etcd-<ts>.snap.age`, `helm-values-<ts>.tar.gz.age`, `kubeconfig-<ts>.yaml.age`
|
||||
- Keep last 7 of each type
|
||||
- Write `.last-backup` stamp
|
||||
- Exit 0 on success, non-zero on any failure
|
||||
- No network required
|
||||
|
||||
Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based
|
||||
custodian DB — wrong scope, not applicable to this server).
|
||||
|
||||
**Done when:** `make backup` runs on COULOMBCORE without error and files
|
||||
appear in `/opt/backup/railiance/cluster/`.
|
||||
|
||||
---
|
||||
|
||||
### T02 — Back up k3s state (SQLite hot backup)
|
||||
|
||||
```task
|
||||
id: T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880"
|
||||
```
|
||||
|
||||
k3s has built-in etcd snapshot support:
|
||||
|
||||
```bash
|
||||
sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
# Default location: /var/lib/rancher/k3s/server/db/snapshots/
|
||||
```
|
||||
|
||||
Add to the backup script: take a fresh snapshot, encrypt with age,
|
||||
copy to `/opt/backup/railiance/cluster/`.
|
||||
|
||||
> **Note — verify etcd is in use before implementing:**
|
||||
> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`.
|
||||
> Without it, k3s uses SQLite and this command will fail.
|
||||
> Verify first: `sudo k3s etcd-snapshot ls 2>&1`
|
||||
|
||||
> **Note — sudo required:** etcd snapshot requires root. See T06 for how
|
||||
> this is resolved (backup runs under root's crontab).
|
||||
|
||||
**Done when:** backup includes a current etcd snapshot.
|
||||
|
||||
---
|
||||
|
||||
### T03 — Back up Helm release values
|
||||
|
||||
```task
|
||||
id: T03
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
|
||||
```
|
||||
|
||||
Capture current runtime Helm values for all releases:
|
||||
|
||||
```bash
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \
|
||||
jq -r '.[].name + " " + .namespace' | \
|
||||
while read name ns; do
|
||||
KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml
|
||||
done
|
||||
```
|
||||
|
||||
Tar and age-encrypt into `helm-values-<ts>.tar.gz.age`.
|
||||
|
||||
> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable
|
||||
> only by default. The backup script must either run as root (see T06) or k3s
|
||||
> must be configured with `--write-kubeconfig-mode=644`. Running as root
|
||||
> (via root crontab) is the chosen approach — no config change needed.
|
||||
|
||||
**Done when:** backup includes a snapshot of all Helm release values.
|
||||
|
||||
---
|
||||
|
||||
### T04 — Back up kubeconfig
|
||||
|
||||
```task
|
||||
id: T04
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665"
|
||||
```
|
||||
|
||||
Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`)
|
||||
into `kubeconfig-<ts>.yaml.age` in the backup directory.
|
||||
|
||||
**Done when:** backup includes the encrypted kubeconfig.
|
||||
|
||||
---
|
||||
|
||||
### T05 — make restore target
|
||||
|
||||
```task
|
||||
id: T05
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
|
||||
```
|
||||
|
||||
Add `tools/cmd/railiance-restore-s2` that decrypts and lists available
|
||||
backups, with guided restore for the etcd snapshot case.
|
||||
|
||||
Restore of etcd from snapshot:
|
||||
```bash
|
||||
sudo k3s server --cluster-reset \
|
||||
--cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<name>
|
||||
```
|
||||
|
||||
**Done when:** `make restore` prints available backups and a restore guide.
|
||||
|
||||
---
|
||||
|
||||
### T06 — Install cron job and run restore drill
|
||||
|
||||
```task
|
||||
id: T06
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07"
|
||||
```
|
||||
|
||||
#### Solving the sudo problem
|
||||
|
||||
The backup script needs root for two reasons:
|
||||
- `k3s etcd-snapshot save` requires root
|
||||
- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only
|
||||
|
||||
**Solution: run the cron under root's crontab.**
|
||||
|
||||
This is the correct pattern for system-level backup jobs. It avoids a
|
||||
proliferating sudoers whitelist (one entry per command, brittle to maintain)
|
||||
and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in
|
||||
production. The backup writes to `/opt/backup/` which is root-owned anyway.
|
||||
|
||||
Install the cron as root:
|
||||
|
||||
```bash
|
||||
sudo crontab -e
|
||||
# Add:
|
||||
0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1
|
||||
```
|
||||
|
||||
Note: use the absolute path to the repo — `~` does not expand reliably in
|
||||
root's crontab unless HOME is set.
|
||||
|
||||
Verify it is installed:
|
||||
```bash
|
||||
sudo crontab -l | grep railiance
|
||||
```
|
||||
|
||||
#### Restore drill
|
||||
|
||||
Once T01–T04 are done, run a decrypt-and-verify drill:
|
||||
|
||||
```bash
|
||||
# Decrypt the etcd snapshot and verify it is a valid snapshot file
|
||||
sudo age -d -i ~/.config/sops/age/keys.txt \
|
||||
/opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \
|
||||
| file -
|
||||
|
||||
# Record the drill
|
||||
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \
|
||||
>> /opt/backup/railiance/cluster/restore-drill.log
|
||||
```
|
||||
|
||||
**Done when:** cron installed under root, drill completes without error,
|
||||
log entry written.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Decision D4: Integrated backup per capability (`DECISIONS.md`)
|
||||
- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement)
|
||||
- OAS Q3: Operability & Resilience
|
||||
- Extension point EP-RAIL-005: Custodian full-stack backup orchestration
|
||||
- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from api.services import forge_projection as fp
|
||||
from api.services import historical_projection as hp
|
||||
from tests.test_forge_projection import _FakeSession,_Repo,_Row,_TaskRow
|
||||
|
||||
FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster'
|
||||
@pytest.fixture
|
||||
def bound(tmp_path):
|
||||
root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root)
|
||||
derived=fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
assert len(derived.workplans)==3
|
||||
rows=[];tasks=[]
|
||||
for parent in derived.workplans:
|
||||
row=_Row(parent.record_id.lower(),'finished',parent.relative_path);row.id=uuid.UUID(parent.uuid);rows.append(row)
|
||||
for source in parent.tasks:
|
||||
task=_TaskRow(source.record_id,status='done',workplan_id=row.id);task.id=uuid.UUID(source.uuid);tasks.append(task)
|
||||
assert len(tasks)==15
|
||||
return root,derived,rows,tasks
|
||||
|
||||
def test_exact_three_sources_derive_only_frozen_15_task_uuids(bound):
|
||||
root,derived,rows,tasks=bound
|
||||
assert all(parent.historical_preservation for parent in derived.workplans)
|
||||
assert all(parent.uuid!=fp.derived_record_uuid(parent.record_id) for parent in derived.workplans)
|
||||
expected={item['uuid'] for entry in hp.BINDINGS if entry['repo']=='railiance-cluster' for item in entry['tasks']}
|
||||
assert {str(t.id) for t in tasks}==expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_rows_are_entirely_unchanged_on_normal_receiving(bound):
|
||||
root,derived,rows,tasks=bound;before=[dict(x.__dict__) for x in rows+tasks]
|
||||
session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
assert outcome.status=='noop' and not outcome.refused
|
||||
assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
assert not session.added and not session.deleted
|
||||
|
||||
@pytest.mark.parametrize('defect',['missing-parent','missing-task','foreign-holder','nonterminal-task','nonterminal-parent','duplicate-current-label'])
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_actual_binding_refuses_before_any_mutation(bound,defect):
|
||||
root,derived,rows,tasks=bound
|
||||
if defect=='missing-parent':rows.pop()
|
||||
elif defect=='missing-task':tasks.pop()
|
||||
elif defect=='foreign-holder':tasks[0].workplan_id=uuid.uuid4()
|
||||
elif defect=='nonterminal-task':tasks[0].status='progress'
|
||||
elif defect=='duplicate-current-label':tasks.append(_TaskRow(tasks[0].record_id,status='done',workplan_id=tasks[0].workplan_id))
|
||||
else:rows[0].status='active'
|
||||
before=[dict(x.__dict__) for x in rows+tasks];session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
assert outcome.status=='refused';assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
assert not session.added and not session.deleted
|
||||
|
||||
@pytest.mark.parametrize('change',['hash-drift','missing','symlink','live-source','missing-source-uuid'])
|
||||
def test_frozen_source_drift_refuses_derivation(bound,change):
|
||||
root,*_=bound;p=next((root/'workplans/archived').glob('*.md'));raw=p.read_bytes()
|
||||
if change=='missing':p.unlink()
|
||||
elif change=='symlink':p.unlink();target=root/'saved';target.write_bytes(raw);p.symlink_to(target)
|
||||
elif change=='live-source':p.write_bytes(raw.replace(b'status: finished',b'status: active',1))
|
||||
elif change=='missing-source-uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1))
|
||||
else:p.write_bytes(raw+b'\nunreviewed\n')
|
||||
with pytest.raises(fp.ForgeDeriveError,match='Historical source binding'):fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
|
||||
def test_unknown_repository_never_trusts_frozen_source_uuids(bound):
|
||||
root,derived,*_=bound;other=fp.derive_from_checkout(root,'different-repo','candidate')
|
||||
assert not any(parent.historical_preservation for parent in other.workplans)
|
||||
assert not {parent.uuid for parent in derived.workplans}&{parent.uuid for parent in other.workplans}
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
---
|
||||
id: STATE-WP-0095
|
||||
type: workplan
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Normal Forge receiving must preserve the seven bound Activity legacy source
|
||||
views and the Custodian retired ADHOC source without new identities, source
|
||||
rewrites, task movement, deletion, relabeling or unretirement. This implements
|
||||
receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns
|
||||
cross-owner coordination; deployment and replay remain explicit owner returns.
|
||||
|
||||
## Verify source bindings and shared views
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7"
|
||||
```
|
||||
|
||||
Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound
|
||||
closed tasks. The archived custodian-WP-0003 parent and three split_from views
|
||||
share one existing parent UUID; every repeated task binding/content agrees.
|
||||
Custodian ADHOC-2026-08-25-retired retains parent
|
||||
1c53d3db-6633-5e2d-987f-10706d73be9e and task
|
||||
d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized.
|
||||
|
||||
## Implement preservation receiving and global collision refusal
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822"
|
||||
```
|
||||
|
||||
Prepared exact source digest manifest, existing-parent/task verification,
|
||||
immutable receiving and UUID-based historical diff. Ordinary canonical sources
|
||||
continue deriving their identities. Missing/changed/symlinked source, absent
|
||||
bound rows or nonterminal hub histories refuse. Check task UUID holders globally,
|
||||
including retired and acknowledged-stale parent workplans, before INSERT.
|
||||
|
||||
Offline preview tests and snapshot proof are evidence of source behavior only;
|
||||
finish after applying reviewed source and its focused tests to the owning repo.
|
||||
|
||||
## Deliver receiving through normal deployment and verify owner return
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T03
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T02]
|
||||
state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a"
|
||||
decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca"
|
||||
```
|
||||
|
||||
No direct live code patch. This task is a deployment return, not policy authority
|
||||
or permission to resolve identity decisions. Exact receiving fixture acceptance
|
||||
and global collision rejection must hold in the delivered version.
|
||||
|
||||
## Verify normal historical replay preserves existing identities
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T04
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03]
|
||||
state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4"
|
||||
```
|
||||
|
||||
Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired
|
||||
parent/task unchanged; no historical row creation, movement, deletion,
|
||||
relabeling or unretirement. Unrelated stale projections remain separate decisions.
|
||||
|
||||
2026-10-05: Source exact-set retirement support now exists. Repository projection
|
||||
reconcile accepts `expected_retirement_uuids`; that set must equal the stale
|
||||
projections, and any task or dependency edge on a stale row refuses the call.
|
||||
Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted
|
||||
review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three
|
||||
empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`.
|
||||
That acceptance is not a live retirement and does not resolve
|
||||
`91b7c633-a66f-5d43-9f4b-6565aefb9356`.
|
||||
|
||||
## Offline evidence, 2026-10-04
|
||||
|
||||
Prepared receiver suite: Forge projection regression plus historical preservation,
|
||||
missing/changed source, terminality, immutable UUID diff, and global holder query.
|
||||
Real-source offline snapshot proof preserves four Activity parents/64 tasks and
|
||||
one Custodian parent/task without mutations; no API writes or deployment occurred.
|
||||
Exact original bindings are maintained in api/services/historical_projection.py.
|
||||
|
||||
2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner
|
||||
cases passed; the independent offline current-source/current-hub-snapshot proof
|
||||
retained five existing parents and 65 task UUIDs, no mutations, clean historical
|
||||
diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and
|
||||
normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json.
|
||||
|
||||
|
||||
## Verified receiving rollout and remaining replay gate, 2026-10-04
|
||||
|
||||
T03 is done on actual founder approval of proposal
|
||||
STATE-WP-0095-T03-historical-receiving revision 1, SHA256
|
||||
`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`,
|
||||
approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`.
|
||||
The normal owner Helm path delivered `main-5dd3df9`, commit
|
||||
`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready,
|
||||
primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source
|
||||
hashes match. Rollback remains revision 71; no receipt-table downgrade is needed.
|
||||
Actual historical preservation and global task-holder refusal were verified in
|
||||
the delivered version. Durable receipt:
|
||||
[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json).
|
||||
|
||||
Custodian immutable replay applied while preserving the retired historical
|
||||
parent/task UUIDs. Activity immutable replay retained all four historical parent
|
||||
and 64 task UUIDs and historical row contents, but refused transactionally on
|
||||
three unrelated stale projections: custodian-wp-0003 (archived),
|
||||
custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement
|
||||
was issued and that refusal is not represented as normal replay success.
|
||||
|
||||
Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own
|
||||
normal sync succeeded; registrar admission does not authorize retiring Activity
|
||||
projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting
|
||||
Activity Core's precise reviewed disposition of those three stale rows. No
|
||||
rename, identity migration, new paid native trial or further live action is
|
||||
included in this source status update.
|
||||
|
||||
## Activity Core return, 2026-10-06
|
||||
|
||||
Activity Core returned the exact disposition (message
|
||||
`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit
|
||||
`941fac0`). `expected_retirement_uuids`:
|
||||
`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003),
|
||||
`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a),
|
||||
`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero
|
||||
tasks and zero dependency edges. None is preserved parent
|
||||
`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the
|
||||
set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired.
|
||||
T04 now waits on decision `91b7c633` and a reviewed live rollout. After that,
|
||||
Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03.
|
||||
|
||||
## Retirement rollout and T04 verification, 2026-10-06
|
||||
|
||||
The founder resolved decision `91b7c633` (retire exactly the three projections)
|
||||
and approved the rollout. The founder ran the normal Helm upgrade to
|
||||
`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP
|
||||
are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is
|
||||
unchanged; no migrations. The activity-core reconcile at commit
|
||||
`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set
|
||||
to the three UUIDs applied: 0 created, 3 retired (tombstoned with an
|
||||
`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks
|
||||
cancelled. All 7 bound Activity source files still match their digests. The four
|
||||
historical parents and 64 task UUIDs are present under their bound parents with
|
||||
unchanged fields. The Custodian retired parent and task are unchanged. Activity
|
||||
had 54 workplans and 288 tasks before and after. Evidence:
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
|
||||
|
||||
## Cluster exact historical receiving successor, 2026-10-17
|
||||
|
||||
This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
registrar admission; never derive replacement identities or modify those
|
||||
sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
The local derived case is waiting; no live worker injection is authorized.
|
||||
|
||||
## Prepare exact cluster source preservation and refusal cases
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b"
|
||||
```
|
||||
|
||||
Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
expected ownership and terminal status. These are source and metadata checks,
|
||||
not delivered receiving or a committed native trial.
|
||||
|
||||
## Deliver reviewed cluster receiving successor before registrar admission
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T06
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T05]
|
||||
needs_human: true
|
||||
intervention_note: "Review exact committed source, CI image and owner deployment proposal before rollout."
|
||||
state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4"
|
||||
```
|
||||
|
||||
Use the normal CI image and Helm owner path after exact revision/image pin,
|
||||
fresh headroom and reviewed dry-run/diff. API/MCP currently run main-7d99282;
|
||||
Helm revision75 is the observed rollback baseline, requiring freshness before
|
||||
execution. No deployment assent exists for this successor. Verify delivered
|
||||
module hash, health, both readiness checks and expected migrated schema
|
||||
c4d5e6f7a8b9; verify exact
|
||||
three parents/15 tasks and rejection behavior without committed probe changes.
|
||||
Only the verified return unblocks RMGR-WP-0019-T05. The full current image also carries inbox-feed migration c4d5e6f7a8b9,
|
||||
although this receiver patch adds none. Helm rollback leaves that migration in
|
||||
place; unrelated migration acceptance and old-image compatibility need normal
|
||||
owner evidence before rollout approval. Do not run destructive default local
|
||||
DB tests; use only exact disposable DB containment. Costs and monetary risk are
|
||||
unknown pending exact rollout proposal; no zero-cost or autonomy grant is inferred.
|
||||
|
||||
## Verify ordinary cluster receiving and immutable historical rows
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T07
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06]
|
||||
state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
```
|
||||
|
||||
Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
unretirement or retirement is authorized for these histories. A rollback first
|
||||
fences cluster sync and restores registrar refusal before removing receiving
|
||||
support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
original TLS recovery or owner-native admission.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
def load(name):
|
||||
path=Path(__file__).parent/(name+'.py')
|
||||
spec=importlib.util.spec_from_file_location(name,path)
|
||||
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
pub=load('publish_receiver_source')
|
||||
ci=load('lookup_ci_metadata')
|
||||
|
||||
def git(root,*args):
|
||||
return subprocess.check_output(['git','-C',str(root),*args]).decode().strip()
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
root=tmp_path/'owner';root.mkdir()
|
||||
git(root,'init','-b','main');git(root,'config','user.name','Synthetic Test');git(root,'config','user.email','synthetic@example.invalid')
|
||||
for name in ['source.py','.custodian-brief.md','WORK-RECORDS.md','evidence.json']:
|
||||
(root/name).write_text('old\n')
|
||||
git(root,'add','.');git(root,'commit','-m','baseline')
|
||||
base=git(root,'rev-parse','HEAD')
|
||||
(root/'.custodian-brief.md').write_text('staged brief\n');git(root,'add','.custodian-brief.md')
|
||||
(root/'.custodian-brief.md').write_text('working brief\n')
|
||||
(root/'WORK-RECORDS.md').write_text('working index\n');(root/'evidence.json').write_text('working evidence\n')
|
||||
(root/'new-evidence.json').write_text('new evidence\n')
|
||||
(root/'source.py').write_text('reviewed source\n');(root/'new-test.py').write_text('reviewed new test\n')
|
||||
expected={'base_revision':base,'files':[{'path':p,'candidate_sha256':hashlib.sha256((root/p).read_bytes()).hexdigest()} for p in ['source.py','new-test.py']]}
|
||||
return root,expected
|
||||
|
||||
def test_exact_commit_preserves_staged_brief_and_worktree_evidence(repo,tmp_path):
|
||||
root,expected=repo
|
||||
remote=tmp_path/'remote.git';subprocess.run(['git','init','--bare',str(remote)],check=True,capture_output=True)
|
||||
git(root,'remote','add','origin',str(remote))
|
||||
owned={f['path'] for f in expected['files']};before=pub.preserved_state(root,owned)
|
||||
result=pub.commit_source(root,expected,[sys.executable,'-c','raise SystemExit(0)'],execute=True,push=True)
|
||||
assert result['status']=='source_published_verified'
|
||||
assert pub.preserved_state(root,owned)==before
|
||||
assert git(root,'show',':.custodian-brief.md')=='staged brief'
|
||||
assert git(root,'show','HEAD:.custodian-brief.md')=='old'
|
||||
assert set(git(root,'diff-tree','--no-commit-id','--name-only','-r','HEAD').splitlines())==owned
|
||||
|
||||
def test_default_is_read_only_and_drift_refuses_before_commit(repo):
|
||||
root,expected=repo;head=git(root,'rev-parse','HEAD');stage=git(root,'ls-files','--stage')
|
||||
assert pub.commit_source(root,expected,[])['status']=='validated_not_executed'
|
||||
assert git(root,'rev-parse','HEAD')==head and git(root,'ls-files','--stage')==stage
|
||||
(root/'source.py').write_text('unreviewed change')
|
||||
with pytest.raises(pub.Refusal,match='reviewed_source_drift'):
|
||||
pub.commit_source(root,expected,[],execute=True)
|
||||
assert git(root,'rev-parse','HEAD')==head and git(root,'ls-files','--stage')==stage
|
||||
|
||||
def test_normal_scanner_refusal_does_not_commit_or_change_unrelated_state(repo):
|
||||
root,expected=repo;head=git(root,'rev-parse','HEAD')
|
||||
owned={f['path'] for f in expected['files']};before=pub.preserved_state(root,owned)
|
||||
with pytest.raises(pub.Refusal,match='normal_source_scanner_refused'):
|
||||
pub.commit_source(root,expected,[sys.executable,'-c','raise SystemExit(1)'],execute=True)
|
||||
assert git(root,'rev-parse','HEAD')==head and pub.preserved_state(root,owned)==before
|
||||
|
||||
@pytest.mark.parametrize('task_status,digest,expected',[('success','sha256:'+'b'*64,'artifact_metadata_verified'),('failure','sha256:'+'b'*64,'artifact_unverified'),('success',None,'artifact_unverified')])
|
||||
def test_ci_and_digest_both_required(monkeypatch,task_status,digest,expected):
|
||||
revision='a'*40
|
||||
tasks={'workflow_runs':[{'head_sha':revision,'name':'build-and-push','status':task_status,'id':12}]}
|
||||
def response(url,*args):
|
||||
return {'status':'ok','body':json.dumps(tasks).encode()} if url==ci.TASKS else {'status':'ok','digest':digest}
|
||||
monkeypatch.setattr(ci,'request',response)
|
||||
assert ci.lookup(revision)['status']==expected
|
||||
|
||||
def test_public_auth_refusal_does_not_attempt_credential_fallback(monkeypatch):
|
||||
calls=[]
|
||||
def refused(url,*args):calls.append(url);return {'status':'public_access_refused','http_status':401}
|
||||
monkeypatch.setattr(ci,'request',refused)
|
||||
result=ci.lookup('a'*40)
|
||||
assert result['status']=='artifact_unverified' and len(calls)==2
|
||||
assert 'image_digest' not in result
|
||||
|
||||
def test_exact_revision_required_before_network(monkeypatch):
|
||||
monkeypatch.setattr(ci,'request',lambda *a:pytest.fail('network must not start'))
|
||||
with pytest.raises(ValueError,match='exact40char_revision_required'):ci.lookup('a'*7)
|
||||
|
||||
preflight=load('preflight_receiver')
|
||||
|
||||
@pytest.mark.parametrize('target,headroom,status,expected_calls',[(False,True,1,1),(True,False,1,2),(True,True,0,3)])
|
||||
def test_preflight_refuses_wrong_target_or_headroom_and_never_deploys(monkeypatch,capsys,target,headroom,status,expected_calls):
|
||||
calls=[]
|
||||
def command(argv,env,timeout):
|
||||
calls.append(argv)
|
||||
assert env['KUBECONFIG']==preflight.KUBECONFIG
|
||||
if argv[0]=='kubectl':return 0,json.dumps({'items':[{'status':{'addresses':[{'address':'92.205.62.239' if target else '192.0.2.1'}]}}]}).encode()
|
||||
if 'railiance-state-hub-headroom' in argv:return 0,json.dumps({'ok':headroom,'reasons':[]}).encode()
|
||||
assert 'railiance-state-hub-server-dry-run' in argv
|
||||
assert 'RAILIANCE_STATE_HUB_IMAGE_TAG=main-aaaaaaa' in argv
|
||||
return 0,b'sensitive internal diagnostics must not be emitted'
|
||||
monkeypatch.setattr(preflight,'command',command)
|
||||
monkeypatch.setattr(sys,'argv',['preflight','--source-revision','a'*40])
|
||||
assert preflight.main()==status and len(calls)==expected_calls
|
||||
emitted=capsys.readouterr().out
|
||||
assert 'sensitive internal diagnostics' not in emitted
|
||||
assert json.loads(emitted)['production_mutations'] is False
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"schema": "receiver-policy-compatible-make-serverdryrun.v1",
|
||||
"candidate_makefile_sha256": "sha256:5584ef94071044cca8215141ecd8aee8d6ae5cfe73e8f5719358c680eb8af0d4",
|
||||
"image_tag": "main-51bc70d",
|
||||
"target_kubeconfig_fixed": true,
|
||||
"status": "passed",
|
||||
"exit_code": 0,
|
||||
"classification": null,
|
||||
"production_mutations": false,
|
||||
"actual_secret_writes": false,
|
||||
"raw_diagnostics_retained": false,
|
||||
"force_conflicts_used": false,
|
||||
"prior_make_failure_preserved": true,
|
||||
"limitations": [
|
||||
"Server admission only; not exact Helm reuse-values upgrade equivalence or rollout assent."
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"schema": "receiver-selected-image-source-comparison.v1",
|
||||
"source_revision": "51bc70d1ff8f25450a8650d8e50caf053093a02e",
|
||||
"image_digest": "sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015",
|
||||
"selected_files": [
|
||||
{
|
||||
"path": "api/services/historical_projection.py",
|
||||
"expected_sha256": "sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2",
|
||||
"image_sha256": "sha256:e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2",
|
||||
"matches": true,
|
||||
"comparison_status": "match"
|
||||
},
|
||||
{
|
||||
"path": "api/models/agent_message.py",
|
||||
"expected_sha256": "sha256:97c3bf5f66304d0ddefbb5f0fa7889e087b17b9c9064676f3a8221c8865914a0",
|
||||
"image_sha256": null,
|
||||
"matches": false,
|
||||
"comparison_status": "not_selected_in_receipt"
|
||||
},
|
||||
{
|
||||
"path": "api/routers/message_feed.py",
|
||||
"expected_sha256": "sha256:8e71d80397cd8cfa446682a14e6f4b1aabc3db17c748a279f95da61acf9b9aa9",
|
||||
"image_sha256": null,
|
||||
"matches": false,
|
||||
"comparison_status": "not_selected_in_receipt"
|
||||
},
|
||||
{
|
||||
"path": "migrations/versions/c4d5e6f7a8b9_inbox_feed_revision.py",
|
||||
"expected_sha256": "sha256:48f1730037be99b5623e878d7f20a06bec243ce27317fde81c82e16006d7b3ce",
|
||||
"image_sha256": "sha256:48f1730037be99b5623e878d7f20a06bec243ce27317fde81c82e16006d7b3ce",
|
||||
"matches": true,
|
||||
"comparison_status": "match"
|
||||
}
|
||||
],
|
||||
"image_executed": false,
|
||||
"scope": "Selected source bytes only; package origin/hermetic or full runtime acceptance not inferred.",
|
||||
"all_observed_match": true,
|
||||
"full_requested_selection_observed": false
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"schema": "repo-manager.identifier-assignment.v1",
|
||||
"ok": true,
|
||||
"executed": true,
|
||||
"repo": "state-hub",
|
||||
"namespace": "helixforge",
|
||||
"assignments": [
|
||||
{
|
||||
"kind": "task",
|
||||
"record_id": "STATE-WP-0095-T08",
|
||||
"uuid": "2dd9b04f-2250-5a8a-b5bc-c8a009872858",
|
||||
"path": "workplans/STATE-WP-0095-historical-source-receiving.md"
|
||||
}
|
||||
],
|
||||
"historical_invalid_identifiers": [],
|
||||
"files_changed": [
|
||||
"workplans/STATE-WP-0095-historical-source-receiving.md"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"schema": "statehub-readiness-review-freeze.v1",
|
||||
"stable_proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1",
|
||||
"revision": 3,
|
||||
"files": [
|
||||
{
|
||||
"path": "ROLLOUT-SUPPLEMENT.md",
|
||||
"sha256": "sha256:df2962fd1588784a987e37932bd67f03f6248ea6384cf4bbb74ad1fe2e26be2b"
|
||||
},
|
||||
{
|
||||
"path": "source-patch-manifest.json",
|
||||
"sha256": "sha256:98211e65bebeee2c25ca75c6b9f0bc45128093baaba02069b6c41a15be32fe74"
|
||||
},
|
||||
{
|
||||
"path": "state-hub-readiness.patch",
|
||||
"sha256": "sha256:4c52fd5a102df43ceb8e007d93d34efe861a3b321527cf5f3e195e35113c36a0"
|
||||
},
|
||||
{
|
||||
"path": "managed-task-uuid-assignment.json",
|
||||
"sha256": "sha256:16f1b720a38744734dbab524ed2496269b7a08796cb6f2d72272f922f8f067f8"
|
||||
},
|
||||
{
|
||||
"path": "candidate-make-serverdryrun-result.json",
|
||||
"sha256": "sha256:d251ca8f03146d95046ef723e364bde2413b223a5a2027cfc74101a180344a63"
|
||||
},
|
||||
{
|
||||
"path": "image-selected-source-comparison.json",
|
||||
"sha256": "sha256:8fc724b3f61119e78b0ce153b0f48e0d8658ec46411c1c555b7009a58e0ce477"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
--- a/Makefile
|
||||
+++ b/Makefile
|
||||
@@ -174,12 +174,12 @@
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||
- $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
+ $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \
|
||||
- $(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
+ $(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||
else \
|
||||
echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \
|
||||
--- /dev/null
|
||||
+++ b/tests/test_server_dryrun_commands.py
|
||||
@@ -0,0 +1,30 @@
|
||||
+"""Pure Make command fixture: run with --noconftest, no database or cluster."""
|
||||
+import json
|
||||
+import os
|
||||
+from pathlib import Path
|
||||
+import subprocess
|
||||
+import sys
|
||||
+import pytest
|
||||
+
|
||||
+@pytest.mark.parametrize('namespace_exists',[True,False])
|
||||
+def test_secret_server_dryrun_uses_ssa_without_force_or_other_phase_changes(tmp_path,namespace_exists):
|
||||
+ makefile=Path(__file__).resolve().parents[1]/'Makefile'
|
||||
+ log=tmp_path/'commands.jsonl';helm=tmp_path/'helm';kubectl=tmp_path/'kubectl'
|
||||
+ helm.write_text('#!'+sys.executable+'\nprint("apiVersion: v1\\nkind: ConfigMap\\nmetadata: {name: fixture}")\n')
|
||||
+ kubectl.write_text('#!'+sys.executable+'\nimport json,os,sys\nwith open(os.environ["NK_DRYRUN_LOG"],"a") as log:log.write(json.dumps(sys.argv[1:])+"\\n")\nif sys.argv[1:3]==["get","namespace"] and os.environ["NK_NAMESPACE_EXISTS"]=="0":raise SystemExit(1)\n')
|
||||
+ helm.chmod(0o700);kubectl.chmod(0o700)
|
||||
+ env=dict(os.environ);env['NK_DRYRUN_LOG']=str(log);env['NK_NAMESPACE_EXISTS']='1' if namespace_exists else '0'
|
||||
+ p=subprocess.run(['make','--no-print-directory','-f',str(makefile),'railiance-state-hub-server-dry-run','HELM='+str(helm),'KUBECTL='+str(kubectl),'RAILIANCE_STATE_HUB_IMAGE_TAG=main-51bc70d'],cwd=tmp_path,env=env,capture_output=True,timeout=10)
|
||||
+ assert p.returncode==0
|
||||
+ calls=[json.loads(line) for line in log.read_text().splitlines()]
|
||||
+ applies=[args for args in calls if args[0]=='apply']
|
||||
+ secrets=[args for args in applies if any(p.endswith('.sops.yaml.template') for p in args)]
|
||||
+ assert len(secrets)==2
|
||||
+ assert all('--force-conflicts' not in args and '--force' not in args for args in applies)
|
||||
+ for args in secrets:
|
||||
+ if '--dry-run=server' in args:assert '--server-side' in args
|
||||
+ else:assert not namespace_exists and '--dry-run=client' in args and '--server-side' not in args
|
||||
+ others=[args for args in applies if args not in secrets]
|
||||
+ assert all('--server-side' not in args for args in others)
|
||||
+ assert all('--dry-run=server' in args or '--dry-run=client' in args for args in applies)
|
||||
+ assert not any(args[0] in {'create','replace','delete','patch'} for args in calls)
|
||||
--- a/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
+++ b/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
@@ -237,3 +237,25 @@
|
||||
fences cluster sync and restores registrar refusal before removing receiving
|
||||
support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
original TLS recovery or owner-native admission.
|
||||
+
|
||||
+## Fix policy-compatible Secret-template server dry-run preparation
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T08
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858"
|
||||
+```
|
||||
+
|
||||
+Prepared the narrow Makefile successor after the existing readiness command
|
||||
+refused its first Secret-template phase. Ordinary client-side apply adds the
|
||||
+last-applied annotation, correctly denied by the existing Platform
|
||||
+reject-secret-last-applied policy. Only the two Secret-template server
|
||||
+dry-run requests now use server-side apply with dry-run; no force-conflicts,
|
||||
+Secret value change, policy relaxation or actual Secret write is introduced.
|
||||
+Command-fixture tests verify both requests and preserve other dry-run phases.
|
||||
+Evidence and original refusal remain in the Custodian cluster historical
|
||||
+publication packet, revisions/r2 and r3-readiness. This is completed source
|
||||
+preparation; T06 remains the explicit native rollout gate. The runtime image
|
||||
+continues to be main-51bc70d; Makefile/workplan changes do not create a new
|
||||
+application image or native acceptance.
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
.PHONY: start install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard dashboard-local sync check-primary primary-port-clear check check-local test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run railiance-state-hub-headroom
|
||||
|
||||
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
|
||||
PYTHON ?= python3
|
||||
HELM ?= $(shell command -v helm 2>/dev/null || if [ -x "$$HOME/.local/bin/helm" ]; then printf "%s" "$$HOME/.local/bin/helm"; else printf "%s" "helm"; fi)
|
||||
KUBECTL ?= $(shell command -v kubectl 2>/dev/null || if [ -x "$$HOME/.local/bin/kubectl" ]; then printf "%s" "$$HOME/.local/bin/kubectl"; else printf "%s" "kubectl"; fi)
|
||||
|
||||
RAILIANCE_STATE_HUB_RELEASE ?= state-hub
|
||||
RAILIANCE_STATE_HUB_NAMESPACE ?= state-hub
|
||||
RAILIANCE_STATE_HUB_CHART ?= deploy/railiance/apps/charts/state-hub
|
||||
RAILIANCE_STATE_HUB_VALUES ?= deploy/railiance/apps/helm/state-hub-values.yaml
|
||||
RAILIANCE_STATE_HUB_IMAGE_TAG ?= main-0747b4d
|
||||
RAILIANCE_STATE_HUB_PLATFORM_DIR ?= deploy/railiance/platform
|
||||
RAILIANCE_STATE_HUB_APP_MANIFESTS ?= deploy/railiance/apps/manifests
|
||||
# Codex/WSL non-login shells may not source ~/.profile; keep uv discoverable.
|
||||
UV ?= $(shell command -v uv 2>/dev/null || if [ -x "$$HOME/.local/bin/uv" ]; then printf "%s" "$$HOME/.local/bin/uv"; else printf "%s" "uv"; fi)
|
||||
RMGR ?= $(shell command -v rmgr 2>/dev/null || if [ -x "$$HOME/repo-manager/.venv/bin/rmgr" ]; then printf "%s" "$$HOME/repo-manager/.venv/bin/rmgr"; else printf "%s" "rmgr"; fi)
|
||||
STATE_HUB_API_BASE ?= http://127.0.0.1:8000
|
||||
SYNC_PATH ?= .
|
||||
SYNC_PUSH ?= 1
|
||||
|
||||
start:
|
||||
@echo "# Normal production UI access (run in order)"
|
||||
@echo "make bridges # connect local :8000 to the railiance01 primary"
|
||||
@echo "make dashboard # verify the primary, then serve the UI on :3000"
|
||||
@echo "make sync # push file-backed records and reconcile the exact forge commit"
|
||||
@echo ""
|
||||
@echo "# Optional local MCP adapter"
|
||||
@echo "make mcp-http # local SSE adapter on :8001; not needed by the UI"
|
||||
@echo ""
|
||||
@echo "# Local fallback/development only"
|
||||
@echo "make api # local Postgres + API; conflicts with the production :8000 tunnel"
|
||||
@echo "make dashboard-local # dashboard against the deliberate local API"
|
||||
|
||||
install:
|
||||
$(UV) sync
|
||||
|
||||
dashboard/node_modules/.bin/observable: dashboard/package.json dashboard/package-lock.json
|
||||
cd dashboard && npm ci
|
||||
|
||||
dashboard-install: dashboard/node_modules/.bin/observable
|
||||
|
||||
dashboard-check: dashboard-install
|
||||
cd dashboard && npm run build
|
||||
|
||||
## Symlink the custodian CLI into ~/.local/bin so it's on PATH system-wide
|
||||
install-cli: install
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(shell pwd)/.venv/bin/custodian" ~/.local/bin/custodian
|
||||
ln -sf "$(shell pwd)/.venv/bin/statehub" ~/.local/bin/statehub
|
||||
@echo "Installed: custodian → $$(readlink -f ~/.local/bin/custodian)"
|
||||
@echo "Installed: statehub → $$(readlink -f ~/.local/bin/statehub)"
|
||||
@echo "Make sure ~/.local/bin is on your PATH:"
|
||||
@echo " echo 'export PATH=\"\$$HOME/.local/bin:\$$PATH\"' >> ~/.bashrc && source ~/.bashrc"
|
||||
|
||||
db:
|
||||
$(COMPOSE) up -d postgres
|
||||
|
||||
db-tools:
|
||||
$(COMPOSE) --profile tools up -d
|
||||
|
||||
migrate:
|
||||
$(UV) run alembic upgrade head
|
||||
|
||||
seed:
|
||||
$(UV) run python scripts/seed.py
|
||||
|
||||
## Start (or restart) the MCP SSE server on :8001 — primary transport for Claude Code.
|
||||
## Remote clients (e.g. COULOMBCORE) connect via the ops-bridge tunnel (port 18001).
|
||||
## Registration: claude mcp add-json -s user state-hub '{"type":"sse","url":"http://127.0.0.1:8001/sse"}'
|
||||
mcp-http:
|
||||
@fuser -k 8001/tcp 2>/dev/null && echo "Stopped running MCP server" || true
|
||||
MCP_TRANSPORT=sse MCP_PORT=8001 $(UV) run python mcp_server/server.py
|
||||
|
||||
## Require the production identity before serving the normal dashboard. This
|
||||
## prevents an accidental local `make api` from presenting an empty fallback DB
|
||||
## as the live State Hub.
|
||||
check-primary:
|
||||
@health="$$(curl -fsS --max-time 5 http://127.0.0.1:8000/state/health 2>/dev/null)" || { \
|
||||
echo "ERROR: State Hub primary is not reachable on 127.0.0.1:8000." >&2; \
|
||||
echo "Run 'make bridges' first." >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: 127.0.0.1:8000 is '$$identity', not the primary/railiance01 State Hub." >&2; \
|
||||
echo "Stop the local API, then run 'make bridges'. Use 'make dashboard-local' only for intentional local development." >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
## Refuse to start the production tunnel when a non-primary API already owns
|
||||
## port 8000. With no listener, bridge is free to establish the tunnel.
|
||||
primary-port-clear:
|
||||
@if health="$$(curl -fsS --max-time 2 http://127.0.0.1:8000/state/health 2>/dev/null)"; then \
|
||||
identity="$$(printf '%s' "$$health" | $(PYTHON) -c 'import json, sys; d=json.load(sys.stdin); print("{}/{}".format(d.get("instance_role", ""), d.get("instance_label", "")))')"; \
|
||||
if [ "$$identity" != "primary/railiance01" ]; then \
|
||||
echo "ERROR: port 8000 is occupied by '$$identity'." >&2; \
|
||||
echo "Stop the local 'make api' process before starting the production tunnel." >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
fi
|
||||
|
||||
dashboard: check-primary
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
## Deliberate local-development dashboard; bypasses the production identity gate.
|
||||
dashboard-local:
|
||||
@echo "WARNING: serving the dashboard against the local/fallback API on :8000."
|
||||
@fuser -k 3000/tcp 2>/dev/null && echo "Stopped running dashboard" || true
|
||||
$(MAKE) dashboard-install
|
||||
cd dashboard && npm run dev
|
||||
|
||||
## Fast work-record path. Repo Manager assigns only missing deterministic IDs,
|
||||
## verifies the primary/railliance01 bridge, and asks central to derive the
|
||||
## exact pushed Forgejo commit in one transactional request.
|
||||
sync:
|
||||
$(RMGR) sync --path "$(SYNC_PATH)" --api-base "$(STATE_HUB_API_BASE)" $(if $(filter 1 true yes,$(SYNC_PUSH)),--push,)
|
||||
|
||||
check: check-primary
|
||||
@echo "State Hub primary/railiance01 is healthy."
|
||||
|
||||
check-local:
|
||||
curl -sf http://127.0.0.1:8000/state/health | python3 -m json.tool
|
||||
|
||||
# CUST-WP-0067-T09. The chart ships a copy of the-custodian canon allowed-values
|
||||
# because a container has no checkout to read it from. Regenerate after any
|
||||
# canon vocabulary change; the -check target fails on drift so a release cannot
|
||||
# validate classification against a stale vocabulary.
|
||||
sync-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py
|
||||
|
||||
check-classification-allowed:
|
||||
python3 scripts/sync_classification_allowed.py --check
|
||||
|
||||
# STATE-WP-0091: read-only surge-headroom check. Does not start Helm.
|
||||
# Live: make railiance-state-hub-headroom
|
||||
# Fixtures: make railiance-state-hub-headroom HEADROOM_FIXTURE=tests/fixtures/release_headroom/rev63-105m.json
|
||||
HEADROOM_FIXTURE ?=
|
||||
railiance-state-hub-headroom:
|
||||
@if [ -n "$(HEADROOM_FIXTURE)" ]; then \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --fixture "$(HEADROOM_FIXTURE)"; \
|
||||
else \
|
||||
$(PYTHON) scripts/release_headroom_preflight.py --kubectl "$(KUBECTL)"; \
|
||||
fi
|
||||
|
||||
railiance-state-hub-render: check-classification-allowed
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG)
|
||||
|
||||
railiance-state-hub-client-dry-run:
|
||||
@set -e; \
|
||||
tmpdir="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"
|
||||
|
||||
railiance-state-hub-server-dry-run:
|
||||
@set -e; \
|
||||
tmpdir="$$(mktemp -d)"; \
|
||||
trap 'rm -rf "$$tmpdir"' EXIT; \
|
||||
$(HELM) template $(RAILIANCE_STATE_HUB_RELEASE) $(RAILIANCE_STATE_HUB_CHART) \
|
||||
--namespace $(RAILIANCE_STATE_HUB_NAMESPACE) \
|
||||
-f $(RAILIANCE_STATE_HUB_VALUES) \
|
||||
--set image.tag=$(RAILIANCE_STATE_HUB_IMAGE_TAG) > "$$tmpdir/state-hub.yaml"; \
|
||||
$(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-credentials.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-cluster.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_PLATFORM_DIR)/state-hub-db-networkpolicies.yaml; \
|
||||
$(KUBECTL) apply --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
if $(KUBECTL) get namespace $(RAILIANCE_STATE_HUB_NAMESPACE) >/dev/null 2>&1; then \
|
||||
$(KUBECTL) apply --server-side --dry-run=server -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=server -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||
else \
|
||||
echo "Namespace $(RAILIANCE_STATE_HUB_NAMESPACE) does not exist; validating namespaced app manifests with client dry-run."; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-namespace.yaml; \
|
||||
$(KUBECTL) apply --dry-run=client -f $(RAILIANCE_STATE_HUB_APP_MANIFESTS)/state-hub-env.secret.sops.yaml.template; \
|
||||
$(KUBECTL) apply --dry-run=client -n $(RAILIANCE_STATE_HUB_NAMESPACE) -f "$$tmpdir/state-hub.yaml"; \
|
||||
fi
|
||||
|
||||
test: test-python dashboard-check
|
||||
|
||||
test-python:
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian_test \
|
||||
$(UV) run pytest -x -q
|
||||
|
||||
## Benchmark /state/summary revision cache (API must be running on :8000)
|
||||
benchmark-summary-cache:
|
||||
$(UV) run python scripts/benchmark_summary_cache.py
|
||||
|
||||
## ops-bridge managed tunnels
|
||||
## Requires ops-bridge: bridge is at /home/worsch/.local/bin/bridge
|
||||
tunnels-up: primary-port-clear
|
||||
bridge up
|
||||
|
||||
tunnels-status:
|
||||
bridge status
|
||||
|
||||
## End-to-end check: verifies SSH process alive + remote port listening on COULOMBCORE.
|
||||
## Exits non-zero if any tunnel is not fully operational.
|
||||
tunnels-check:
|
||||
bridge check
|
||||
|
||||
## Ensure all ops-bridge tunnels are up and healthy.
|
||||
## Brings up any stopped/stale tunnels, shows final status, exits non-zero if anything is still down.
|
||||
bridges: primary-port-clear
|
||||
@echo "==> Bringing up all tunnels..."
|
||||
bridge up
|
||||
@echo ""
|
||||
@echo "==> Tunnel status:"
|
||||
bridge status
|
||||
@echo ""
|
||||
@echo "==> Checking tunnel health..."
|
||||
bridge check
|
||||
|
||||
## Start (or restart) the LOCAL FALLBACK backend — db + migrate + uvicorn.
|
||||
## This replaces anything on :8000, including the production State Hub tunnel.
|
||||
api: db
|
||||
@echo "WARNING: starting the local fallback API; this is not the railiance01 primary."
|
||||
@echo "Waiting for postgres..."; \
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do \
|
||||
nc -z 127.0.0.1 5432 2>/dev/null && break; \
|
||||
sleep 1; \
|
||||
done
|
||||
$(MAKE) migrate
|
||||
@fuser -k 8000/tcp 2>/dev/null && echo "Stopped running API" || true
|
||||
$(UV) run uvicorn api.main:app --reload --reload-dir api --reload-dir mcp_server --reload-dir task_flow_engine --host 127.0.0.1 --port 8000
|
||||
|
||||
## Register a project (Claude Code): make register-project DOMAIN=railiance PROJECT_PATH=/home/worsch/railiance
|
||||
register-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)"
|
||||
|
||||
## Register a Codex project (AGENTS.md + HTTP API): make register-codex-project DOMAIN=capabilities PROJECT_PATH=/home/worsch/my-repo
|
||||
register-codex-project:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required. Usage: make register-codex-project DOMAIN=<domain> PROJECT_PATH=<path>"; exit 1)
|
||||
@test -n "$(PROJECT_PATH)" || (echo "ERROR: PROJECT_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(PROJECT_PATH)" --codex
|
||||
|
||||
## Register State Hub MCP for Claude Code. Optional: make register-mcp MCP_URL=http://127.0.0.1:18001/sse
|
||||
register-mcp:
|
||||
scripts/register-mcp.sh \
|
||||
$(if $(MCP_URL),--url "$(MCP_URL)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Configure Codex workspace-write networking for local State Hub access.
|
||||
configure-codex:
|
||||
scripts/configure-codex.sh \
|
||||
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(WITH_MCP),--with-mcp,) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing"
|
||||
bootstrap-env:
|
||||
scripts/bootstrap-env.sh $(ARGS)
|
||||
|
||||
## Files-first local dev hub (CUST-WP-0054-T07): postgres + migrate + seed + API + repo registration.
|
||||
## Optional: WITH_EDGE=1 WITH_MCP=1 make dev-hub
|
||||
## Fleet tunnel when reachable: make dev-hub PROFILE=fleet
|
||||
dev-hub:
|
||||
bash scripts/dev_hub_up.sh $(if $(PROFILE),$(PROFILE),dev)
|
||||
|
||||
## Local edge relay for offline write buffering (STATE-WP-0068).
|
||||
edge-relay:
|
||||
@fuser -k 18080/tcp 2>/dev/null && echo "Stopped running edge relay" || true
|
||||
STATEHUB_UPSTREAM_URL=$${STATEHUB_UPSTREAM_URL:-http://127.0.0.1:8000} \
|
||||
$(UV) run uvicorn api.edge.relay:app --host 127.0.0.1 --port 18080
|
||||
|
||||
## Switch MCP/API profile: make mcp-profile PROFILE=dev|fleet|status
|
||||
mcp-profile:
|
||||
@test -n "$(PROFILE)" || (echo "ERROR: PROFILE is required (dev|fleet|status)."; exit 1)
|
||||
bash scripts/mcp_hub_profile.sh "$(PROFILE)"
|
||||
|
||||
## Add a second repo to an existing domain: make add-repo DOMAIN=railiance REPO_PATH=/home/worsch/railiance-infra
|
||||
add-repo:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
@test -n "$(REPO_PATH)" || (echo "ERROR: REPO_PATH is required."; exit 1)
|
||||
scripts/register_project.sh "$(DOMAIN)" "$(REPO_PATH)" --additional
|
||||
|
||||
## Create a new domain: make add-domain DOMAIN=my_domain NAME="My Domain"
|
||||
add-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required (slug)."; exit 1)
|
||||
@test -n "$(NAME)" || (echo "ERROR: NAME is required (display name)."; exit 1)
|
||||
curl -sf -X POST http://127.0.0.1:8000/domains/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"slug\": \"$(DOMAIN)\", \"name\": \"$(NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Rename a domain: make rename-domain DOMAIN=old_slug NEW_SLUG=new_slug NEW_NAME="New Name"
|
||||
rename-domain:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN (old slug) is required."; exit 1)
|
||||
@test -n "$(NEW_SLUG)" || (echo "ERROR: NEW_SLUG is required."; exit 1)
|
||||
@test -n "$(NEW_NAME)" || (echo "ERROR: NEW_NAME is required."; exit 1)
|
||||
curl -sf -X PATCH http://127.0.0.1:8000/domains/$(DOMAIN)/rename \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"new_slug\": \"$(NEW_SLUG)\", \"new_name\": \"$(NEW_NAME)\"}" | python3 -m json.tool
|
||||
|
||||
## Register this machine's local path for a repo: make register-path REPO=marki-docx PATH=/home/tegwick/marki-docx
|
||||
register-path:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
@test -n "$(PATH)" || (echo "ERROR: PATH is required. Usage: make register-path REPO=<slug> PATH=<path>"; exit 1)
|
||||
curl -sf -X POST "http://127.0.0.1:8000/repos/$(REPO)/paths" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"host\": \"$$(hostname)\", \"path\": \"$(PATH)\"}" | python3 -m json.tool
|
||||
|
||||
## List repos for a domain: make list-repos DOMAIN=railiance
|
||||
list-repos:
|
||||
@test -n "$(DOMAIN)" || (echo "ERROR: DOMAIN is required."; exit 1)
|
||||
curl -sf "http://127.0.0.1:8000/repos/?domain=$(DOMAIN)" | python3 -m json.tool
|
||||
|
||||
## Ingest SBOM data for a repo (all mechanisms: lockfiles + ansible + sbom-tools.yaml).
|
||||
## Auto-detect all sources: make ingest-sbom REPO=the-custodian REPO_PATH=/home/worsch/the-custodian
|
||||
## Single lockfile (explicit): make ingest-sbom REPO=the-custodian LOCKFILE=/path/to/uv.lock
|
||||
## Dry-run (no submit): make ingest-sbom REPO=the-custodian REPO_PATH=... DRY_RUN=1
|
||||
## Tip: run capture-tools first for repos with system-level tool dependencies.
|
||||
ingest-sbom:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_sbom.py --repo "$(REPO)" \
|
||||
$(if $(LOCKFILE),--lockfile "$(LOCKFILE)") \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Ingest capability declarations from SCOPE.md into the catalog.
|
||||
## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian]
|
||||
## Or: make ingest-capabilities-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-capabilities:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_capabilities.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-capabilities-all:
|
||||
$(UV) run python scripts/ingest_capabilities.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check Repository Definition of Integrated (DoI) criteria for a repo.
|
||||
## Usage: make check-doi REPO=llm-connect
|
||||
## Or: make check-doi-all
|
||||
## Add JSON=1 for machine-readable output.
|
||||
check-doi:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/check_doi.py --repo "$(REPO)" $(if $(JSON),--json)
|
||||
|
||||
check-doi-all:
|
||||
$(UV) run python scripts/check_doi.py --all $(if $(JSON),--json)
|
||||
|
||||
## Ingest tpsc.yaml service declarations from a repo into the TPSC catalog.
|
||||
## Usage: make ingest-tpsc REPO=llm-connect
|
||||
## Or: make ingest-tpsc-all
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
ingest-tpsc:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/ingest_tpsc.py --repo "$(REPO)" \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
ingest-tpsc-all:
|
||||
$(UV) run python scripts/ingest_tpsc.py --all \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml.
|
||||
## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra]
|
||||
## Add DRY_RUN=1 to preview without writing.
|
||||
capture-tools:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1)
|
||||
$(UV) run python scripts/capture_sbom_tools.py --repo "$(REPO)" \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \
|
||||
$(if $(DRY_RUN),--dry-run)
|
||||
|
||||
## Check a repo for ADR-001 compliance: make validate-adr REPO=/path/to/repo [DOMAIN=custodian]
|
||||
validate-adr:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make validate-adr REPO=<path> [DOMAIN=<slug>]"; exit 1)
|
||||
$(UV) run python scripts/validate_repo_adr.py "$(REPO)" $(if $(DOMAIN),--domain "$(DOMAIN)",)
|
||||
|
||||
## Consistency exit contract:
|
||||
## - Direct scripts/consistency_check.py: 0 clean, 2 warnings-only, 1 failures.
|
||||
## - Agent/operator Make wrappers below normalize warning-only 2 to shell success
|
||||
## while preserving visible WARN output and keeping real failures non-zero.
|
||||
## Check a single repo for ADR-001 consistency: make check-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
check-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make check-consistency REPO=<slug>"; exit 1)
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix a single repo: make fix-consistency REPO=the-custodian [REPO_PATH=/override]
|
||||
## Exit 0 = clean or warnings-only (warnings stay visible), exit 1 = failures
|
||||
fix-consistency:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make fix-consistency REPO=<slug>"; exit 1)
|
||||
$(UV) run python scripts/consistency_check.py --repo "$(REPO)" --fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(REPO_PATH),--repo-path "$(REPO_PATH)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Normalize workplan frontmatter and task status literals in attached repos.
|
||||
## Usage: make normalize-attached-workplans REPO=artifact-store
|
||||
## make normalize-attached-workplans DIRTY=1
|
||||
normalize-attached-workplans:
|
||||
$(UV) run python scripts/normalize_attached_repo_workplans.py \
|
||||
$(if $(REPO),--repo "$(REPO)",) \
|
||||
$(if $(DIRTY),--dirty,) \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||
|
||||
## Regenerate AGENTS.md / CLAUDE.md / .claude/rules from templates.
|
||||
## Usage: make update-agent-instructions REPO=artifact-store
|
||||
## make update-agent-instructions DIRTY=1
|
||||
update-agent-instructions:
|
||||
$(UV) run python scripts/update_agent_instruction_files.py \
|
||||
$(if $(REPO),--repo "$(REPO)",) \
|
||||
$(if $(DIRTY),--dirty,)
|
||||
@test -n "$(REPO)$(DIRTY)" || (echo "ERROR: set REPO=<slug> or DIRTY=1"; exit 1)
|
||||
|
||||
## Reconcile measured token sources against State Hub.
|
||||
## Usage: make token-reconcile [SINCE=2026-05-19] [APPLY=1] [ZERO_FALLBACKS=1]
|
||||
token-reconcile:
|
||||
$(PYTHON) scripts/token_reconcile.py \
|
||||
$(if $(SINCE),--since "$(SINCE)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
|
||||
$(if $(CLAUDE_HOME),--claude-home "$(CLAUDE_HOME)",) \
|
||||
$(if $(APPLY),--apply,) \
|
||||
$(if $(ZERO_FALLBACKS),--zero-superseded-fallbacks,)
|
||||
|
||||
## Pull then fix: single repo or all repos if REPO omitted
|
||||
## make fix-consistency-remote — smart pull+fix all repos that need it
|
||||
## make fix-consistency-remote REPO=slug — pull+fix one repo
|
||||
fix-consistency-remote:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
$(if $(REPO),--repo "$(REPO)",--all) \
|
||||
--remote \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",) \
|
||||
$(if $(NO_WRITEBACK),--no-writeback,); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and check: make check-consistency-here [REPO_PATH=/path/to/repo]
|
||||
## Omit REPO_PATH to use the Python script's CWD (i.e. pass an empty --here flag).
|
||||
check-consistency-here:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Infer repo slug from git remote URL and fix: make fix-consistency-here [REPO_PATH=/path/to/repo]
|
||||
fix-consistency-here:
|
||||
$(UV) run python scripts/consistency_check.py \
|
||||
--here $(if $(REPO_PATH),"$(REPO_PATH)",) \
|
||||
--fix \
|
||||
$(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check all registered repos for ADR-001 consistency
|
||||
check-consistency-all:
|
||||
$(UV) run python scripts/consistency_check.py --all $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Check and auto-fix all registered repos
|
||||
fix-consistency-all:
|
||||
$(UV) run python scripts/consistency_check.py --all --fix $(if $(API_BASE),--api-base "$(API_BASE)",); \
|
||||
e=$$?; [ $$e -eq 2 ] && exit 0 || exit $$e
|
||||
|
||||
## Cancel open tasks belonging to completed/archived workstreams.
|
||||
## Safe to run at any time; also suitable for a daily cron job.
|
||||
## Cron example: 0 3 * * * cd ~/state-hub && make cleanup-stale
|
||||
cleanup-stale:
|
||||
$(UV) run python scripts/cleanup_stale_tasks.py
|
||||
|
||||
## Install custodian post-commit sync hook into one repo: make install-hooks REPO=marki-docx
|
||||
install-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make install-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)"
|
||||
|
||||
## Install custodian post-commit sync hook into all active registered repos
|
||||
install-hooks-all:
|
||||
bash scripts/install_hooks.sh --all
|
||||
|
||||
## Remove custodian post-commit sync hook from one repo: make remove-hooks REPO=marki-docx
|
||||
remove-hooks:
|
||||
@test -n "$(REPO)" || (echo "ERROR: REPO is required. Usage: make remove-hooks REPO=<slug>"; exit 1)
|
||||
bash scripts/install_hooks.sh --repo "$(REPO)" --remove
|
||||
|
||||
## Compare Gitea coulomb org repos against state-hub registered repos
|
||||
## Requires GITEA_TOKEN in env or .env: make gitea-inventory GITEA_TOKEN=<token>
|
||||
gitea-inventory:
|
||||
$(UV) run python scripts/gitea_inventory.py $(if $(JSON),--json)
|
||||
|
||||
## Register/update one repo from .repo-classification.yaml:
|
||||
## make register-from-classification REPO=state-hub
|
||||
## make register-from-classification PATH=/path/to/repo
|
||||
## Optional: DRY_RUN=1
|
||||
register-from-classification:
|
||||
@test -n "$(REPO)" -o -n "$(PATH)" || (echo "ERROR: REPO or PATH is required."; exit 1)
|
||||
$(UV) run python scripts/register_from_classification.py \
|
||||
$(if $(PATH),--repo-path "$(PATH)",--slug "$(REPO)") \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
## Bulk register/update all active repos with accessible local paths
|
||||
register-from-classification-all:
|
||||
$(UV) run python scripts/register_from_classification.py --bulk \
|
||||
$(if $(DRY_RUN),--dry-run,)
|
||||
|
||||
clean:
|
||||
$(COMPOSE) down -v
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"""Pure Make command fixture: run with --noconftest, no database or cluster."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
@pytest.mark.parametrize('namespace_exists',[True,False])
|
||||
def test_secret_server_dryrun_uses_ssa_without_force_or_other_phase_changes(tmp_path,namespace_exists):
|
||||
makefile=Path(__file__).resolve().parents[1]/'Makefile'
|
||||
log=tmp_path/'commands.jsonl';helm=tmp_path/'helm';kubectl=tmp_path/'kubectl'
|
||||
helm.write_text('#!'+sys.executable+'\nprint("apiVersion: v1\\nkind: ConfigMap\\nmetadata: {name: fixture}")\n')
|
||||
kubectl.write_text('#!'+sys.executable+'\nimport json,os,sys\nwith open(os.environ["NK_DRYRUN_LOG"],"a") as log:log.write(json.dumps(sys.argv[1:])+"\\n")\nif sys.argv[1:3]==["get","namespace"] and os.environ["NK_NAMESPACE_EXISTS"]=="0":raise SystemExit(1)\n')
|
||||
helm.chmod(0o700);kubectl.chmod(0o700)
|
||||
env=dict(os.environ);env['NK_DRYRUN_LOG']=str(log);env['NK_NAMESPACE_EXISTS']='1' if namespace_exists else '0'
|
||||
p=subprocess.run(['make','--no-print-directory','-f',str(makefile),'railiance-state-hub-server-dry-run','HELM='+str(helm),'KUBECTL='+str(kubectl),'RAILIANCE_STATE_HUB_IMAGE_TAG=main-51bc70d'],cwd=tmp_path,env=env,capture_output=True,timeout=10)
|
||||
assert p.returncode==0
|
||||
calls=[json.loads(line) for line in log.read_text().splitlines()]
|
||||
applies=[args for args in calls if args[0]=='apply']
|
||||
secrets=[args for args in applies if any(p.endswith('.sops.yaml.template') for p in args)]
|
||||
assert len(secrets)==2
|
||||
assert all('--force-conflicts' not in args and '--force' not in args for args in applies)
|
||||
for args in secrets:
|
||||
if '--dry-run=server' in args:assert '--server-side' in args
|
||||
else:assert not namespace_exists and '--dry-run=client' in args and '--server-side' not in args
|
||||
others=[args for args in applies if args not in secrets]
|
||||
assert all('--server-side' not in args for args in others)
|
||||
assert all('--dry-run=server' in args or '--dry-run=client' in args for args in applies)
|
||||
assert not any(args[0] in {'create','replace','delete','patch'} for args in calls)
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
---
|
||||
id: STATE-WP-0095
|
||||
type: workplan
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Normal Forge receiving must preserve the seven bound Activity legacy source
|
||||
views and the Custodian retired ADHOC source without new identities, source
|
||||
rewrites, task movement, deletion, relabeling or unretirement. This implements
|
||||
receiving support before RMGR-WP-0019 registrar admission. CUST-WP-0077 owns
|
||||
cross-owner coordination; deployment and replay remain explicit owner returns.
|
||||
|
||||
## Verify source bindings and shared views
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T01
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6a5f87bb-e63d-5e20-b9fc-c1b5cd403ff7"
|
||||
```
|
||||
|
||||
Reviewed seven exact Activity files: four existing workplans and 64 UUID-bound
|
||||
closed tasks. The archived custodian-WP-0003 parent and three split_from views
|
||||
share one existing parent UUID; every repeated task binding/content agrees.
|
||||
Custodian ADHOC-2026-08-25-retired retains parent
|
||||
1c53d3db-6633-5e2d-987f-10706d73be9e and task
|
||||
d8af3e6b-26ae-580c-bbeb-caa8ad419bec. No identity migration is authorized.
|
||||
|
||||
## Implement preservation receiving and global collision refusal
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T02
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ebb24194-e4cc-5130-aaa6-663f2ea2c822"
|
||||
```
|
||||
|
||||
Prepared exact source digest manifest, existing-parent/task verification,
|
||||
immutable receiving and UUID-based historical diff. Ordinary canonical sources
|
||||
continue deriving their identities. Missing/changed/symlinked source, absent
|
||||
bound rows or nonterminal hub histories refuse. Check task UUID holders globally,
|
||||
including retired and acknowledged-stale parent workplans, before INSERT.
|
||||
|
||||
Offline preview tests and snapshot proof are evidence of source behavior only;
|
||||
finish after applying reviewed source and its focused tests to the owning repo.
|
||||
|
||||
## Deliver receiving through normal deployment and verify owner return
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T03
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T02]
|
||||
state_hub_task_id: "e8566341-cf6e-5b9c-ad80-8564d14cd52a"
|
||||
decision_id: "36772026-f43d-4331-b466-5f4b1afe4eca"
|
||||
```
|
||||
|
||||
No direct live code patch. This task is a deployment return, not policy authority
|
||||
or permission to resolve identity decisions. Exact receiving fixture acceptance
|
||||
and global collision rejection must hold in the delivered version.
|
||||
|
||||
## Verify normal historical replay preserves existing identities
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T04
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T03, RMGR-WP-0019-T03]
|
||||
state_hub_task_id: "3bbb311f-194e-5862-8aed-358a580f22b4"
|
||||
```
|
||||
|
||||
Verify Activity four historical parent UUIDs/64 task UUIDs and Custodian retired
|
||||
parent/task unchanged; no historical row creation, movement, deletion,
|
||||
relabeling or unretirement. Unrelated stale projections remain separate decisions.
|
||||
|
||||
2026-10-05: Source exact-set retirement support now exists. Repository projection
|
||||
reconcile accepts `expected_retirement_uuids`; that set must equal the stale
|
||||
projections, and any task or dependency edge on a stale row refuses the call.
|
||||
Live apply still needs a reviewed rollout. On 2026-10-05 the founder accepted
|
||||
review `review-1ece4f3e-d0e3-4fd6-abc8-272f1b1876f4`, which names only the three
|
||||
empty projections and preserves parent `b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`.
|
||||
That acceptance is not a live retirement and does not resolve
|
||||
`91b7c633-a66f-5d43-9f4b-6565aefb9356`.
|
||||
|
||||
## Offline evidence, 2026-10-04
|
||||
|
||||
Prepared receiver suite: Forge projection regression plus historical preservation,
|
||||
missing/changed source, terminality, immutable UUID diff, and global holder query.
|
||||
Real-source offline snapshot proof preserves four Activity parents/64 tasks and
|
||||
one Custodian parent/task without mutations; no API writes or deployment occurred.
|
||||
Exact original bindings are maintained in api/services/historical_projection.py.
|
||||
|
||||
2026-10-04 actual owning-source validation: 155 focused receiver/qualifier/planner
|
||||
cases passed; the independent offline current-source/current-hub-snapshot proof
|
||||
retained five existing parents and 65 task UUIDs, no mutations, clean historical
|
||||
diff. T02 is source-complete; T03/T04 remain wait for reviewed deployment and
|
||||
normal registrar/replay receiving, respectively. Evidence: docs/evidence/2026-10-04-historical-receiving-source.json.
|
||||
|
||||
|
||||
## Verified receiving rollout and remaining replay gate, 2026-10-04
|
||||
|
||||
T03 is done on actual founder approval of proposal
|
||||
STATE-WP-0095-T03-historical-receiving revision 1, SHA256
|
||||
`d6789359b3fe7988a6201fff6f367cb46948aa197e1cc0ca66c31f45e1ea9981`,
|
||||
approval receipt reference `user_reply:call_o87uEfFs3AtXfxO5YqUnSpcg:0`.
|
||||
The normal owner Helm path delivered `main-5dd3df9`, commit
|
||||
`5dd3df9c69eb564af10ea5ea01e349786212501a`, revision 72; API and MCP are Ready,
|
||||
primary/railiance01 health and schema a0b1c2d3e4f5 verified, running source
|
||||
hashes match. Rollback remains revision 71; no receipt-table downgrade is needed.
|
||||
Actual historical preservation and global task-holder refusal were verified in
|
||||
the delivered version. Durable receipt:
|
||||
[verified historical receiving rollout](../docs/evidence/2026-10-04-historical-receiving-rollout.json).
|
||||
|
||||
Custodian immutable replay applied while preserving the retired historical
|
||||
parent/task UUIDs. Activity immutable replay retained all four historical parent
|
||||
and 64 task UUIDs and historical row contents, but refused transactionally on
|
||||
three unrelated stale projections: custodian-wp-0003 (archived),
|
||||
custodian-wp-0003a (done), custodian-wp-0003b (done). No retirement acknowledgement
|
||||
was issued and that refusal is not represented as normal replay success.
|
||||
|
||||
Registrar source `c41d36c2` has 38 actual passing tests and Repo Manager's own
|
||||
normal sync succeeded; registrar admission does not authorize retiring Activity
|
||||
projections. T04 therefore remains wait, retaining RMGR-WP-0019-T03 and awaiting
|
||||
Activity Core's precise reviewed disposition of those three stale rows. No
|
||||
rename, identity migration, new paid native trial or further live action is
|
||||
included in this source status update.
|
||||
|
||||
## Activity Core return, 2026-10-06
|
||||
|
||||
Activity Core returned the exact disposition (message
|
||||
`cf2d5847-8b3f-4653-9d9f-d5fcfbe3f1e4`, ACTIVITY-WP-0045, activity-core commit
|
||||
`941fac0`). `expected_retirement_uuids`:
|
||||
`683724d9-4249-5e0e-88e0-7baff4330d58` (custodian-wp-0003),
|
||||
`644017c7-c5f2-5a64-81c3-d8ee806f8251` (custodian-wp-0003a),
|
||||
`a5806df9-5b9a-50da-81c1-7e606076bed9` (custodian-wp-0003b). Each has zero
|
||||
tasks and zero dependency edges. None is preserved parent
|
||||
`b4eb45a9-69e3-4ab0-b00c-67a53c3117c5`. Repo Manager independently verified the
|
||||
set (message `916841f6`, repo-manager commit `498682c`). Nothing was retired.
|
||||
T04 now waits on decision `91b7c633` and a reviewed live rollout. After that,
|
||||
Repo Manager re-runs the Activity owner sync and closes RMGR-WP-0019-T03.
|
||||
|
||||
## Retirement rollout and T04 verification, 2026-10-06
|
||||
|
||||
The founder resolved decision `91b7c633` (retire exactly the three projections)
|
||||
and approved the rollout. The founder ran the normal Helm upgrade to
|
||||
`main-968a811` (revision 74; rollback revision 73, `main-fad9b3f`). API and MCP
|
||||
are Ready, health is ok on primary/railiance01, and schema `b3c4d5e6f7a8` is
|
||||
unchanged; no migrations. The activity-core reconcile at commit
|
||||
`67a92d2ff8e033baf2c36998d24187fe83edec84` with `expected_retirement_uuids` set
|
||||
to the three UUIDs applied: 0 created, 3 retired (tombstoned with an
|
||||
`@retired-20261006` slug; UUIDs and statuses kept), 0 refused, 0 tasks
|
||||
cancelled. All 7 bound Activity source files still match their digests. The four
|
||||
historical parents and 64 task UUIDs are present under their bound parents with
|
||||
unchanged fields. The Custodian retired parent and task are unchanged. Activity
|
||||
had 54 workplans and 288 tasks before and after. Evidence:
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
|
||||
|
||||
## Cluster exact historical receiving successor, 2026-10-17
|
||||
|
||||
This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
registrar admission; never derive replacement identities or modify those
|
||||
sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
The local derived case is waiting; no live worker injection is authorized.
|
||||
|
||||
## Prepare exact cluster source preservation and refusal cases
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T05
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "339b9a37-181c-5599-9a89-0393e199bd4b"
|
||||
```
|
||||
|
||||
Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
expected ownership and terminal status. These are source and metadata checks,
|
||||
not delivered receiving or a committed native trial.
|
||||
|
||||
## Deliver reviewed cluster receiving successor before registrar admission
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T06
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T05]
|
||||
needs_human: true
|
||||
intervention_note: "Review exact committed source, CI image and owner deployment proposal before rollout."
|
||||
state_hub_task_id: "6af973b4-1964-5d4d-a2f8-8dbd4c01fda4"
|
||||
```
|
||||
|
||||
Use the normal CI image and Helm owner path after exact revision/image pin,
|
||||
fresh headroom and reviewed dry-run/diff. API/MCP currently run main-7d99282;
|
||||
Helm revision75 is the observed rollback baseline, requiring freshness before
|
||||
execution. No deployment assent exists for this successor. Verify delivered
|
||||
module hash, health, both readiness checks and expected migrated schema
|
||||
c4d5e6f7a8b9; verify exact
|
||||
three parents/15 tasks and rejection behavior without committed probe changes.
|
||||
Only the verified return unblocks RMGR-WP-0019-T05. The full current image also carries inbox-feed migration c4d5e6f7a8b9,
|
||||
although this receiver patch adds none. Helm rollback leaves that migration in
|
||||
place; unrelated migration acceptance and old-image compatibility need normal
|
||||
owner evidence before rollout approval. Do not run destructive default local
|
||||
DB tests; use only exact disposable DB containment. Costs and monetary risk are
|
||||
unknown pending exact rollout proposal; no zero-cost or autonomy grant is inferred.
|
||||
|
||||
## Verify ordinary cluster receiving and immutable historical rows
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T07
|
||||
status: wait
|
||||
priority: high
|
||||
depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06]
|
||||
state_hub_task_id: "cfd1ea99-e75b-542e-a434-acefb62f3c7d"
|
||||
```
|
||||
|
||||
Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
unretirement or retirement is authorized for these histories. A rollback first
|
||||
fences cluster sync and restores registrar refusal before removing receiving
|
||||
support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
original TLS recovery or owner-native admission.
|
||||
|
||||
## Fix policy-compatible Secret-template server dry-run preparation
|
||||
|
||||
```task
|
||||
id: STATE-WP-0095-T08
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2dd9b04f-2250-5a8a-b5bc-c8a009872858"
|
||||
```
|
||||
|
||||
Prepared the narrow Makefile successor after the existing readiness command
|
||||
refused its first Secret-template phase. Ordinary client-side apply adds the
|
||||
last-applied annotation, correctly denied by the existing Platform
|
||||
reject-secret-last-applied policy. Only the two Secret-template server
|
||||
dry-run requests now use server-side apply with dry-run; no force-conflicts,
|
||||
Secret value change, policy relaxation or actual Secret write is introduced.
|
||||
Command-fixture tests verify both requests and preserve other dry-run phases.
|
||||
Evidence and original refusal remain in the Custodian cluster historical
|
||||
publication packet, revisions/r2 and r3-readiness. This is completed source
|
||||
preparation; T06 remains the explicit native rollout gate. The runtime image
|
||||
continues to be main-51bc70d; Makefile/workplan changes do not create a new
|
||||
application image or native acceptance.
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
{
|
||||
"proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1",
|
||||
"disposition": "pending",
|
||||
"outcome": "pending",
|
||||
"proposal_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r4-rollout-review/proposal-metadata.json",
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "5bef9e7818ce1ebc64b81e300b6b40dcfc710a8625d6e0595534f8284dc2052d",
|
||||
"original_digest": "5bef9e7818ce1ebc64b81e300b6b40dcfc710a8625d6e0595534f8284dc2052d",
|
||||
"current_revision": 4,
|
||||
"current_digest_sha256": "a2bee1709f1fe213318c7adc1b28ad04d6755616d59867781b7d44ad5b8e6bcb",
|
||||
"target": "railiance01 state-hub release/API/MCP and exact-new-trial migration Job only",
|
||||
"expected_result": "Normal owner image promotion makes exact frozen cluster receiving available; API/MCP ready, expected c4 schema and exact three historical parents/15 tasks preserved; only then is later registrar admission eligible. No original TLS/identity native acceptance is inferred.",
|
||||
"verification_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness/ROLLOUT-SUPPLEMENT.md",
|
||||
"rollback_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r4-rollout-review/MIGRATION-JOB-RECOVERY.md",
|
||||
"cost_estimate_eur": null,
|
||||
"risk_estimate_eur": null,
|
||||
"cost_estimate": "Unknown attributable execution/network/operator recovery cost on existing infrastructure; no zero-cost claim or budget reservation inferred.",
|
||||
"risk_estimate": "Unpriced production coordination outage, message migration locks/data compatibility, retained active migration Job, uncertain connection/lock mapping and recovery time; three-minute Helm wait is not a bound on outage or DB effects. Explicit supervised risk disposition required.",
|
||||
"supervisor": "Bernd Worsch",
|
||||
"approval_ref": null,
|
||||
"approved_revision": null,
|
||||
"approved_digest_sha256": null,
|
||||
"executed_revision": null,
|
||||
"executed_digest_sha256": null,
|
||||
"refinement_or_rescue": true,
|
||||
"promotion_evidence": false,
|
||||
"reason": "Exact native rollout proposal remains pending. Source preparation, synthetic compatibility and public artifact verification are evidence only; earlier preparation failures and revisions remain one trial.",
|
||||
"revisions": [
|
||||
{
|
||||
"revision": 1,
|
||||
"proposal_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/REVIEW.md",
|
||||
"digest_sha256": "5bef9e7818ce1ebc64b81e300b6b40dcfc710a8625d6e0595534f8284dc2052d",
|
||||
"disposition": "not_submitted_or_unadjudicated",
|
||||
"executed": false
|
||||
},
|
||||
{
|
||||
"revision": 2,
|
||||
"proposal_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r2/REVIEW.md",
|
||||
"digest_sha256": "c8f13ed5d67d4743ea8e81bf720367022c820a060d7b225e84338eab2907493f",
|
||||
"disposition": "not_submitted_or_unadjudicated",
|
||||
"executed": false
|
||||
},
|
||||
{
|
||||
"revision": 3,
|
||||
"proposal_ref": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness/ROLLOUT-SUPPLEMENT.md",
|
||||
"digest_sha256": "df2962fd1588784a987e37932bd67f03f6248ea6384cf4bbb74ad1fe2e26be2b",
|
||||
"disposition": "not_submitted_or_unadjudicated",
|
||||
"executed": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"schema": "custodian.pending-supervision-addition.v1",
|
||||
"status": "candidate_only_not_applied",
|
||||
"target_record": ".kaizen/agents/custodian-codex/supervision.json",
|
||||
"expected_record_sha256": "407340493ffe584e9a245911d01f60e8f9ec1478f84478388f0210fd0c8b1a6d",
|
||||
"operation": "append_one_proposal_if_ID_absent_and_record_preimage_matches",
|
||||
"entry_ref": "pending-supervision-entry.json",
|
||||
"entry_sha256": "f6335be4151c9d2cf913e6260c20a7444778cd662e368c035d33e5dc2c66234c",
|
||||
"preserve_authority_fields": [
|
||||
"agent_id",
|
||||
"scope",
|
||||
"mode",
|
||||
"supervisor",
|
||||
"runtime_enforcement",
|
||||
"autopilot_grant",
|
||||
"cost_budget_eur",
|
||||
"risk_limit_eur"
|
||||
],
|
||||
"authority_changes": false,
|
||||
"approval_or_execution_inferred": false
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
{
|
||||
"schema": "custodian.supervised-rollout-proposal.v1",
|
||||
"proposal_id": "STATE-WP-0095-T06-cluster-frozen-receiving-r1",
|
||||
"revision": 4,
|
||||
"status": "prepared_pending_supervisor_review_not_authorized",
|
||||
"prior_revisions_retained": [
|
||||
1,
|
||||
2,
|
||||
3
|
||||
],
|
||||
"original_revision": 1,
|
||||
"original_digest_sha256": "5bef9e7818ce1ebc64b81e300b6b40dcfc710a8625d6e0595534f8284dc2052d",
|
||||
"target": {
|
||||
"host": "railiance01",
|
||||
"node_ip": "92.205.62.239",
|
||||
"namespace": "state-hub",
|
||||
"release": "state-hub",
|
||||
"deployments": [
|
||||
"state-hub",
|
||||
"state-hub-mcp"
|
||||
],
|
||||
"migration_job_name": "state-hub-migrate",
|
||||
"migration_job_uid": "must_capture_exact_new_trial_UID_after_approved_creation_before_any_conditional_cancellation"
|
||||
},
|
||||
"source_revision": "51bc70d1ff8f25450a8650d8e50caf053093a02e",
|
||||
"readiness_source_patch_sha256": "4c52fd5a102df43ceb8e007d93d34efe861a3b321527cf5f3e195e35113c36a0",
|
||||
"image": {
|
||||
"tag": "main-51bc70d",
|
||||
"digest": "sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015",
|
||||
"ci_task": 12624,
|
||||
"ci_status": "success",
|
||||
"selected_source_files": 233,
|
||||
"package_metadata_records": 86,
|
||||
"floating_build_origins": "unknown_observed_bytes_bound_to_manifest"
|
||||
},
|
||||
"expected_result": "Normal owner image promotion makes exact frozen cluster receiving available; API/MCP ready, expected c4 schema and exact three historical parents/15 tasks preserved; only then is later registrar admission eligible. No original TLS/identity native acceptance is inferred.",
|
||||
"verification_ref": "../r3-readiness/ROLLOUT-SUPPLEMENT.md; MIGRATION-JOB-RECOVERY.md",
|
||||
"rollback_ref": "MIGRATION-JOB-RECOVERY.md",
|
||||
"observed_rollback_baseline": {
|
||||
"helm_revision": 75,
|
||||
"image_tag": "main-7d99282",
|
||||
"freshness": "must_refresh_before_trial",
|
||||
"schema_downgrade": "forbidden_automatic",
|
||||
"migration_job_quiescence_required": true
|
||||
},
|
||||
"requested_conditional_recovery_scope": [
|
||||
"Exact-new-trial Job UID/image/ownership-bound foreground cancellation if still active after failed Helm, with UID precondition",
|
||||
"Observation of only that bound Job and its owned Pod UIDs and inability to recreate",
|
||||
"Private owner metadata-only verification of bound migration connections/transactions/locks quiescent",
|
||||
"Image-only owner rollback only after quiescence and actual-schema assessment; no automatic schema downgrade"
|
||||
],
|
||||
"explicit_exclusions": [
|
||||
"No deployment before exact supervisor disposition",
|
||||
"No Repo Manager admission before delivered receiver proof",
|
||||
"No Secret value read/write, native credential request or guard change",
|
||||
"No broad backend cancellation, namespace cleanup, finalizer removal or old-data restore",
|
||||
"No owner-native TLS/recovery/private-admission claim"
|
||||
],
|
||||
"cost_estimate_eur": null,
|
||||
"risk_estimate_eur": null,
|
||||
"cost_estimate": "Unknown attributable execution/network/operator recovery cost on existing infrastructure; no zero-cost claim or budget reservation inferred.",
|
||||
"risk_estimate": "Unpriced production coordination outage, message migration locks/data compatibility, retained active migration Job, uncertain connection/lock mapping and recovery time; three-minute Helm wait is not a bound on outage or DB effects. Explicit supervised risk disposition required.",
|
||||
"remaining_readiness": [
|
||||
"Canonical review/integration/publication of exact r3 Make/test/T08 source",
|
||||
"Exact reuse-values upgrade dry-run/diff with verified image identity",
|
||||
"Fresh release/image/schema/headroom and prior/new Job identity baseline",
|
||||
"Explicit supervisor disposition including conditional Job cancellation scope, recovery operator and unpriced risk"
|
||||
],
|
||||
"approval_ref": null,
|
||||
"approved_revision": null,
|
||||
"approved_digest_sha256": null,
|
||||
"executed_revision": null,
|
||||
"executed_digest_sha256": null,
|
||||
"production_execution_started": false,
|
||||
"refinement_or_rescue": true,
|
||||
"promotion_evidence": false,
|
||||
"artifacts": [
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/REVIEW.md",
|
||||
"sha256": "sha256:5bef9e7818ce1ebc64b81e300b6b40dcfc710a8625d6e0595534f8284dc2052d"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r2/REVIEW.md",
|
||||
"sha256": "sha256:c8f13ed5d67d4743ea8e81bf720367022c820a060d7b225e84338eab2907493f"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness/ROLLOUT-SUPPLEMENT.md",
|
||||
"sha256": "sha256:df2962fd1588784a987e37932bd67f03f6248ea6384cf4bbb74ad1fe2e26be2b"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness/IMAGE-RECEIPT-SUPPLEMENT.md",
|
||||
"sha256": "sha256:dbd5d08cfe9e19499634a7f3de756704697712caa041b05814fc211b90820fe6"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r3-readiness/source-patch-manifest.json",
|
||||
"sha256": "sha256:98211e65bebeee2c25ca75c6b9f0bc45128093baaba02069b6c41a15be32fe74"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-16/cluster-historical-publication-r1/revisions/r4-rollout-review/MIGRATION-JOB-RECOVERY.md",
|
||||
"sha256": "sha256:bfbd229bcb534c4f104b40090dba6c2dd0e2e040dce627e923143f81035880bc"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/statehub-public-image-inspection.json",
|
||||
"sha256": "sha256:5d182e5b0a3e9462024c0b9a123d631215f1b2d0568f18060b10a310a8251741"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/statehub-anonymous-registry-metadata.json",
|
||||
"sha256": "sha256:30f80aeb153bfe33076e2e5b94ae127e97ed38c0e1d033dabfd94934cef8be9a"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/migration-compat-r1-result.json",
|
||||
"sha256": "sha256:c290afa92777c4635a5ee06c1bf6c3f28b1a265b6bff6a91f5aee45f799c2923"
|
||||
},
|
||||
{
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/migration-compat-r1-cleanup.json",
|
||||
"sha256": "sha256:1d7e0830460c0e2c537027f94242b01661702aec1b2f93aa9041af6eb14cde21"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"status": "initial_source_recovered_from_exact_recorded_two_edit_inverse",
|
||||
"source_reconstruction": "Reversed only the explicit public-coordinate constant and exact-coordinate condition added after initial literal_sensitive_env_refused receipt. No independently retained pre-change digest existed.",
|
||||
"safe_failure_receipt": "../../exact-reuse-values-dryrun-result.json",
|
||||
"sha256": "da487e2a302143486588279984fc19bd617f8f73fe718e7bdb116b2931ac1e09"
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only exact owner Helm reuse-values render/diff; no raw output retained."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
OWNER=Path('/home/worsch/state-hub')
|
||||
KUBE='/home/worsch/.kube/config-railiance01'
|
||||
CHART='deploy/railiance/apps/charts/state-hub'
|
||||
TAG='main-51bc70d'
|
||||
HELM=['helm','--kubeconfig',KUBE,'-n','state-hub']
|
||||
DRYRUN=HELM+['upgrade','state-hub',CHART,'--reuse-values','--set-string','image.tag='+TAG,
|
||||
'--dry-run=server','--hide-secret','--no-hooks','--timeout','3m']
|
||||
ALLOWED={'ConfigMap','Deployment','Job','Service','ServiceAccount','Namespace','Ingress'}
|
||||
LIMIT=2*1024*1024
|
||||
class Refusal(RuntimeError):pass
|
||||
|
||||
def digest(obj):return 'sha256:'+hashlib.sha256(json.dumps(obj,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
|
||||
def parse(raw):
|
||||
if len(raw)>LIMIT:raise Refusal('render_size_refused')
|
||||
result=[]
|
||||
for obj in yaml.safe_load_all(raw):
|
||||
if not obj:continue
|
||||
if not isinstance(obj,dict) or obj.get('kind') not in ALLOWED:raise Refusal('unexpected_or_secret_resource_refused')
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
def sections(raw):
|
||||
text=raw.decode()
|
||||
if '\nMANIFEST:\n' not in text:raise Refusal('helm_render_sections_unrecognized')
|
||||
before,manifest=text.split('\nMANIFEST:\n',1)
|
||||
manifest=manifest.split('\nNOTES:',1)[0]
|
||||
hooks=before.split('\nHOOKS:\n',1)[1] if '\nHOOKS:\n' in before else ''
|
||||
return parse(hooks)+parse(manifest)
|
||||
|
||||
def identity(obj):
|
||||
meta=obj.get('metadata') or {}
|
||||
return (obj['apiVersion'],obj['kind'],meta.get('namespace') or ('' if obj['kind']=='Namespace' else 'state-hub'),meta['name'])
|
||||
|
||||
def diff_paths(a,b,prefix=''):
|
||||
if type(a)!=type(b):return [prefix]
|
||||
if isinstance(a,dict):
|
||||
paths=[]
|
||||
for key in sorted(set(a)|set(b)):
|
||||
path=prefix+'/'+key.replace('~','~0').replace('/','~1')
|
||||
paths.extend([path] if key not in a or key not in b else diff_paths(a[key],b[key],path))
|
||||
return paths
|
||||
if isinstance(a,list):
|
||||
if len(a)!=len(b):return [prefix]
|
||||
return [p for i,(x,y) in enumerate(zip(a,b)) for p in diff_paths(x,y,prefix+'/'+str(i))]
|
||||
return [] if a==b else [prefix]
|
||||
|
||||
def compare(old,new):
|
||||
baseline={identity(o):o for o in old};intended={identity(o):o for o in new}
|
||||
if len(baseline)!=len(old) or len(intended)!=len(new):raise Refusal('duplicate_object_identity')
|
||||
rows=[]
|
||||
for key in sorted(set(baseline)|set(intended)):
|
||||
a=baseline.get(key);b=intended.get(key);paths=diff_paths(a,b)
|
||||
images=[]
|
||||
if b and b['kind'] in {'Deployment','Job'}:
|
||||
spec=b.get('spec',{}).get('template',{}).get('spec',{})
|
||||
for container in (spec.get('containers',[])+spec.get('initContainers',[])):
|
||||
image=container.get('image','')
|
||||
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/state-hub:main-[0-9a-f]{7}(?:@sha256:[0-9a-f]{64})?',image):raise Refusal('unexpected_image_refused')
|
||||
images.append(image)
|
||||
for env in container.get('env',[]):
|
||||
if 'value' in env and re.search(r'PASSWORD|TOKEN|SECRET|DATABASE_URL',env.get('name',''),re.I):raise Refusal('literal_sensitive_env_refused')
|
||||
rows.append({'api_version':key[0],'kind':key[1],'namespace':key[2],'name':key[3],
|
||||
'intended_images':images,'intended_spec_sha256':digest(b.get('spec',{})) if b else None,
|
||||
'baseline_object_sha256':digest(a) if a else None,'intended_object_sha256':digest(b) if b else None,
|
||||
'changed_paths':paths})
|
||||
image_path=re.compile(r'^/spec/template/spec/(?:initContainers|containers)/[0-9]+/image$')
|
||||
only_images=all(image_path.fullmatch(p) for row in rows for p in row['changed_paths'])
|
||||
return {'objects':rows,'changed_object_count':sum(bool(r['changed_paths']) for r in rows),
|
||||
'intended_diff_only_images':only_images,'object_identity_set_unchanged':set(baseline)==set(intended)}
|
||||
|
||||
def command(args):
|
||||
env=dict(os.environ);env['KUBECONFIG']=KUBE
|
||||
p=subprocess.run(args,cwd=OWNER,env=env,capture_output=True,timeout=45)
|
||||
if len(p.stdout)>LIMIT:raise Refusal('command_output_size_refused')
|
||||
if p.returncode:
|
||||
lower=p.stderr.decode(errors='replace').lower()
|
||||
label=next((label for marker,label in [('reject-secret-last-applied','secret_annotation_policy_refused'),('forbidden','admission_or_auth_refused'),('cannot patch','patch_validation_refused'),('is invalid','resource_validation_refused'),('timeout','bounded_timeout'),('connection refused','api_unavailable')] if marker in lower),'unknown_refusal')
|
||||
raise Refusal(label)
|
||||
return p.stdout
|
||||
|
||||
def main():
|
||||
result={'schema':'statehub.exact-reuse-values-readonly-diff.v1','status':'failed',
|
||||
'dryrun_argv':DRYRUN,'production_mutations':False,'hooks_executed':False,
|
||||
'raw_helm_values_release_content_logs_retained':False,
|
||||
'limitations':['Dry-run admission/render only, not an actual image pull/migration/deployment.',
|
||||
'Helm timeout does not stop a later live migration Job; exact scoped cancellation/quiescence remains required.']}
|
||||
try:
|
||||
nodes=json.loads(command(['kubectl','--kubeconfig',KUBE,'get','nodes','-o','json']))
|
||||
if not any(a.get('address')=='92.205.62.239' for n in nodes.get('items',[]) for a in n.get('status',{}).get('addresses',[])):raise Refusal('target_identity_refused')
|
||||
result['stage']='baseline_release_manifest'
|
||||
old=parse(command(HELM+['get','manifest','state-hub']))
|
||||
result['stage']='baseline_release_hooks'
|
||||
old+=parse(command(HELM+['get','hooks','state-hub']))
|
||||
result['stage']='exact_upgrade_server_dryrun'
|
||||
new=sections(command(DRYRUN))
|
||||
result.update(compare(old,new));result['status']='passed_images_only' if result['intended_diff_only_images'] and result['object_identity_set_unchanged'] else 'review_nonimage_delta_required'
|
||||
except (Refusal,subprocess.TimeoutExpired,ValueError,yaml.YAMLError) as error:
|
||||
result['error_class']=type(error).__name__
|
||||
result['error_label']=str(error) if isinstance(error,Refusal) else 'bounded_parse_or_command_failure'
|
||||
print(json.dumps(result,indent=2))
|
||||
return int(result['status']!='passed_images_only')
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only exact owner Helm reuse-values render/diff; no raw output retained."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
OWNER=Path('/home/worsch/state-hub')
|
||||
KUBE='/home/worsch/.kube/config-railiance01'
|
||||
CHART='deploy/railiance/apps/charts/state-hub'
|
||||
TAG='main-51bc70d'
|
||||
HELM=['helm','--kubeconfig',KUBE,'-n','state-hub']
|
||||
DRYRUN=HELM+['upgrade','state-hub',CHART,'--reuse-values','--set-string','image.tag='+TAG,
|
||||
'--dry-run=server','--hide-secret','--no-hooks','--timeout','3m']
|
||||
PUBLIC_COORDINATES={'OPENBAO_K8S_TOKEN_PATH':'/var/run/secrets/openbao/token',
|
||||
'FORGE_READ_SECRET_PATH':'platform/data/workloads/state-hub/forge-derivation',
|
||||
'FORGE_READ_SECRET_KEY':'FORGE_READ_TOKEN'}
|
||||
ALLOWED={'ConfigMap','Deployment','Job','Service','ServiceAccount','Namespace','Ingress'}
|
||||
LIMIT=2*1024*1024
|
||||
class Refusal(RuntimeError):pass
|
||||
|
||||
def digest(obj):return 'sha256:'+hashlib.sha256(json.dumps(obj,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
|
||||
def parse(raw):
|
||||
if len(raw)>LIMIT:raise Refusal('render_size_refused')
|
||||
result=[]
|
||||
for obj in yaml.safe_load_all(raw):
|
||||
if not obj:continue
|
||||
if not isinstance(obj,dict) or obj.get('kind') not in ALLOWED:raise Refusal('unexpected_or_secret_resource_refused')
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
def sections(raw):
|
||||
text=raw.decode()
|
||||
if '\nMANIFEST:\n' not in text:raise Refusal('helm_render_sections_unrecognized')
|
||||
before,manifest=text.split('\nMANIFEST:\n',1)
|
||||
manifest=manifest.split('\nNOTES:',1)[0]
|
||||
hooks=before.split('\nHOOKS:\n',1)[1] if '\nHOOKS:\n' in before else ''
|
||||
return parse(hooks)+parse(manifest)
|
||||
|
||||
def identity(obj):
|
||||
meta=obj.get('metadata') or {}
|
||||
return (obj['apiVersion'],obj['kind'],meta.get('namespace') or ('' if obj['kind']=='Namespace' else 'state-hub'),meta['name'])
|
||||
|
||||
def diff_paths(a,b,prefix=''):
|
||||
if type(a)!=type(b):return [prefix]
|
||||
if isinstance(a,dict):
|
||||
paths=[]
|
||||
for key in sorted(set(a)|set(b)):
|
||||
path=prefix+'/'+key.replace('~','~0').replace('/','~1')
|
||||
paths.extend([path] if key not in a or key not in b else diff_paths(a[key],b[key],path))
|
||||
return paths
|
||||
if isinstance(a,list):
|
||||
if len(a)!=len(b):return [prefix]
|
||||
return [p for i,(x,y) in enumerate(zip(a,b)) for p in diff_paths(x,y,prefix+'/'+str(i))]
|
||||
return [] if a==b else [prefix]
|
||||
|
||||
def compare(old,new):
|
||||
baseline={identity(o):o for o in old};intended={identity(o):o for o in new}
|
||||
if len(baseline)!=len(old) or len(intended)!=len(new):raise Refusal('duplicate_object_identity')
|
||||
rows=[]
|
||||
for key in sorted(set(baseline)|set(intended)):
|
||||
a=baseline.get(key);b=intended.get(key);paths=diff_paths(a,b)
|
||||
images=[]
|
||||
if b and b['kind'] in {'Deployment','Job'}:
|
||||
spec=b.get('spec',{}).get('template',{}).get('spec',{})
|
||||
for container in (spec.get('containers',[])+spec.get('initContainers',[])):
|
||||
image=container.get('image','')
|
||||
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/state-hub:main-[0-9a-f]{7}(?:@sha256:[0-9a-f]{64})?',image):raise Refusal('unexpected_image_refused')
|
||||
images.append(image)
|
||||
for env in container.get('env',[]):
|
||||
if 'value' in env and re.search(r'PASSWORD|TOKEN|SECRET|DATABASE_URL',env.get('name',''),re.I):
|
||||
if env.get('name') not in PUBLIC_COORDINATES or env['value']!=PUBLIC_COORDINATES[env['name']]:raise Refusal('literal_sensitive_env_refused')
|
||||
rows.append({'api_version':key[0],'kind':key[1],'namespace':key[2],'name':key[3],
|
||||
'intended_images':images,'intended_spec_sha256':digest(b.get('spec',{})) if b else None,
|
||||
'baseline_object_sha256':digest(a) if a else None,'intended_object_sha256':digest(b) if b else None,
|
||||
'changed_paths':paths})
|
||||
image_path=re.compile(r'^/spec/template/spec/(?:initContainers|containers)/[0-9]+/image$')
|
||||
only_images=all(image_path.fullmatch(p) for row in rows for p in row['changed_paths'])
|
||||
return {'objects':rows,'changed_object_count':sum(bool(r['changed_paths']) for r in rows),
|
||||
'intended_diff_only_images':only_images,'object_identity_set_unchanged':set(baseline)==set(intended)}
|
||||
|
||||
def command(args):
|
||||
env=dict(os.environ);env['KUBECONFIG']=KUBE
|
||||
p=subprocess.run(args,cwd=OWNER,env=env,capture_output=True,timeout=45)
|
||||
if len(p.stdout)>LIMIT:raise Refusal('command_output_size_refused')
|
||||
if p.returncode:
|
||||
lower=p.stderr.decode(errors='replace').lower()
|
||||
label=next((label for marker,label in [('reject-secret-last-applied','secret_annotation_policy_refused'),('forbidden','admission_or_auth_refused'),('cannot patch','patch_validation_refused'),('is invalid','resource_validation_refused'),('timeout','bounded_timeout'),('connection refused','api_unavailable')] if marker in lower),'unknown_refusal')
|
||||
raise Refusal(label)
|
||||
return p.stdout
|
||||
|
||||
def main():
|
||||
result={'schema':'statehub.exact-reuse-values-readonly-diff.v1','status':'failed',
|
||||
'dryrun_argv':DRYRUN,'production_mutations':False,'hooks_executed':False,
|
||||
'raw_helm_values_release_content_logs_retained':False,
|
||||
'limitations':['Dry-run admission/render only, not an actual image pull/migration/deployment.',
|
||||
'Helm timeout does not stop a later live migration Job; exact scoped cancellation/quiescence remains required.']}
|
||||
try:
|
||||
nodes=json.loads(command(['kubectl','--kubeconfig',KUBE,'get','nodes','-o','json']))
|
||||
if not any(a.get('address')=='92.205.62.239' for n in nodes.get('items',[]) for a in n.get('status',{}).get('addresses',[])):raise Refusal('target_identity_refused')
|
||||
result['stage']='baseline_release_manifest'
|
||||
old=parse(command(HELM+['get','manifest','state-hub']))
|
||||
result['stage']='baseline_release_hooks'
|
||||
old+=parse(command(HELM+['get','hooks','state-hub']))
|
||||
result['stage']='exact_upgrade_server_dryrun'
|
||||
new=sections(command(DRYRUN))
|
||||
result.update(compare(old,new));result['status']='passed_images_only' if result['intended_diff_only_images'] and result['object_identity_set_unchanged'] else 'review_nonimage_delta_required'
|
||||
except (Refusal,subprocess.TimeoutExpired,ValueError,yaml.YAMLError) as error:
|
||||
result['error_class']=type(error).__name__
|
||||
result['error_label']=str(error) if isinstance(error,Refusal) else 'bounded_parse_or_command_failure'
|
||||
print(json.dumps(result,indent=2))
|
||||
return int(result['status']!='passed_images_only')
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Read-only exact owner Helm reuse-values render/diff; no raw output retained."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import yaml
|
||||
OWNER=Path('/home/worsch/state-hub')
|
||||
KUBE='/home/worsch/.kube/config-railiance01'
|
||||
CHART='deploy/railiance/apps/charts/state-hub'
|
||||
TAG='main-51bc70d@sha256:c802690ee1811b3d77df4077936f9cdced1385514e8673fcbc9232b84d645015'
|
||||
HELM=['helm','--kubeconfig',KUBE,'-n','state-hub']
|
||||
DRYRUN=HELM+['upgrade','state-hub',CHART,'--reuse-values','--set-string','image.tag='+TAG,
|
||||
'--dry-run=server','--hide-secret','--no-hooks','--timeout','3m']
|
||||
PUBLIC_COORDINATES={'OPENBAO_K8S_TOKEN_PATH':'/var/run/secrets/openbao/token',
|
||||
'FORGE_READ_SECRET_PATH':'platform/data/workloads/state-hub/forge-derivation',
|
||||
'FORGE_READ_SECRET_KEY':'FORGE_READ_TOKEN'}
|
||||
ALLOWED={'ConfigMap','Deployment','Job','Service','ServiceAccount','Namespace','Ingress'}
|
||||
LIMIT=2*1024*1024
|
||||
class Refusal(RuntimeError):pass
|
||||
|
||||
def digest(obj):return 'sha256:'+hashlib.sha256(json.dumps(obj,sort_keys=True,separators=(',',':')).encode()).hexdigest()
|
||||
|
||||
def parse(raw):
|
||||
if len(raw)>LIMIT:raise Refusal('render_size_refused')
|
||||
result=[]
|
||||
for obj in yaml.safe_load_all(raw):
|
||||
if not obj:continue
|
||||
if not isinstance(obj,dict) or obj.get('kind') not in ALLOWED:raise Refusal('unexpected_or_secret_resource_refused')
|
||||
result.append(obj)
|
||||
return result
|
||||
|
||||
def sections(raw):
|
||||
text=raw.decode()
|
||||
if '\nMANIFEST:\n' not in text:raise Refusal('helm_render_sections_unrecognized')
|
||||
before,manifest=text.split('\nMANIFEST:\n',1)
|
||||
manifest=manifest.split('\nNOTES:',1)[0]
|
||||
hooks=before.split('\nHOOKS:\n',1)[1] if '\nHOOKS:\n' in before else ''
|
||||
return parse(hooks)+parse(manifest)
|
||||
|
||||
def identity(obj):
|
||||
meta=obj.get('metadata') or {}
|
||||
return (obj['apiVersion'],obj['kind'],meta.get('namespace') or ('' if obj['kind']=='Namespace' else 'state-hub'),meta['name'])
|
||||
|
||||
def diff_paths(a,b,prefix=''):
|
||||
if type(a)!=type(b):return [prefix]
|
||||
if isinstance(a,dict):
|
||||
paths=[]
|
||||
for key in sorted(set(a)|set(b)):
|
||||
path=prefix+'/'+key.replace('~','~0').replace('/','~1')
|
||||
paths.extend([path] if key not in a or key not in b else diff_paths(a[key],b[key],path))
|
||||
return paths
|
||||
if isinstance(a,list):
|
||||
if len(a)!=len(b):return [prefix]
|
||||
return [p for i,(x,y) in enumerate(zip(a,b)) for p in diff_paths(x,y,prefix+'/'+str(i))]
|
||||
return [] if a==b else [prefix]
|
||||
|
||||
def compare(old,new):
|
||||
baseline={identity(o):o for o in old};intended={identity(o):o for o in new}
|
||||
if len(baseline)!=len(old) or len(intended)!=len(new):raise Refusal('duplicate_object_identity')
|
||||
rows=[]
|
||||
for key in sorted(set(baseline)|set(intended)):
|
||||
a=baseline.get(key);b=intended.get(key);paths=diff_paths(a,b)
|
||||
images=[]
|
||||
if b and b['kind'] in {'Deployment','Job'}:
|
||||
spec=b.get('spec',{}).get('template',{}).get('spec',{})
|
||||
for container in (spec.get('containers',[])+spec.get('initContainers',[])):
|
||||
image=container.get('image','')
|
||||
if not re.fullmatch(r'forgejo\.coulomb\.social/coulomb/state-hub:main-[0-9a-f]{7}(?:@sha256:[0-9a-f]{64})?',image):raise Refusal('unexpected_image_refused')
|
||||
images.append(image)
|
||||
for env in container.get('env',[]):
|
||||
if 'value' in env and re.search(r'PASSWORD|TOKEN|SECRET|DATABASE_URL',env.get('name',''),re.I):
|
||||
if env.get('name') not in PUBLIC_COORDINATES or env['value']!=PUBLIC_COORDINATES[env['name']]:raise Refusal('literal_sensitive_env_refused')
|
||||
rows.append({'api_version':key[0],'kind':key[1],'namespace':key[2],'name':key[3],
|
||||
'intended_images':images,'intended_spec_sha256':digest(b.get('spec',{})) if b else None,
|
||||
'baseline_object_sha256':digest(a) if a else None,'intended_object_sha256':digest(b) if b else None,
|
||||
'changed_paths':paths})
|
||||
image_path=re.compile(r'^/spec/template/spec/(?:initContainers|containers)/[0-9]+/image$')
|
||||
only_images=all(image_path.fullmatch(p) for row in rows for p in row['changed_paths'])
|
||||
return {'objects':rows,'changed_object_count':sum(bool(r['changed_paths']) for r in rows),
|
||||
'intended_diff_only_images':only_images,'object_identity_set_unchanged':set(baseline)==set(intended)}
|
||||
|
||||
def command(args):
|
||||
env=dict(os.environ);env['KUBECONFIG']=KUBE
|
||||
p=subprocess.run(args,cwd=OWNER,env=env,capture_output=True,timeout=45)
|
||||
if len(p.stdout)>LIMIT:raise Refusal('command_output_size_refused')
|
||||
if p.returncode:
|
||||
lower=p.stderr.decode(errors='replace').lower()
|
||||
label=next((label for marker,label in [('reject-secret-last-applied','secret_annotation_policy_refused'),('forbidden','admission_or_auth_refused'),('cannot patch','patch_validation_refused'),('is invalid','resource_validation_refused'),('timeout','bounded_timeout'),('connection refused','api_unavailable')] if marker in lower),'unknown_refusal')
|
||||
raise Refusal(label)
|
||||
return p.stdout
|
||||
|
||||
def main():
|
||||
result={'schema':'statehub.exact-reuse-values-readonly-diff.v1','status':'failed',
|
||||
'dryrun_argv':DRYRUN,'production_mutations':False,'hooks_executed':False,
|
||||
'raw_helm_values_release_content_logs_retained':False,
|
||||
'limitations':['Dry-run admission/render only, not an actual image pull/migration/deployment.',
|
||||
'Helm timeout does not stop a later live migration Job; exact scoped cancellation/quiescence remains required.']}
|
||||
try:
|
||||
nodes=json.loads(command(['kubectl','--kubeconfig',KUBE,'get','nodes','-o','json']))
|
||||
if not any(a.get('address')=='92.205.62.239' for n in nodes.get('items',[]) for a in n.get('status',{}).get('addresses',[])):raise Refusal('target_identity_refused')
|
||||
result['stage']='baseline_release_manifest'
|
||||
old=parse(command(HELM+['get','manifest','state-hub']))
|
||||
result['stage']='baseline_release_hooks'
|
||||
old+=parse(command(HELM+['get','hooks','state-hub']))
|
||||
result['stage']='exact_upgrade_server_dryrun'
|
||||
new=sections(command(DRYRUN))
|
||||
result.update(compare(old,new));result['status']='passed_images_only' if result['intended_diff_only_images'] and result['object_identity_set_unchanged'] else 'review_nonimage_delta_required'
|
||||
except (Refusal,subprocess.TimeoutExpired,ValueError,yaml.YAMLError) as error:
|
||||
result['error_class']=type(error).__name__
|
||||
result['error_label']=str(error) if isinstance(error,Refusal) else 'bounded_parse_or_command_failure'
|
||||
print(json.dumps(result,indent=2))
|
||||
return int(result['status']!='passed_images_only')
|
||||
if __name__=='__main__':raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"status": "metadata_observed",
|
||||
"release": "state-hub",
|
||||
"namespace": "state-hub",
|
||||
"revision": 75,
|
||||
"image": {
|
||||
"repository": "forgejo.coulomb.social/coulomb/state-hub",
|
||||
"tag": "main-7d99282",
|
||||
"pullPolicy": "IfNotPresent"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"schema": "cluster-historical-source-patches.v1",
|
||||
"status": "isolated_review_candidate_not_applied",
|
||||
"binding_manifests_identical": true,
|
||||
"repos": {
|
||||
"repo-manager": {
|
||||
"base_revision": "9ebde0a9bd60d5c82910494fbe6bd1b891455cee",
|
||||
"files": [
|
||||
{
|
||||
"path": "src/repo_manager/historical_sources.py",
|
||||
"action": "modify",
|
||||
"base_sha256": "a465aa595867c5e80918168158f8a27bf429d469b2e5249eca98b4e5ef84ce03",
|
||||
"candidate_sha256": "838a0422b81e1215e3ec148c3104dd603d9dfc476610bb5617d98ccf5d3be218"
|
||||
},
|
||||
{
|
||||
"path": "src/repo_manager/identifiers.py",
|
||||
"action": "modify",
|
||||
"base_sha256": "68dbbd3f137f326a49d20ba3f5855d6b00b4d62eb01dc904b51a08d77552d2bf",
|
||||
"candidate_sha256": "4e35fcd009c20822574fd33171176bd14a1ff2dd5af8b0bb2820954974593a2a"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_cluster_frozen_history.py",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "0490ef3eb474f653e4a6a52b711999876bef061c07de3e7a6d327a8ba54acaf3"
|
||||
},
|
||||
{
|
||||
"path": "workplans/RMGR-WP-0019-historical-source-admission.md",
|
||||
"action": "modify",
|
||||
"base_sha256": "638af4000aa442a57df8eddef2d8b5aa5dffb9dbc9ccf547d9e04cf25012ffe4",
|
||||
"candidate_sha256": "6378d96b70259ae81435b6ce8f97864698bdc5b6679cabbe9dc61a1fc1d09699"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3"
|
||||
}
|
||||
],
|
||||
"patch_sha256": "232552c3d601d185984be5a8e5bc9937bc9ddda7327923b5f0440f6deb2c87fc"
|
||||
},
|
||||
"state-hub": {
|
||||
"base_revision": "4ab66e363ed00a67ad1ea0434854bc4284c7a26e",
|
||||
"files": [
|
||||
{
|
||||
"path": "api/services/historical_projection.py",
|
||||
"action": "modify",
|
||||
"base_sha256": "4d7cd127462bedaaf1c0915c6a11f58b25d28ae6f000eb89c95d903bf4a0e94e",
|
||||
"candidate_sha256": "e3c4bf965764b32a6df5d61c71272ee22569df9a9ddae2a3fc3df97ec554e0b2"
|
||||
},
|
||||
{
|
||||
"path": "tests/test_cluster_historical_projection.py",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "183f0574aa01f69269a06d162b29b5b95c3415f6fce0560bb92188c09be26507"
|
||||
},
|
||||
{
|
||||
"path": "workplans/STATE-WP-0095-historical-source-receiving.md",
|
||||
"action": "modify",
|
||||
"base_sha256": "9be4b0661f0cc817d55dcf02f959339cb6c13f56956b111a6ecd87d1bec79405",
|
||||
"candidate_sha256": "706c7113bf13c124e0281ce9838141d50ceb8fc6007879b4bb52b6f4a346f0a9"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a"
|
||||
},
|
||||
{
|
||||
"path": "tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
"action": "add",
|
||||
"base_sha256": null,
|
||||
"candidate_sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3"
|
||||
}
|
||||
],
|
||||
"patch_sha256": "258e936a06b11446cbf131c48b9965be4d0800f753ba3a3bf180c53a631b8e94"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,922 @@
|
|||
--- a/api/services/historical_projection.py
|
||||
+++ b/api/services/historical_projection.py
|
||||
@@ -72,6 +72,111 @@
|
||||
"record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED",
|
||||
"uuid": "1c53d3db-6633-5e2d-987f-10706d73be9e",
|
||||
"parent_record_id": "CUST-WP-ADHOC-2026-08-25-RETIRED"
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md",
|
||||
+ "sha256": "8d6f8533c119117686bc0d5bb9ed5349726aa6c3189ff98b53409c68ac685ca9",
|
||||
+ "record_id": "RAIL-BS-WP-0002",
|
||||
+ "uuid": "4c63dfc6-9eac-4e79-9f77-8f644ad7147d",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0002",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "3f042630-eab0-4c6a-9167-e2b28ff20e40",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "e8510646-46ed-4697-a345-f3d3009eea78",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "dab2c07f-8aa0-4635-8df6-857e87e93fc5",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "5c3d40e4-239b-488e-9519-6f7a38d2325f",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "7f9e0e58-a130-467a-a2d0-b3f2564e496f",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md",
|
||||
+ "sha256": "4dc2f61e15adc3c5c81f9ecf8b933512275b95e8f829fac52ae7cde1385cea4a",
|
||||
+ "record_id": "RAIL-BS-WP-0003",
|
||||
+ "uuid": "7ee9ee22-1fae-4567-9194-8d70a9e0f45b",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0003",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "ab166073-30a7-4702-a037-4091e8706e20",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "140da396-8e30-4f4d-b88c-c42c0cd46c01",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
+ },
|
||||
+ {
|
||||
+ "repo": "railiance-cluster",
|
||||
+ "path": "workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md",
|
||||
+ "sha256": "04f787078232395bd2d43861de16c521848559fa286f06e1caa9cb779aa71ac3",
|
||||
+ "record_id": "RAIL-BS-WP-0004",
|
||||
+ "uuid": "7e8b0c20-51eb-40c9-9e3b-85dd380d7625",
|
||||
+ "parent_record_id": "RAIL-BS-WP-0004",
|
||||
+ "tasks": [
|
||||
+ {
|
||||
+ "label": "T01",
|
||||
+ "uuid": "4526a842-ea31-4874-9231-92ab556cfe7b",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T02",
|
||||
+ "uuid": "a6313e06-1976-46a7-8e31-df4eb2eca880",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T03",
|
||||
+ "uuid": "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T04",
|
||||
+ "uuid": "08233868-d522-4117-bc4e-6c0f52545665",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T05",
|
||||
+ "uuid": "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8",
|
||||
+ "status": "done"
|
||||
+ },
|
||||
+ {
|
||||
+ "label": "T06",
|
||||
+ "uuid": "f8e4a094-c367-40eb-b895-da17bc144b07",
|
||||
+ "status": "done"
|
||||
+ }
|
||||
+ ]
|
||||
}
|
||||
]''')
|
||||
|
||||
--- /dev/null
|
||||
+++ b/tests/test_cluster_historical_projection.py
|
||||
@@ -0,0 +1,67 @@
|
||||
+import shutil
|
||||
+import uuid
|
||||
+from pathlib import Path
|
||||
+import pytest
|
||||
+from api.services import forge_projection as fp
|
||||
+from api.services import historical_projection as hp
|
||||
+from tests.test_forge_projection import _FakeSession,_Repo,_Row,_TaskRow
|
||||
+
|
||||
+FIXTURE=Path(__file__).parent/'fixtures/railiance-cluster'
|
||||
+@pytest.fixture
|
||||
+def bound(tmp_path):
|
||||
+ root=tmp_path/'railiance-cluster';shutil.copytree(FIXTURE,root)
|
||||
+ derived=fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
+ assert len(derived.workplans)==3
|
||||
+ rows=[];tasks=[]
|
||||
+ for parent in derived.workplans:
|
||||
+ row=_Row(parent.record_id.lower(),'finished',parent.relative_path);row.id=uuid.UUID(parent.uuid);rows.append(row)
|
||||
+ for source in parent.tasks:
|
||||
+ task=_TaskRow(source.record_id,status='done',workplan_id=row.id);task.id=uuid.UUID(source.uuid);tasks.append(task)
|
||||
+ assert len(tasks)==15
|
||||
+ return root,derived,rows,tasks
|
||||
+
|
||||
+def test_exact_three_sources_derive_only_frozen_15_task_uuids(bound):
|
||||
+ root,derived,rows,tasks=bound
|
||||
+ assert all(parent.historical_preservation for parent in derived.workplans)
|
||||
+ assert all(parent.uuid!=fp.derived_record_uuid(parent.record_id) for parent in derived.workplans)
|
||||
+ expected={item['uuid'] for entry in hp.BINDINGS if entry['repo']=='railiance-cluster' for item in entry['tasks']}
|
||||
+ assert {str(t.id) for t in tasks}==expected
|
||||
+
|
||||
+@pytest.mark.asyncio
|
||||
+async def test_existing_rows_are_entirely_unchanged_on_normal_receiving(bound):
|
||||
+ root,derived,rows,tasks=bound;before=[dict(x.__dict__) for x in rows+tasks]
|
||||
+ session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
+ outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
+ assert outcome.status=='noop' and not outcome.refused
|
||||
+ assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
+ assert not session.added and not session.deleted
|
||||
+
|
||||
+@pytest.mark.parametrize('defect',['missing-parent','missing-task','foreign-holder','nonterminal-task','nonterminal-parent','duplicate-current-label'])
|
||||
+@pytest.mark.asyncio
|
||||
+async def test_unsupported_actual_binding_refuses_before_any_mutation(bound,defect):
|
||||
+ root,derived,rows,tasks=bound
|
||||
+ if defect=='missing-parent':rows.pop()
|
||||
+ elif defect=='missing-task':tasks.pop()
|
||||
+ elif defect=='foreign-holder':tasks[0].workplan_id=uuid.uuid4()
|
||||
+ elif defect=='nonterminal-task':tasks[0].status='progress'
|
||||
+ elif defect=='duplicate-current-label':tasks.append(_TaskRow(tasks[0].record_id,status='done',workplan_id=tasks[0].workplan_id))
|
||||
+ else:rows[0].status='active'
|
||||
+ before=[dict(x.__dict__) for x in rows+tasks];session=_FakeSession(_Repo(),rows,task_rows=tasks)
|
||||
+ outcome=await fp.reset_repository_projection(session,'railiance-cluster',derived=derived)
|
||||
+ assert outcome.status=='refused';assert [dict(x.__dict__) for x in rows+tasks]==before
|
||||
+ assert not session.added and not session.deleted
|
||||
+
|
||||
+@pytest.mark.parametrize('change',['hash-drift','missing','symlink','live-source','missing-source-uuid'])
|
||||
+def test_frozen_source_drift_refuses_derivation(bound,change):
|
||||
+ root,*_=bound;p=next((root/'workplans/archived').glob('*.md'));raw=p.read_bytes()
|
||||
+ if change=='missing':p.unlink()
|
||||
+ elif change=='symlink':p.unlink();target=root/'saved';target.write_bytes(raw);p.symlink_to(target)
|
||||
+ elif change=='live-source':p.write_bytes(raw.replace(b'status: finished',b'status: active',1))
|
||||
+ elif change=='missing-source-uuid':p.write_bytes(raw.replace(b'state_hub_task_id:',b'old_task_id:',1))
|
||||
+ else:p.write_bytes(raw+b'\nunreviewed\n')
|
||||
+ with pytest.raises(fp.ForgeDeriveError,match='Historical source binding'):fp.derive_from_checkout(root,'railiance-cluster','candidate')
|
||||
+
|
||||
+def test_unknown_repository_never_trusts_frozen_source_uuids(bound):
|
||||
+ root,derived,*_=bound;other=fp.derive_from_checkout(root,'different-repo','candidate')
|
||||
+ assert not any(parent.historical_preservation for parent in other.workplans)
|
||||
+ assert not {parent.uuid for parent in derived.workplans}&{parent.uuid for parent in other.workplans}
|
||||
--- a/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
+++ b/workplans/STATE-WP-0095-historical-source-receiving.md
|
||||
@@ -4,10 +4,10 @@
|
||||
title: "Preserve exact closed historical source bindings in Forge receiving"
|
||||
domain: infotech
|
||||
repo: state-hub
|
||||
-status: finished
|
||||
+status: active
|
||||
owner: codex
|
||||
created: "2026-10-04"
|
||||
-updated: "2026-10-06"
|
||||
+updated: "2026-10-17"
|
||||
flavor: implementation
|
||||
depends_on: []
|
||||
state_hub_workstream_id: "79579572-8120-5d3b-aca5-dd4aff60d058"
|
||||
@@ -167,3 +167,65 @@
|
||||
[activity stale retirement rollout](../docs/evidence/2026-10-06-activity-stale-retirement-rollout.json).
|
||||
Repo Manager's re-run of the Activity owner sync (RMGR-WP-0019-T03) and
|
||||
ACTIVITY-WP-0045-T02 remain with their owners.
|
||||
+
|
||||
+
|
||||
+## Cluster exact historical receiving successor, 2026-10-17
|
||||
+
|
||||
+This scoped extension retains the completed Activity/Custodian outcomes above.
|
||||
+Three frozen cluster archives contain three existing parent UUIDs and 15 existing
|
||||
+closed task UUIDs. Extend the existing exact historical receiving path before
|
||||
+registrar admission; never derive replacement identities or modify those
|
||||
+sources. Cross-owner observation attaches to existing SHR-WP-0001, with
|
||||
+RMGR-WP-0019 as obligor/beneficiary and NK-WP-0049-T35 benefiting indirectly.
|
||||
+The local derived case is waiting; no live worker injection is authorized.
|
||||
+
|
||||
+## Prepare exact cluster source preservation and refusal cases
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T05
|
||||
+status: done
|
||||
+priority: high
|
||||
+```
|
||||
+
|
||||
+Prepared the same full-file SHA256 and 15 task binding manifest as Repo Manager.
|
||||
+Candidate receiving tests pass 26: existing rows remain entirely unchanged;
|
||||
+missing/foreign/nonterminal rows and unknown, changed, missing or symlinked
|
||||
+source refuse. Read-only public metadata confirms all 18 existing rows have the
|
||||
+expected ownership and terminal status. These are source and metadata checks,
|
||||
+not delivered receiving or a committed native trial.
|
||||
+
|
||||
+## Deliver reviewed cluster receiving successor before registrar admission
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T06
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [STATE-WP-0095-T05]
|
||||
+needs_human: true
|
||||
+intervention_note: "Review exact committed source, CI image and owner deployment proposal before rollout."
|
||||
+```
|
||||
+
|
||||
+Use the normal CI image and Helm owner path after exact revision/image pin,
|
||||
+fresh headroom and reviewed dry-run/diff. API/MCP currently run main-7d99282;
|
||||
+Helm revision75 is the observed rollback baseline, requiring freshness before
|
||||
+execution. No deployment assent exists for this successor. Verify delivered
|
||||
+module hash, health, both readiness checks and unchanged schema; verify exact
|
||||
+three parents/15 tasks and rejection behavior without committed probe changes.
|
||||
+Only the verified return unblocks RMGR-WP-0019-T05. Costs and monetary risk are
|
||||
+unknown pending exact rollout proposal; no zero-cost or autonomy grant is inferred.
|
||||
+
|
||||
+## Verify ordinary cluster receiving and immutable historical rows
|
||||
+
|
||||
+```task
|
||||
+id: STATE-WP-0095-T07
|
||||
+status: wait
|
||||
+priority: high
|
||||
+depends_on: [STATE-WP-0095-T06, RMGR-WP-0019-T06]
|
||||
+```
|
||||
+
|
||||
+Capture normal cluster owner publication/receiving receipts and compare entire
|
||||
+existing row snapshots and exact source bytes. No creation, movement, relabel,
|
||||
+unretirement or retirement is authorized for these histories. A rollback first
|
||||
+fences cluster sync and restores registrar refusal before removing receiving
|
||||
+support; it never rewrites archived IDs or UUIDs. This work does not establish
|
||||
+original TLS recovery or owner-native admission.
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0002-k3s-baseline.md
|
||||
@@ -0,0 +1,177 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0002
|
||||
+type: workplan
|
||||
+title: "k3s and Kubernetes Platform Baseline"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: railiance
|
||||
+topic_slug: railiance
|
||||
+repo_goal_id: "70ab2379-fb9d-4fec-a09d-b2a717e4ace8"
|
||||
+state_hub_workstream_id: "4c63dfc6-9eac-4e79-9f77-8f644ad7147d"
|
||||
+created: "2026-03-09"
|
||||
+updated: "2026-03-10"
|
||||
+completed: "2026-03-10"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# k3s and Kubernetes Platform Baseline
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Install k3s, Helm, and the baseline Kubernetes services on the converged
|
||||
+HostEurope node. This workplan picks up exactly where `railiance-hosts`
|
||||
+leaves off: a hardened, verified OS node that is ready for Kubernetes.
|
||||
+
|
||||
+## Pre-condition
|
||||
+
|
||||
+`railiance-infra` converge + Goss verify must pass before any task here
|
||||
+is executed:
|
||||
+
|
||||
+```bash
|
||||
+# In railiance-infra/
|
||||
+make converge
|
||||
+make verify # must exit 0
|
||||
+```
|
||||
+
|
||||
+## Boundary
|
||||
+
|
||||
+This repo owns everything from k3s upward. It must not re-configure items
|
||||
+defined in `railiance-infra/spec/server-baseline.yaml`. See ADR-003:
|
||||
+`railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`.
|
||||
+
|
||||
+**Out of scope here:** platform services (PostgreSQL, storage, identity)
|
||||
+→ `railiance-platform`. Application deployments (Gitea, coulomb services)
|
||||
+→ `railiance-apps`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Ansible playbook: install k3s (server mode)
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "3f042630-eab0-4c6a-9167-e2b28ff20e40"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Harden `ansible/bootstrap.yml` to a production-ready k3s install:
|
||||
+
|
||||
+- Use the official k3s install script pinned to a specific version
|
||||
+ (`INSTALL_K3S_VERSION=v1.35.1+k3s1`)
|
||||
+- `INSTALL_K3S_EXEC="server --cluster-init --write-kubeconfig-mode=644"`
|
||||
+ (cluster-init enables embedded etcd for future HA expansion)
|
||||
+- Wait for node `Ready` before proceeding:
|
||||
+ ```bash
|
||||
+ k3s kubectl wait node --all --for=condition=Ready --timeout=120s
|
||||
+ ```
|
||||
+- Fetch kubeconfig to the control node as `~/.kube/config-hosteurope`
|
||||
+
|
||||
+**Done when:** `k3s kubectl get nodes` returns `Ready` from both the server
|
||||
+and the control node (via kubeconfig).
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Helm installation
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "e8510646-46ed-4697-a345-f3d3009eea78"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add a task (or a role `roles/helm/`) that:
|
||||
+
|
||||
+1. Downloads the Helm binary (pinned version) to `/usr/local/bin/helm`
|
||||
+2. Verifies the checksum
|
||||
+3. Confirms `helm version` succeeds
|
||||
+
|
||||
+**Done when:** `helm version` succeeds on the HostEurope node.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Smoke test: k3s + Helm
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "dab2c07f-8aa0-4635-8df6-857e87e93fc5"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+- `k3s kubectl get nodes` → node in Ready state
|
||||
+- `helm version` exits 0
|
||||
+- CoreDNS pod running in `kube-system`
|
||||
+- Traefik ingress controller pod running (default in k3s)
|
||||
+
|
||||
+Run via:
|
||||
+```bash
|
||||
+ansible-playbook -i ansible/hosts.ini ansible/smoke.yml
|
||||
+```
|
||||
+or directly over SSH if the kubeconfig is available locally.
|
||||
+
|
||||
+**Done when:** all assertions pass and the script exits 0.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Commit kubeconfig management notes
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "5c3d40e4-239b-488e-9519-6f7a38d2325f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Document in `docs/kubeconfig.md`:
|
||||
+
|
||||
+- Where the kubeconfig is fetched to (`~/.kube/config-hosteurope`)
|
||||
+- How to merge it into `~/.kube/config`
|
||||
+- How to switch context: `kubectl config use-context default`
|
||||
+- Security note: kubeconfig is gitignored (contains cluster CA + client cert)
|
||||
+
|
||||
+**Done when:** doc written and committed.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — Add `make k3s-install` and `make smoke` targets
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "7f9e0e58-a130-467a-a2d0-b3f2564e496f"
|
||||
+completed: "2026-03-10"
|
||||
+```
|
||||
+
|
||||
+Add to Makefile (create one if none exists):
|
||||
+
|
||||
+```makefile
|
||||
+k3s-install: ## Install k3s and Helm on all inventory hosts
|
||||
+ ansible-playbook -i ansible/hosts.ini ansible/bootstrap.yml
|
||||
+
|
||||
+smoke: ## Run Kubernetes smoke tests
|
||||
+ bash tests/smoke_kube.sh
|
||||
+```
|
||||
+
|
||||
+**Done when:** both targets work and are listed in `make help`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Repo goal: `70ab2379-fb9d-4fec-a09d-b2a717e4ace8` (Install k3s and Kubernetes Baseline)
|
||||
+- Domain goal: `6f96c712-60e6-4ea9-ab06-168878eafbce` (Three-Phoenix Secure Kubernetes Infrastructure)
|
||||
+- Pre-condition: railiance-infra WP-0001 (Secure Single-Server Bootstrap) — completed 2026-03-09
|
||||
+- Boundary ADR: `railiance-infra/docs/adr/ADR-003-railiance-5repo-stack-architecture.md`
|
||||
+- k3s releases: https://github.com/k3s-io/k3s/releases
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0003-pgpool-ha-failover-fix.md
|
||||
@@ -0,0 +1,196 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0003
|
||||
+type: workplan
|
||||
+title: "pgpool CrashLoopBackOff on PostgreSQL HA failover — missing secret key"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+created: "2026-03-10"
|
||||
+updated: "2026-03-10"
|
||||
+state_hub_workstream_id: "7ee9ee22-1fae-4567-9194-8d70a9e0f45b"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Bug Report: pgpool CrashLoopBackOff on PostgreSQL HA failover
|
||||
+
|
||||
+## Summary
|
||||
+
|
||||
+On 2026-03-10 a PostgreSQL HA failover caused all three postgresql pods to
|
||||
+restart. pgpool — the connection pooler between Gitea and PostgreSQL — then
|
||||
+entered CrashLoopBackOff and produced no logs. As a result Gitea's login
|
||||
+and all write operations hung indefinitely. The root page was still served
|
||||
+(from Valkey cache) which masked the failure.
|
||||
+
|
||||
+The fix was to patch a missing key in a Kubernetes secret. The root cause is
|
||||
+that the `gitea-12.2.0` Helm chart (postgresql-ha subchart v16.2.2) does not
|
||||
+populate the `pgpool-password` key in the `gitea-postgresql-ha-postgresql`
|
||||
+secret, even though the pgpool pod requires it at startup.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Timeline
|
||||
+
|
||||
+| Time (UTC) | Event |
|
||||
+|---|---|
|
||||
+| ~09:45 | postgresql-0, postgresql-2 pods restarted (repmgr failover) |
|
||||
+| ~09:45 | pgpool pod restarted and entered CrashLoopBackOff |
|
||||
+| ~11:00 | User noticed Gitea login hanging; home page still loading |
|
||||
+| ~13:00 | Root cause identified: missing `pgpool-password` secret key |
|
||||
+| ~13:10 | Secret patched; pgpool pod deleted and restarted cleanly |
|
||||
+| ~13:15 | Gitea fully operational |
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Root Cause
|
||||
+
|
||||
+The Bitnami `pgpool` container startup script reads the file
|
||||
+`/opt/bitnami/pgpool/secrets/pgpool-password`, which is mounted from the
|
||||
+`gitea-postgresql-ha-postgresql` Kubernetes Secret via a `subPath` volume
|
||||
+mount. That secret key was never created by the Helm chart, so the file did
|
||||
+not exist. The container exited immediately with no logs.
|
||||
+
|
||||
+The pod had been running for 20 days without a restart, so this gap was
|
||||
+never discovered during initial deployment.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Evidence
|
||||
+
|
||||
+```bash
|
||||
+# Secret was missing the pgpool-password key
|
||||
+sudo k3s kubectl get secret -n default gitea-postgresql-ha-postgresql -o yaml
|
||||
+# data: keys were password, postgres-password, repmgr-password only
|
||||
+# pgpool-password was absent
|
||||
+
|
||||
+# pgpool pod describe showed 824 back-off restarts over 173 minutes
|
||||
+# No logs in either current or --previous output
|
||||
+sudo k3s kubectl logs -n default <pgpool-pod> --previous
|
||||
+# (empty)
|
||||
+
|
||||
+# Gitea process had zero TCP connections to PostgreSQL port 5432
|
||||
+# but many connections to Valkey port 6379
|
||||
+cat /proc/<gitea-pid>/net/tcp | grep 1538 # 1538 = 5432 hex — no results
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Immediate Fix Applied
|
||||
+
|
||||
+```bash
|
||||
+# Add the missing key (value = sr-check-password = changeme4 = base64: Y2hhbmdlbWU0)
|
||||
+sudo k3s kubectl patch secret -n default gitea-postgresql-ha-postgresql \
|
||||
+ --type='json' \
|
||||
+ -p='[{"op":"add","path":"/data/pgpool-password","value":"Y2hhbmdlbWU0"}]'
|
||||
+
|
||||
+# Restart pgpool
|
||||
+sudo k3s kubectl delete pod -n default <pgpool-pod-name>
|
||||
+```
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Risk: Fix Will Be Lost on helm upgrade
|
||||
+
|
||||
+The patched secret is managed by Helm (annotation:
|
||||
+`meta.helm.sh/release-name: gitea`). A `helm upgrade` will regenerate the
|
||||
+secret from the chart template, which does not include `pgpool-password`,
|
||||
+and the bug will recur.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Add pgpool-password to Helm values
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "6841c93a-f146-47eb-9f7c-8fa0e02c1bbc"
|
||||
+```
|
||||
+
|
||||
+Create or update `helm/gitea-values.yaml` (or equivalent) to permanently
|
||||
+include the pgpool-password so it survives `helm upgrade`:
|
||||
+
|
||||
+```yaml
|
||||
+postgresql-ha:
|
||||
+ postgresql:
|
||||
+ pgpoolPassword: <value matching sr-check-password>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `helm upgrade gitea` completes and pgpool starts cleanly
|
||||
+without manual secret patching.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Add pgpool health check to smoke test
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "ab166073-30a7-4702-a037-4091e8706e20"
|
||||
+```
|
||||
+
|
||||
+Extend `tests/smoke_kube.sh` to assert:
|
||||
+
|
||||
+```bash
|
||||
+# All postgresql-ha pods Running
|
||||
+kubectl get pods -n default | grep gitea-postgresql-ha | grep -v Running && exit 1
|
||||
+
|
||||
+# pgpool specifically not in CrashLoopBackOff
|
||||
+kubectl get pod -n default -l app.kubernetes.io/component=pgpool \
|
||||
+ -o jsonpath='{.items[0].status.containerStatuses[0].state}' | grep -v crash
|
||||
+```
|
||||
+
|
||||
+**Done when:** the smoke test catches a pgpool failure within 5 minutes.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Add HA failover test
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "140da396-8e30-4f4d-b88c-c42c0cd46c01"
|
||||
+```
|
||||
+
|
||||
+Create `tests/test_ha_failover.sh` that:
|
||||
+
|
||||
+1. Records Gitea login response time (baseline)
|
||||
+2. Kills the primary PostgreSQL pod: `kubectl delete pod gitea-postgresql-ha-postgresql-0 -n default`
|
||||
+3. Waits for repmgr to promote a replica (max 60s)
|
||||
+4. Asserts Gitea login POST still succeeds within 10s
|
||||
+5. Asserts pgpool pod is Running (not CrashLoopBackOff)
|
||||
+6. Asserts all postgresql pods return to Running
|
||||
+
|
||||
+This test must pass before any PostgreSQL HA deployment is considered done.
|
||||
+
|
||||
+**Done when:** script exits 0 against a live cluster.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Document the incident in docs/
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "d8a3ba40-fda0-4c1f-a9f1-ffcd621a5b3d"
|
||||
+```
|
||||
+
|
||||
+Add `docs/incidents/2026-03-10-pgpool-missing-secret.md` with the full
|
||||
+timeline, root cause, and fix, so future operators understand what happened
|
||||
+and how to recover.
|
||||
+
|
||||
+**Done when:** doc committed and linked from `docs/README.md`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Bitnami postgresql-ha chart v16.2.2
|
||||
+- Gitea Helm chart v12.2.0
|
||||
+- Related decision: D3 (HA testing policy) in `DECISIONS.md`
|
||||
--- /dev/null
|
||||
+++ b/tests/fixtures/railiance-cluster/workplans/archived/260622-RAIL-BS-WP-0004-safety-net.md
|
||||
@@ -0,0 +1,275 @@
|
||||
+---
|
||||
+id: RAIL-BS-WP-0004
|
||||
+type: workplan
|
||||
+title: "Integrated Backup — S2 Kubernetes Runtime Layer"
|
||||
+domain: financials
|
||||
+repo: railiance-cluster
|
||||
+status: finished
|
||||
+owner: tegwick
|
||||
+topic_slug: railiance
|
||||
+state_hub_workstream_id: "7e8b0c20-51eb-40c9-9e3b-85dd380d7625"
|
||||
+created: "2026-02-25"
|
||||
+updated: "2026-03-26"
|
||||
+---
|
||||
+
|
||||
+> **Terminology note:** Historical text in this archived workplan may use the legacy term "workstream". The fleet term is **workplan** (`canon/standards/workplan-terminology-fleet_v0.1.md`).
|
||||
+
|
||||
+# Integrated Backup — S2 Kubernetes Runtime Layer
|
||||
+
|
||||
+## Goal
|
||||
+
|
||||
+Implement the Q3 (Operability & Resilience) integrated backup for
|
||||
+railiance-cluster (S2). Backs up what S2 owns — the Kubernetes runtime state —
|
||||
+encrypted with age, written to a local directory on the server. No external
|
||||
+dependencies required.
|
||||
+
|
||||
+## Architecture (Decision D4)
|
||||
+
|
||||
+Each railiance repo implements its own backup for what it owns. No central
|
||||
+backup service. See `DECISIONS.md` D4 for full rationale.
|
||||
+
|
||||
+**Standard interface every railiance repo must provide:**
|
||||
+
|
||||
+```bash
|
||||
+make backup # encrypt + write to /opt/backup/railiance/<layer>/
|
||||
+make restore # restore from most recent local backup
|
||||
+```
|
||||
+
|
||||
+Encryption: age, same key pair as SOPS secrets (`.sops.yaml` public key).
|
||||
+Output: `/opt/backup/railiance/cluster/` on the server.
|
||||
+
|
||||
+## What S2 (railiance-cluster) owns and must back up
|
||||
+
|
||||
+| Asset | Why it matters |
|
||||
+|---|---|
|
||||
+| k3s etcd snapshots | Full cluster state — all workloads, configs, secrets |
|
||||
+| Helm release values | Runtime values not in git (any manually applied overrides) |
|
||||
+| kubeconfig | Admin access to the cluster |
|
||||
+
|
||||
+**Not S2's responsibility:**
|
||||
+- Custodian State Hub DB → the-custodian owns this
|
||||
+- Operator workstation config (`.claude/`, `.gitconfig`) → operator's own concern
|
||||
+- Application data (Gitea repos, uploads) → S5 (railiance-apps) owns this
|
||||
+- PostgreSQL data volumes → S3 (railiance-platform) owns this
|
||||
+
|
||||
+## Encryption
|
||||
+
|
||||
+Reuse the age public key from `.sops.yaml`:
|
||||
+
|
||||
+```bash
|
||||
+AGE_PUBLIC_KEY=$(grep 'age:' .sops.yaml | awk '{print $2}')
|
||||
+tar -czf - <assets> | age -r "${AGE_PUBLIC_KEY}" -o backup.tar.gz.age
|
||||
+```
|
||||
+
|
||||
+Decryption requires the private key at `~/.config/sops/age/keys.txt`
|
||||
+(same key used for `sops -d`). No additional key management needed.
|
||||
+
|
||||
+## Extension Point EP-RAIL-005
|
||||
+
|
||||
+Once all five OAS layers implement this interface, the custodian can
|
||||
+orchestrate a full-stack backup with:
|
||||
+
|
||||
+```bash
|
||||
+for repo in railiance-infra railiance-cluster railiance-platform \
|
||||
+ railiance-enablement railiance-apps; do
|
||||
+ make -C ~/$repo backup
|
||||
+done
|
||||
+```
|
||||
+
|
||||
+No special protocol needed — just the standard interface.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## Tasks
|
||||
+
|
||||
+### T01 — Define backup directory and encryption wrapper
|
||||
+
|
||||
+```task
|
||||
+id: T01
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "4526a842-ea31-4874-9231-92ab556cfe7b"
|
||||
+```
|
||||
+
|
||||
+Create `tools/cmd/railiance-backup-s2` (replacing the old `railiance-backup`):
|
||||
+
|
||||
+- Backup dir: `/opt/backup/railiance/cluster/` (create with `mkdir -p`)
|
||||
+- Encrypt each artifact with age using public key from `.sops.yaml`
|
||||
+- Write timestamp-named files: `etcd-<ts>.snap.age`, `helm-values-<ts>.tar.gz.age`, `kubeconfig-<ts>.yaml.age`
|
||||
+- Keep last 7 of each type
|
||||
+- Write `.last-backup` stamp
|
||||
+- Exit 0 on success, non-zero on any failure
|
||||
+- No network required
|
||||
+
|
||||
+Also remove the old `tools/cmd/railiance-backup` (backed up Docker-based
|
||||
+custodian DB — wrong scope, not applicable to this server).
|
||||
+
|
||||
+**Done when:** `make backup` runs on COULOMBCORE without error and files
|
||||
+appear in `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T02 — Back up k3s state (SQLite hot backup)
|
||||
+
|
||||
+```task
|
||||
+id: T02
|
||||
+status: done
|
||||
+priority: high
|
||||
+state_hub_task_id: "a6313e06-1976-46a7-8e31-df4eb2eca880"
|
||||
+```
|
||||
+
|
||||
+k3s has built-in etcd snapshot support:
|
||||
+
|
||||
+```bash
|
||||
+sudo k3s etcd-snapshot save --name railiance-$(date -u +%Y%m%dT%H%M%SZ)
|
||||
+# Default location: /var/lib/rancher/k3s/server/db/snapshots/
|
||||
+```
|
||||
+
|
||||
+Add to the backup script: take a fresh snapshot, encrypt with age,
|
||||
+copy to `/opt/backup/railiance/cluster/`.
|
||||
+
|
||||
+> **Note — verify etcd is in use before implementing:**
|
||||
+> `k3s etcd-snapshot` only works if k3s was started with `--cluster-init`.
|
||||
+> Without it, k3s uses SQLite and this command will fail.
|
||||
+> Verify first: `sudo k3s etcd-snapshot ls 2>&1`
|
||||
+
|
||||
+> **Note — sudo required:** etcd snapshot requires root. See T06 for how
|
||||
+> this is resolved (backup runs under root's crontab).
|
||||
+
|
||||
+**Done when:** backup includes a current etcd snapshot.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T03 — Back up Helm release values
|
||||
+
|
||||
+```task
|
||||
+id: T03
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "05d42a55-921f-4aa7-bb76-e8af9c7e0ac3"
|
||||
+```
|
||||
+
|
||||
+Capture current runtime Helm values for all releases:
|
||||
+
|
||||
+```bash
|
||||
+KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm list -A -o json | \
|
||||
+ jq -r '.[].name + " " + .namespace' | \
|
||||
+ while read name ns; do
|
||||
+ KUBECONFIG=/etc/rancher/k3s/k3s.yaml helm get values "$name" -n "$ns" -o yaml
|
||||
+ done
|
||||
+```
|
||||
+
|
||||
+Tar and age-encrypt into `helm-values-<ts>.tar.gz.age`.
|
||||
+
|
||||
+> **Note — kubeconfig permissions:** `/etc/rancher/k3s/k3s.yaml` is root-readable
|
||||
+> only by default. The backup script must either run as root (see T06) or k3s
|
||||
+> must be configured with `--write-kubeconfig-mode=644`. Running as root
|
||||
+> (via root crontab) is the chosen approach — no config change needed.
|
||||
+
|
||||
+**Done when:** backup includes a snapshot of all Helm release values.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T04 — Back up kubeconfig
|
||||
+
|
||||
+```task
|
||||
+id: T04
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "08233868-d522-4117-bc4e-6c0f52545665"
|
||||
+```
|
||||
+
|
||||
+Age-encrypt `~/.kube/config-hosteurope` (or `/etc/rancher/k3s/k3s.yaml`)
|
||||
+into `kubeconfig-<ts>.yaml.age` in the backup directory.
|
||||
+
|
||||
+**Done when:** backup includes the encrypted kubeconfig.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T05 — make restore target
|
||||
+
|
||||
+```task
|
||||
+id: T05
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "2d5acff7-4a4e-4ddd-ad06-08237ad3dac8"
|
||||
+```
|
||||
+
|
||||
+Add `tools/cmd/railiance-restore-s2` that decrypts and lists available
|
||||
+backups, with guided restore for the etcd snapshot case.
|
||||
+
|
||||
+Restore of etcd from snapshot:
|
||||
+```bash
|
||||
+sudo k3s server --cluster-reset \
|
||||
+ --cluster-reset-restore-path=/var/lib/rancher/k3s/server/db/snapshots/<name>
|
||||
+```
|
||||
+
|
||||
+**Done when:** `make restore` prints available backups and a restore guide.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+### T06 — Install cron job and run restore drill
|
||||
+
|
||||
+```task
|
||||
+id: T06
|
||||
+status: done
|
||||
+priority: medium
|
||||
+state_hub_task_id: "f8e4a094-c367-40eb-b895-da17bc144b07"
|
||||
+```
|
||||
+
|
||||
+#### Solving the sudo problem
|
||||
+
|
||||
+The backup script needs root for two reasons:
|
||||
+- `k3s etcd-snapshot save` requires root
|
||||
+- `/etc/rancher/k3s/k3s.yaml` (kubeconfig) is root-readable only
|
||||
+
|
||||
+**Solution: run the cron under root's crontab.**
|
||||
+
|
||||
+This is the correct pattern for system-level backup jobs. It avoids a
|
||||
+proliferating sudoers whitelist (one entry per command, brittle to maintain)
|
||||
+and matches how tools like `rsnapshot`, `bacula`, and `borgbackup` work in
|
||||
+production. The backup writes to `/opt/backup/` which is root-owned anyway.
|
||||
+
|
||||
+Install the cron as root:
|
||||
+
|
||||
+```bash
|
||||
+sudo crontab -e
|
||||
+# Add:
|
||||
+0 2 * * * make -C /home/tegwick/railiance-cluster backup >> /opt/backup/railiance/cluster/backup.log 2>&1
|
||||
+```
|
||||
+
|
||||
+Note: use the absolute path to the repo — `~` does not expand reliably in
|
||||
+root's crontab unless HOME is set.
|
||||
+
|
||||
+Verify it is installed:
|
||||
+```bash
|
||||
+sudo crontab -l | grep railiance
|
||||
+```
|
||||
+
|
||||
+#### Restore drill
|
||||
+
|
||||
+Once T01–T04 are done, run a decrypt-and-verify drill:
|
||||
+
|
||||
+```bash
|
||||
+# Decrypt the etcd snapshot and verify it is a valid snapshot file
|
||||
+sudo age -d -i ~/.config/sops/age/keys.txt \
|
||||
+ /opt/backup/railiance/cluster/etcd-$(ls /opt/backup/railiance/cluster/etcd-*.snap.age | sort -r | head -1 | xargs basename | sed 's/etcd-//;s/.snap.age//').snap.age \
|
||||
+ | file -
|
||||
+
|
||||
+# Record the drill
|
||||
+echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) restore drill OK" \
|
||||
+ >> /opt/backup/railiance/cluster/restore-drill.log
|
||||
+```
|
||||
+
|
||||
+**Done when:** cron installed under root, drill completes without error,
|
||||
+log entry written.
|
||||
+
|
||||
+---
|
||||
+
|
||||
+## References
|
||||
+
|
||||
+- Decision D4: Integrated backup per capability (`DECISIONS.md`)
|
||||
+- Decision D2: Nextcloud as optional offsite extension (still valid, not a requirement)
|
||||
+- OAS Q3: Operability & Resilience
|
||||
+- Extension point EP-RAIL-005: Custodian full-stack backup orchestration
|
||||
+- k3s etcd snapshots: https://docs.k3s.io/datastore/backup-restore
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
"""Repo classification validation for State Hub registration (STATE-WP-0065 P1).
|
||||
|
||||
Loads allowed values from the custodian canon standard and validates classification
|
||||
blocks against controlled vocabularies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
# Explicit override first — every other candidate below is a developer
|
||||
# workstation path, so in a container none of them exist and classification
|
||||
# validation fails outright. Set REPO_CLASSIFICATION_ALLOWED_PATH in any
|
||||
# deployment that does not carry a the-custodian checkout (CUST-WP-0067-T04).
|
||||
_ENV_ALLOWED = os.environ.get("REPO_CLASSIFICATION_ALLOWED_PATH")
|
||||
|
||||
# Workstation checkout, railiance01 fleet checkout, then state-hub sibling fallback.
|
||||
_PRIMARY_ALLOWED = Path(
|
||||
"/home/worsch/the-custodian/canon/standards/repo-classification.allowed.yaml"
|
||||
)
|
||||
_RAILIANCE_ALLOWED = Path(
|
||||
"/home/tegwick/the-custodian/canon/standards/repo-classification.allowed.yaml"
|
||||
)
|
||||
_FALLBACK_ALLOWED = (
|
||||
Path(__file__).resolve().parent.parent.parent
|
||||
/ "the-custodian"
|
||||
/ "canon"
|
||||
/ "standards"
|
||||
/ "repo-classification.allowed.yaml"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClassificationData:
|
||||
"""Normalized classification fields stored on ``managed_repos``."""
|
||||
|
||||
category: str
|
||||
domain: str
|
||||
secondary_domains: list[str] = field(default_factory=list)
|
||||
capability_tags: list[str] = field(default_factory=list)
|
||||
business_stake: list[str] = field(default_factory=list)
|
||||
business_mechanics: list[str] = field(default_factory=list)
|
||||
classified_at: str | None = None
|
||||
classified_by: str | None = None
|
||||
standard_version: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"category": self.category,
|
||||
"domain": self.domain,
|
||||
"secondary_domains": list(self.secondary_domains),
|
||||
"capability_tags": list(self.capability_tags),
|
||||
"business_stake": list(self.business_stake),
|
||||
"business_mechanics": list(self.business_mechanics),
|
||||
"classified_at": self.classified_at,
|
||||
"classified_by": self.classified_by,
|
||||
"standard_version": self.standard_version,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_block(cls, block: dict) -> ClassificationData:
|
||||
return cls(
|
||||
category=block["category"],
|
||||
domain=block["domain"],
|
||||
secondary_domains=list(block.get("secondary_domains") or []),
|
||||
capability_tags=list(block.get("capability_tags") or []),
|
||||
business_stake=list(block.get("business_stake") or []),
|
||||
business_mechanics=list(block.get("business_mechanics") or []),
|
||||
classified_at=block.get("classified_at"),
|
||||
classified_by=block.get("classified_by"),
|
||||
standard_version=block.get("version") or block.get("standard_version"),
|
||||
)
|
||||
|
||||
|
||||
def _allowed_path() -> Path:
|
||||
if _ENV_ALLOWED:
|
||||
candidate = Path(_ENV_ALLOWED)
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
f"REPO_CLASSIFICATION_ALLOWED_PATH is set to {candidate}, which is not a file"
|
||||
)
|
||||
for candidate in (_PRIMARY_ALLOWED, _RAILIANCE_ALLOWED, _FALLBACK_ALLOWED):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
"repo-classification.allowed.yaml not found at "
|
||||
f"{_PRIMARY_ALLOWED}, {_RAILIANCE_ALLOWED}, or {_FALLBACK_ALLOWED}. "
|
||||
"Set REPO_CLASSIFICATION_ALLOWED_PATH when running without a "
|
||||
"the-custodian checkout (for example in a container)."
|
||||
)
|
||||
|
||||
|
||||
def load_allowed_values(path: Path | None = None) -> dict:
|
||||
"""Load the machine-readable allowed-values YAML."""
|
||||
target = path or _allowed_path()
|
||||
with target.open(encoding="utf-8") as fh:
|
||||
return yaml.safe_load(fh)
|
||||
|
||||
|
||||
def _known_capability_tags(allowed: dict) -> set[str]:
|
||||
tags: set[str] = set()
|
||||
for fam in (allowed.get("capability_families") or {}).values():
|
||||
tags.update(fam or [])
|
||||
return tags
|
||||
|
||||
|
||||
def validate_classification(block: dict) -> tuple[list[str], list[str]]:
|
||||
"""Validate a ``repo_classification`` block.
|
||||
|
||||
Returns ``(errors, warnings)``. *block* should be the inner mapping (not the
|
||||
full YAML document with the ``repo_classification`` wrapper).
|
||||
"""
|
||||
allowed = load_allowed_values()
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
if not isinstance(block, dict):
|
||||
return (["classification block must be a mapping"], [])
|
||||
|
||||
categories = set(allowed["categories"])
|
||||
domains = set(allowed["domains"])
|
||||
stakes = set(allowed["business_stake"])
|
||||
mechanics = set(allowed["business_mechanics"])
|
||||
guidance = allowed.get("guidance", {})
|
||||
pattern = re.compile(
|
||||
guidance.get("capability_tag_pattern", r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
||||
)
|
||||
|
||||
category = block.get("category")
|
||||
if category is None:
|
||||
errors.append("`category` is required")
|
||||
elif category not in categories:
|
||||
errors.append(f"`category` '{category}' not in {sorted(categories)}")
|
||||
|
||||
domain = block.get("domain")
|
||||
if domain is None:
|
||||
errors.append("`domain` is required")
|
||||
elif domain not in domains:
|
||||
errors.append(f"`domain` '{domain}' not in allowed domains")
|
||||
|
||||
secondary = block.get("secondary_domains") or []
|
||||
if not isinstance(secondary, list):
|
||||
errors.append("`secondary_domains` must be a list")
|
||||
secondary = []
|
||||
for d in secondary:
|
||||
if d not in domains:
|
||||
errors.append(f"secondary domain '{d}' not in allowed domains")
|
||||
if d == domain:
|
||||
errors.append(f"secondary domain '{d}' repeats the primary domain")
|
||||
if len(secondary) != len(set(secondary)):
|
||||
errors.append("`secondary_domains` contains duplicates")
|
||||
smax = guidance.get("secondary_domains_max", 3)
|
||||
if len(secondary) > smax:
|
||||
warnings.append(
|
||||
f"{len(secondary)} secondary_domains exceeds recommended max {smax}"
|
||||
)
|
||||
|
||||
tags = block.get("capability_tags") or []
|
||||
if not isinstance(tags, list):
|
||||
errors.append("`capability_tags` must be a list")
|
||||
tags = []
|
||||
known = _known_capability_tags(allowed)
|
||||
for t in tags:
|
||||
if not isinstance(t, str) or not pattern.match(t):
|
||||
errors.append(f"capability_tag '{t}' is not lowercase kebab-case")
|
||||
elif t not in known:
|
||||
warnings.append(
|
||||
f"capability_tag '{t}' is not a recommended family tag "
|
||||
"(allowed, check for synonym)"
|
||||
)
|
||||
|
||||
stake = block.get("business_stake") or []
|
||||
if not isinstance(stake, list):
|
||||
errors.append("`business_stake` must be a list")
|
||||
stake = []
|
||||
for s in stake:
|
||||
if s not in stakes:
|
||||
errors.append(f"business_stake '{s}' not in {sorted(stakes)}")
|
||||
if stake:
|
||||
lo = guidance.get("business_stake_recommended_min", 2)
|
||||
hi = guidance.get("business_stake_recommended_max", 6)
|
||||
if not (lo <= len(stake) <= hi):
|
||||
warnings.append(
|
||||
f"{len(stake)} business_stake values; {lo}-{hi} recommended"
|
||||
)
|
||||
|
||||
mech = block.get("business_mechanics") or []
|
||||
if not isinstance(mech, list):
|
||||
errors.append("`business_mechanics` must be a list")
|
||||
mech = []
|
||||
for m in mech:
|
||||
if m not in mechanics:
|
||||
errors.append(f"business_mechanics '{m}' not in {sorted(mechanics)}")
|
||||
|
||||
return errors, warnings
|
||||
|
||||
|
||||
CLASSIFICATION_FILENAME = ".repo-classification.yaml"
|
||||
|
||||
# Market-domain slugs (Repo Classification Standard v1.0 §6).
|
||||
MARKET_DOMAIN_SLUGS: frozenset[str] = frozenset({
|
||||
"infotech",
|
||||
"financials",
|
||||
"communication",
|
||||
"consumer",
|
||||
"health",
|
||||
"industrials",
|
||||
"energy",
|
||||
"utilities",
|
||||
"materials",
|
||||
"realestate",
|
||||
"crypto",
|
||||
"agents",
|
||||
"space",
|
||||
"government",
|
||||
})
|
||||
|
||||
# Legacy coordination-domain slugs still found in workplan frontmatter ``domain:``.
|
||||
# Maps to market-domain slugs used by the Hub ``domains`` table post-migration.
|
||||
LEGACY_COORDINATION_TO_MARKET: dict[str, str] = {
|
||||
"custodian": "infotech",
|
||||
"railiance": "financials",
|
||||
"markitect": "communication",
|
||||
"coulomb_social": "communication",
|
||||
"personhood": "government",
|
||||
"foerster_capabilities": "agents",
|
||||
"capabilities": "agents",
|
||||
"canon": "infotech",
|
||||
"citation_evidence": "infotech",
|
||||
"helix_forge": "infotech",
|
||||
"inter_hub": "infotech",
|
||||
"netkingdom": "communication",
|
||||
"stack": "infotech",
|
||||
"vergabe_teilnahme": "government",
|
||||
"whynot": "consumer",
|
||||
"test_domain_v2": "infotech",
|
||||
}
|
||||
|
||||
|
||||
def resolve_topic_domain_slug(
|
||||
workplan_domain: str,
|
||||
*,
|
||||
repo_market_domain: str | None = None,
|
||||
) -> str:
|
||||
"""Map a workplan frontmatter ``domain`` value to a market-domain slug.
|
||||
|
||||
Workplans may still carry legacy coordination slugs (e.g. ``custodian``)
|
||||
after the spine migration; topic lookup must use the market domain stored
|
||||
on ``domains.slug``.
|
||||
"""
|
||||
domain = (workplan_domain or "").strip()
|
||||
if not domain:
|
||||
return repo_market_domain or ""
|
||||
if domain in MARKET_DOMAIN_SLUGS:
|
||||
return domain
|
||||
mapped = LEGACY_COORDINATION_TO_MARKET.get(domain)
|
||||
if mapped:
|
||||
return mapped
|
||||
return repo_market_domain or domain
|
||||
|
||||
|
||||
def load_classification_document(path: Path) -> dict | None:
|
||||
"""Load and return the YAML document, or ``None`` if missing/unreadable."""
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
doc = yaml.safe_load(fh)
|
||||
except (OSError, yaml.YAMLError):
|
||||
return None
|
||||
return doc if isinstance(doc, dict) else None
|
||||
|
||||
|
||||
def extract_classification_block(doc: dict | None) -> dict | None:
|
||||
"""Return the inner ``repo_classification`` mapping from a loaded document."""
|
||||
if not doc:
|
||||
return None
|
||||
block = doc.get("repo_classification")
|
||||
return block if isinstance(block, dict) else None
|
||||
|
||||
|
||||
def load_classification_file(
|
||||
repo_path: Path | str,
|
||||
*,
|
||||
filename: str = CLASSIFICATION_FILENAME,
|
||||
) -> tuple[ClassificationData | None, list[str], list[str]]:
|
||||
"""Load ``.repo-classification.yaml`` from a repo root and validate it.
|
||||
|
||||
Returns ``(data, errors, warnings)``. *data* is ``None`` when the file is
|
||||
missing, unreadable, or has blocking validation errors.
|
||||
"""
|
||||
root = Path(repo_path)
|
||||
doc = load_classification_document(root / filename)
|
||||
block = extract_classification_block(doc)
|
||||
if block is None:
|
||||
if doc is None:
|
||||
return (None, [f"{filename} missing or unreadable"], [])
|
||||
return (None, [f"{filename} has no repo_classification block"], [])
|
||||
|
||||
errors, warnings = validate_classification(block)
|
||||
if errors:
|
||||
return (None, errors, warnings)
|
||||
return (ClassificationData.from_block(block), [], warnings)
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
database_url: str = "postgresql+asyncpg://custodian:changeme@127.0.0.1:5432/custodian"
|
||||
api_base: str = "http://127.0.0.1:8000"
|
||||
# What this instance claims to be. Deliberately defaults to "unknown":
|
||||
# an instance that has not declared itself is not the primary, and callers
|
||||
# that need the authoritative hub must be able to tell the difference.
|
||||
# Answering on a port is not evidence of authority — that assumption cost
|
||||
# seven weeks of onboarding (CUST-WP-0067-T03, ADR-010).
|
||||
state_hub_instance_role: Literal["primary", "cache", "unknown"] = "unknown"
|
||||
# Env vars are STATE_HUB_INSTANCE_ROLE / STATE_HUB_INSTANCE_LABEL — the
|
||||
# field name *is* the binding, so renaming either breaks the deployment
|
||||
# silently. Covered by tests/test_instance_identity.py.
|
||||
# Free-form label to name *which* instance answered, e.g. "railiance01".
|
||||
state_hub_instance_label: str | None = None
|
||||
debug: bool = False
|
||||
state_hub_report_dir: str = "reports/recently-on-scope"
|
||||
state_hub_markitect_cli_path: str | None = None
|
||||
activity_core_url: str | None = None
|
||||
activity_core_worker_token: str | None = None
|
||||
ops_run_projection_ttl_seconds: float = 15.0
|
||||
ops_run_sla_hours: float = 1.0
|
||||
sbom_nexus_url: str | None = None
|
||||
sbom_nexus_read_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_write_mode: Literal["legacy", "nexus"] = "legacy"
|
||||
sbom_nexus_timeout_seconds: float = 5.0
|
||||
# Repository renames are central-only, compare-and-set operations. The
|
||||
# secret signs short-lived, non-persistent preflight evidence; it must be
|
||||
# supplied by the deployment (normally through OpenBao), never recorded in
|
||||
# State Hub. With no secret the read-only report remains available but no
|
||||
# mutation token can be issued.
|
||||
repository_rename_preflight_secret: str | None = None
|
||||
repository_rename_preflight_ttl_seconds: int = 900
|
||||
repository_rename_forge_timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from api.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=settings.debug,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
async_session_factory = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
"""DoI engine — evaluates all 14 Repository Definition of Integrated criteria.
|
||||
|
||||
Shared by the API endpoint (async) and the CLI check script (asyncio.run).
|
||||
All checks use only the repo dict from /repos/{slug} + HTTP calls to the API
|
||||
+ local filesystem reads. No direct DB access.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import yaml
|
||||
|
||||
CriterionStatus = Literal["pass", "fail", "warn", "skip"]
|
||||
Tier = Literal["none", "core", "standard", "full"]
|
||||
|
||||
# Criteria that belong to each tier (in check order)
|
||||
CORE_IDS = {"C1", "C2", "C3", "C4"}
|
||||
STANDARD_IDS = {"C5a", "C5b", "C5c", "C6", "C7", "C8", "C9"}
|
||||
FULL_IDS = {"C10", "C11", "C12", "C13", "C14"}
|
||||
|
||||
STANDARD_SCOPE_SECTIONS = [
|
||||
"One-liner",
|
||||
"Core Idea",
|
||||
"In Scope",
|
||||
"Out of Scope",
|
||||
"Relevant When",
|
||||
"Not Relevant When",
|
||||
"Current State",
|
||||
"How It Fits",
|
||||
"Terminology",
|
||||
"Related / Overlapping",
|
||||
"Provided Capabilities",
|
||||
]
|
||||
|
||||
_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)```", re.DOTALL | re.IGNORECASE)
|
||||
_H2_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CriterionResult:
|
||||
id: str
|
||||
label: str
|
||||
tier: str
|
||||
status: CriterionStatus
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoIReport:
|
||||
repo_slug: str
|
||||
tier: Tier
|
||||
core_pass: bool
|
||||
standard_pass: bool
|
||||
full_pass: bool
|
||||
criteria: list[CriterionResult] = field(default_factory=list)
|
||||
checked_at: str = field(default_factory=lambda: datetime.now(tz=timezone.utc).isoformat())
|
||||
|
||||
|
||||
def evaluate_scope_health(repo: dict) -> list[dict[str, Any]]:
|
||||
"""Return machine-readable SCOPE.md health issues for C5a/C5b/C5c.
|
||||
|
||||
The returned records intentionally mirror DoI criterion IDs while carrying
|
||||
section-level hints that downstream repo-scoping can use to refresh only
|
||||
the affected parts of SCOPE.md.
|
||||
"""
|
||||
repo_path = _resolve_path(repo)
|
||||
if not repo_path:
|
||||
return [
|
||||
{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
{
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
{
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "skip",
|
||||
"detail": "Local path unavailable",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
},
|
||||
]
|
||||
|
||||
scope_path = Path(repo_path) / "SCOPE.md"
|
||||
if not scope_path.exists():
|
||||
return [
|
||||
{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "fail",
|
||||
"detail": "SCOPE.md not found at repo root",
|
||||
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
},
|
||||
{
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "skip",
|
||||
"detail": "SCOPE.md absent",
|
||||
"missing_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": STANDARD_SCOPE_SECTIONS.copy(),
|
||||
},
|
||||
{
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "skip",
|
||||
"detail": "SCOPE.md absent",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": ["Provided Capabilities"],
|
||||
},
|
||||
]
|
||||
|
||||
text = scope_path.read_text()
|
||||
issues: list[dict[str, Any]] = [{
|
||||
"id": "C5a",
|
||||
"label": "SCOPE.md present",
|
||||
"status": "pass",
|
||||
"detail": "",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
}]
|
||||
|
||||
headings = {h.strip() for h in _H2_RE.findall(text)}
|
||||
missing_sections = [section for section in STANDARD_SCOPE_SECTIONS if section not in headings]
|
||||
if missing_sections:
|
||||
issues.append({
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "warn",
|
||||
"detail": f"Missing H2 section(s): {', '.join(missing_sections)}",
|
||||
"missing_sections": missing_sections,
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": missing_sections,
|
||||
})
|
||||
else:
|
||||
issues.append({
|
||||
"id": "C5b",
|
||||
"label": "SCOPE.md standard sections",
|
||||
"status": "pass",
|
||||
"detail": f"All {len(STANDARD_SCOPE_SECTIONS)} standard sections present",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": [],
|
||||
"needs_refresh_sections": [],
|
||||
})
|
||||
|
||||
capability_blocks = _CAPABILITY_BLOCK_RE.findall(text)
|
||||
valid_blocks = 0
|
||||
invalid_blocks: list[dict[str, Any]] = []
|
||||
for index, block in enumerate(capability_blocks, start=1):
|
||||
try:
|
||||
parsed = yaml.safe_load(block) or {}
|
||||
if isinstance(parsed, dict) and parsed.get("type") and parsed.get("title"):
|
||||
valid_blocks += 1
|
||||
else:
|
||||
invalid_blocks.append({
|
||||
"index": index,
|
||||
"reason": "Capability block must be YAML with type and title",
|
||||
})
|
||||
except yaml.YAMLError as exc:
|
||||
invalid_blocks.append({"index": index, "reason": str(exc)})
|
||||
|
||||
if valid_blocks > 0:
|
||||
issues.append({
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "pass",
|
||||
"detail": f"{valid_blocks} valid capability block(s)",
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": invalid_blocks,
|
||||
"needs_refresh_sections": [],
|
||||
})
|
||||
else:
|
||||
detail = "No fenced capability block found"
|
||||
if invalid_blocks:
|
||||
detail = "No valid capability block found"
|
||||
issues.append({
|
||||
"id": "C5c",
|
||||
"label": "SCOPE.md capability blocks",
|
||||
"status": "warn",
|
||||
"detail": detail,
|
||||
"missing_sections": [],
|
||||
"invalid_capability_blocks": invalid_blocks,
|
||||
"needs_refresh_sections": ["Provided Capabilities"],
|
||||
})
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def compute_fingerprint(
|
||||
repo: dict,
|
||||
latest_tpsc_snap_at: str | None,
|
||||
latest_goal_updated_at: str | None,
|
||||
) -> str:
|
||||
"""Compute a pipe-joined fingerprint of all inputs that affect DoI criteria.
|
||||
|
||||
If any component changes, the fingerprint changes and the cache is invalidated:
|
||||
- repo.updated_at → covers last_sbom_at, remote_url, host_paths, domain changes
|
||||
- latest_tpsc_snap_at → C9 (TPSC snapshot exists)
|
||||
- latest_goal_updated_at → C10 (active repo goal)
|
||||
- mtime of SCOPE.md, CLAUDE.md, tpsc.yaml → C5, C6, C9, C11, C12
|
||||
"""
|
||||
parts = [
|
||||
str(repo.get("updated_at") or ""),
|
||||
str(latest_tpsc_snap_at or ""),
|
||||
str(latest_goal_updated_at or ""),
|
||||
]
|
||||
repo_path = _resolve_path(repo)
|
||||
if repo_path:
|
||||
for fname in ("SCOPE.md", "CLAUDE.md", "tpsc.yaml"):
|
||||
f = Path(repo_path) / fname
|
||||
try:
|
||||
parts.append(f"{fname}:{f.stat().st_mtime:.3f}")
|
||||
except FileNotFoundError:
|
||||
parts.append(f"{fname}:absent")
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _sweep_hostname() -> str:
|
||||
"""Hostname for host_paths resolution; override when pod hostname != fleet FQDN."""
|
||||
return os.environ.get("STATE_HUB_SWEEP_HOSTNAME", "").strip() or socket.gethostname()
|
||||
|
||||
|
||||
def _resolve_path(repo: dict) -> str:
|
||||
hostname = _sweep_hostname()
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
candidates = []
|
||||
if host_paths.get(hostname):
|
||||
candidates.append(host_paths[hostname])
|
||||
if repo.get("local_path"):
|
||||
candidates.append(repo["local_path"])
|
||||
for raw in candidates:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return str(p)
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_repo_path(repo: dict) -> str:
|
||||
"""Resolve the repo path using the same host-aware rules as DoI checks."""
|
||||
return _resolve_path(repo)
|
||||
|
||||
|
||||
def _get_sync(api_base: str, path: str, params: dict | None = None) -> object:
|
||||
url = f"{api_base}{path}"
|
||||
if params:
|
||||
q = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
||||
if q:
|
||||
url = f"{url}?{q}"
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
return json.loads(r.read())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def _get(api_base: str, path: str, params: dict | None = None) -> object:
|
||||
"""Async wrapper — runs blocking urllib in a thread so the event loop stays free."""
|
||||
return await asyncio.to_thread(_get_sync, api_base, path, params)
|
||||
|
||||
|
||||
async def _run_consistency(repo_slug: str, api_base: str) -> tuple[int, int, int]:
|
||||
"""Run consistency_check.py and return (fail, warn, info) counts."""
|
||||
script = Path(__file__).parent.parent / "scripts" / "consistency_check.py"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"uv", "run", "python", str(script),
|
||||
"--repo", repo_slug,
|
||||
"--api-base", api_base,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(Path(__file__).parent.parent),
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
text = stdout.decode()
|
||||
fail = warn = info = 0
|
||||
for line in text.splitlines():
|
||||
if "Summary:" in line:
|
||||
parts = line.split("|")
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if "fail" in p:
|
||||
try: fail = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
elif "warn" in p:
|
||||
try: warn = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
elif "info" in p:
|
||||
try: info = int(p.split()[0])
|
||||
except ValueError: pass
|
||||
return fail, warn, info
|
||||
|
||||
|
||||
async def evaluate(
|
||||
repo: dict,
|
||||
api_base: str = "http://127.0.0.1:8000",
|
||||
skip_consistency: bool = False,
|
||||
prefetch: dict | None = None,
|
||||
) -> DoIReport:
|
||||
"""Evaluate all 14 DoI criteria for a repo.
|
||||
|
||||
Args:
|
||||
repo: Repo dict (slug, domain_slug, local_path, remote_url, host_paths, last_sbom_at).
|
||||
api_base: API base URL — only used when prefetch is absent.
|
||||
skip_consistency: Skip C7/C13 subprocess calls (used in summary mode).
|
||||
prefetch: Optional pre-fetched bulk data to avoid HTTP self-calls:
|
||||
{
|
||||
"domain_status": {"custodian": "active", ...}, # slug → status
|
||||
"tpsc_snap_counts": {"llm-connect": 1, ...}, # repo_slug → count
|
||||
"active_goal_counts": {"llm-connect": 0, ...}, # repo_slug → count
|
||||
}
|
||||
"""
|
||||
slug = repo.get("slug", "unknown")
|
||||
results: list[CriterionResult] = []
|
||||
|
||||
def _r(id: str, label: str, tier: str, status: CriterionStatus, detail: str = "") -> CriterionResult:
|
||||
r = CriterionResult(id=id, label=label, tier=tier, status=status, detail=detail)
|
||||
results.append(r)
|
||||
return r
|
||||
|
||||
# ── Tier 1: Core ─────────────────────────────────────────────────────────
|
||||
|
||||
# C1: registered
|
||||
_r("C1", "Registered in state-hub", "core", "pass", "Repo record exists")
|
||||
|
||||
# C2: domain assigned and active
|
||||
domain_slug = repo.get("domain_slug") or ""
|
||||
if not domain_slug:
|
||||
_r("C2", "Domain assigned", "core", "fail", "No domain_slug on repo record")
|
||||
else:
|
||||
if prefetch and "domain_status" in prefetch:
|
||||
dom_status = prefetch["domain_status"].get(domain_slug)
|
||||
else:
|
||||
d = await _get(api_base, f"/domains/{domain_slug}/")
|
||||
dom_status = d.get("status") if d else None
|
||||
if dom_status == "active":
|
||||
_r("C2", "Domain assigned", "core", "pass", f"domain: {domain_slug}")
|
||||
elif dom_status:
|
||||
_r("C2", "Domain assigned", "core", "warn", f"Domain '{domain_slug}' status: {dom_status}")
|
||||
else:
|
||||
_r("C2", "Domain assigned", "core", "fail", f"Domain '{domain_slug}' not found")
|
||||
|
||||
# C3: local path resolves
|
||||
repo_path = _resolve_path(repo)
|
||||
if repo_path:
|
||||
_r("C3", "Local path resolves", "core", "pass", repo_path)
|
||||
else:
|
||||
raw = repo.get("local_path") or "(none)"
|
||||
_r("C3", "Local path resolves", "core", "fail", f"Path not accessible: {raw}")
|
||||
|
||||
# C4: remote URL set
|
||||
remote = repo.get("remote_url") or ""
|
||||
if remote.strip():
|
||||
_r("C4", "Remote URL set", "core", "pass", remote)
|
||||
else:
|
||||
_r("C4", "Remote URL set", "core", "fail", "remote_url is empty")
|
||||
|
||||
# ── Tier 2: Standard ─────────────────────────────────────────────────────
|
||||
|
||||
# C5a/C5b/C5c: SCOPE.md structure and capability declarations
|
||||
for issue in evaluate_scope_health(repo):
|
||||
_r(issue["id"], issue["label"], "standard", issue["status"], issue["detail"])
|
||||
|
||||
# C6: CLAUDE.md
|
||||
if not repo_path:
|
||||
_r("C6", "CLAUDE.md present", "standard", "skip", "Local path unavailable")
|
||||
elif (Path(repo_path) / "CLAUDE.md").exists():
|
||||
_r("C6", "CLAUDE.md present", "standard", "pass")
|
||||
else:
|
||||
_r("C6", "CLAUDE.md present", "standard", "fail", "CLAUDE.md not found at repo root")
|
||||
|
||||
# C7: workplan convention — consistency check 0 FAIL
|
||||
if skip_consistency:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
|
||||
else:
|
||||
try:
|
||||
fail, warn, _ = await _run_consistency(slug, api_base)
|
||||
if fail == 0:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "pass", f"consistency: {fail} fail / {warn} warn")
|
||||
else:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "fail", f"consistency: {fail} fail / {warn} warn")
|
||||
except Exception as e:
|
||||
_r("C7", "Workplan convention (0 FAIL)", "standard", "skip", f"Could not run consistency check: {e}")
|
||||
|
||||
# C8: SBOM ingested
|
||||
last_sbom = repo.get("last_sbom_at")
|
||||
if last_sbom:
|
||||
_r("C8", "SBOM ingested", "standard", "pass", f"last ingested: {last_sbom[:10]}")
|
||||
else:
|
||||
_r("C8", "SBOM ingested", "standard", "fail", "last_sbom_at not set — run make ingest-sbom")
|
||||
|
||||
# C9: TPSC declared (tpsc.yaml present + snapshot exists)
|
||||
tpsc_file_ok = repo_path and (Path(repo_path) / "tpsc.yaml").exists()
|
||||
if prefetch and "tpsc_snap_counts" in prefetch:
|
||||
has_snap = (prefetch["tpsc_snap_counts"].get(slug, 0) > 0)
|
||||
snap_count = prefetch["tpsc_snap_counts"].get(slug, 0)
|
||||
else:
|
||||
tpsc_snaps = await _get(api_base, "/tpsc/snapshots/", {"repo_slug": slug}) or []
|
||||
has_snap = len(tpsc_snaps) > 0
|
||||
snap_count = len(tpsc_snaps)
|
||||
if not repo_path:
|
||||
_r("C9", "TPSC declared", "standard", "skip", "Local path unavailable")
|
||||
elif tpsc_file_ok and has_snap:
|
||||
_r("C9", "TPSC declared", "standard", "pass", f"{snap_count} snapshot(s)")
|
||||
elif tpsc_file_ok and not has_snap:
|
||||
_r("C9", "TPSC declared", "standard", "warn", "tpsc.yaml exists but not yet ingested — run make ingest-tpsc")
|
||||
elif not tpsc_file_ok:
|
||||
_r("C9", "TPSC declared", "standard", "fail", "tpsc.yaml missing at repo root")
|
||||
|
||||
# ── Tier 3: Full ─────────────────────────────────────────────────────────
|
||||
|
||||
# C10: active repo goal
|
||||
if prefetch and "active_goal_counts" in prefetch:
|
||||
active_goal_count = prefetch["active_goal_counts"].get(slug, 0)
|
||||
else:
|
||||
goals = await _get(api_base, "/repo-goals/", {"repo_slug": slug}) or []
|
||||
active_goal_count = sum(1 for g in goals if g.get("status") == "active")
|
||||
if active_goal_count > 0:
|
||||
_r("C10", "Active repo goal", "full", "pass", f"{active_goal_count} active goal(s)")
|
||||
else:
|
||||
_r("C10", "Active repo goal", "full", "fail", "No active repo goal — create one with create_repo_goal()")
|
||||
|
||||
# C11: Provided Capabilities declared in SCOPE.md
|
||||
if not repo_path:
|
||||
_r("C11", "Provided Capabilities declared", "full", "skip", "Local path unavailable")
|
||||
else:
|
||||
scope = Path(repo_path) / "SCOPE.md"
|
||||
if not scope.exists():
|
||||
_r("C11", "Provided Capabilities declared", "full", "skip", "SCOPE.md absent")
|
||||
else:
|
||||
text = scope.read_text()
|
||||
has_cap_block = "```capability" in text
|
||||
has_none_explicit = "## Provided Capabilities" in text and (
|
||||
"none" in text.lower().split("## provided capabilities")[-1][:200]
|
||||
or "no capabilities" in text.lower().split("## provided capabilities")[-1][:200]
|
||||
)
|
||||
if has_cap_block:
|
||||
_r("C11", "Provided Capabilities declared", "full", "pass", "capability block(s) found in SCOPE.md")
|
||||
elif has_none_explicit:
|
||||
_r("C11", "Provided Capabilities declared", "full", "pass", "Explicitly declared none in SCOPE.md")
|
||||
elif "## Provided Capabilities" in text:
|
||||
_r("C11", "Provided Capabilities declared", "full", "warn",
|
||||
"Section present but no capability block or explicit none — add blocks or state 'none'")
|
||||
else:
|
||||
_r("C11", "Provided Capabilities declared", "full", "fail",
|
||||
"No '## Provided Capabilities' section in SCOPE.md")
|
||||
|
||||
# C12: agents template applied (CLAUDE.md mentions kaizen)
|
||||
if not repo_path:
|
||||
_r("C12", "Agents template applied", "full", "skip", "Local path unavailable")
|
||||
else:
|
||||
claude_md = Path(repo_path) / "CLAUDE.md"
|
||||
if not claude_md.exists():
|
||||
_r("C12", "Agents template applied", "full", "skip", "CLAUDE.md absent")
|
||||
else:
|
||||
text = claude_md.read_text()
|
||||
if "get_kaizen_agent" in text or "kaizen" in text.lower():
|
||||
_r("C12", "Agents template applied", "full", "pass")
|
||||
else:
|
||||
_r("C12", "Agents template applied", "full", "fail",
|
||||
"CLAUDE.md has no kaizen agent reference")
|
||||
|
||||
# C13: consistency check clean (0 FAIL, 0 WARN — C-12 exempt)
|
||||
if skip_consistency:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", "Not checked in summary mode — use /repos/{slug}/doi for full check")
|
||||
else:
|
||||
try:
|
||||
fail, warn, _ = await _run_consistency(slug, api_base)
|
||||
if fail == 0 and warn == 0:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "pass")
|
||||
elif fail == 0 and warn > 0:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "warn",
|
||||
f"{warn} warn(s) — C-12 legacy tasks may be exempt")
|
||||
else:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "fail",
|
||||
f"{fail} fail(s), {warn} warn(s)")
|
||||
except Exception as e:
|
||||
_r("C13", "Consistency check clean (0 FAIL/WARN)", "full", "skip", f"Could not run: {e}")
|
||||
|
||||
# C14: host paths registered
|
||||
host_paths = repo.get("host_paths") or {}
|
||||
if host_paths:
|
||||
_r("C14", "Host paths registered", "full", "pass",
|
||||
f"{len(host_paths)} host(s): {', '.join(host_paths.keys())}")
|
||||
else:
|
||||
_r("C14", "Host paths registered", "full", "fail",
|
||||
"host_paths empty — run update_repo_path() for each active machine")
|
||||
|
||||
# ── Compute tier ─────────────────────────────────────────────────────────
|
||||
by_id = {r.id: r for r in results}
|
||||
|
||||
def _tier_pass(ids: set[str]) -> bool:
|
||||
return all(by_id[i].status in ("pass", "warn") for i in ids if i in by_id)
|
||||
|
||||
core_pass = _tier_pass(CORE_IDS)
|
||||
standard_pass = core_pass and _tier_pass(STANDARD_IDS)
|
||||
full_pass = standard_pass and _tier_pass(FULL_IDS)
|
||||
|
||||
if full_pass:
|
||||
tier: Tier = "full"
|
||||
elif standard_pass:
|
||||
tier = "standard"
|
||||
elif core_pass:
|
||||
tier = "core"
|
||||
else:
|
||||
tier = "none"
|
||||
|
||||
return DoIReport(
|
||||
repo_slug=slug,
|
||||
tier=tier,
|
||||
core_pass=core_pass,
|
||||
standard_pass=standard_pass,
|
||||
full_pass=full_pass,
|
||||
criteria=results,
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""State Hub edge relay and durable outbox helpers."""
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from api.services.write_idempotency import route_class_for
|
||||
|
||||
DEFAULT_OUTBOX_PATH = Path(os.environ.get("STATEHUB_OUTBOX_PATH", "~/.statehub/edge-outbox.sqlite3")).expanduser()
|
||||
MAX_PAYLOAD_BYTES = 64 * 1024
|
||||
SECRET_FIELD_NAMES = {
|
||||
"authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"bearer_token",
|
||||
"client_secret",
|
||||
"private_key",
|
||||
"credential",
|
||||
"credentials",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutboxEnvelope:
|
||||
id: str
|
||||
idempotency_key: str
|
||||
method: str
|
||||
path: str
|
||||
body: dict[str, Any] | list[Any] | None
|
||||
route_class: str
|
||||
source_agent: str | None
|
||||
source_host: str | None
|
||||
repo_slug: str | None
|
||||
session_id: str | None
|
||||
observed_revision: dict[str, Any] | None
|
||||
status: str
|
||||
attempt_count: int
|
||||
next_retry_at: str | None
|
||||
last_error: str | None
|
||||
response_status: int | None
|
||||
response_body: dict[str, Any] | list[Any] | str | None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
acked_at: str | None
|
||||
|
||||
|
||||
class PayloadRejected(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def default_outbox_path() -> Path:
|
||||
return DEFAULT_OUTBOX_PATH
|
||||
|
||||
|
||||
def scrub_payload(value: Any) -> Any:
|
||||
if isinstance(value, dict):
|
||||
scrubbed: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
normalized = str(key).lower().replace("-", "_")
|
||||
if normalized in SECRET_FIELD_NAMES:
|
||||
scrubbed[key] = "[redacted]"
|
||||
else:
|
||||
scrubbed[key] = scrub_payload(item)
|
||||
return scrubbed
|
||||
if isinstance(value, list):
|
||||
return [scrub_payload(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _json_loads(raw: str | None) -> Any:
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _json_dumps(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
return datetime.fromisoformat(value)
|
||||
|
||||
|
||||
class OutboxStore:
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
self.path = Path(path).expanduser() if path is not None else default_outbox_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
self._chmod_private()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS outbox_envelopes (
|
||||
id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
method TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
body_json TEXT,
|
||||
route_class TEXT NOT NULL,
|
||||
source_agent TEXT,
|
||||
source_host TEXT,
|
||||
repo_slug TEXT,
|
||||
session_id TEXT,
|
||||
observed_revision_json TEXT,
|
||||
status TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_retry_at TEXT,
|
||||
last_error TEXT,
|
||||
response_status INTEGER,
|
||||
response_body_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
acked_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_status ON outbox_envelopes(status)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS ix_outbox_next_retry ON outbox_envelopes(next_retry_at)")
|
||||
conn.commit()
|
||||
|
||||
def _chmod_private(self) -> None:
|
||||
try:
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
method: str,
|
||||
path: str,
|
||||
body: Any,
|
||||
idempotency_key: str | None = None,
|
||||
source_agent: str | None = None,
|
||||
source_host: str | None = None,
|
||||
repo_slug: str | None = None,
|
||||
session_id: str | None = None,
|
||||
observed_revision: dict[str, Any] | None = None,
|
||||
) -> OutboxEnvelope:
|
||||
route_class = route_class_for(method, path)
|
||||
if route_class is None:
|
||||
raise PayloadRejected(f"{method.upper()} {path} is not queueable")
|
||||
scrubbed = scrub_payload(body)
|
||||
encoded = _json_dumps(scrubbed)
|
||||
if encoded is not None and len(encoded.encode("utf-8")) > MAX_PAYLOAD_BYTES:
|
||||
raise PayloadRejected("payload exceeds offline outbox size limit")
|
||||
now = utcnow()
|
||||
envelope_id = str(uuid.uuid4())
|
||||
key = idempotency_key or f"statehub-edge:{envelope_id}"
|
||||
method_upper = method.upper()
|
||||
with self._connect() as conn:
|
||||
if route_class == "replace":
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE outbox_envelopes
|
||||
SET status = 'cancelled', updated_at = ?, last_error = ?
|
||||
WHERE status = 'queued'
|
||||
AND route_class = 'replace'
|
||||
AND method = ?
|
||||
AND path = ?
|
||||
""",
|
||||
(now, f"superseded by {envelope_id}", method_upper, path),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO outbox_envelopes (
|
||||
id, idempotency_key, method, path, body_json, route_class,
|
||||
source_agent, source_host, repo_slug, session_id,
|
||||
observed_revision_json, status, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)
|
||||
""",
|
||||
(
|
||||
envelope_id,
|
||||
key,
|
||||
method_upper,
|
||||
path,
|
||||
encoded,
|
||||
route_class,
|
||||
source_agent,
|
||||
source_host,
|
||||
repo_slug,
|
||||
session_id,
|
||||
_json_dumps(observed_revision),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return self.get(envelope_id)
|
||||
|
||||
def get(self, envelope_id: str) -> OutboxEnvelope:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM outbox_envelopes WHERE id = ?", (envelope_id,)).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(envelope_id)
|
||||
return self._row_to_envelope(row)
|
||||
|
||||
def list(self, *, status: str | None = None, limit: int = 100) -> list[OutboxEnvelope]:
|
||||
with self._connect() as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM outbox_envelopes WHERE status = ? ORDER BY created_at LIMIT ?",
|
||||
(status, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM outbox_envelopes ORDER BY created_at LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [self._row_to_envelope(row) for row in rows]
|
||||
|
||||
def due(self, *, limit: int = 50) -> list[OutboxEnvelope]:
|
||||
now = utcnow()
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM outbox_envelopes
|
||||
WHERE status = 'queued' AND (next_retry_at IS NULL OR next_retry_at <= ?)
|
||||
ORDER BY created_at
|
||||
LIMIT ?
|
||||
""",
|
||||
(now, limit),
|
||||
).fetchall()
|
||||
return [self._row_to_envelope(row) for row in rows]
|
||||
|
||||
def summary(self) -> dict[str, Any]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT status, COUNT(*) AS count, MIN(created_at) AS oldest FROM outbox_envelopes GROUP BY status"
|
||||
).fetchall()
|
||||
by_status = {row["status"]: row["count"] for row in rows}
|
||||
oldest_pending = None
|
||||
for row in rows:
|
||||
if row["status"] in {"queued", "sending", "conflict"} and row["oldest"]:
|
||||
oldest_pending = min(filter(None, [oldest_pending, row["oldest"]])) if oldest_pending else row["oldest"]
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"by_status": by_status,
|
||||
"pending_count": sum(by_status.get(status, 0) for status in ("queued", "sending")),
|
||||
"conflict_count": by_status.get("conflict", 0),
|
||||
"oldest_pending_at": oldest_pending,
|
||||
}
|
||||
|
||||
def mark_sending(self, envelope_id: str) -> None:
|
||||
self._update(envelope_id, status="sending", updated_at=utcnow())
|
||||
|
||||
def mark_acked(self, envelope_id: str, *, response_status: int, response_body: Any) -> None:
|
||||
now = utcnow()
|
||||
self._update(
|
||||
envelope_id,
|
||||
status="acked",
|
||||
response_status=response_status,
|
||||
response_body_json=_json_dumps(response_body),
|
||||
updated_at=now,
|
||||
acked_at=now,
|
||||
last_error=None,
|
||||
next_retry_at=None,
|
||||
)
|
||||
|
||||
def mark_conflict(self, envelope_id: str, *, response_status: int, response_body: Any) -> None:
|
||||
self._update(
|
||||
envelope_id,
|
||||
status="conflict",
|
||||
response_status=response_status,
|
||||
response_body_json=_json_dumps(response_body),
|
||||
updated_at=utcnow(),
|
||||
last_error="conflict",
|
||||
)
|
||||
|
||||
def mark_dead(self, envelope_id: str, *, error: str, response_status: int | None = None, response_body: Any = None) -> None:
|
||||
self._update(
|
||||
envelope_id,
|
||||
status="dead",
|
||||
response_status=response_status,
|
||||
response_body_json=_json_dumps(response_body),
|
||||
updated_at=utcnow(),
|
||||
last_error=error,
|
||||
)
|
||||
|
||||
def mark_retry(self, envelope_id: str, *, error: str, attempt_count: int) -> None:
|
||||
delay_seconds = min(3600, 2 ** min(attempt_count, 10))
|
||||
next_retry = datetime.now(tz=timezone.utc) + timedelta(seconds=delay_seconds)
|
||||
self._update(
|
||||
envelope_id,
|
||||
status="queued",
|
||||
attempt_count=attempt_count,
|
||||
next_retry_at=next_retry.isoformat(),
|
||||
updated_at=utcnow(),
|
||||
last_error=error[:500],
|
||||
)
|
||||
|
||||
def retry(self, envelope_id: str) -> None:
|
||||
self._update(envelope_id, status="queued", next_retry_at=None, updated_at=utcnow())
|
||||
|
||||
def cancel(self, envelope_id: str) -> None:
|
||||
self._update(envelope_id, status="cancelled", updated_at=utcnow())
|
||||
|
||||
def export(self, *, status: str | None = None, limit: int = 1000) -> list[dict[str, Any]]:
|
||||
return [envelope.__dict__ for envelope in self.list(status=status, limit=limit)]
|
||||
|
||||
def _update(self, envelope_id: str, **values: Any) -> None:
|
||||
assignments = ", ".join(f"{key} = ?" for key in values)
|
||||
params = list(values.values()) + [envelope_id]
|
||||
with self._connect() as conn:
|
||||
conn.execute(f"UPDATE outbox_envelopes SET {assignments} WHERE id = ?", params)
|
||||
conn.commit()
|
||||
|
||||
def _row_to_envelope(self, row: sqlite3.Row) -> OutboxEnvelope:
|
||||
return OutboxEnvelope(
|
||||
id=row["id"],
|
||||
idempotency_key=row["idempotency_key"],
|
||||
method=row["method"],
|
||||
path=row["path"],
|
||||
body=_json_loads(row["body_json"]),
|
||||
route_class=row["route_class"],
|
||||
source_agent=row["source_agent"],
|
||||
source_host=row["source_host"],
|
||||
repo_slug=row["repo_slug"],
|
||||
session_id=row["session_id"],
|
||||
observed_revision=_json_loads(row["observed_revision_json"]),
|
||||
status=row["status"],
|
||||
attempt_count=row["attempt_count"],
|
||||
next_retry_at=row["next_retry_at"],
|
||||
last_error=row["last_error"],
|
||||
response_status=row["response_status"],
|
||||
response_body=_json_loads(row["response_body_json"]),
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
acked_at=row["acked_at"],
|
||||
)
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
DEFAULT_READ_CACHE_PATH = Path(
|
||||
os.environ.get("STATEHUB_READ_CACHE_PATH", "~/.statehub/edge-read-cache.sqlite3")
|
||||
).expanduser()
|
||||
MAX_CACHE_ENTRY_BYTES = 1024 * 1024
|
||||
|
||||
CACHEABLE_GET_PREFIXES = (
|
||||
"/state/",
|
||||
"/workplans/",
|
||||
"/messages/",
|
||||
"/decisions/",
|
||||
"/tasks/",
|
||||
"/progress/",
|
||||
"/sbom/",
|
||||
"/repos",
|
||||
"/legacy-meter/",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadCacheEntry:
|
||||
cache_key: str
|
||||
status_code: int
|
||||
content: bytes
|
||||
content_type: str | None
|
||||
cached_at: str
|
||||
|
||||
|
||||
def default_read_cache_path() -> Path:
|
||||
return DEFAULT_READ_CACHE_PATH
|
||||
|
||||
|
||||
def utcnow() -> str:
|
||||
return datetime.now(tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
def build_cache_key(method: str, path: str, query_items: list[tuple[str, str]]) -> str:
|
||||
normalized_path = path if path.startswith("/") else f"/{path}"
|
||||
query = urlencode(sorted(query_items))
|
||||
if query:
|
||||
return f"{method.upper()}:{normalized_path}?{query}"
|
||||
return f"{method.upper()}:{normalized_path}"
|
||||
|
||||
|
||||
def is_cacheable_get(path: str) -> bool:
|
||||
normalized = path if path.startswith("/") else f"/{path}"
|
||||
if normalized.startswith("/edge/"):
|
||||
return False
|
||||
return any(normalized.startswith(prefix) for prefix in CACHEABLE_GET_PREFIXES)
|
||||
|
||||
|
||||
class ReadCacheStore:
|
||||
def __init__(self, path: str | Path | None = None) -> None:
|
||||
self.path = Path(path).expanduser() if path is not None else default_read_cache_path()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_db()
|
||||
self._chmod_private()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_db(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS read_cache_entries (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
status_code INTEGER NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
content_type TEXT,
|
||||
cached_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_read_cache_cached_at ON read_cache_entries(cached_at)"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def _chmod_private(self) -> None:
|
||||
try:
|
||||
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def put(
|
||||
self,
|
||||
*,
|
||||
cache_key: str,
|
||||
status_code: int,
|
||||
content: bytes,
|
||||
content_type: str | None,
|
||||
) -> None:
|
||||
if len(content) > MAX_CACHE_ENTRY_BYTES:
|
||||
return
|
||||
now = utcnow()
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO read_cache_entries (
|
||||
cache_key, status_code, content, content_type, cached_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
status_code = excluded.status_code,
|
||||
content = excluded.content,
|
||||
content_type = excluded.content_type,
|
||||
cached_at = excluded.cached_at
|
||||
""",
|
||||
(cache_key, status_code, content, content_type, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def get(self, cache_key: str) -> ReadCacheEntry | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM read_cache_entries WHERE cache_key = ?",
|
||||
(cache_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return ReadCacheEntry(
|
||||
cache_key=row["cache_key"],
|
||||
status_code=row["status_code"],
|
||||
content=row["content"],
|
||||
content_type=row["content_type"],
|
||||
cached_at=row["cached_at"],
|
||||
)
|
||||
|
||||
def summary(self) -> dict[str, int | str | None]:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS entry_count,
|
||||
MIN(cached_at) AS oldest_cached_at
|
||||
FROM read_cache_entries
|
||||
"""
|
||||
).fetchone()
|
||||
return {
|
||||
"entry_count": int(row["entry_count"] or 0),
|
||||
"oldest_cached_at": row["oldest_cached_at"],
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from api.edge.outbox import OutboxEnvelope, OutboxStore, PayloadRejected, default_outbox_path
|
||||
from api.edge.read_cache import (
|
||||
ReadCacheStore,
|
||||
build_cache_key,
|
||||
default_read_cache_path,
|
||||
is_cacheable_get,
|
||||
)
|
||||
from api.services.write_idempotency import route_class_for
|
||||
|
||||
HOP_BY_HOP_HEADERS = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
}
|
||||
|
||||
|
||||
def _safe_response_headers(headers: httpx.Headers) -> dict[str, str]:
|
||||
return {key: value for key, value in headers.items() if key.lower() not in HOP_BY_HOP_HEADERS}
|
||||
|
||||
|
||||
def _body_summary(response: httpx.Response) -> Any:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return {"text": response.text[:500]}
|
||||
|
||||
|
||||
def queued_receipt(envelope: OutboxEnvelope, upstream_error: str) -> dict[str, Any]:
|
||||
return {
|
||||
"queued": True,
|
||||
"outbox_id": envelope.id,
|
||||
"idempotency_key": envelope.idempotency_key,
|
||||
"upstream": "unreachable",
|
||||
"upstream_error": upstream_error,
|
||||
"route_class": envelope.route_class,
|
||||
}
|
||||
|
||||
|
||||
async def replay_pending(
|
||||
store: OutboxStore,
|
||||
*,
|
||||
upstream_url: str,
|
||||
limit: int = 50,
|
||||
timeout: float = 10.0,
|
||||
) -> dict[str, int]:
|
||||
counts = {"sent": 0, "acked": 0, "conflict": 0, "retry": 0, "dead": 0}
|
||||
async with httpx.AsyncClient(base_url=upstream_url.rstrip("/"), timeout=timeout) as client:
|
||||
for envelope in store.due(limit=limit):
|
||||
counts["sent"] += 1
|
||||
store.mark_sending(envelope.id)
|
||||
try:
|
||||
response = await client.request(
|
||||
envelope.method,
|
||||
envelope.path,
|
||||
json=envelope.body,
|
||||
headers={
|
||||
"Idempotency-Key": envelope.idempotency_key,
|
||||
"X-StateHub-Source-Agent": envelope.source_agent or "statehub-edge",
|
||||
"X-StateHub-Source-Host": envelope.source_host or socket.gethostname(),
|
||||
},
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
counts["retry"] += 1
|
||||
store.mark_retry(envelope.id, error=str(exc), attempt_count=envelope.attempt_count + 1)
|
||||
continue
|
||||
|
||||
response_body = _body_summary(response)
|
||||
if response.status_code == 409:
|
||||
counts["conflict"] += 1
|
||||
store.mark_conflict(envelope.id, response_status=response.status_code, response_body=response_body)
|
||||
elif 200 <= response.status_code < 300:
|
||||
counts["acked"] += 1
|
||||
store.mark_acked(envelope.id, response_status=response.status_code, response_body=response_body)
|
||||
elif response.status_code >= 500:
|
||||
counts["retry"] += 1
|
||||
store.mark_retry(
|
||||
envelope.id,
|
||||
error=f"HTTP {response.status_code}: {response.text[:300]}",
|
||||
attempt_count=envelope.attempt_count + 1,
|
||||
)
|
||||
else:
|
||||
counts["dead"] += 1
|
||||
store.mark_dead(
|
||||
envelope.id,
|
||||
error=f"HTTP {response.status_code}: not retryable",
|
||||
response_status=response.status_code,
|
||||
response_body=response_body,
|
||||
)
|
||||
return counts
|
||||
|
||||
|
||||
def _cache_age_seconds(cached_at: str) -> int:
|
||||
try:
|
||||
cached = datetime.fromisoformat(cached_at)
|
||||
except ValueError:
|
||||
return 0
|
||||
if cached.tzinfo is None:
|
||||
cached = cached.replace(tzinfo=timezone.utc)
|
||||
return max(0, int((datetime.now(timezone.utc) - cached).total_seconds()))
|
||||
|
||||
|
||||
def _stale_cache_headers(cached_at: str) -> dict[str, str]:
|
||||
return {
|
||||
"X-StateHub-Edge-Cache": "stale",
|
||||
"X-StateHub-Edge-Cache-Age": str(_cache_age_seconds(cached_at)),
|
||||
}
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
upstream_url: str | None = None,
|
||||
outbox_path: str | None = None,
|
||||
read_cache_path: str | None = None,
|
||||
timeout: float = 10.0,
|
||||
) -> FastAPI:
|
||||
upstream = (upstream_url or os.environ.get("STATEHUB_UPSTREAM_URL") or os.environ.get("API_BASE") or "http://127.0.0.1:8000").rstrip("/")
|
||||
store_path = outbox_path or default_outbox_path()
|
||||
cache_path = read_cache_path or str(default_read_cache_path())
|
||||
store_instance: OutboxStore | None = None
|
||||
cache_instance: ReadCacheStore | None = None
|
||||
|
||||
def get_store() -> OutboxStore:
|
||||
nonlocal store_instance
|
||||
if store_instance is None:
|
||||
store_instance = OutboxStore(store_path)
|
||||
return store_instance
|
||||
|
||||
def get_cache() -> ReadCacheStore:
|
||||
nonlocal cache_instance
|
||||
if cache_instance is None:
|
||||
cache_instance = ReadCacheStore(cache_path)
|
||||
return cache_instance
|
||||
|
||||
app = FastAPI(title="State Hub Edge Relay", version="0.1.0")
|
||||
|
||||
@app.get("/edge/health")
|
||||
async def edge_health() -> dict[str, Any]:
|
||||
reachable = False
|
||||
error = None
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=upstream, timeout=2.0) as client:
|
||||
response = await client.get("/state/health")
|
||||
reachable = response.status_code < 500
|
||||
except httpx.HTTPError as exc:
|
||||
error = str(exc)
|
||||
return {
|
||||
"status": "ok",
|
||||
"upstream": upstream,
|
||||
"upstream_reachable": reachable,
|
||||
"upstream_error": error,
|
||||
"outbox": get_store().summary(),
|
||||
"read_cache": get_cache().summary(),
|
||||
}
|
||||
|
||||
@app.post("/edge/replay")
|
||||
async def edge_replay(limit: int = 50) -> dict[str, int]:
|
||||
return await replay_pending(get_store(), upstream_url=upstream, limit=limit, timeout=timeout)
|
||||
|
||||
@app.api_route("/{path:path}", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
|
||||
async def proxy(path: str, request: Request) -> Response:
|
||||
api_path = "/" + path
|
||||
body: Any = None
|
||||
if request.method in {"POST", "PATCH", "PUT"}:
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
|
||||
headers = {}
|
||||
if idempotency_key := request.headers.get("idempotency-key"):
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
if request.headers.get("content-type"):
|
||||
headers["Content-Type"] = request.headers["content-type"]
|
||||
|
||||
cache_key = None
|
||||
if request.method == "GET" and is_cacheable_get(api_path):
|
||||
cache_key = build_cache_key(
|
||||
request.method,
|
||||
api_path,
|
||||
list(request.query_params.multi_items()),
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=upstream, timeout=timeout) as client:
|
||||
response = await client.request(
|
||||
request.method,
|
||||
api_path,
|
||||
params=request.query_params,
|
||||
json=body if body is not None else None,
|
||||
headers=headers,
|
||||
)
|
||||
if cache_key is not None and 200 <= response.status_code < 300:
|
||||
get_cache().put(
|
||||
cache_key=cache_key,
|
||||
status_code=response.status_code,
|
||||
content=response.content,
|
||||
content_type=response.headers.get("content-type"),
|
||||
)
|
||||
response_headers = _safe_response_headers(response.headers)
|
||||
if cache_key is not None:
|
||||
response_headers["X-StateHub-Edge-Cache"] = "hit"
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=response_headers,
|
||||
media_type=response.headers.get("content-type"),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
if cache_key is not None:
|
||||
cached = get_cache().get(cache_key)
|
||||
if cached is not None:
|
||||
stale_headers = _stale_cache_headers(cached.cached_at)
|
||||
if cached.content_type:
|
||||
stale_headers["Content-Type"] = cached.content_type
|
||||
return Response(
|
||||
content=cached.content,
|
||||
status_code=cached.status_code,
|
||||
headers=stale_headers,
|
||||
media_type=cached.content_type,
|
||||
)
|
||||
|
||||
route_class = route_class_for(request.method, api_path)
|
||||
if route_class is None or request.method not in {"POST", "PATCH"}:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "upstream unreachable and route is not queueable",
|
||||
"method": request.method,
|
||||
"path": api_path,
|
||||
"upstream": upstream,
|
||||
"detail": str(exc),
|
||||
},
|
||||
)
|
||||
try:
|
||||
envelope = get_store().enqueue(
|
||||
method=request.method,
|
||||
path=api_path,
|
||||
body=body,
|
||||
idempotency_key=request.headers.get("idempotency-key"),
|
||||
source_agent=request.headers.get("x-statehub-source-agent"),
|
||||
source_host=request.headers.get("x-statehub-source-host") or socket.gethostname(),
|
||||
repo_slug=request.headers.get("x-statehub-repo-slug"),
|
||||
session_id=request.headers.get("x-statehub-session-id"),
|
||||
observed_revision=None,
|
||||
)
|
||||
except PayloadRejected as reject:
|
||||
return JSONResponse(status_code=422, content={"error": str(reject)})
|
||||
return JSONResponse(status_code=202, content=queued_receipt(envelope, str(exc)))
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
from api.events.envelope import EventEnvelope
|
||||
from api.events.nats_publisher import (
|
||||
publish_event,
|
||||
publish_event_sync,
|
||||
shutdown_publisher,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EventEnvelope",
|
||||
"publish_event",
|
||||
"publish_event_sync",
|
||||
"shutdown_publisher",
|
||||
]
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"""EventEnvelope — schema for state-hub lifecycle events published to NATS.
|
||||
|
||||
Mirrors the EventEnvelope contract defined in activity-core
|
||||
(`src/activity_core/models.py`). The state-hub publishes; activity-core
|
||||
consumes and routes to ActivityDefinitions.
|
||||
|
||||
Subject naming convention (see docs/nats-event-subjects.md):
|
||||
|
||||
org.statehub.{noun}.{verb}
|
||||
|
||||
Examples:
|
||||
org.statehub.repo.registered
|
||||
org.statehub.workstream.completed
|
||||
org.statehub.decision.resolved
|
||||
org.statehub.domain.goal.activated
|
||||
org.statehub.task.stale
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
PUBLISHER = "state-hub"
|
||||
|
||||
|
||||
class EventEnvelope(BaseModel):
|
||||
"""Standard envelope shared with activity-core. Do not break compatibility.
|
||||
|
||||
All inbound events on activity-core's side are normalised into this shape.
|
||||
"""
|
||||
|
||||
id: str = Field(description="UUID v4 — stable unique ID for deduplication.")
|
||||
type: str = Field(description="Dot-namespaced event type, e.g. 'org.statehub.repo.registered'.")
|
||||
version: str = Field(default="1.0", description="Schema version string.")
|
||||
timestamp: datetime = Field(description="When the event occurred (UTC).")
|
||||
publisher: str = Field(default=PUBLISHER, description="Originating service.")
|
||||
attributes: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Event-specific attributes; structure varies by event type.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def new(cls, event_type: str, attributes: dict[str, Any] | None = None) -> "EventEnvelope":
|
||||
"""Construct an envelope with a fresh UUID and current UTC timestamp."""
|
||||
return cls(
|
||||
id=str(uuid.uuid4()),
|
||||
type=event_type,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
publisher=PUBLISHER,
|
||||
attributes=attributes or {},
|
||||
)
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
"""NATS JetStream publisher for state-hub lifecycle events.
|
||||
|
||||
Design:
|
||||
- One process-wide publisher (`_Publisher` singleton).
|
||||
- Connects lazily on first publish; reuses the connection thereafter.
|
||||
- When ``NATS_URL`` is unset or empty, every publish is a logged no-op
|
||||
so the state hub remains usable in environments without NATS.
|
||||
- All publishes are fire-and-forget from the caller's perspective.
|
||||
Failures are logged but never raise — losing a lifecycle event must
|
||||
never break the API request that triggered it.
|
||||
|
||||
Stream + subject conventions live in ``docs/nats-event-subjects.md``.
|
||||
Envelope schema lives in :mod:`api.events.envelope`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from api.events.envelope import EventEnvelope
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover — import-only for typing
|
||||
from nats.aio.client import Client as NATSClient
|
||||
from nats.js.client import JetStreamContext
|
||||
|
||||
logger = logging.getLogger("state_hub.events.nats")
|
||||
|
||||
_STREAM_NAME = "ACTIVITY_EVENTS"
|
||||
_STREAM_SUBJECT_PATTERN = "org.>"
|
||||
|
||||
|
||||
def _nats_url() -> str | None:
|
||||
"""Resolve NATS_URL at call time so tests / configs can override it."""
|
||||
url = os.environ.get("NATS_URL", "").strip()
|
||||
return url or None
|
||||
|
||||
|
||||
class _Publisher:
|
||||
"""Singleton holding the live NATS connection + JetStream context."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._nc: "NATSClient | None" = None
|
||||
self._js: "JetStreamContext | None" = None
|
||||
self._connect_lock = asyncio.Lock()
|
||||
self._ensured_stream = False
|
||||
|
||||
async def _connect(self, url: str) -> None:
|
||||
# Imported inside the method so module import works without the dep.
|
||||
import nats
|
||||
import nats.js.api
|
||||
|
||||
async with self._connect_lock:
|
||||
if self._nc is not None and self._nc.is_connected:
|
||||
return
|
||||
self._nc = await nats.connect(url, connect_timeout=2)
|
||||
self._js = self._nc.jetstream()
|
||||
logger.info("nats: connected to %s", url)
|
||||
|
||||
if not self._ensured_stream:
|
||||
try:
|
||||
await self._js.find_stream_name_by_subject("org.statehub.repo.registered")
|
||||
self._ensured_stream = True
|
||||
except Exception:
|
||||
try:
|
||||
await self._js.add_stream(
|
||||
nats.js.api.StreamConfig(
|
||||
name=_STREAM_NAME,
|
||||
subjects=[_STREAM_SUBJECT_PATTERN],
|
||||
)
|
||||
)
|
||||
logger.info("nats: created JetStream stream %r", _STREAM_NAME)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.warning("nats: could not ensure stream %r: %s", _STREAM_NAME, exc)
|
||||
self._ensured_stream = True
|
||||
|
||||
async def publish(self, subject: str, envelope: EventEnvelope) -> None:
|
||||
url = _nats_url()
|
||||
if url is None:
|
||||
logger.debug("nats: NATS_URL unset — skipping publish %s (id=%s)", subject, envelope.id)
|
||||
return
|
||||
|
||||
try:
|
||||
if self._nc is None or not self._nc.is_connected:
|
||||
await self._connect(url)
|
||||
assert self._js is not None
|
||||
payload = envelope.model_dump_json().encode()
|
||||
ack = await self._js.publish(subject, payload)
|
||||
logger.info(
|
||||
"nats: published %s id=%s stream=%s seq=%s",
|
||||
subject,
|
||||
envelope.id,
|
||||
getattr(ack, "stream", "?"),
|
||||
getattr(ack, "seq", "?"),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("nats: publish failed %s id=%s err=%s", subject, envelope.id, exc)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
if self._nc is not None:
|
||||
try:
|
||||
await self._nc.drain()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
self._nc = None
|
||||
self._js = None
|
||||
self._ensured_stream = False
|
||||
|
||||
|
||||
_PUBLISHER = _Publisher()
|
||||
|
||||
|
||||
async def publish_event(subject: str, envelope: EventEnvelope) -> None:
|
||||
"""Publish ``envelope`` on ``subject``. Logs but never raises on failure.
|
||||
|
||||
No-op when ``NATS_URL`` is not configured.
|
||||
"""
|
||||
await _PUBLISHER.publish(subject, envelope)
|
||||
|
||||
|
||||
def publish_event_sync(subject: str, envelope: EventEnvelope) -> None:
|
||||
"""Fire-and-forget variant for sync callers (scripts, cron jobs).
|
||||
|
||||
Runs the publish in a short-lived event loop. Intended for one-shot CLI
|
||||
callers that aren't already inside an async context. Server code should
|
||||
prefer :func:`publish_event` with ``asyncio.create_task``.
|
||||
"""
|
||||
try:
|
||||
asyncio.run(publish_event(subject, envelope))
|
||||
except RuntimeError:
|
||||
# Already inside a running loop — schedule and forget.
|
||||
asyncio.get_event_loop().create_task(publish_event(subject, envelope))
|
||||
|
||||
|
||||
async def shutdown_publisher() -> None:
|
||||
"""Drain the NATS connection on app shutdown."""
|
||||
await _PUBLISHER.shutdown()
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from task_flow_engine import AssertionDef, AssertionResult, FlowDef, FlowEngine, FlowResult
|
||||
|
||||
|
||||
FLOW_DIR = Path(__file__).resolve().parents[1] / "flows"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_flow(entity_type: str) -> FlowDef:
|
||||
path = FLOW_DIR / f"{entity_type}.yaml"
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
return FlowDef.from_dict(data)
|
||||
|
||||
|
||||
def evaluate_transition(
|
||||
entity_type: str,
|
||||
current_workstation: str,
|
||||
target_workstation: str,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> tuple[bool, list[AssertionResult], FlowResult]:
|
||||
flow = load_flow(entity_type)
|
||||
obj = {
|
||||
"status": current_workstation,
|
||||
"workstation": current_workstation,
|
||||
"previous_workstation": current_workstation,
|
||||
**(extra or {}),
|
||||
}
|
||||
engine = create_flow_engine()
|
||||
result = engine.evaluate(obj, flow)
|
||||
can_reach, failures = engine.can_reach(obj, flow, target_workstation)
|
||||
return can_reach, failures, result
|
||||
|
||||
|
||||
def create_flow_engine() -> FlowEngine:
|
||||
return FlowEngine(
|
||||
custom_ops={
|
||||
"dependencies.any_incomplete": _dependencies_any_incomplete,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _dependencies_any_incomplete(
|
||||
assertion: AssertionDef,
|
||||
obj: dict[str, Any],
|
||||
values: list[Any],
|
||||
) -> bool:
|
||||
expected = assertion.value
|
||||
if isinstance(expected, list):
|
||||
return bool(values) and any(value not in expected for value in values)
|
||||
return bool(values) and any(value != expected for value in values)
|
||||
|
||||
|
||||
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
|
||||
return {
|
||||
"id": result.id,
|
||||
"passed": result.passed,
|
||||
"target": result.target,
|
||||
"op": result.op,
|
||||
"expected": result.expected,
|
||||
"actual": result.actual,
|
||||
"description": result.description,
|
||||
"reason": result.reason,
|
||||
}
|
||||
|
||||
|
||||
def flow_result_to_dict(result: FlowResult) -> dict[str, Any]:
|
||||
return {
|
||||
"current_workstation": result.current_workstation,
|
||||
"exit_blocked": result.exit_blocked,
|
||||
"blocking_assertions": [
|
||||
assertion_result_to_dict(item) for item in result.blocking_assertions
|
||||
],
|
||||
"reachable": result.reachable,
|
||||
"unreachable": [
|
||||
{
|
||||
"workstation": item.workstation,
|
||||
"blocking": assertion_result_to_dict(item.blocking),
|
||||
}
|
||||
for item in result.unreachable
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
import hashlib
|
||||
import os
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
|
||||
from api.database import engine
|
||||
from api.events import shutdown_publisher
|
||||
from api.services.write_idempotency import WriteIdempotencyMiddleware
|
||||
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, memo_notifications, capability_requests, tpsc, services
|
||||
from api.routers import token_events
|
||||
from api.routers import message_feed
|
||||
from api.routers import interface_changes
|
||||
from api.routers import flows
|
||||
from api.routers import recently_on_scope
|
||||
from api.routers import consistency_sweep
|
||||
from api.routers import reconciliation
|
||||
from api.routers import execution
|
||||
from api.routers import fabric
|
||||
from api.routers import legacy_meter
|
||||
from api.routers import review_contracts
|
||||
from api.routers import identifier_migrations
|
||||
from api.routers import repository_renames
|
||||
from api.routers import work_record_projection
|
||||
|
||||
|
||||
class ETagMiddleware(BaseHTTPMiddleware):
|
||||
"""Add ETag + conditional-GET (304) support to all JSON GET responses."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
started = time.perf_counter()
|
||||
response = await call_next(request)
|
||||
if request.method != "GET":
|
||||
response.headers["X-StateHub-Elapsed-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
|
||||
return response
|
||||
if "application/json" not in response.headers.get("content-type", ""):
|
||||
response.headers["X-StateHub-Elapsed-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
|
||||
return response
|
||||
|
||||
body_parts = []
|
||||
async for chunk in response.body_iterator:
|
||||
body_parts.append(chunk)
|
||||
body = b"".join(body_parts)
|
||||
elapsed_ms = f"{(time.perf_counter() - started) * 1000:.1f}"
|
||||
|
||||
etag = '"' + hashlib.md5(body, usedforsecurity=False).hexdigest() + '"'
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return StarletteResponse(
|
||||
status_code=304,
|
||||
headers={
|
||||
"ETag": etag,
|
||||
"Cache-Control": "no-cache",
|
||||
"X-StateHub-Elapsed-Ms": elapsed_ms,
|
||||
"X-StateHub-Response-Bytes": "0",
|
||||
},
|
||||
)
|
||||
|
||||
headers = {k: v for k, v in response.headers.items() if k.lower() != "content-length"}
|
||||
headers["ETag"] = etag
|
||||
headers["X-StateHub-Elapsed-Ms"] = elapsed_ms
|
||||
headers["X-StateHub-Response-Bytes"] = str(len(body))
|
||||
if not any(k.lower() == "cache-control" for k in headers):
|
||||
headers["Cache-Control"] = "no-cache"
|
||||
return StarletteResponse(
|
||||
content=body,
|
||||
status_code=response.status_code,
|
||||
headers=headers,
|
||||
media_type=response.media_type,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
yield
|
||||
await shutdown_publisher()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Custodian State Hub",
|
||||
description="Local-first state API for the Custodian agent system.",
|
||||
version="0.6.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
_default_dashboard_origins = [
|
||||
*(f"http://localhost:{port}" for port in range(3000, 3006)),
|
||||
*(f"http://127.0.0.1:{port}" for port in range(3000, 3006)),
|
||||
*(f"http://[::1]:{port}" for port in range(3000, 3006)),
|
||||
]
|
||||
_cors_env = os.getenv("CORS_ORIGINS", ",".join(_default_dashboard_origins))
|
||||
_cors_origins = [o.strip() for o in _cors_env.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(WriteIdempotencyMiddleware)
|
||||
app.add_middleware(ETagMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_cors_origins,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||
allow_headers=["Content-Type", "If-None-Match", "Idempotency-Key", "X-StateHub-Source-Agent", "X-StateHub-Source-Host"],
|
||||
expose_headers=["ETag", "X-StateHub-Elapsed-Ms", "X-StateHub-Response-Bytes", "X-StateHub-Cache", "X-StateHub-Idempotency-Replay"],
|
||||
)
|
||||
|
||||
app.include_router(domains.router)
|
||||
app.include_router(recently_on_scope.hourly_router)
|
||||
app.include_router(recently_on_scope.router)
|
||||
app.include_router(consistency_sweep.router)
|
||||
app.include_router(repos.router)
|
||||
app.include_router(repository_renames.router)
|
||||
app.include_router(repository_renames.operation_router)
|
||||
app.include_router(work_record_projection.router)
|
||||
app.include_router(topics.router)
|
||||
app.include_router(workstreams.router)
|
||||
app.include_router(workstreams.workplan_router)
|
||||
app.include_router(workstream_dependencies.router)
|
||||
app.include_router(workstream_dependencies.workplan_router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(decisions.router)
|
||||
app.include_router(intake.router)
|
||||
app.include_router(extension_points.router)
|
||||
app.include_router(technical_debt.router)
|
||||
app.include_router(progress.router)
|
||||
app.include_router(domain_goals.router)
|
||||
app.include_router(repo_goals.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
# Before messages.router so /messages/feed is not captured by /messages/{id}.
|
||||
app.include_router(message_feed.router)
|
||||
app.include_router(messages.router)
|
||||
app.include_router(memo_notifications.router)
|
||||
app.include_router(capability_requests.router)
|
||||
app.include_router(suggestions.router)
|
||||
app.include_router(tpsc.router)
|
||||
app.include_router(services.router)
|
||||
app.include_router(token_events.router)
|
||||
app.include_router(interface_changes.router)
|
||||
app.include_router(flows.router)
|
||||
app.include_router(reconciliation.router)
|
||||
app.include_router(execution.router)
|
||||
app.include_router(fabric.router)
|
||||
app.include_router(legacy_meter.router)
|
||||
app.include_router(review_contracts.router)
|
||||
app.include_router(identifier_migrations.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(ops_runs.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return {"service": "dev-hub", "docs": "/docs"}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
from api.models.base import Base
|
||||
from api.models.domain import Domain
|
||||
from api.models.domain_goal import DomainGoal, DomainGoalStatus
|
||||
from api.models.topic import Topic, TopicStatus
|
||||
from api.models.managed_repo import ManagedRepo
|
||||
from api.models.repo_goal import RepoGoal, RepoGoalStatus
|
||||
from api.models.workplan import Workplan
|
||||
from api.models.workplan_dependency import WorkplanDependency
|
||||
from api.models.workstream import Workstream
|
||||
from api.models.workstream_dependency import WorkstreamDependency
|
||||
from api.models.task import Task, TaskStatus, TaskPriority
|
||||
from api.models.decision import Decision, DecisionType, DecisionStatus
|
||||
from api.models.intake import (
|
||||
Intake,
|
||||
IntakeNote,
|
||||
IntakeLane,
|
||||
IntakeStatus,
|
||||
IntakeOutcome,
|
||||
)
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.extension_point import ExtensionPoint, EPStatus
|
||||
from api.models.technical_debt import TechnicalDebt, TDStatus
|
||||
from api.models.contribution import Contribution, ContributionType, ContributionStatus
|
||||
from api.models.sbom_snapshot import SBOMSnapshot
|
||||
from api.models.sbom_entry import SBOMEntry, Ecosystem
|
||||
from api.models.agent_message import AgentMessage, MessageReceipt, MessageTombstone
|
||||
from api.models.memo_notification import MemoNotificationReceipt
|
||||
from api.models.capability_catalog import CapabilityCatalog
|
||||
from api.models.capability_request import CapabilityRequest
|
||||
from api.models.tpsc import TPSCCatalog, TPSCSnapshot, TPSCEntry
|
||||
from api.models.service_catalog import (
|
||||
ServiceCatalog,
|
||||
ServiceThirdParty,
|
||||
ServiceFirstParty,
|
||||
ServiceCloud,
|
||||
ServiceSelfHosted,
|
||||
)
|
||||
from api.models.doi_cache import DOICache
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.interface_change import InterfaceChange
|
||||
from api.models.workplan_launch_request import WorkplanLaunchRequest
|
||||
from api.models.fabric_graph import FabricGraphImport, FabricGraphNode, FabricGraphEdge
|
||||
from api.models.legacy_meter import LegacyInterface, LegacyInterfaceUsageBucket
|
||||
from api.models.write_idempotency_key import WriteIdempotencyKey
|
||||
from api.models.work_record_identifier_alias import WorkRecordIdentifierAlias
|
||||
from api.models.review_contract import ReviewContract, ReviewReceipt
|
||||
from api.models.repository_rename import (
|
||||
RepositoryForgeIdentity,
|
||||
RepositoryRenameOperation,
|
||||
RepositorySlug,
|
||||
)
|
||||
from api.models.suggestion import (
|
||||
Suggestion,
|
||||
SuggestionNote,
|
||||
SuggestionRelevanceBump,
|
||||
SuggestionStage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"Domain",
|
||||
"DomainGoal", "DomainGoalStatus",
|
||||
"Topic", "TopicStatus",
|
||||
"ManagedRepo",
|
||||
"RepoGoal", "RepoGoalStatus",
|
||||
"Workplan",
|
||||
"WorkplanDependency",
|
||||
"Workstream",
|
||||
"WorkstreamDependency",
|
||||
"Task", "TaskStatus", "TaskPriority",
|
||||
"Decision", "DecisionType", "DecisionStatus",
|
||||
"Intake", "IntakeNote", "IntakeLane", "IntakeStatus", "IntakeOutcome",
|
||||
"ProgressEvent",
|
||||
"ExtensionPoint", "EPStatus",
|
||||
"TechnicalDebt", "TDStatus",
|
||||
"Contribution", "ContributionType", "ContributionStatus",
|
||||
"SBOMSnapshot",
|
||||
"SBOMEntry", "Ecosystem",
|
||||
"AgentMessage",
|
||||
"MessageReceipt",
|
||||
"MemoNotificationReceipt",
|
||||
"CapabilityCatalog",
|
||||
"CapabilityRequest",
|
||||
"TPSCCatalog", "TPSCSnapshot", "TPSCEntry",
|
||||
"ServiceCatalog", "ServiceThirdParty", "ServiceFirstParty",
|
||||
"ServiceCloud", "ServiceSelfHosted",
|
||||
"DOICache",
|
||||
"TokenEvent",
|
||||
"InterfaceChange",
|
||||
"WorkplanLaunchRequest",
|
||||
"FabricGraphImport", "FabricGraphNode", "FabricGraphEdge",
|
||||
"LegacyInterface", "LegacyInterfaceUsageBucket",
|
||||
"WriteIdempotencyKey",
|
||||
"WorkRecordIdentifierAlias",
|
||||
"ReviewContract", "ReviewReceipt",
|
||||
"RepositoryForgeIdentity", "RepositoryRenameOperation", "RepositorySlug",
|
||||
"Suggestion", "SuggestionNote", "SuggestionRelevanceBump", "SuggestionStage",
|
||||
]
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
DDL,
|
||||
BigInteger,
|
||||
DateTime,
|
||||
FetchedValue,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Sequence,
|
||||
String,
|
||||
Text,
|
||||
event,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, new_uuid
|
||||
|
||||
|
||||
BROADCAST = "broadcast"
|
||||
MESSAGE_KINDS = ("message", "news", "standing")
|
||||
|
||||
# STATE-WP-0098-T05: inbox change feed (docs/inbox-change-feed-contract.md).
|
||||
# ``revision`` is owned by the database trigger below; application code never
|
||||
# sets it. Every inbox write transaction gets exactly one revision.
|
||||
INBOX_REVISION_SEQ = Sequence("inbox_revision_seq", metadata=Base.metadata)
|
||||
|
||||
|
||||
def _revision_column() -> Mapped[int]:
|
||||
return mapped_column(
|
||||
BigInteger,
|
||||
nullable=False,
|
||||
index=True,
|
||||
server_default=FetchedValue(),
|
||||
server_onupdate=FetchedValue(),
|
||||
)
|
||||
|
||||
|
||||
class AgentMessage(Base):
|
||||
__tablename__ = "agent_messages"
|
||||
__table_args__ = (
|
||||
Index("ix_agent_messages_to_kind_archived", "to_agent", "kind", "archived_at"),
|
||||
)
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
from_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
to_agent: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
thread_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# STATE-WP-0093: kind is only meaningful for broadcasts
|
||||
# (message | news | standing); direct messages stay "message".
|
||||
kind: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="message", server_default="message"
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
supersedes_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=text("now()"),
|
||||
nullable=False,
|
||||
)
|
||||
revision: Mapped[int] = _revision_column()
|
||||
|
||||
thread_root: Mapped["AgentMessage | None"] = relationship(
|
||||
"AgentMessage",
|
||||
remote_side="AgentMessage.id",
|
||||
foreign_keys=[thread_id],
|
||||
lazy="select",
|
||||
)
|
||||
|
||||
|
||||
class MessageReceipt(Base):
|
||||
"""Per-reader state for broadcast messages (STATE-WP-0093).
|
||||
|
||||
Written only for broadcasts; direct messages keep the global
|
||||
``AgentMessage.read_at``.
|
||||
"""
|
||||
|
||||
__tablename__ = "message_receipts"
|
||||
__mapper_args__ = {"eager_defaults": True}
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("agent_messages.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
)
|
||||
agent: Mapped[str] = mapped_column(String(100), primary_key=True, index=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
acknowledged_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revision: Mapped[int] = _revision_column()
|
||||
|
||||
|
||||
class MessageTombstone(Base):
|
||||
"""A deleted ``agent_messages`` row, written by the delete trigger.
|
||||
|
||||
Bodies are never kept. Served by the inbox feed within the 90-day horizon
|
||||
(contract D1).
|
||||
"""
|
||||
|
||||
__tablename__ = "message_tombstones"
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True)
|
||||
revision: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
|
||||
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
# Trigger DDL for ``Base.metadata.create_all`` (the test suite). Production
|
||||
# gets the same objects from migration c4d5e6f7a8b9_inbox_feed_revision, which
|
||||
# keeps its own frozen copy; tests/test_message_feed.py checks they agree.
|
||||
INBOX_ADVISORY_LOCK_KEY = 5_348_490_098
|
||||
|
||||
INBOX_FEED_FUNCTIONS_SQL = f"""
|
||||
CREATE OR REPLACE FUNCTION inbox_feed_revision() RETURNS bigint
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
tx text := txid_current()::text;
|
||||
cached text := coalesce(current_setting('statehub.inbox_rev', true), '');
|
||||
rev bigint;
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock({INBOX_ADVISORY_LOCK_KEY});
|
||||
IF split_part(cached, ':', 1) = tx THEN
|
||||
RETURN split_part(cached, ':', 2)::bigint;
|
||||
END IF;
|
||||
rev := nextval('inbox_revision_seq');
|
||||
PERFORM set_config('statehub.inbox_rev', tx || ':' || rev::text, true);
|
||||
RETURN rev;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION inbox_feed_stamp_revision() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN
|
||||
IF TG_OP = 'UPDATE' THEN
|
||||
IF NEW IS NOT DISTINCT FROM OLD THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END IF;
|
||||
NEW.revision := inbox_feed_revision();
|
||||
RETURN NEW;
|
||||
END
|
||||
$fn$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION inbox_feed_tombstone() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $fn$
|
||||
BEGIN
|
||||
INSERT INTO message_tombstones (message_id, revision, deleted_at, reason)
|
||||
VALUES (
|
||||
OLD.id,
|
||||
inbox_feed_revision(),
|
||||
now(),
|
||||
nullif(current_setting('statehub.delete_reason', true), '')
|
||||
)
|
||||
ON CONFLICT (message_id) DO UPDATE
|
||||
SET revision = EXCLUDED.revision,
|
||||
deleted_at = EXCLUDED.deleted_at,
|
||||
reason = EXCLUDED.reason;
|
||||
RETURN OLD;
|
||||
END
|
||||
$fn$;
|
||||
"""
|
||||
|
||||
event.listen(
|
||||
AgentMessage.__table__,
|
||||
"after_create",
|
||||
DDL(
|
||||
INBOX_FEED_FUNCTIONS_SQL
|
||||
+ """
|
||||
CREATE TRIGGER agent_messages_inbox_revision
|
||||
BEFORE INSERT OR UPDATE ON agent_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION inbox_feed_stamp_revision();
|
||||
CREATE TRIGGER agent_messages_inbox_tombstone
|
||||
BEFORE DELETE ON agent_messages
|
||||
FOR EACH ROW EXECUTE FUNCTION inbox_feed_tombstone();
|
||||
"""
|
||||
),
|
||||
)
|
||||
event.listen(
|
||||
MessageReceipt.__table__,
|
||||
"after_create",
|
||||
DDL(
|
||||
INBOX_FEED_FUNCTIONS_SQL
|
||||
+ """
|
||||
CREATE TRIGGER message_receipts_inbox_revision
|
||||
BEFORE INSERT OR UPDATE ON message_receipts
|
||||
FOR EACH ROW EXECUTE FUNCTION inbox_feed_stamp_revision();
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def new_uuid() -> uuid.UUID:
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
def new_uuid7() -> uuid.UUID:
|
||||
"""Generate a UUIDv7 (RFC 9562): 48-bit big-endian ms timestamp, version
|
||||
and variant bits, remaining bits random. Time-sortable, so primary keys
|
||||
generated with this helper order chronologically without a separate
|
||||
created_at index lookup — the identity layering canon
|
||||
(work-record-types_v0.1.md) calls this out as the primary internal key
|
||||
for new work-record entities.
|
||||
|
||||
Dependency-free (no uuid7 in stdlib before Python 3.14, no third-party
|
||||
lib added for a ~15-line, non-cryptographic layout).
|
||||
"""
|
||||
unix_ts_ms = int(time.time() * 1000)
|
||||
rand = int.from_bytes(os.urandom(10), "big")
|
||||
rand_a = (rand >> 62) & 0x0FFF # top 12 bits of the 80 random bits
|
||||
rand_b = rand & 0x3FFFFFFFFFFFFFFF # bottom 62 bits
|
||||
value = (
|
||||
(unix_ts_ms << 80)
|
||||
| (0x7 << 76) # version 7
|
||||
| (rand_a << 64)
|
||||
| (0x2 << 62) # variant 10
|
||||
| rand_b
|
||||
)
|
||||
return uuid.UUID(int=value)
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityCatalog(Base, TimestampMixin):
|
||||
__tablename__ = "capability_catalog"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
keywords: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(String), nullable=False, server_default="{}"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="active", server_default="active"
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo | None"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
||||
@property
|
||||
def repo_slug(self) -> str | None:
|
||||
return self.repo.slug if self.repo is not None else None
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class CapabilityRequest(Base, TimestampMixin):
|
||||
__tablename__ = "capability_requests"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capability_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
priority: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="medium", server_default="medium"
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="requested", server_default="requested"
|
||||
)
|
||||
|
||||
# Requester side
|
||||
requesting_domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
requesting_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
|
||||
# Fulfiller side (populated on accept / auto-route)
|
||||
fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
fulfilling_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
# Links
|
||||
blocking_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("tasks.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("capability_catalog.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
routing_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# Dispute fields (populated when status = routing_disputed)
|
||||
dispute_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
disputed_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
dispute_suggested_domain: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
disputed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
requesting_domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[requesting_domain_id], lazy="selectin"
|
||||
)
|
||||
fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821
|
||||
"Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin"
|
||||
)
|
||||
blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821
|
||||
catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821
|
||||
"CapabilityCatalog", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def requesting_domain_slug(self) -> str:
|
||||
return self.requesting_domain.slug if self.requesting_domain else ""
|
||||
|
||||
@property
|
||||
def fulfilling_domain_slug(self) -> str | None:
|
||||
return self.fulfilling_domain.slug if self.fulfilling_domain else None
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class ContributionType(str, enum.Enum):
|
||||
br = "br"
|
||||
fr = "fr"
|
||||
ep = "ep"
|
||||
upr = "upr"
|
||||
|
||||
|
||||
class ContributionStatus(str, enum.Enum):
|
||||
draft = "draft"
|
||||
submitted = "submitted"
|
||||
acknowledged = "acknowledged"
|
||||
accepted = "accepted"
|
||||
rejected = "rejected"
|
||||
merged = "merged"
|
||||
withdrawn = "withdrawn"
|
||||
|
||||
|
||||
class Contribution(Base, TimestampMixin):
|
||||
__tablename__ = "contributions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
type: Mapped[ContributionType] = mapped_column(
|
||||
Enum(ContributionType, name="contributiontype"), nullable=False
|
||||
)
|
||||
target_org: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
target_repo: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
slug: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status: Mapped[ContributionStatus] = mapped_column(
|
||||
Enum(ContributionStatus, name="contributionstatus"),
|
||||
nullable=False, default=ContributionStatus.draft,
|
||||
)
|
||||
body_path: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
related_topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
related_workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
repo_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("managed_repos.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
submitted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
repo: Mapped["ManagedRepo"] = relationship("ManagedRepo", lazy="selectin") # noqa: F821
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class DecisionType(str, enum.Enum):
|
||||
made = "made"
|
||||
pending = "pending"
|
||||
|
||||
|
||||
class DecisionStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
resolved = "resolved"
|
||||
escalated = "escalated"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class Decision(Base, TimestampMixin):
|
||||
__tablename__ = "decisions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"topic_id IS NOT NULL OR workplan_id IS NOT NULL",
|
||||
name="ck_decisions_topic_or_workplan",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="RESTRICT"), nullable=True, index=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="RESTRICT", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decision_type: Mapped[DecisionType] = mapped_column(
|
||||
Enum(DecisionType), nullable=False, default=DecisionType.pending
|
||||
)
|
||||
status: Mapped[DecisionStatus] = mapped_column(
|
||||
Enum(DecisionStatus), nullable=False, default=DecisionStatus.open
|
||||
)
|
||||
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
decided_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
deadline: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
escalation_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
superseded_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("decisions.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
topic: Mapped["Topic | None"] = relationship("Topic", back_populates="decisions") # noqa: F821
|
||||
workplan: Mapped["Workplan | None"] = relationship("Workplan", back_populates="decisions") # noqa: F821
|
||||
progress_events: Mapped[list["ProgressEvent"]] = relationship( # noqa: F821
|
||||
"ProgressEvent", back_populates="decision", lazy="selectin"
|
||||
)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import JSON, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.models.base import Base
|
||||
|
||||
|
||||
class DOICache(Base):
|
||||
__tablename__ = "doi_cache"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
repo_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("managed_repos.id", ondelete="CASCADE"),
|
||||
nullable=False, unique=True, index=True,
|
||||
)
|
||||
tier: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
core_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
standard_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
full_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||
criteria: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
# Pipe-joined string of timestamps/mtimes used to detect staleness
|
||||
fingerprint: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
checked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import uuid
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class Domain(Base, TimestampMixin):
|
||||
__tablename__ = "domains"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active")
|
||||
|
||||
topics: Mapped[list["Topic"]] = relationship( # noqa: F821
|
||||
"Topic", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
repos: Mapped[list["ManagedRepo"]] = relationship( # noqa: F821
|
||||
"ManagedRepo", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
goals: Mapped[list["DomainGoal"]] = relationship( # noqa: F821
|
||||
"DomainGoal", back_populates="domain", lazy="selectin"
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class DomainGoalStatus(str, enum.Enum):
|
||||
active = "active"
|
||||
archived = "archived"
|
||||
superseded = "superseded"
|
||||
|
||||
|
||||
class DomainGoal(Base, TimestampMixin):
|
||||
__tablename__ = "domain_goals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("domains.id", ondelete="RESTRICT"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=DomainGoalStatus.active.value, server_default="active"
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship( # noqa: F821
|
||||
"Domain", back_populates="goals", lazy="selectin"
|
||||
)
|
||||
repo_goals: Mapped[list["RepoGoal"]] = relationship( # noqa: F821
|
||||
"RepoGoal", back_populates="domain_goal", lazy="selectin"
|
||||
)
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.models.base import Base, TimestampMixin, new_uuid
|
||||
|
||||
|
||||
class EPStatus(str, enum.Enum):
|
||||
open = "open"
|
||||
in_progress = "in_progress"
|
||||
addressed = "addressed"
|
||||
deferred = "deferred"
|
||||
wont_fix = "wont_fix"
|
||||
|
||||
|
||||
class ExtensionPoint(Base, TimestampMixin):
|
||||
__tablename__ = "extension_points"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=new_uuid
|
||||
)
|
||||
ep_id: Mapped[str | None] = mapped_column(
|
||||
String(30), nullable=True, unique=True, index=True
|
||||
) # human-readable ref, e.g. EP-CUST-001
|
||||
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("domains.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
ep_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="other"
|
||||
) # api | schema | mcp | dashboard | architecture | integration | other
|
||||
status: Mapped[EPStatus] = mapped_column(
|
||||
Enum(EPStatus, name="epstatus"), nullable=False, default=EPStatus.open
|
||||
)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="medium")
|
||||
topic_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("topics.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
workplan_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("workplans.id", ondelete="SET NULL", onupdate="CASCADE"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821
|
||||
topic: Mapped["Topic"] = relationship("Topic", lazy="selectin") # noqa: F821
|
||||
workplan: Mapped["Workplan"] = relationship("Workplan", lazy="selectin") # noqa: F821
|
||||
|
||||
@property
|
||||
def domain_slug(self) -> str:
|
||||
return self.domain.slug if self.domain is not None else ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue