feat(smolmachines): rewrite Smolfile to smolvm 0.8.0 schema + drop gvproxy (PRD 0023 chunk 2a)
First sub-PR of chunk 2: rewrite the renderer chunk 1 shipped to match smolvm 0.8.0's actual Smolfile shape, delete the dead gvproxy renderer + its tests, simplify the prepare flow now that there's no gvproxy socket + no loopback-port allocation. Smolfile renderer: - Old shape (under the abandoned gvproxy design): name = ..., command = [...], [[net]] attachment = "unixgram", socket = "...". - New shape (smolvm 0.8.0): env = [...] (sorted K=V pairs), [network] allow_cidrs = ["<bundle-ip>/32"]. Nothing else. image / entrypoint / cmd come from the .smolmachine artifact built in chunk 2b; cpus / memory left at smolvm defaults. - Tests assert no leakage of TSI's --outbound-localhost-only or the old gvproxy/unixgram keys. util.py: - smolmachines_gvproxy_subnet → smolmachines_bundle_subnet, returning (subnet, gateway, bundle_ip). bundle_ip is always at .2 (gateway .1); subnet is /24, third octet derived from the slug hash, skipping the docker-default 17 to avoid the common 192.168.17.x collision. - allocate_loopback_port: deleted. The bundle gets a pinned docker IP now; the agent dials that IP directly through TSI. - smolmachines_preflight: dropped the gvproxy check; only smolvm is required. prepare.py: - Drops the gvproxy.yaml render + the loopback port allocation + the gvproxy_socket field on the plan. - Derives subnet / gateway / bundle_ip from the slug and populates the new SmolmachinesBottlePlan fields. - Agent env now uses IP-literal URLs (http://<bundle-ip>:8888 etc) since the guest will have no DNS resolver inside TSI's allowlist. bottle_plan.py: - Old fields: gvproxy_config_path, gvproxy_socket, gvproxy_subnet, gvproxy_gateway, host_port_map. - New fields: bundle_subnet, bundle_gateway, bundle_ip, smolfile_path. (smolmachine artifact path lands in chunk 2b.) Net: -410 lines. Full unit suite: 516 passing. The VM lifecycle + bundle bringup + launch wiring + smoke tests land in chunk 2b. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,90 +1,65 @@
|
||||
"""Smolfile (TOML) renderer for the smolmachines backend (PRD 0023).
|
||||
|
||||
The Smolfile pins the per-bottle microVM's command + env + virtio-net
|
||||
device. Three fields drive what we emit:
|
||||
The Smolfile shape comes from `smolvm machine create --smolfile`
|
||||
in smolvm 0.8.0. The renderer emits only the runtime overrides
|
||||
that vary per bottle:
|
||||
|
||||
- `command` — the entrypoint claude-bottle runs inside the guest.
|
||||
Chunk 1 ships a placeholder (`sleep infinity`); chunk 4 wires
|
||||
the real `claude` entrypoint once provisioning is in place.
|
||||
- `env = ["K=V", ...]` — agent's HTTPS_PROXY / NO_PROXY /
|
||||
NODE_EXTRA_CA_CERTS, all using IP literals pointing at the
|
||||
per-bottle sidecar bundle's pinned docker IP.
|
||||
|
||||
- `env` — the agent's HTTP_PROXY / NO_PROXY / CA paths, pointing
|
||||
at `proxy.internal:<gateway-port>`. gvproxy resolves
|
||||
`proxy.internal` to the gateway IP and port-forwards to the
|
||||
host-side sidecar bundle.
|
||||
- `[network] allow_cidrs = ["<bundle-ip>/32"]` — TSI's single-IP
|
||||
allowlist. With this and no other `allow_*` or
|
||||
`outbound-localhost-only`, the agent can dial exactly one IP:
|
||||
the bundle. Host loopback, LAN, and the public internet
|
||||
directly are all refused at the VMM layer.
|
||||
|
||||
- `[[net]]` — a virtio-net device backed by gvproxy's unixgram
|
||||
socket via the VFKT handshake. This is the line that rejects
|
||||
libkrun's TSI mode: TSI's CIDR allowlist permits the entire
|
||||
127.0.0.0/8 of host loopback, which exposes every host-side
|
||||
service; gvproxy's explicit port-forward list is the only thing
|
||||
the guest can reach.
|
||||
What the renderer does NOT emit:
|
||||
|
||||
The renderer is a pure function. Disk writes happen in
|
||||
`prepare.py` via `smolfile_write`."""
|
||||
- `image` / `entrypoint` / `cmd` — those come from the
|
||||
`.smolmachine` artifact (produced by `smolvm pack create
|
||||
--image claude-bottle:latest`) and don't vary across bottles
|
||||
of the same agent image.
|
||||
|
||||
- `cpus` / `memory` — left at smolvm defaults until the
|
||||
operator surfaces a need to override per bottle (the manifest
|
||||
has no such field today).
|
||||
|
||||
Pure function; disk writes happen via `smolfile_write`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
# Default port assignments INSIDE the gvproxy network — what the
|
||||
# guest dials. The agent's HTTPS_PROXY etc. resolve to
|
||||
# `proxy.internal:<one of these>`. Host-side mapping is dynamic
|
||||
# (chunk 3 allocates loopback ports per bottle).
|
||||
GVPROXY_PIPELOCK_GATEWAY_PORT = 8888
|
||||
GVPROXY_GIT_GATE_GATEWAY_PORT = 8889
|
||||
GVPROXY_SUPERVISE_GATEWAY_PORT = 8890
|
||||
|
||||
|
||||
def smolfile_build(
|
||||
*,
|
||||
slug: str,
|
||||
gvproxy_socket: Path,
|
||||
env: Mapping[str, str],
|
||||
command: tuple[str, ...] = ("sleep", "infinity"),
|
||||
bundle_ip: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the Smolfile config dict.
|
||||
|
||||
`gvproxy_socket` is the unixgram socket gvproxy listens on; the
|
||||
guest's virtio-net device handshakes (VFKT magic) with it on
|
||||
start. `env` is `{NAME: VALUE}` for the guest's process env.
|
||||
`command` is the entrypoint argv inside the guest (placeholder
|
||||
until chunk 4 — see module docstring).
|
||||
|
||||
Returns a TOML-shaped dict; render with `smolfile_render`."""
|
||||
"""Build the Smolfile config dict. `env` is `{NAME: VALUE}` for
|
||||
the guest's process env (IP-literal HTTPS_PROXY etc.).
|
||||
`bundle_ip` is the pinned docker IP of the per-bottle sidecar
|
||||
bundle; it lands in `[network] allow_cidrs` as a /32."""
|
||||
return {
|
||||
"name": f"claude-bottle-{slug}",
|
||||
"command": list(command),
|
||||
"env": [f"{k}={v}" for k, v in sorted(env.items())],
|
||||
"net": [
|
||||
{
|
||||
"type": "virtio-net",
|
||||
"attachment": "unixgram",
|
||||
"socket": str(gvproxy_socket),
|
||||
},
|
||||
],
|
||||
"network": {
|
||||
"allow_cidrs": [f"{bundle_ip}/32"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def smolfile_render(cfg: dict[str, Any]) -> str:
|
||||
"""Render the Smolfile dict as TOML. Stdlib has `tomllib` for
|
||||
reading TOML but no writer; the smolmachines schema we emit is
|
||||
narrow enough (string scalars + string lists + one inline table
|
||||
per net device) to render by hand. Avoids a `tomli_w` runtime
|
||||
dep and keeps the project stdlib-only."""
|
||||
"""Render the Smolfile dict as TOML. Schema is narrow (string
|
||||
list + one table with a string list) so we render by hand and
|
||||
stay stdlib-only."""
|
||||
lines: list[str] = []
|
||||
lines.append(f'name = {_toml_str(cfg["name"])}')
|
||||
lines.append(f'command = {_toml_array(cfg["command"])}')
|
||||
lines.append(f'env = {_toml_array(cfg["env"])}')
|
||||
lines.append("")
|
||||
for net in cfg.get("net", ()):
|
||||
lines.append("[[net]]")
|
||||
for key, value in net.items():
|
||||
lines.append(f'{key} = {_toml_str(value)}')
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip("\n") + "\n"
|
||||
lines.append("[network]")
|
||||
lines.append(f'allow_cidrs = {_toml_array(cfg["network"]["allow_cidrs"])}')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def smolfile_write(cfg: dict[str, Any], path: Path) -> Path:
|
||||
@@ -96,16 +71,11 @@ def smolfile_write(cfg: dict[str, Any], path: Path) -> Path:
|
||||
|
||||
def _toml_str(value: Any) -> str:
|
||||
"""TOML basic string: double-quoted with backslash + double-quote
|
||||
escapes. The smolmachines fields we emit (slugs, paths, env
|
||||
pairs) are ASCII-safe; the escape table covers what's reachable."""
|
||||
escapes."""
|
||||
s = str(value)
|
||||
s = s.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{s}"'
|
||||
|
||||
|
||||
def _toml_array(values: list[Any]) -> str:
|
||||
"""TOML inline array. Uses `_toml_str` so quoting is consistent;
|
||||
the alternative would be `json.dumps(values)` which renders
|
||||
identical text for ASCII-only lists, but going through the same
|
||||
quoter is one less surprise on future inputs."""
|
||||
return "[" + ", ".join(_toml_str(v) for v in values) + "]"
|
||||
|
||||
Reference in New Issue
Block a user