From f18eb0b8df197fb2c395709c12f4834bd97be2cd Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 1 Nov 2026 22:46:46 +0100 Subject: [PATCH] Add an on/off switch for the MAS facility. MAS-WP-0007 is finished. power.sh scales the MX and statute together and records the power annotation. apply.sh keeps an off facility off. The live facility was annotated power=on and was not restarted. Assistant: grok Assistant-Session: 01a198fa-fd27-7e41-a0a2-57355c3e4efd --- INTENT.md | 3 +- SCOPE.md | 27 +++---- WORK-RECORDS.md | 8 +- deploy/railiance/Dockerfile | 2 +- deploy/railiance/README.md | 37 +++++++++ deploy/railiance/apply.sh | 23 ++++++ deploy/railiance/facility.py | 81 ++++++++++++++++++++ deploy/railiance/manifests.yaml | 18 +++-- deploy/railiance/power.sh | 73 ++++++++++++++++++ registry/release-units/mas.yaml | 9 ++- tests/test_facility_power.py | 74 ++++++++++++++++++ workplans/MAS-WP-0007-switchable-facility.md | 25 ++++-- 12 files changed, 344 insertions(+), 36 deletions(-) create mode 100644 deploy/railiance/README.md create mode 100755 deploy/railiance/apply.sh create mode 100644 deploy/railiance/facility.py create mode 100755 deploy/railiance/power.sh create mode 100644 tests/test_facility_power.py diff --git a/INTENT.md b/INTENT.md index 081537e..b86a0e0 100644 --- a/INTENT.md +++ b/INTENT.md @@ -687,7 +687,8 @@ multimodal framework itself are out too. 3. v0.2 is declared final, or is withdrawn with recorded reasons; 4. the handoff reading of P2 is recorded in the v0.2 spec text, and the address spec points to the mode contract; -5. the live test environment is retired, with its evidence archived; +5. the MAS facility can be turned off and back on, keeping its evidence, + credentials, and DNS; 6. mode contract v0.1 is published with JSON schemas and fixtures; 7. the email mode reference passes them in both roles: as master against the callback stub, and as slave under the stub outside master; diff --git a/SCOPE.md b/SCOPE.md index 952c712..b3f0f9a 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -58,9 +58,11 @@ delivery stays external. outside master that issues case ids), a scripted callback stub, a stub outside master and the flow runner. Email is the only real channel. See `docs/reuse-surface.md`. -- **Live test environment.** `deploy/railiance/`: the MX for +- **MAS facility.** `deploy/railiance/`: the MX and statute for `mas.coulomb.social`, with replies through Scaleway TEM capped at 300. - It is temporary; MAS-WP-0007 retires it. + `power.sh` turns the two Deployments on and off. Off keeps the + namespace, evidence volume, credentials, DNS, and LoadBalancer. + MAS-WP-0007. ## Out of Scope @@ -85,23 +87,22 @@ delivery stays external. - Status: active. The reference experiment (MAS-WP-0002) passed its decision gate on 2026-10-18; the findings are in `docs/experiment-review.md`. -- Finished: MAS-WP-0001 to 0006 and 0008 to 0014. That covers the - Postfix profile, the v0.1.1 errata, v0.2 (final), the address composer, - the mode contract v0.1 (final) with authentication, one outbound path - and bundle-only flows, and the second implementation (`impl/js/`, both - specs, both contract sides). -- Open: MAS-WP-0007 (retire the live MX; backlog, kept by decision). +- Finished: MAS-WP-0001 to 0015. That covers the Postfix profile, the + v0.1.1 errata, v0.2 (final), the address composer, the mode contract + v0.1 (final) with authentication, one outbound path and bundle-only + flows, the second implementation (`impl/js/`, both specs, both contract + sides), and the switchable MAS facility. - v0.2 is final (founder, 2026-10-19, after MAS-WP-0009). Mode contract v0.1 is final too (founder, 2026-10-19, after MAS-WP-0014: independent JS email side; MCP-1 to MCP-6). - P2 is decided: `!MODE` requests a handoff (founder, 2026-10-19; SCP-8, v0.2 §7.1). The mode contract and its reference implementation are done (MAS-WP-0010); `mas test` runs the contract corpus. -- The live MX runs image `mas-live:v0.2-480e69a` (redeployed 2026-10-19) - and is kept up by founder decision (MAS-WP-0007 stays in backlog). - The deployment with MAS-WP-0012 (one outbound path) is recorded in - MAS-WP-0007. -- Tests: `.venv/bin/pytest` runs 274 tests, offline apart from loopback +- The MAS facility runs image `mas-live:v0.2-480e69a` (redeployed + 2026-10-19). Founder decision 2026-11-01: keep it and power it on or + off (MAS-WP-0007). It was left on. The 300-reply cap, the side-loaded + image, and snakeoil SMTP TLS stay until a later decision. +- Tests: `.venv/bin/pytest` runs 280 tests, offline apart from loopback fixtures, the Docker probe and Node for `impl/js/`. `mas test` runs four corpora: parser 64, statute 16, v0.2 statute-aware 33, mode contract 195. `cd impl/js && node --test` runs 85 more. diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 5173dfb..3d5d895 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,7 +14,7 @@ | workplan | MAS-WP-0004 | finished | — | workplans/MAS-WP-0004-postfix-integration-profile.md | | workplan | MAS-WP-0005 | finished | — | workplans/MAS-WP-0005-spec-errata-v0-1-1.md | | workplan | MAS-WP-0006 | finished | — | workplans/MAS-WP-0006-address-composer.md | -| workplan | MAS-WP-0007 | active | — | workplans/MAS-WP-0007-switchable-facility.md | +| workplan | MAS-WP-0007 | finished | — | workplans/MAS-WP-0007-switchable-facility.md | | workplan | MAS-WP-0008 | finished | — | workplans/MAS-WP-0008-finalize-v0-2.md | | workplan | MAS-WP-0009 | finished | — | workplans/MAS-WP-0009-declare-v0-2-final.md | | workplan | MAS-WP-0010 | finished | — | workplans/MAS-WP-0010-mode-reference-implementation.md | @@ -61,9 +61,9 @@ | task | MAS-WP-0006-T01 | done | — | workplans/MAS-WP-0006-address-composer.md | | task | MAS-WP-0006-T02 | done | — | workplans/MAS-WP-0006-address-composer.md | | task | MAS-WP-0007-T01 | cancel | — | workplans/MAS-WP-0007-switchable-facility.md | -| task | MAS-WP-0007-T02 | todo | — | workplans/MAS-WP-0007-switchable-facility.md | -| task | MAS-WP-0007-T03 | todo | — | workplans/MAS-WP-0007-switchable-facility.md | -| task | MAS-WP-0007-T04 | todo | — | workplans/MAS-WP-0007-switchable-facility.md | +| task | MAS-WP-0007-T02 | done | — | workplans/MAS-WP-0007-switchable-facility.md | +| task | MAS-WP-0007-T03 | done | — | workplans/MAS-WP-0007-switchable-facility.md | +| task | MAS-WP-0007-T04 | done | — | workplans/MAS-WP-0007-switchable-facility.md | | task | MAS-WP-0008-T01 | done | — | workplans/MAS-WP-0008-finalize-v0-2.md | | task | MAS-WP-0009-T01 | done | — | workplans/MAS-WP-0009-declare-v0-2-final.md | | task | MAS-WP-0009-T02 | done | — | workplans/MAS-WP-0009-declare-v0-2-final.md | diff --git a/deploy/railiance/Dockerfile b/deploy/railiance/Dockerfile index 46ecda8..e7b98c7 100644 --- a/deploy/railiance/Dockerfile +++ b/deploy/railiance/Dockerfile @@ -1,4 +1,4 @@ -# MAS live-test MX for mas.coulomb.social (MAS-WP-0003). +# MAS facility image for mas.coulomb.social (MAS-WP-0007). # Build from the repo root: docker build -f deploy/railiance/Dockerfile . FROM debian:bookworm-slim RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ diff --git a/deploy/railiance/README.md b/deploy/railiance/README.md new file mode 100644 index 0000000..776cb48 --- /dev/null +++ b/deploy/railiance/README.md @@ -0,0 +1,37 @@ +# MAS facility + +The MX and the statute for `mas.coulomb.social`. Replies go out through +Scaleway TEM and stay capped at 300. MAS-WP-0007. + +## On and off + +`power.sh` scales `deploy/mas-mx` and `deploy/mas-statute` together. It +writes annotation `mas.coulomb.social/power` on namespace `mas`. + +| Power | What runs | What stays | +|---|---|---| +| `on` | both Deployments at 1 replica | evidence PVC, Services, Ingress, ExternalSecret, DNS, OpenBao role | +| `off` | both Deployments at 0 | the same durable parts; port 25 has no ready pod | + +```bash +KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh status +KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh off +KUBECONFIG=~/.kube/config-railiance01 deploy/railiance/power.sh on +``` + +The workstation's default kubeconfig is `ops-mason-builder`. That identity +can see Services and cannot scale these Deployments. Use the railiance01 +admin kubeconfig for `power.sh` and `apply.sh`. + +`apply.sh IMAGE` renders `manifests.yaml` and applies it. When the +annotation is `off`, both Deployments are rendered at 0 replicas, so a +later apply does not turn the facility back on. A missing annotation means +on. The script stops if it cannot read the namespace. + +## Limits + +One replica. The image is side-loaded into k3s containerd, not pulled from +a registry. SMTP TLS is the image's snakeoil certificate. The running image +is `docker.io/library/mas-live:v0.2-480e69a`, whose banner still says +`MAS live test`. These stay until a later decision. This switch does not +roll a new image. diff --git a/deploy/railiance/apply.sh b/deploy/railiance/apply.sh new file mode 100755 index 0000000..726e218 --- /dev/null +++ b/deploy/railiance/apply.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Apply the facility manifest without undoing an off switch (MAS-WP-0007). +# A missing power annotation is on. Refuses to apply when the namespace +# annotation cannot be read. +set -eu + +cd "$(dirname "$0")" +if [ "$#" -ne 1 ]; then + echo "usage: apply.sh IMAGE" >&2 + exit 2 +fi +image=$1 + +if ! power=$(kubectl get namespace mas \ + -o jsonpath='{.metadata.annotations.mas\.coulomb\.social/power}'); then + echo "cannot read namespace mas; refusing to apply" >&2 + exit 1 +fi +if [ -z "$power" ]; then + power=on +fi + +python3 ./facility.py render "$image" "$power" < manifests.yaml | kubectl apply -f - diff --git a/deploy/railiance/facility.py b/deploy/railiance/facility.py new file mode 100644 index 0000000..93f12a5 --- /dev/null +++ b/deploy/railiance/facility.py @@ -0,0 +1,81 @@ +"""Render the MAS facility manifest for a power state. + +This file does not talk to the cluster. power.sh and apply.sh do. +""" + +from __future__ import annotations + +import sys + +SWITCHABLE = ("mas-mx", "mas-statute") + + +def render(manifest: str, image: str, power: str) -> str: + """Return the manifest with IMAGE filled in and replicas set from power. + + `on` keeps one replica of each switchable Deployment. `off` sets both + to zero. Services, the evidence volume, the Ingress, and the + ExternalSecret are copied through unchanged. + """ + if power not in ("on", "off"): + raise ValueError(f"power must be on or off, got {power!r}") + if not image or any(ch.isspace() for ch in image): + raise ValueError("image must be a single token") + if "image: IMAGE" not in manifest: + raise ValueError("manifest has no image: IMAGE placeholder") + + rendered = manifest.replace("image: IMAGE", f"image: {image}") + docs = rendered.split("\n---\n") + seen: set[str] = set() + out: list[str] = [] + for doc in docs: + name = _deployment_name(doc) + if name in SWITCHABLE: + seen.add(name) + doc = _set_replicas(doc, 0 if power == "off" else 1) + out.append(doc) + missing = [name for name in SWITCHABLE if name not in seen] + if missing: + raise ValueError("manifest is missing Deployments: " + ", ".join(missing)) + return "\n---\n".join(out) + + +def _deployment_name(doc: str) -> str | None: + kind = None + name = None + for line in doc.splitlines(): + if line.startswith("kind:"): + kind = line.split(":", 1)[1].strip() + elif line.startswith(" name:") and name is None: + name = line.split(":", 1)[1].strip() + if kind == "Deployment": + return name + return None + + +def _set_replicas(doc: str, count: int) -> str: + lines = doc.splitlines(keepends=True) + found = 0 + out = [] + for line in lines: + if line.startswith(" replicas:"): + found += 1 + newline = "\n" if line.endswith("\n") else "" + out.append(f" replicas: {count}{newline}") + else: + out.append(line) + if found != 1: + raise ValueError(f"expected one replicas field, found {found}") + return "".join(out) + + +def main(argv: list[str]) -> int: + if len(argv) != 4 or argv[1] != "render": + print("usage: facility.py render IMAGE on|off", file=sys.stderr) + return 2 + sys.stdout.write(render(sys.stdin.read(), argv[2], argv[3])) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/deploy/railiance/manifests.yaml b/deploy/railiance/manifests.yaml index 4352d82..29508b8 100644 --- a/deploy/railiance/manifests.yaml +++ b/deploy/railiance/manifests.yaml @@ -1,9 +1,11 @@ -# MAS live provider test (mail-address-semantics MAS-WP-0003). -# Founder go for A-D: 2026-10-08. Temporary: delete the namespace after the test. -# A namespace + MX pod B LoadBalancer :25 C statute-only ingress D no outbound mail -# D superseded 2026-10-18: operator chose replies via Scaleway TEM (Secret mas-relay, -# synced by ExternalSecret from OpenBao platform/suppliers/scaleway/mas-tem-relay). -# IMAGE is replaced with the pushed digest at apply time. +# MAS facility for mas.coulomb.social (MAS-WP-0007). +# Durable: this namespace, the evidence PVC, Services, Ingress, ExternalSecret. +# Switchable: Deployments mas-mx and mas-statute (replicas 1 on, 0 off). +# deploy/railiance/apply.sh reads annotation mas.coulomb.social/power and +# renders replicas 0 when the facility is off. A missing annotation means on. +# Replies use Scaleway TEM (Secret mas-relay, from OpenBao +# platform/suppliers/scaleway/mas-tem-relay), capped at 300. +# IMAGE is replaced at apply time. The running image is side-loaded. apiVersion: v1 kind: Namespace metadata: @@ -20,8 +22,8 @@ spec: --- # TEM SMTP login from OpenBao (ops-mason MASON-WP-0014, CCR-2026-0051). # OpenBao role external-secrets-mas-tem-relay and its read policy are -# cluster-scoped. Retire them at MAS-WP-0003 close; deleting the namespace -# does not remove them. +# cluster-scoped. They stay with the facility. Deleting the namespace would +# not remove them, and the power switch does not delete the namespace. apiVersion: v1 kind: ServiceAccount metadata: {name: mas-tem-relay-eso, namespace: mas} diff --git a/deploy/railiance/power.sh b/deploy/railiance/power.sh new file mode 100755 index 0000000..67a7bb2 --- /dev/null +++ b/deploy/railiance/power.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Turn the MAS facility on or off (MAS-WP-0007). +# Scales only deploy/mas-mx and deploy/mas-statute. The namespace, evidence +# PVC, Services, Ingress, and ExternalSecret stay. +# Records the desired power on namespace mas: +# mas.coulomb.social/power=on|off +set -eu +set -o pipefail + +ns=mas +annotation=mas.coulomb.social/power +deployments="mas-mx mas-statute" + +usage() { + echo "usage: power.sh on|off|status" >&2 + exit 2 +} + +power_now() { + kubectl get namespace "$ns" \ + -o jsonpath='{.metadata.annotations.mas\.coulomb\.social/power}' +} + +wait_ready() { + name=$1 + kubectl -n "$ns" rollout status "deploy/$name" --timeout=180s +} + +wait_stopped() { + name=$1 + i=0 + while [ "$i" -lt 90 ]; do + left=$(kubectl -n "$ns" get pods -l "app.kubernetes.io/name=$name" --no-headers 2>/dev/null | wc -l) + left=$(echo "$left" | tr -d ' ') + if [ "$left" -eq 0 ]; then + return 0 + fi + i=$((i + 1)) + sleep 2 + done + echo "pods for $name are still running" >&2 + exit 1 +} + +cmd=${1:-} +case "$cmd" in + on|off) + kubectl annotate namespace "$ns" "$annotation=$cmd" --overwrite + if [ "$cmd" = on ]; then + replicas=1 + else + replicas=0 + fi + for name in $deployments; do + kubectl -n "$ns" scale "deploy/$name" --replicas="$replicas" + done + for name in $deployments; do + if [ "$cmd" = on ]; then + wait_ready "$name" + else + wait_stopped "$name" + fi + done + ;; + status) + echo "power=$(power_now)" + kubectl -n "$ns" get deploy $deployments + kubectl -n "$ns" get endpointslice -l 'kubernetes.io/service-name in (mas-smtp,mas-statute)' + ;; + *) + usage + ;; +esac diff --git a/registry/release-units/mas.yaml b/registry/release-units/mas.yaml index 244ee98..8b65c34 100644 --- a/registry/release-units/mas.yaml +++ b/registry/release-units/mas.yaml @@ -1,6 +1,7 @@ -# No owner classification file. Recorded confidential/medium for this -# validation MX: it holds relay credentials and message evidence, and it is -# capped rather than a production mail service. +# No owner classification file. Recorded confidential/medium: this facility +# holds relay credentials and message evidence. It is the operated MAS +# facility for mas.coulomb.social and can be powered off without removing +# that state. Replies stay capped at 300. kind: release-unit unit_id: mas owner: mail-address-semantics @@ -8,7 +9,7 @@ contract_version: 1.0.0 data_classification: confidential criticality: medium composition: - purpose: Covers the live MAS reference MX and statute service. + purpose: Covers the MAS facility (MX and statute), powered on or off as a pair. member_repos: - repo: mail-address-semantics role: rollout owner diff --git a/tests/test_facility_power.py b/tests/test_facility_power.py new file mode 100644 index 0000000..3393408 --- /dev/null +++ b/tests/test_facility_power.py @@ -0,0 +1,74 @@ +"""MAS facility power render (MAS-WP-0007). No cluster.""" + +import importlib.util +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +_spec = importlib.util.spec_from_file_location( + "facility", ROOT / "deploy/railiance/facility.py" +) +_facility = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_facility) +render = _facility.render +MANIFEST = (ROOT / "deploy/railiance/manifests.yaml").read_text() +POWER = (ROOT / "deploy/railiance/power.sh").read_text() +APPLY = (ROOT / "deploy/railiance/apply.sh").read_text() +IMAGE = "docker.io/library/mas-live:v0.2-480e69a" +DURABLE = ( + "kind: PersistentVolumeClaim", + "name: mas-evidence", + "name: mas-smtp", + "kind: Ingress", + "kind: ExternalSecret", + "name: mas-relay", +) + + +def docs(text): + return text.split("\n---\n") + + +def deployment(text, name): + for doc in docs(text): + if doc.startswith("apiVersion: apps/v1") and f"\n name: {name}\n" in doc: + return doc + raise AssertionError(name) + + +def test_off_stops_both_deployments_and_keeps_durable_objects(): + rendered = render(MANIFEST, IMAGE, "off") + assert rendered.count("\n replicas: 0\n") == 2 + assert "replicas: 1" not in rendered + for name in ("mas-mx", "mas-statute"): + assert "\n replicas: 0\n" in deployment(rendered, name) + for marker in DURABLE: + assert marker in rendered + assert f"image: {IMAGE}" in rendered + assert "image: IMAGE" not in rendered + + +def test_on_leaves_one_replica_each(): + rendered = render(MANIFEST, IMAGE, "on") + assert rendered.count("\n replicas: 1\n") == 2 + assert "replicas: 0" not in rendered + for marker in DURABLE: + assert marker in rendered + + +def test_render_rejects_unknown_power_and_a_blank_image(): + with pytest.raises(ValueError): + render(MANIFEST, IMAGE, "standby") + with pytest.raises(ValueError): + render(MANIFEST, "mas live", "on") + + +def test_operator_command_does_not_delete_durable_objects(): + assert POWER.startswith("#!/bin/bash\n") + for script in (POWER, APPLY): + assert "kubectl delete" not in script + assert "delete namespace" not in script + assert "mas-mx" in POWER and "mas-statute" in POWER + assert "mas.coulomb.social/power" in POWER + assert "facility.py render" in APPLY diff --git a/workplans/MAS-WP-0007-switchable-facility.md b/workplans/MAS-WP-0007-switchable-facility.md index cd8c937..d79eb1d 100644 --- a/workplans/MAS-WP-0007-switchable-facility.md +++ b/workplans/MAS-WP-0007-switchable-facility.md @@ -4,7 +4,7 @@ type: workplan title: "Operate MAS as a switchable facility" domain: communication repo: mail-address-semantics -status: active +status: finished owner: claude topic_slug: coulomb-social created: "2026-10-18" @@ -20,7 +20,7 @@ Residual of MAS-WP-0003. Namespace `mas` is the MAS facility for `mas.coulomb.social`, with replies through Scaleway TEM capped at 300. It stays. The 2026-10-19 retirement backlog is withdrawn. -**Founder decision (2026-11-01): keep it, and make it switchable.** Do not +**Founder decision (2026-11-01, `4d38ed47-8ab1-419e-839c-92921f7eeef1`): keep it, and make it switchable.** Do not delete the namespace, revoke the TEM key, remove DNS, or tear down the OpenBao role. Operate the existing facility so mail and the statute can be turned off and back on without discarding evidence, credentials, or @@ -91,7 +91,7 @@ images. ```task id: MAS-WP-0007-T02 -status: todo +status: done priority: high state_hub_task_id: "18d8898e-52c9-572d-aa7c-014fb1f7751e" ``` @@ -103,11 +103,16 @@ switchable parts and the limits above. The release-unit note stops calling this something other than the operated facility. Historical workplans and `history/` stay as they were. +Done 2026-11-01. `SCOPE.md`, INTENT.md criterion 5, `deploy/railiance/README.md`, +the manifest header, and `registry/release-units/mas.yaml` describe the +switchable facility. The 300-reply cap, side-loaded image, and snakeoil +SMTP TLS are recorded as limits. + ## Add the power switch ```task id: MAS-WP-0007-T03 -status: todo +status: done priority: high state_hub_task_id: "78a11c89-7cda-5ff7-bd0a-b7958b6c9fe7" ``` @@ -124,11 +129,19 @@ ingress. On the cluster, record `power=on` to match the running facility. Do not scale it to 0 and do not roll a new image. +Done 2026-11-01. `deploy/railiance/power.sh` and `apply.sh`, with +`facility.py` doing the render. Live: `power.sh on` with the railiance01 +admin kubeconfig annotated namespace `mas` as `power=on`, scaled both +Deployments to 1, and both were already ready. Pod start times stayed +`mas-mx` 2026-10-21T22:18:00Z and `mas-statute` 2026-10-19T11:36:30Z. +`power.sh status` shows `power=on`, both Deployments 1/1, and endpoints +for `mas-smtp` and `mas-statute`. No new image. + ## Test the switch offline ```task id: MAS-WP-0007-T04 -status: todo +status: done priority: high state_hub_task_id: "3f458be1-a8c8-5b05-96e8-851294e36d60" ``` @@ -137,3 +150,5 @@ Test the render without a cluster. Power `off` sets both Deployment replicas to 0 and leaves the Service, PVC, Ingress, and ExternalSecret in place. Power `on` leaves `replicas: 1`. The image placeholder is replaced. The operator command has no delete of those durable objects. + +Done 2026-11-01. `tests/test_facility_power.py`: 4 passed.