Skip to content

Commit fd8e65d

Browse files
feat: nested sandbox support (sandbox-in-sandbox)
Allow running ╭───── VM Created ──────╮ │ Created VM 'sbx-ohm'. │ ╰───────────────────────╯ VM Details ┌─────────┬─────────────────────────┐ │ Name │ sbx-ohm │ │ Status │ running │ │ OS │ ubuntu │ │ Started │ 2026-06-26 17:17:51 UTC │ └─────────┴─────────────────────────┘ Next: smolvm sandbox shell sbx-ohm SSH: smolvm sandbox ssh sbx-ohm Info: smolvm sandbox info sbx-ohm inside a SmolVM sandbox guest. Changes: - backends.py: add _running_inside_smolvm_guest() that detects nested context via SMOLVM_NESTED env var or /run/smolvm-guest marker file; auto-selects QEMU backend when nested instead of Firecracker - qemu_args.py: force TCG accelerator when running inside a guest (no KVM available); reads SMOLVM_QEMU_ACCEL env var first so it can still be overridden explicitly - comm/select.py: disable vsock when nested (no /dev/vhost-vsock in guest); falls back to SSH for the control channel - cloud_init.py: write nested env vars to /etc/environment (not just profile.d), add /etc/pip.conf, write /run/smolvm-guest in bootcmd, install arch-aware qemu-system package in runcmd - Dockerfile.base-rootfs: bake python3-pip, python3-venv, qemu-utils, qemu-system-arm/x86 (arch-detected), nftables into the base image - preset-init.sh: write /run/smolvm-guest marker and /etc/pip.conf at PID 1 startup on every boot - tests/test_qemu_args.py: patch _KVM_DEV and _SMOLVM_GUEST_MARKER in autouse fixture so Linux host simulation works from macOS runner After image rebuild and new release, the user flow becomes: smolvm sandbox create --name my-computer --memory 2048 smolvm sandbox ssh my-computer pip install smolvm smolvm sandbox create --name inner --os ubuntu --memory 512 --boot-timeout 300
1 parent dc8388a commit fd8e65d

11 files changed

Lines changed: 185 additions & 15 deletions

File tree

scripts/ci/Dockerfile.base-rootfs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ RUN apt-get update -qq \
3030
git \
3131
bash \
3232
python3 \
33+
python3-pip \
34+
python3-venv \
35+
qemu-utils \
36+
nftables \
37+
&& ARCH="$(dpkg --print-architecture)" \
38+
&& case "$ARCH" in \
39+
amd64) apt-get install -y -qq --no-install-recommends qemu-system-x86 ;; \
40+
arm64) apt-get install -y -qq --no-install-recommends qemu-system-arm ;; \
41+
esac \
3342
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
3443
&& apt-get install -y -qq --no-install-recommends nodejs \
3544
&& apt-get purge -y -qq gnupg \

scripts/ci/preset-init.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,34 @@ chmod 1777 /tmp
8282

8383
log_ts "root-ready"
8484

85+
# ── Nested SmolVM guest identity ─────────────────────────────
86+
# Mark this as a SmolVM guest so nested smolvm invocations auto-select
87+
# QEMU+TCG instead of Firecracker, and skip vsock (no /dev/vhost-vsock).
88+
# Written to /run (tmpfs) so it's always present after boot regardless of
89+
# whether the user's shell sources /etc/profile.d or /etc/environment.
90+
touch /run/smolvm-guest
91+
92+
# ── pip system-wide config ───────────────────────────────────
93+
# Allow pip to install into the system Python without --break-system-packages.
94+
# This makes `pip install smolvm` work out of the box inside a sandbox.
95+
if [ ! -f /etc/pip.conf ]; then
96+
printf '[global]\nbreak-system-packages = true\n' > /etc/pip.conf
97+
fi
98+
99+
# ── Nested SmolVM environment ────────────────────────────────
100+
# Write key env vars to /etc/environment so they apply to all sessions
101+
# (SSH non-login, subprocesses, etc.) without requiring profile.d sourcing.
102+
# These are safe to set on every boot — they're no-ops on a non-nested host.
103+
if ! grep -q 'SMOLVM_NESTED' /etc/environment 2>/dev/null; then
104+
cat >> /etc/environment <<'EOF'
105+
SMOLVM_NESTED=1
106+
SMOLVM_BACKEND=qemu
107+
SMOLVM_QEMU_ACCEL=tcg
108+
SMOLVM_BOOT_TIMEOUT=300
109+
PIP_BREAK_SYSTEM_PACKAGES=1
110+
EOF
111+
fi
112+
85113
# ── Guest agent (vsock control plane) ────────────────────────
86114
# Started before networking and sshd, so explicit-vsock sandboxes can become
87115
# ready without waiting for SSH host-key generation or network setup. SSH-only

src/smolvm/cli/commands/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,13 @@ def _make_start_callback(preset_name: str, command_name: str) -> Any:
918918
)
919919
@click.option("--mount", "mounts", multiple=True, metavar="HOST_PATH[:GUEST_PATH]")
920920
@click.option("--writable-mounts", is_flag=True)
921+
@click.option(
922+
"--disk-mode",
923+
type=click.Choice(["auto", "isolated", "shared"]),
924+
default="auto",
925+
show_default=True,
926+
help="Disk lifecycle mode (isolated = per-VM disk, shared = direct from source).",
927+
)
921928
@click.option("--install-timeout", type=positive_float_type(), default=600.0)
922929
@click.option("--attach/--no-attach", default=None)
923930
@comm_channel_option
@@ -932,6 +939,7 @@ def start(
932939
os_name: str | None,
933940
mounts: tuple[str, ...],
934941
writable_mounts: bool,
942+
disk_mode: str,
935943
install_timeout: float,
936944
attach: bool | None,
937945
comm_channel: str | None,
@@ -951,6 +959,7 @@ def start(
951959
os=os_name,
952960
mounts=_mounts(mounts),
953961
writable_mounts=writable_mounts,
962+
disk_mode=disk_mode,
954963
install_timeout=install_timeout,
955964
attach=attach,
956965
comm_channel=comm_channel,

src/smolvm/cli/commands/options.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
import platform
67
from collections.abc import Callable, Mapping
78
from functools import wraps
@@ -85,7 +86,7 @@ def boot_timeout_option(fn: F) -> F:
8586
"--boot-timeout",
8687
type=float,
8788
callback=_positive_number,
88-
default=30.0,
89+
default=_boot_timeout_default(),
8990
show_default=True,
9091
help="Seconds to wait for the sandbox to be ready.",
9192
)(fn)
@@ -107,3 +108,13 @@ def positive_int_type() -> click.IntRange:
107108

108109
def positive_float_type() -> click.FloatRange:
109110
return click.FloatRange(min=0, min_open=True)
111+
112+
113+
def _boot_timeout_default() -> float:
114+
raw = os.environ.get("SMOLVM_BOOT_TIMEOUT", "").strip()
115+
if not raw:
116+
return 30.0
117+
try:
118+
return float(raw)
119+
except ValueError:
120+
return 30.0

src/smolvm/comm/select.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939
from smolvm.comm.base import CommChannelKind
4040
from smolvm.exceptions import SmolVMError
41-
from smolvm.runtime.backends import BACKEND_FIRECRACKER, BACKEND_QEMU
41+
from smolvm.runtime.backends import BACKEND_FIRECRACKER, BACKEND_QEMU, _running_inside_smolvm_guest
4242
from smolvm.types import GuestOS
4343

4444
_VHOST_VSOCK_DEV = Path("/dev/vhost-vsock")
@@ -49,7 +49,13 @@ def host_supports_vsock() -> bool:
4949
5050
True only on Linux with the ``vhost_vsock`` driver loaded (its device node
5151
present). macOS/HVF has no equivalent, so this returns False there.
52+
53+
Also returns False inside a nested SmolVM sandbox: the guest kernel exposes
54+
no ``/dev/vhost-vsock`` in that context, and the sandbox always uses SSH as
55+
its control channel.
5256
"""
57+
if _running_inside_smolvm_guest():
58+
return False
5359
return platform.system() == "Linux" and _VHOST_VSOCK_DEV.exists()
5460

5561

src/smolvm/facade.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
BACKEND_FIRECRACKER,
8080
BACKEND_LIBKRUN,
8181
BACKEND_QEMU,
82+
_running_inside_smolvm_guest,
8283
resolve_backend,
8384
)
8485
from smolvm.runtime.boot_profiles import (
@@ -575,6 +576,11 @@ def _build_auto_config(
575576
public_key_value = public_key_path.read_text().strip()
576577

577578
resolved_memory = _AUTO_CONFIG_DEFAULT_MEM_SIZE_MIB[resolved_os]
579+
# Inside a nested SmolVM guest, TCG (software emulation) is used because
580+
# KVM is unavailable. Use a smaller default so the inner VM fits within
581+
# the outer sandbox's memory without the user needing to specify --memory.
582+
if memory is None and _running_inside_smolvm_guest():
583+
resolved_memory = min(resolved_memory, 512)
578584
if memory is not None:
579585
resolved_memory = memory
580586
default_disk_size_mib = _AUTO_CONFIG_DEFAULT_DISK_SIZE_MIB[resolved_os]

src/smolvm/images/builder.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,9 @@ def build_alpine_ssh(
559559
iproute2 \\
560560
curl \\
561561
bash \\
562-
python3
562+
python3 \\
563+
qemu-img \\
564+
qeum-system-x86_64
563565
564566
# Configure SSH. Host keys are generated at first boot in /init, not here,
565567
# so each VM gets a unique SSH identity — required for safely sharing images.

src/smolvm/images/cloud_init.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,17 @@ def default_user_data(ssh_public_key: str) -> str:
3737
shell: /bin/bash
3838
ssh_authorized_keys:
3939
- {ssh_public_key}
40+
package_update: true
41+
packages:
42+
- python3-pip
43+
- python3-venv
44+
- qemu-utils
45+
- openssh-client
46+
- iproute2
47+
- nftables
4048
bootcmd:
4149
- mkdir -p /etc/systemd/resolved.conf.d
50+
- touch /run/smolvm-guest
4251
- ['sh', '-c', '{dns_cmd}']
4352
- ['systemctl', 'restart', 'systemd-resolved']
4453
- ['systemctl', 'restart', 'systemd-timesyncd']
@@ -53,8 +62,36 @@ def default_user_data(ssh_public_key: str) -> str:
5362
content: |
5463
#!/bin/sh
5564
printf '\\n SmolVM Sandbox — powered by Celesto AI\\n https://celesto.ai\\n\\n'
65+
- path: /etc/pip.conf
66+
permissions: "0644"
67+
content: |
68+
[global]
69+
break-system-packages = true
70+
- path: /etc/environment
71+
permissions: "0644"
72+
append: true
73+
content: |
74+
SMOLVM_NESTED=1
75+
SMOLVM_BACKEND=qemu
76+
SMOLVM_QEMU_ACCEL=tcg
77+
SMOLVM_BOOT_TIMEOUT=300
78+
PIP_BREAK_SYSTEM_PACKAGES=1
79+
- path: /etc/profile.d/smolvm_nested.sh
80+
permissions: "0644"
81+
content: |
82+
export SMOLVM_NESTED=1
83+
export SMOLVM_BACKEND=qemu
84+
export SMOLVM_QEMU_ACCEL=tcg
85+
export SMOLVM_BOOT_TIMEOUT=300
86+
export PIP_BREAK_SYSTEM_PACKAGES=1
5687
runcmd:
5788
- rm -f /etc/update-motd.d/10-help-text /etc/update-motd.d/60-unminimize
89+
- ['sh', '-lc', 'command -v pip >/dev/null || ln -sf /usr/bin/pip3 /usr/local/bin/pip']
90+
- >
91+
case "$(uname -m)" in
92+
aarch64|arm64) apt-get install -y --no-install-recommends qemu-system-arm ;;
93+
x86_64|amd64) apt-get install -y --no-install-recommends qemu-system-x86 ;;
94+
esac
5895
- >
5996
systemctl restart ssh || systemctl restart sshd ||
6097
service ssh restart || service sshd restart || true

src/smolvm/runtime/backends.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import os
2020
import platform
21+
from pathlib import Path
2122

2223
from smolvm.utils import which # noqa: F401 — imported for test patching
2324

@@ -28,6 +29,22 @@
2829

2930
SUPPORTED_BACKENDS = {BACKEND_FIRECRACKER, BACKEND_QEMU, BACKEND_LIBKRUN}
3031

32+
# Marker file written by cloud-init inside every SmolVM guest image.
33+
# Presence means we're running inside a SmolVM sandbox, not on the host.
34+
_SMOLVM_GUEST_MARKER = Path("/run/smolvm-guest")
35+
36+
37+
def _running_inside_smolvm_guest() -> bool:
38+
"""Return True if this process is running inside a SmolVM sandbox guest.
39+
40+
Checks (in order):
41+
1. ``SMOLVM_NESTED=1`` environment variable (set by profile.d / /etc/environment).
42+
2. The ``/run/smolvm-guest`` marker file written by cloud-init at boot.
43+
"""
44+
if os.environ.get("SMOLVM_NESTED") == "1":
45+
return True
46+
return _SMOLVM_GUEST_MARKER.exists()
47+
3148

3249
def resolve_backend(requested: str | None = None) -> str:
3350
"""Resolve the effective backend name.
@@ -52,6 +69,8 @@ def resolve_backend(requested: str | None = None) -> str:
5269
system = platform.system().lower()
5370
if system == "darwin":
5471
return BACKEND_QEMU
72+
if _running_inside_smolvm_guest():
73+
return BACKEND_QEMU
5574
return BACKEND_FIRECRACKER
5675

5776
if raw in SUPPORTED_BACKENDS:

src/smolvm/runtime/qemu_args.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from typing import cast
3636

3737
from smolvm.exceptions import SmolVMError
38+
from smolvm.runtime.backends import _running_inside_smolvm_guest
3839
from smolvm.runtime.guest_platforms import GuestPlatformSpec
3940
from smolvm.runtime.qemu import QEMU_ROOT_NODE_NAME
4041
from smolvm.types import GuestOS, QemuMachine, VMInfo
@@ -50,9 +51,10 @@
5051
_Q35_AHCI_PORTS = 6
5152

5253
_QEMU_MACHINE_ENV = "SMOLVM_QEMU_MACHINE"
53-
_QEMU_MICROVM_MACHINE = "microvm,accel=kvm,acpi=on,pcie=off,pic=off,pit=off,rtc=on"
54+
_QEMU_MICROVM_MACHINE_BASE = "microvm,acpi=on,pcie=off,pic=off,pit=off,rtc=on"
5455
_QEMU_MACHINE_VALUES: set[QemuMachine] = {"auto", "q35", "microvm"}
55-
56+
_QEMU_ACCEL_ENV = "SMOLVM_QEMU_ACCEL"
57+
_KVM_DEV = Path("/dev/kvm")
5658

5759
# Candidate UEFI firmware locations for aarch64 QEMU firmware-boot.
5860
# Searched in order; the first existing file wins. macOS Homebrew ships
@@ -68,6 +70,28 @@
6870
)
6971

7072

73+
def _resolve_qemu_accel(host_system: str) -> tuple[str, str]:
74+
"""Return (accel, cpu) for QEMU based on host + env."""
75+
raw = os.environ.get(_QEMU_ACCEL_ENV, "").strip().lower()
76+
if raw in {"kvm", "hvf", "tcg"}:
77+
accel = raw
78+
else:
79+
# Inside a SmolVM guest there is no KVM or HVF — always use TCG.
80+
# We only apply this guard when the effective host_system is Linux
81+
# (i.e. not when tests override host_system to exercise Linux code
82+
# paths from a Darwin test runner).
83+
if host_system == "Linux" and _running_inside_smolvm_guest():
84+
accel = "tcg"
85+
elif host_system == "Darwin":
86+
accel = "hvf"
87+
else:
88+
accel = "kvm" if _KVM_DEV.exists() and os.access(_KVM_DEV, os.R_OK | os.W_OK) else "tcg"
89+
90+
# TCG needs a generic CPU model; "host" is only valid for KVM/HVF.
91+
cpu = "host" if accel in {"kvm", "hvf"} else "max"
92+
return accel, cpu
93+
94+
7195
def _find_aarch64_uefi_firmware() -> Path | None:
7296
"""Return the first existing aarch64 UEFI firmware file, or ``None``."""
7397
for candidate in _AARCH64_EDK2_FIRMWARE_CANDIDATES:
@@ -337,10 +361,12 @@ def build_qemu_argv(
337361
# macOS → Hypervisor.framework; Linux → KVM (if /dev/kvm is
338362
# missing the user couldn't run firecracker either, so requiring
339363
# KVM here is consistent).
340-
if system == "Darwin":
341-
machine, cpu = "virt,accel=hvf", "host"
342-
else:
343-
machine, cpu = "virt,accel=kvm", "host"
364+
# if system == "Darwin":
365+
# machine, cpu = "virt,accel=hvf", "host"
366+
# else:
367+
# machine, cpu = "virt,accel=kvm", "host"
368+
accel, cpu = _resolve_qemu_accel(system)
369+
machine = f"virt,accel={accel}"
344370
cmd.extend(["-machine", machine, "-cpu", cpu])
345371
if vm_info.config.boot_mode == "firmware":
346372
firmware_path = _find_aarch64_uefi_firmware()
@@ -373,7 +399,9 @@ def build_qemu_argv(
373399
]
374400
)
375401
elif use_qemu_microvm:
376-
cmd.extend(["-machine", _QEMU_MICROVM_MACHINE, "-cpu", "host"])
402+
accel, cpu = _resolve_qemu_accel(system)
403+
machine = f"microvm,accel={accel},{_QEMU_MICROVM_MACHINE_BASE.split(',', 1)[1]}"
404+
cmd.extend(["-machine", machine, "-cpu", cpu])
377405
for drive_id in extra_drive_ids:
378406
cmd.extend(["-device", f"virtio-blk-device,drive={drive_id}"])
379407
for fsdev_id, tag in workspace_fsdev_ids:
@@ -390,10 +418,12 @@ def build_qemu_argv(
390418
# See accel comment on the aarch64 branch above. Without
391419
# accel=kvm on Linux, QEMU runs TCG and Ubuntu cloud-init blows
392420
# past wait_for_ssh in seconds vs minutes.
393-
if system == "Darwin":
394-
machine_base, cpu_base = "q35,accel=hvf", "host"
395-
else:
396-
machine_base, cpu_base = "q35,accel=kvm", "host"
421+
# if system == "Darwin":
422+
# machine_base, cpu_base = "q35,accel=hvf", "host"
423+
# else:
424+
# machine_base, cpu_base = "q35,accel=kvm", "host"
425+
accel, cpu = _resolve_qemu_accel(system)
426+
machine_base, cpu_base = f"q35,accel={accel}", cpu
397427
# Per-OS extras are comma-appended to the base. For _LINUX_SPEC
398428
# both extra tuples are empty → byte-identical to the legacy form.
399429
machine_str = ",".join((machine_base, *platform_spec.machine_extra_opts))

0 commit comments

Comments
 (0)