Skip to content

feat(orch): upgrade envd in filesystem-only snapshots via offline rootfs swap - #3467

Open
kalyazin wants to merge 5 commits into
mainfrom
feat/en1947-offline-swap
Open

feat(orch): upgrade envd in filesystem-only snapshots via offline rootfs swap#3467
kalyazin wants to merge 5 commits into
mainfrom
feat/en1947-offline-swap

Conversation

@kalyazin

@kalyazin kalyazin commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Why

The same-PID live self-upgrade (envd's /upgrade handover) moves a sandbox's envd forward
only for envd that already carries the upgrade machinery (MinEnvdVersionForUpgrade = 0.6.12).
Everything older — including the pre-fsfreeze < 0.6.6 cohort — is stuck on whatever envd it
was built with for the sandbox's entire life: there is no way to deliver an envd fix, feature,
or security patch to it, and a sandbox whose old envd is slow enough to time out on resume
becomes effectively un-resumable.

This reaches that cohort with a version-agnostic path: the old envd never has to cooperate.
When a filesystem-only snapshot (memory dropped, resume = cold boot) comes up, we rewrite
/usr/bin/envd in its rootfs before the VM boots, so it cold-boots on the new envd. The cost is
the in-memory state a filesystem-only pause already discards — nothing more.

What

Offline swap primitive (pkg/sandbox/rootfs/envd_swap_linux.go). SwapEnvdBinary replaces
/usr/bin/envd inside the unmounted ext4 rootfs entirely in userspace via debugfs
(libext2fs) — rm + write + sif to restore an executable mode — never a host-kernel mount
of the tenant image. It runs under a systemd-run seccomp jail (empty root, DynamicUser, no
capabilities, no network, @system-service minus @privileged/@resources, MemoryDenyWriteExecute,
kernel-logs/cgroups/hostname/realtime protections), so parsing a hostile filesystem is contained, not a
host compromise — systemd-analyze security scores the unit 0.4 (SAFE), its residual exposure being
only the disk group and the one /dev/nbdX node the rewrite fundamentally needs. It operates on the
rootfs, which the cold-boot path exposes as an NBD block device
(/dev/nbdX) — debugfs edits its ext4 in place, and the jail is granted exactly that one device
node (DeviceAllow/BindPaths) with the staged binary bind-mounted read-only. A ^/dev/nbd[0-9]+$
check guards that grant so the raw-block rewrite can't be pointed at a real disk, a partition, or a
traversal to a co-tenant device.

The swap is transactional against the never-brick guarantee. debugfs -f runs a script of
commands and exits 0 even if an individual command fails, so a naive rm-then-write could delete
the old envd, fail the write (e.g. ENOSPC), and still report success — a bricked rootfs. So
SwapEnvdBinary (a) dumps the original out to the host first, refusing to proceed if it can't;
(b) does the rm+write+sif; (c) verifies by reading /usr/bin/envd back and checking both its
content sha256 against the intended binary and that it is an executable regular file (debugfs stat) — the process exit is not trusted, and a content-only check would miss a silent sif
failure that left it non-executable; and (d) decides purely from the read-back state, never the
debugfs exit code
(a non-zero exit — timeout, kill, jail/device failure — says nothing reliable
about what is on disk): new binary → success; the intact original (swap never touched it) → boot
it with no rollback; damaged/gone (partial write, or removed then not rewritten, e.g. a kill
after rm) → rollback from the host backup; and unreadable rootfs (persistent jail failure)
ErrOfflineSwapUnrecoverable, on which the reboot hook fails the boot so CreateSandbox discards
the overlay rather than booting an unknown rootfs. This single state-driven decision avoids both
bricking (never boot a damaged/unknown envd) and breaking an intact rootfs (never run the rollback
rm when the original is already in place); a rollback that itself fails is also unrecoverable.

Wired into cold-boot resume (pkg/sandbox/reboot.go). RebootSandbox builds a PreBootFn
that runs the swap against the NBD-attached rootfs before Firecracker boots. A pure
decideOfflineSwap gates it on two conditions: the resolver returns an upgrade target, and
the snapshot's rootfs was captured frozen (meta.IsFsQuiesced(), from #3445) so it is
crash-consistent. A misconfigured/unstaged target is logged (not swapped); a resolved-but-unfrozen
snapshot is skipped and counted (not_quiesced). When the swap primitive returns
ErrOfflineSwapUnrecoverable (it couldn't confirm a bootable envd — see above), the hook fails the
boot so CreateSandbox discards the dirty overlay; every other failure is best-effort — it logs,
counts swap_failed, and boots the original envd. The swap is time-bounded on a cancel-free context
so a request cancellation can't kill debugfs mid-write.

Reuses the merged upgrade decision (packages/shared/pkg/featureflags/flags.go). A new
ResolveEnvdOfflineUpgrade forwards to the same pure resolveEnvdUpgradePath the live path uses
(built-with vs target version compare, upgrade-only, path-traversal-safe) — one decision, two
delivery mechanisms. It is keyed on a sibling flag envd-offline-upgrade-target (same
off/promoted/<sha> grammar as envd-upgrade-target) so the newer offline mechanism ramps
independently of the vetted live path, and threads the sandbox/template LD context for %-ramp
targeting.

Metrics (packages/shared/pkg/telemetry/meters.go):
orchestrator.envd.offline_upgrade.attempts{result,from_version,to_version} (result ∈
success/swap_failed/not_quiesced) and .duration{result}.

Flag Default Guards
envd-offline-upgrade-target off whether the offline swap runs, and to which version (fed to the shared resolver)

Convergence (by design). The gate keys on the snapshot's built-with version (there is no
running envd at cold-boot swap time), which the swap does not advance and pause does not
overwrite with the live version. So a re-resumed, already-upgraded snapshot resolves the upgrade
again and re-fires the swap on every cold boot — harmlessly (the on-disk binary is already the
target; debugfs replaces same-with-same). This matches the already-shipped live path, which
re-upgrades on every memory-resume. True cross-pause convergence (persist the running version at
pause) is a deliberately deferred, orthogonal follow-up.

Validation

  • Unit (all -race, golangci-lint clean):
    • resolver: ResolveEnvdOfflineUpgrade reads the sibling flag and is independent of the
      live-path flag (the two ramp separately);
    • convergence: built-with unchanged ⇒ swap re-fires; built-with == target ⇒ same_version no-op;
    • the gate: decideOfflineSwap swaps only when an upgrade is resolved and the rootfs is
      frozen, counts the not_quiesced skip, stays silent on benign no-ops, logs misconfigured
      targets — the frozen gate is what keeps a torn-journal rootfs from being rewritten;
    • swap primitive: the jailed debugfs device allowlist refuses anything but a bare /dev/nbd*
      (verified without root/debugfs) and the output cap holds;
    • a meter-map guard that both new metrics carry description + unit entries.
  • Jail hardening, node-verified: systemd-analyze security on a live unit with the jail's exact
    property set scores 0.4 (SAFE); a fixture ext4 driven through the real write+sif+stat+dump
    sequence under that hardened jail on a dev node produced the expected inode (regular, mode 0755,
    matching content sha), confirming the tightened syscall/mapping restrictions don't break debugfs.
  • Dev-cluster 2-cycle e2e, run twice (before and after the decideOfflineSwap factor-out;
    identical results). One sandbox from an envd-0.6.5 template, envd-offline-upgrade-target=vb
    /fc-envd/envd.vb (0.6.12), two pause(memory:false) → resume cycles:
    • in-guest sha256(/usr/bin/envd) / version: a0810802…/0.6.58e05066c…/0.6.12
      8e05066c…/0.6.12 (swapped on cycle 1, unchanged on cycle 2);
    • orchestrator log: exactly swapped envd binary before reboot, both
      built_with=0.6.5 → envd.vb/0.6.12;
    • Mimir: orchestrator_envd_offline_upgrade_attempts_total{result="success",from_version="0.6.5", to_version="0.6.12"} = 2.
  • Transactional swap, cluster-verified (the swap primitive has no CI coverage — no debugfs
    in CI — so this was validated on a dev node):
    • happy path: an fs-only resume of an envd-0.6.5 snapshot swaps to 0.6.12 end-to-end (the
      jailed debugfs writes its backup + verify dumps successfully and the content-hash verify
      passes) — confirmed by the in-guest hash flip + the swapped envd binary before reboot log;
    • rollback (content): with a forced rm-succeeds/write-fails injection, the verify caught the
      emptied binary (got sha e3b0c442… [empty] vs the 0.6.12 target) and the rollback restored the
      original — the guest booted and stayed responsive on 0.6.5, never a rootfs without envd;
    • rollback (mode): with a forced non-executable sif 0100644 (content correct, mode wrong), the
      verify failed with /usr/bin/envd is not an executable regular file after write and the
      rollback again restored a bootable 0.6.5 — proving the mode-check catches a silent sif
      failure a content-only check would pass;
    • intact-original (verify-then-decide): with a forced no-op swap (envd untouched), the state
      read-back matched the original, so it logged swap did not apply; original left in place and
      booted 0.6.5 with no rollback — proving an intact rootfs is never rm'd, and (with the
      rollback passes above) that the swap decision is driven by the read-back state, not the exit code.

Key decisions

  • Userspace debugfs in a seccomp jail, not a host mount — mounting a customer-controlled
    ext4 image on the host kernel exposes the node and co-tenants to the ext4 parser's CVE surface;
    the jailed userspace rewrite contains a crash.
  • Gate on fs_quiesced (feat(orch): persist an fs-quiesced flag on filesystem-only pause, with telemetry #3445), no repair — only rewrite rootfs known to be frozen at pause
    (crash-consistent). Legacy/sync-fallback snapshots are left on their envd and become eligible
    after their next freezing pause; there is deliberately no host-side journal repair.
  • Reuse the merged resolver + a sibling flag — the "which version" decision is identical to the
    live path, so it shares resolveEnvdUpgradePath; a separate flag keeps the offline mechanism's
    rollout blast radius independent.
  • Transactional swap, best-effort resumedebugfs -f exits 0 even when a scripted command
    fails, so the primitive backs the original out to the host, verifies the result by content sha
    after the swap, and rolls the original back on any mismatch; a swap failure boots the original
    envd, so the feature can only make a sandbox newer, never unbootable. (An atomic in-place
    rename via debugfs ln was rejected — its ln/unlink don't maintain inode link counts and
    would leave an fsck-dirty rootfs.)
  • Accept the idempotent per-resume re-fire — matches the existing live-upgrade contract;
    cross-pause convergence is deferred rather than bundled here.
  • Deferred: a CI-runnable integration test of the real debugfs rewrite (fixture ext4 → assert
    bytes/mode/no-other-inode-changed) — it needs debugfs + a loop image, so it is left as a
    build-tagged follow-up; the rewrite is currently covered by the dev-cluster e2e.
  • Default off; no customer-facing API change; no envd/proto/schema changes.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Rewrites tenant ext4 images on the resume path via privileged block-device tooling; mistakes could brick boots or widen blast radius, though gating, NBD-only device checks, and rollback reduce exposure.

Overview
Filesystem-only snapshot cold boots can now replace /usr/bin/envd in the guest rootfs before Firecracker starts, so sandboxes on envd too old for live /upgrade can still pick up a newer node-local binary. The orchestrator wires this into RebootSandbox as a PreBootFn, resolves the target via a separate LaunchDarkly flag (envd-offline-upgrade-target) that shares the live upgrade decision logic but ramps independently, and only runs when the snapshot rootfs was frozen at pause (fs_quiesced). The swap uses userspace debugfs inside a tight systemd-run jail on the NBD device, with backup, content and mode verification, rollback on partial failure, and boot failure only when the rootfs might have no usable envd; recoverable failures still boot the original binary. Attempt and duration metrics and architecture docs were added for rollout visibility.

Reviewed by Cursor Bugbot for commit 94673f3. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 8.61619% with 350 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go 7.22% 255 Missing and 2 partials ⚠️
packages/orchestrator/pkg/sandbox/reboot.go 10.57% 93 Missing ⚠️

📢 Thoughts on this report? Let us know!

Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2d220dccdb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +82 to +85
out, err := runDebugfsSwapSandboxed(ctx, devicePath, stage, scriptPath)
span.SetAttributes(attribute.Int("debugfs_output_bytes", len(out)))
if err != nil {
return fmt.Errorf("debugfs envd swap: %w (output: %q)", err, string(out))

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 Verify debugfs command results before booting

When the offline flag is enabled and the debugfs process accepts the script but an individual command fails, this check still treats the swap as successful; I verified with debugfs 1.47.0 that failing commands in a -f script can exit 0. If rm /usr/bin/envd succeeds and the subsequent write fails because the new binary needs more space than the old one freed, the rootfs is left without envd, yet the caller records success and cold-boots it instead of falling back to the original envd. Please verify the resulting file and executable mode after the script, or use a transactional replacement that preserves the old binary on failure.

AGENTS.md reference: AGENTS.md:L22-L26

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed — the swap is now transactional instead of trusting the debugfs exit code. SwapEnvdBinary now:

  1. dumps the original /usr/bin/envd out to a host file first (and refuses to proceed if it can't — nothing to roll back to);
  2. does the rm + write + sif;
  3. verifies by reading /usr/bin/envd back out and comparing its sha256 to the intended binary — the debugfs -f process exit is no longer trusted, since it's 0 even when a scripted command fails;
  4. on any mismatch, restores the original from the host backup and returns an error, so the guest boots its original envd rather than a rootfs with no envd.

So the exact scenario (rm succeeds, write fails → envd gone, exit 0) is now caught by step 3 and undone by step 4. packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go.

Note: there's no CI coverage of the real debugfs path yet, so this is pending a dev-cluster re-verify (including a full-rootfs fault-injection case to exercise the rollback) before merge.

Comment on lines +117 to +145
func runDebugfsSwapSandboxed(ctx context.Context, devicePath, stageDir, scriptPath string) ([]byte, error) {
if !nbdDevicePath.MatchString(devicePath) {
return nil, fmt.Errorf("refusing to run debugfs on unexpected device path %q", devicePath)
}

unit := "envd-swap-" + filepath.Base(devicePath)

args := []string{
"--wait", "--pipe", "--collect", "--quiet",
"--unit=" + unit,
fmt.Sprintf("--property=RuntimeMaxSec=%d", int(EnvdSwapTimeout.Seconds())),
"--property=KillSignal=SIGKILL",
"--property=TimeoutStopSec=10s",
"--property=DynamicUser=yes",
"--property=SupplementaryGroups=disk",
"--property=ProtectProc=invisible",
"--property=ProcSubset=pid",
"--property=PrivateNetwork=yes",
"--property=PrivateIPC=yes",
"--property=ProtectHome=yes",
"--property=NoNewPrivileges=yes",
"--property=CapabilityBoundingSet=",
"--property=AmbientCapabilities=",
"--property=RestrictNamespaces=yes",
"--property=SystemCallFilter=@system-service",
"--property=SystemCallArchitectures=native",
"--property=RestrictAddressFamilies=AF_UNIX",
"--property=LockPersonality=yes",
"--property=ProtectClock=yes",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 debugfs runs in batch mode (-w -f script device) and per-command failures (rm/write/sif) — or even a failed device open — are only reported via stderr, never through the process exit code, so cmd.Run() returns nil on real failures too. SwapEnvdBinary/runDebugfsSwapSandboxed never scan the captured output on the success path, so a genuine failure (e.g. rootfs missing /usr/bin, ENOSPC) is recorded as offline_upgrade.attempts{result=success} and logged as swapped, while the guest actually cold-boots the old envd — and a rm-succeeds/write-fails split can even leave the rootfs with no envd binary at all, still reported as success.

Extended reasoning...

The bug: debugfs -w -f <script> <device> is a well-documented case where the process exit code does not reflect the success or failure of the scripted commands. In batch (-f) mode, debugfs reads each line of the script and executes it in a request loop; if a command fails (e.g. rm/write/sif hit "File not found by ext2_lookup" because the parent directory is missing, or write fails with ENOSPC) the error is printed to stderr via com_err, but the loop continues to the next command and the process still exits 0 at the end of the script. Even a failed filesystem open (a corrupt or unexpected device) only prints an error and still returns exit code 0. systemd-run --wait simply propagates that same exit status.

Where it breaks: runDebugfsSwapSandboxed (envd_swap_linux.go:117-145) returns cmd.Run()'s error directly as its result. SwapEnvdBinary treats a nil error as proof the rewrite happened. Back in reboot.go's envdOfflineUpgradePreBoot, err == nil drives the result := "success" branch: it logs "swapped envd binary before reboot" and records offline_upgrade.attempts{result="success", ...} via the counter. Nothing about this path is contingent on the debugfs script actually having succeeded — only on the wrapping process having exited 0, which it almost always does.

Why nothing else catches it: the out buffer captured from stdout/stderr (cappedBuffer) is inspected only inside SwapEnvdBinary's error branch, to decorate the fmt.Errorf message (\"debugfs envd swap: %w (output: %q)\"). On the success path, out is discarded entirely (only len(out) is recorded as a span attribute). There is also no post-swap verification step — no re-open/re-stat of /usr/bin/envd on the rewritten device to confirm the write landed — so nothing else in the pipeline would notice a silently-failed rewrite.

Concrete walkthrough: Suppose a filesystem-only snapshot's rootfs unexpectedly lacks a /usr/bin directory (a malformed/atypical template, or a bit of drift from what the resolver assumed). envdOfflineUpgradePreBoot resolves an upgrade target and fsQuiesced is true, so decideOfflineSwap returns swap: true. The PreBootFn calls rootfs.SwapEnvdBinary, which stages the new binary, writes the debugfs script (rm /usr/bin/envd, write ... /usr/bin/envd, sif ...), and calls runDebugfsSwapSandboxed. Inside the jail, debugfs -w -f script /dev/nbdX opens the device fine, but rm /usr/bin/envd fails with "File not found by ext2_lookup" (parent dir absent), prints that to stderr, and moves on to write, which also fails the same way, then sif, same. debugfs finishes the script and exits 0. cmd.Run() returns nil. runDebugfsSwapSandboxed returns (out, nil). SwapEnvdBinary sees err == nil and returns nil. Back in the PreBootFn, err == nilresult = \"success\"; the orchestrator logs "swapped envd binary before reboot" and emits offline_upgrade.attempts{result=\"success\", from_version=0.6.5, to_version=0.6.12}. The VM then cold-boots on the untouched, original /usr/bin/envd (still 0.6.5) while every observable signal — logs and metrics — says the upgrade succeeded.

A worse variant: if /usr/bin/envd does exist so rm succeeds, but the subsequent write fails (e.g. ENOSPC, or a transient jail/bind-mount hiccup), the rootfs is left with no /usr/bin/envd at all — an unbootable/broken sandbox — while the same success telemetry is still recorded. This directly contradicts the PR's stated "best-effort, never fail the resume" safety property, since a failure here isn't merely a no-op back to the original envd, it's actively destructive, and it's invisible to the very metric (result=swap_failed) the rollout plan says operators should watch.

Impact: This defeats the core observability the PR's own dev-cluster validation leans on (checking orchestrator_envd_offline_upgrade_attempts_total{result=\"success\"} in Mimir) to gauge whether the ramp is working — a real failure rate of, say, 5% would be entirely absorbed into the success bucket. Since the feature is default-off (envd-offline-upgrade-target=off), there's no current production exposure, but as soon as it's ramped, this makes swap_failed an unreliable/blind signal and creates a path to silently un-resumable sandboxes with no alerting.

Fix direction: After runDebugfsSwapSandboxed returns with err == nil, scan the captured out buffer for debugfs's known failure markers (e.g. lines containing "File not found", "not open", or the general "debugfs: " error prefix that com_err emits) and treat their presence as a failure. Better still, verify the swap actually happened — e.g. re-run a debugfs stat /usr/bin/envd (or show_inode_info) after the write and compare size/mtime against the staged binary, or check debugfs's own request-count vs error-count if exposed — rather than trusting either the exit code or output-string matching alone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed at HEAD. The swap no longer trusts the debugfs exit code: it backs the original out to the host, does the swap, verifies by reading /usr/bin/envd back and comparing its sha256 to the intended binary, and rolls the original back on any mismatch. Cluster-verified both directions — happy path 0.6.5 → 0.6.12, and a forced rm-succeeds/write-fails run where the verify caught the empty result and the rollback restored a bootable original envd.

Comment on lines +265 to +278
logger.WithBuildID(buildID.String()),
zap.String("built_with", from),
zap.String("target", toVersion),
)
envdOfflineUpgradeAttempts.Add(ctx, 1, metric.WithAttributes(
attribute.String("result", dec.countResult),
attribute.String("from_version", from),
attribute.String("to_version", toVersion),
))
}

return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 In the not_quiesced skip-log branch of envdOfflineUpgradePreBoot (reboot.go:265-278), zap.String("target", toVersion) logs the resolved envd version string (e.g. 0.6.12) under the target field, whereas the sibling swap-success/failure logs a few lines below use target for the binary path and carry the version separately as to_version. This line also omits to_version entirely. A log query or dashboard that filters/joins on target expecting a path will silently mismatch on this skip case.

Extended reasoning...

What the bug is: envdOfflineUpgradePreBoot in packages/orchestrator/pkg/sandbox/reboot.go emits three structured log lines for the offline envd-upgrade feature: a misconfig-skip log, a not_quiesced-skip log, and the swap success/failure logs. The swap success log (line ~294) and the swap failure log (line ~302) both use zap.String("target", path)path is the resolved binary path such as /fc-envd/envd.<sha> — and separately carry zap.String("to_version", toVersion). The not_quiesced skip log (lines 265-278) breaks this convention: it emits zap.String("target", toVersion), putting the version string (e.g. "0.6.12") into the field everywhere else means "binary path," and it drops the to_version field altogether.

Code path that triggers it: decideOfflineSwap returns countResult: "not_quiesced" only when the resolver already found a valid upgrade path (resolverPath != "") but the snapshot's rootfs wasn't frozen at pause (!fsQuiesced). At that call site in envdOfflineUpgradePreBoot, the real binary path is sitting right there in the path variable returned by featureflags.ResolveEnvdOfflineUpgrade — it just isn't the value logged. So this isn't a case where a path is unavailable; the wrong variable was logged under the wrong field name.

Why nothing else catches it: This is unit-tested at the decideOfflineSwap pure-function level (reboot_offline_upgrade_test.go), which only asserts on the swap/countResult/logMisconfig struct fields — it never inspects the log line's field names or values, so the mismatch has no test coverage. Code review of the diff also doesn't immediately surface it, because the log statement is syntactically valid and toVersion reads plausibly close to target in context.

Impact: Purely an observability/log-schema inconsistency — no functional or behavioral effect on the swap decision, the swap itself, or the emitted metric (envdOfflineUpgradeAttempts correctly uses to_version=toVersion for this same skip case, so metric-based dashboards are unaffected). The impact is confined to structured log consumers: an operator or dashboard that filters/joins the target field across the three offline-upgrade log lines expecting a filesystem path (to e.g. correlate "which binary would have been swapped in") will get a version string instead for not_quiesced events, and will find no to_version field to fall back to on those lines.

Step-by-step proof:

  1. A filesystem-only snapshot resumes via RebootSandboxenvdOfflineUpgradePreBoot is invoked with fsQuiesced=false (the pause wasn't frozen) and the offline-upgrade flag resolves an upgrade, e.g. path="/fc-envd/envd.vb", toVersion="0.6.12", reason="".
  2. decideOfflineSwap("/fc-envd/envd.vb", "", false) hits the !fsQuiesced branch and returns offlineSwapDecision{countResult: "not_quiesced"} (swap=false).
  3. Back in envdOfflineUpgradePreBoot, since dec.countResult != "", it logs: zap.String("built_with", from), zap.String("target", toVersion) → this emits target="0.6.12".
  4. Compare to the swap-success log a resume later actually executes (when fsQuiesced=true): zap.String("target", path), zap.String("to_version", toVersion), zap.String("built_with", from) → this emits target="/fc-envd/envd.vb", to_version="0.6.12".
  5. A dashboard built to filter target="/fc-envd/envd.vb" (a path) across all three offline-upgrade log lines to see "who was gated vs who succeeded on this binary" will match the success/failure lines but silently miss every not_quiesced line, because there target holds "0.6.12" instead.

How to fix: In the not_quiesced log block, log zap.String("target", path) instead of toVersion, and add zap.String("to_version", toVersion) alongside it, matching the two sibling log statements in the same function.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed. The not_quiesced skip log now uses target for the binary path and carries the version as to_version, matching the swap-success/failure logs (reboot.go):

zap.String("built_with", from),
zap.String("target", path),
zap.String("to_version", toVersion),

so target means the same thing (a path) across all three log sites, and the skip log no longer omits to_version.

Comment on lines +60 to +82

// The jail runs debugfs as a transient DynamicUser that must traverse this
// directory to read the staged binary and command file; MkdirTemp creates it
// 0700 (owner-only), so widen it to 0755. The files inside are world-readable.
if err := os.Chmod(stage, 0o755); err != nil {
return fmt.Errorf("chmod swap stage dir: %w", err)
}

stagedBin := filepath.Join(stage, "envd")
if err := copyFile(srcPath, stagedBin, 0o755); err != nil {
return fmt.Errorf("stage target envd %q: %w", srcPath, err)
}

// debugfs request script: replace /usr/bin/envd (rm then write, since write
// refuses an existing path) and restore an executable regular-file mode
// (0100755 = S_IFREG | 0755) so systemd's ExecStart=/usr/bin/envd can run it.
script := fmt.Sprintf("rm /usr/bin/envd\nwrite %s /usr/bin/envd\nsif /usr/bin/envd mode 0100755\n", stagedBin)
scriptPath := filepath.Join(stage, "cmds")
if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil {
return fmt.Errorf("write debugfs script: %w", err)
}

out, err := runDebugfsSwapSandboxed(ctx, devicePath, stage, scriptPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The staged envd binary/script in SwapEnvdBinary are created via os.OpenFile/os.WriteFile with an explicit mode, but that mode is still subject to the orchestrator process' umask, and there is no follow-up chmod to correct it. Under a restrictive umask (e.g. 0077) the staged binary lands 0700 root-owned, and the jailed debugfs runs as an unprivileged DynamicUser (empty CapabilityBoundingSet, no CAP_DAC_OVERRIDE) that can only read it via the world bits — so it fails to open the source file and the offline swap silently fails on that node (result=swap_failed).

Extended reasoning...

SwapEnvdBinary in packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go stages the new envd binary and a debugfs command script into a private MkdirTemp directory that gets bind-mounted read-only into a systemd-run jail. The jail runs debugfs as a DynamicUser — an unprivileged, dynamically-allocated uid/gid with SupplementaryGroups=disk, CapabilityBoundingSet= and AmbientCapabilities= both empty (no CAP_DAC_OVERRIDE/CAP_DAC_READ_SEARCH). Since the staged files are owned by root and the DynamicUser's only relevant group membership is disk (not the file's owning group), the jailed process can only read them via the "other" permission bits.

copyFile(srcPath, stagedBin, 0o755) creates the staged binary with os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode). Per POSIX, the kernel ANDs that mode with ^umask before applying it — Go's os.OpenFile does not correct for this. The debugfs command script is written the same way via os.WriteFile(scriptPath, []byte(script), 0o644). Neither file gets a follow-up chmod. Under the default umask 0022 (root daemons typically inherit this, and nothing in the repo overrides it for the orchestrator's Nomad raw_exec job) the result is 0755/0644 as intended and the DynamicUser can read both. But under a more restrictive umask commonly used to harden root daemons — 0077 — the binary becomes 0700 and the script becomes 0600: both owner-only, unreadable by the jail's DynamicUser.

The code is aware of this exact pitfall for the directory: right after MkdirTemp (which always creates directories 0700), it does os.Chmod(stage, 0o755) specifically to defeat that default and make the directory traversable by the DynamicUser, with a comment asserting "The files inside are world-readable." That assumption is not actually enforced for the files themselves — only the directory chmod is umask-immune (chmod doesn't apply umask); the files' initial OpenFile/WriteFile modes are not re-applied via chmod and so remain umask-dependent. This is precisely the pattern already known and worked around elsewhere in this codebase: packages/orchestrator/pkg/volumes/file_create.go explicitly re-Chmods a written file after creation with the comment "we do this again to avoid the process' umask from automatically 'fixing' our requests" (verified at file_create.go:105-107).

Step-by-step manifestation:

  1. The orchestrator process is started with umask 0077 (a plausible hardening default for a root-owned daemon, though not the current one used anywhere in this repo).
  2. A filesystem-only snapshot resumes via RebootSandbox, decideOfflineSwap resolves an upgrade target and the rootfs is fs_quiesced, so envdOfflineUpgradePreBoot's PreBootFn runs.
  3. SwapEnvdBinary calls copyFile(srcPath, stagedBin, 0o755)os.OpenFile(..., 0o755) → kernel applies 0o755 &^ 0o077 = 0o700. stagedBin is now mode 0700, owned by root.
  4. runDebugfsSwapSandboxed launches systemd-run with DynamicUser=yes, SupplementaryGroups=disk, empty CapabilityBoundingSet/AmbientCapabilities, and bind-mounts stageDir read-only.
  5. Inside the jail, debugfs -w -f <script> <device> executes write <stagedBin> /usr/bin/envd. To open stagedBin, the kernel checks the DynamicUser's uid/gid against the file's owner (root, no match) and group (root's primary group, not disk, no match), falling through to the "other" bits — which are 0 under mode 0700. open() fails with EACCES.
  6. debugfs returns a non-zero exit status; runDebugfsSwapSandboxed and then SwapEnvdBinary return an error; envdOfflineUpgradePreBoot's PreBootFn logs "offline envd swap before reboot failed; booting original envd", records envdOfflineUpgradeAttempts{result="swap_failed"}, and swallows the error so the boot proceeds — the sandbox boots successfully, just still on the old envd.
  7. Every cold-boot resume on that node repeats this failure indefinitely (since the gate re-fires idempotently per the PR's documented convergence behavior), so the whole offline-upgrade feature is silently inert on any node with a restrictive umask, with no crash or user-visible symptom — only a swap_failed metric an operator would have to notice.

Why existing code doesn't prevent it: the directory's os.Chmod(stage, 0o755) only fixes traversal into stage/; it does nothing for the mode of the files created inside it afterward. The stagedBin/scriptPath writes still rely on the initial OpenFile/WriteFile mode argument surviving the umask mask unmodified, which POSIX does not guarantee.

Fix: add an explicit os.Chmod(stagedBin, 0o755) after copyFile (or after os.WriteFile for scriptPath), mirroring the existing file_create.go pattern — or set stage's files up before setting the process umask to 0 for this section. A one-line fix per staged file.

Severity: This is real but not blocking. The orchestrator's Nomad raw_exec job doesn't set an explicit umask anywhere in this repo, so it inherits the shell/service default (typically 0022), under which the code works exactly as intended — this is why the PR's dev-cluster e2e passed. The feature itself is also default-off (envd-offline-upgrade-target flag) and explicitly best-effort: a swap_failed result boots the original envd and never aborts or breaks the resume. So merging as-is causes no user-visible failure under any currently-deployed configuration; it's a latent robustness gap that would only manifest if this orchestrator is later run under a hardened umask, and the fix is trivial and low-risk.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed. The staged binary and the debugfs command file now get an explicit chmod (0755 / 0644) after creation, so a restrictive orchestrator umask can't strip the world-read bits the jailed DynamicUser relies on. Cluster-verified: the swap reads the staged binary and completes (0.6.5 → 0.6.12).

@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from 2d220dc to f6ab5f0 Compare July 30, 2026 14:40
Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go Outdated
Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch 2 times, most recently from a37adfb to ee3622f Compare July 30, 2026 15:23
Comment thread packages/orchestrator/pkg/sandbox/reboot.go
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from ee3622f to 5883e00 Compare July 30, 2026 15:35
Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from 5883e00 to 5aaef5e Compare July 30, 2026 16:09
Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from 5aaef5e to 3836430 Compare July 30, 2026 16:17

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3836430. Configure here.

Comment thread packages/orchestrator/pkg/sandbox/rootfs/envd_swap_linux.go
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from 3836430 to 67a6464 Compare July 30, 2026 16:43
Base automatically changed from feat/persist-fs-quiesced to main July 31, 2026 10:04
kalyazin and others added 5 commits July 31, 2026 11:18
Add the shared building blocks the offline (cold-boot) envd upgrade
needs, reusing the merged live-upgrade decision so both paths present a
single upgrade decision with two delivery mechanisms:

- EnvdOfflineUpgradeTargetFlag, a sibling of envd-upgrade-target with the
  same value grammar ("off"/"promoted"/"<sha>") and env-overridable dev
  fallback, so the newer offline mechanism ramps independently.
- ResolveEnvdOfflineUpgrade, forwarding to the existing pure
  resolveEnvdUpgradePath (built-with vs target, upgrade-only, path-safe),
  but keyed on the snapshot's built-with version — there is no running
  envd at cold-boot swap time to key on.
- orchestrator.envd.offline_upgrade.{attempts,duration} meter entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Rewrite /usr/bin/envd inside the filesystem-only snapshot's rootfs before
the cold boot, so an envd too old to self-upgrade (< 0.6.12) comes up on a
newer envd without any guest cooperation.

- SwapEnvdBinary rewrites the unmounted ext4 rootfs entirely in userspace
  via debugfs (libext2fs) under a systemd-run seccomp jail (empty root,
  DynamicUser, no network) — never a host-kernel mount of the tenant image.
  The jailed debugfs is constrained to a bare /dev/nbd* device.
- RebootSandbox resolves the target through ResolveEnvdOfflineUpgrade;
  decideOfflineSwap then gates it: swap only when an upgrade is resolved AND
  the snapshot's rootfs was frozen at pause (fs_quiesced) — a legacy/
  sync-fallback snapshot stays on its envd until a future freezing pause,
  and a misconfigured/unstaged target is logged rather than swapped. The
  swap runs in the PreBootFn, fully best-effort: any failure boots the
  original envd.

Because the swap keys on the built-with version, which it does not update,
it re-fires idempotently on every cold-boot resume until a re-pause
re-bakes the version (visible via the offline_upgrade.attempts metric).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
…eters

Pin the resolver and metric plumbing: ResolveEnvdOfflineUpgrade reads the
sibling flag and is independent of the live-path flag (so the two ramp
separately); the convergence behavior — the swap keys on the built-with
version, which it never advances, so a re-resumed already-upgraded snapshot
resolves an upgrade again on every cold boot (idempotent re-fire), and only
a re-pause that re-bakes the version makes the resolver no-op (same_version);
and a guard that the offline_upgrade counter + duration histogram both carry
description and unit map entries (a missing one silently emits an
undocumented series).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Pin the safety-critical wiring: decideOfflineSwap swaps only when an upgrade
is resolved AND the rootfs was frozen at pause (fs_quiesced), counts the
not_quiesced gated skip, and stays silent on benign resolver no-ops while
logging misconfigured targets — the frozen gate is what keeps a torn-journal
rootfs from being rewritten. Also pin the swap primitive's device allowlist
(jailed debugfs refuses anything but a bare /dev/nbd*, verified without root
or debugfs) and the output cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
Add the cold-boot offline-upgrade sibling to the resume-flow notes in
ARCHITECTURE.md, next to the existing live-upgrade bullet: the jailed
debugfs rewrite of the rootfs envd before RebootSandbox, its
envd-offline-upgrade-target flag and fs_quiesced gate, and the
built-with-keyed idempotent re-fire.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Nikita Kalyazin <nikita.kalyazin@e2b.dev>
@kalyazin
kalyazin force-pushed the feat/en1947-offline-swap branch from 67a6464 to 94673f3 Compare July 31, 2026 10:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant