max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 files changed,
+287 insertions,
-43 deletions
| @@ -297,6 +297,19 @@ | |||
| 297 | 297 | # ===================================================================== | |
| 298 | 298 | RUN systemctl preset-all | |
| 299 | 299 | ||
| 300 | + | # Template instances have to be enabled by name. `preset-all` iterates over | |
| 301 | + | # the unit *files* that exist, and `alloy-debug-shell@.service` is a template | |
| 302 | + | # with no instance of its own, so a preset line naming `@tty9` matches nothing | |
| 303 | + | # and is silently ignored: no symlink, no unit, and a kernel flag that appears | |
| 304 | + | # to do nothing at all. Found by booting with `alloy.debug` set and getting a | |
| 305 | + | # plain login prompt. | |
| 306 | + | # | |
| 307 | + | # Both instances are inert unless the kernel command line carries | |
| 308 | + | # `alloy.debug` (see the unit's ConditionKernelCommandLine), which only the | |
| 309 | + | # installer ISO's verbose entries set, so enabling them in every image is | |
| 310 | + | # safe. | |
| 311 | + | RUN systemctl enable alloy-debug-shell@tty9.service alloy-debug-shell@ttyS0.service | |
| 312 | + | ||
| 300 | 313 | # ===================================================================== | |
| 301 | 314 | # Branding | |
| 302 | 315 | # ===================================================================== |
| @@ -11,7 +11,7 @@ | |||
| 11 | 11 | # | |
| 12 | 12 | # Shape of the result: | |
| 13 | 13 | # LiveOS/squashfs.img the Alloy image, as the live system | |
| 14 | - | # source/alloy.oci the same image again, as what gets installed | |
| 14 | + | # source/alloy/ the same image again, as what gets installed | |
| 15 | 15 | # EFI/BOOT + boot/ shim, grub, kernel, live initramfs | |
| 16 | 16 | # | |
| 17 | 17 | # The live system runs alloy-installer.service, which is inert unless the | |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | # Usage: | |
| 22 | 22 | # build/build-iso.sh # build image, then ISO | |
| 23 | 23 | # build/build-iso.sh --skip-build # reuse the current image | |
| 24 | - | # build/build-iso.sh --skip-source # omit the oci-archive (faster; the | |
| 24 | + | # build/build-iso.sh --skip-source # omit the OCI layout (faster; the | |
| 25 | 25 | # # ISO boots but cannot install) | |
| 26 | 26 | # build/build-iso.sh --fast # iteration: cheap compression, keeps source | |
| 27 | 27 | # build/build-iso.sh --fast --skip-source # boot chain only, cannot install | |
| @@ -84,9 +84,19 @@ | |||
| 84 | 84 | fi | |
| 85 | 85 | sudo mkdir -p "$OUTPUT" "$WORKDIR/source" | |
| 86 | 86 | ||
| 87 | - | # 4. The image to install, as an oci-archive. skopeo rather than `podman | |
| 87 | + | # 4. The image to install, as an OCI layout. skopeo rather than `podman | |
| 88 | 88 | # save` because bootc reads skopeo transports, and this is the exact | |
| 89 | 89 | # format --source-imgref will be handed. | |
| 90 | + | # | |
| 91 | + | # A layout (`oci:`) and not an archive (`oci-archive:`). An archive is a | |
| 92 | + | # tarball, and the containers stack cannot read a tarball in place: it | |
| 93 | + | # untars the whole image into /var/tmp before reading the manifest, and | |
| 94 | + | # on the live medium /var/tmp is RAM. Against this image that fails with | |
| 95 | + | # `untarring file "/var/tmp/container_images_oci...": write ...` *after* | |
| 96 | + | # the installer has wiped and partitioned the target disk, which is the | |
| 97 | + | # worst possible moment. Reproduced in a VM at 4 GB and again at 8 GB, so | |
| 98 | + | # it is not about the machine being small. A layout is a directory and is | |
| 99 | + | # read where it lies. Same bytes on the ISO, no temp space. | |
| 90 | 100 | if [ "$SKIP_SOURCE" -eq 0 ]; then | |
| 91 | 101 | say "exporting the install source (several GB, and slow)" | |
| 92 | 102 | # skopeo from the builder rather than the host: this box is Pop!_OS and | |
| @@ -99,7 +109,7 @@ | |||
| 99 | 109 | -v "$WORKDIR/source":/source \ | |
| 100 | 110 | --entrypoint skopeo \ | |
| 101 | 111 | "$BUILDER" \ | |
| 102 | - | copy "containers-storage:$IMAGE" "oci-archive:/source/alloy.oci:alloy:local" | |
| 112 | + | copy "containers-storage:$IMAGE" "oci:/source/alloy:local" | |
| 103 | 113 | else | |
| 104 | 114 | say "skipping the install source" | |
| 105 | 115 | fi |
| @@ -6,10 +6,10 @@ | |||
| 6 | 6 | # Expects: | |
| 7 | 7 | # /rootfs the Alloy image's root filesystem, read-only | |
| 8 | 8 | # /output where install.iso is written | |
| 9 | - | # /source an oci-archive of the Alloy image, copied onto the ISO | |
| 9 | + | # /source an OCI layout of the Alloy image, copied onto the ISO | |
| 10 | 10 | # | |
| 11 | 11 | # The ISO carries two copies of Alloy for two different jobs. The squashfs | |
| 12 | - | # is the live system the installer runs in. The oci-archive is what gets | |
| 12 | + | # is the live system the installer runs in. The OCI layout is what gets | |
| 13 | 13 | # deployed, because `bootc install` installs a container image and cannot | |
| 14 | 14 | # install a running squashfs. They are the same content and the ISO pays | |
| 15 | 15 | # for it twice; that is the cost of not needing a network to install. | |
| @@ -120,7 +120,7 @@ | |||
| 120 | 120 | mkdir -p "$WORK/iso/source" | |
| 121 | 121 | cp -a "$SOURCE/." "$WORK/iso/source/" | |
| 122 | 122 | else | |
| 123 | - | echo "warning: no oci-archive at $SOURCE; the ISO will have nothing to install" >&2 | |
| 123 | + | echo "warning: no OCI layout at $SOURCE; the ISO will have nothing to install" >&2 | |
| 124 | 124 | fi | |
| 125 | 125 | ||
| 126 | 126 | # --------------------------------------------------------------------- | |
| @@ -139,7 +139,7 @@ | |||
| 139 | 139 | # monitor would sit black. The installer renders on tty1 explicitly | |
| 140 | 140 | # (alloy-installer.service, TTYPath) and does not depend on this ordering. | |
| 141 | 141 | CONSOLES="console=ttyS0,115200 console=tty0" | |
| 142 | - | # selinux=0 on the live medium. | |
| 142 | + | # SELinux on the live medium: permissive by default, off as a fallback. | |
| 143 | 143 | # | |
| 144 | 144 | # The live rootfs.img is built by mkfs.ext4 from a container rootfs, and it | |
| 145 | 145 | # carries SELinux labels only if the machine doing the building has SELinux | |
| @@ -148,13 +148,33 @@ | |||
| 148 | 148 | # allocate manager object: Permission denied", then it freezes, with no | |
| 149 | 149 | # hint that labelling is what is missing. | |
| 150 | 150 | # | |
| 151 | - | # This is a real limitation of the ISO, not just of the live boot: an | |
| 152 | - | # installer that cannot label cannot label the target either. Building on a | |
| 153 | - | # Fedora host is what fixes it properly. Until then the ISO installs | |
| 154 | - | # systems that need `enforcing=0` to boot, which is recorded in the wiki | |
| 155 | - | # note alloy-build-notes. | |
| 151 | + | # `selinux=0` cures the freeze and costs the install. A process running | |
| 152 | + | # with SELinux switched off cannot write labels to anything, the target | |
| 153 | + | # disk included, so the machine this ISO installs comes up unlabeled and | |
| 154 | + | # freezes the same way on its own first boot, with no way past it but | |
| 155 | + | # editing the kernel line at GRUB by hand. | |
| 156 | + | # | |
| 157 | + | # `enforcing=0` is the setting between the two: the policy in the image at | |
| 158 | + | # /etc/selinux/targeted/policy loads, unlabeled files stop being fatal, and | |
| 159 | + | # the kernel still tracks labels. That is enough for the live system to | |
| 160 | + | # boot and, unlike selinux=0, leaves `bootc install` able to label the | |
| 161 | + | # target as it writes it. An install from the permissive entry should come | |
| 162 | + | # up enforcing and correct on its first boot, with no relabel pass and no | |
| 163 | + | # manual step. | |
| 164 | + | # | |
| 165 | + | # Should, because it is untested until this ISO boots on real hardware, | |
| 166 | + | # which is why both entries ship. Boot the default; if the live system | |
| 167 | + | # freezes before the installer draws, the SELinux-off entry is exactly the | |
| 168 | + | # medium that was known to work, at the cost of an install that needs | |
| 169 | + | # `enforcing=0` typed at GRUB on first boot. Recorded in the wiki note | |
| 170 | + | # alloy-build-notes. | |
| 171 | + | # | |
| 172 | + | # Building on a Fedora host removes the question entirely by shipping a | |
| 173 | + | # labeled live root. That is a build-host project, not a boot flag. | |
| 174 | + | SELINUX_PERMISSIVE="enforcing=0" | |
| 156 | 175 | SELINUX_OFF="selinux=0" | |
| 157 | - | CMDLINE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES $SELINUX_OFF quiet loglevel=3" | |
| 176 | + | LIVE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES" | |
| 177 | + | CMDLINE="$LIVE $SELINUX_PERMISSIVE quiet loglevel=3" | |
| 158 | 178 | ||
| 159 | 179 | cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF | |
| 160 | 180 | set default=0 | |
| @@ -165,8 +185,25 @@ | |||
| 165 | 185 | initrd /boot/initramfs.img | |
| 166 | 186 | } | |
| 167 | 187 | ||
| 168 | - | menuentry "Install Alloy (verbose, for diagnosing a failed boot)" { | |
| 169 | - | linux /boot/vmlinuz root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES $SELINUX_OFF rd.debug rd.shell systemd.log_level=debug | |
| 188 | + | # The fallback, for a live system that freezes before the installer draws. | |
| 189 | + | # Installs a machine that needs enforcing=0 on its own first boot; see the | |
| 190 | + | # SELinux note above. | |
| 191 | + | menuentry "Install Alloy (SELinux off, if the default will not boot)" { | |
| 192 | + | linux /boot/vmlinuz $LIVE $SELINUX_OFF quiet loglevel=3 | |
| 193 | + | initrd /boot/initramfs.img | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | # The verbose entries also arm alloy-debug-shell@.service, which puts a root | |
| 197 | + | # shell on tty2 and on the serial console. The live medium has no account and | |
| 198 | + | # a locked root, so without this a failure inside the live system can only be | |
| 199 | + | # read off whatever the installer printed before it stopped. | |
| 200 | + | menuentry "Install Alloy (verbose, with a root shell on tty9)" { | |
| 201 | + | linux /boot/vmlinuz $LIVE $SELINUX_PERMISSIVE alloy.debug rd.debug rd.shell systemd.log_level=debug | |
| 202 | + | initrd /boot/initramfs.img | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | menuentry "Install Alloy (SELinux off, verbose, with a root shell on tty9)" { | |
| 206 | + | linux /boot/vmlinuz $LIVE $SELINUX_OFF alloy.debug rd.debug rd.shell systemd.log_level=debug | |
| 170 | 207 | initrd /boot/initramfs.img | |
| 171 | 208 | } | |
| 172 | 209 | ||
| @@ -231,8 +268,15 @@ | |||
| 231 | 268 | # legacy BIOS path, so a hybrid MBR would advertise a boot that fails. | |
| 232 | 269 | # --------------------------------------------------------------------- | |
| 233 | 270 | say "xorriso" | |
| 271 | + | # -rock is not decoration here. An OCI layout stores every blob under a | |
| 272 | + | # 64-character hex filename, and plain ISO 9660 truncates names to 31, so | |
| 273 | + | # without Rock Ridge the layout arrives on the medium renamed and the | |
| 274 | + | # install fails looking like a corrupt image rather than a naming problem. | |
| 275 | + | # The squashfs and the boot files have short names and never needed it, | |
| 276 | + | # which is why it was absent until the install source became a directory. | |
| 234 | 277 | xorriso -as mkisofs \ | |
| 235 | 278 | -iso-level 3 \ | |
| 279 | + | -rock \ | |
| 236 | 280 | -volid "$VOLID" \ | |
| 237 | 281 | -appid "Alloy Installer" \ | |
| 238 | 282 | -eltorito-alt-boot \ |
| @@ -289,6 +289,51 @@ | |||
| 289 | 289 | .arg(disk) | |
| 290 | 290 | } | |
| 291 | 291 | ||
| 292 | + | /// Clear the read-only flag on a block device. | |
| 293 | + | /// | |
| 294 | + | /// Immediately after `bootc install to-disk` completes, the target's root | |
| 295 | + | /// partition reads as write-protected, and mounting it gives | |
| 296 | + | /// "WARNING: source write-protected, mounted read-only" followed by every | |
| 297 | + | /// configure command failing with `Read-only file system`. The install itself | |
| 298 | + | /// is fine: the same disk, examined from another machine afterwards, reports | |
| 299 | + | /// `blockdev --getro` of 0 and mounts read-write. | |
| 300 | + | /// | |
| 301 | + | /// So this clears a flag that is set in the live session and does not outlive | |
| 302 | + | /// it. What sets it has not been identified; the candidates are bootc's own | |
| 303 | + | /// finalize and a stale flag left by the partition table re-read. That is why | |
| 304 | + | /// the mount is verified afterwards rather than trusted: if the cause turns | |
| 305 | + | /// out to be something this does not address, the install should say so | |
| 306 | + | /// rather than proceed to write an account into a read-only filesystem and | |
| 307 | + | /// report success. | |
| 308 | + | fn clear_read_only(device: &str) -> Invocation { | |
| 309 | + | Invocation::new("blockdev").args(["--setrw", device]) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// Ask findmnt for the mount options in effect at `mount`. | |
| 313 | + | fn mount_options(mount: &str) -> Invocation { | |
| 314 | + | Invocation::new("findmnt").args(["-no", "OPTIONS", mount]) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Fail unless the mount options say the filesystem is writable. | |
| 318 | + | /// | |
| 319 | + | /// `mount` reports a read-only fallback as a warning and exits 0, so the | |
| 320 | + | /// sequence would otherwise carry on and fail later, in the middle of | |
| 321 | + | /// `useradd`, about something that sounds unrelated. Checking here turns that | |
| 322 | + | /// into one sentence naming what is wrong. | |
| 323 | + | fn writable_mount(options: &str) -> Result<(), String> { | |
| 324 | + | options | |
| 325 | + | .split(',') | |
| 326 | + | .any(|option| option.trim() == "rw") | |
| 327 | + | .then_some(()) | |
| 328 | + | .ok_or_else(|| { | |
| 329 | + | format!( | |
| 330 | + | "{TARGET_MOUNT} mounted read-only (options: {}); \ | |
| 331 | + | the target cannot be configured", | |
| 332 | + | options.trim() | |
| 333 | + | ) | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | ||
| 292 | 337 | /// Pick the root partition out of an `lsblk -o PATH,PARTTYPE` listing. | |
| 293 | 338 | /// | |
| 294 | 339 | /// Matching on the DPS type GUID rather than on position or size: "the third | |
| @@ -598,29 +643,46 @@ | |||
| 598 | 643 | ||
| 599 | 644 | /// Where the installer ISO carries the image it installs. | |
| 600 | 645 | /// | |
| 601 | - | /// dmsquash-live mounts the medium here, and build/make-iso.sh puts the | |
| 602 | - | /// oci-archive at `source/` on it. The two are a pair: renaming either | |
| 603 | - | /// leaves an installer that runs and cannot install. | |
| 604 | - | const LIVE_SOURCE: &str = "/run/initramfs/live/source/alloy.oci"; | |
| 646 | + | /// dmsquash-live mounts the medium here, and build/make-iso.sh puts the OCI | |
| 647 | + | /// layout at `source/` on it. The two are a pair: renaming either leaves an | |
| 648 | + | /// installer that runs and cannot install. | |
| 649 | + | const LIVE_SOURCE: &str = "/run/initramfs/live/source/alloy"; | |
| 650 | + | ||
| 651 | + | /// The tag the layout is written under, by build/build-iso.sh. | |
| 652 | + | /// | |
| 653 | + | /// A layout can hold more than one image, so the reference names which one | |
| 654 | + | /// even though Alloy's holds exactly one. Naming it is what keeps this | |
| 655 | + | /// working the day that stops being true. | |
| 656 | + | const LIVE_SOURCE_TAG: &str = "local"; | |
| 605 | 657 | ||
| 606 | 658 | /// The `--source-imgref` for this run, or `None` when bootc's own default | |
| 607 | 659 | /// applies. | |
| 608 | 660 | /// | |
| 609 | 661 | /// Returning an owned skopeo reference rather than a bare path because the | |
| 610 | 662 | /// transport is part of the answer: bootc reads skopeo formats, and an | |
| 611 | - | /// `oci-archive:` prefix is what makes a file on the medium a thing it can | |
| 663 | + | /// `oci:` prefix is what makes a directory on the medium a thing it can | |
| 612 | 664 | /// deploy from. | |
| 613 | 665 | fn live_source_image() -> Option<String> { | |
| 614 | 666 | source_image_at(std::path::Path::new(LIVE_SOURCE)) | |
| 615 | 667 | } | |
| 616 | 668 | ||
| 617 | - | /// The reference for an archive at `path`, if it is there. | |
| 669 | + | /// The reference for an OCI layout at `path`, if it is there. | |
| 618 | 670 | /// | |
| 619 | 671 | /// Split from [`live_source_image`] so the transport prefix can be tested | |
| 620 | 672 | /// without an installer medium to hand. | |
| 673 | + | /// | |
| 674 | + | /// `oci:` and not `oci-archive:`, and the difference is the whole install. | |
| 675 | + | /// An archive is a tarball, which the containers stack cannot read in place: | |
| 676 | + | /// before it can fetch even the manifest it untars the entire image into | |
| 677 | + | /// `/var/tmp`, which on a live medium is RAM. Against a 2.6 GB image that | |
| 678 | + | /// fails partway through with | |
| 679 | + | /// `untarring file "/var/tmp/container_images_oci...": write ...`, after the | |
| 680 | + | /// disk has already been wiped and partitioned. Observed in a VM at both 4 | |
| 681 | + | /// and 8 GB of RAM, so it is not a matter of giving the machine more. A | |
| 682 | + | /// layout is a directory, read where it lies, with no temp space involved. | |
| 621 | 683 | fn source_image_at(path: &std::path::Path) -> Option<String> { | |
| 622 | 684 | path.exists() | |
| 623 | - | .then(|| format!("oci-archive:{}", path.display())) | |
| 685 | + | .then(|| format!("oci:{}:{LIVE_SOURCE_TAG}", path.display())) | |
| 624 | 686 | } | |
| 625 | 687 | ||
| 626 | 688 | /// The whole install, as stages. | |
| @@ -633,6 +695,7 @@ | |||
| 633 | 695 | let hostname = hostname.to_string(); | |
| 634 | 696 | let username = username.to_string(); | |
| 635 | 697 | let password = password.to_string(); | |
| 698 | + | let target_disk = disk.to_string(); | |
| 636 | 699 | ||
| 637 | 700 | vec![ | |
| 638 | 701 | // --wipe is explicit rather than implied by the confirm the user just | |
| @@ -644,7 +707,7 @@ | |||
| 644 | 707 | // be running inside the one it is installing. On the ISO it is | |
| 645 | 708 | // not: it runs in the live system, which is a squashfs, and a | |
| 646 | 709 | // squashfs is not a container image. So the image travels | |
| 647 | - | // separately as an oci-archive and is named here. | |
| 710 | + | // separately as an OCI layout and is named here. | |
| 648 | 711 | // | |
| 649 | 712 | // Detected rather than configured, because the same binary runs | |
| 650 | 713 | // in both places. Present means booted from the installer | |
| @@ -672,19 +735,38 @@ | |||
| 672 | 735 | Stage::Resolve { | |
| 673 | 736 | invocation: partition_types(disk), | |
| 674 | 737 | then: Box::new(move |listing| { | |
| 675 | - | let partition = root_partition(listing)?; | |
| 738 | + | let partition = root_partition(listing)?.to_string(); | |
| 676 | 739 | Ok(vec![ | |
| 677 | - | Stage::Run(Invocation::new("mount").arg(partition).arg(TARGET_MOUNT)), | |
| 678 | - | // Second discovery, only possible once mounted: where the | |
| 679 | - | // deployment actually is inside the sysroot. | |
| 740 | + | // Both the disk and the partition: the flag can sit on | |
| 741 | + | // either, and a partition inherits its disk's. | |
| 742 | + | Stage::Run(clear_read_only(&target_disk)), | |
| 743 | + | Stage::Run(clear_read_only(&partition)), | |
| 744 | + | Stage::Run( | |
| 745 | + | Invocation::new("mount") | |
| 746 | + | .args(["-o", "rw"]) | |
| 747 | + | .arg(&partition) | |
| 748 | + | .arg(TARGET_MOUNT), | |
| 749 | + | ), | |
| 750 | + | // Prove it took. `mount` warns and exits 0 when it falls | |
| 751 | + | // back to read-only, so success here is not evidence. | |
| 680 | 752 | Stage::Resolve { | |
| 681 | - | invocation: deployment_dir(TARGET_MOUNT), | |
| 682 | - | then: Box::new(move |printed| { | |
| 683 | - | let deployment = printed.trim(); | |
| 684 | - | if deployment.is_empty() { | |
| 685 | - | return Err("ostree reported no current deployment".into()); | |
| 686 | - | } | |
| 687 | - | configure_plan(&hostname, &username, &password, deployment) | |
| 753 | + | invocation: mount_options(TARGET_MOUNT), | |
| 754 | + | then: Box::new(move |options| { | |
| 755 | + | writable_mount(options)?; | |
| 756 | + | // Second discovery, only possible once mounted: | |
| 757 | + | // where the deployment is inside the sysroot. | |
| 758 | + | Ok(vec![Stage::Resolve { | |
| 759 | + | invocation: deployment_dir(TARGET_MOUNT), | |
| 760 | + | then: Box::new(move |printed| { | |
| 761 | + | let deployment = printed.trim(); | |
| 762 | + | if deployment.is_empty() { | |
| 763 | + | return Err( | |
| 764 | + | "ostree reported no current deployment".into() | |
| 765 | + | ); | |
| 766 | + | } | |
| 767 | + | configure_plan(&hostname, &username, &password, deployment) | |
| 768 | + | }), | |
| 769 | + | }]) | |
| 688 | 770 | }), | |
| 689 | 771 | }, | |
| 690 | 772 | ]) | |
| @@ -2478,23 +2560,59 @@ | |||
| 2478 | 2560 | #[test] | |
| 2479 | 2561 | fn a_source_image_is_named_with_its_transport() { | |
| 2480 | 2562 | let dir = std::env::temp_dir().join("alloy-source-test"); | |
| 2563 | + | let layout = dir.join("alloy"); | |
| 2564 | + | std::fs::create_dir_all(&layout).unwrap(); | |
| 2565 | + | ||
| 2566 | + | let named = source_image_at(&layout).expect("a layout that is there is named"); | |
| 2567 | + | assert!(named.starts_with("oci:"), "{named}"); | |
| 2568 | + | assert!(named.ends_with(":local"), "{named}"); | |
| 2569 | + | ||
| 2570 | + | std::fs::remove_dir_all(&dir).unwrap(); | |
| 2571 | + | } | |
| 2572 | + | ||
| 2573 | + | // `oci-archive:` is the shape this must never go back to: it untars the | |
| 2574 | + | // image into the live system's RAM-backed /var/tmp and dies there, after | |
| 2575 | + | // the target disk has been wiped. See `source_image_at`. | |
| 2576 | + | #[test] | |
| 2577 | + | fn a_source_image_is_never_an_archive() { | |
| 2578 | + | let dir = std::env::temp_dir().join("alloy-source-transport-test"); | |
| 2481 | 2579 | std::fs::create_dir_all(&dir).unwrap(); | |
| 2482 | - | let archive = dir.join("alloy.oci"); | |
| 2483 | - | std::fs::write(&archive, b"not really an archive").unwrap(); | |
| 2484 | 2580 | ||
| 2485 | - | let named = source_image_at(&archive).expect("an archive that is there is named"); | |
| 2486 | - | assert!(named.starts_with("oci-archive:"), "{named}"); | |
| 2487 | - | assert!(named.ends_with("alloy.oci"), "{named}"); | |
| 2581 | + | let named = source_image_at(&dir).expect("a layout that is there is named"); | |
| 2582 | + | assert!(!named.starts_with("oci-archive:"), "{named}"); | |
| 2488 | 2583 | ||
| 2489 | - | std::fs::remove_file(&archive).unwrap(); | |
| 2584 | + | std::fs::remove_dir_all(&dir).unwrap(); | |
| 2585 | + | } | |
| 2586 | + | ||
| 2587 | + | #[test] | |
| 2588 | + | fn a_writable_mount_passes_the_check() { | |
| 2589 | + | assert!(writable_mount("rw,relatime,attr2,inode64,logbufs=8").is_ok()); | |
| 2590 | + | } | |
| 2591 | + | ||
| 2592 | + | // The failure this exists to catch: `mount` falls back to read-only, warns, | |
| 2593 | + | // and exits 0, so without this the sequence runs on and dies inside | |
| 2594 | + | // useradd instead. | |
| 2595 | + | #[test] | |
| 2596 | + | fn a_read_only_mount_is_a_named_failure() { | |
| 2597 | + | let error = writable_mount("ro,relatime,attr2,inode64").unwrap_err(); | |
| 2598 | + | assert!(error.contains("read-only"), "{error}"); | |
| 2599 | + | assert!(error.contains(TARGET_MOUNT), "{error}"); | |
| 2600 | + | } | |
| 2601 | + | ||
| 2602 | + | // `rw` has to be a whole option, not a substring: "nosuid,rwtest" is not | |
| 2603 | + | // a writable mount, and neither is anything else that merely contains the | |
| 2604 | + | // letters. | |
| 2605 | + | #[test] | |
| 2606 | + | fn a_substring_is_not_a_writable_mount() { | |
| 2607 | + | assert!(writable_mount("ro,relatime,rwtest").is_err()); | |
| 2490 | 2608 | } | |
| 2491 | 2609 | ||
| 2492 | 2610 | // Absent means running inside a container during development, where | |
| 2493 | 2611 | // bootc installs the image it is running and needs no help. | |
| 2494 | 2612 | #[test] | |
| 2495 | 2613 | fn no_source_image_means_bootc_uses_its_own_default() { | |
| 2496 | - | let missing = std::env::temp_dir().join("alloy-no-such-archive.oci"); | |
| 2497 | - | let _ = std::fs::remove_file(&missing); | |
| 2614 | + | let missing = std::env::temp_dir().join("alloy-no-such-layout"); | |
| 2615 | + | let _ = std::fs::remove_dir_all(&missing); | |
| 2498 | 2616 | assert_eq!(source_image_at(&missing), None); | |
| 2499 | 2617 | } | |
| 2500 | 2618 |
| @@ -20,5 +20,10 @@ | |||
| 20 | 20 | # the installer ISO sets, so enabling it on every install is safe and keeps | |
| 21 | 21 | # the live medium from needing a modified copy of the image. | |
| 22 | 22 | enable alloy-installer.service | |
| 23 | + | # alloy-debug-shell@ is deliberately not listed here. It is a template, and | |
| 24 | + | # `systemctl preset-all` cannot instantiate one from a preset line: a line | |
| 25 | + | # naming `alloy-debug-shell@tty9.service` matches no unit file and is ignored | |
| 26 | + | # without a word, which reads exactly like the feature being broken. Its | |
| 27 | + | # instances are enabled by name in the Containerfile instead. | |
| 23 | 28 | ||
| 24 | 29 | disable tailscaled.service |
| @@ -1,0 +1,54 @@ | |||
| 1 | + | # A root shell on %I, for diagnosing a live medium that will not finish. | |
| 2 | + | # | |
| 3 | + | # Gated on `alloy.debug` the same way alloy-installer.service is gated on | |
| 4 | + | # `alloy.installer`: the live system and the installed system are the same | |
| 5 | + | # image, so a unit that could start on an installed machine would be a root | |
| 6 | + | # shell with no password on every Alloy in the world. Only the verbose GRUB | |
| 7 | + | # entries in build/make-iso.sh set the flag, and an installed system's kernel | |
| 8 | + | # arguments come from the image, not from those entries. | |
| 9 | + | # | |
| 10 | + | # It exists because the live medium is otherwise unreachable. There is no | |
| 11 | + | # account on it (the installer creates one on the target, not here), root is | |
| 12 | + | # locked, so a failure inside the live system can only be read off whatever | |
| 13 | + | # the installer happened to print. Diagnosing the read-only mount that | |
| 14 | + | # blocked the configure step meant a full ISO rebuild per question. | |
| 15 | + | # | |
| 16 | + | # Instantiated for tty9 and ttyS0 by the preset: tty9 for a laptop with a | |
| 17 | + | # keyboard in front of it (Ctrl-Alt-F9), ttyS0 for a VM, where the serial | |
| 18 | + | # console is a text log that can be read and searched instead of | |
| 19 | + | # photographed. | |
| 20 | + | # | |
| 21 | + | # tty9 rather than a low-numbered VT because logind auto-spawns a getty on | |
| 22 | + | # the first six VTs as soon as one is switched to, which takes the terminal | |
| 23 | + | # straight back off this unit and offers a login prompt for an account that | |
| 24 | + | # does not exist on a live medium. Observed on tty2; systemd's own | |
| 25 | + | # debug-shell.service picks tty9 for the same reason. | |
| 26 | + | [Unit] | |
| 27 | + | Description=Alloy debug shell on %I | |
| 28 | + | Documentation=https://git.sr.ht/~maxmj/alloy | |
| 29 | + | ConditionKernelCommandLine=alloy.debug | |
| 30 | + | After=systemd-user-sessions.service | |
| 31 | + | Before=getty.target | |
| 32 | + | # Whichever getty owns this terminal has to lose it, and which one that is | |
| 33 | + | # depends on the terminal: serial consoles get serial-getty@, VTs get getty@. | |
| 34 | + | # Naming both is how one template serves both without knowing which it is. | |
| 35 | + | Conflicts=getty@%i.service serial-getty@%i.service | |
| 36 | + | ||
| 37 | + | [Service] | |
| 38 | + | # Type=idle for the same reason the installer uses it: let the boot's own | |
| 39 | + | # output finish before taking the terminal. | |
| 40 | + | Type=idle | |
| 41 | + | ExecStart=/usr/bin/bash --login | |
| 42 | + | TTYPath=/dev/%I | |
| 43 | + | StandardInput=tty | |
| 44 | + | StandardOutput=tty | |
| 45 | + | StandardError=journal | |
| 46 | + | TTYReset=yes | |
| 47 | + | TTYVHangup=yes | |
| 48 | + | # A debug shell that does not come back when you exit it is a debug shell | |
| 49 | + | # you get to use once per boot. | |
| 50 | + | Restart=always | |
| 51 | + | RestartSec=1 | |
| 52 | + | ||
| 53 | + | [Install] | |
| 54 | + | WantedBy=multi-user.target |