Skip to content

Commit 286f970

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 286f970

10 files changed

Lines changed: 159 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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,20 @@ 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+
8599
# ── Guest agent (vsock control plane) ────────────────────────
86100
# Started before networking and sshd, so explicit-vsock sandboxes can become
87101
# ready without waiting for SSH host-key generation or network setup. SSH-only

src/smolvm/cli/commands/app.py

Lines changed: 7 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

src/smolvm/cli/commands/options.py

Lines changed: 11 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,12 @@ 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+
def _boot_timeout_default() -> float:
113+
raw = os.environ.get("SMOLVM_BOOT_TIMEOUT", "").strip()
114+
if not raw:
115+
return 30.0
116+
try:
117+
return float(raw)
118+
except ValueError:
119+
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/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: 39 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
@@ -67,6 +69,26 @@
6769
"/usr/share/edk2-armvirt/aarch64/QEMU_EFI.fd",
6870
)
6971

72+
def _resolve_qemu_accel(host_system: str) -> tuple[str, str]:
73+
"""Return (accel, cpu) for QEMU based on host + env."""
74+
raw = os.environ.get(_QEMU_ACCEL_ENV, "").strip().lower()
75+
if raw in {"kvm", "hvf", "tcg"}:
76+
accel = raw
77+
else:
78+
# Inside a SmolVM guest there is no KVM or HVF — always use TCG.
79+
# We only apply this guard when the effective host_system is Linux
80+
# (i.e. not when tests override host_system to exercise Linux code
81+
# paths from a Darwin test runner).
82+
if host_system == "Linux" and _running_inside_smolvm_guest():
83+
accel = "tcg"
84+
elif host_system == "Darwin":
85+
accel = "hvf"
86+
else:
87+
accel = "kvm" if _KVM_DEV.exists() and os.access(_KVM_DEV, os.R_OK | os.W_OK) else "tcg"
88+
89+
# TCG needs a generic CPU model; "host" is only valid for KVM/HVF.
90+
cpu = "host" if accel in {"kvm", "hvf"} else "max"
91+
return accel, cpu
7092

7193
def _find_aarch64_uefi_firmware() -> Path | None:
7294
"""Return the first existing aarch64 UEFI firmware file, or ``None``."""
@@ -337,10 +359,12 @@ def build_qemu_argv(
337359
# macOS → Hypervisor.framework; Linux → KVM (if /dev/kvm is
338360
# missing the user couldn't run firecracker either, so requiring
339361
# KVM here is consistent).
340-
if system == "Darwin":
341-
machine, cpu = "virt,accel=hvf", "host"
342-
else:
343-
machine, cpu = "virt,accel=kvm", "host"
362+
# if system == "Darwin":
363+
# machine, cpu = "virt,accel=hvf", "host"
364+
# else:
365+
# machine, cpu = "virt,accel=kvm", "host"
366+
accel, cpu = _resolve_qemu_accel(system)
367+
machine = f"virt,accel={accel}"
344368
cmd.extend(["-machine", machine, "-cpu", cpu])
345369
if vm_info.config.boot_mode == "firmware":
346370
firmware_path = _find_aarch64_uefi_firmware()
@@ -373,7 +397,9 @@ def build_qemu_argv(
373397
]
374398
)
375399
elif use_qemu_microvm:
376-
cmd.extend(["-machine", _QEMU_MICROVM_MACHINE, "-cpu", "host"])
400+
accel, cpu = _resolve_qemu_accel(system)
401+
machine = f"microvm,accel={accel},{_QEMU_MICROVM_MACHINE_BASE.split(',', 1)[1]}"
402+
cmd.extend(["-machine", machine, "-cpu", cpu])
377403
for drive_id in extra_drive_ids:
378404
cmd.extend(["-device", f"virtio-blk-device,drive={drive_id}"])
379405
for fsdev_id, tag in workspace_fsdev_ids:
@@ -390,10 +416,12 @@ def build_qemu_argv(
390416
# See accel comment on the aarch64 branch above. Without
391417
# accel=kvm on Linux, QEMU runs TCG and Ubuntu cloud-init blows
392418
# 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"
419+
# if system == "Darwin":
420+
# machine_base, cpu_base = "q35,accel=hvf", "host"
421+
# else:
422+
# machine_base, cpu_base = "q35,accel=kvm", "host"
423+
accel, cpu = _resolve_qemu_accel(system)
424+
machine_base, cpu_base = f"q35,accel={accel}", cpu
397425
# Per-OS extras are comma-appended to the base. For _LINUX_SPEC
398426
# both extra tuples are empty → byte-identical to the legacy form.
399427
machine_str = ",".join((machine_base, *platform_spec.machine_extra_opts))

tests/test_qemu_args.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,20 @@
4040

4141

4242
@pytest.fixture(autouse=True)
43-
def _clear_qemu_machine_env(monkeypatch: pytest.MonkeyPatch) -> None:
43+
def _clear_qemu_machine_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
4444
monkeypatch.delenv("SMOLVM_QEMU_MACHINE", raising=False)
45+
# Simulate a Linux host with KVM and no nested-guest marker.
46+
# Tests that need different behaviour (Darwin, TCG, nested) override these.
47+
monkeypatch.delenv("SMOLVM_NESTED", raising=False)
48+
monkeypatch.delenv("SMOLVM_QEMU_ACCEL", raising=False)
49+
import smolvm.runtime.backends as _be
50+
import smolvm.runtime.qemu_args as _qa
51+
# Make _KVM_DEV point to a real file so "Linux" tests see KVM as available.
52+
kvm_fake = tmp_path / "kvm"
53+
kvm_fake.touch()
54+
monkeypatch.setattr(_qa, "_KVM_DEV", kvm_fake)
55+
# Make the nested-guest marker point to a non-existent path.
56+
monkeypatch.setattr(_be, "_SMOLVM_GUEST_MARKER", tmp_path / "no-smolvm-guest")
4557

4658

4759
def _qemu_vm_info(

0 commit comments

Comments
 (0)