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
This commit is contained in:
tegwick 2026-11-01 22:46:46 +01:00
parent b108e89af4
commit f18eb0b8df
12 changed files with 344 additions and 36 deletions

View file

@ -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 \

View file

@ -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.

23
deploy/railiance/apply.sh Executable file
View file

@ -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 -

View file

@ -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))

View file

@ -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}

73
deploy/railiance/power.sh Executable file
View file

@ -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