Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ func Fingerprint() string {
}

// Profile is the declared, per-family provisioning contract. IDs are the
// /etc/os-release values that map to the family; PkgQueryBody, PkgInstall and
// CARefresh are shell fragments spliced into the generated selector.
// /etc/os-release values that map to the family; PkgQueryBody, PkgInstall,
// CARefresh and Bootstrap are shell fragments spliced into the generated
// selector (Bootstrap, if set, runs first — for premade images with no FHS
// userland yet).
type Profile struct {
Key string
Init InitSystem
Expand All @@ -40,6 +42,7 @@ type Profile struct {
AdminGroup string
CABundle string
CARefresh string
Bootstrap string
}

var Profiles = []Profile{
Expand Down Expand Up @@ -139,6 +142,30 @@ var Profiles = []Profile{
CABundle: "/etc/ssl/certs/ca-certificates.crt",
CARefresh: "update-ca-certificates",
},
{
Key: "nixos",
Init: InitNixOS,
IDs: []string{"nixos"},
// Premade: packages and services are declared in the image's own NixOS
// configuration, so there is no package manager to drive at build time.
Packages: nil,
PkgQueryBody: "true",
PkgInstall: `echo "[provision] ERROR: NixOS images are premade — packages must be declared in the image's NixOS configuration" >&2; exit 1`,
InitBinary: "/nix/var/nix/profiles/system/init",
TimeSyncUnit: "chronyd",
// Left empty on purpose: services.openssh is declared in the image's
// configuration, and the NixOS init setup never enables units.
SSHUnit: "",
AdminGroup: "wheel",
CABundle: "/etc/ssl/certs/ca-certificates.crt",
// The bundle appears at first activation; nothing to refresh pre-boot.
CARefresh: `echo "NixOS: the CA bundle is provided by the image configuration at first activation; nothing to refresh at provision time"`,
// No FHS userland pre-activation — put the baked busybox on PATH first.
Bootstrap: `E2B_BB_DIR=/run/e2b-tools
/usr/bin/busybox mkdir -p "$E2B_BB_DIR"
/usr/bin/busybox --install -s "$E2B_BB_DIR"
export PATH="$E2B_BB_DIR:$PATH"`,
},
}

// SupportedIDs returns every os-release ID the selector accepts.
Expand All @@ -160,6 +187,9 @@ func ShellSelector() string {
b.WriteString(`case "$E2B_DISTRO_ID" in` + "\n")
for _, p := range Profiles {
fmt.Fprintf(&b, " %s)\n", strings.Join(p.IDs, "|"))
if p.Bootstrap != "" {
fmt.Fprintf(&b, " %s\n", p.Bootstrap)
}
fmt.Fprintf(&b, " E2B_PACKAGES=%q\n", strings.Join(p.Packages, " "))
fmt.Fprintf(&b, " e2b_pkg_query() { %s; }\n", p.PkgQueryBody)
fmt.Fprintf(&b, " e2b_pkg_install() { %s; }\n", p.PkgInstall)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const (
InitSystemd InitSystem = "systemd"
// InitOpenRC — Alpine (busybox init → OpenRC via the baked /etc/init.d/envd).
InitOpenRC InitSystem = "openrc"
// InitNixOS — premade NixOS (declarative; provisioning masks nothing).
InitNixOS InitSystem = "nixos"
)

// initSetup is the provisioning-time shell block per init system. Bodies may
Expand Down Expand Up @@ -127,6 +129,12 @@ if [ -e /etc/init.d/sshd ]; then
else
echo "sshd service not present on this image; skipping"
fi`,

// NixOS is declaratively configured; drop the baked systemd units so
// activation can own /etc/systemd/system as a store symlink (foreign files
// there make systemd boot with no units at all).
InitNixOS: `echo "NixOS is declaratively configured; removing the baked systemd drop-ins"
rm -rf /etc/systemd/system`,
}

// indentBlock indents every non-empty line of a shell block for embedding
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# E2B premade NixOS base image

NixOS templates work the inverse of every other family: instead of the
orchestrator provisioning the image imperatively, the image is **premade** from
`configuration.nix`, which declares everything `provision.sh` installs
elsewhere — the envd systemd unit, chrony, sshd, the default `user` (with a
matching `user` group and the exact sudoers line the build steps check for),
`/bin/bash` (build steps invoke it explicitly), and the journald watchdog
override. The orchestrator's `nixos` profile then only verifies and boots
(see `../distro.go` and the `InitNixOS` block in `../init.go`).

## Building and publishing

`./build.sh <tag> [registry]` (run on a Linux host with docker; the registry
defaults to `127.0.0.1:5000`):

1. evaluates the NixOS system closure with `nix` inside a `nixos/nix`
container (`nixpkgs` channel pinned in the script), from the
`configuration.nix` committed next to the script,
2. packs the closure into a single-layer OCI rootfs tar, adding the three
pieces of glue the boot path needs:
- `/sbin/init -> /nix/var/nix/profiles/system/init` (the stage-2 init the
`nixos` profile points the kernel at),
- `/nix/var/nix/profiles/system -> <toplevel store path>`,
- a static `/etc/os-release` with `ID=nixos` so the distro selector can
identify the image *before* the first activation generates the real one,
3. `docker import`s and pushes the tar.

**Push every rebuild under a NEW TAG** (hence the required `<tag>` argument).
The base-layer cache key includes the image reference as written in the
Dockerfile — republishing under the same tag silently reuses the previously
cached base layer (observed; same "default tag" ambiguity called out in
`phases/base/hash.go`).

## Boot-path notes (all observed on real KVM)

- Before the first activation the image has **no FHS userland** — no
`/bin/sh`, no `mkdir`. The provisioning boot runs entirely through the baked
busybox (see `core/rootfs/files/rcS.sh.tpl`, `inittab.tpl`,
`provision-runner.sh.tpl`), and the `nixos` profile's `Bootstrap` puts
busybox applets on `PATH` for the shared provisioning body.
- NixOS activation manages `/etc/systemd/system` as a symlink into the store;
the baked systemd drop-ins must be removed at provisioning (the `InitNixOS`
setup does this) or `setup-etc` refuses the symlink and systemd boots with
no units at all ("Unit default.target not found").
- The sandbox gets the nix toolchain natively (`nix-env` on PATH for `user`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#!/bin/bash
# Build and publish the E2B premade NixOS base image.
#
# ./build.sh <tag> [registry]
#
# Runs from its own directory so the configuration.nix committed next to it is
# the one that gets built. The tag is required: the base-layer cache key
# includes the image reference as written in the Dockerfile, so republishing
# under a tag that was already built silently reuses the cached base layer.
set -euo pipefail

here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
tag=${1:-}
registry=${2:-127.0.0.1:5000}
if [ -z "$tag" ]; then
echo "usage: ${BASH_SOURCE[0]} <tag> [registry] # push every rebuild under a NEW tag" >&2
exit 1
fi
image="$registry/e2b-nixos:$tag"

# Staged outside the repo: the closure tar is ~700 MB, and the repo checkout can
# be a slow network mount on a dev box.
work=${E2B_NIXOS_WORKDIR:-/var/tmp/e2b-nixos-base}
mkdir -p "$work"
cp "$here/configuration.nix" "$work/configuration.nix"
rm -f "$work/result"

# Build the toplevel closure with nix inside the nixos/nix container.
docker run --rm -v "$work:/build" nixos/nix:2.35.1 sh -c "
set -e
nix-build -I nixpkgs=channel:nixos-24.05 -I nixos-config=/build/configuration.nix \
'<nixpkgs/nixos>' -A config.system.build.toplevel -o /build/result
top=\$(readlink /build/result)
echo \"TOPLEVEL=\$top\"
# Pack the full closure + the boot/identity glue into one rootfs tar.
nix-store -qR /build/result > /build/closure.txt
tar -cf /build/nixos-rootfs.tar \$(cat /build/closure.txt)
staging=/tmp/extra
rm -rf \$staging
mkdir -p \$staging/sbin \$staging/etc \$staging/nix/var/nix/profiles
ln -s \$top \$staging/nix/var/nix/profiles/system
# Tarring store paths does not make them valid to nix: the DB lives in
# /nix/var/nix/db, which the closure does not carry. Ship the registration so
# first boot can load it, otherwise nix-env and friends reject every path.
nix-store --dump-db \$(cat /build/closure.txt) > \$staging/nix/var/nix/db-registration
ln -s /nix/var/nix/profiles/system/init \$staging/sbin/init
cat > \$staging/etc/os-release <<OSR
NAME=NixOS
ID=nixos
VERSION_ID=\"24.05\"
PRETTY_NAME=\"NixOS 24.05 (E2B sandbox base)\"
OSR
tar -rf /build/nixos-rootfs.tar -C \$staging sbin etc nix
Comment thread
tomassrnka marked this conversation as resolved.
echo PACKED
"
ls -lh "$work/nixos-rootfs.tar"
docker import "$work/nixos-rootfs.tar" "$image"
docker push "$image"
echo "IMAGE_PUSHED $image"
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# E2B premade NixOS sandbox base.
# Everything provision.sh does imperatively on other distros is declared here;
# the orchestrator's nixos profile only verifies and boots.
{ config, pkgs, lib, ... }:
{
# Boot: the E2B microVM supplies its own kernel and mounts the rootfs rw
# (root=/dev/vda rw), then runs this system's stage-2 init directly — no
# initrd, no bootloader.
# The initrd/kernel in the closure are unused dead weight (the microVM
# boots E2B's kernel with init= pointing at this system's stage-2 init),
# but NixOS's module system requires them to evaluate; only grub is off.
boot.loader.grub.enable = false;
fileSystems."/" = { device = "/dev/vda"; fsType = "ext4"; };

# eth0 is configured by the kernel command line (ip=...); nothing to manage.
networking.useDHCP = false;
networking.resolvconf.enable = false;
# NixOS is the only family that enables a firewall by default — provision.sh
# installs iptables/nftables for user workloads but never filters. Leaving it on
# would drop the orchestrator's connection to envd (TCP 49983) and every
# customer-exposed sandbox port; isolation is enforced by the E2B network layer.
networking.firewall.enable = false;
# The E2B rootfs layer bakes an immutable /etc/resolv.conf; NixOS must not
# try to regenerate it at activation.
environment.etc."resolv.conf".enable = false;
Comment thread
tomassrnka marked this conversation as resolved.
Comment thread
tomassrnka marked this conversation as resolved.
# Same for the baked /etc/hostname and /etc/hosts (both carry e2b.local, which
# every other family keeps). An empty hostName also stops activation calling
# `hostname` and overriding the running name.
networking.hostName = "";
environment.etc."hostname".enable = false;
environment.etc."hosts".enable = false;

# The env daemon: E2B bakes the static binary at /usr/bin/envd as an OCI
# layer on top of this image. Mirrors envd.service.tpl (systemd family).
systemd.services.envd = {
description = "E2B env daemon";
wantedBy = [ "multi-user.target" ];
unitConfig.StartLimitIntervalSec = 0;
# e2b-seed-certs (baked at /usr/local/bin by the E2B layer) bind-mounts a
# tmpfs over /etc/ssl/certs seeded with DEREFERENCED copies of the trust
# bundle: envd APPENDS the egress-proxy CA to ca-certificates.crt at
# sandbox /init, which must not hit a symlink into the read-only store.
# socat and iptables are executed by name: envd spawns socat to forward
# exposed ports and shells out to iptables to pin the MMDS route. There is no
# FHS bin dir to find them in, so they must be on the unit's PATH.
#
# envd hands its OWN environment's PATH to every process it spawns, so this
# list is also the PATH of anything started through the exec API that does
# not go through a login shell (`sh -c` — what the orchestrator's pre-pause
# reclaim/sync use — or a bare argv[0]). Without the system profile that PATH
# has no `sh`, `curl`, `git`, `jq`: on the FHS families the same unit inherits
# systemd's default /usr/bin:/bin, i.e. the whole userland, so append NixOS's
# equivalent. /run/wrappers first, like the login PATH, so setuid wrappers win
# over the plain store copies; the explicit packages stay ahead of the profile
# so envd's own helpers always resolve inside this closure.
path = [
"/run/wrappers"
pkgs.coreutils pkgs.util-linux pkgs.gnutar pkgs.socat pkgs.iptables
"/run/current-system/sw"
];
serviceConfig = {
Type = "simple";
Restart = "always";
ExecStartPre = "/usr/local/bin/e2b-seed-certs";
ExecStart = "/usr/bin/envd";
LimitCORE = "infinity";
# envd.service.tpl templates GOMEMLIMIT per sandbox as min(MemoryMB/2, 512)MiB;
# a premade image can't know the sandbox size, so pin the 512 MiB ceiling —
# envd must still GC under a cap, not grow unbounded.
Environment = [ "GOTRACEBACK=all" "GOMEMLIMIT=512MiB" ];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive envd's memory limit from sandbox RAM

For supported templates below 1 GiB, this hard-coded value gives envd a much larger soft heap limit than the existing min(MemoryMB/2, 512) policy in rootfs/templates.go; for example, a 128 MiB sandbox receives 512 MiB instead of 64 MiB. Under allocation pressure envd can therefore retain far more of the guest's memory before the limit drives aggressive GC, causing workload or VM OOMs on the 128/256/512 MiB configurations, so the unit should calculate the limit at boot or receive the template-specific value.

Useful? React with 👍 / 👎.

# Priority/scheduling parity with envd.service.tpl (ionice 1:4 = realtime,4).
Nice = -20;
IOSchedulingClass = "realtime";
IOSchedulingPriority = 4;
OOMPolicy = "continue";
OOMScoreAdjust = -1000;
# Resource-control parity: reserve envd's memory and win CPU/IO contention.
Delegate = true;
MemoryMin = "50M";
MemoryLow = "100M";
CPUAccounting = true;
CPUWeight = 1000;
IOAccounting = true;
IOWeight = 10000;
};
};
Comment thread
tomassrnka marked this conversation as resolved.

# Default sandbox user (matches configure.sh on the other families).
# Match useradd semantics on the other families: a per-user group named
# after the user (configure.sh chowns /home/user to user:user).
users.groups.user = {};
users.users.user = {
isNormalUser = true;
group = "user";
extraGroups = [ "wheel" ];
initialHashedPassword = "";
};
users.users.root.initialHashedPassword = "";
security.sudo.wheelNeedsPassword = false;
# The DEFAULT USER build step greps for this exact line before appending to
# /etc/sudoers (which is a read-only store symlink on NixOS) — declaring it
# makes that step a clean no-op.
security.sudo.extraConfig = "user ALL=(ALL:ALL) NOPASSWD: ALL";
Comment thread
cursor[bot] marked this conversation as resolved.

services.openssh = {
enable = true;
settings.PermitRootLogin = "yes";
settings.PermitEmptyPasswords = true;
};
security.pam.services.sshd.allowNullPassword = true;

# Time-sync parity with provision.sh: prefer the hypervisor PHC refclock
# (kvm-ptp — no network, tracks the host) when /dev/ptp0 is present, else the
# NTP pool. A refclock line for a missing PHC is FATAL to chronyd, and a premade
# image can't probe the device at build time, so the source line is written at
# boot by e2b-chrony-source.service and pulled in via this include.
services.chrony = {
enable = true;
servers = [ ];
extraConfig = ''
include /run/chrony-e2b/source.conf
makestep 1.0 3
'';
};

systemd.services.e2b-chrony-source = {
description = "E2B: select chrony time source (PHC refclock if /dev/ptp0, else NTP)";
before = [ "chronyd.service" ];
requiredBy = [ "chronyd.service" ];
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
};
script = ''
mkdir -p /run/chrony-e2b
if [ -e /dev/ptp0 ]; then
echo "refclock PHC /dev/ptp0 poll 2 dpoll 2" > /run/chrony-e2b/source.conf
else
echo "pool pool.ntp.org iburst maxsources 3" > /run/chrony-e2b/source.conf
fi
'';
};
Comment thread
tomassrnka marked this conversation as resolved.

# Journald must not watchdog-reboot when the microVM is paused for
# snapshots (mirrors the systemd-family override baked into other images).
systemd.services.systemd-journald.serviceConfig.WatchdogSec = 0;

# No serial getty fighting the console; keep the closure lean.
systemd.services."serial-getty@ttyS0".enable = false;
documentation.enable = false;

# Kernel tunables provision.sh writes to /etc/sysctl.conf on other
# families (NixOS reads sysctl.d from its own config instead).
boot.kernel.sysctl."fs.inotify.max_user_watches" = 65536;
boot.kernel.sysctl."vm.compaction_proactiveness" = 0;

# E2B build steps and customer commands are executed via /bin/bash (the
# orchestrator invokes it explicitly, like on every FHS distro) — provide it.
system.activationScripts.e2bBinBash = "mkdir -m 0755 -p /bin && ln -sfn ${pkgs.bash}/bin/bash /bin/bash";

# Load the store registration build.sh packed, once, so the nix tooling sees
# the closure as valid. Never fail activation over it: without the DB the nix
# commands are broken, but the sandbox itself is fine — which is the status quo
# this repairs, not a regression it could introduce.
system.activationScripts.e2bNixDb = ''
if [ -f /nix/var/nix/db-registration ] && [ ! -e /nix/var/nix/db/db.sqlite ]; then
${pkgs.nix}/bin/nix-store --load-db < /nix/var/nix/db-registration || true
fi
'';

# Parity with the package set provision.sh installs on the other families, so
# a sandbox exposes the same userland whichever base image it was built from.
# (openssh, sudo, chrony and bash are declared as services/programs above.)
# shadow is not optional: finalize's configure.sh runs useradd, usermod and
# passwd by name, the same way it does on the families that install
# shadow/shadow-utils via Packages.
environment.systemPackages = with pkgs; [
shadow socat curl git jq less fuse3 iptables nftables iputils nfs-utils
];
Comment thread
tomassrnka marked this conversation as resolved.

system.stateVersion = "24.05";
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading