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,84 +1,48 @@
|
||||
"""Slug / preflight helpers for the smolmachines backend (PRD 0023).
|
||||
|
||||
Backend-specific utilities the prepare/launch flow reaches for.
|
||||
Kept in its own module so the renderers can be unit-tested without
|
||||
importing the docker subprocess paths."""
|
||||
"""Slug / preflight / subnet helpers for the smolmachines backend
|
||||
(PRD 0023). Kept in its own module so the renderers can be
|
||||
unit-tested without importing the docker subprocess paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
from contextlib import closing
|
||||
|
||||
from ...log import die
|
||||
|
||||
|
||||
def smolmachines_preflight() -> None:
|
||||
"""Ensure the binaries the smolmachines backend shells out to are
|
||||
on PATH. Called from the backend's `_resolve_plan` before any
|
||||
file writes; gives the operator a clear pointer rather than a
|
||||
cryptic FileNotFoundError later.
|
||||
|
||||
`smolvm` drives the libkrun microVM lifecycle. `gvproxy`
|
||||
(gvisor-tap-vsock) is the userspace TCP/IP stack the guest's
|
||||
virtio-net device hooks into."""
|
||||
missing: list[tuple[str, str]] = []
|
||||
if shutil.which("smolvm") is None:
|
||||
missing.append((
|
||||
"smolvm",
|
||||
"brew install smolmachines-dev/smolmachines/smolmachines "
|
||||
"# or build from https://smolmachines.com/",
|
||||
))
|
||||
if shutil.which("gvproxy") is None:
|
||||
missing.append((
|
||||
"gvproxy",
|
||||
"go install "
|
||||
"github.com/containers/gvisor-tap-vsock/cmd/gvproxy@latest "
|
||||
"# requires Go + ensure $GOPATH/bin on PATH",
|
||||
))
|
||||
if not missing:
|
||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
||||
Called from `_resolve_plan`; gives the operator a clear
|
||||
install pointer rather than a cryptic FileNotFoundError
|
||||
later. `gvproxy` is no longer required — see the PRD's design
|
||||
pivot section."""
|
||||
if shutil.which("smolvm") is not None:
|
||||
return
|
||||
lines = [
|
||||
f"CLAUDE_BOTTLE_BACKEND=smolmachines requires the following "
|
||||
f"binar{'y' if len(missing) == 1 else 'ies'} on PATH:",
|
||||
]
|
||||
for name, install in missing:
|
||||
lines.append(f" - {name} install: {install}")
|
||||
die("\n".join(lines))
|
||||
die(
|
||||
"CLAUDE_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh"
|
||||
)
|
||||
|
||||
|
||||
def smolmachines_gvproxy_subnet(slug: str) -> tuple[str, str]:
|
||||
"""Derive a per-bottle subnet + gateway IP from the slug.
|
||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
||||
"""Derive a per-bottle docker subnet + gateway IP + bundle IP
|
||||
from the slug.
|
||||
|
||||
Returns `(subnet_cidr, gateway_ip)`. The third octet comes from
|
||||
SHA-256 of the slug mod 254 (1..254, skipping 0 and 255 + the
|
||||
127.x.x.x range) — collision-free for any reasonable concurrent-
|
||||
bottle count and stable across `start` / `resume` of the same
|
||||
bottle. Bottles that DO collide would fail at gvproxy bringup;
|
||||
`launch.py` (chunk 2) detects the conflict and surfaces a clear
|
||||
error rather than silently reusing another bottle's gateway."""
|
||||
Returns `(subnet_cidr, gateway_ip, bundle_ip)`. The third
|
||||
octet comes from SHA-256 of the slug mod 254 (skipping 17 to
|
||||
avoid the docker-default bridge), so parallel bottles get
|
||||
distinct /24s and `resume` reuses the same /24. The bundle
|
||||
container always lands at `.2`; gateway is `.1`; the smolvm
|
||||
Smolfile's `allow_cidrs` is `<bundle_ip>/32`."""
|
||||
digest = hashlib.sha256(slug.encode("utf-8")).digest()
|
||||
octet = (digest[0] % 254) + 1
|
||||
# Skip docker-default 17 to dodge the usual second-bridge
|
||||
# collision. Operators with docker's default bridge at
|
||||
# 172.17.x.x — common Linux setup — would otherwise see a
|
||||
# collision the moment they run a parallel docker bottle.
|
||||
# Skip the docker-default bridge to dodge the most common
|
||||
# collision (operators with `docker0` at 172.17.x.x or a
|
||||
# 192.168.17.x VPN client).
|
||||
if octet == 17:
|
||||
octet = 18
|
||||
subnet = f"192.168.{octet}.0/24"
|
||||
gateway = f"192.168.{octet}.1"
|
||||
return subnet, gateway
|
||||
|
||||
|
||||
def allocate_loopback_port() -> int:
|
||||
"""Bind / release dance to grab a free TCP port on 127.0.0.1.
|
||||
|
||||
Race window: a parallel allocator could grab the same port
|
||||
between our close and the bundle's bind. The window is small
|
||||
enough that chunk 1 doesn't address it; chunk 3 will add a
|
||||
retry loop if it shows up in practice."""
|
||||
with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return int(s.getsockname()[1])
|
||||
bundle_ip = f"192.168.{octet}.2"
|
||||
return subnet, gateway, bundle_ip
|
||||
|
||||
Reference in New Issue
Block a user