20f411b22e
Ships the smolmachines backend's prepare side: subpackage layout,
`_BACKENDS` registration under "smolmachines", preflight check
for `smolvm` + `gvproxy` on PATH, and the two config-file
renderers (Smolfile TOML + gvproxy YAML). Launch raises
NotImplementedError until chunk 2.
New module layout (mirrors backend/docker/):
claude_bottle/backend/smolmachines/
__init__.py re-exports SmolmachinesBottleBackend
backend.py SmolmachinesBottleBackend façade
bottle.py SmolmachinesBottle stub (NotImpl until ch2)
bottle_plan.py SmolmachinesBottlePlan + .print()
bottle_cleanup_plan.py SmolmachinesBottleCleanupPlan stub
prepare.py resolve_plan: writes both config files
smolfile.py TOML renderer (stdlib, no tomli_w dep)
gvproxy_config.py YAML renderer (same shape as pipelock_yaml)
util.py preflight + per-slug subnet + loopback port
The renderers are pure functions. `resolve_plan` runs the
preflight, allocates one host-side loopback port per active
sidecar (pipelock always; git-gate / supervise conditional),
derives a per-slug gvproxy subnet (hash-mod-254, skipping the
docker-default 17), and writes:
- <stage>/gvproxy.yaml: subnet + DNS rule resolving only
`proxy.internal` + port_forwards (one per active sidecar).
- <stage>/smolfile.toml: guest command/env + virtio-net device
backed by gvproxy's unixgram socket. No TSI flags — see
PRD 0023 "Why gvproxy, not TSI".
The agent's HTTPS_PROXY etc. point at `proxy.internal:<gateway-
port>` so the guest dials through gvproxy. gvproxy resolves only
`proxy.internal` → the gateway IP, and forwards exactly the
listed ports to the host-side sidecar bundle (PRD 0024); every
other destination — host LAN, host loopback, public internet
directly — is unreachable by construction.
29 new unit tests covering renderer correctness, subnet
derivation stability + collision-avoidance, loopback port
allocation, and preflight error paths. Full unit suite: 532
passing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
167 lines
5.4 KiB
Python
167 lines
5.4 KiB
Python
"""smolmachines `_resolve_plan` (PRD 0023 chunk 1).
|
|
|
|
Lays down the two config files the launch step will consume —
|
|
Smolfile (TOML) and gvproxy YAML — under the bottle's stage dir.
|
|
No VM bringup. The plan it returns is enough for the y/N
|
|
preflight to render."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from ...backend import BottleSpec
|
|
from ...backend.docker.bottle_state import (
|
|
BottleMetadata,
|
|
bottle_identity,
|
|
write_metadata,
|
|
)
|
|
from ...backend.docker.sidecar_bundle import sidecar_bundle_container_name
|
|
from .bottle_plan import SmolmachinesBottlePlan
|
|
from .gvproxy_config import (
|
|
PortForward,
|
|
gvproxy_config_build,
|
|
gvproxy_config_write,
|
|
)
|
|
from .smolfile import (
|
|
GVPROXY_GIT_GATE_GATEWAY_PORT,
|
|
GVPROXY_PIPELOCK_GATEWAY_PORT,
|
|
GVPROXY_SUPERVISE_GATEWAY_PORT,
|
|
smolfile_build,
|
|
smolfile_write,
|
|
)
|
|
from .util import (
|
|
allocate_loopback_port,
|
|
smolmachines_gvproxy_subnet,
|
|
smolmachines_preflight,
|
|
)
|
|
|
|
|
|
def resolve_plan(
|
|
spec: BottleSpec, *, stage_dir: Path
|
|
) -> SmolmachinesBottlePlan:
|
|
"""Materialize the smolmachines plan. Three things land on disk
|
|
under stage_dir:
|
|
|
|
- `gvproxy.sock` path (created at launch time by gvproxy,
|
|
not by prepare — prepare only records where it'll go).
|
|
- `gvproxy.yaml` — subnet + DNS rule + port_forwards.
|
|
- `smolfile.toml` — guest command/env + virtio-net device
|
|
wired to the gvproxy unixgram socket.
|
|
|
|
The y/N preflight reads from the returned plan; chunk-2 launch
|
|
consumes the file paths it points at."""
|
|
smolmachines_preflight()
|
|
|
|
manifest = spec.manifest
|
|
agent = manifest.agents[spec.agent_name]
|
|
bottle = manifest.bottle_for(spec.agent_name)
|
|
|
|
slug = spec.identity or bottle_identity(spec.agent_name)
|
|
|
|
# Record minimal metadata. `resume` (PRD 0016) reuses the slug
|
|
# via spec.identity, so the metadata file is the recoverability
|
|
# contract — keep it in lockstep with the docker backend's
|
|
# bottle_state schema.
|
|
write_metadata(BottleMetadata(
|
|
identity=slug,
|
|
agent_name=spec.agent_name,
|
|
cwd=spec.user_cwd if spec.copy_cwd else "",
|
|
copy_cwd=spec.copy_cwd,
|
|
started_at=datetime.now(timezone.utc).isoformat(),
|
|
# No compose project for smolmachines bottles. Empty string
|
|
# so the dashboard's compose-project-based discovery skips
|
|
# these entries cleanly until chunk 4 teaches it about
|
|
# `smolvm machine list`.
|
|
compose_project="",
|
|
))
|
|
|
|
# Per-bottle gvproxy subnet + gateway. Deterministic from slug;
|
|
# collisions surface at launch time (chunk 2).
|
|
subnet, gateway = smolmachines_gvproxy_subnet(slug)
|
|
|
|
# Allocate one host-side loopback port per active sidecar
|
|
# daemon. The bundle (PRD 0024) binds these; gvproxy
|
|
# port-forwards from a fixed gateway port -> host port.
|
|
host_port_map: dict[str, int] = {
|
|
"pipelock": allocate_loopback_port(),
|
|
}
|
|
port_forwards: list[PortForward] = [PortForward(
|
|
gateway_port=GVPROXY_PIPELOCK_GATEWAY_PORT,
|
|
host_port=host_port_map["pipelock"],
|
|
)]
|
|
if bottle.git:
|
|
host_port_map["git-gate"] = allocate_loopback_port()
|
|
port_forwards.append(PortForward(
|
|
gateway_port=GVPROXY_GIT_GATE_GATEWAY_PORT,
|
|
host_port=host_port_map["git-gate"],
|
|
))
|
|
if bottle.supervise:
|
|
host_port_map["supervise"] = allocate_loopback_port()
|
|
port_forwards.append(PortForward(
|
|
gateway_port=GVPROXY_SUPERVISE_GATEWAY_PORT,
|
|
host_port=host_port_map["supervise"],
|
|
))
|
|
|
|
# Render + write the two config files.
|
|
gvproxy_socket = stage_dir / "gvproxy.sock"
|
|
gvproxy_yaml = stage_dir / "gvproxy.yaml"
|
|
smolfile = stage_dir / "smolfile.toml"
|
|
|
|
gvproxy_config_write(
|
|
gvproxy_config_build(
|
|
subnet=subnet,
|
|
gateway=gateway,
|
|
port_forwards=tuple(port_forwards),
|
|
),
|
|
gvproxy_yaml,
|
|
)
|
|
|
|
# Build the guest env. proxy_url points at the gvproxy gateway;
|
|
# gvproxy resolves `proxy.internal` to the gateway IP via its
|
|
# DNS rule, then port-forwards to the host sidecar bundle.
|
|
guest_env: dict[str, str] = {
|
|
**bottle.env,
|
|
"HTTPS_PROXY": f"http://proxy.internal:{GVPROXY_PIPELOCK_GATEWAY_PORT}",
|
|
"HTTP_PROXY": f"http://proxy.internal:{GVPROXY_PIPELOCK_GATEWAY_PORT}",
|
|
"NO_PROXY": "localhost,127.0.0.1",
|
|
}
|
|
if bottle.git:
|
|
guest_env["GIT_GATE_URL"] = (
|
|
f"git://proxy.internal:{GVPROXY_GIT_GATE_GATEWAY_PORT}"
|
|
)
|
|
if bottle.supervise:
|
|
guest_env["MCP_SUPERVISE_URL"] = (
|
|
f"http://proxy.internal:{GVPROXY_SUPERVISE_GATEWAY_PORT}"
|
|
)
|
|
smolfile_write(
|
|
smolfile_build(
|
|
slug=slug,
|
|
gvproxy_socket=gvproxy_socket,
|
|
env=guest_env,
|
|
),
|
|
smolfile,
|
|
)
|
|
|
|
return SmolmachinesBottlePlan(
|
|
spec=spec,
|
|
stage_dir=stage_dir,
|
|
slug=slug,
|
|
smolfile_path=smolfile,
|
|
gvproxy_config_path=gvproxy_yaml,
|
|
gvproxy_socket=gvproxy_socket,
|
|
gvproxy_subnet=subnet,
|
|
gvproxy_gateway=gateway,
|
|
host_port_map=host_port_map,
|
|
)
|
|
|
|
|
|
# Used by future cleanup logic — the bundle container that runs on
|
|
# the host carries this name even when its host is a smolmachines
|
|
# microVM. Re-exported here so chunk 3's sidecar-bringup path has
|
|
# a single import target.
|
|
__all__ = [
|
|
"resolve_plan",
|
|
"sidecar_bundle_container_name",
|
|
]
|