Skip to main content

max / alloy

install: make an install from the ISO get as far as the account Found by driving the installer in a VM instead of on hardware, which is how three of these came to light in an afternoon rather than a trip each. The install source shipped as an oci-archive, and the containers stack cannot read a tarball in place: it untars the whole 2.6 GB image into the live system's RAM-backed /var/tmp before it can read even the manifest, and dies there, after the target disk has already been wiped and partitioned. It ships as an OCI layout now, read where it lies. Reproduced identically at 4 GB and 8 GB of RAM, so it was never about the machine being small. xorriso gained -rock alongside it, because a layout stores every blob under a 64-character hex filename and plain ISO 9660 truncates names to 31. The squashfs and boot files have short names and never needed it. The live medium boots enforcing=0 where it booted selinux=0. With SELinux switched off the installer cannot label the target either, so an install produced a machine that froze on its own first boot with nothing to do but edit the kernel line at GRUB by hand. Permissive loads the policy, tolerates the unlabeled live root, and leaves bootc able to label what it writes; the installed tree now carries real labels. The old entry stays in the menu as a fallback. With those, bootc install to-disk completes: 86 layers, bootloader, done. The target then mounts read-only, which stops the hostname and the account being written, and that is not fixed here. What is fixed is that the installer stops and says so. mount warns and exits 0 when it falls back to read-only, so the failure used to surface three commands later, inside useradd, sounding like something else. blockdev --setrw is attempted first and does not clear it; the cause is still unidentified. Adds a root shell on the live medium for that investigation, on tty9 and the serial console, gated on alloy.debug the way the installer is gated on alloy.installer. The live system has no account and root is locked, so until now a failure inside it could only be read off whatever the installer had managed to print. Its instances are enabled in the Containerfile because systemctl preset-all cannot instantiate a template from a preset line, and ignores one without a word.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-21 20:26 UTC
Signed with PGP, not checked
Commit: cb8cf57faf661abc1e2d9304df323ec72df5a1e2
Parent: d055400
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
M build/make-iso.sh +56 -12
@@ -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