generated from coulomb/repo-seed
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:
13
SCOPE.md
13
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 <id>
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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/<project>` | `/build/sbx-<sandbox_id>/` per create |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
|
||||
54
docs/runbooks/profile-vm-packer-build.md
Normal file
54
docs/runbooks/profile-vm-packer-build.md
Normal 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`
|
||||
@@ -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]
|
||||
|
||||
37
profiles/profile.vm-packer-build.yaml
Normal file
37
profiles/profile.vm-packer-build.yaml
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
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"
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user