| 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 |
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 |
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 |
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 |
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 |
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 |
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.
|