diff --git a/build.zig b/build.zig index 9882273..6d9fee2 100644 --- a/build.zig +++ b/build.zig @@ -1,8 +1,12 @@ const std = @import("std"); const builtin = @import("builtin"); // The danos build API (docs/build-packages-plan.md): the shared user-binary -// recipe lives in the build-support package; this root build orchestrates. +// recipe lives in the build-support package; this root build orchestrates — +// what ships (the bundled list), the kernel + loader, and the test aggregate. +// Image assembly and the QEMU run steps live beside it in build/. const build_support = @import("build-support"); +const images = @import("build/images.zig"); +const qemu = @import("build/qemu.zig"); /// danos is developed against Zig 0.16.x. Pre-1.0 Zig makes breaking API changes /// between minor releases, and the .zon's `minimum_zig_version` only enforces a @@ -19,38 +23,6 @@ fn ensureZigVersion() void { } } -/// Return the first path in `candidates` that exists on the build host, else the -/// first candidate as a fallback so a missing-firmware error still names a -/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware -/// across distro/OS layouts without configuration. -fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 { - for (candidates) |path| { - std.Io.Dir.accessAbsolute(io, path, .{}) catch continue; - return path; - } - return candidates[0]; -} - -/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so -/// repeated runs don't clobber each other's logs. Resolved when `build.zig` runs -/// (i.e. at `zig build` invocation), which is moments before QEMU launches. -fn timestamp(b: *std.Build) []const u8 { - const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds; - const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s)); - const es = std.time.epoch.EpochSeconds{ .secs = secs }; - const yd = es.getEpochDay().calculateYearDay(); - const md = yd.calculateMonthDay(); - const ds = es.getDaySeconds(); - return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ - yd.year, - md.month.numeric(), - @as(u32, md.day_index) + 1, - ds.getHoursIntoDay(), - ds.getMinutesIntoHour(), - ds.getSecondsIntoMinute(), - }); -} - /// The modules the kernel imports, gathered once so both kernel variants (the /// installed one and the serial-enabled one `run-x86-64` boots) are built from /// the same set. `build_options` is *not* here — it carries `serial`/`test_case`, @@ -124,43 +96,6 @@ fn addKernel( return exe; } -/// One user binary and its FHS home on the boot volume (and in zig-out). -const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath }; - -/// Assemble the bootable FAT32 image (the in-repo Python builder) holding the -/// EFI stub, the kernel, and every user binary at its FHS path — the volume's -/// /system tree IS the system image; the EFI loader walks it at boot and builds -/// the in-RAM initial_ramdisk from it. Factored so the serial-enabled -/// `run-x86-64` variant can bundle its own serial kernel while sharing the -/// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both -/// follow the top-level -Dserial). Returns the image's LazyPath. -fn addBootImage( - b: *std.Build, - kernel_bin: std.Build.LazyPath, - efi_bin: std.Build.LazyPath, - manifest: std.Build.LazyPath, - capsule: std.Build.LazyPath, - bundled: []const BundledBinary, -) std.Build.LazyPath { - const mk_fat = b.addSystemCommand(&.{"python3"}); - mk_fat.addFileArg(b.path("tools/make-fat-image.py")); - const fat_image = mk_fat.addOutputFileArg("danos-usb.img"); - mk_fat.addArg("64"); // MiB - mk_fat.addArg("EFI/BOOT/BOOTX64.efi"); - mk_fat.addFileArg(efi_bin); - mk_fat.addArg("system/kernel"); - mk_fat.addFileArg(kernel_bin); - mk_fat.addArg("system/manifest"); - mk_fat.addFileArg(manifest); - mk_fat.addArg("boot/system.img"); - mk_fat.addFileArg(capsule); - for (bundled) |item| { - mk_fat.addArg(item.path); - mk_fat.addFileArg(item.binary); - } - return fat_image; -} - pub fn build(b: *std.Build) void { ensureZigVersion(); @@ -271,15 +206,10 @@ pub fn build(b: *std.Build) void { .initial_ramdisk = initial_ramdisk_module, }; // The installed/flashable kernel: serial follows -Dserial (off by default). + // The serial-enabled twin is what `run-x86-64` boots — built lazily (only + // when its image is requested), never installed. const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial); - - // Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and* - // the boot volume. Each binary lands at its addressed, leaf-collapsed path — the - // kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at - // zig-out/system/services/init, and so on (see docs/README.md). The bootloader - // then loads these FHS paths off the volume. - const kernel_install = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "system" } } }); - b.getInstallStep().dependOn(&kernel_install.step); + const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true); // --- the user-space binaries, every one of them a package --- // Binary packages (docs/build-packages-plan.md, phase 2): each binary @@ -371,7 +301,7 @@ pub fn build(b: *std.Build) void { // variant that omits the display stack (so the kernel's boot transcript stays // on screen); both are bundled at the same /etc/init.csv path. const init_csv_source = if (diagnose) "etc/init-diagnose.csv" else "etc/init.csv"; - const production_bundled = [_]BundledBinary{ + const production_bundled = [_]images.BundledBinary{ .{ .path = "system/services/init", .binary = init_exe.getEmittedBin() }, .{ .path = "system/services/fat", .binary = fat_exe.getEmittedBin() }, .{ .path = "system/services/display", .binary = display_exe.getEmittedBin() }, @@ -402,7 +332,7 @@ pub fn build(b: *std.Build) void { // harness signals by passing -Dtest-case= for every scenario, exactly when // these fixtures must be on the boot volume. Merely building this array never // forces a compile: the fixture exes build only if `bundled` (below) includes them. - const test_bundled = [_]BundledBinary{ + const test_bundled = [_]images.BundledBinary{ .{ .path = "test/system/services/vfs-test", .binary = vfstest_exe.getEmittedBin() }, .{ .path = "test/system/services/fat-test", .binary = fat_test_exe.getEmittedBin() }, .{ .path = "test/system/services/shared-memory-server", .binary = shared_memory_server_exe.getEmittedBin() }, @@ -420,52 +350,11 @@ pub fn build(b: *std.Build) void { // A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the // production set only. Test fixtures join in only under -Dtest-case; the // diagnose display-omission is already handled by init_csv_source above. - var bundled_list: std.ArrayListUnmanaged(BundledBinary) = .empty; + var bundled_list: std.ArrayListUnmanaged(images.BundledBinary) = .empty; bundled_list.appendSlice(b.allocator, &production_bundled) catch @panic("OOM"); if (test_case != null) bundled_list.appendSlice(b.allocator, &test_bundled) catch @panic("OOM"); const bundled = bundled_list.items; - // The boot manifest: the FHS path of every bundled binary, one per line. The - // EFI loader reads THIS by name and opens each listed path by name — FAT - // name lookup is case-insensitive and firmware-portable, unlike directory - // ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come - // back uppercase on some FAT drivers). The tree walk remains only as the - // loader's fallback for hand-assembled sticks without a manifest. - var manifest_text: std.ArrayListUnmanaged(u8) = .empty; - for (bundled) |item| { - manifest_text.append(b.allocator, '/') catch @panic("OOM"); - manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM"); - manifest_text.append(b.allocator, '\n') catch @panic("OOM"); - } - const manifest_files = b.addWriteFiles(); - const manifest_file = manifest_files.add("manifest", manifest_text.items); - const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest"); - b.getInstallStep().dependOn(&manifest_install.step); - - // The boot capsule: the same bundled list packed into ONE file (v2 - // initial_ramdisk format), because a single open + sequential read is the - // only firmware file I/O shape that is fast everywhere — a per-file tree - // walk measured MINUTES on real firmware. The loader tries this first, - // then the manifest, then the walk; the running system cannot tell the - // difference (it always receives the same in-RAM table). Derived from the - // tree in the same build graph, so the two cannot drift. - const mk_capsule = b.addSystemCommand(&.{"python3"}); - mk_capsule.addFileArg(b.path("tools/pack-system-image.py")); - const capsule_img = mk_capsule.addOutputFileArg("system.img"); - for (bundled) |item| { - mk_capsule.addArg(item.path); - mk_capsule.addFileArg(item.binary); - } - const capsule_install = b.addInstallFile(capsule_img, "boot/system.img"); - b.getInstallStep().dependOn(&capsule_install.step); - - // Install every bundled binary to its FHS home, so zig-out is a true image of - // the filesystem — the same tree make-fat-image.py lays out on the boot volume. - for (bundled) |item| { - const install = b.addInstallFileWithDir(item.binary, .prefix, item.path); - b.getInstallStep().dependOn(&install.step); - } - // Boot methods live in boot/, one per way of getting the kernel running. // Each is its own binary/entry (a loader is built for its own target); today // that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis. @@ -495,212 +384,16 @@ pub fn build(b: *std.Build) void { }), }); - // UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi, - // so that path is fixed by the firmware (it is /boot's EFI stub, conceptually). - const efi_install = b.addInstallArtifact(efiexe, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } }); - b.getInstallStep().dependOn(&efi_install.step); - - // --- danos-usb.img: the bootable FAT32 USB image --- - // Format a real FAT32 image (the in-repo Python builder, no external tools) - // holding the EFI stub, the kernel, and the whole /system tree of user - // binaries at their FHS paths. QEMU presents this image as a USB mass-storage - // device the guest boots from (see run-x86-64 and the test harness), and the - // danos fat driver mounts the same image at /mnt/usb. - const fat_image = addBootImage(b, exe.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled); - const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img"); - b.getInstallStep().dependOn(&fat_image_install.step); - - // The image `run-x86-64` boots: identical to the flashable one but with the - // serial log sink compiled in, so a developer always gets the machine-readable - // log captured to serial0 — without baking serial into the image users flash. - // Built lazily (only when `run-x86-64` is requested), and never installed. - const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true); - const fat_image_serial = addBootImage(b, exe_serial.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled); - - // `zig build check-fat-image` — validate the produced image is a real FAT32 - // with the EFI stub present (the builder's own --verify, no external tools). - const check_fat = b.addSystemCommand(&.{"python3"}); - check_fat.addFileArg(b.path("tools/make-fat-image.py")); - check_fat.addArg("--verify"); - check_fat.addFileArg(fat_image); - const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable"); - check_fat_step.dependOn(&check_fat.step); - - // --- release-x86-64: danos-x86-64.iso, the flashable release image --- - // Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder - // again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry - // and MBR ESP partition entry both point at the embedded FAT image. One - // file then boots every way release media is consumed — flashed raw to a - // USB stick with Etcher or dd, or burned to optical media — while - // danos-usb.img stays the raw superfloppy QEMU and the test harness boot. - const mk_iso = b.addSystemCommand(&.{"python3"}); - mk_iso.addFileArg(b.path("tools/make-iso-image.py")); - const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso"); - mk_iso.addFileArg(fat_image); - const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso"); - const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)"); - release_step.dependOn(&iso_install.step); - - // `zig build check-iso-image` — the ISO builder's own --verify (mirroring - // check-fat-image): the MBR partition, the El Torito catalog, and the - // embedded FAT32 image must all agree. - const check_iso = b.addSystemCommand(&.{"python3"}); - check_iso.addFileArg(b.path("tools/make-iso-image.py")); - check_iso.addArg("--verify"); - check_iso.addFileArg(iso_image); - const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)"); - check_iso_step.dependOn(&check_iso.step); - - // --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF --- - // Firmware lives in different places per OS/distro, so probe the known - // layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first - // that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere. - const ovmf_code = b.option( - []const u8, - "ovmf-code", - "Path to the OVMF_CODE firmware image", - ) orelse firstExisting(b.graph.io, &.{ - "/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture - "/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu - "/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu - "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora - "/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon) - "/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel) + // Image assembly (the FHS install tree, boot manifest + capsule, both FAT32 + // images, the release ISO, the check steps) and the QEMU run steps live in + // build/ — the root decides what ships, those files own how it runs. + const fat_image_serial = images.addImageSteps(b, .{ + .kernel = exe, + .kernel_serial = exe_serial, + .efi = efiexe, + .bundled = bundled, }); - const ovmf_vars = b.option( - []const u8, - "ovmf-vars", - "Path to the OVMF_VARS firmware image (a writable copy is made)", - ) orelse firstExisting(b.graph.io, &.{ - "/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture - "/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu - "/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu - "/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora - "/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon) - "/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel) - }); - - // The guest boots the self-contained FAT image (attached as USB storage below), - // not the installed FHS zig-out — see the run step's drive/device flags. - - // The firmware needs to write NVRAM, so give it a writable copy of the vars. - const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); - const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd"); - - const run_efi = b.addSystemCommand(&.{ - "qemu-system-x86_64", - "-device", - "qemu-xhci,id=xhci", - "-device", - "usb-mouse,bus=xhci.0", - "-device", - "usb-kbd,bus=xhci.0", - // "-usb", - // "-device", - // "usb-ehci,id=ehci", - // "-device", - // "usb-tablet,bus=usb-bus.0", - // "-device", - // "usb-mouse,bus=ehci.0", - "-machine", - "q35", - "-m", - "128M", - "-drive", - b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), - }); - run_efi.addArg("-drive"); - run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); - // Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as - // the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots. - // The serial-enabled variant, so serial0 carries the log for this dev boot. - run_efi.addArg("-drive"); - run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); - run_efi.addArgs(&.{ - "-device", - "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", - "-net", - "none", - // Emulated display advertising 1280x720 as its native (EDID preferred) - // resolution, so the kernel's native-resolution switch has something to - // find. `-vga none` avoids a second, default adapter. - "-vga", - "none", - "-device", - "VGA,edid=on,xres=1280,yres=720", - }); - // Capture the guest's serial0 (danos's machine-readable log) to the qemu-test - // scratch area — a dev/host artifact, kept out of the FHS boot volume we mount. - // (/var/log/system is reserved for the kernel's own logging system later.) One - // timestamped file per run. - const log_dir = b.fmt("{s}/qemu-test", .{b.install_path}); - const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir }); - const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) }); - run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) }); - // We boot the self-contained `fat_image_serial` (added as a file arg above, so - // it's already a dependency) — not the installed FHS zig-out — so `run-x86-64` - // builds only the serial kernel, never the flashable one. Just make the serial - // scratch dir first. - run_efi.step.dependOn(&make_log_dir.step); - - const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-.log"); - run_efi_step.dependOn(&run_efi.step); - - // --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter --- - // The VGA device still supplies the boot (GOP) framebuffer the compositor starts - // on; the virtio-gpu function is discovered by the device-manager stack, its - // driver announces a shared scanout, and the compositor upgrades off the GOP - // floor to fenced, tear-free native presents (docs/display-v2.md). - // This is the interactive twin of the `display-native` test case, and 512M - // matches it (the whole driver stack + the compositor's surfaces at once). - // QEMU shows one head per adapter: pick the virtio-gpu head in the View menu - // to watch the native output. - const run_gpu = b.addSystemCommand(&.{ - "qemu-system-x86_64", - "-device", - "qemu-xhci,id=xhci", - "-device", - "usb-mouse,bus=xhci.0", - "-device", - "usb-kbd,bus=xhci.0", - "-machine", - "q35", - "-m", - "512M", - "-drive", - b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), - }); - run_gpu.addArg("-drive"); - run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); - run_gpu.addArg("-drive"); - run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); - run_gpu.addArgs(&.{ - "-device", - "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", - "-net", - "none", - "-vga", - "none", - "-device", - "VGA,edid=on,xres=1280,yres=720", - "-device", - "virtio-gpu-pci", - }); - const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) }); - run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) }); - run_gpu.step.dependOn(&make_log_dir.step); - - const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu"); - run_gpu_step.dependOn(&run_gpu.step); - - // const run_cmd = b.addRunArtifact(exe); - // const run_step = b.step("run", "Run the app"); - // run_step.dependOn(&run_cmd.step); - // run_cmd.step.dependOn(b.getInstallStep()); - // - // if (b.args) |args| { - // run_cmd.addArgs(args); - // } + qemu.addRunSteps(b, fat_image_serial); // Tests run on the host. The kernel and bootloader target freestanding/UEFI // and can't be executed natively, so only the shared contracts are unit-tested diff --git a/build/images.zig b/build/images.zig new file mode 100644 index 0000000..6300aa2 --- /dev/null +++ b/build/images.zig @@ -0,0 +1,166 @@ +//! Boot-image assembly (docs/build-packages-plan.md, phase 3): everything +//! between "here are the built binaries" and "here is a bootable volume". +//! The FHS-shaped zig-out install tree, the boot manifest, the boot capsule, +//! the FAT32 USB image (+ its serial-enabled twin for the QEMU run steps), +//! and the release ISO — with their check steps. The root build.zig decides +//! WHAT ships (the bundled list); this file owns HOW it becomes an image. + +const std = @import("std"); + +/// One user binary and its FHS home on the boot volume (and in zig-out). +pub const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath }; + +pub const Options = struct { + /// The installed/flashable kernel (serial follows the root -Dserial). + kernel: *std.Build.Step.Compile, + /// The serial-enabled kernel variant the `run-x86-64` image boots. + kernel_serial: *std.Build.Step.Compile, + /// The UEFI loader (BOOTX64). + efi: *std.Build.Step.Compile, + /// Every user binary and data file at its FHS path. + bundled: []const BundledBinary, +}; + +/// Wire up the install tree, both FAT32 boot images, the release ISO, and the +/// check steps. Returns the serial-enabled FAT image for the QEMU run steps. +pub fn addImageSteps(b: *std.Build, options: Options) std.Build.LazyPath { + // Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and* + // the boot volume. Each binary lands at its addressed, leaf-collapsed path — the + // kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at + // zig-out/system/services/init, and so on (see docs/README.md). The bootloader + // then loads these FHS paths off the volume. + const kernel_install = b.addInstallArtifact(options.kernel, .{ .dest_dir = .{ .override = .{ .custom = "system" } } }); + b.getInstallStep().dependOn(&kernel_install.step); + + // UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi, + // so that path is fixed by the firmware (it is /boot's EFI stub, conceptually). + const efi_install = b.addInstallArtifact(options.efi, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } }); + b.getInstallStep().dependOn(&efi_install.step); + + // The boot manifest: the FHS path of every bundled binary, one per line. The + // EFI loader reads THIS by name and opens each listed path by name — FAT + // name lookup is case-insensitive and firmware-portable, unlike directory + // ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come + // back uppercase on some FAT drivers). The tree walk remains only as the + // loader's fallback for hand-assembled sticks without a manifest. + var manifest_text: std.ArrayListUnmanaged(u8) = .empty; + for (options.bundled) |item| { + manifest_text.append(b.allocator, '/') catch @panic("OOM"); + manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM"); + manifest_text.append(b.allocator, '\n') catch @panic("OOM"); + } + const manifest_files = b.addWriteFiles(); + const manifest_file = manifest_files.add("manifest", manifest_text.items); + const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest"); + b.getInstallStep().dependOn(&manifest_install.step); + + // The boot capsule: the same bundled list packed into ONE file (v2 + // initial_ramdisk format), because a single open + sequential read is the + // only firmware file I/O shape that is fast everywhere — a per-file tree + // walk measured MINUTES on real firmware. The loader tries this first, + // then the manifest, then the walk; the running system cannot tell the + // difference (it always receives the same in-RAM table). Derived from the + // tree in the same build graph, so the two cannot drift. + const mk_capsule = b.addSystemCommand(&.{"python3"}); + mk_capsule.addFileArg(b.path("tools/pack-system-image.py")); + const capsule_img = mk_capsule.addOutputFileArg("system.img"); + for (options.bundled) |item| { + mk_capsule.addArg(item.path); + mk_capsule.addFileArg(item.binary); + } + const capsule_install = b.addInstallFile(capsule_img, "boot/system.img"); + b.getInstallStep().dependOn(&capsule_install.step); + + // Install every bundled binary to its FHS home, so zig-out is a true image of + // the filesystem — the same tree make-fat-image.py lays out on the boot volume. + for (options.bundled) |item| { + const install = b.addInstallFileWithDir(item.binary, .prefix, item.path); + b.getInstallStep().dependOn(&install.step); + } + + // --- danos-usb.img: the bootable FAT32 USB image --- + // Format a real FAT32 image (the in-repo Python builder, no external tools) + // holding the EFI stub, the kernel, and the whole /system tree of user + // binaries at their FHS paths. QEMU presents this image as a USB mass-storage + // device the guest boots from (see run-x86-64 and the test harness), and the + // danos fat driver mounts the same image at /mnt/usb. + const fat_image = addBootImage(b, options.kernel.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled); + const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img"); + b.getInstallStep().dependOn(&fat_image_install.step); + + // The image `run-x86-64` boots: identical to the flashable one but with the + // serial log sink compiled in, so a developer always gets the machine-readable + // log captured to serial0 — without baking serial into the image users flash. + // Built lazily (only when `run-x86-64` is requested), and never installed. + const fat_image_serial = addBootImage(b, options.kernel_serial.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled); + + // `zig build check-fat-image` — validate the produced image is a real FAT32 + // with the EFI stub present (the builder's own --verify, no external tools). + const check_fat = b.addSystemCommand(&.{"python3"}); + check_fat.addFileArg(b.path("tools/make-fat-image.py")); + check_fat.addArg("--verify"); + check_fat.addFileArg(fat_image); + const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable"); + check_fat_step.dependOn(&check_fat.step); + + // --- release-x86-64: danos-x86-64.iso, the flashable release image --- + // Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder + // again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry + // and MBR ESP partition entry both point at the embedded FAT image. One + // file then boots every way release media is consumed — flashed raw to a + // USB stick with Etcher or dd, or burned to optical media — while + // danos-usb.img stays the raw superfloppy QEMU and the test harness boot. + const mk_iso = b.addSystemCommand(&.{"python3"}); + mk_iso.addFileArg(b.path("tools/make-iso-image.py")); + const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso"); + mk_iso.addFileArg(fat_image); + const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso"); + const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)"); + release_step.dependOn(&iso_install.step); + + // `zig build check-iso-image` — the ISO builder's own --verify (mirroring + // check-fat-image): the MBR partition, the El Torito catalog, and the + // embedded FAT32 image must all agree. + const check_iso = b.addSystemCommand(&.{"python3"}); + check_iso.addFileArg(b.path("tools/make-iso-image.py")); + check_iso.addArg("--verify"); + check_iso.addFileArg(iso_image); + const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)"); + check_iso_step.dependOn(&check_iso.step); + + return fat_image_serial; +} + +/// Assemble the bootable FAT32 image (the in-repo Python builder) holding the +/// EFI stub, the kernel, and every user binary at its FHS path — the volume's +/// /system tree IS the system image; the EFI loader walks it at boot and builds +/// the in-RAM initial_ramdisk from it. Factored so the serial-enabled +/// `run-x86-64` variant can bundle its own serial kernel while sharing the +/// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both +/// follow the top-level -Dserial). Returns the image's LazyPath. +fn addBootImage( + b: *std.Build, + kernel_bin: std.Build.LazyPath, + efi_bin: std.Build.LazyPath, + manifest: std.Build.LazyPath, + capsule: std.Build.LazyPath, + bundled: []const BundledBinary, +) std.Build.LazyPath { + const mk_fat = b.addSystemCommand(&.{"python3"}); + mk_fat.addFileArg(b.path("tools/make-fat-image.py")); + const fat_image = mk_fat.addOutputFileArg("danos-usb.img"); + mk_fat.addArg("64"); // MiB + mk_fat.addArg("EFI/BOOT/BOOTX64.efi"); + mk_fat.addFileArg(efi_bin); + mk_fat.addArg("system/kernel"); + mk_fat.addFileArg(kernel_bin); + mk_fat.addArg("system/manifest"); + mk_fat.addFileArg(manifest); + mk_fat.addArg("boot/system.img"); + mk_fat.addFileArg(capsule); + for (bundled) |item| { + mk_fat.addArg(item.path); + mk_fat.addFileArg(item.binary); + } + return fat_image; +} diff --git a/build/qemu.zig b/build/qemu.zig new file mode 100644 index 0000000..c61e34e --- /dev/null +++ b/build/qemu.zig @@ -0,0 +1,176 @@ +//! The QEMU run steps (docs/build-packages-plan.md, phase 3): `run-x86-64` +//! boots the serial-enabled FAT image via UEFI/OVMF; `run-x86-64-gpu` adds a +//! virtio-gpu adapter for the native-present display path. OVMF firmware is +//! probed across distro/OS layouts (-Dovmf-code / -Dovmf-vars override). + +const std = @import("std"); + +/// Wire up the `run-x86-64` and `run-x86-64-gpu` steps around the given +/// serial-enabled boot image (the guest boots that self-contained image +/// attached as USB storage, not the installed FHS zig-out). +pub fn addRunSteps(b: *std.Build, fat_image_serial: std.Build.LazyPath) void { + // Firmware lives in different places per OS/distro, so probe the known + // layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first + // that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere. + const ovmf_code = b.option( + []const u8, + "ovmf-code", + "Path to the OVMF_CODE firmware image", + ) orelse firstExisting(b.graph.io, &.{ + "/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture + "/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu + "/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu + "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora + "/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon) + "/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel) + }); + const ovmf_vars = b.option( + []const u8, + "ovmf-vars", + "Path to the OVMF_VARS firmware image (a writable copy is made)", + ) orelse firstExisting(b.graph.io, &.{ + "/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture + "/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu + "/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu + "/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora + "/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon) + "/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel) + }); + + // The firmware needs to write NVRAM, so give it a writable copy of the vars. + const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); + const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd"); + + // Capture the guest's serial0 (danos's machine-readable log) to the qemu-test + // scratch area — a dev/host artifact, kept out of the FHS boot volume we mount. + // (/var/log/system is reserved for the kernel's own logging system later.) One + // timestamped file per run. + const log_dir = b.fmt("{s}/qemu-test", .{b.install_path}); + const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir }); + + // --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF --- + const run_efi = b.addSystemCommand(&.{ + "qemu-system-x86_64", + "-device", + "qemu-xhci,id=xhci", + "-device", + "usb-mouse,bus=xhci.0", + "-device", + "usb-kbd,bus=xhci.0", + "-machine", + "q35", + "-m", + "128M", + "-drive", + b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), + }); + run_efi.addArg("-drive"); + run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); + // Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as + // the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots. + // The serial-enabled variant, so serial0 carries the log for this dev boot. + run_efi.addArg("-drive"); + run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); + run_efi.addArgs(&.{ + "-device", + "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", + "-net", + "none", + // Emulated display advertising 1280x720 as its native (EDID preferred) + // resolution, so the kernel's native-resolution switch has something to + // find. `-vga none` avoids a second, default adapter. + "-vga", + "none", + "-device", + "VGA,edid=on,xres=1280,yres=720", + }); + const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) }); + run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) }); + // We boot the self-contained `fat_image_serial` (added as a file arg above, so + // it's already a dependency) — not the installed FHS zig-out — so `run-x86-64` + // builds only the serial kernel, never the flashable one. Just make the serial + // scratch dir first. + run_efi.step.dependOn(&make_log_dir.step); + + const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-.log"); + run_efi_step.dependOn(&run_efi.step); + + // --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter --- + // The VGA device still supplies the boot (GOP) framebuffer the compositor starts + // on; the virtio-gpu function is discovered by the device-manager stack, its + // driver announces a shared scanout, and the compositor upgrades off the GOP + // floor to fenced, tear-free native presents (docs/display-v2.md). + // This is the interactive twin of the `display-native` test case, and 512M + // matches it (the whole driver stack + the compositor's surfaces at once). + // QEMU shows one head per adapter: pick the virtio-gpu head in the View menu + // to watch the native output. + const run_gpu = b.addSystemCommand(&.{ + "qemu-system-x86_64", + "-device", + "qemu-xhci,id=xhci", + "-device", + "usb-mouse,bus=xhci.0", + "-device", + "usb-kbd,bus=xhci.0", + "-machine", + "q35", + "-m", + "512M", + "-drive", + b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), + }); + run_gpu.addArg("-drive"); + run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); + run_gpu.addArg("-drive"); + run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); + run_gpu.addArgs(&.{ + "-device", + "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", + "-net", + "none", + "-vga", + "none", + "-device", + "VGA,edid=on,xres=1280,yres=720", + "-device", + "virtio-gpu-pci", + }); + const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) }); + run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) }); + run_gpu.step.dependOn(&make_log_dir.step); + + const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu"); + run_gpu_step.dependOn(&run_gpu.step); +} + +/// Return the first path in `candidates` that exists on the build host, else the +/// first candidate as a fallback so a missing-firmware error still names a +/// concrete (and, by convention, the primary) path. Used to locate OVMF firmware +/// across distro/OS layouts without configuration. +fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 { + for (candidates) |path| { + std.Io.Dir.accessAbsolute(io, path, .{}) catch continue; + return path; + } + return candidates[0]; +} + +/// A UTC timestamp like "20260708-153045", for naming a per-run artifact so +/// repeated runs don't clobber each other's logs. Resolved when `zig build` +/// runs, which is moments before QEMU launches. +fn timestamp(b: *std.Build) []const u8 { + const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds; + const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s)); + const es = std.time.epoch.EpochSeconds{ .secs = secs }; + const yd = es.getEpochDay().calculateYearDay(); + const md = yd.calculateMonthDay(); + const ds = es.getDaySeconds(); + return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ + yd.year, + md.month.numeric(), + @as(u32, md.day_index) + 1, + ds.getHoursIntoDay(), + ds.getMinutesIntoHour(), + ds.getSecondsIntoMinute(), + }); +}