Skip to content

Commit 7f60bd6

Browse files
authored
Journal ordering cleanups (#218)
* credentials: Fix local-fs.target references missed by remote-fs.target migration Commit be88173 ("credentials: Use remote-fs.target for virtiofs mount ordering") changed the mount unit generator to use Before=remote-fs.target and updated the libvirt code path dropin, but missed three other locations: - run_ephemeral.rs SMBIOS dropin: still created a dropin targeting local-fs.target~bcvk-mounts instead of remote-fs.target~bcvk-mounts - run_ephemeral.rs inject_systemd_units(): still created local-fs.target.wants/ directory and symlinks there - bcvk-qemu smbios_creds_for_mount_unit(): still referenced local-fs.target in the dropin credential This inconsistency meant that the ephemeral code path would create mount units ordered Before=remote-fs.target but activated via a local-fs.target dropin, which is contradictory: remote-fs.target orders After=local-fs.target, so a unit that says Before=remote-fs.target should not be pulled in by local-fs.target. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters <walters@verbum.org> * tests: Add systemd health checks using structured journal queries Add two new integration tests for systemd health: test_systemd_health_cross_distro (parameterized, runs per image): Queries the guest journal via SSH for "Failed with result" and "ordering cycle" messages using journalctl -o json, parsed with serde. This catches regressions where mount unit ordering changes (like the local-fs/remote-fs migration) cause units to fail or create dependency cycles. test_run_ephemeral_detect_ordering_cycle (negative test): Injects two units with a deliberate mutual Before= cycle via --systemd-units, boots the VM, and verifies the "ordering cycle" diagnostic appears in the journal. This ensures the health check is not passing vacuously. Assisted-by: OpenCode (Claude claude-opus-4-6) Signed-off-by: Colin Walters <walters@verbum.org> --------- Signed-off-by: Colin Walters <walters@verbum.org>
1 parent 262b4e4 commit 7f60bd6

4 files changed

Lines changed: 184 additions & 10 deletions

File tree

crates/bcvk-qemu/src/credentials.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ pub fn generate_virtiofs_mount_unit(
8080
///
8181
/// Creates systemd credentials for:
8282
/// 1. The mount unit itself (via systemd.extra-unit)
83-
/// 2. A dropin for local-fs.target that wants this mount unit
83+
/// 2. A dropin for remote-fs.target that wants this mount unit
8484
///
8585
/// Returns a vector of SMBIOS credential strings.
8686
pub fn smbios_creds_for_mount_unit(
@@ -95,14 +95,14 @@ pub fn smbios_creds_for_mount_unit(
9595
let mount_cred =
9696
format!("io.systemd.credential.binary:systemd.extra-unit.{unit_name}={encoded_mount}");
9797

98-
// Create a dropin for local-fs.target that wants this mount
98+
// Create a dropin for remote-fs.target that wants this mount
9999
let dropin_content = format!(
100100
"[Unit]\n\
101101
Wants={unit_name}\n"
102102
);
103103
let encoded_dropin = data_encoding::BASE64.encode(dropin_content.as_bytes());
104104
let dropin_cred = format!(
105-
"io.systemd.credential.binary:systemd.unit-dropin.local-fs.target~bcvk-mounts={encoded_dropin}"
105+
"io.systemd.credential.binary:systemd.unit-dropin.remote-fs.target~bcvk-mounts={encoded_dropin}"
106106
);
107107

108108
Ok(vec![mount_cred, dropin_cred])

crates/integration-tests/src/tests/run_ephemeral.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ use color_eyre::Result;
1818
use integration_tests::integration_test;
1919
use xshell::cmd;
2020

21+
use std::fs;
22+
use tempfile::TempDir;
23+
24+
use camino::Utf8Path;
2125
use tracing::debug;
2226

2327
use crate::{get_bck_command, get_test_image, shell, INTEGRATION_TEST_LABEL};
@@ -458,3 +462,83 @@ fn test_run_ephemeral_mount_layout() -> Result<()> {
458462
Ok(())
459463
}
460464
integration_test!(test_run_ephemeral_mount_layout);
465+
466+
/// Verify that systemd ordering cycle detection actually works by injecting
467+
/// a deliberate cycle: unit A Before=B, unit B Before=A.
468+
///
469+
/// We inject the units via --systemd-units with default.target.wants/
470+
/// (which inject_systemd_units() knows how to copy), let the system boot
471+
/// normally, then use --execute to check the journal for the expected
472+
/// "ordering cycle" diagnostic.
473+
fn test_run_ephemeral_detect_ordering_cycle() -> Result<()> {
474+
let sh = shell()?;
475+
let bck = get_bck_command()?;
476+
let label = INTEGRATION_TEST_LABEL;
477+
let image = get_test_image();
478+
479+
let units_dir = TempDir::new()?;
480+
let units_dir_path = Utf8Path::from_path(units_dir.path()).expect("temp dir is not utf8");
481+
let system_dir = units_dir_path.join("system");
482+
fs::create_dir(&system_dir)?;
483+
484+
// Create a cycle: A wants and orders before B, B wants and orders before A.
485+
// Both Wants= ensure both units are in the transaction; the mutual
486+
// Before= constraints form the actual ordering cycle.
487+
fs::write(
488+
system_dir.join("cycle-a.service"),
489+
"[Unit]\n\
490+
Description=Cycle test unit A\n\
491+
Wants=cycle-b.service\n\
492+
Before=cycle-b.service\n\
493+
[Service]\n\
494+
Type=oneshot\n\
495+
ExecStart=/bin/true\n\
496+
RemainAfterExit=yes\n",
497+
)?;
498+
fs::write(
499+
system_dir.join("cycle-b.service"),
500+
"[Unit]\n\
501+
Description=Cycle test unit B\n\
502+
Wants=cycle-a.service\n\
503+
Before=cycle-a.service\n\
504+
[Service]\n\
505+
Type=oneshot\n\
506+
ExecStart=/bin/true\n\
507+
RemainAfterExit=yes\n",
508+
)?;
509+
510+
// Enable via default.target.wants/ which inject_systemd_units() copies
511+
let wants_dir = system_dir.join("default.target.wants");
512+
fs::create_dir(&wants_dir)?;
513+
std::os::unix::fs::symlink("../cycle-a.service", wants_dir.join("cycle-a.service"))?;
514+
515+
// Use --execute to query the journal in JSON format for ordering cycle
516+
// messages after the system boots. journalctl -g exits non-zero when no
517+
// matches are found, so we ignore the exit status.
518+
let check_script = "journalctl -b --no-pager -o json -g 'ordering cycle'";
519+
520+
let stdout = cmd!(
521+
sh,
522+
"{bck} ephemeral run --rm --label {label} --execute {check_script} --systemd-units {units_dir_path} {image}"
523+
)
524+
.ignore_status()
525+
.read()?;
526+
527+
// Parse JSON lines and look for cycle messages
528+
let has_cycle = stdout.lines().any(|line| {
529+
serde_json::from_str::<serde_json::Value>(line)
530+
.ok()
531+
.and_then(|v| v.get("MESSAGE")?.as_str().map(String::from))
532+
.is_some_and(|msg| msg.contains("ordering cycle"))
533+
});
534+
535+
assert!(
536+
has_cycle,
537+
"Expected ordering cycle to be detected for deliberately cyclic units. \
538+
Output: {}",
539+
stdout
540+
);
541+
542+
Ok(())
543+
}
544+
integration_test!(test_run_ephemeral_detect_ordering_cycle);

crates/integration-tests/src/tests/run_ephemeral_ssh.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,3 +414,91 @@ fn test_run_ephemeral_ssh_timeout() -> Result<()> {
414414
Ok(())
415415
}
416416
integration_test!(test_run_ephemeral_ssh_timeout);
417+
418+
/// A journal entry from `journalctl -o json`.
419+
///
420+
/// We only deserialize the fields we care about; serde ignores the rest.
421+
#[derive(serde::Deserialize)]
422+
struct JournalEntry {
423+
#[serde(rename = "MESSAGE", default)]
424+
message: String,
425+
#[serde(rename = "UNIT", default)]
426+
unit: Option<String>,
427+
}
428+
429+
/// Parse `journalctl -o json` output (one JSON object per line).
430+
fn parse_journal_entries(output: &str) -> Vec<JournalEntry> {
431+
output
432+
.lines()
433+
.filter_map(|line| serde_json::from_str::<JournalEntry>(line).ok())
434+
.collect()
435+
}
436+
437+
/// Test systemd health across all configured test images
438+
///
439+
/// Queries the guest journal directly (via SSH) for well-known systemd
440+
/// messages that indicate problems:
441+
/// - "Failed with result" — a unit entered failed state
442+
/// - "ordering cycle" — conflicting Before=/After= dependencies
443+
///
444+
/// Uses `journalctl -o json` for structured output parsed with serde,
445+
/// avoiding brittle text parsing of human-readable journal formats.
446+
fn test_systemd_health_cross_distro(image: &str) -> Result<()> {
447+
let sh = shell()?;
448+
let bck = get_bck_command()?;
449+
let label = INTEGRATION_TEST_LABEL;
450+
451+
// Query journal for unit failures and ordering cycles in a single SSH call.
452+
// Using -o json gives us one JSON object per line, which we parse with serde.
453+
// journalctl -g exits non-zero when no matches are found, so we ignore the
454+
// exit status and just parse whatever stdout we get (empty = no matches = pass).
455+
let check_script = "journalctl -b --no-pager -o json -g 'Failed with result|ordering cycle'";
456+
457+
let stdout = cmd!(
458+
sh,
459+
"{bck} ephemeral run-ssh --label {label} {image} -- {check_script}"
460+
)
461+
.ignore_status()
462+
.read()?;
463+
464+
let entries = parse_journal_entries(&stdout);
465+
466+
let failures: Vec<&JournalEntry> = entries
467+
.iter()
468+
.filter(|e| e.message.contains("Failed with result"))
469+
.collect();
470+
471+
let cycles: Vec<&JournalEntry> = entries
472+
.iter()
473+
.filter(|e| e.message.contains("ordering cycle"))
474+
.collect();
475+
476+
assert!(
477+
failures.is_empty(),
478+
"Found failed systemd unit(s) on image {}:\n{}",
479+
image,
480+
failures
481+
.iter()
482+
.map(|e| format!(
483+
" {}: {}",
484+
e.unit.as_deref().unwrap_or("<unknown>"),
485+
e.message
486+
))
487+
.collect::<Vec<_>>()
488+
.join("\n")
489+
);
490+
491+
assert!(
492+
cycles.is_empty(),
493+
"Found systemd ordering cycle(s) on image {}:\n{}",
494+
image,
495+
cycles
496+
.iter()
497+
.map(|e| format!(" {}", e.message))
498+
.collect::<Vec<_>>()
499+
.join("\n")
500+
);
501+
502+
Ok(())
503+
}
504+
parameterized_integration_test!(test_systemd_health_cross_distro);

crates/kit/src/run_ephemeral.rs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -728,7 +728,7 @@ pub(crate) fn process_disk_files(
728728
}
729729

730730
/// Copy systemd units from /run/systemd-units/system/ to container image /etc/systemd/system/.
731-
/// Auto-enables .mount units in local-fs.target.wants/, preserves default.target.wants/ symlinks.
731+
/// Auto-enables .mount units in remote-fs.target.wants/, preserves default.target.wants/ symlinks.
732732
fn inject_systemd_units() -> Result<()> {
733733
use std::fs;
734734

@@ -744,7 +744,7 @@ fn inject_systemd_units() -> Result<()> {
744744
// Create target directories
745745
fs::create_dir_all(target_units)?;
746746
fs::create_dir_all(&format!("{}/default.target.wants", target_units))?;
747-
fs::create_dir_all(&format!("{}/local-fs.target.wants", target_units))?;
747+
fs::create_dir_all(&format!("{}/remote-fs.target.wants", target_units))?;
748748

749749
// Copy all .service and .mount files
750750
for entry in fs::read_dir(source_units)? {
@@ -759,7 +759,7 @@ fn inject_systemd_units() -> Result<()> {
759759

760760
// Create symlinks for mount units to enable them
761761
if extension.as_deref() == Some("mount") {
762-
let wants_dir = format!("{}/local-fs.target.wants", target_units);
762+
let wants_dir = format!("{}/remote-fs.target.wants", target_units);
763763
let symlink_path = format!("{}/{}", wants_dir, filename);
764764
let relative_target = format!("../{}", filename);
765765
std::os::unix::fs::symlink(&relative_target, &symlink_path).ok();
@@ -1062,7 +1062,7 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> {
10621062
);
10631063
mount_unit_smbios_creds.push(mount_cred);
10641064

1065-
// Collect unit name for the local-fs.target dropin
1065+
// Collect unit name for the remote-fs.target dropin
10661066
mount_unit_names.push(unit_name.clone());
10671067

10681068
debug!(
@@ -1072,17 +1072,19 @@ pub(crate) async fn run_impl(opts: RunEphemeralOpts) -> Result<()> {
10721072
}
10731073
}
10741074

1075-
// If we have mount units, create a single dropin for local-fs.target
1075+
// If we have mount units, create a single dropin for remote-fs.target.
1076+
// We use remote-fs.target because virtiofs is conceptually similar to a remote
1077+
// filesystem - it requires virtio transport infrastructure, like NFS needs network.
10761078
if !mount_unit_names.is_empty() {
10771079
let wants_list = mount_unit_names.join(" ");
10781080
let dropin_content = format!("[Unit]\nWants={}\n", wants_list);
10791081
let encoded_dropin = data_encoding::BASE64.encode(dropin_content.as_bytes());
10801082
let dropin_cred = format!(
1081-
"io.systemd.credential.binary:systemd.unit-dropin.local-fs.target~bcvk-mounts={encoded_dropin}"
1083+
"io.systemd.credential.binary:systemd.unit-dropin.remote-fs.target~bcvk-mounts={encoded_dropin}"
10821084
);
10831085
mount_unit_smbios_creds.push(dropin_cred);
10841086
debug!(
1085-
"Created local-fs.target dropin for {} mount units",
1087+
"Created remote-fs.target dropin for {} mount units",
10861088
mount_unit_names.len()
10871089
);
10881090
}

0 commit comments

Comments
 (0)