diff --git a/capd/devspace.local-dev.capd.json b/capd/devspace.local-dev.capd.json index c688288..63a5a68 100644 --- a/capd/devspace.local-dev.capd.json +++ b/capd/devspace.local-dev.capd.json @@ -3,11 +3,14 @@ "kind": "dev.inner-loop", "status": "experimental", "name": "SourceOS DevSpace — inner-loop dev-environment", - "description": "Nocalhost-style inner-loop over the continuum: an isolated per-user namespace, hot code sync into a running workload, a sidecar dev container, and port-forward + local debug — shortening the modify->result feedback loop. Consumes the control plane; surfaced to humans via the portal and to agents via the MCP ops surface (one governed source, two views).", + "description": "Nocalhost-style inner-loop over the continuum: an isolated per-user namespace (DevSpace), Signadot-style header-routed sandboxes that share a baseline, and the agent-machine as a StatefulSet with a per-replica TopoLVM inception mount. Consumes the control plane; surfaced to humans via the portal and to agents via the MCP ops surface (one governed source, two views).", "links": { + "engine": "tools/devspace.py", "portal": "tools/portal_server.py", "agent_surface": "tools/mcp_ops_server.py", - "reference_pattern": "Nocalhost DevSpace (inner-loop) — met and bettered: sovereign + evidence-emitting" + "inception_storage": "deploy/topolvm/ (TopoLVM StorageClass + runbook)", + "spec_witness": "docs/PATTERN_INTEGRATION.md", + "reference_pattern": "Nocalhost DevSpace + MeshSpace, Signadot sandboxes, StatefulSet + TopoLVM inception mount — met and bettered: sovereign, tenancy-labelled, grant + quota governed, evidence-emitting" }, "composes_with": { "control_plane": "caps.infra.paas.continuum-local@0.1.0", diff --git a/deploy/topolvm/README.md b/deploy/topolvm/README.md new file mode 100644 index 0000000..252f53e --- /dev/null +++ b/deploy/topolvm/README.md @@ -0,0 +1,44 @@ +# TopoLVM — the agent-machine inception mount + +Per the Edge/Fog design, the agent-machine's persistent **inception mount** (`/var/lib/sourceos/inception`) +is backed by **TopoLVM**: topology-aware, LVM-backed, node-local persistent volumes. The DevSpace PVC +and the agent-machine StatefulSet's `volumeClaimTemplates` default to `storageClassName: +topolvm-provisioner`. + +## Why TopoLVM (not local-path / NFS) + +- **Node-local + fast** — the inception mount is on the node's SSD via LVM, not a network hop. +- **Topology-aware** — `WaitForFirstConsumer` schedules the pod to a node that actually has capacity + to carve the LV, then binds. No "volume provisioned on the wrong node" failures. +- **Capacity-aware + expandable** — TopoLVM tracks free VG space per node and reports it to the + scheduler; `allowVolumeExpansion: true`. + +## Install (production cluster) + +TopoLVM needs cert-manager (its webhook) and an LVM volume group on each storage node. + +```bash +# 1. cert-manager (webhook certs) +kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml + +# 2. on each node: a volume group named to match the device-class (e.g. an SSD VG) +# sudo vgcreate ssd-vg /dev/nvme1n1 # real disk in prod +# (lab: losetup a sparse file, then vgcreate) + +# 3. TopoLVM (Helm) with lvmd pointed at that VG/device-class +helm repo add topolvm https://topolvm.github.io/topolvm +helm install topolvm topolvm/topolvm -n topolvm-system --create-namespace \ + --set lvmd.deviceClasses[0].name=ssd \ + --set lvmd.deviceClasses[0].volume-group=ssd-vg \ + --set lvmd.deviceClasses[0].default=true + +# 4. the StorageClass the DevSpace + StatefulSet reference +kubectl apply -f deploy/topolvm/topolvm-storageclass.yaml +``` + +## Local (kind/podman) note + +A rootless-podman `kind` node has no LVM tooling, so TopoLVM won't provision there. For local proofs +the DevSpace is created with `storage_class="standard"` (kind's local-path) — the inception mount +persistence was verified end-to-end that way (write in one pod, read in another). Production is a +one-field swap back to `topolvm-provisioner`. diff --git a/deploy/topolvm/topolvm-storageclass.yaml b/deploy/topolvm/topolvm-storageclass.yaml new file mode 100644 index 0000000..8cb9b17 --- /dev/null +++ b/deploy/topolvm/topolvm-storageclass.yaml @@ -0,0 +1,20 @@ +# TopoLVM StorageClass for the agent-machine inception mount (Edge/Fog design). +# +# TopoLVM gives topology-aware, LVM-backed, node-local persistent volumes — so the agent-machine's +# inception mount lives on fast local disk and the pod is scheduled to the node that has capacity +# (WaitForFirstConsumer). This is the storage class the DevSpace + agent-machine StatefulSet default +# to (`storage_class="topolvm-provisioner"`). +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: topolvm-provisioner + labels: + sourceos.io/mount: inception +provisioner: topolvm.io +parameters: + # bind to a specific LVM device-class/volume-group configured in lvmd on the nodes. + "csi.storage.k8s.io/fstype": "xfs" + "topolvm.io/device-class": "ssd" +volumeBindingMode: WaitForFirstConsumer # schedule the pod where the LV can be carved +allowVolumeExpansion: true +reclaimPolicy: Delete diff --git a/docs/PATTERN_INTEGRATION.md b/docs/PATTERN_INTEGRATION.md new file mode 100644 index 0000000..57d37a6 --- /dev/null +++ b/docs/PATTERN_INTEGRATION.md @@ -0,0 +1,52 @@ +# Pattern integration register — demonstrated designs vs. our platform + +Every pattern from the reference designs (Nocalhost, Signadot, ShellHub, the Edge/Fog↔Cloud-Twin +K3s design, the 7-layer PaaS, Eclipse/IBM Parallel Environment) mapped to what we have, honestly +graded. `✅` shipped · `◑` partial · `○` gap (named next step). + +## Developer inner loop & environments + +| Pattern (source) | Our implementation | Status | +|---|---|---| +| **DevSpace** — isolated per-user namespace (Nocalhost) | `devspace.devspace_manifests` — Namespace + ResourceQuota + default-deny NetworkPolicy + inception PVC | ✅ | +| **User → Space → Application** tenancy (Nocalhost) | `DevSpacePlane` (tenant→space→env), tenancy labels; admission quotas per subject | ✅ | +| **MeshSpace / Sandbox** — header-routed fork sharing the baseline (Nocalhost MeshSpace, Signadot) | `devspace.sandbox_manifests` — fork Deployment + Istio VirtualService routing `x-sandbox-routing-key` (≡ Nocalhost `uberctx-trace`) | ✅ | +| **Fast inner loop** — file-sync / port-forward / remote-debug / exec-in-container (Nocalhost Dev Mode) | declared in `caps.dev.devspace-inner-loop`; runtime hot-sync agent | ○ next | +| **KubeConfig connect, any cluster** (Nocalhost) | executor honours an explicit `SOURCEOS_KUBE_CONTEXT` (never the current context) | ✅ | +| **Deploy Helm/Kustomize/YAML** (Nocalhost) | executor emits YAML/JSON manifests; Helm/Kustomize adapters | ◑ | + +## Stateful & storage + +| Pattern | Our implementation | Status | +|---|---|---| +| **StatefulSet** — stable identity + per-replica storage (k8s framework for stateful apps) | `devspace.agent_machine_statefulset` — headless Service + `volumeClaimTemplates` | ✅ | +| **TopoLVM inception mount** — node-local LVM PV for the agent-machine (Edge/Fog `PersistentVolumes TopoLVM`) | `INCEPTION_MOUNT_PATH` + PVC/volumeClaimTemplates on `topolvm-provisioner`; `deploy/topolvm/`; persistence proven on a real cluster | ✅ | +| **Volumes vs bind-mounts vs tmpfs** (Docker storage) | inception mount = a persistent **volume** (PVC), not a bind/writable-layer — survives pod death | ✅ | +| **Operator pattern** — CRD + controller for stateful lifecycle | `control_loop` is a governed reconciler; a real `AgentMachine` CRD + operator | ○ next | + +## Compute fabric & jobs (IBM Parallel Environment / HPC Toolkit) + +| Pattern | Our implementation | Status | +|---|---|---| +| **Placement across substrates** (LoadLeveler/SLURM-style) | `compute_plane.place` over local/k8s/hpc-slurm/wasm/p2p/volunteer/blockchain | ✅ | +| **Batch job** | `Job` (k8s, real) / `sbatch` (slurm) | ✅ k8s · ○ slurm submit | +| **Parallel / MPI job (N ranks)** — the POE pattern | Indexed `Job` (k8s) / `--ntasks` MPI + `--array` (slurm) | ○ **next unit** | +| **Real SLURM submission** | today: placement + descriptor only (`DescriptorAdapter`); real `sbatch`/`srun`/MPI adapter | ○ **next unit** | +| **Edge/Fog K3s ↔ Cloud Twin sync** over intermittent links (LAN/WAN/sneakernet) | mesh telemetry + hyperswarm scale-up capability; real twin-sync + S3 export | ◑ | + +## Reach, governance, evidence + +| Pattern | Our implementation | Status | +|---|---|---| +| **SSH gateway to a device fleet** (ShellHub: Server + Agents on computer/device/container/server) | cloud-shell fog: Edge Gateway + HyperSwarm discovery + Grant-bound attach; a ShellHub-style agent per node | ◑ | +| **7-layer PaaS** (UX→Object Store→Derived→Vendor→Retrieval→Policy→Tool-runtime) | knowledge commons (canonical + derived + provenance), MCP surface (tool runtime), promotion gate (policy) | ◑ | +| **Governed connector calls** (Gemini/OpenAI/Claude Files APIs — materialize→handle→dispatch→result) | the SAME governed dispatch as a compute job: a `connector` effect through grant + executor | ○ **next unit** | +| **Append-only audit ledger** (every diagram) | sealed receipts (`artifacts/`), MCP-A2A ledger conformance | ✅ | + +## What this establishes + +The compute mesh + grants + admission is the **governance & scale-out substrate**; the DevSpace/ +Sandbox/StatefulSet plane is the **environment & stateful substrate**. The two open frontiers that +would make the HPC/connector story first-class are one unit: **parallel/MPI jobs (Indexed Job + +real SLURM) and connector-call-as-dispatch** — unifying "run a job" and "call a connector" under one +`Grant`-gated executor. That is the codification the IBM Parallel Environment pattern is asking for. diff --git a/tools/devspace.py b/tools/devspace.py new file mode 100644 index 0000000..8aa8275 --- /dev/null +++ b/tools/devspace.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""DevSpace + Sandbox plane — governed, isolated, ephemeral developer environments. + +Synthesizes three demonstrated patterns into one governed plane, emitting real, appliable k8s +manifests: + + * Nocalhost DevSpace — an isolated per-user namespace with its own quota and default-deny network + isolation; the home for the fast inner loop (file-sync / port-forward / remote-debug). + * The Nocalhost User/Space tenancy model — Workspace/Tenant -> Space -> Application, each space + isolated and labelled. + * Signadot sandboxes — a request-routed ephemeral FORK of a baseline workload. Instead of + duplicating the whole stack, a sandbox shares the baseline cluster and a routing header + (`x-sandbox-routing-key`) sends only matching requests to the fork; everything else hits + baseline. That's how you test one changed service against the real rest-of-system, cheaply. + +Every environment is tenancy-labelled, quota-bounded (composes with `admission`), and Grant-labelled +(composes with `mcp_a2a_grant`) — so who owns it, what it may consume, and under what authority are +all first-class. +""" +from __future__ import annotations + +import re + + +# The agent-machine's persistent writable FS seam ("inception mount"), backed by a PVC. Per the +# Edge/Fog design this is a TopoLVM volume — topology-aware, LVM-backed local storage, so the +# agent-machine's state lives on fast node-local disk and follows the node it's scheduled to. +INCEPTION_MOUNT_PATH = "/var/lib/sourceos/inception" +INCEPTION_PVC = "inception-mount" +DEFAULT_STORAGE_CLASS = "topolvm-provisioner" + + +def _slug(*parts: str) -> str: + s = "-".join(p for p in parts if p) + s = re.sub(r"[^a-z0-9-]", "-", s.lower()).strip("-") + return re.sub(r"-+", "-", s)[:63] + + +def devspace_manifests(*, tenant: str, user: str, space: str, quota: dict | None = None, + grant_id: str | None = None, inception_storage: str = "10Gi", + storage_class: str = DEFAULT_STORAGE_CLASS) -> list[dict]: + """Nocalhost-style DevSpace: an isolated per-user Namespace + ResourceQuota + default-deny + NetworkPolicy + the agent-machine's persistent **inception mount** (a TopoLVM-backed PVC).""" + ns_name = "ds-" + _slug(tenant, user, space) + labels = {"sourceos.io/tenant": tenant, "sourceos.io/user": user, "sourceos.io/space": space, + "sourceos.io/kind": "devspace"} + if grant_id: + labels["sourceos.io/grant-id"] = grant_id + quota = quota or {"pods": "10", "requests.cpu": "4", "requests.memory": "8Gi", + "limits.cpu": "8", "limits.memory": "16Gi"} + return [ + {"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": ns_name, "labels": labels}}, + {"apiVersion": "v1", "kind": "ResourceQuota", + "metadata": {"name": "devspace-quota", "namespace": ns_name, "labels": labels}, + "spec": {"hard": quota}}, + {"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy", + "metadata": {"name": "devspace-isolation", "namespace": ns_name, "labels": labels}, + "spec": {"podSelector": {}, "policyTypes": ["Ingress"], + "ingress": [{"from": [{"podSelector": {}}]}]}}, # only same-DevSpace ingress + {"apiVersion": "v1", "kind": "PersistentVolumeClaim", + "metadata": {"name": INCEPTION_PVC, "namespace": ns_name, + "labels": {**labels, "sourceos.io/mount": "inception"}}, + "spec": {"accessModes": ["ReadWriteOnce"], "storageClassName": storage_class, + "resources": {"requests": {"storage": inception_storage}}}}, + ] + + +def sandbox_manifests(*, baseline: str, image: str, routing_key: str, namespace: str, + grant_id: str | None = None, port: int = 8080, + command: str | None = None) -> list[dict]: + """Signadot-style sandbox: a FORK Deployment of `baseline`, plus an Istio VirtualService that + routes requests carrying `x-sandbox-routing-key: ` to the fork and everything else + to the baseline — so the sandbox shares the cluster instead of duplicating the stack.""" + fork = _slug(baseline, "sbx", routing_key) + labels = {"sourceos.io/kind": "sandbox", "sourceos.io/baseline": baseline, + "sourceos.io/routing-key": routing_key} + if grant_id: + labels["sourceos.io/grant-id"] = grant_id + container = {"name": "app", "image": image, "ports": [{"containerPort": port}]} + if command: + import shlex + container["command"] = shlex.split(command) + deploy = { + "apiVersion": "apps/v1", "kind": "Deployment", + "metadata": {"name": fork, "namespace": namespace, "labels": labels}, + "spec": {"replicas": 1, "selector": {"matchLabels": {"app": fork}}, + "template": {"metadata": {"labels": {**labels, "app": fork}}, + "spec": {"containers": [container]}}}, + } + vs = { + "apiVersion": "networking.istio.io/v1beta1", "kind": "VirtualService", + "metadata": {"name": _slug(baseline, "sandbox-route"), "namespace": namespace, "labels": labels}, + "spec": {"hosts": [baseline], + "http": [ + {"name": f"sandbox-{routing_key}", + "match": [{"headers": {"x-sandbox-routing-key": {"exact": routing_key}}}], + "route": [{"destination": {"host": fork}}]}, + {"name": "baseline", "route": [{"destination": {"host": baseline}}]}, + ]}, + } + return [deploy, vs] + + +def agent_machine_statefulset(*, name: str, tenant: str, space: str, namespace: str, + image: str = "busybox:1.36", replicas: int = 1, + storage_class: str = DEFAULT_STORAGE_CLASS, storage: str = "10Gi", + command: str | None = None, grant_id: str | None = None, + port: int = 8080) -> list[dict]: + """The agent-machine as a STATEFUL app — the right k8s primitive for a long-lived workload that + keeps state. A StatefulSet gives each replica a stable identity and, via `volumeClaimTemplates`, + its OWN persistent inception mount (on TopoLVM) that follows it across reschedules; a headless + Service gives stable network identity. This is what Jobs/Deployments can't do.""" + sname = _slug(name) + labels = {"sourceos.io/kind": "agent-machine", "sourceos.io/tenant": tenant, + "sourceos.io/space": space, "app": sname} + if grant_id: + labels["sourceos.io/grant-id"] = grant_id + container = {"name": "agent-machine", "image": image, + "ports": [{"containerPort": port}], + "resources": {"requests": {"cpu": "100m", "memory": "128Mi"}, + "limits": {"cpu": "100m", "memory": "128Mi"}}, + "volumeMounts": [{"name": "inception", "mountPath": INCEPTION_MOUNT_PATH}]} + if command: + import shlex + container["command"] = shlex.split(command) + svc = {"apiVersion": "v1", "kind": "Service", + "metadata": {"name": sname, "namespace": namespace, "labels": labels}, + "spec": {"clusterIP": "None", "selector": {"app": sname}, + "ports": [{"port": port, "name": "agent"}]}} + sts = {"apiVersion": "apps/v1", "kind": "StatefulSet", + "metadata": {"name": sname, "namespace": namespace, "labels": labels}, + "spec": {"serviceName": sname, "replicas": replicas, + "selector": {"matchLabels": {"app": sname}}, + "template": {"metadata": {"labels": labels}, "spec": {"containers": [container]}}, + "volumeClaimTemplates": [ + {"metadata": {"name": "inception", "labels": {"sourceos.io/mount": "inception"}}, + "spec": {"accessModes": ["ReadWriteOnce"], "storageClassName": storage_class, + "resources": {"requests": {"storage": storage}}}}]}} + return [svc, sts] + + +class DevSpacePlane: + """The User/Space tenancy model: a tenant owns spaces; a space holds devspaces + sandboxes.""" + + def __init__(self): + self._registry: dict[str, dict] = {} + + def provision_devspace(self, *, tenant, user, space, quota=None, grant_id=None) -> dict: + manifests = devspace_manifests(tenant=tenant, user=user, space=space, quota=quota, grant_id=grant_id) + ns = manifests[0]["metadata"]["name"] + rec = {"kind": "devspace", "tenant": tenant, "user": user, "space": space, + "namespace": ns, "manifests": manifests} + self._registry[ns] = rec + return rec + + def provision_sandbox(self, *, tenant, space, baseline, image, routing_key, namespace, + grant_id=None, command=None) -> dict: + manifests = sandbox_manifests(baseline=baseline, image=image, routing_key=routing_key, + namespace=namespace, grant_id=grant_id, command=command) + key = f"{namespace}/{baseline}#{routing_key}" + rec = {"kind": "sandbox", "tenant": tenant, "space": space, "baseline": baseline, + "routing_key": routing_key, "namespace": namespace, "manifests": manifests} + self._registry[key] = rec + return rec + + def environments(self, *, tenant=None) -> list[dict]: + return [r for r in self._registry.values() if tenant is None or r["tenant"] == tenant] + + +if __name__ == "__main__": + import json + plane = DevSpacePlane() + ds = plane.provision_devspace(tenant="acme", user="alice", space="feature-x") + sb = plane.provision_sandbox(tenant="acme", space="feature-x", baseline="productpage", + image="acme/productpage:pr-42", routing_key="pr-42", + namespace=ds["namespace"]) + print(json.dumps({"devspace_namespace": ds["namespace"], + "devspace_kinds": [m["kind"] for m in ds["manifests"]], + "sandbox_fork": sb["manifests"][0]["metadata"]["name"], + "routing": sb["manifests"][1]["spec"]["http"][0]["match"]}, indent=2)) diff --git a/tools/executor.py b/tools/executor.py index 90ab945..6ed68cd 100644 --- a/tools/executor.py +++ b/tools/executor.py @@ -60,20 +60,23 @@ class K8sAdapter: backend = "k8s" def manifest(self, workload, decision, grant): + # Always set requests + limits (with small defaults) so pods are admissible under a DevSpace + # ResourceQuota, which requires them. res = workload.get("resource", {}) - requests = {} - if res.get("cpu"): - requests["cpu"] = str(res["cpu"]) - if res.get("mem"): - requests["memory"] = str(res["mem"]) + requests = {"cpu": str(res.get("cpu", "100m")), "memory": str(res.get("mem", "128Mi"))} limits = dict(requests) if workload.get("needs_gpu"): limits["nvidia.com/gpu"] = "1" container = {"name": "workload", - "image": workload.get("image", "busybox:1.36"), - "command": shlex.split(workload["command"]) if workload.get("command") else ["true"]} - if requests or limits: - container["resources"] = {k: v for k, v in (("requests", requests), ("limits", limits)) if v} + "image": workload.get("image") or "busybox:1.36", # or-default: "" is falsy but present + "command": shlex.split(workload["command"]) if workload.get("command") else ["true"], + "resources": {"requests": requests, "limits": limits}} + pod_spec = {"restartPolicy": "Never", "containers": [container]} + # Mount the agent-machine's persistent inception mount (a TopoLVM-backed PVC) when asked. + pvc = workload.get("inception_pvc") + if pvc: + container["volumeMounts"] = [{"name": "inception", "mountPath": "/var/lib/sourceos/inception"}] + pod_spec["volumes"] = [{"name": "inception", "persistentVolumeClaim": {"claimName": pvc}}] return { "apiVersion": "batch/v1", "kind": "Job", "metadata": {"generateName": f"{workload.get('name', 'wl')}-", @@ -82,7 +85,7 @@ def manifest(self, workload, decision, grant): "sourceos.io/backend": "k8s"}}, "spec": {"backoffLimit": 0, "ttlSecondsAfterFinished": 3600, "template": {"metadata": {"labels": {"sourceos.io/grant-id": grant["grant_id"]}}, - "spec": {"restartPolicy": "Never", "containers": [container]}}}, + "spec": pod_spec}}, } def dispatch(self, workload, decision, grant, *, apply): diff --git a/tools/sourceosctl.py b/tools/sourceosctl.py index 0c4652b..2a18984 100644 --- a/tools/sourceosctl.py +++ b/tools/sourceosctl.py @@ -50,11 +50,15 @@ def _sha(s: str) -> str: def run_workload(*, name, command, effect, sensitivity, scalable, gpu, image, subject, - heartbeats_dir, key, apply=False, dry=False, admission=None, cost=1.0) -> dict: + heartbeats_dir, key, apply=False, dry=False, admission=None, cost=1.0, + inception=False) -> dict: """Core of `run` — testable without the CLI. Returns the full spine trace (or a placement).""" reg = mt.MeshRegistry.from_dir(heartbeats_dir) workload = {"name": name, "command": command, "effect": effect, "sensitivity": sensitivity, "scalable": scalable, "needs_gpu": gpu, "image": image} + if inception: # mount the agent-machine's persistent TopoLVM inception mount (k8s backend) + import devspace + workload["inception_pvc"] = devspace.INCEPTION_PVC policy = {"require_attestation": sensitivity == "sensitive"} if dry: return {"status": "placed", "decision": cp.place(workload, policy, reg.availability())} @@ -77,7 +81,8 @@ def cmd_run(args) -> int: out = run_workload(name=args.name, command=args.command, effect=args.effect, sensitivity=args.sensitivity, scalable=not args.no_scale, gpu=args.gpu, image=args.image, subject=args.subject, heartbeats_dir=HEARTBEATS, - key=_key(), apply=args.apply, dry=args.dry, admission=admission, cost=args.cost) + key=_key(), apply=args.apply, dry=args.dry, admission=admission, cost=args.cost, + inception=args.inception) if out["status"] == "denied": a = out["admission"] print(f"DENIED (fail-closed): {a['reason']} [usage {a['usage']} vs quota {a['quota']}]") @@ -143,6 +148,7 @@ def build_parser() -> argparse.ArgumentParser: r.add_argument("--no-scale", action="store_true", help="keep it small (non-scalable)") r.add_argument("--subject", default="spiffe://sourceos/agent/dev", help="the requesting subject SPIFFE id") r.add_argument("--cost", type=float, default=1.0, help="cost units to charge against the subject's budget") + r.add_argument("--inception", action="store_true", help="mount the agent-machine's persistent TopoLVM inception mount (k8s)") r.add_argument("--apply", action="store_true", help="actually execute (local subprocess / kubectl apply)") r.add_argument("--dry", action="store_true", help="only decide placement; dispatch nothing") r.set_defaults(func=cmd_run) diff --git a/tools/test_devspace.py b/tools/test_devspace.py new file mode 100644 index 0000000..23d13af --- /dev/null +++ b/tools/test_devspace.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Tests for the DevSpace + Sandbox plane. Covers the Nocalhost DevSpace (isolated, tenancy-labelled, +TopoLVM inception mount), the Signadot sandbox (header-routed fork sharing the baseline), and the +agent-machine StatefulSet (per-replica TopoLVM inception mount via volumeClaimTemplates).""" +import devspace as ds + + +def test_devspace_is_isolated_tenancy_labelled_and_topolvm_backed(): + ms = ds.devspace_manifests(tenant="acme", user="alice", space="feat") + assert [m["kind"] for m in ms] == ["Namespace", "ResourceQuota", "NetworkPolicy", "PersistentVolumeClaim"] + ns = ms[0] + assert ns["metadata"]["name"] == "ds-acme-alice-feat" + assert ns["metadata"]["labels"]["sourceos.io/tenant"] == "acme" + pvc = ms[3] + assert pvc["metadata"]["name"] == "inception-mount" + assert pvc["spec"]["storageClassName"] == "topolvm-provisioner" # TopoLVM by default (Edge/Fog design) + assert pvc["spec"]["accessModes"] == ["ReadWriteOnce"] + + +def test_devspace_storage_class_is_overridable_for_kind(): + ms = ds.devspace_manifests(tenant="a", user="b", space="c", storage_class="standard") + assert ms[3]["spec"]["storageClassName"] == "standard" + + +def test_devspace_netpol_denies_cross_devspace_ingress(): + np = ds.devspace_manifests(tenant="a", user="b", space="c")[2] + assert np["spec"]["policyTypes"] == ["Ingress"] + assert np["spec"]["ingress"] == [{"from": [{"podSelector": {}}]}] # only same-DevSpace + + +def test_sandbox_routes_by_header_to_the_fork_else_baseline(): + dep, vs = ds.sandbox_manifests(baseline="productpage", image="acme/pp:pr-42", + routing_key="pr-42", namespace="ns") + assert dep["kind"] == "Deployment" and dep["metadata"]["name"] == "productpage-sbx-pr-42" + http = vs["spec"]["http"] + assert http[0]["match"][0]["headers"]["x-sandbox-routing-key"]["exact"] == "pr-42" + assert http[0]["route"][0]["destination"]["host"] == "productpage-sbx-pr-42" # matching -> fork + assert http[1]["route"][0]["destination"]["host"] == "productpage" # default -> baseline + + +def test_agent_machine_is_a_statefulset_with_per_replica_topolvm_inception_mount(): + svc, sts = ds.agent_machine_statefulset(name="worker", tenant="acme", space="feat", + namespace="ns", replicas=3) + assert svc["kind"] == "Service" and svc["spec"]["clusterIP"] == "None" # headless -> stable identity + assert sts["kind"] == "StatefulSet" + assert sts["spec"]["serviceName"] == "worker" and sts["spec"]["replicas"] == 3 + vct = sts["spec"]["volumeClaimTemplates"][0] + assert vct["metadata"]["name"] == "inception" + assert vct["spec"]["storageClassName"] == "topolvm-provisioner" # per-replica TopoLVM volume + vm = sts["spec"]["template"]["spec"]["containers"][0]["volumeMounts"][0] + assert vm["mountPath"] == ds.INCEPTION_MOUNT_PATH + + +def test_plane_provisions_environments_and_lists_by_tenant(): + p = ds.DevSpacePlane() + p.provision_devspace(tenant="acme", user="alice", space="x") + p.provision_sandbox(tenant="acme", space="x", baseline="pp", image="img", + routing_key="k", namespace="ds-acme-alice-x") + p.provision_devspace(tenant="other", user="bob", space="y") + assert len(p.environments(tenant="acme")) == 2 + assert len(p.environments()) == 3 + + +if __name__ == "__main__": + import sys + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")] + for fn in fns: + fn() + print(f"ok: {len(fns)} devspace tests passed") + sys.exit(0) diff --git a/tools/test_executor.py b/tools/test_executor.py index 721ed6b..f1a31be 100644 --- a/tools/test_executor.py +++ b/tools/test_executor.py @@ -81,6 +81,15 @@ def test_k8s_apply_refuses_without_an_explicit_context(): os.environ["SOURCEOS_KUBE_CONTEXT"] = saved +def test_k8s_mounts_the_inception_pvc_when_requested(): + d = _decision("k8s") + res = ex.execute({"command": "echo x", "effect": "exec", "inception_pvc": "inception-mount"}, + d, _grant(d, "exec"), session_id="sess_exec1", verifier=VERIFIER, apply=False) + spec = res["dispatch"]["manifest"]["spec"]["template"]["spec"] + assert spec["volumes"][0]["persistentVolumeClaim"]["claimName"] == "inception-mount" + assert spec["containers"][0]["volumeMounts"][0]["mountPath"] == "/var/lib/sourceos/inception" + + def test_descriptor_adapter_emits_a_backend_specific_descriptor(): d = _decision("hpc-slurm") res = ex.execute({"command": "srun train", "effect": "compute"}, d, _grant(d, "compute"), diff --git a/tools/validate.py b/tools/validate.py index 66710e7..f1338f6 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -34,6 +34,7 @@ "tools/sourceosctl.py", "tools/admission.py", "tools/control_loop.py", + "tools/devspace.py", ] CAPD_KEYS = ("capability_id", "kind", "status", "links", "composes_with", "policy") # Every CapD in capd/ must carry the core keys and parse — not just the flagship control-plane one.