feat(firecracker): port freeze/migrate off host Docker (#397) #398
Reference in New Issue
Block a user
Delete Branch "firecracker-freeze-migrate-no-docker"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #397.
The last host-Docker dependency in the Firecracker launch path (PRD 0069 / #348). Freeze and resume no longer touch the docker daemon, so the backend needs firecracker + KVM only — completing #348.
Changes
Freeze (
firecracker/freezer.py) — stream the guest rootfs over SSH straight into a persistentcommitted-rootfs.tar(the resumable/migratable artifact) instead of round-tripping throughdocker buildfrom aFROM scratchimage. Written to a.partialsibling and atomically renamed so a failed freeze never leaves a truncated artifact.Resume (
firecracker/launch.py:_build_agent_base) — extract the snapshot tar into a cached base dir and feed it to the existing rootlessmke2fs -dpipeline, replacingdocker create+docker export | tar. Dropped thedocker_modimport.New helper (
firecracker/util.py:build_committed_rootfs_dir) — extracts the tar, recreates theproc/sys/dev/runmount points the freezer excludes (so the guest init can mount them — more robust than the old Docker round-trip, which also dropped them), and injects the guest boot bits. Cached under the rootfs cache keyed by tar size+mtime, so repeated resumes don't re-extract but a re-freeze does.State (
bottle_state.py) — addedcommitted_rootfs_path(identity);read/write_committed_imagestill records that a snapshot exists.Out of scope (kept intentionally)
util.build_base_rootfs_dir/docker_image_idremain — they back the opt-inBOT_BOTTLE_INFRA_BUILD=localdev path and off-hostpublish_infra, as noted in the issue.Notes
The committed ref is stored as an absolute host path, so same-host resume works directly. Cross-host migration still requires copying the tar and is what the
to export for migration: cp …hint points at.Verification
TestFirecrackerFreezerfor the native-tar behavior; addedTestBuildCommittedRootfsDircovering extraction, mount-point recreation, boot injection, and the cache/re-freeze behavior./bin/sleepmissing intest_gateway_init— unrelated to this change.)Two security-relevant issues should be addressed before merge:
[P1] Untrusted snapshot symlinks can overwrite host files (
bot_bottle/backend/firecracker/util.py, lines 239-266). The guest controls the tar contents and can make/bb-initor/bb-dropbeara symlink to a host path such as/home/node/.bashrc. After extraction,Path.write_text()andshutil.copy2()follow those symlinks, overwriting the target as the host user during resume. Please remove/reject these entries before injection and validate that extraction cannot escape the staging tree.[P2] Snapshot artifacts are created world-readable (
bot_bottle/backend/firecracker/freezer.py, lines 65-67).open(partial, "wb")normally creates the rootfs snapshot with mode 0644. The tar can contain the bottle workspace and other private data. Please create it with mode 0600 (for example viaos.open) and enforce permissions after replacement.The focused 57 unit tests pass.
Thanks — both addressed in
39d47b8.P1 (symlink write-through,
util.py). Good catch — the committed snapshot is guest-controlled, sobb-init/bb-dropbearcould be planted as symlinks andwrite_text/copy2would follow them onto a host file during resume.inject_guest_bootnow routes through a_write_staged_filehelper that (a) removes any pre-existing entry at the path (unlinking a symlink removes the link, not its target) and (b) creates the file withO_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW, so the write always lands a fresh regular file inside the staging tree and can never follow a symlink out of it. Addedtest_planted_symlink_does_not_escape_staging_tree, which plantsbb-init/bb-dropbearas symlinks to a host file and asserts the host file is untouched and the staged paths are fresh regular files.On "validate that extraction cannot escape the staging tree": the extraction target is a freshly created empty dir (rmtree + mkdir), so there are no pre-existing symlinks for tar to traverse through, and GNU tar with
-C basestrips leading/and refuses..members by default. The one remaining host-write vector was our own post-extraction injection, which is what theO_NOFOLLOWchange closes. Symlinks that survive in the tree are packed as-is bymke2fs -d(in-guest paths) and never dereferenced host-side.P2 (world-readable snapshot,
freezer.py). Fixed — the.partialis now created viaos.open(..., 0o600)and re-chmod'd to0600after the atomic rename (belt-and-suspenders;os.replacealready carries the source mode). Addedtest_snapshot_created_owner_only.Focused firecracker/freezer/state/commit suites pass (103 tests); pyright strict clean.
Thanks — P1 is resolved; unlinking the leaf entries and recreating them with
O_EXCL|O_NOFOLLOWcloses the reported host-write path.One P2 edge remains: the mode argument to
os.open(..., 0o600)is only applied when the file is newly created. If an interrupted run of the previous implementation leftcommitted-rootfs.tar.partialat0644, this call opens/truncates it without changing its mode, and the new private snapshot remains world-readable for the duration of the SSH stream. The finalchmodonly happens afteros.replace. Pleaseos.fchmod(fd, 0o600)immediately after opening (and preferably addO_NOFOLLOWfor the predictable staging name), or unlink and exclusively recreate the partial before streaming. A regression test can pre-create the partial as0644and inspect its mode from the mockedsubprocess.run.Good catch on the mode-only-applies-on-creation edge — fixed in
dfc693e._commit_rootfs_via_sshnowpartial.unlink(missing_ok=True)first, then opens withO_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW(so a leftover partial or a symlink swapped in at that predictable name can't be reused/followed) andos.fchmod(fd, 0o600)immediately after open — so the snapshot is owner-only for the entire stream, not just after the final rename. The post-os.replacechmodstays as belt-and-suspenders.Added
test_leftover_world_readable_partial_is_recreated_private: it pre-creates the partial at0644, then asserts (viaos.fstaton the write fd inside the mockedsubprocess.run) that the mode is0600mid-stream — that assertion fails against the previousO_TRUNC-without-fchmodcode.