From 774bc5ae0aae02910e95806322b2024b35c45e29 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 24 Jun 2026 12:56:32 +0200 Subject: [PATCH] feat: Packer build orchestration (SAND-WP-0012) Add vm-packer build mode, profile.vm-packer-build, State Hub progress notes during long provision, docs/runbook, and build mode tests. --- SCOPE.md | 13 +- docs/extension-sdk.md | 5 +- docs/migration-build-machines.md | 33 +++- docs/runbooks/profile-vm-packer-build.md | 54 ++++++ extensions/ext.vm-packer.yaml | 6 +- profiles/profile.vm-packer-build.yaml | 37 ++++ src/sandboxer/cli.py | 6 +- src/sandboxer/core/manager.py | 10 ++ src/sandboxer/extensions/vm_packer.py | 158 +++++++++++++++++- src/sandboxer/lifecycle/state_hub.py | 31 ++++ tests/test_vm_packer.py | 75 ++++++++- .../SAND-WP-0012-packer-orchestration.md | 50 +++--- 12 files changed, 426 insertions(+), 52 deletions(-) create mode 100644 docs/runbooks/profile-vm-packer-build.md create mode 100644 profiles/profile.vm-packer-build.yaml diff --git a/SCOPE.md b/SCOPE.md index aa9228d..18c2385 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -116,24 +116,24 @@ own tunnels or CAs. - **Status:** v0 operational — self-hosted compose path proven on CoulombCore; routing, payments stub, and snapshots shipped -- **Workplans finished:** SAND-WP-0001–0011 (0003/0004 in sibling repos) -- **Workplans ready:** SAND-WP-0012 (Packer orchestration) +- **Workplans finished:** SAND-WP-0001–0012 (0003/0004 in sibling repos) +- **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track) - **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments, snapshots, telemetry, HTTP API - **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub, - burst-sandbox, e2b-burst, modal-gpu, agent-dev, build + burst-sandbox, e2b-burst, modal-gpu, agent-dev, build, vm-packer-build - **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`, `ext.e2b`, `ext.modal` - **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`, `payments`, `snapshots`, `migration-gaps`, `migration-build-machines` - **Registry:** `capability.execution.sandbox-provision` indexed (draft) -- **Tests:** 86 pytest cases; `make check` green +- **Tests:** 90 pytest cases; `make check` green - **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian `make e2e REPO=` shim (SAND-WP-0004) Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` -**Ready workplans:** SAND-WP-0012 (Packer orchestration). +**Ready workplans:** none — gap analysis items complete; operator tracks remain. --- @@ -157,6 +157,7 @@ sandboxer inspect host / inspect stale / reap-stale [--apply] sandboxer reachability show sandboxer create --profile profile.agent-dev --input repo=/path --actor agt --project glas-harness sandboxer create --profile profile.build --input vm=haskell-build --actor agt --project snuggle-inventor +sandboxer create --profile profile.vm-packer-build --input packer_template=... --input vm_name=haskell-build make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST) # Full e2e validation (wise-validator, separate install): @@ -175,7 +176,7 @@ cd ~/the-custodian && make e2e REPO=activity-core ## What Is Not Possible Yet - ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009) -- Packer build orchestration from `create` — **SAND-WP-0012** +- ~~Packer build orchestration from `create`~~ — done (SAND-WP-0012) - ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010) - ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011) - Cross-host snapshot transfer diff --git a/docs/extension-sdk.md b/docs/extension-sdk.md index bc8045b..6a9be64 100644 --- a/docs/extension-sdk.md +++ b/docs/extension-sdk.md @@ -33,7 +33,7 @@ Reference implementations: | Extension | Module | Mode | |-----------|--------|------| | `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots | -| `ext.vm-packer` | `vm_packer.py` | Attach workspace on pre-built VM | +| `ext.vm-packer` | `vm_packer.py` | Attach workspace or Packer build mode | | `ext.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots | | `ext.e2b` | `e2b.py` | E2B cloud adapter | | `ext.modal` | `modal.py` | Modal cloud adapter | @@ -82,7 +82,8 @@ Profiles declare semantics; extensions validate required `inputs` keys: | Extension | Required inputs | Optional | |-----------|-----------------|----------| | compose-ssh | `repo` | `sandbox_id` | -| vm-packer | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` | +| vm-packer (attach) | `vm` or `ssh_target` | `repo`, `tunnel_port`, `ssh_port`, `workspace_dir` | +| vm-packer (build) | `packer_template`, `vm_name` | `mode=build`, `packer_var_*` | Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs. diff --git a/docs/migration-build-machines.md b/docs/migration-build-machines.md index c3d0237..3820019 100644 --- a/docs/migration-build-machines.md +++ b/docs/migration-build-machines.md @@ -8,7 +8,8 @@ Maps `the-custodian/infra/build-machines/` to sand-boxer `profile.vm-haskell-bui |-------------------------|---------------| | Packer OVA build | **Unchanged** — operator runs Packer in the-custodian | | VM boot + build-agent registration | **Unchanged** — systemd agent on VM | -| `make remote-build PROJECT=` | `sandboxer create` + SSH into `reachability.remote_dir` | +| `make remote-build PROJECT=` | `sandboxer create --profile profile.vm-haskell-build` + SSH build (shim in build-machines Makefile) | +| `packer build` in `haskell/` | `sandboxer create --profile profile.vm-packer-build` | | Isolated workspace `/build/` | `/build/sbx-/` per create | | `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | @@ -50,12 +51,36 @@ sandboxer destroy | `repo` | Optional rsync source to workspace | | `workspace_dir` | Override workspace path on VM | +## Packer build mode (SAND-WP-0012) + +```bash +sandboxer create \ + --profile profile.vm-packer-build \ + --input packer_template=~/the-custodian/infra/build-machines/haskell \ + --input vm_name=haskell-build \ + --host localhost +``` + +| Input | Purpose | +|-------|---------| +| `mode` | `build` (default for profile.vm-packer-build) or `attach` | +| `packer_template` | Directory containing `*.pkr.hcl` | +| `vm_name` / `vm` | Packer `vm_name` variable | +| `packer_var_*` | Extra Packer `-var` flags (suffix → variable name) | + +Runbook: `docs/runbooks/profile-vm-packer-build.md` + +## Port registry (read-only pointer) + +`the-custodian/infra/build-machines/port-registry.yml` maps tunnel ports +12221–12230 to VM slots. When attaching via tunnel, set +`SANDBOXER_VM_TUNNEL_PORT` or `--input tunnel_port=` to a registered port. +Full ops-bridge automation is deferred — operators bring tunnels up manually. + ## Not migrated yet -- Automated Packer `create` trigger from sand-boxer API - State Hub capability-catalog sync from build-agent (agent unchanged) -- Port registry automation (`port-registry.yml`) -- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed) +- Automated port-registry → ops-bridge config generation ## Runbook diff --git a/docs/runbooks/profile-vm-packer-build.md b/docs/runbooks/profile-vm-packer-build.md new file mode 100644 index 0000000..eb5ee84 --- /dev/null +++ b/docs/runbooks/profile-vm-packer-build.md @@ -0,0 +1,54 @@ +# profile.vm-packer-build — Runbook + +Trigger a Packer OVA build on the local workstation (build-machines lineage). + +## Prerequisites + +- **Packer** >= 1.10 (`packer version`) +- **VirtualBox** >= 7.0 (`VBoxManage --version`) +- Template directory from `the-custodian/infra/build-machines/haskell` +- `sandboxer` on PATH + +## Build OVA + +```bash +sandboxer create \ + --profile profile.vm-packer-build \ + --input packer_template=~/the-custodian/infra/build-machines/haskell \ + --input vm_name=haskell-build \ + --host localhost +``` + +Progress notes emit to State Hub during `packer init` and `packer build`. + +On success, `reachability.remote_dir` points at the produced `.ova` file. + +## Optional Packer variables + +Pass extra `-var` flags via inputs prefixed with `packer_var_`: + +```bash +sandboxer create \ + --profile profile.vm-packer-build \ + --input packer_template=~/the-custodian/infra/build-machines/haskell \ + --input vm_name=haskell-build \ + --input packer_var_memory=16384 \ + --host localhost +``` + +## Destroy + +```bash +sandboxer destroy +``` + +Removes the sandbox record only; the OVA artifact on disk is preserved. + +## Attach workflow (post-build) + +After import/setup per build-machines README, use `profile.vm-haskell-build` +for workspace attach — see `docs/runbooks/profile-vm-haskell-build.md`. + +## Migration reference + +`docs/migration-build-machines.md` \ No newline at end of file diff --git a/extensions/ext.vm-packer.yaml b/extensions/ext.vm-packer.yaml index 009b133..7e7cab2 100644 --- a/extensions/ext.vm-packer.yaml +++ b/extensions/ext.vm-packer.yaml @@ -1,9 +1,9 @@ id: ext.vm-packer title: VM workspace (Packer lineage) description: > - Attach an isolated workspace on a pre-built VM (the-custodian build-machines - lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build - orchestration is operator-driven and deferred. + Attach an isolated workspace on a pre-built VM, or run Packer build mode + (the-custodian build-machines lineage). Attach via SSH alias or tunnel port; + build via inputs.mode=build or profile.vm-packer-build. handler: sandboxer.extensions.vm_packer:VMPackerExtension capabilities: isolation_levels: [microvm] diff --git a/profiles/profile.vm-packer-build.yaml b/profiles/profile.vm-packer-build.yaml new file mode 100644 index 0000000..b1f168f --- /dev/null +++ b/profiles/profile.vm-packer-build.yaml @@ -0,0 +1,37 @@ +id: profile.vm-packer-build +version: "1.0.0" +extension: ext.vm-packer +isolation: + level: microvm +network: + default: deny + egress: [] +workspace: + mode: remote-canonical + access: rw +scope_default: session +ttl: + default: 12h + max: 48h + idle_reap: null +resources: + cpu: null + memory_mb: null +setup: + instructions: > + Trigger a Packer OVA build (the-custodian build-machines conventions). + Requires packer and VirtualBox on the placement host. Pass + --input packer_template=~/the-custodian/infra/build-machines/haskell + and --input vm_name=haskell-build (or vm=haskell-build). Attach mode + uses profile.vm-haskell-build instead. + secret_refs: [] +placement: + prefer: [localhost] + fallback: [workstation] +reachability: + tunnel: ops-bridge + identity: ops-warden +metadata: + cost_class: self-hosted + latency_class: standard + observability: none \ No newline at end of file diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index 535c060..e79a352 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -94,7 +94,11 @@ def sandbox_create( host: Annotated[str | None, typer.Option(help="Override placement host")] = None, ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None, ) -> None: - """Provision a sandbox. No args → canary self-deploy of sand-boxer.""" + """Provision a sandbox. No args → canary self-deploy of sand-boxer. + + vm-packer modes: attach (profile.vm-haskell-build) or build + (profile.vm-packer-build / --input mode=build with packer_template, vm_name). + """ parsed = _parse_inputs(input or []) resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed) request = SandboxCreateRequest( diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index 14f2745..a6b0554 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -131,6 +131,16 @@ class SandboxManager: try: secret_bundle = resolve_setup_secrets(profile) provision_inputs = dict(request.inputs) + build_mode = ( + provision_inputs.get("mode") == "build" + or profile.id == "profile.vm-packer-build" + ) + if build_mode: + emit_lifecycle_event( + status, + summary=f"Packer build starting ({profile.id})", + event_type="note", + ) handle = backend.provision(profile, provision_inputs, resolved_host) if secret_bundle: handle["_secret_refs"] = secret_bundle diff --git a/src/sandboxer/extensions/vm_packer.py b/src/sandboxer/extensions/vm_packer.py index e66c95e..8e2c7c9 100644 --- a/src/sandboxer/extensions/vm_packer.py +++ b/src/sandboxer/extensions/vm_packer.py @@ -1,32 +1,46 @@ -"""ext.vm-packer — attach to pre-built VMs (build-machines lineage). +"""ext.vm-packer — attach to pre-built VMs or trigger Packer builds. -v0 supports **attach** mode only: connect via SSH to an existing VM (tunnel alias, -localhost:port, or direct host). Creates an isolated workspace directory; teardown -removes the workspace, not the VM. +**attach** mode: connect via SSH to an existing VM (tunnel alias, localhost:port, +or direct host). Creates an isolated workspace directory; teardown removes the +workspace, not the VM. -Full Packer build / OVA import orchestration is deferred — operators still build -images via the-custodian/infra/build-machines/ workflows. +**build** mode: run `packer init` + `packer build` for a template directory +(the-custodian build-machines conventions). Teardown preserves the OVA artifact. """ from __future__ import annotations import os +import subprocess from pathlib import Path from typing import Any from sandboxer.extensions.base import SandboxExtension from sandboxer.extensions.ssh import SSHConfig +from sandboxer.lifecycle.state_hub import emit_progress_note from sandboxer.models import Profile +BUILD_PROFILE_IDS = frozenset({"profile.vm-packer-build"}) + class VMPackerExtension(SandboxExtension): - """Attach sandbox workspace on a pre-provisioned VM.""" + """Attach sandbox workspace on a pre-provisioned VM, or run Packer build.""" def __init__(self, config: dict[str, Any] | None = None) -> None: super().__init__(config) self.workspace_base: str = self.config.get("workspace_base", "/build") self.default_user: str | None = self.config.get("ssh_user") self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30)) + self.default_packer_template: str | None = self.config.get("default_packer_template") + self.packer_bin: str = self.config.get("packer_bin", "packer") + + def _resolve_mode(self, profile: Profile, inputs: dict[str, str]) -> str: + mode = inputs.get("mode") + if mode: + return mode + if profile.id in BUILD_PROFILE_IDS: + return "build" + return "attach" def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig: port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT") @@ -53,7 +67,106 @@ class VMPackerExtension(SandboxExtension): return "localhost", int(env_port) return inputs.get("vm_host") or placement_host, None - def provision( + def _run_packer( + self, + args: list[str], + *, + cwd: Path, + sandbox_id: str, + profile_id: str, + ) -> None: + cmd = [self.packer_bin, *args] + emit_progress_note( + f"Packer: {' '.join(cmd)}", + sandbox_id=sandbox_id, + profile_id=profile_id, + detail={"cwd": str(cwd)}, + ) + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + msg = result.stderr.strip() or result.stdout.strip() or "unknown error" + raise RuntimeError(f"Packer command failed ({' '.join(cmd)}): {msg}") + + def _find_ova_artifact(self, template_dir: Path, vm_name: str) -> Path | None: + candidates = sorted( + template_dir.glob(f"{vm_name}*.ova"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if candidates: + return candidates[0] + all_ova = sorted( + template_dir.glob("*.ova"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + return all_ova[0] if all_ova else None + + def _provision_build( + self, profile: Profile, inputs: dict[str, str], host: str + ) -> dict[str, str]: + sandbox_id = self.new_sandbox_id(inputs) + template_raw = ( + inputs.get("packer_template") + or os.environ.get("SANDBOXER_PACKER_TEMPLATE") + or self.default_packer_template + ) + if not template_raw: + raise ValueError( + "inputs.packer_template is required for build mode " + "(or set SANDBOXER_PACKER_TEMPLATE)" + ) + + vm_name = inputs.get("vm_name") or inputs.get("vm") + if not vm_name: + raise ValueError("inputs.vm_name or inputs.vm is required for build mode") + + template_dir = Path(template_raw).expanduser().resolve() + if not template_dir.is_dir(): + raise FileNotFoundError(f"Packer template directory not found: {template_dir}") + + pkr_files = list(template_dir.glob("*.pkr.hcl")) + list(template_dir.glob("*.pkr.json")) + if not pkr_files: + raise FileNotFoundError(f"No Packer template (*.pkr.hcl) in {template_dir}") + + self._run_packer( + ["init", "."], + cwd=template_dir, + sandbox_id=sandbox_id, + profile_id=profile.id, + ) + build_args = ["build", "-var", f"vm_name={vm_name}"] + for key, value in inputs.items(): + if key.startswith("packer_var_"): + build_args.extend(["-var", f"{key.removeprefix('packer_var_')}={value}"]) + self._run_packer( + build_args, + cwd=template_dir, + sandbox_id=sandbox_id, + profile_id=profile.id, + ) + + artifact = self._find_ova_artifact(template_dir, vm_name) + artifact_path = str(artifact) if artifact else "" + + return { + "sandbox_id": sandbox_id, + "host": host, + "mode": "build", + "vm_name": vm_name, + "vm_target": vm_name, + "packer_template": str(template_dir), + "artifact_path": artifact_path, + "remote_dir": artifact_path, + } + + def _provision_attach( self, profile: Profile, inputs: dict[str, str], host: str ) -> dict[str, str]: vm_target = inputs.get("vm") or inputs.get("ssh_target") @@ -94,11 +207,29 @@ class VMPackerExtension(SandboxExtension): "remote_dir": remote_dir, "ssh_user": ssh.user or "", "ssh_port": str(ssh.port) if ssh.port else "", - "mode": inputs.get("mode", "attach"), + "mode": "attach", "repo": repo_path_str, } + def provision( + self, profile: Profile, inputs: dict[str, str], host: str + ) -> dict[str, str]: + mode = self._resolve_mode(profile, inputs) + if mode == "build": + return self._provision_build(profile, inputs, host) + return self._provision_attach(profile, inputs, host) + def wait_ready(self, handle: dict[str, str]) -> dict[str, str]: + if handle.get("mode") == "build": + artifact = handle.get("artifact_path") or handle.get("remote_dir") + if not artifact or not Path(artifact).is_file(): + raise RuntimeError(f"Packer artifact not found: {artifact}") + return { + "host": handle.get("host"), + "remote_dir": artifact, + "endpoint": artifact, + } + ssh = self._ssh_from_handle(handle) remote_dir = handle["remote_dir"] cmd = f"test -d {remote_dir} && echo ready" @@ -112,6 +243,15 @@ class VMPackerExtension(SandboxExtension): } def teardown(self, handle: dict[str, str]) -> dict[str, str]: + if handle.get("mode") == "build": + artifact = handle.get("artifact_path") or handle.get("remote_dir") or "" + return { + "workspace_removed": "false", + "remote_dir": artifact, + "vm_preserved": "true", + "artifact_preserved": "true", + } + remote_dir = handle.get("remote_dir") cleaned_dir = False if remote_dir: diff --git a/src/sandboxer/lifecycle/state_hub.py b/src/sandboxer/lifecycle/state_hub.py index 99a086e..4d8eaae 100644 --- a/src/sandboxer/lifecycle/state_hub.py +++ b/src/sandboxer/lifecycle/state_hub.py @@ -59,6 +59,37 @@ def emit_lifecycle_event( return None +def emit_progress_note( + summary: str, + *, + sandbox_id: str, + profile_id: str, + detail: dict[str, Any] | None = None, + author: str = "sandboxer", +) -> dict[str, Any] | None: + """Emit a progress note during long-running provision (e.g. Packer build).""" + if os.environ.get("SANDBOXER_NO_STATE_HUB", "").lower() in ("1", "true", "yes"): + return None + + payload = { + "event_type": "note", + "summary": summary, + "author": author, + "detail": { + "sandbox_id": sandbox_id, + "profile_id": profile_id, + **(detail or {}), + }, + } + + try: + response = httpx.post(f"{hub_url()}/progress/", json=payload, timeout=10.0) + response.raise_for_status() + return response.json() + except httpx.HTTPError: + return None + + def event_type_for_state(state: SandboxState) -> str: if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED): return "milestone" diff --git a/tests/test_vm_packer.py b/tests/test_vm_packer.py index 0f7bc5e..302e3f4 100644 --- a/tests/test_vm_packer.py +++ b/tests/test_vm_packer.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from subprocess import CompletedProcess from unittest.mock import patch import pytest @@ -21,6 +22,16 @@ def _profile() -> Profile: ) +def _build_profile() -> Profile: + return Profile.model_validate( + { + "id": "profile.vm-packer-build", + "version": "1.0.0", + "extension": "ext.vm-packer", + } + ) + + def test_provision_attach_via_alias(tmp_path: Path) -> None: repo = tmp_path / "proj" repo.mkdir() @@ -73,4 +84,66 @@ def test_teardown_preserves_vm() -> None: with patch("sandboxer.extensions.vm_packer.SSHConfig.run", return_value=(0, "")): report = ext.teardown(handle) assert report["vm_preserved"] == "true" - assert report["workspace_removed"] == "True" \ No newline at end of file + assert report["workspace_removed"] == "True" + + +def test_provision_build_runs_packer(tmp_path: Path) -> None: + template = tmp_path / "haskell" + template.mkdir() + (template / "haskell-build.pkr.hcl").write_text('packer {}') + ova = template / "haskell-build-20260624.ova" + ova.write_bytes(b"ova") + + ext = VMPackerExtension() + with ( + patch( + "sandboxer.extensions.vm_packer.subprocess.run", + return_value=CompletedProcess(args=[], returncode=0, stdout="", stderr=""), + ) as run, + patch("sandboxer.extensions.vm_packer.emit_progress_note"), + ): + handle = ext.provision( + _build_profile(), + {"vm_name": "haskell-build", "packer_template": str(template)}, + "localhost", + ) + + assert handle["mode"] == "build" + assert handle["artifact_path"] == str(ova) + assert run.call_count == 2 + init_cmd, build_cmd = [c.args[0] for c in run.call_args_list] + assert init_cmd[:2] == ["packer", "init"] + assert build_cmd[:2] == ["packer", "build"] + assert "-var" in build_cmd and "vm_name=haskell-build" in build_cmd + + +def test_provision_build_requires_template() -> None: + ext = VMPackerExtension() + with pytest.raises(ValueError, match="packer_template"): + ext.provision(_build_profile(), {"vm_name": "haskell-build"}, "localhost") + + +def test_wait_ready_build_checks_artifact(tmp_path: Path) -> None: + ova = tmp_path / "haskell-build.ova" + ova.write_bytes(b"ova") + ext = VMPackerExtension() + reach = ext.wait_ready( + { + "mode": "build", + "artifact_path": str(ova), + "host": "localhost", + } + ) + assert reach["endpoint"] == str(ova) + + +def test_teardown_build_preserves_artifact() -> None: + ext = VMPackerExtension() + report = ext.teardown( + { + "mode": "build", + "artifact_path": "/tmp/haskell-build.ova", + } + ) + assert report["artifact_preserved"] == "true" + assert report["workspace_removed"] == "false" \ No newline at end of file diff --git a/workplans/SAND-WP-0012-packer-orchestration.md b/workplans/SAND-WP-0012-packer-orchestration.md index 0357e3a..0820b7b 100644 --- a/workplans/SAND-WP-0012-packer-orchestration.md +++ b/workplans/SAND-WP-0012-packer-orchestration.md @@ -4,7 +4,7 @@ type: workplan title: "Packer build orchestration" domain: infotech repo: sand-boxer -status: ready +status: finished owner: codex topic_slug: custodian created: "2026-06-24" @@ -20,7 +20,7 @@ Trigger Packer builds from `sandboxer create` and ship the-custodian Gap analysis P8: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` Carries forward: SAND-WP-0005-T06 (deferred) -**Predecessor:** SAND-WP-0011 (consumer profiles — proposed; attach mode done) +**Predecessor:** SAND-WP-0011 (consumer profiles) **Follow-on:** reuse-surface federation publish; sandboxer01 operator track --- @@ -29,79 +29,77 @@ Carries forward: SAND-WP-0005-T06 (deferred) ```task id: SAND-WP-0012-T01 -status: todo +status: done priority: high state_hub_task_id: "9dc30d94-1797-4c35-81a0-e75e5414f6fc" ``` -Extend `VMPackerExtension` with optional `build` mode: inputs `packer_template`, -`vm_name` trigger local/SSH Packer run per the-custodian -`infra/build-machines/` conventions. Distinct from attach mode; teardown does not -destroy VM image. Tests mocked subprocess. +`VMPackerExtension` build mode: inputs `packer_template`, `vm_name` trigger +local Packer run per the-custodian `infra/build-machines/` conventions. +Distinct from attach mode; teardown preserves OVA artifact. Tests mocked subprocess. ## profile.vm-packer-build ```task id: SAND-WP-0012-T02 -status: todo +status: done priority: high state_hub_task_id: "8e30794c-d8b9-48c7-ae93-db84724eedf2" ``` -New profile binding build mode with placement and TTL suitable for long builds. -Document inputs in `docs/migration-build-machines.md`. +Profile binding build mode with placement and TTL suitable for long builds. +Documented inputs in `docs/migration-build-machines.md`. ## Manager and CLI integration ```task id: SAND-WP-0012-T03 -status: todo +status: done priority: high state_hub_task_id: "685f766c-90ae-4698-87d0-b61535e7491a" ``` -`create` path selects build vs attach via profile or `inputs.mode=build|attach`. -Progress events to State Hub during long provision. CLI help text. +`create` selects build vs attach via profile or `inputs.mode=build|attach`. +Progress events to State Hub during long provision. CLI help text updated. ## the-custodian remote-build shim ```task id: SAND-WP-0012-T04 -status: todo +status: done priority: medium state_hub_task_id: "6c4c0f85-5153-4fe9-84e6-26c5c9d33bb1" ``` -In `the-custodian`: `make remote-build PROJECT=` delegates to -`sandboxer create --profile profile.vm-haskell-build` (attach) or new build -profile. Deprecation notice on legacy rsync-only path. Verification script -mirroring SAND-WP-0004 e2e shim pattern. +`make remote-build PROJECT=` in build-machines delegates to +`sandboxer create --profile profile.vm-haskell-build` when CLI present; +legacy rsync path retained with deprecation notice. +`scripts/verify-remote-build-shim.sh` mirrors SAND-WP-0004 pattern. ## Port-registry automation ```task id: SAND-WP-0012-T05 -status: todo +status: done priority: low state_hub_task_id: "701b2640-36ea-4702-b660-7169a4ec72cc" ``` -Optional helper: register tunnel port from build-machines port-registry when VM -attach provisions (read-only or emit ops-bridge config snippet). Document only -if full automation deferred. +Documented read-only port-registry pointer in `docs/migration-build-machines.md`; +full ops-bridge automation deferred. ## Docs, tests, runbook ```task id: SAND-WP-0012-T06 -status: todo +status: done priority: high state_hub_task_id: "2378cd6a-ac23-47e9-a5d9-0d80b9e9f7af" ``` -Update `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator -runbook under `docs/runbooks/`. `tests/test_vm_packer.py` build mode cases. -`make check` green. +Updated `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator +runbook `docs/runbooks/profile-vm-packer-build.md`. Build mode cases in +`tests/test_vm_packer.py`. `make check` green (90 tests). ---