macOS: provision Fedora-on-QEMU VM and firecracker() zsh bridge
After the normal package install on macOS, fetch the latest Fedora cloud qcow2 (auto-discovered from dl.fedoraproject.org), verify its SHA256, build a cloud-init seed ISO with hdiutil, boot it under QEMU/HVF (UEFI on aarch64, q35 on x86_64), and wait for cloud-init to install firecracker inside the guest. Then write a `firecracker()` function block into ~/.zshrc that starts the backing VM on demand and proxies invocations via SSH. The host's own firecracker custom-package install is dropped on macOS since it's Linux-only. Suppress the whole flow with --no-vm. https://claude.ai/code/session_01R2CnCeEBYT5QoHiwxczDNq
This commit is contained in:
+374
-4
@@ -7,6 +7,10 @@ Sections handled:
|
||||
Flatpak Packages — installed via flatpak from Flathub (Linux only;
|
||||
skipped with --no-gui and skipped entirely on macOS)
|
||||
Custom Packages — downloaded, verified, extracted
|
||||
macOS firecracker VM — provisions a Fedora cloud image under QEMU/HVF,
|
||||
installs firecracker inside it, and adds a
|
||||
`firecracker()` wrapper to ~/.zshrc that proxies
|
||||
invocations via SSH. Suppress with --no-vm.
|
||||
|
||||
OS detection is automatic. On macOS the first actions are to install the
|
||||
Xcode Command Line Tools and Homebrew, which is then used as the system
|
||||
@@ -14,7 +18,7 @@ package manager.
|
||||
|
||||
Usage:
|
||||
Linux: sudo python3 bootstrap_environment.py [--only system|flatpak|custom] [--no-gui]
|
||||
macOS: python3 bootstrap_environment.py [--only system|custom] [--no-gui]
|
||||
macOS: python3 bootstrap_environment.py [--only system|custom] [--no-gui] [--no-vm]
|
||||
(do NOT use sudo on macOS — Homebrew refuses to run as root)
|
||||
"""
|
||||
|
||||
@@ -1418,9 +1422,6 @@ def install_custom_packages(to_install: list[CustomPackage]) -> None:
|
||||
print("\n=== Custom Packages ===")
|
||||
for pkg in to_install:
|
||||
name_lower = pkg.name.lower()
|
||||
if name_lower == "firecracker" and IS_MACOS:
|
||||
warn("firecracker is Linux-only — skipping on macOS")
|
||||
continue
|
||||
_, check_path = is_custom_pkg_installed(pkg)
|
||||
print(f"\n Installing {pkg.display_name} ..."
|
||||
+ (f" (install path: {check_path})" if check_path else ""))
|
||||
@@ -1616,6 +1617,365 @@ def check_and_setup_ssh() -> None:
|
||||
err("gh auth login failed — skipping key upload.")
|
||||
return
|
||||
|
||||
# ── macOS: Fedora VM + firecracker bridge ────────────────────────────────────
|
||||
#
|
||||
# Firecracker is Linux-only (needs KVM). On macOS we provision a Fedora cloud
|
||||
# VM via QEMU/HVF, install firecracker inside it, and expose a `firecracker`
|
||||
# zsh function on the host that proxies invocations over SSH into the VM.
|
||||
|
||||
_VM_DIR = Path.home() / ".firecracker-vm"
|
||||
_VM_SSH_PORT = 2222
|
||||
_VM_USER = "fc"
|
||||
_VM_QCOW2_NAME = "fedora.qcow2"
|
||||
_VM_SEED_ISO_NAME = "seed.iso"
|
||||
_VM_PID_NAME = "vm.pid"
|
||||
_VM_KEY_NAME = "id_ed25519"
|
||||
_FIRECRACKER_FN_BEGIN = "# >>> firecracker-vm wrapper >>>"
|
||||
_FIRECRACKER_FN_END = "# <<< firecracker-vm wrapper <<<"
|
||||
|
||||
|
||||
def _latest_fedora_cloud_image() -> Optional[tuple[str, str, str]]:
|
||||
"""Return (filename, qcow2_url, checksum_url) for the latest Fedora cloud qcow2.
|
||||
|
||||
Walks the Fedora mirror directory listing from newest release downward,
|
||||
and returns the first arch-matching qcow2 it finds.
|
||||
"""
|
||||
base = "https://dl.fedoraproject.org/pub/fedora/linux/releases/"
|
||||
listing = _fetch_text(base)
|
||||
if not listing:
|
||||
return None
|
||||
versions = sorted(
|
||||
{int(m.group(1)) for m in re.finditer(r'href="(\d+)/?"', listing)},
|
||||
reverse=True,
|
||||
)
|
||||
for ver in versions:
|
||||
images_url = f"{base}{ver}/Cloud/{ARCH}/images/"
|
||||
idx = _fetch_text(images_url)
|
||||
if not idx:
|
||||
continue
|
||||
qcow = re.search(
|
||||
rf'href="(Fedora-Cloud-Base[A-Za-z0-9_-]*-{ver}-[\d.]+\.{ARCH}\.qcow2)"',
|
||||
idx,
|
||||
)
|
||||
ck = re.search(r'href="([^"]*CHECKSUM)"', idx)
|
||||
if not qcow or not ck:
|
||||
continue
|
||||
return qcow.group(1), images_url + qcow.group(1), images_url + ck.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _verify_fedora_qcow2(qcow2: Path, checksum_url: str) -> bool:
|
||||
text = _fetch_text(checksum_url)
|
||||
if not text:
|
||||
return False
|
||||
expected: Optional[str] = None
|
||||
for line in text.splitlines():
|
||||
m = re.match(rf"SHA256 \({re.escape(qcow2.name)}\) = ([0-9a-fA-F]+)", line)
|
||||
if m:
|
||||
expected = m.group(1).lower()
|
||||
break
|
||||
if expected is None:
|
||||
err(f"No SHA256 entry for {qcow2.name} in checksum file")
|
||||
return False
|
||||
print(" Verifying SHA256 (this can take a minute) ...")
|
||||
if _sha256_of(qcow2).lower() != expected:
|
||||
err(f"Fedora image SHA256 mismatch (got {_sha256_of(qcow2)}, expected {expected})")
|
||||
return False
|
||||
print(" SHA256 OK")
|
||||
return True
|
||||
|
||||
|
||||
def _download_fedora_image(qcow2_url: str, dest: Path) -> bool:
|
||||
"""Stream the Fedora qcow2 via curl (progress bar, resumable)."""
|
||||
if not has_cmd("curl"):
|
||||
return _download(qcow2_url, dest)
|
||||
print(f" Downloading {dest.name} ...")
|
||||
result = run(["curl", "-L", "--fail", "-#",
|
||||
"-o", str(dest), qcow2_url], check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
_FIRECRACKER_USERDATA = """#cloud-config
|
||||
hostname: firecracker-vm
|
||||
users:
|
||||
- name: {user}
|
||||
sudo: ALL=(ALL) NOPASSWD:ALL
|
||||
shell: /bin/bash
|
||||
ssh_authorized_keys:
|
||||
- {pubkey}
|
||||
ssh_pwauth: false
|
||||
packages:
|
||||
- curl
|
||||
- tar
|
||||
- qemu-kvm
|
||||
write_files:
|
||||
- path: /usr/local/sbin/install-firecracker.sh
|
||||
permissions: '0755'
|
||||
content: |
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
ARCH=$(uname -m)
|
||||
TAG=$(curl -fsSL https://api.github.com/repos/firecracker-microvm/firecracker/releases/latest \\
|
||||
| grep -oE '"tag_name":[[:space:]]*"v[^"]+"' | head -1 \\
|
||||
| sed -E 's/.*"v([^"]+)"/\\1/')
|
||||
cd /tmp
|
||||
curl -fsSL -o fc.tgz \\
|
||||
"https://github.com/firecracker-microvm/firecracker/releases/download/v${{TAG}}/firecracker-v${{TAG}}-${{ARCH}}.tgz"
|
||||
tar -xzf fc.tgz
|
||||
BIN=$(find . -maxdepth 3 -type f -name "firecracker-v${{TAG}}-${{ARCH}}" ! -name '*.debug' | head -1)
|
||||
install -m 0755 "$BIN" /usr/local/bin/firecracker
|
||||
touch /var/lib/firecracker-ready
|
||||
runcmd:
|
||||
- /usr/local/sbin/install-firecracker.sh
|
||||
"""
|
||||
|
||||
|
||||
def _write_cloud_init_seed(seed_dir: Path, pubkey: str) -> None:
|
||||
seed_dir.mkdir(parents=True, exist_ok=True)
|
||||
(seed_dir / "user-data").write_text(
|
||||
_FIRECRACKER_USERDATA.format(user=_VM_USER, pubkey=pubkey.strip())
|
||||
)
|
||||
(seed_dir / "meta-data").write_text(
|
||||
"instance-id: firecracker-vm\nlocal-hostname: firecracker-vm\n"
|
||||
)
|
||||
|
||||
|
||||
def _build_seed_iso(seed_dir: Path, iso_path: Path) -> bool:
|
||||
if iso_path.exists():
|
||||
iso_path.unlink()
|
||||
result = run(
|
||||
["hdiutil", "makehybrid", "-iso", "-joliet",
|
||||
"-default-volume-name", "cidata",
|
||||
"-o", str(iso_path), str(seed_dir)],
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _write_vm_start_script() -> Path:
|
||||
brew_share = Path(_brew_prefix()) / "share" / "qemu"
|
||||
script_path = _VM_DIR / "vm-start.sh"
|
||||
|
||||
if ARCH == "aarch64":
|
||||
edk_code = brew_share / "edk2-aarch64-code.fd"
|
||||
# The brew qemu package ships a generic arm vars template.
|
||||
edk_vars_template = brew_share / "edk2-arm-vars.fd"
|
||||
qemu_block = f"""\
|
||||
# Ensure a writable NVRAM file exists (UEFI vars persist here).
|
||||
if [[ ! -f edk2-aarch64-vars.fd ]]; then
|
||||
if [[ -f "{edk_vars_template}" ]]; then
|
||||
cp "{edk_vars_template}" edk2-aarch64-vars.fd
|
||||
else
|
||||
truncate -s 64M edk2-aarch64-vars.fd
|
||||
fi
|
||||
fi
|
||||
|
||||
exec qemu-system-aarch64 \\
|
||||
-machine virt,accel=hvf,highmem=on \\
|
||||
-cpu host \\
|
||||
-smp 2 -m 2048 \\
|
||||
-drive if=pflash,format=raw,readonly=on,file="{edk_code}" \\
|
||||
-drive if=pflash,format=raw,file=edk2-aarch64-vars.fd \\
|
||||
-drive file={_VM_QCOW2_NAME},if=virtio,format=qcow2 \\
|
||||
-drive file={_VM_SEED_ISO_NAME},format=raw,if=virtio,readonly=on \\
|
||||
-display none -serial file:vm.log \\
|
||||
-netdev user,id=net0,hostfwd=tcp::{_VM_SSH_PORT}-:22 \\
|
||||
-device virtio-net-device,netdev=net0 \\
|
||||
-daemonize -pidfile {_VM_PID_NAME}
|
||||
"""
|
||||
else:
|
||||
qemu_block = f"""\
|
||||
exec qemu-system-x86_64 \\
|
||||
-machine q35,accel=hvf \\
|
||||
-cpu host \\
|
||||
-smp 2 -m 2048 \\
|
||||
-drive file={_VM_QCOW2_NAME},if=virtio,format=qcow2 \\
|
||||
-drive file={_VM_SEED_ISO_NAME},format=raw,if=virtio,readonly=on \\
|
||||
-display none -serial file:vm.log \\
|
||||
-netdev user,id=net0,hostfwd=tcp::{_VM_SSH_PORT}-:22 \\
|
||||
-device virtio-net-device,netdev=net0 \\
|
||||
-daemonize -pidfile {_VM_PID_NAME}
|
||||
"""
|
||||
|
||||
script = f"""#!/usr/bin/env bash
|
||||
# Start the Fedora-on-QEMU VM that backs the host `firecracker` zsh function.
|
||||
set -euo pipefail
|
||||
cd "{_VM_DIR}"
|
||||
if [[ -f {_VM_PID_NAME} ]] && kill -0 "$(cat {_VM_PID_NAME})" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
rm -f {_VM_PID_NAME}
|
||||
{qemu_block}"""
|
||||
script_path.write_text(script)
|
||||
script_path.chmod(0o755)
|
||||
return script_path
|
||||
|
||||
|
||||
def _ssh_to_vm(priv_key: Path, *remote: str, timeout: int = 3) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["ssh", "-q",
|
||||
"-i", str(priv_key),
|
||||
"-p", str(_VM_SSH_PORT),
|
||||
"-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null",
|
||||
"-o", f"ConnectTimeout={timeout}",
|
||||
"-o", "LogLevel=ERROR",
|
||||
f"{_VM_USER}@127.0.0.1", *remote],
|
||||
capture_output=True, check=False,
|
||||
)
|
||||
|
||||
|
||||
def _wait_for_vm_ssh(priv_key: Path, timeout_s: int = 300) -> bool:
|
||||
print(f" Waiting for VM SSH on port {_VM_SSH_PORT} (up to {timeout_s}s) ...")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if _ssh_to_vm(priv_key, "true").returncode == 0:
|
||||
print(" VM SSH ready.")
|
||||
return True
|
||||
time.sleep(5)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_firecracker_in_vm(priv_key: Path, timeout_s: int = 900) -> bool:
|
||||
print(f" Waiting for cloud-init to install firecracker inside the VM "
|
||||
f"(up to {timeout_s}s) ...")
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if _ssh_to_vm(priv_key, "test", "-f", "/var/lib/firecracker-ready").returncode == 0:
|
||||
print(" firecracker is installed inside the VM.")
|
||||
return True
|
||||
time.sleep(10)
|
||||
return False
|
||||
|
||||
|
||||
def _firecracker_zsh_function(priv_key: Path) -> str:
|
||||
return f"""{_FIRECRACKER_FN_BEGIN}
|
||||
firecracker() {{
|
||||
local vm_dir="{_VM_DIR}"
|
||||
if [[ ! -f "$vm_dir/{_VM_QCOW2_NAME}" ]]; then
|
||||
echo "firecracker: Fedora VM not provisioned (expected $vm_dir/{_VM_QCOW2_NAME})." >&2
|
||||
return 1
|
||||
fi
|
||||
if ! "$vm_dir/vm-start.sh"; then
|
||||
echo "firecracker: failed to start backing VM (see $vm_dir/vm.log)." >&2
|
||||
return 1
|
||||
fi
|
||||
local i
|
||||
for i in $(seq 1 60); do
|
||||
ssh -q -i "{priv_key}" -p {_VM_SSH_PORT} \\
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\
|
||||
-o ConnectTimeout=2 -o LogLevel=ERROR \\
|
||||
{_VM_USER}@127.0.0.1 true && break
|
||||
sleep 1
|
||||
done
|
||||
local args=() a
|
||||
for a in "$@"; do args+=("$(printf %q "$a")"); done
|
||||
ssh -t -q -i "{priv_key}" -p {_VM_SSH_PORT} \\
|
||||
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \\
|
||||
-o LogLevel=ERROR \\
|
||||
{_VM_USER}@127.0.0.1 "sudo /usr/local/bin/firecracker ${{args[*]}}"
|
||||
}}
|
||||
{_FIRECRACKER_FN_END}
|
||||
"""
|
||||
|
||||
|
||||
def _install_firecracker_zsh_function(content: str) -> None:
|
||||
zshrc = Path.home() / ".zshrc"
|
||||
existing = zshrc.read_text() if zshrc.exists() else ""
|
||||
pattern = re.compile(
|
||||
re.escape(_FIRECRACKER_FN_BEGIN) + r".*?" + re.escape(_FIRECRACKER_FN_END) + r"\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
if pattern.search(existing):
|
||||
new = pattern.sub(content, existing)
|
||||
else:
|
||||
new = (existing.rstrip() + "\n\n" if existing else "") + content
|
||||
zshrc.write_text(new)
|
||||
print(f" Wrote firecracker() function block to {zshrc}")
|
||||
|
||||
|
||||
def setup_firecracker_vm() -> None:
|
||||
"""Provision a Fedora VM via QEMU, install firecracker inside it,
|
||||
and add a firecracker() wrapper to ~/.zshrc. macOS only."""
|
||||
if not IS_MACOS:
|
||||
return
|
||||
|
||||
qemu_bin = "qemu-system-aarch64" if ARCH == "aarch64" else "qemu-system-x86_64"
|
||||
if not has_cmd(qemu_bin):
|
||||
err(f"{qemu_bin} not found — skipping firecracker VM setup. "
|
||||
f"Install qemu via brew first.")
|
||||
return
|
||||
|
||||
print("\n=== macOS firecracker VM (Fedora on QEMU) ===")
|
||||
_VM_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
priv_key = _VM_DIR / _VM_KEY_NAME
|
||||
pub_key = priv_key.with_suffix(priv_key.suffix + ".pub")
|
||||
if not priv_key.exists():
|
||||
print(f" Generating SSH keypair at {priv_key} ...")
|
||||
if run(["ssh-keygen", "-t", "ed25519", "-N", "",
|
||||
"-f", str(priv_key), "-q"], check=False).returncode != 0:
|
||||
err("ssh-keygen failed — aborting VM setup")
|
||||
return
|
||||
|
||||
qcow2 = _VM_DIR / _VM_QCOW2_NAME
|
||||
if qcow2.exists():
|
||||
print(f" Reusing existing Fedora image at {qcow2}")
|
||||
else:
|
||||
print(" Looking up latest Fedora cloud image ...")
|
||||
info = _latest_fedora_cloud_image()
|
||||
if info is None:
|
||||
err("Could not resolve latest Fedora cloud image — aborting VM setup")
|
||||
return
|
||||
filename, qcow2_url, checksum_url = info
|
||||
print(f" Latest: {filename}")
|
||||
download_dest = _VM_DIR / filename
|
||||
if not _download_fedora_image(qcow2_url, download_dest):
|
||||
err("Fedora image download failed — aborting VM setup")
|
||||
return
|
||||
if not _verify_fedora_qcow2(download_dest, checksum_url):
|
||||
download_dest.unlink(missing_ok=True)
|
||||
return
|
||||
download_dest.rename(qcow2)
|
||||
if has_cmd("qemu-img"):
|
||||
print(" Resizing image to 10G ...")
|
||||
run(["qemu-img", "resize", str(qcow2), "10G"], check=False)
|
||||
|
||||
print(" Building cloud-init seed ISO ...")
|
||||
seed_dir = _VM_DIR / "seed"
|
||||
_write_cloud_init_seed(seed_dir, pub_key.read_text())
|
||||
seed_iso = _VM_DIR / _VM_SEED_ISO_NAME
|
||||
if not _build_seed_iso(seed_dir, seed_iso):
|
||||
err("hdiutil failed to build seed ISO — aborting VM setup")
|
||||
return
|
||||
|
||||
print(" Writing VM start script ...")
|
||||
start_script = _write_vm_start_script()
|
||||
|
||||
print(f" Booting VM via {start_script} ...")
|
||||
if run([str(start_script)], check=False).returncode != 0:
|
||||
err(f"VM start failed — see {_VM_DIR / 'vm.log'}")
|
||||
return
|
||||
|
||||
if not _wait_for_vm_ssh(priv_key):
|
||||
err(f"VM SSH never came up — see {_VM_DIR / 'vm.log'}")
|
||||
return
|
||||
|
||||
if not _wait_for_firecracker_in_vm(priv_key):
|
||||
warn("firecracker did not appear in the VM within the timeout; "
|
||||
"cloud-init may still be running. Check `sudo cloud-init status` "
|
||||
"inside the VM (ssh -i ~/.firecracker-vm/id_ed25519 -p 2222 "
|
||||
"fc@127.0.0.1).")
|
||||
|
||||
print(" Installing firecracker() wrapper into ~/.zshrc ...")
|
||||
_install_firecracker_zsh_function(_firecracker_zsh_function(priv_key))
|
||||
|
||||
print(f" firecracker VM ready. Start manually with: {start_script}")
|
||||
print(f" Note: firecracker microVMs need /dev/kvm in the guest — macOS HVF")
|
||||
print(f" does not expose nested KVM, so spawning microVMs from inside this")
|
||||
print(f" Fedora guest will not work. firecracker CLI ops (--version, etc.)")
|
||||
print(f" and dry-run/api-sock setup will still function.")
|
||||
|
||||
# ── packages module loader ────────────────────────────────────────────────────
|
||||
|
||||
def load_packages() -> tuple[list[str], list[str], list[CustomPackage]]:
|
||||
@@ -1636,10 +1996,18 @@ def main() -> None:
|
||||
help="Skip GUI applications (suitable for headless environments). "
|
||||
"Excludes GUI system packages and skips the entire Flatpak "
|
||||
"section, including installing flatpak itself.")
|
||||
ap.add_argument("--no-vm", action="store_true",
|
||||
help="macOS only: skip provisioning the Fedora-on-QEMU VM that "
|
||||
"backs the firecracker() zsh wrapper.")
|
||||
args = ap.parse_args()
|
||||
|
||||
system_pkgs, flatpak_pkgs, custom_pkgs = load_packages()
|
||||
|
||||
if IS_MACOS:
|
||||
# firecracker is provisioned inside the Fedora VM (see setup_firecracker_vm),
|
||||
# not on the host. Drop it from the host custom-package list.
|
||||
custom_pkgs = [p for p in custom_pkgs if p.name.lower() != "firecracker"]
|
||||
|
||||
print(f"OS: {OS}")
|
||||
print(f"Architecture: {ARCH}")
|
||||
print(f"Package manager: {PKG_MGR}")
|
||||
@@ -1707,6 +2075,8 @@ def main() -> None:
|
||||
if args.only is None:
|
||||
check_and_setup_ssh()
|
||||
_clone_nvim_config()
|
||||
if IS_MACOS and not args.no_vm:
|
||||
setup_firecracker_vm()
|
||||
|
||||
if pyenv_thread is not None:
|
||||
if pyenv_thread.is_alive():
|
||||
|
||||
Reference in New Issue
Block a user