From 24a3eaf56ea0e7987afa5b4961574d6252c25b8c Mon Sep 17 00:00:00 2001 From: mdheller <21163552+mdheller@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:38:56 -0400 Subject: [PATCH] =?UTF-8?q?feat(devmode):=20Nocalhost-style=20inner=20loop?= =?UTF-8?q?=20=E2=80=94=20dev-mode=20patch=20+=20file-sync=20+=20port-forw?= =?UTF-8?q?ard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tight modify->result loop against a real cluster — the point of a local-first PaaS. Edit on the low-mem box, see it running in a real DevSpace pod in seconds, no image rebuild. tools/devmode.py (pure, unit-tested core): - devmode_patch: strategic-merge patch that swaps a Deployment's container for a dev-runner with an in-pod /workspace, so synced code runs live; grant-labelled, devmode-labelled. - sync_command: `kubectl cp` local -> pod (hot reload, no rebuild). - port_forward_command: `kubectl port-forward` local:remote (local access + debug port). - devmode_plan: ties patch + sync + forward into one inner-loop plan. Nocalhost Dev Mode (file-sync/local-access/debugging), governed and in the user's own DevSpace namespace. Tests: +4 = 133 tools tests green. Wired validate REQUIRED + devspace CapD link. Live k3s proof to follow. --- capd/devspace.local-dev.capd.json | 1 + tools/devmode.py | 77 +++++++++++++++++++++++++++++++ tools/test_devmode.py | 41 ++++++++++++++++ tools/validate.py | 1 + 4 files changed, 120 insertions(+) create mode 100644 tools/devmode.py create mode 100644 tools/test_devmode.py diff --git a/capd/devspace.local-dev.capd.json b/capd/devspace.local-dev.capd.json index 63a5a68..24ed278 100644 --- a/capd/devspace.local-dev.capd.json +++ b/capd/devspace.local-dev.capd.json @@ -6,6 +6,7 @@ "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", + "devmode": "tools/devmode.py", "portal": "tools/portal_server.py", "agent_surface": "tools/mcp_ops_server.py", "inception_storage": "deploy/topolvm/ (TopoLVM StorageClass + runbook)", diff --git a/tools/devmode.py b/tools/devmode.py new file mode 100644 index 0000000..8fba7b8 --- /dev/null +++ b/tools/devmode.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Nocalhost-style dev-mode — the tight modify->result inner loop against a real cluster. + +The whole point of a local-first PaaS: edit on the low-mem box, see it running in a real DevSpace +pod in seconds, without rebuilding an image. Dev-mode does the three things Nocalhost's Dev Mode +does, governed and in the user's own DevSpace namespace: + + 1. put a workload into DEV MODE — swap its container for a dev-runner with an in-pod workspace, so + synced code runs live (no image rebuild); + 2. SYNC local files into the running pod (hot reload) — `kubectl cp`; + 3. PORT-FORWARD the remote service (+ a debug port) to localhost (local access + remote debug). + +The manifest patch and the kubectl command lines are pure functions (unit-tested); a driver wraps +them for a live cluster. +""" +from __future__ import annotations + +DEV_WORKSPACE = "/workspace" + + +def devmode_patch(*, workload: str, dev_image: str = "python:3.12-alpine", + run_cmd: str | None = None, grant_id: str | None = None) -> dict: + """A strategic-merge patch that puts a Deployment into dev mode: a dev-runner container with an + emptyDir workspace at /workspace, running the synced code (or idling until synced).""" + labels = {"sourceos.io/devmode": "on", "sourceos.io/workload": workload} + if grant_id: + labels["sourceos.io/grant-id"] = grant_id + container = {"name": "dev", "image": dev_image, "workingDir": DEV_WORKSPACE, + "command": ["sh", "-c", run_cmd or "sleep infinity"], + "volumeMounts": [{"name": "workspace", "mountPath": DEV_WORKSPACE}]} + return {"metadata": {"labels": labels}, + "spec": {"template": {"metadata": {"labels": labels}, + "spec": {"containers": [container], + "volumes": [{"name": "workspace", "emptyDir": {}}]}}}} + + +def _ctx(context): + return ["--context", context] if context else [] + + +def sync_command(*, local_dir: str, namespace: str, pod: str, container: str = "dev", + remote: str = DEV_WORKSPACE, context: str | None = None) -> list: + """`kubectl cp` local -> pod: the file-sync / hot-reload step (no image rebuild).""" + return ["kubectl", *_ctx(context), "cp", local_dir, f"{namespace}/{pod}:{remote}", "-c", container] + + +def port_forward_command(*, namespace: str, pod: str, ports: list, context: str | None = None) -> list: + """`kubectl port-forward` local:remote — local access + a debug port. ports = [(local, remote)].""" + maps = [f"{lo}:{re}" for lo, re in ports] + return ["kubectl", *_ctx(context), "port-forward", "-n", namespace, f"pod/{pod}", *maps] + + +def devmode_plan(*, workload: str, namespace: str, local_dir: str, ports: list, + dev_image: str = "python:3.12-alpine", run_cmd: str | None = None, + context: str | None = None, grant_id: str | None = None) -> dict: + """The full inner-loop plan: patch the workload into dev-mode, sync files, forward ports.""" + return { + "workspace": DEV_WORKSPACE, + "patch": devmode_patch(workload=workload, dev_image=dev_image, run_cmd=run_cmd, grant_id=grant_id), + "patch_command": ["kubectl", *_ctx(context), "-n", namespace, "patch", "deployment", workload, + "--type", "strategic", "-p", ""], + "sync": sync_command(local_dir=local_dir, namespace=namespace, pod=f"<{workload}-pod>", + context=context), + "forward": port_forward_command(namespace=namespace, pod=f"<{workload}-pod>", ports=ports, + context=context), + } + + +if __name__ == "__main__": + import json + plan = devmode_plan(workload="productpage", namespace="ds-acme-alice-feat", + local_dir="./app", ports=[(8080, 8080), (5678, 5678)], + run_cmd="python -m http.server 8080") + print(json.dumps({"workspace": plan["workspace"], + "dev_container": plan["patch"]["spec"]["template"]["spec"]["containers"][0]["name"], + "sync": " ".join(plan["sync"]), + "forward": " ".join(plan["forward"])}, indent=2)) diff --git a/tools/test_devmode.py b/tools/test_devmode.py new file mode 100644 index 0000000..2904f25 --- /dev/null +++ b/tools/test_devmode.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Tests for dev-mode manifest/command generation (the pure core of the inner loop).""" +import devmode as dm + + +def test_devmode_patch_swaps_in_a_dev_runner_with_a_workspace(): + p = dm.devmode_patch(workload="productpage", run_cmd="python -m http.server 8080", grant_id="g1") + c = p["spec"]["template"]["spec"]["containers"][0] + assert c["name"] == "dev" and c["workingDir"] == "/workspace" + assert c["command"] == ["sh", "-c", "python -m http.server 8080"] + assert c["volumeMounts"][0]["mountPath"] == "/workspace" + assert p["spec"]["template"]["spec"]["volumes"][0]["emptyDir"] == {} + assert p["metadata"]["labels"]["sourceos.io/devmode"] == "on" + assert p["metadata"]["labels"]["sourceos.io/grant-id"] == "g1" + + +def test_sync_command_is_kubectl_cp_into_the_workspace(): + cmd = dm.sync_command(local_dir="./app", namespace="ds-x", pod="pp-abc", context="kind-x") + assert cmd == ["kubectl", "--context", "kind-x", "cp", "./app", "ds-x/pp-abc:/workspace", "-c", "dev"] + + +def test_port_forward_maps_local_to_remote(): + cmd = dm.port_forward_command(namespace="ds-x", pod="pp-abc", ports=[(8080, 8080), (5678, 5678)]) + assert cmd == ["kubectl", "port-forward", "-n", "ds-x", "pod/pp-abc", "8080:8080", "5678:5678"] + + +def test_plan_ties_patch_sync_and_forward(): + plan = dm.devmode_plan(workload="pp", namespace="ds-x", local_dir="./app", + ports=[(8080, 8080)], run_cmd="python -m http.server 8080") + assert plan["workspace"] == "/workspace" + assert "cp" in plan["sync"] and "port-forward" in plan["forward"] + assert plan["patch"]["spec"]["template"]["spec"]["containers"][0]["name"] == "dev" + + +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)} devmode tests passed") + sys.exit(0) diff --git a/tools/validate.py b/tools/validate.py index ee88cb6..550674e 100644 --- a/tools/validate.py +++ b/tools/validate.py @@ -38,6 +38,7 @@ "tools/devspace.py", "tools/work_unit.py", "tools/lease_scheduler.py", + "tools/devmode.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.