Skip to main content

max / alloy

Let the install finish, and let the machine it makes take an update Two fixes to the same path, both found by installing from the ISO built this morning and watching it fail. The account could not be created at all. `7f6c8dc` added `video` to useradd's --groups so the brightness keys would work, and every install since has ended at useradd: group 'video' does not exist The image carries `video` in /usr/lib/group and not in /etc/group, and NSS is `group: files [SUCCESS=merge] altfiles`, so the two tools that could bridge that disagree about whether the group is there: `getent group video` answers `video:x:39:` by way of altfiles, `groupadd video` refuses with "already exists" and exit 9, and `useradd --groups video` refuses with "does not exist" — same machine, same second. Neither consults NSS for this and neither can be argued out of it, so the line is written into /etc/group directly, before useradd, with the GID copied out of /usr/lib/group rather than written as a literal 39 so it cannot drift from the number udev chgrp's the backlight to. The tests that shipped with the original could not have caught it: they asserted the argv string contained --groups wheel,video, which is true whether or not the group exists. The new fixture is the asymmetry itself, taken from the image, so a fixture that "fixed" it would stop proving anything. Same class as 55cfbfbd. A base image with no video anywhere resolves to no stages rather than aborting: the account is still made, still gets wheel, and only brightness is lost. Second, --target-imgref is now overridable by `alloy.update-target=` on the kernel command line, put there by `build/build-iso.sh --update-target`. The compiled-in default names a registry that does not exist yet, so every install needed a `bootc switch` before it could take an update at all. Media built against a registry that does exist now produces machines that update from it. An untagged target is refused rather than accepted, in the build script loudly and in the installer as a fallback, because the containers stack reads a missing tag as `latest` and docs/IMAGE.md makes a Fedora major bump a deliberate act. Splitting a reference on its first colon was already wrong and is now fixed: a registry that names a port carries two, so the old one-colon rule rejected host:5000/alloy:43 as malformed.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 13:54 UTC
Signed with PGP, not checked
Commit: 8e2e3bdda9fadef79ec0b9ca6481326124b0581a
Parent: 3311fe4
3 files changed, +327 insertions, -6 deletions
@@ -25,6 +25,7 @@
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
28 + # build/build-iso.sh --update-target host:5000/alloy:43 # updates come from there
28 29
29 30 set -euo pipefail
30 31
@@ -37,6 +38,9 @@
37 38 SKIP_BUILD=0
38 39 SKIP_SOURCE=0
39 40 FAST=0
41 + # Empty means the installer keeps its compiled-in default, the public registry.
42 + # See --update-target below.
43 + UPDATE_TARGET=""
40 44
41 45 die() { printf 'error: %s\n' "$*" >&2; exit 1; }
42 46 say() { printf '==> %s\n' "$*"; }
@@ -52,6 +56,14 @@
52 56 # at all. Pair with --skip-source when only the boot chain is in
53 57 # question.
54 58 --fast) SKIP_BUILD=1; FAST=1; shift ;;
59 + # Bakes `alloy.update-target=` into the ISO's GRUB entries, so machines
60 + # installed from this medium fetch updates from the named registry instead
61 + # of the compiled-in public one. The reason it exists: the public registry
62 + # does not exist yet, so without it every install needs a `bootc switch`
63 + # before it can take an update at all.
64 + --update-target)
65 + [ $# -ge 2 ] || die "--update-target needs a registry reference"
66 + UPDATE_TARGET="$2"; shift 2 ;;
55 67 -h|--help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
56 68 *) die "unknown argument: $1 (see --help)" ;;
57 69 esac
@@ -128,6 +140,7 @@
128 140 say "assembling the ISO"
129 141 sudo podman run --rm --privileged \
130 142 -e "ALLOY_ISO_FAST=$FAST" \
143 + -e "ALLOY_UPDATE_TARGET=$UPDATE_TARGET" \
131 144 --security-opt label=type:unconfined_t \
132 145 -v "$ROOTFS":/rootfs:ro \
133 146 -v "$OUTPUT":/output \
@@ -189,6 +189,35 @@
189 189 SELINUX_PERMISSIVE="enforcing=0"
190 190 SELINUX_OFF="selinux=0"
191 191 LIVE="root=live:CDLABEL=$VOLID rd.live.image rd.live.overlay.overlayfs=1 alloy.installer $CONSOLES"
192 +
193 + # Where machines installed from this ISO will fetch their updates.
194 + #
195 + # Empty for ordinary media, which leaves the installer on its compiled-in
196 + # default (the public registry). Set by `build/build-iso.sh --update-target`
197 + # for media built against a registry that exists today, so the machines it
198 + # installs can take an update without a `bootc switch` afterwards.
199 + #
200 + # Whitespace would split into a second kernel parameter and silently truncate
201 + # the reference, so it is refused rather than quoted: a reference cannot
202 + # legally contain any.
203 + if [ -n "${ALLOY_UPDATE_TARGET:-}" ]; then
204 + case "$ALLOY_UPDATE_TARGET" in
205 + *[[:space:]]*)
206 + echo "update target contains whitespace: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
207 + esac
208 + # A tag is required, because the containers stack reads its absence as
209 + # `latest` and docs/IMAGE.md makes a major bump a deliberate act. The tag is
210 + # what follows the last colon, and only when no `/` follows it: a colon
211 + # before a slash is a registry port, not a tag.
212 + case "${ALLOY_UPDATE_TARGET##*:}" in
213 + */*|"$ALLOY_UPDATE_TARGET")
214 + echo "update target has no tag: $ALLOY_UPDATE_TARGET" >&2; exit 1 ;;
215 + latest)
216 + echo "update target is :latest; pin a version" >&2; exit 1 ;;
217 + esac
218 + say "update target $ALLOY_UPDATE_TARGET"
219 + LIVE="$LIVE alloy.update-target=$ALLOY_UPDATE_TARGET"
220 + fi
192 221 CMDLINE="$LIVE $SELINUX_PERMISSIVE quiet loglevel=3"
193 222
194 223 cat > "$WORK/iso/boot/grub/grub.cfg" <<EOF
@@ -824,6 +824,58 @@
824 824 .exists()
825 825 }
826 826
827 + /// The group that owns the backlight once udev's rule has run.
828 + const VIDEO_GROUP: &str = "video";
829 +
830 + /// The GID `name` holds in a `group(5)` listing, if it holds one.
831 + ///
832 + /// Tolerant of the malformed line rather than failing on it: a `/etc/group`
833 + /// with one bad entry is still authoritative about every other entry, and this
834 + /// runs after the disk has been wiped.
835 + fn group_gid(listing: &str, name: &str) -> Option<u32> {
836 + listing
837 + .lines()
838 + .filter_map(|line| {
839 + let mut fields = line.split(':');
840 + let found = fields.next()?;
841 + let gid = fields.nth(1)?;
842 + (found == name).then_some(gid)
843 + })
844 + .find_map(|gid| gid.trim().parse().ok())
845 + }
846 +
847 + /// The line `/etc/group` needs before `useradd --groups video` will be obeyed.
848 + ///
849 + /// **The two tools that could have bridged this gap disagree about whether the
850 + /// group exists.** The image carries `video` in `/usr/lib/group` and not in
851 + /// `/etc/group`, and NSS is `group: files [SUCCESS=merge] altfiles [...]`, so
852 + /// `getent group video` answers `video:x:39:` by way of altfiles. Neither
853 + /// shadow-utils tool consults NSS for this: `groupadd video` refuses with
854 + /// "group 'video' already exists" and exit 9, while `useradd --groups video`
855 + /// refuses with "group 'video' does not exist", on the same machine in the same
856 + /// second. Neither can be argued out of its position, so the line is written
857 + /// directly and both are then satisfied.
858 + ///
859 + /// Measured, not reasoned. This is what failed the install on 2026-07-30, after
860 + /// `7f6c8dc` put `video` in `--groups` behind tests that only asserted the argv
861 + /// string and so could not have caught it.
862 + ///
863 + /// The GID is copied out of `/usr/lib/group` rather than written as a literal
864 + /// 39, because the number has to match the one udev's rule chgrp's the
865 + /// backlight to. A base image that renumbers it would otherwise leave the
866 + /// account in a group that no longer owns anything.
867 + ///
868 + /// `None` when there is nothing to do (the target already carries the group) or
869 + /// nothing to copy (the base image has no `video`, where inventing a GID would
870 + /// be worse than the brightness keys staying dead).
871 + fn video_group_line(etc_group: &str, lib_group: &str) -> Option<String> {
872 + if group_gid(etc_group, VIDEO_GROUP).is_some() {
873 + return None;
874 + }
875 + let gid = group_gid(lib_group, VIDEO_GROUP)?;
876 + Some(format!("{VIDEO_GROUP}:x:{gid}:\n"))
877 + }
878 +
827 879 /// The stage that sets the timezone from the network, if asked for.
828 880 ///
829 881 /// Empty when the box was not ticked, which is the whole of the opt-in: no
@@ -890,6 +942,9 @@
890 942 let ids_user = username.to_string();
891 943 let ids_home = home.clone();
892 944
945 + let group_root = root.to_string();
946 + let group_file = format!("{root}/etc/group");
947 +
893 948 let mut stages = vec![
894 949 // Before anything is written: a shell the target will not hand out
895 950 // makes an account nobody can log into, and useradd will not catch it.
@@ -909,6 +964,36 @@
909 964 // answer would be silently discarded.
910 965 .arg("--force"),
911 966 ),
967 + // Before useradd, because useradd is what rejects a group it cannot
968 + // find, and it rejects it by failing the install outright. See
969 + // [`video_group_line`] for why the group is missing from the file that
970 + // useradd reads while being present everywhere a person would look.
971 + //
972 + // Reading the file rather than asking getent, for the reason the uid
973 + // discovery below states: getent answers about this machine, and the
974 + // question is about the target. Here that distinction is the entire
975 + // bug rather than a precaution.
976 + Stage::Resolve {
977 + invocation: Invocation::new("cat").arg(format!("{group_root}/etc/group")),
978 + then: Box::new(move |etc_group| {
979 + // Absent or unreadable resolves the same way a missing `video`
980 + // does, to no stages: the account is still created, still gets
981 + // wheel, and still logs in. Only the brightness keys are lost,
982 + // which is the symptom this whole path exists to fix and not a
983 + // reason to abort an install whose disk is already gone.
984 + let lib_group = std::fs::read_to_string(format!("{group_root}/usr/lib/group"))
985 + .unwrap_or_default();
986 + let Some(line) = video_group_line(etc_group, &lib_group) else {
987 + return Ok(Vec::new());
988 + };
989 + Ok(vec![Stage::Run(
990 + Invocation::new("tee")
991 + .arg("-a")
992 + .arg(&group_file)
993 + .stdin(Secret::new(line)),
994 + )])
995 + }),
996 + },
912 997 // useradd rather than a sysusers entry because this account needs a home
913 998 // and supplementary groups. wheel is the group Fedora's polkit resolves
914 999 // an administrator to, which is what `run0` and every writing console
@@ -1100,6 +1185,61 @@
1100 1185 /// degrades to "updates can never work".
1101 1186 const UPDATE_IMAGE: &str = "quay.io/alloy/alloy:43";
1102 1187
1188 + /// The kernel command line parameter that overrides [`UPDATE_IMAGE`].
1189 + const UPDATE_TARGET_PARAM: &str = "alloy.update-target=";
1190 +
1191 + /// Split a registry reference into its name and its tag.
1192 + ///
1193 + /// **Not `split_once(':')`, and not a count of colons.** A reference to a
1194 + /// registry that names a port carries two of them
1195 + /// (`astra.example.ts.net:5000/alloy:43`), so splitting on the first lands
1196 + /// inside the host and counting rejects a perfectly good reference. The tag is
1197 + /// what follows the *last* colon, and only when no `/` follows it: a colon
1198 + /// before a slash is a port.
1199 + ///
1200 + /// `None` when the reference carries no tag at all.
1201 + fn split_tag(reference: &str) -> Option<(&str, &str)> {
1202 + let (name, tag) = reference.rsplit_once(':')?;
1203 + (!tag.contains('/')).then_some((name, tag))
1204 + }
1205 +
1206 + /// The update target named on `cmdline`, if it names one.
1207 + ///
1208 + /// Lets locally built media point its installs at a registry that exists, which
1209 + /// is the difference between a machine that can take an update today and one
1210 + /// that waits for [`UPDATE_IMAGE`] to be published. `build/build-iso.sh
1211 + /// --update-target` is what puts it there.
1212 + ///
1213 + /// An empty value is treated as absent rather than as a reference, so
1214 + /// `alloy.update-target=` with nothing after it falls back to the default
1215 + /// instead of writing an empty origin that nothing can parse. That is the
1216 + /// failure this whole constant exists to prevent.
1217 + ///
1218 + /// An untagged reference is refused for the same reason [`UPDATE_IMAGE`] pins
1219 + /// `:43`: the containers stack reads a missing tag as `latest`, and docs/IMAGE.md
1220 + /// makes a Fedora major bump a deliberate act rather than something that arrives
1221 + /// through the ordinary update channel. `build/build-iso.sh` rejects one loudly
1222 + /// at build time, so falling back here is the second line rather than the first.
1223 + fn update_target_in(cmdline: &str) -> Option<&str> {
1224 + cmdline
1225 + .split_ascii_whitespace()
1226 + .find_map(|word| word.strip_prefix(UPDATE_TARGET_PARAM))
1227 + .filter(|value| split_tag(value).is_some())
1228 + }
1229 +
1230 + /// Where the installed machine will fetch every update after this one.
1231 + ///
1232 + /// The command line wins over [`UPDATE_IMAGE`] so that an ISO built for a
1233 + /// tailnet registry produces machines that update from it, with no `bootc
1234 + /// switch` afterwards. An unreadable `/proc/cmdline` falls back rather than
1235 + /// failing: by the time this runs the disk is already committed.
1236 + fn update_image() -> String {
1237 + std::fs::read_to_string("/proc/cmdline")
1238 + .ok()
1239 + .and_then(|cmdline| update_target_in(&cmdline).map(str::to_string))
1240 + .unwrap_or_else(|| UPDATE_IMAGE.to_string())
1241 + }
1242 +
1103 1243 /// The `--source-imgref` for this run, or `None` when bootc's own default
1104 1244 /// applies.
1105 1245 ///
@@ -1173,7 +1313,12 @@
1173 1313 // container; where updates come from does not, and a machine
1174 1314 // installed during development wanting the same update stream as
1175 1315 // everyone else is correct rather than a side effect.
1176 - install = install.args(["--target-imgref", UPDATE_IMAGE]);
1316 + //
1317 + // The value is still overridable, but on the command line of the
1318 + // medium rather than by detection: media built for a tailnet
1319 + // registry install machines that update from it. See
1320 + // [`update_image`].
1321 + install = install.args(["--target-imgref", &update_image()]);
1177 1322 install.arg(disk)
1178 1323 }),
1179 1324 // bootc returns when the install is done, not when the kernel and
@@ -3442,6 +3587,72 @@
3442 3587 std::fs::remove_dir_all(&dir).unwrap();
3443 3588 }
3444 3589
3590 + // ---- the video group ----
3591 +
3592 + // The shape the image actually ships, trimmed to the entries that matter:
3593 + // wheel in both files, video in /usr/lib/group only. Copied from
3594 + // localhost/alloy:local on 2026-07-30 rather than invented, because the
3595 + // asymmetry between the two files IS the bug and a fixture that had video
3596 + // in both would pass while proving nothing.
3597 + const ETC_GROUP: &str = "root:x:0:\nbin:x:1:\nwheel:x:10:\n";
3598 + const LIB_GROUP: &str = "root:x:0:\nwheel:x:10:\nvideo:x:39:\naudio:x:63:\n";
3599 +
3600 + #[test]
3601 + fn the_video_group_is_copied_from_the_file_that_has_it() {
3602 + assert_eq!(
3603 + video_group_line(ETC_GROUP, LIB_GROUP).as_deref(),
3604 + Some("video:x:39:\n"),
3605 + );
3606 + }
3607 +
3608 + // The number is not a literal anywhere, because it has to agree with the
3609 + // GID udev chgrp's the backlight to. A renumbered base image must move the
3610 + // account with it rather than leave it in a group that owns nothing.
3611 + #[test]
3612 + fn the_gid_comes_from_the_image_and_is_not_assumed_to_be_39() {
3613 + let renumbered = "video:x:1039:\n";
3614 + assert_eq!(
3615 + video_group_line(ETC_GROUP, renumbered).as_deref(),
3616 + Some("video:x:1039:\n"),
3617 + );
3618 + }
3619 +
3620 + // Idempotent, so an image that starts shipping the group in /etc/group does
3621 + // not get a duplicate line whose GID could disagree with the first.
3622 + #[test]
3623 + fn a_target_that_already_has_the_group_is_left_alone() {
3624 + let already = format!("{ETC_GROUP}video:x:39:\n");
3625 + assert_eq!(video_group_line(&already, LIB_GROUP), None);
3626 + }
3627 +
3628 + // No video anywhere resolves to no stages rather than to an invented GID.
3629 + // The install still completes and the account still gets wheel.
3630 + #[test]
3631 + fn a_base_image_without_the_group_does_not_get_one_invented() {
3632 + assert_eq!(video_group_line(ETC_GROUP, ETC_GROUP), None);
3633 + }
3634 +
3635 + // The regression proper: useradd reads /etc/group and nothing else, so the
3636 + // line has to be in that file before it runs. Ordering, not just content.
3637 + #[test]
3638 + fn the_group_is_written_before_the_account_that_joins_it() {
3639 + let shown = configured();
3640 +
3641 + let group = shown
3642 + .iter()
3643 + .position(|line| line.contains("/etc/group"))
3644 + .expect("the plan reads the target's group file");
3645 + let useradd = shown
3646 + .iter()
3647 + .position(|line| line.contains("useradd"))
3648 + .expect("the plan creates the account");
3649 +
3650 + assert!(
3651 + group < useradd,
3652 + "the group file is settled at {group}, after useradd at {useradd}: {shown:#?}",
3653 + );
3654 + }
3655 +
3445 3656 // ---- timezone from location ----
3446 3657
3447 3658 /// A target root carrying one zone, so the availability check has something
@@ -3992,17 +4203,85 @@
3992 4203 !UPDATE_IMAGE.starts_with('/'),
3993 4204 "a path is what the bug wrote: {UPDATE_IMAGE}",
3994 4205 );
3995 - assert!(
3996 - !UPDATE_IMAGE.contains(':') || UPDATE_IMAGE.split(':').count() == 2,
3997 - "one colon, separating image from tag: {UPDATE_IMAGE}",
3998 - );
3999 - let (image, tag) = UPDATE_IMAGE.split_once(':').expect("a tag is pinned");
4206 + let (image, tag) = split_tag(UPDATE_IMAGE).expect("a tag is pinned");
4000 4207 assert!(image.contains('.'), "a registry host: {image}");
4001 4208 // Not `latest`: docs/IMAGE.md makes a Fedora major bump a deliberate act,
4002 4209 // and `latest` would deliver one through the ordinary update channel.
4003 4210 assert_ne!(tag, "latest", "a fresh install follows its own version");
4004 4211 }
4005 4212
4213 + // The tag splitter, against the reference shape that broke the rule this
4214 + // test replaced. A registry that names a port carries two colons, so
4215 + // splitting on the first lands inside the hostname and counting them
4216 + // rejects a reference that is entirely valid.
4217 + #[test]
4218 + fn a_registry_port_is_not_mistaken_for_a_tag() {
4219 + assert_eq!(
4220 + split_tag("astra.tailc6b3e1.ts.net:5000/alloy:43"),
4221 + Some(("astra.tailc6b3e1.ts.net:5000/alloy", "43")),
4222 + );
4223 + assert_eq!(
4224 + split_tag("quay.io/alloy/alloy:43"),
4225 + Some(("quay.io/alloy/alloy", "43"))
4226 + );
4227 + // A port and no tag is not a tag. Reading `5000/alloy` as one is exactly
4228 + // the mistake `split_once` makes here.
4229 + assert_eq!(split_tag("astra.tailc6b3e1.ts.net:5000/alloy"), None);
4230 + assert_eq!(split_tag("alloy"), None);
4231 + }
4232 +
4233 + // ---- the update target on the command line ----
4234 +
4235 + #[test]
4236 + fn the_command_line_can_name_where_updates_come_from() {
4237 + let cmdline = "root=live:CDLABEL=ALLOY rd.live.image alloy.installer \
4238 + alloy.update-target=astra.tailc6b3e1.ts.net:5000/alloy:43 quiet";
4239 + assert_eq!(
4240 + update_target_in(cmdline),
4241 + Some("astra.tailc6b3e1.ts.net:5000/alloy:43"),
4242 + );
4243 + }
4244 +
4245 + // The default is what an ordinary install medium produces, and it has to
4246 + // survive a command line that mentions neither the parameter nor anything
4247 + // resembling it.
4248 + #[test]
4249 + fn a_command_line_without_the_parameter_keeps_the_default() {
4250 + assert_eq!(
4251 + update_target_in("root=live:CDLABEL=ALLOY alloy.installer quiet"),
4252 + None
4253 + );
4254 + }
4255 +
4256 + // An empty value falls back rather than writing an empty origin. Writing one
4257 + // would reproduce the class of bug the whole constant exists to prevent: an
4258 + // install that succeeds and can never take an update.
4259 + #[test]
4260 + fn an_empty_update_target_is_not_a_reference() {
4261 + assert_eq!(
4262 + update_target_in("alloy.installer alloy.update-target= quiet"),
4263 + None
4264 + );
4265 + }
4266 +
4267 + // Not a prefix match on a longer parameter that merely starts the same way.
4268 + #[test]
4269 + fn a_similarly_named_parameter_is_not_the_update_target() {
4270 + assert_eq!(update_target_in("alloy.update-target-check=1"), None);
4271 + }
4272 +
4273 + // An untagged target would resolve to `latest`, which is the one tag
4274 + // docs/IMAGE.md says must never arrive through the ordinary channel. The
4275 + // build script refuses it first; this is what happens if one reaches a
4276 + // booted installer anyway.
4277 + #[test]
4278 + fn an_untagged_update_target_falls_back_rather_than_meaning_latest() {
4279 + assert_eq!(
4280 + update_target_in("alloy.update-target=astra.tailc6b3e1.ts.net:5000/alloy"),
4281 + None,
4282 + );
4283 + }
4284 +
4006 4285 // Missing answers cannot happen from the summary — every step gates on its
4007 4286 // own validation — but a partial plan would render a command with a hole in
4008 4287 // it, so it returns nothing instead.