142 lines
4.6 KiB
Python
142 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Emit the complete, self-contained deployment for this rig.
|
|
|
|
python3 ctrl/manifest.py [namespace] > generated/<slug>.yaml
|
|
|
|
The output is the ARTIFACT. It carries everything — namespace, the vite app and
|
|
bundle.json embedded in a ConfigMap, the Pod and the Service — so it applies
|
|
unmodified to any cluster:
|
|
|
|
kubectl apply -f generated/sample-rig.yaml
|
|
|
|
On kind, MetalLB answers the `type: LoadBalancer` Service. On a real external
|
|
cluster the cloud load balancer does. Same file, no edits, no branch — which is
|
|
the point: what runs locally is byte-identical to the deployment applied
|
|
elsewhere, so local success actually means something.
|
|
|
|
`ctrl/bundle.sh up` applies this same generated output rather than a separate
|
|
code path, so the local convenience wrapper can never drift from the artifact.
|
|
|
|
Stdlib only, deliberately: this must run on a bare staging workstation before
|
|
anything is installed, so it cannot depend on PyYAML or a template engine.
|
|
|
|
Open seam — not built: everything here derives from bundle.json plus a target
|
|
namespace. A Pulumi or Terraform emitter would sit beside this file consuming the
|
|
same inputs; nothing above it assumes the artifact is YAML.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
UI = ROOT / "rig-ui"
|
|
|
|
# Files embedded into the ConfigMap, mounted read-only at /src in the pod and
|
|
# copied into vite's layout at start (see rig-ui/k8s.yaml). Flat on purpose:
|
|
# ConfigMap keys cannot contain '/'.
|
|
EMBEDDED = {
|
|
"bundle.json": ROOT / "bundle.json",
|
|
"package.json": UI / "package.json",
|
|
"vite.config.js": UI / "vite.config.js",
|
|
"index.html": UI / "index.html",
|
|
"main.js": UI / "src" / "main.js",
|
|
"style.css": UI / "src" / "style.css",
|
|
}
|
|
|
|
|
|
def slug(name: str) -> str:
|
|
"""Reduce a folder name to a DNS label, matching ctrl/bundle.sh's rule."""
|
|
out = re.sub(r"[^a-z0-9-]", "-", name.lower()).strip("-")
|
|
return out or "rig-bundle"
|
|
|
|
|
|
def block(text: str, indent: int) -> str:
|
|
"""Indent a file's contents for a YAML literal block scalar.
|
|
|
|
Blank lines are emitted truly empty rather than as whitespace: trailing
|
|
spaces on an otherwise blank line are legal YAML but show up as diff noise
|
|
in a committed artifact.
|
|
"""
|
|
pad = " " * indent
|
|
return "\n".join(pad + line if line.strip() else "" for line in text.splitlines())
|
|
|
|
|
|
def checksum(parts: list[str]) -> str:
|
|
"""Stable content hash of everything embedded, stamped as a label.
|
|
|
|
A mounted ConfigMap updates in place without restarting anything, so without
|
|
a visible change nothing signals that the pod is serving stale content.
|
|
"""
|
|
return str(zlib.crc32("".join(parts).encode()) & 0xFFFFFFFF)
|
|
|
|
|
|
def build(namespace: str) -> str:
|
|
contents = {}
|
|
for key, path in EMBEDDED.items():
|
|
if not path.exists():
|
|
sys.exit(f"missing input: {path}")
|
|
contents[key] = path.read_text()
|
|
|
|
# Fail loudly here rather than shipping an artifact that renders an error.
|
|
try:
|
|
json.loads(contents["bundle.json"])
|
|
except json.JSONDecodeError as exc:
|
|
sys.exit(f"bundle.json is not valid JSON: {exc}")
|
|
|
|
app = (UI / "k8s.yaml").read_text()
|
|
app = app.replace("__RIG_NAME__", namespace)
|
|
|
|
data = "\n".join(
|
|
f" {key}: |\n{block(text, 4)}" for key, text in sorted(contents.items())
|
|
)
|
|
|
|
return f"""# GENERATED by ctrl/manifest.py — do not edit.
|
|
# Regenerate with: make manifest
|
|
#
|
|
# Self-contained: applies as-is to any cluster, local kind or external.
|
|
# kubectl apply -f this-file.yaml
|
|
#
|
|
# Namespace carries the identity, so several rigs coexist in one cluster.
|
|
apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: {namespace}
|
|
labels:
|
|
rig.bundle/name: {namespace}
|
|
---
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: rig-ui
|
|
namespace: {namespace}
|
|
labels:
|
|
rig.bundle/checksum: "{checksum(list(contents.values()))}"
|
|
data:
|
|
{data}
|
|
---
|
|
{_namespaced(app, namespace)}
|
|
"""
|
|
|
|
|
|
def _namespaced(doc: str, namespace: str) -> str:
|
|
"""Add `namespace:` to each resource so the artifact applies without -n.
|
|
|
|
rig-ui/k8s.yaml omits it on purpose — applied by hand it should land in
|
|
whatever namespace you choose. Pinning it belongs to the generated artifact,
|
|
which has to be self-contained.
|
|
"""
|
|
return re.sub(
|
|
r"^(metadata:\n(?:[ \t]+.*\n)*?)([ \t]+)(name: rig-ui)$",
|
|
lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}\n{m.group(2)}namespace: {namespace}",
|
|
doc.strip(),
|
|
flags=re.MULTILINE,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
target = sys.argv[1] if len(sys.argv) > 1 else slug(ROOT.name)
|
|
sys.stdout.write(build(target))
|