Skip to content
Draft
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
496 changes: 496 additions & 0 deletions crates/lib/src/bootc_composefs/loader_entries.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/lib/src/bootc_composefs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub(crate) mod digest;
pub(crate) mod export;
pub(crate) mod finalize;
pub(crate) mod gc;
pub(crate) mod loader_entries;
pub(crate) mod repo;
pub(crate) mod rollback;
pub(crate) mod selinux;
Expand Down
24 changes: 18 additions & 6 deletions crates/lib/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1955,12 +1955,24 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
Opt::LoaderEntries(opts) => match opts {
LoaderEntriesOpts::SetOptionsForSource(opts) => {
let storage = get_storage().await?;
let sysroot = storage.get_ostree()?;
crate::loader_entries::set_options_for_source_staged(
sysroot,
&opts.source,
opts.options.as_deref(),
)?;
match storage.kind()? {
BootedStorageKind::Ostree(_) => {
let sysroot = storage.get_ostree()?;
crate::loader_entries::set_options_for_source_staged(
sysroot,
&opts.source,
opts.options.as_deref(),
)?;
}
BootedStorageKind::Composefs(booted_cfs) => {
crate::bootc_composefs::loader_entries::set_options_for_source(
&storage,
&booted_cfs,
&opts.source,
opts.options.as_deref(),
)?;
}
}
Ok(())
}
},
Expand Down
12 changes: 6 additions & 6 deletions crates/lib/src/loader_entries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,17 @@ use ostree_ext::ostree;
use std::collections::BTreeMap;

/// The BLS extension key prefix for source-tracked options.
const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";
pub(crate) const OPTIONS_SOURCE_KEY_PREFIX: &str = "x-options-source-";

/// A validated source name (alphanumeric + hyphens + underscores, non-empty).
///
/// This is a newtype wrapper around `String` that enforces validation at
/// construction time. See <https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/>.
struct SourceName(String);
pub(crate) struct SourceName(String);

impl SourceName {
/// Parse and validate a source name.
fn parse(source: &str) -> Result<Self> {
pub(crate) fn parse(source: &str) -> Result<Self> {
ensure!(!source.is_empty(), "Source name must not be empty");
ensure!(
source
Expand All @@ -39,7 +39,7 @@ impl SourceName {
}

/// The BLS key for this source (e.g., `x-options-source-tuned`).
fn bls_key(&self) -> String {
pub(crate) fn bls_key(&self) -> String {
format!("{OPTIONS_SOURCE_KEY_PREFIX}{}", self.0)
}
}
Expand All @@ -59,7 +59,7 @@ impl std::fmt::Display for SourceName {

/// Extract source options from BLS entry content. Parses `x-options-source-*` keys
/// from the raw BLS text since the ostree BootconfigParser doesn't expose key iteration.
fn extract_source_options_from_bls(content: &str) -> BTreeMap<String, CmdlineOwned> {
pub(crate) fn extract_source_options_from_bls(content: &str) -> BTreeMap<String, CmdlineOwned> {
let mut sources = BTreeMap::new();
for line in content.lines() {
let line = line.trim();
Expand Down Expand Up @@ -89,7 +89,7 @@ fn extract_source_options_from_bls(content: &str) -> BTreeMap<String, CmdlineOwn
/// 3. Add the new options for the specified source
///
/// Options not tracked by any source are preserved as-is.
fn compute_merged_options(
pub(crate) fn compute_merged_options(
current_options: &str,
source_options: &BTreeMap<String, CmdlineOwned>,
target_source: &SourceName,
Expand Down
7 changes: 7 additions & 0 deletions tmt/plans/integration.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -254,4 +254,11 @@ execute:
test:
- /tmt/tests/tests/test-42-loader-entries-source
extra-fixme_skip_if_composefs: true

/plan-43-composefs-loader-entries-source:
summary: Test bootc loader-entries set-options-for-source on composefs
discover:
how: fmf
test:
- /tmt/tests/tests/test-43-composefs-loader-entries-source
# END GENERATED PLANS
209 changes: 209 additions & 0 deletions tmt/tests/booted/test-composefs-loader-entries-source.nu
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# number: 43
# tmt:
# summary: Test bootc loader-entries set-options-for-source on composefs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But can't we have just one test that works on both backends?

# duration: 30m
#
# This test verifies source-tracked kernel argument management on composefs-
# booted systems. The composefs path directly modifies BLS entry files on
# /boot rather than staging a new ostree deployment. It covers:
# 1. Input validation (invalid/empty source names)
# 2. Adding source-tracked kargs and verifying they appear in /proc/cmdline
# 3. Source keys (x-options-source-*) in BLS entry files
# 4. Source replacement semantics (old kargs removed, new ones added)
# 5. Multiple sources coexisting independently
# 6. Source removal (--source without --options clears all owned kargs)
# 7. Idempotent operation (no changes when kargs already match)
# 8. Existing system kargs preserved through changes
#
# This test is composefs-specific. It exits 0 (skip) on ostree-booted systems.
# The UKI boot type is also skipped since kargs are embedded in the PE binary.
#
# See: https://github.com/bootc-dev/bootc/issues/899
use std assert
use tap.nu

# Skip if not composefs-booted
if not (tap is_composefs) {
print "Not a composefs system, skipping"
exit 0
}

# Skip if UKI boot type — kargs are embedded in the PE binary
let st = bootc status --json | from json
let boot_type = $st.status.booted.composefs?.bootType? | default "bls"
if ($boot_type | str downcase) == "uki" {
print "UKI boot type, skipping (kargs embedded in PE binary)"
exit 0
}

def parse_cmdline [] {
open /proc/cmdline | str trim | split row " "
}

# Read x-options-source-* keys from the booted BLS entry.
# On composefs, entries are named bootc_*.conf (not ostree-*.conf).
def read_bls_source_keys [] {
let entries = glob /boot/loader/entries/bootc_*.conf | sort
if ($entries | length) == 0 {
error make { msg: "No composefs BLS entries found" }
}
let entry = open ($entries | last)
$entry | lines | where { |line| $line starts-with "x-options-source-" }
}

# Save the current system kargs for later comparison
def save_system_kargs [] {
let cmdline = parse_cmdline
let system_kargs = $cmdline | where { |k|
(($k starts-with "root=") or ($k == "rw") or ($k starts-with "console="))
}
$system_kargs | to json | save -f /var/bootc-test-system-kargs.json
}

def load_system_kargs [] {
open /var/bootc-test-system-kargs.json
}

def first_boot [] {
tap begin "composefs loader-entries set-options-for-source"

save_system_kargs

# -- Input validation (same as ostree test) --

let r = do -i { bootc loader-entries set-options-for-source --source "bad name" --options "foo=bar" } | complete
assert ($r.exit_code != 0) "spaces in source name should fail"

let r = do -i { bootc loader-entries set-options-for-source --source "foo@bar" --options "foo=bar" } | complete
assert ($r.exit_code != 0) "special chars in source name should fail"

let r = do -i { bootc loader-entries set-options-for-source --source "" --options "foo=bar" } | complete
assert ($r.exit_code != 0) "empty source name should fail"

# Valid name with underscores/dashes
bootc loader-entries set-options-for-source --source "my_custom-src" --options "testvalid=1"
# Clear it immediately
bootc loader-entries set-options-for-source --source "my_custom-src"

# -- Add source kargs --
# On composefs, this directly modifies the BLS entry (no staging)
bootc loader-entries set-options-for-source --source tuned --options "nohz=full isolcpus=1-3"

# Verify the BLS entry was updated immediately (composefs writes directly)
let source_keys = read_bls_source_keys
let tuned_key = $source_keys | where { |line| $line starts-with "x-options-source-tuned" }
assert (($tuned_key | length) > 0) "x-options-source-tuned should be in BLS entry immediately"
print "ok: source key written to BLS entry"

# Add a second source
bootc loader-entries set-options-for-source --source admin --options "quiet"

# Verify both source keys present
let source_keys = read_bls_source_keys
let admin_key = $source_keys | where { |line| $line starts-with "x-options-source-admin" }
assert (($admin_key | length) > 0) "x-options-source-admin should be in BLS entry"
print "ok: multiple sources written"

print "ok: validation and initial BLS update"
tmt-reboot
}

def second_boot [] {
# Verify kargs survived reboot
let cmdline = parse_cmdline
assert ("nohz=full" in $cmdline) "nohz=full should be in cmdline after reboot"
assert ("isolcpus=1-3" in $cmdline) "isolcpus=1-3 should be in cmdline after reboot"
assert ("quiet" in $cmdline) "admin quiet karg should be in cmdline after reboot"
print "ok: kargs survived reboot"

# Verify system kargs preserved
let system_kargs = load_system_kargs
for karg in $system_kargs {
assert ($karg in $cmdline) $"system karg '($karg)' must be preserved"
}
print "ok: system kargs preserved"

# Verify source keys in BLS entry
let source_keys = read_bls_source_keys
let tuned_key = $source_keys | where { |line| $line starts-with "x-options-source-tuned" }
assert (($tuned_key | length) > 0) "x-options-source-tuned should be in BLS entry"
let tuned_line = $tuned_key | first
assert ($tuned_line | str contains "nohz=full") "tuned source key should contain nohz=full"
assert ($tuned_line | str contains "isolcpus=1-3") "tuned source key should contain isolcpus=1-3"
print "ok: source keys persisted across reboot"

# -- Source replacement: new kargs replace old ones --
# Clean up admin source first
bootc loader-entries set-options-for-source --source admin

# Replace tuned kargs
bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7"

tmt-reboot
}

def third_boot [] {
# Verify replacement worked
let cmdline = parse_cmdline
assert ("nohz=full" not-in $cmdline) "old nohz=full should be gone"
assert ("isolcpus=1-3" not-in $cmdline) "old isolcpus=1-3 should be gone"
assert ("nohz=on" in $cmdline) "new nohz=on should be present"
assert ("rcu_nocbs=2-7" in $cmdline) "new rcu_nocbs=2-7 should be present"
assert ("quiet" not-in $cmdline) "admin quiet should be gone after removal"

# Verify system kargs still preserved
let system_kargs = load_system_kargs
for karg in $system_kargs {
assert ($karg in $cmdline) $"system karg '($karg)' must survive replacement"
}
print "ok: source replacement persisted, system kargs preserved"

# -- Multiple sources coexist --
bootc loader-entries set-options-for-source --source dracut --options "rd.driver.pre=vfio-pci"

# -- Idempotent: same kargs again should be a no-op --
# On composefs, idempotency means the BLS file is not rewritten
bootc loader-entries set-options-for-source --source tuned --options "nohz=on rcu_nocbs=2-7"
# (No easy way to detect no-write on composefs, but the command should succeed silently)
print "ok: idempotent operation succeeded"

# -- Source removal --
bootc loader-entries set-options-for-source --source dracut

# Verify dracut removed, tuned preserved (check BLS immediately)
let source_keys = read_bls_source_keys
let dracut_keys = $source_keys | where { |line| $line starts-with "x-options-source-dracut" }
assert (($dracut_keys | length) == 0) "dracut source key should be gone after removal"
let tuned_keys = $source_keys | where { |line| $line starts-with "x-options-source-tuned" }
assert (($tuned_keys | length) > 0) "tuned source key should still exist"
print "ok: source removal and coexistence verified"

tmt-reboot
}

def fourth_boot [] {
# Final verification after reboot
let cmdline = parse_cmdline
assert ("rd.driver.pre=vfio-pci" not-in $cmdline) "dracut karg should be gone"
assert ("nohz=on" in $cmdline) "tuned nohz=on should still be present"
assert ("rcu_nocbs=2-7" in $cmdline) "tuned rcu_nocbs=2-7 should still be present"

# Verify system kargs intact through all phases
let system_kargs = load_system_kargs
for karg in $system_kargs {
assert ($karg in $cmdline) $"system karg '($karg)' must survive all phases"
}
print "ok: all phases completed, system kargs preserved"

tap ok
}

def main [] {
match $env.TMT_REBOOT_COUNT? {
null | "0" => first_boot,
"1" => second_boot,
"2" => third_boot,
"3" => fourth_boot,
$o => { error make { msg: $"Unexpected TMT_REBOOT_COUNT ($o)" } },
}
}
2 changes: 2 additions & 0 deletions tmt/tests/booted/test-loader-entries-source.nu
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# number: 42
# extra:
# fixme_skip_if_composefs: true
# tmt:
# summary: Test bootc loader-entries set-options-for-source
# duration: 30m
Expand Down
5 changes: 5 additions & 0 deletions tmt/tests/tests.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,8 @@ check:
summary: Test bootc loader-entries set-options-for-source
duration: 30m
test: nu booted/test-loader-entries-source.nu

/test-43-composefs-loader-entries-source:
summary: Test bootc loader-entries set-options-for-source on composefs
duration: 30m
test: nu booted/test-composefs-loader-entries-source.nu
Loading