Extend VM Service Linux Support

Nucleic-Session: 44D29355-D1D8-4327-97B6-B1AEBD5EDE57
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-08 06:02:37 -07:00
co-authored by nucleic
parent f350fd1b94
commit f253e495f5
@@ -46,4 +46,177 @@ extension MacVMEngine {
?? MacVMBundle(root: builtLinuxBaseDir)
return bundle.isComplete(for: .linux) ? bundle.root.path : nil
}
/// Delete the engine-built Linux base so the next ``buildLinuxBaseImage`` rebuilds from scratch.
/// Refuses while the base is busy. A configured *prebuilt* base is left untouched.
public func deleteLinuxBaseImage() throws {
guard !baseIsBusy else {
throw MacVMError.baseBusy("a base image build is in progress — wait for it to finish, then delete.")
}
try? FileManager.default.removeItem(at: builtLinuxBaseDir)
baseProgress = nil
}
}
#if arch(arm64)
extension MacVMEngine {
// MARK: - Linux base build (fully automated, two in-guest phases)
/// Build the golden **Linux** base bundle end-to-end (docs/LINUX_VM.md §base build). macOS has no
/// Linux filesystem tooling, so the ext4 root disk is assembled *inside the guest* across two boots:
///
/// 1. **bootstrap** boot the downloaded kernel + a tiny busybox initramfs with the empty root
/// disk attached and the provisioning payload (Ubuntu rootfs tarball, the vsock agent, the
/// provisioning script + systemd units) shared in over virtiofs. Its `/init` formats the disk,
/// unpacks the rootfs, bakes in the agent + first-boot unit, and powers off.
/// 2. **provision** boot the installed rootfs; the first-boot unit installs a minimal Wayland
/// desktop + dev toolchain, then writes the readiness flag the host waits on before sealing.
///
/// Long-running (multi-GB download, then two boots with an `apt` install); progress rides
/// ``currentBaseProgress()``. Invoke explicitly (Settings Build Linux base image / the spike),
/// never lazily on an agent turn like ``buildBaseImage``.
public func buildLinuxBaseImage() async throws {
guard Self.isSupported else {
throw MacVMError.unavailable(Self.unsupportedReason ?? "requires Apple silicon")
}
guard !baseIsBusy else {
throw MacVMError.installFailed("a base image build is already in progress")
}
// A complete, provisioned base is a no-op (rebuild after an explicit delete).
let existing = MacVMBundle(root: builtLinuxBaseDir)
if existing.isComplete(for: .linux), readBaseStatus(existing).provisioned,
MacVMSettings.linuxBasePrebuiltPath == nil
{
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
return
}
baseBuilding = true
defer { baseBuilding = false }
let fm = FileManager.default
try fm.createDirectory(at: linuxArtifactsDir, withIntermediateDirectories: true)
// 1. Resolve (download + cache) the boot artifacts and the guest payload.
baseProgress = MacVMBaseProgress(
phase: .downloadingRestoreImage, fraction: 0, detail: "Downloading the Linux kernel, initrd, and root filesystem")
let artifacts = try await resolveLinuxArtifacts()
// Build into a temp bundle, publish atomically so an interrupted build never looks complete.
let tmp = MacVMBundle(root: storageRoot.appendingPathComponent("linux-base.building", isDirectory: true))
try? fm.removeItem(at: tmp.root)
try fm.createDirectory(at: tmp.root, withIntermediateDirectories: true)
do {
// Kernel + initrd + persisted cmdline live in the bundle; the rootfs disk is created empty
// and filled by the bootstrap boot.
try fm.copyItem(at: artifacts.kernel, to: tmp.kernelURL)
try fm.copyItem(at: artifacts.initrd, to: tmp.initrdURL)
try Self.defaultLinuxCommandLine.write(to: tmp.cmdlineURL, atomically: true, encoding: .utf8)
try Self.createDiskImage(
at: tmp.diskImageURL,
sizeBytes: UInt64(MacVMSettings.baseDiskGiB) * 1024 * 1024 * 1024)
// 2. Bootstrap boot assemble the rootfs in-guest.
baseProgress = MacVMBaseProgress(
phase: .installing, fraction: nil, detail: "Assembling the root filesystem (bootstrap boot)")
try await runLinuxBootstrapBoot(bundle: tmp, artifacts: artifacts)
// Publish the installed-but-unprovisioned base first (so a provisioning failure doesn't
// discard the multi-GB rootfs), then provision in place.
var status = MacVMBaseStatus(
installed: true, provisioned: false, osVersion: "24.04", os: .linux)
writeBaseStatus(tmp, status)
try? fm.removeItem(at: builtLinuxBaseDir)
try fm.moveItem(at: tmp.root, to: builtLinuxBaseDir)
// 3. Provision boot install the desktop + toolchain; wait for the readiness flag.
let base = MacVMBundle(root: builtLinuxBaseDir)
baseProgress = MacVMBaseProgress(
phase: .provisioning, fraction: nil, detail: "Installing the Linux desktop + toolchain")
try await runLinuxProvisionBoot(bundle: base)
status.provisioned = true
writeBaseStatus(base, status)
baseProgress = MacVMBaseProgress(phase: .ready, fraction: 1)
} catch {
try? fm.removeItem(at: tmp.root)
baseProgress = nil
if let e = error as? MacVMError { throw e }
throw MacVMError.installFailed(String(describing: error))
}
}
// MARK: - Artifact resolution
struct LinuxArtifacts {
let kernel: URL // uncompressed arm64 Image
let initrd: URL // Ubuntu generic initrd (phase-2 boot)
let rootfsTarball: URL // Ubuntu root tarball (.tar.xz/.gz), fed to the bootstrap
}
/// Download (and cache, version-keyed by filename) the kernel + initrd + rootfs tarball, gunzipping
/// the kernel if it arrived as a gzip `vmlinuz` (VZLinuxBootLoader needs an uncompressed `Image`).
func resolveLinuxArtifacts() async throws -> LinuxArtifacts {
guard let kernelSrc = URL(string: MacVMSettings.linuxKernelURL),
let initrdSrc = URL(string: MacVMSettings.linuxInitrdURL),
let rootfsSrc = URL(string: MacVMSettings.linuxRootfsURL)
else { throw MacVMError.kernelMissing("the configured Linux artifact URLs are invalid") }
let kernelDL = try await downloadLinuxArtifact(from: kernelSrc, label: "kernel")
let kernel = try gunzipIfNeeded(kernelDL, to: linuxArtifactsDir.appendingPathComponent("vmlinux"))
let initrd = try await downloadLinuxArtifact(from: initrdSrc, label: "initrd")
let rootfs = try await downloadLinuxArtifact(from: rootfsSrc, label: "rootfs")
return LinuxArtifacts(kernel: kernel, initrd: initrd, rootfsTarball: rootfs)
}
/// Download `url` into the version-keyed cache unless present, reporting progress.
private func downloadLinuxArtifact(from url: URL, label: String) async throws -> URL {
let fm = FileManager.default
let dest = linuxArtifactsDir.appendingPathComponent(Self.linuxCacheName(for: url))
if fm.fileExists(atPath: dest.path) { return dest }
try await downloadFile(from: url, to: dest) { [weak self] fraction in
Task {
await self?.setBaseProgress(.init(
phase: .downloadingRestoreImage, fraction: fraction,
detail: "Downloading the Linux \(label)"))
}
}
return dest
}
/// A filesystem-safe cache filename for a Linux artifact URL its own leaf (which encodes the
/// release), so different versions land in different files. Unlike ``restoreCacheName`` it does not
/// force an `.ipsw` suffix.
static func linuxCacheName(for url: URL) -> String {
var leaf = url.lastPathComponent
if leaf.isEmpty || leaf == "/" { leaf = "artifact" }
return leaf.map { ($0 == "/" || $0 == ":" || $0 == "?") ? "_" : $0 }.reduce(into: "") { $0.append($1) }
}
/// If `src` is gzip-compressed (magic `1f 8b`), decompress it to `dest`; otherwise hardlink/copy it
/// through unchanged. Uses `/usr/bin/gunzip` (present on macOS). Cached: skips if `dest` exists.
func gunzipIfNeeded(_ src: URL, to dest: URL) throws -> URL {
let fm = FileManager.default
if fm.fileExists(atPath: dest.path) { return dest }
let handle = try FileHandle(forReadingFrom: src)
let magic = try handle.read(upToCount: 2) ?? Data()
try? handle.close()
if magic == Data([0x1F, 0x8B]) {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/gunzip")
proc.arguments = ["-c", src.path]
let out = try FileHandle(forWritingTo: fm.createFile(atPath: dest.path, contents: nil)
? dest : dest)
proc.standardOutput = out
try proc.run()
proc.waitUntilExit()
try? out.close()
guard proc.terminationStatus == 0 else {
throw MacVMError.kernelMissing("could not gunzip the kernel image")
}
} else {
try fm.copyItem(at: src, to: dest)
}
return dest
}
}
#endif