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.
This commit is contained in:
2026-06-24 12:56:32 +02:00
parent 92eaf8bae5
commit 774bc5ae0a
12 changed files with 426 additions and 52 deletions

View File

@@ -116,24 +116,24 @@ own tunnels or CAs.
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore; - **Status:** v0 operational — self-hosted compose path proven on CoulombCore;
routing, payments stub, and snapshots shipped routing, payments stub, and snapshots shipped
- **Workplans finished:** SAND-WP-00010011 (0003/0004 in sibling repos) - **Workplans finished:** SAND-WP-00010012 (0003/0004 in sibling repos)
- **Workplans ready:** SAND-WP-0012 (Packer orchestration) - **Workplans ready:** none (reuse-surface publish / sandboxer01 operator track)
- **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments, - **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments,
snapshots, telemetry, HTTP API snapshots, telemetry, HTTP API
- **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub, - **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`, - **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`,
`ext.e2b`, `ext.modal` `ext.e2b`, `ext.modal`
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`, - **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines` `payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
- **Registry:** `capability.execution.sandbox-provision` indexed (draft) - **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 - **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
`make e2e REPO=` shim (SAND-WP-0004) `make e2e REPO=` shim (SAND-WP-0004)
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md` 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` 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 <id> sandboxer reachability show <id>
sandboxer create --profile profile.agent-dev --input repo=/path --actor agt --project glas-harness 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.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) make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
# Full e2e validation (wise-validator, separate install): # Full e2e validation (wise-validator, separate install):
@@ -175,7 +176,7 @@ cd ~/the-custodian && make e2e REPO=activity-core
## What Is Not Possible Yet ## What Is Not Possible Yet
- ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009) - ~~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) - ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010)
- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011) - ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011)
- Cross-host snapshot transfer - Cross-host snapshot transfer

View File

@@ -33,7 +33,7 @@ Reference implementations:
| Extension | Module | Mode | | Extension | Module | Mode |
|-----------|--------|------| |-----------|--------|------|
| `ext.compose-ssh` | `compose_ssh.py` | Remote compose stack + tar snapshots | | `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.saas-stub` | `saas_stub.py` | Metered stub + metadata snapshots |
| `ext.e2b` | `e2b.py` | E2B cloud adapter | | `ext.e2b` | `e2b.py` | E2B cloud adapter |
| `ext.modal` | `modal.py` | Modal 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 | | Extension | Required inputs | Optional |
|-----------|-----------------|----------| |-----------|-----------------|----------|
| compose-ssh | `repo` | `sandbox_id` | | 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. Consumer attribution travels on `SandboxCreateRequest.consumer`, not extension inputs.

View File

@@ -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 | | Packer OVA build | **Unchanged** — operator runs Packer in the-custodian |
| VM boot + build-agent registration | **Unchanged** — systemd agent on VM | | 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/<project>` | `/build/sbx-<sandbox_id>/` per create | | Isolated workspace `/build/<project>` | `/build/sbx-<sandbox_id>/` per create |
| `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) | | `make bridge-status` | `ssh -p 12222 build@localhost` or `sandboxer inspect` (future) |
@@ -50,12 +51,36 @@ sandboxer destroy <sandbox_id>
| `repo` | Optional rsync source to workspace | | `repo` | Optional rsync source to workspace |
| `workspace_dir` | Override workspace path on VM | | `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
1222112230 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 ## Not migrated yet
- Automated Packer `create` trigger from sand-boxer API
- State Hub capability-catalog sync from build-agent (agent unchanged) - State Hub capability-catalog sync from build-agent (agent unchanged)
- Port registry automation (`port-registry.yml`) - Automated port-registry → ops-bridge config generation
- `make remote-build` Makefile targets in the-custodian (add shim in follow-on if needed)
## Runbook ## Runbook

View File

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

View File

@@ -1,9 +1,9 @@
id: ext.vm-packer id: ext.vm-packer
title: VM workspace (Packer lineage) title: VM workspace (Packer lineage)
description: > description: >
Attach an isolated workspace on a pre-built VM (the-custodian build-machines Attach an isolated workspace on a pre-built VM, or run Packer build mode
lineage). v0 supports attach mode via SSH alias or tunnel port; Packer build (the-custodian build-machines lineage). Attach via SSH alias or tunnel port;
orchestration is operator-driven and deferred. build via inputs.mode=build or profile.vm-packer-build.
handler: sandboxer.extensions.vm_packer:VMPackerExtension handler: sandboxer.extensions.vm_packer:VMPackerExtension
capabilities: capabilities:
isolation_levels: [microvm] isolation_levels: [microvm]

View File

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

View File

@@ -94,7 +94,11 @@ def sandbox_create(
host: Annotated[str | None, typer.Option(help="Override placement host")] = None, host: Annotated[str | None, typer.Option(help="Override placement host")] = None,
ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None, ttl: Annotated[str | None, typer.Option(help="TTL override (e.g. 4h)")] = None,
) -> 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 []) parsed = _parse_inputs(input or [])
resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed) resolved_profile, resolved_inputs = resolve_create_defaults(profile, parsed)
request = SandboxCreateRequest( request = SandboxCreateRequest(

View File

@@ -131,6 +131,16 @@ class SandboxManager:
try: try:
secret_bundle = resolve_setup_secrets(profile) secret_bundle = resolve_setup_secrets(profile)
provision_inputs = dict(request.inputs) 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) handle = backend.provision(profile, provision_inputs, resolved_host)
if secret_bundle: if secret_bundle:
handle["_secret_refs"] = secret_bundle handle["_secret_refs"] = secret_bundle

View File

@@ -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, **attach** mode: connect via SSH to an existing VM (tunnel alias, localhost:port,
localhost:port, or direct host). Creates an isolated workspace directory; teardown or direct host). Creates an isolated workspace directory; teardown removes the
removes the workspace, not the VM. workspace, not the VM.
Full Packer build / OVA import orchestration is deferred — operators still build **build** mode: run `packer init` + `packer build` for a template directory
images via the-custodian/infra/build-machines/ workflows. (the-custodian build-machines conventions). Teardown preserves the OVA artifact.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import subprocess
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from sandboxer.extensions.base import SandboxExtension from sandboxer.extensions.base import SandboxExtension
from sandboxer.extensions.ssh import SSHConfig from sandboxer.extensions.ssh import SSHConfig
from sandboxer.lifecycle.state_hub import emit_progress_note
from sandboxer.models import Profile from sandboxer.models import Profile
BUILD_PROFILE_IDS = frozenset({"profile.vm-packer-build"})
class VMPackerExtension(SandboxExtension): 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: def __init__(self, config: dict[str, Any] | None = None) -> None:
super().__init__(config) super().__init__(config)
self.workspace_base: str = self.config.get("workspace_base", "/build") self.workspace_base: str = self.config.get("workspace_base", "/build")
self.default_user: str | None = self.config.get("ssh_user") self.default_user: str | None = self.config.get("ssh_user")
self.ready_timeout_s: int = int(self.config.get("ready_timeout_s", 30)) 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: def _ssh_from_handle(self, handle: dict[str, str]) -> SSHConfig:
port_raw = handle.get("ssh_port") or os.environ.get("SANDBOXER_VM_SSH_PORT") 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 "localhost", int(env_port)
return inputs.get("vm_host") or placement_host, None 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 self, profile: Profile, inputs: dict[str, str], host: str
) -> dict[str, str]: ) -> dict[str, str]:
vm_target = inputs.get("vm") or inputs.get("ssh_target") vm_target = inputs.get("vm") or inputs.get("ssh_target")
@@ -94,11 +207,29 @@ class VMPackerExtension(SandboxExtension):
"remote_dir": remote_dir, "remote_dir": remote_dir,
"ssh_user": ssh.user or "", "ssh_user": ssh.user or "",
"ssh_port": str(ssh.port) if ssh.port else "", "ssh_port": str(ssh.port) if ssh.port else "",
"mode": inputs.get("mode", "attach"), "mode": "attach",
"repo": repo_path_str, "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]: 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) ssh = self._ssh_from_handle(handle)
remote_dir = handle["remote_dir"] remote_dir = handle["remote_dir"]
cmd = f"test -d {remote_dir} && echo ready" 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]: 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") remote_dir = handle.get("remote_dir")
cleaned_dir = False cleaned_dir = False
if remote_dir: if remote_dir:

View File

@@ -59,6 +59,37 @@ def emit_lifecycle_event(
return None 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: def event_type_for_state(state: SandboxState) -> str:
if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED): if state in (SandboxState.READY, SandboxState.DESTROYED, SandboxState.EXPIRED):
return "milestone" return "milestone"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from subprocess import CompletedProcess
from unittest.mock import patch from unittest.mock import patch
import pytest 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: def test_provision_attach_via_alias(tmp_path: Path) -> None:
repo = tmp_path / "proj" repo = tmp_path / "proj"
repo.mkdir() repo.mkdir()
@@ -74,3 +85,65 @@ def test_teardown_preserves_vm() -> None:
report = ext.teardown(handle) report = ext.teardown(handle)
assert report["vm_preserved"] == "true" assert report["vm_preserved"] == "true"
assert report["workspace_removed"] == "True" 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"

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Packer build orchestration" title: "Packer build orchestration"
domain: infotech domain: infotech
repo: sand-boxer repo: sand-boxer
status: ready status: finished
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-06-24" 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` Gap analysis P8: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
Carries forward: SAND-WP-0005-T06 (deferred) 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 **Follow-on:** reuse-surface federation publish; sandboxer01 operator track
--- ---
@@ -29,79 +29,77 @@ Carries forward: SAND-WP-0005-T06 (deferred)
```task ```task
id: SAND-WP-0012-T01 id: SAND-WP-0012-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "9dc30d94-1797-4c35-81a0-e75e5414f6fc" state_hub_task_id: "9dc30d94-1797-4c35-81a0-e75e5414f6fc"
``` ```
Extend `VMPackerExtension` with optional `build` mode: inputs `packer_template`, `VMPackerExtension` build mode: inputs `packer_template`, `vm_name` trigger
`vm_name` trigger local/SSH Packer run per the-custodian local Packer run per the-custodian `infra/build-machines/` conventions.
`infra/build-machines/` conventions. Distinct from attach mode; teardown does not Distinct from attach mode; teardown preserves OVA artifact. Tests mocked subprocess.
destroy VM image. Tests mocked subprocess.
## profile.vm-packer-build ## profile.vm-packer-build
```task ```task
id: SAND-WP-0012-T02 id: SAND-WP-0012-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "8e30794c-d8b9-48c7-ae93-db84724eedf2" state_hub_task_id: "8e30794c-d8b9-48c7-ae93-db84724eedf2"
``` ```
New profile binding build mode with placement and TTL suitable for long builds. Profile binding build mode with placement and TTL suitable for long builds.
Document inputs in `docs/migration-build-machines.md`. Documented inputs in `docs/migration-build-machines.md`.
## Manager and CLI integration ## Manager and CLI integration
```task ```task
id: SAND-WP-0012-T03 id: SAND-WP-0012-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "685f766c-90ae-4698-87d0-b61535e7491a" state_hub_task_id: "685f766c-90ae-4698-87d0-b61535e7491a"
``` ```
`create` path selects build vs attach via profile or `inputs.mode=build|attach`. `create` selects build vs attach via profile or `inputs.mode=build|attach`.
Progress events to State Hub during long provision. CLI help text. Progress events to State Hub during long provision. CLI help text updated.
## the-custodian remote-build shim ## the-custodian remote-build shim
```task ```task
id: SAND-WP-0012-T04 id: SAND-WP-0012-T04
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "6c4c0f85-5153-4fe9-84e6-26c5c9d33bb1" state_hub_task_id: "6c4c0f85-5153-4fe9-84e6-26c5c9d33bb1"
``` ```
In `the-custodian`: `make remote-build PROJECT=` delegates to `make remote-build PROJECT=` in build-machines delegates to
`sandboxer create --profile profile.vm-haskell-build` (attach) or new build `sandboxer create --profile profile.vm-haskell-build` when CLI present;
profile. Deprecation notice on legacy rsync-only path. Verification script legacy rsync path retained with deprecation notice.
mirroring SAND-WP-0004 e2e shim pattern. `scripts/verify-remote-build-shim.sh` mirrors SAND-WP-0004 pattern.
## Port-registry automation ## Port-registry automation
```task ```task
id: SAND-WP-0012-T05 id: SAND-WP-0012-T05
status: todo status: done
priority: low priority: low
state_hub_task_id: "701b2640-36ea-4702-b660-7169a4ec72cc" state_hub_task_id: "701b2640-36ea-4702-b660-7169a4ec72cc"
``` ```
Optional helper: register tunnel port from build-machines port-registry when VM Documented read-only port-registry pointer in `docs/migration-build-machines.md`;
attach provisions (read-only or emit ops-bridge config snippet). Document only full ops-bridge automation deferred.
if full automation deferred.
## Docs, tests, runbook ## Docs, tests, runbook
```task ```task
id: SAND-WP-0012-T06 id: SAND-WP-0012-T06
status: todo status: done
priority: high priority: high
state_hub_task_id: "2378cd6a-ac23-47e9-a5d9-0d80b9e9f7af" state_hub_task_id: "2378cd6a-ac23-47e9-a5d9-0d80b9e9f7af"
``` ```
Update `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator Updated `docs/migration-build-machines.md`, `docs/extension-sdk.md`, operator
runbook under `docs/runbooks/`. `tests/test_vm_packer.py` build mode cases. runbook `docs/runbooks/profile-vm-packer-build.md`. Build mode cases in
`make check` green. `tests/test_vm_packer.py`. `make check` green (90 tests).
--- ---