Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 199 additions & 1 deletion crates/osmodifier/src/grub_cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ use crate::OsModifierContext;
/// Possible grub.cfg locations, tried in order.
const GRUB_CFG_PATHS: &[&str] = &["/boot/grub2/grub.cfg", "/boot/grub/grub.cfg"];

/// BLS (Boot Loader Spec) entry directory. Fedora-based distros (including
/// AZL4) store kernel boot entries here instead of inline in grub.cfg.
const BLS_ENTRIES_DIR: &str = "/boot/loader/entries";

/// Extract boot arguments from the generated grub.cfg.
///
/// Returns a tuple of (args_to_sync, optional_root_device).
Expand All @@ -37,7 +41,14 @@ pub fn extract_boot_args_from_grub_cfg(

// Find the non-recovery linux command lines.
// Go expects exactly one; error otherwise.
let linux_lines = find_non_recovery_linux_lines(&content)?;
let linux_lines = match find_non_recovery_linux_lines(&content) {
Ok(lines) => lines,
Err(_) if content.contains("blscfg") => {
debug!("grub.cfg uses BLS (blscfg); reading boot args from BLS entries");
extract_options_from_bls_entries(ctx)?
}
Err(e) => return Err(e),
};
if linux_lines.len() != 1 {
bail!(
"expected 1 non-recovery linux line, found {}",
Expand Down Expand Up @@ -94,6 +105,77 @@ fn find_grub_cfg(ctx: &OsModifierContext) -> Result<std::path::PathBuf, Error> {
bail!("Could not find grub.cfg at any of: {:?}", GRUB_CFG_PATHS)
}

/// Read boot arguments from BLS (Boot Loader Spec) entries.
///
/// Scans `{root}/boot/loader/entries/*.conf`, skips entries whose title
/// contains "rescue" or "recovery" (case-insensitive), and returns the
/// `options` line from the first valid entry (sorted lexically, matching
/// grub's ordering).
fn extract_options_from_bls_entries(ctx: &OsModifierContext) -> Result<Vec<String>, Error> {
let entries_dir = ctx.path(BLS_ENTRIES_DIR);
let mut conf_files: Vec<std::path::PathBuf> = fs::read_dir(&entries_dir)
.with_context(|| format!("Failed to read BLS entries dir '{}'", entries_dir.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "conf"))
.collect();

conf_files.sort();

for conf_path in &conf_files {
let content = fs::read_to_string(conf_path)
.with_context(|| format!("Failed to read BLS entry '{}'", conf_path.display()))?;

let mut title = None;
let mut options = None;

for line in content.lines() {
// BLS entries may be indented; trim leading whitespace and require a
// whitespace delimiter after the key so we do not match keys like
// "titlebar" or pick up an empty value.
let line = line.trim_start();
if let Some(value) = line
.strip_prefix("title")
.filter(|v| v.starts_with(char::is_whitespace))
Comment thread
bfjelds marked this conversation as resolved.
{
title = Some(value.trim().to_string());
} else if let Some(value) = line
.strip_prefix("options")
.filter(|v| v.starts_with(char::is_whitespace))
Comment thread
bfjelds marked this conversation as resolved.
{
options = Some(value.trim().to_string());
}
}

// Skip recovery/rescue entries.
if let Some(ref t) = title {
let lower = t.to_lowercase();
if lower.contains("rescue") || lower.contains("recovery") {
trace!(
"Skipping BLS rescue/recovery entry: {}",
conf_path.display()
);
continue;
}
}

if let Some(opts) = options {
debug!(
"Using BLS entry '{}': options = {opts}",
conf_path.display()
);
// Return as a synthetic "linux" line: prepend a dummy kernel path
// so the downstream parser (which skips the first token) works.
return Ok(vec![format!("/boot/vmlinuz {opts}")]);
}
}

bail!(
"no non-recovery BLS entry found in '{}'",
entries_dir.display()
)
}

/// Return the first whitespace-delimited word from a line, or None if the
/// line is empty / whitespace-only.
fn first_word(line: &str) -> Option<&str> {
Expand Down Expand Up @@ -757,4 +839,120 @@ mod tests {
assert_eq!(count_braces("menuentry 'title {x}' {"), (1, 0));
assert_eq!(count_braces(r#"menuentry "title {x}" {"#), (1, 0));
}

// ======================= BLS entry support =======================

#[test]
fn test_extract_bls_fallback() {
let tmp = tempdir().unwrap();

// Write a BLS-style grub.cfg (contains blscfg, no inline linux lines)
let grub_dir = tmp.path().join("boot/grub2");
std::fs::create_dir_all(&grub_dir).unwrap();
std::fs::write(
grub_dir.join("grub.cfg"),
indoc::indoc! {r#"
set timeout=5
load_env -f /boot/grub2/grubenv
blscfg
"#},
)
.unwrap();

// Write a BLS entry
let bls_dir = tmp.path().join("boot/loader/entries");
std::fs::create_dir_all(&bls_dir).unwrap();
std::fs::write(
bls_dir.join("azl4.conf"),
indoc::indoc! {r#"
title Azure Linux 4.0 (6.6.60)
version 6.6.60
linux /boot/vmlinuz-6.6.60
initrd /boot/initramfs-6.6.60.img
options root=/dev/sda2 ro selinux=1 rd.overlayfs=lower,upper,work,/dev/sda5
"#},
)
.unwrap();

let ctx = OsModifierContext {
root: tmp.path().to_path_buf(),
};

let (args, root_device) = extract_boot_args_from_grub_cfg(&ctx).unwrap();
assert_eq!(root_device, Some("/dev/sda2".to_string()));
assert!(args.contains(&"selinux=1".to_string()));
}

#[test]
fn test_extract_bls_skips_recovery() {
let tmp = tempdir().unwrap();

let grub_dir = tmp.path().join("boot/grub2");
std::fs::create_dir_all(&grub_dir).unwrap();
std::fs::write(grub_dir.join("grub.cfg"), "set timeout=5\nblscfg\n").unwrap();

let bls_dir = tmp.path().join("boot/loader/entries");
std::fs::create_dir_all(&bls_dir).unwrap();

// Rescue entry (should be skipped)
std::fs::write(
bls_dir.join("rescue.conf"),
indoc::indoc! {r#"
title Azure Linux 4.0 rescue
version 6.6.60
linux /boot/vmlinuz-6.6.60
initrd /boot/initramfs-6.6.60.img
options root=/dev/sda2 ro single
"#},
)
.unwrap();

// Normal entry (should be used)
std::fs::write(
bls_dir.join("zzz-normal.conf"),
indoc::indoc! {r#"
title Azure Linux 4.0 (6.6.60)
version 6.6.60
linux /boot/vmlinuz-6.6.60
initrd /boot/initramfs-6.6.60.img
options root=/dev/sda2 ro selinux=1
"#},
)
.unwrap();

let ctx = OsModifierContext {
root: tmp.path().to_path_buf(),
};

let (args, root_device) = extract_boot_args_from_grub_cfg(&ctx).unwrap();
assert_eq!(root_device, Some("/dev/sda2".to_string()));
assert!(args.contains(&"selinux=1".to_string()));
// "single" from rescue entry should NOT appear
assert!(!args.iter().any(|a| a.contains("single")));
}

#[test]
fn test_extract_bls_no_entries() {
let tmp = tempdir().unwrap();

let grub_dir = tmp.path().join("boot/grub2");
std::fs::create_dir_all(&grub_dir).unwrap();
std::fs::write(grub_dir.join("grub.cfg"), "set timeout=5\nblscfg\n").unwrap();

// Empty BLS entries dir
let bls_dir = tmp.path().join("boot/loader/entries");
std::fs::create_dir_all(&bls_dir).unwrap();

let ctx = OsModifierContext {
root: tmp.path().to_path_buf(),
};

let result = extract_boot_args_from_grub_cfg(&ctx);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("no non-recovery BLS entry found"),
"Error should mention no BLS entries, got: {err_msg}"
);
}
}
62 changes: 62 additions & 0 deletions crates/osutils/src/grub.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,18 @@ impl GrubConfig {
}

/// Update the search command in the GRUB config.
///
/// Three variants of the GRUB stub `search` line exist in practice:
///
/// 1. The upstream legacy form: `search -n -u <UUID> -s`
/// 2. AZL3 / standard form: `search --no-floppy --fs-uuid --set=root <UUID>`
/// 3. AZL4 / Fedora-based form: `search --fs-uuid --set=root <UUID>`
/// (`--no-floppy` is a Mariner-specific convention; Fedora's grub2
/// scripts don't emit it, and it's redundant on EFI machines.)
pub fn update_search(&mut self, uuid: &Uuid) -> Result<(), Error> {
let re = Regex::new(r"(?m)^(\s*)search -n -u [\w-]+ -s$").unwrap();
let re2 = Regex::new(r"(?m)^(\s*)search --no-floppy --fs-uuid --set=root [\w-]+$").unwrap();
let re3 = Regex::new(r"(?m)^(\s*)search --fs-uuid --set=root [\w-]+$").unwrap();

if re.is_match(&self.contents) {
self.contents = re
Expand All @@ -246,6 +255,13 @@ impl GrubConfig {
&format!("${{1}}search --no-floppy --fs-uuid --set=root {uuid}"),
)
.to_string();
} else if re3.is_match(&self.contents) {
self.contents = re3
.replace(
&self.contents,
&format!("${{1}}search --fs-uuid --set=root {uuid}"),
)
.to_string();
} else {
bail!(
"Unable to find search command in '{}'",
Expand Down Expand Up @@ -953,6 +969,52 @@ mod tests {
.unwrap();
}

#[test]
fn test_update_search_azl3_form() {
// AZL3 stubs use `search --no-floppy --fs-uuid --set=root <UUID>`.
let mut grub_config = GrubConfig {
path: PathBuf::new(),
contents: indoc::indoc! { r#"
set timeout=0
search --no-floppy --fs-uuid --set=root deadbeef-cafe-babe-0000-111122223333
"# }
.to_owned(),
linux_command_line: None,
};

let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap();
grub_config.update_search(&new_uuid).unwrap();

assert!(grub_config.contents.contains(&format!(
"search --no-floppy --fs-uuid --set=root {new_uuid}"
)));
assert!(!grub_config.contents.contains("deadbeef"));
}

#[test]
fn test_update_search_azl4_form() {
// AZL4 (Fedora-based) stubs omit --no-floppy.
let mut grub_config = GrubConfig {
path: PathBuf::new(),
contents: indoc::indoc! { r#"
set timeout=0
search --fs-uuid --set=root deadbeef-cafe-babe-0000-111122223333
"# }
.to_owned(),
linux_command_line: None,
};

let new_uuid = Uuid::parse_str("9e6a9d2c-b7fe-4359-ac45-18b505e29d8c").unwrap();
grub_config.update_search(&new_uuid).unwrap();

assert!(grub_config
.contents
.contains(&format!("search --fs-uuid --set=root {new_uuid}")));
assert!(!grub_config.contents.contains("deadbeef"));
// Must not accidentally insert --no-floppy.
assert!(!grub_config.contents.contains("--no-floppy"));
}

#[test]
fn test_update_rootdevice() {
// Define original GRUB config contents on target machine
Expand Down
2 changes: 2 additions & 0 deletions crates/osutils/src/mkinitrd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ mod functional_test {
fn test_regenerate_initrd() {
let pattern = if osrelease::is_azl3().unwrap() {
"/boot/initramfs-*.azl3.img"
} else if osrelease::is_azl4().unwrap() {
"/boot/initramfs-*.azl4.img"
} else {
"/boot/initrd.img-*"
};
Expand Down
Loading
Loading