diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/distro.go b/packages/orchestrator/pkg/template/build/phases/base/distro/distro.go index 375ea26140..72a2e7b576 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/distro/distro.go +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/distro.go @@ -10,26 +10,29 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "strings" ) -// Version forces a base-layer rebuild for provisioning changes the generated -// selector text can't otherwise capture; bump it when the contract changes. -const Version = "1" +// Version forces a base-layer rebuild for provisioning changes the view data +// can't otherwise capture (e.g. how provision.sh consumes a field); bump it +// when the contract changes. "2": selection moved into provision.sh's template. +const Version = "2" -// Fingerprint hashes the whole generated provisioning contract into the -// base-layer cache key, so any profile or init-setup change rebuilds the base. +// Fingerprint hashes the distro registry's entire contribution to the +// provisioning script — the view data, which embeds the init-setup files, the +// quoted profile scalars and the id lists. The script's structure is the raw +// provision.sh template, hashed separately by base's Hash(). %#v prints field +// names, so a field added to ProfileView is fingerprinted automatically. func Fingerprint() string { - sum := sha256.Sum256([]byte(Version + "\x00" + ShellSelector())) + sum := sha256.Sum256([]byte(Version + "\x00" + fmt.Sprintf("%#v", NewTemplateData()))) return hex.EncodeToString(sum[:]) } // Profile is the declared, per-family provisioning contract. IDs are the // /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). +// CARefresh and Bootstrap are shell fragments spliced into provision.sh's +// selection template (Bootstrap, if set, runs first — for premade images with +// no FHS userland yet). type Profile struct { Key string Init InitSystem @@ -175,7 +178,7 @@ var Profiles = []Profile{ }, } -// SupportedIDs returns every os-release ID the selector accepts. +// SupportedIDs returns every os-release ID the selection accepts. func SupportedIDs() []string { var ids []string for _, p := range Profiles { @@ -187,81 +190,6 @@ func SupportedIDs() []string { // RejectedIDs are distro ids we refuse even though their ID_LIKE names a // family we do support. Oracle Linux and Amazon Linux both declare -// ID_LIKE=fedora, so without this the ID_LIKE fallback below would quietly +// ID_LIKE=fedora, so without this provision.sh's ID_LIKE fallback would quietly // re-admit exactly the images the rhel profile documents as out of scope. var RejectedIDs = []string{"rhel", "ol", "amzn"} - -// ShellSelector generates the POSIX-sh block provision.sh sources: it switches -// on the guest's $E2B_DISTRO_ID and defines the profile's packages, shell -// functions, init path, time-sync unit, admin group and CA handling. -// -// An unknown id retries each $E2B_ID_LIKE token in order and provisions the -// first matching family — best effort, with a customer-visible warning. -// RejectedIDs, ids matching nothing, and images without /etc/os-release exit 1. -func ShellSelector() string { - var b strings.Builder - - // Selection lives in a function so it can be retried per ID_LIKE token. - // Assignments and function definitions inside a POSIX-sh function are - // global, so the caller sees the profile the same way it always has. - // The match is reported via e2b_profile_matched, not the return status — - // a function called as an if-condition runs with errexit suppressed, - // which would swallow Bootstrap failures inside a matched arm. - b.WriteString("e2b_select_profile() {\n") - b.WriteString(" e2b_profile_matched=\n") - b.WriteString(` case "$1" 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) - fmt.Fprintf(&b, " E2B_INIT_BIN=%q\n", p.InitBinary) - fmt.Fprintf(&b, " E2B_TIMESYNC_UNIT=%q\n", p.TimeSyncUnit) - fmt.Fprintf(&b, " E2B_SSH_UNIT=%q\n", p.SSHUnit) - fmt.Fprintf(&b, " E2B_ADMIN_GROUP=%q\n", p.AdminGroup) - fmt.Fprintf(&b, " E2B_CA_BUNDLE=%q\n", p.CABundle) - fmt.Fprintf(&b, " e2b_ca_refresh() { %s; }\n", p.CARefresh) - fmt.Fprintf(&b, " E2B_INIT_SYSTEM=%q\n", p.Init) - fmt.Fprintf(&b, " e2b_init_setup() {\n%s\n }\n", indentBlock(initSetup[p.Init], " ")) - fmt.Fprintf(&b, " e2b_profile_matched=1\n") - fmt.Fprintf(&b, " ;;\n") - } - fmt.Fprintf(&b, " *)\n ;;\n") - b.WriteString(" esac\n}\n\n") - - b.WriteString(`e2b_select_profile "$E2B_DISTRO_ID"` + "\n") - b.WriteString(`if [ -z "$e2b_profile_matched" ]; then` + "\n") - - // Deliberate rejections are checked before the fallback, so they keep - // failing fast with their own reason instead of being matched by ID_LIKE. - fmt.Fprintf(&b, " case \"$E2B_DISTRO_ID\" in\n") - fmt.Fprintf(&b, " %s)\n", strings.Join(RejectedIDs, "|")) - fmt.Fprintf(&b, " echo \"[provision] ERROR: base image distribution ID='$E2B_DISTRO_ID' is not supported.\" >&2\n") - fmt.Fprintf(&b, " echo \"[provision] Sandboxes boot E2B's kernel, so the kABI, signed modules and SELinux these images are chosen for are unavailable.\" >&2\n") - fmt.Fprintf(&b, " exit 1\n") - fmt.Fprintf(&b, " ;;\n") - fmt.Fprintf(&b, " esac\n") - - b.WriteString(" e2b_like_match=\n") - b.WriteString(` for e2b_like in $E2B_ID_LIKE; do` + "\n") - b.WriteString(` e2b_select_profile "$e2b_like"` + "\n") - b.WriteString(` if [ -n "$e2b_profile_matched" ]; then` + "\n") - b.WriteString(" e2b_like_match=$e2b_like\n") - b.WriteString(" break\n") - b.WriteString(" fi\n") - b.WriteString(" done\n") - - b.WriteString(` if [ -z "$e2b_like_match" ]; then` + "\n") - fmt.Fprintf(&b, " echo \"[provision] ERROR: unsupported base image distribution: ID='${E2B_DISTRO_ID:-unknown}'.\" >&2\n") - fmt.Fprintf(&b, " echo \"[provision] E2B template builds support: %s.\" >&2\n", strings.Join(SupportedIDs(), ", ")) - fmt.Fprintf(&b, " exit 1\n") - b.WriteString(" fi\n") - - fmt.Fprintf(&b, " echo \"[provision] WARNING: base image distribution ID='$E2B_DISTRO_ID' is not officially supported; provisioning it as '$e2b_like_match' from ID_LIKE. This is best effort and untested.\" >&2\n") - b.WriteString("fi\n") - - return b.String() -} diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/distro_test.go b/packages/orchestrator/pkg/template/build/phases/base/distro/distro_test.go index 3e5ae903fd..292bb349b7 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/distro/distro_test.go +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/distro_test.go @@ -3,6 +3,7 @@ package distro import ( "crypto/sha256" "encoding/hex" + "fmt" "strings" "testing" ) @@ -82,40 +83,11 @@ func TestRhelAllowErasingFollowsSubcommand(t *testing.T) { } } -// The generated selector keys on the DECLARED distro id, never on which -// package-manager binary happens to exist. -func TestSelectorNoPackageManagerProbing(t *testing.T) { +// Alpine is supported via the OpenRC track — and it must be the OpenRC +// profile, never folded into a systemd family. (Selection-text assertions +// live in base/provision_test.go against the rendered script.) +func TestAlpineIsOpenRC(t *testing.T) { t.Parallel() - sel := ShellSelector() - for _, bad := range []string{ - "command -v apt-get", "command -v dnf", "command -v yum", - "command -v microdnf", "command -v pacman", "PKG_FAMILY", - } { - if strings.Contains(sel, bad) { - t.Errorf("selector leaked package-manager probing: %q", bad) - } - } - if !strings.Contains(sel, `case "$E2B_DISTRO_ID" in`) { - t.Error("selector must switch on $E2B_DISTRO_ID (declared distro identity)") - } -} - -// Every supported id gets a case arm; an unknown id hits the failing default. -func TestSelectorCoversIDsAndRejects(t *testing.T) { - t.Parallel() - sel := ShellSelector() - for _, id := range SupportedIDs() { - if !strings.Contains(sel, id) { - t.Errorf("selector missing arm for supported id %q", id) - } - } - for _, want := range []string{"*)", "unsupported base image", "exit 1"} { - if !strings.Contains(sel, want) { - t.Errorf("selector missing fast-reject piece %q", want) - } - } - // Alpine is supported via the OpenRC track — and it must be the OpenRC - // profile, never folded into a systemd family. alpine := profileByKey(t, "alpine") if alpine.Init != InitOpenRC { t.Errorf("alpine must be the OpenRC profile, got init %q", alpine.Init) @@ -145,16 +117,6 @@ func TestInitSystemsDeclaredAndCoherent(t *testing.T) { } } } - sel := ShellSelector() - if !strings.Contains(sel, "e2b_init_setup() {") { - t.Error("selector must define e2b_init_setup()") - } - // The OpenRC boot chain pieces the alpine arm must carry. - for _, want := range []string{"/etc/inittab", "rc-update add envd default", "openrc sysinit"} { - if !strings.Contains(sel, want) { - t.Errorf("selector missing OpenRC boot piece %q", want) - } - } } // The per-profile fragments are spliced into single-line generated shell @@ -233,18 +195,19 @@ func TestOpenRCDisablesChronySeccompRegardlessOfSource(t *testing.T) { } } -// The cache fingerprint must cover the whole generated provisioning contract: -// stable across calls, and carrying both the selector text and the explicit -// Version (a profile change must rotate the base-layer cache key). +// The cache fingerprint must cover the registry's whole contribution to +// provisioning: stable across calls, and carrying both the view data and the +// explicit Version (a profile or init-file change must rotate the base-layer +// cache key; the script structure is hashed separately in base). func TestFingerprintStableAndVersioned(t *testing.T) { t.Parallel() a, b := Fingerprint(), Fingerprint() if a != b || len(a) != 64 { t.Errorf("fingerprint must be a stable sha256 hex: %q vs %q", a, b) } - want := sha256.Sum256([]byte(Version + "\x00" + ShellSelector())) + want := sha256.Sum256([]byte(Version + "\x00" + fmt.Sprintf("%#v", NewTemplateData()))) if a != hex.EncodeToString(want[:]) { - t.Error("fingerprint must hash Version + selector text") + t.Error("fingerprint must hash Version + template view data") } } @@ -279,46 +242,49 @@ func TestKernelDependentIDsAreRejected(t *testing.T) { } } -// An id we don't know falls back to ID_LIKE with a warning instead of failing: -// switching provisioning to the declared id silently dropped every Debian -// derivative (Kali declares ID=kali ID_LIKE=debian) that used to work back when -// we probed for a package manager. -func TestUnknownIDFallsBackToIDLike(t *testing.T) { +// Quoted view fields are spliced into sh double quotes via Go %q, which only +// matches sh semantics for values free of `"`, `\`, `$`, backticks and control +// characters (%q passes $ and backtick through unescaped — sh would expand +// them — and escapes control chars into sequences sh reads literally). +func TestQuotedFieldsAreShellSafe(t *testing.T) { t.Parallel() - sel := ShellSelector() - if !strings.Contains(sel, "e2b_select_profile") { - t.Error("selection must be a function so it can be retried per ID_LIKE token") - } - if !strings.Contains(sel, "for e2b_like in $E2B_ID_LIKE; do") { - t.Error("selector must retry each ID_LIKE token") - } - if !strings.Contains(sel, "WARNING") { - t.Error("an ID_LIKE match must warn, not pass silently") - } - // Nothing matched is still fatal — better than provisioning a guessed family. - if !strings.Contains(sel, "unsupported base image distribution") { - t.Error("an id matching neither ID nor ID_LIKE must still fail") - } - // An if-condition call runs the function body with errexit suppressed. - if strings.Contains(sel, "if e2b_select_profile") || strings.Contains(sel, "if ! e2b_select_profile") { - t.Error("e2b_select_profile must not be invoked as an if-condition (errexit suppression)") - } - if !strings.Contains(sel, "e2b_profile_matched=1") { - t.Error("a matched profile arm must set e2b_profile_matched") + for _, p := range Profiles { + fields := map[string]string{ + "Packages": strings.Join(p.Packages, " "), + "InitBinary": p.InitBinary, + "TimeSyncUnit": p.TimeSyncUnit, + "SSHUnit": p.SSHUnit, + "AdminGroup": p.AdminGroup, + "CABundle": p.CABundle, + "InitSystem": string(p.Init), + } + for name, v := range fields { + if fmt.Sprintf("%q", v) != `"`+v+`"` { + t.Errorf("profile %q field %s needs %%q escaping — not plain-sh-quotable: %q", p.Key, name, v) + } + if strings.ContainsAny(v, "$`") { + t.Errorf("profile %q field %s contains sh-expandable characters: %q", p.Key, name, v) + } + } } } -// ID_LIKE must not re-admit the ids the rhel profile documents as out of scope: -// Oracle and Amazon Linux both declare ID_LIKE=fedora. -func TestRejectedIDsAreNotReachableViaIDLike(t *testing.T) { +// The view hands provision.sh ready-made patterns and lists. +func TestTemplateDataJoins(t *testing.T) { t.Parallel() - sel := ShellSelector() - guard := strings.Join(RejectedIDs, "|") - if !strings.Contains(sel, guard) { - t.Errorf("selector must guard rejected ids (%s) before the ID_LIKE fallback", guard) + data := NewTemplateData() + if len(data.Profiles) != len(Profiles) { + t.Fatalf("view has %d profiles, registry %d", len(data.Profiles), len(Profiles)) + } + for i, p := range Profiles { + if data.Profiles[i].CasePattern != strings.Join(p.IDs, "|") { + t.Errorf("profile %q CasePattern mismatch: %q", p.Key, data.Profiles[i].CasePattern) + } + } + if data.RejectedIDsPattern != strings.Join(RejectedIDs, "|") { + t.Errorf("RejectedIDsPattern mismatch: %q", data.RejectedIDsPattern) } - // The guard has to come first, or ID_LIKE=fedora would match them. - if strings.Index(sel, guard) > strings.Index(sel, "E2B_ID_LIKE") { - t.Error("the rejected-id guard must precede the ID_LIKE fallback") + if data.SupportedIDs != strings.Join(SupportedIDs(), ", ") { + t.Errorf("SupportedIDs mismatch: %q", data.SupportedIDs) } } diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/init-nixos.sh b/packages/orchestrator/pkg/template/build/phases/base/distro/init-nixos.sh new file mode 100644 index 0000000000..f8cec78109 --- /dev/null +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/init-nixos.sh @@ -0,0 +1,5 @@ +# 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). +echo "NixOS is declaratively configured; removing the baked systemd drop-ins" +rm -rf /etc/systemd/system diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/init-openrc.sh b/packages/orchestrator/pkg/template/build/phases/base/distro/init-openrc.sh new file mode 100644 index 0000000000..0016963566 --- /dev/null +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/init-openrc.sh @@ -0,0 +1,88 @@ +# OpenRC (Alpine). The image is still running the one-shot PROVISIONING +# inittab (it is what launched this script); replace it with the real +# boot sequence and wire the runlevels a container image ships without. +echo "Installing boot inittab (busybox init -> OpenRC runlevels)" +printf '%s\n' \ + '::sysinit:/sbin/openrc sysinit' \ + '::sysinit:/sbin/openrc boot' \ + '::wait:/sbin/openrc default' \ + '::shutdown:/sbin/openrc shutdown' \ + '::ctrlaltdel:/sbin/reboot' \ + > /etc/inittab + +echo "Registering base OpenRC services" +# Container images carry no runlevel wiring at all (setup-alpine does this on +# real installs): kernel filesystems in sysinit, system prep in boot. bootmisc +# also wipes /tmp in the boot runlevel, so envd (default runlevel) can never +# race the wipe — the ordering systemd needs After= for is inherent here. +# Which scripts exist varies by image (mdev vs udev, procfs presence) — check +# and say so instead of swallowing rc-update errors. +for svc in devfs sysfs procfs dmesg mdev; do + if [ -e "/etc/init.d/$svc" ]; then + rc-update add "$svc" sysinit + else + echo "OpenRC service $svc not present on this image; skipping" + fi +done +for svc in localmount sysctl hostname bootmisc; do + if [ -e "/etc/init.d/$svc" ]; then + rc-update add "$svc" boot + else + echo "OpenRC service $svc not present on this image; skipping" + fi +done + +# The FC guest's eth0 is configured by the kernel (ip=), but OpenRC services +# declaring a "need net" dependency (chronyd) trigger the networking service, +# which errors out on a missing /etc/network/interfaces and takes chronyd +# down with it. A loopback-only interfaces file lets networking start (and +# provide "net") without touching the kernel-managed eth0. +printf 'auto lo\niface lo inet loopback\n' > /etc/network/interfaces +if [ -e /etc/init.d/networking ]; then + rc-update add networking boot +else + # openrc ships this script and the profile installs openrc, so this is + # unreachable on the images we support. If it ever fires, nothing provides + # "net": chronyd is still enabled below so the failure shows up in the boot + # log rather than the sandbox silently running without time sync. + echo "OpenRC networking service not present on this image; nothing provides 'net', so time sync will fail to start" +fi + +echo "Enable time synchronization ($E2B_TIMESYNC_UNIT)" +rc-update add "$E2B_TIMESYNC_UNIT" default + +echo "Install the boot-time time-source selector" +# Writes the source line chrony.conf includes, in the boot runlevel so it is +# done before chronyd starts in default. Baked outside /etc/init.d for the same +# reason as the envd service script. +cp /usr/local/share/e2b/chrony-source.openrc /etc/init.d/e2b-chrony-source +chmod 0755 /etc/init.d/e2b-chrony-source +rc-update add e2b-chrony-source boot + +echo "Disabling the chronyd seccomp filter" +# Alpine's OpenRC init script hardcodes '-F 1', which loads chronyd's seccomp +# filter, and Alpine's chrony build (-NTS -SECHASH -DEBUG) takes a SIGSYS — "Bad +# system call" right after "Loaded seccomp filter (level 1)" — as soon as the PHC +# refclock is driven, leaving the service in OpenRC's "crashed" state with the +# clock unsynced. Unconditional, NOT gated on the PHC being present: which source +# chronyd drives is decided at boot by e2b-chrony-source, so provisioning cannot +# know. It costs nothing when the pool branch is taken, and the systemd families +# pass no -F at all, so this is parity rather than a new hole. The init script +# splices $command_args in after its own -F 1 and chronyd honours the last -F. +# conf.d must be named for the init script OpenRC sources it for. +mkdir -p /etc/conf.d +echo 'command_args="-F 0"' >>"/etc/conf.d/$E2B_TIMESYNC_UNIT" + +echo "Enable envd autostart" +# The service script is baked at a neutral path (envd.openrc.tpl) so the +# Debian family's update-rc.d never sees it; install it for OpenRC here. +cp /usr/local/share/e2b/envd.openrc /etc/init.d/envd +chmod 0755 /etc/init.d/envd +rc-update add envd default + +echo "Enable sshd" +if [ -e /etc/init.d/sshd ]; then + rc-update add sshd default +else + echo "sshd service not present on this image; skipping" +fi diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/init-systemd.sh b/packages/orchestrator/pkg/template/build/phases/base/distro/init-systemd.sh new file mode 100644 index 0000000000..ab8b7646ef --- /dev/null +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/init-systemd.sh @@ -0,0 +1,48 @@ +echo "Don't wait for ttyS0 (serial console kernel logs)" +# This is required when the Firecracker kernel args has specified console=ttyS0 +systemctl mask serial-getty@ttyS0.service + +echo "Disable network online wait" +systemctl mask systemd-networkd-wait-online.service + +echo "Disable system first boot wizard" +# This was problem with Ubuntu 24.04, that differently calculate wizard should be called +# and Linux boot was stuck in wizard until envd wait timeout +systemctl mask systemd-firstboot.service + +echo "Enable time synchronization ($E2B_TIMESYNC_UNIT)" +# Distro-correct chrony unit (chrony on Debian, chronyd on RHEL/Arch). +systemctl enable "$E2B_TIMESYNC_UNIT" + +echo "Pull the boot-time time-source selector into $E2B_TIMESYNC_UNIT" +# e2b-chrony-source.service writes the source line chrony.conf includes; the +# unit that must pull it in is only known here (the name differs per family). +mkdir -p "/etc/systemd/system/$E2B_TIMESYNC_UNIT.service.d" +printf '[Unit]\nRequires=e2b-chrony-source.service\nAfter=e2b-chrony-source.service\n' \ + >"/etc/systemd/system/$E2B_TIMESYNC_UNIT.service.d/e2b-chrony-source.conf" + +echo "Enable SSH ($E2B_SSH_UNIT)" +# provision.sh writes the sandbox sshd_config on every family, but nothing was +# turning the unit on: Debian's postinst and the RHEL RPM scriptlet enable it +# themselves, Arch does not, so Arch sandboxes shipped with SSH configured and +# dead. Enabling is idempotent where the packaging already did it. +systemctl enable "$E2B_SSH_UNIT.service" + +echo "Enable envd autostart" +# Belt-and-suspenders with the baked 00-e2b.preset: on the RHEL family the +# package transaction above runs systemd's RPM scriptlet 'systemctl preset-all' +# (policy 'disable *'), which deletes the baked wants-symlink. +systemctl enable envd.service + +echo "Disable chrony-wait" +# chrony-wait blocks multi-user.target until the first clock sync (~8s); +# chrony still syncs in the background, nothing needs to wait for it. +# masking a unit that doesn't exist on this distro still succeeds (systemctl +# mask just writes the /dev/null symlink), so a failure here is real. +systemctl mask chrony-wait.service + +echo "Disable slow boot units not needed in the sandbox" +# binfmt registrations (foreign-arch exec) take ~1s of CPU early in boot and +# compete with envd start; e2scrub is for LVM-backed ext4 only. +systemctl mask systemd-binfmt.service +systemctl mask e2scrub_reap.service diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/init.go b/packages/orchestrator/pkg/template/build/phases/base/distro/init.go index 5cb456a9fa..36b880fec3 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/distro/init.go +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/init.go @@ -1,9 +1,12 @@ // Init-system axis of the distro profiles: one provisioning-time shell block -// per init system, rendered into the selector as e2b_init_setup() so provision.sh -// stays init-agnostic. +// per init system (init-*.sh), rendered into the selector as e2b_init_setup() +// so provision.sh stays init-agnostic. package distro -import "strings" +import ( + _ "embed" + "strings" +) // InitSystem is the guest init family a profile boots with. type InitSystem string @@ -17,153 +20,23 @@ const ( InitNixOS InitSystem = "nixos" ) -// initSetup is the provisioning-time shell block per init system. Bodies may -// reference the selector's profile variables (e.g. $E2B_TIMESYNC_UNIT), defined -// in the same case arm before the function is called. -var initSetup = map[InitSystem]string{ - InitSystemd: `echo "Don't wait for ttyS0 (serial console kernel logs)" -# This is required when the Firecracker kernel args has specified console=ttyS0 -systemctl mask serial-getty@ttyS0.service - -echo "Disable network online wait" -systemctl mask systemd-networkd-wait-online.service - -echo "Disable system first boot wizard" -# This was problem with Ubuntu 24.04, that differently calculate wizard should be called -# and Linux boot was stuck in wizard until envd wait timeout -systemctl mask systemd-firstboot.service - -echo "Enable time synchronization ($E2B_TIMESYNC_UNIT)" -# Distro-correct chrony unit (chrony on Debian, chronyd on RHEL/Arch). -systemctl enable "$E2B_TIMESYNC_UNIT" - -echo "Pull the boot-time time-source selector into $E2B_TIMESYNC_UNIT" -# e2b-chrony-source.service writes the source line chrony.conf includes; the -# unit that must pull it in is only known here (the name differs per family). -mkdir -p "/etc/systemd/system/$E2B_TIMESYNC_UNIT.service.d" -printf '[Unit]\nRequires=e2b-chrony-source.service\nAfter=e2b-chrony-source.service\n' \ - >"/etc/systemd/system/$E2B_TIMESYNC_UNIT.service.d/e2b-chrony-source.conf" - -echo "Enable SSH ($E2B_SSH_UNIT)" -# provision.sh writes the sandbox sshd_config on every family, but nothing was -# turning the unit on: Debian's postinst and the RHEL RPM scriptlet enable it -# themselves, Arch does not, so Arch sandboxes shipped with SSH configured and -# dead. Enabling is idempotent where the packaging already did it. -systemctl enable "$E2B_SSH_UNIT.service" - -echo "Enable envd autostart" -# Belt-and-suspenders with the baked 00-e2b.preset: on the RHEL family the -# package transaction above runs systemd's RPM scriptlet 'systemctl preset-all' -# (policy 'disable *'), which deletes the baked wants-symlink. -systemctl enable envd.service - -echo "Disable chrony-wait" -# chrony-wait blocks multi-user.target until the first clock sync (~8s); -# chrony still syncs in the background, nothing needs to wait for it. -# masking a unit that doesn't exist on this distro still succeeds (systemctl -# mask just writes the /dev/null symlink), so a failure here is real. -systemctl mask chrony-wait.service +//go:embed init-systemd.sh +var initSystemdSh string -echo "Disable slow boot units not needed in the sandbox" -# binfmt registrations (foreign-arch exec) take ~1s of CPU early in boot and -# compete with envd start; e2scrub is for LVM-backed ext4 only. -systemctl mask systemd-binfmt.service -systemctl mask e2scrub_reap.service`, +//go:embed init-openrc.sh +var initOpenRCSh string - // OpenRC (Alpine). The image is still running the one-shot PROVISIONING - // inittab (it is what launched this script); replace it with the real - // boot sequence and wire the runlevels a container image ships without. - InitOpenRC: `echo "Installing boot inittab (busybox init -> OpenRC runlevels)" -printf '%s\n' \ - '::sysinit:/sbin/openrc sysinit' \ - '::sysinit:/sbin/openrc boot' \ - '::wait:/sbin/openrc default' \ - '::shutdown:/sbin/openrc shutdown' \ - '::ctrlaltdel:/sbin/reboot' \ - > /etc/inittab +//go:embed init-nixos.sh +var initNixOSSh string -echo "Registering base OpenRC services" -# Container images carry no runlevel wiring at all (setup-alpine does this on -# real installs): kernel filesystems in sysinit, system prep in boot. bootmisc -# also wipes /tmp in the boot runlevel, so envd (default runlevel) can never -# race the wipe — the ordering systemd needs After= for is inherent here. -# Which scripts exist varies by image (mdev vs udev, procfs presence) — check -# and say so instead of swallowing rc-update errors. -for svc in devfs sysfs procfs dmesg mdev; do - if [ -e "/etc/init.d/$svc" ]; then - rc-update add "$svc" sysinit - else - echo "OpenRC service $svc not present on this image; skipping" - fi -done -for svc in localmount sysctl hostname bootmisc; do - if [ -e "/etc/init.d/$svc" ]; then - rc-update add "$svc" boot - else - echo "OpenRC service $svc not present on this image; skipping" - fi -done - -# The FC guest's eth0 is configured by the kernel (ip=), but OpenRC services -# declaring a "need net" dependency (chronyd) trigger the networking service, -# which errors out on a missing /etc/network/interfaces and takes chronyd -# down with it. A loopback-only interfaces file lets networking start (and -# provide "net") without touching the kernel-managed eth0. -printf 'auto lo\niface lo inet loopback\n' > /etc/network/interfaces -if [ -e /etc/init.d/networking ]; then - rc-update add networking boot -else - # openrc ships this script and the profile installs openrc, so this is - # unreachable on the images we support. If it ever fires, nothing provides - # "net": chronyd is still enabled below so the failure shows up in the boot - # log rather than the sandbox silently running without time sync. - echo "OpenRC networking service not present on this image; nothing provides 'net', so time sync will fail to start" -fi - -echo "Enable time synchronization ($E2B_TIMESYNC_UNIT)" -rc-update add "$E2B_TIMESYNC_UNIT" default - -echo "Install the boot-time time-source selector" -# Writes the source line chrony.conf includes, in the boot runlevel so it is -# done before chronyd starts in default. Baked outside /etc/init.d for the same -# reason as the envd service script. -cp /usr/local/share/e2b/chrony-source.openrc /etc/init.d/e2b-chrony-source -chmod 0755 /etc/init.d/e2b-chrony-source -rc-update add e2b-chrony-source boot - -echo "Disabling the chronyd seccomp filter" -# Alpine's OpenRC init script hardcodes '-F 1', which loads chronyd's seccomp -# filter, and Alpine's chrony build (-NTS -SECHASH -DEBUG) takes a SIGSYS — "Bad -# system call" right after "Loaded seccomp filter (level 1)" — as soon as the PHC -# refclock is driven, leaving the service in OpenRC's "crashed" state with the -# clock unsynced. Unconditional, NOT gated on the PHC being present: which source -# chronyd drives is decided at boot by e2b-chrony-source, so provisioning cannot -# know. It costs nothing when the pool branch is taken, and the systemd families -# pass no -F at all, so this is parity rather than a new hole. The init script -# splices $command_args in after its own -F 1 and chronyd honours the last -F. -# conf.d must be named for the init script OpenRC sources it for. -mkdir -p /etc/conf.d -echo 'command_args="-F 0"' >>"/etc/conf.d/$E2B_TIMESYNC_UNIT" - -echo "Enable envd autostart" -# The service script is baked at a neutral path (envd.openrc.tpl) so the -# Debian family's update-rc.d never sees it; install it for OpenRC here. -cp /usr/local/share/e2b/envd.openrc /etc/init.d/envd -chmod 0755 /etc/init.d/envd -rc-update add envd default - -echo "Enable sshd" -if [ -e /etc/init.d/sshd ]; then - rc-update add sshd default -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`, +// initSetup is the provisioning-time shell block per init system. Bodies may +// reference the selector's profile variables (e.g. $E2B_TIMESYNC_UNIT), defined +// in the same case arm before the function is called. The trailing newline is +// trimmed so bodies splice like the former in-Go literals. +var initSetup = map[InitSystem]string{ + InitSystemd: strings.TrimRight(initSystemdSh, "\n"), + InitOpenRC: strings.TrimRight(initOpenRCSh, "\n"), + InitNixOS: strings.TrimRight(initNixOSSh, "\n"), } // indentBlock indents every non-empty line of a shell block for embedding diff --git a/packages/orchestrator/pkg/template/build/phases/base/distro/template.go b/packages/orchestrator/pkg/template/build/phases/base/distro/template.go new file mode 100644 index 0000000000..17d085164f --- /dev/null +++ b/packages/orchestrator/pkg/template/build/phases/base/distro/template.go @@ -0,0 +1,71 @@ +// View model for provision.sh: the profile registry prepared for template +// splicing, with all shell-quoting done here in Go where it is testable. +package distro + +import ( + "fmt" + "strings" +) + +// ProfileView is one profile prepared for provision.sh's selection template. +// Quoted fields carry their own double quotes (TestQuotedFieldsAreShellSafe +// pins the %q == sh-double-quote assumption); fragment fields are raw shell. +type ProfileView struct { + Key string + CasePattern string // IDs joined with "|" — the unquoted case-arm pattern + Bootstrap string // raw shell; empty for all but nixos + Packages string // sh-quoted, space-joined package list + PkgQuery string // raw shell, spliced into { ...; } + PkgInstall string // raw shell, spliced into { ...; } + InitBinary string // sh-quoted + TimeSyncUnit string // sh-quoted + SSHUnit string // sh-quoted + AdminGroup string // sh-quoted + CABundle string // sh-quoted + CARefresh string // raw shell, spliced into { ...; } + InitSystem string // sh-quoted + InitSetup string // init-.sh body, pre-indented for the function body +} + +// TemplateData is everything the distro registry contributes to provision.sh. +type TemplateData struct { + Profiles []ProfileView + RejectedIDsPattern string // e.g. "rhel|ol|amzn" + SupportedIDs string // comma-joined, for the fatal no-match message +} + +// NewTemplateData builds the provision.sh view of the profile registry. +func NewTemplateData() TemplateData { + views := make([]ProfileView, 0, len(Profiles)) + for _, p := range Profiles { + views = append(views, ProfileView{ + Key: p.Key, + CasePattern: strings.Join(p.IDs, "|"), + Bootstrap: p.Bootstrap, + Packages: shQuote(strings.Join(p.Packages, " ")), + PkgQuery: p.PkgQueryBody, + PkgInstall: p.PkgInstall, + InitBinary: shQuote(p.InitBinary), + TimeSyncUnit: shQuote(p.TimeSyncUnit), + SSHUnit: shQuote(p.SSHUnit), + AdminGroup: shQuote(p.AdminGroup), + CABundle: shQuote(p.CABundle), + CARefresh: p.CARefresh, + InitSystem: shQuote(string(p.Init)), + InitSetup: indentBlock(initSetup[p.Init], " "), + }) + } + + return TemplateData{ + Profiles: views, + RejectedIDsPattern: strings.Join(RejectedIDs, "|"), + SupportedIDs: strings.Join(SupportedIDs(), ", "), + } +} + +// shQuote double-quotes a value for sh. Go %q matches plain sh double-quoting +// only for values without `"`, `\`, `$`, backticks or control characters — +// enforced by TestQuotedFieldsAreShellSafe. +func shQuote(s string) string { + return fmt.Sprintf("%q", s) +} diff --git a/packages/orchestrator/pkg/template/build/phases/base/files.go b/packages/orchestrator/pkg/template/build/phases/base/files.go index 1563226a92..d9fb280d73 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/files.go +++ b/packages/orchestrator/pkg/template/build/phases/base/files.go @@ -46,10 +46,10 @@ func constructLayerFilesFromOCI( featureFlags, ) provisionScript, err := getProvisionScript(ctx, ProvisionScriptParams{ - BusyBox: rootfs.SandboxBusyBoxPath, - ResultPath: provisionScriptResultPath, - Provider: buildContext.BuilderConfig.Provider, - DistroSelector: distro.ShellSelector(), + BusyBox: rootfs.SandboxBusyBoxPath, + ResultPath: provisionScriptResultPath, + Provider: buildContext.BuilderConfig.Provider, + Distro: distro.NewTemplateData(), }) if err != nil { return nil, nil, containerregistry.Config{}, fmt.Errorf("error getting provision script: %w", err) diff --git a/packages/orchestrator/pkg/template/build/phases/base/hash.go b/packages/orchestrator/pkg/template/build/phases/base/hash.go index 39235ea5bf..f03b82a8ff 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/hash.go +++ b/packages/orchestrator/pkg/template/build/phases/base/hash.go @@ -36,10 +36,10 @@ func (bb *BaseBuilder) Hash(ctx context.Context, _ phases.LayerResult) (string, } // For fallback/dev environments, include baked rootfs file contents and - // the distro provisioning contract (profiles + init blocks — the rendered - // selector is part of the script but not of the raw provisionScriptFile - // hashed here) in the provision version. In production, - // BuildProvisionVersion controls rollout invalidation explicitly. + // the distro provisioning contract in the provision version: the selection + // structure lives in the raw provisionScriptFile hashed here, the spliced + // data (profiles, init-setup files, id lists) in distro.Fingerprint(). In + // production, BuildProvisionVersion controls rollout invalidation explicitly. provisionVersion := cache.HashKeys(provisionScriptFile, rootfs.FilesHash(), distro.Fingerprint()) if val := bb.featureFlags.IntFlag( ctx, diff --git a/packages/orchestrator/pkg/template/build/phases/base/provision.go b/packages/orchestrator/pkg/template/build/phases/base/provision.go index 1e94f6ce5e..c84b544705 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/provision.go +++ b/packages/orchestrator/pkg/template/build/phases/base/provision.go @@ -4,16 +4,13 @@ package base import ( "bufio" - "bytes" "context" - _ "embed" "errors" "fmt" "io" "os" "os/exec" "strings" - tt "text/template" "time" "go.uber.org/zap" @@ -30,7 +27,6 @@ import ( "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/build/phases" "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/build/writer" "github.com/e2b-dev/infra/packages/shared/pkg/logger" - "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" "github.com/e2b-dev/infra/packages/shared/pkg/units" "github.com/e2b-dev/infra/packages/shared/pkg/utils" ) @@ -39,39 +35,12 @@ const ( provisionTimeout = 5 * time.Minute ) -//go:embed provision.sh -var provisionScriptFile string -var ProvisionScriptTemplate = tt.Must(tt.New("provisioning-script").Parse(provisionScriptFile)) - const ( // provisionScriptFileName is a path where the provision script stores it's exit code. provisionScriptResultPath = "/provision.result" provisionLogPrefix = "[external] " ) -type ProvisionScriptParams struct { - BusyBox string - ResultPath string - Provider string - // DistroSelector is the generated POSIX-sh block that selects the base - // image's distro profile by its /etc/os-release ID. - DistroSelector string -} - -func getProvisionScript( - ctx context.Context, - params ProvisionScriptParams, -) (string, error) { - var scriptDef bytes.Buffer - err := ProvisionScriptTemplate.Execute(&scriptDef, params) - if err != nil { - return "", fmt.Errorf("error executing provision script: %w", err) - } - telemetry.ReportEvent(ctx, "executed provision script env") - - return scriptDef.String(), nil -} - func (bb *BaseBuilder) provisionSandbox( ctx context.Context, userLogger logger.Logger, diff --git a/packages/orchestrator/pkg/template/build/phases/base/provision.sh b/packages/orchestrator/pkg/template/build/phases/base/provision.sh index 583a6be6b8..8acfd763f4 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/provision.sh +++ b/packages/orchestrator/pkg/template/build/phases/base/provision.sh @@ -14,10 +14,10 @@ echo "Making configuration immutable" $BUSYBOX chattr +i /etc/resolv.conf # Identify the base image by its DECLARED /etc/os-release ID, never by probing -# for a package manager. The selector (generated from the distro profile -# registry) defines the E2B_* vars and pkg/CA shell functions, or exits 1 on an -# unsupported distribution. Images with no os-release (distroless, scratch) are -# rejected by name rather than guessed. +# for a package manager. The selection below (rendered inline from the distro +# profile registry) defines the E2B_* vars and pkg/CA shell functions, or exits +# 1 on an unsupported distribution. Images with no os-release (distroless, +# scratch) are rejected by name rather than guessed. echo "Detecting base image distribution" if [ -r /etc/os-release ]; then . /etc/os-release @@ -30,7 +30,68 @@ else E2B_ID_LIKE="" fi -{{ .DistroSelector }} +# Assignments and function definitions inside a POSIX-sh function are global. +# The match is reported via e2b_profile_matched, never the return status — a +# function called as an if-condition runs with errexit suppressed. +e2b_select_profile() { + e2b_profile_matched= + case "$1" in +{{- range .Distro.Profiles }} + {{ .CasePattern }}) +{{- if .Bootstrap }} + {{ .Bootstrap }} +{{- end }} + E2B_PACKAGES={{ .Packages }} + e2b_pkg_query() { {{ .PkgQuery }}; } + e2b_pkg_install() { {{ .PkgInstall }}; } + E2B_INIT_BIN={{ .InitBinary }} + E2B_TIMESYNC_UNIT={{ .TimeSyncUnit }} + E2B_SSH_UNIT={{ .SSHUnit }} + E2B_ADMIN_GROUP={{ .AdminGroup }} + E2B_CA_BUNDLE={{ .CABundle }} + e2b_ca_refresh() { {{ .CARefresh }}; } + E2B_INIT_SYSTEM={{ .InitSystem }} + e2b_init_setup() { +{{ .InitSetup }} + } + e2b_profile_matched=1 + ;; +{{- end }} + *) + ;; + esac +} + +e2b_select_profile "$E2B_DISTRO_ID" +if [ -z "$e2b_profile_matched" ]; then + # Deliberate rejections fail fast with their own reason, checked before + # ID_LIKE could match them (Oracle and Amazon Linux declare ID_LIKE=fedora). + case "$E2B_DISTRO_ID" in + {{ .Distro.RejectedIDsPattern }}) + echo "[provision] ERROR: base image distribution ID='$E2B_DISTRO_ID' is not supported." >&2 + echo "[provision] Sandboxes boot E2B's kernel, so the kABI, signed modules and SELinux these images are chosen for are unavailable." >&2 + exit 1 + ;; + esac + + # Unknown id: retry each ID_LIKE token (Kali declares ID=kali ID_LIKE=debian). + e2b_like_match= + for e2b_like in $E2B_ID_LIKE; do + e2b_select_profile "$e2b_like" + if [ -n "$e2b_profile_matched" ]; then + e2b_like_match=$e2b_like + break + fi + done + + if [ -z "$e2b_like_match" ]; then + echo "[provision] ERROR: unsupported base image distribution: ID='${E2B_DISTRO_ID:-unknown}'." >&2 + echo "[provision] E2B template builds support: {{ .Distro.SupportedIDs }}." >&2 + exit 1 + fi + + echo "[provision] WARNING: base image distribution ID='$E2B_DISTRO_ID' is not officially supported; provisioning it as '$e2b_like_match' from ID_LIKE. This is best effort and untested." >&2 +fi echo "Provisioning for distro '$E2B_DISTRO_ID' (init=$E2B_INIT_BIN, timesync=$E2B_TIMESYNC_UNIT, admin-group=$E2B_ADMIN_GROUP)" diff --git a/packages/orchestrator/pkg/template/build/phases/base/provision_script.go b/packages/orchestrator/pkg/template/build/phases/base/provision_script.go new file mode 100644 index 0000000000..1f00a8ebfc --- /dev/null +++ b/packages/orchestrator/pkg/template/build/phases/base/provision_script.go @@ -0,0 +1,41 @@ +// No build tag on purpose — provision_test.go must run on darwin too. +package base + +import ( + "bytes" + "context" + _ "embed" + "fmt" + tt "text/template" + + "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/build/phases/base/distro" + "github.com/e2b-dev/infra/packages/shared/pkg/telemetry" +) + +//go:embed provision.sh +var provisionScriptFile string + +var ProvisionScriptTemplate = tt.Must(tt.New("provisioning-script").Parse(provisionScriptFile)) + +type ProvisionScriptParams struct { + BusyBox string + ResultPath string + Provider string + // Distro is the profile registry's contribution to the script: the + // selection case arms, the rejected-id pattern and the supported-id list. + Distro distro.TemplateData +} + +func getProvisionScript( + ctx context.Context, + params ProvisionScriptParams, +) (string, error) { + var scriptDef bytes.Buffer + err := ProvisionScriptTemplate.Execute(&scriptDef, params) + if err != nil { + return "", fmt.Errorf("error executing provision script: %w", err) + } + telemetry.ReportEvent(ctx, "executed provision script env") + + return scriptDef.String(), nil +} diff --git a/packages/orchestrator/pkg/template/build/phases/base/provision_test.go b/packages/orchestrator/pkg/template/build/phases/base/provision_test.go index 9e9d43a71c..0eee3c75fc 100644 --- a/packages/orchestrator/pkg/template/build/phases/base/provision_test.go +++ b/packages/orchestrator/pkg/template/build/phases/base/provision_test.go @@ -1,22 +1,117 @@ -//go:build linux - +// No build tag on purpose — these tests must run on darwin too. package base import ( + "context" "strings" "testing" + + "github.com/e2b-dev/infra/packages/orchestrator/pkg/template/build/phases/base/distro" ) +func renderProvisionScript(t *testing.T) string { + t.Helper() + s, err := getProvisionScript(context.Background(), ProvisionScriptParams{ + BusyBox: "/usr/bin/busybox", + ResultPath: "/provision.result", + Provider: "", + Distro: distro.NewTemplateData(), + }) + if err != nil { + t.Fatalf("rendering provision.sh: %v", err) + } + + return s +} + +// The script keys on the DECLARED distro id, never on which package-manager +// binary happens to exist. +func TestProvisionScriptNoPackageManagerProbing(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + for _, bad := range []string{ + "command -v apt-get", "command -v dnf", "command -v yum", + "command -v microdnf", "command -v pacman", "PKG_FAMILY", + } { + if strings.Contains(script, bad) { + t.Errorf("script leaked package-manager probing: %q", bad) + } + } + if !strings.Contains(script, `case "$E2B_DISTRO_ID" in`) { + t.Error("script must switch on $E2B_DISTRO_ID (declared distro identity)") + } + // A leftover action delimiter means something rendered as literal text. + if strings.Contains(script, "{{") { + t.Error("rendered script contains an unexecuted template action") + } +} + +// Every supported id gets a case arm; an unknown id hits the failing default. +func TestProvisionScriptCoversIDsAndRejects(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + for _, id := range distro.SupportedIDs() { + if !strings.Contains(script, id) { + t.Errorf("script missing arm for supported id %q", id) + } + } + for _, want := range []string{"*)", "unsupported base image", "exit 1"} { + if !strings.Contains(script, want) { + t.Errorf("script missing fast-reject piece %q", want) + } + } +} + +// Each profile reaches the guest through one template action per field, and +// the init-setup bodies now live in distro/init-*.sh rather than in Go. A +// dropped or misnamed action leaves the Go-side tests over Profiles and +// initSetup green while the rendered case arm never defines the variable +// provision.sh reads a few lines later — fatal under `set -u`, on every +// distro. So assert each arm carries the exact text the view produced. +func TestProvisionScriptSplicesEveryProfileField(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + if !strings.Contains(script, "e2b_init_setup() {") { + t.Fatal("script must define e2b_init_setup()") + } + for _, p := range distro.NewTemplateData().Profiles { + want := map[string]string{ + "E2B_PACKAGES": "E2B_PACKAGES=" + p.Packages, + "e2b_pkg_query": "e2b_pkg_query() { " + p.PkgQuery + "; }", + "e2b_pkg_install": "e2b_pkg_install() { " + p.PkgInstall + "; }", + "E2B_INIT_BIN": "E2B_INIT_BIN=" + p.InitBinary, + "E2B_TIMESYNC_UNIT": "E2B_TIMESYNC_UNIT=" + p.TimeSyncUnit, + "E2B_SSH_UNIT": "E2B_SSH_UNIT=" + p.SSHUnit, + "E2B_ADMIN_GROUP": "E2B_ADMIN_GROUP=" + p.AdminGroup, + "E2B_CA_BUNDLE": "E2B_CA_BUNDLE=" + p.CABundle, + "e2b_ca_refresh": "e2b_ca_refresh() { " + p.CARefresh + "; }", + "E2B_INIT_SYSTEM": "E2B_INIT_SYSTEM=" + p.InitSystem, + "init setup body": p.InitSetup, + } + if p.Bootstrap != "" { + want["Bootstrap"] = p.Bootstrap + } + for field, text := range want { + if !strings.Contains(script, text) { + t.Errorf("profile %q: %s not spliced into the rendered script (wanted %q)", p.Key, field, text) + } + } + } +} + // Provisioning must not decide the chrony time source: it runs on a build node, // and the sandbox can cold-boot on a node with a different PHC situation. The -// baked config only includes what e2b-chrony-source writes at boot. +// baked config only includes what e2b-chrony-source writes at boot. Asserted on +// the rendered script, not the raw template: the init-setup blocks moved out to +// distro/init-*.sh, so only the rendered form covers both halves. func TestProvisionScriptDefersChronySourceToBoot(t *testing.T) { t.Parallel() + script := renderProvisionScript(t) // E2B_CHRONY_PHC was the provision-time verdict the Alpine seccomp workaround // used to read. It no longer exists, and under `set -u` a leftover reference // is a hard provisioning failure on every distro, not just Alpine. for _, bad := range []string{"[ -e /dev/ptp0 ]", "refclock PHC", "E2B_CHRONY_PHC"} { - if strings.Contains(provisionScriptFile, bad) { + if strings.Contains(script, bad) { t.Errorf("provision.sh must not decide the time source (%q) — that happens at boot", bad) } } @@ -24,8 +119,69 @@ func TestProvisionScriptDefersChronySourceToBoot(t *testing.T) { `echo "include /run/chrony-e2b/source.conf"`, `echo "makestep 1.0 3"`, } { - if !strings.Contains(provisionScriptFile, want) { + if !strings.Contains(script, want) { t.Errorf("provision.sh missing chrony config line %q", want) } } } + +// An id we don't know falls back to ID_LIKE with a warning instead of failing +// (Kali declares ID=kali ID_LIKE=debian); nothing matching is still fatal. +func TestProvisionScriptIDLikeFallback(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + if !strings.Contains(script, "e2b_select_profile") { + t.Error("selection must be a function so it can be retried per ID_LIKE token") + } + if !strings.Contains(script, "for e2b_like in $E2B_ID_LIKE; do") { + t.Error("script must retry each ID_LIKE token") + } + if !strings.Contains(script, "WARNING") { + t.Error("an ID_LIKE match must warn, not pass silently") + } + if !strings.Contains(script, "unsupported base image distribution") { + t.Error("an id matching neither ID nor ID_LIKE must still fail") + } + // An if-condition call runs the function body with errexit suppressed. + if strings.Contains(script, "if e2b_select_profile") || strings.Contains(script, "if ! e2b_select_profile") { + t.Error("e2b_select_profile must not be invoked as an if-condition (errexit suppression)") + } + if !strings.Contains(script, "e2b_profile_matched=1") { + t.Error("a matched profile arm must set e2b_profile_matched") + } +} + +// ID_LIKE must not re-admit the ids the rhel profile documents as out of +// scope: Oracle and Amazon Linux both declare ID_LIKE=fedora, so the guard +// must run before the fallback loop. +func TestProvisionScriptRejectedIDsGuard(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + guard := strings.Join(distro.RejectedIDs, "|") + if !strings.Contains(script, guard) { + t.Errorf("script must guard rejected ids (%s) before the ID_LIKE fallback", guard) + } + // Anchor on the loop line: E2B_ID_LIKE itself is first assigned in the + // os-release detection block above the guard. + if strings.Index(script, guard) > strings.Index(script, "for e2b_like in $E2B_ID_LIKE") { + t.Error("the rejected-id guard must precede the ID_LIKE fallback loop") + } +} + +// Byte-exact customer-visible messages: the integration test +// TestTemplateBuildUnsupportedDistro (and customer log greps) pin these. +func TestProvisionScriptCustomerMessages(t *testing.T) { + t.Parallel() + script := renderProvisionScript(t) + for _, want := range []string{ + `[provision] ERROR: base image distribution ID='$E2B_DISTRO_ID' is not supported.`, + `[provision] Sandboxes boot E2B's kernel, so the kABI, signed modules and SELinux these images are chosen for are unavailable.`, + `[provision] ERROR: unsupported base image distribution: ID='${E2B_DISTRO_ID:-unknown}'.`, + "[provision] E2B template builds support: " + strings.Join(distro.SupportedIDs(), ", ") + ".", + `[provision] WARNING: base image distribution ID='$E2B_DISTRO_ID' is not officially supported; provisioning it as '$e2b_like_match' from ID_LIKE. This is best effort and untested.`, + } { + if !strings.Contains(script, want) { + t.Errorf("script missing customer-visible message %q", want) + } + } +}