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

View file

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

View file

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

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,
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:

View file

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