Skip to content

Commit 5aab4f5

Browse files
committed
tmt: Generate integration.fmf from test code
We need to run most of our tests in a separate provisioned machine, which means it needs an individual plan. And then we need a test for that plan. And then we need the *actual test code*. This "triplication" is a huge annoying pain. TMT is soooo complicated, yet as far as I can tell it doesn't offer us any tools to solve this. So we'll do it here, cut over to generating the TMT stuff from metadata defined in the test file. Hence adding a test is just: - Write a new tests/booted/foo.nu - `cargo xtask update-generated` Signed-off-by: Colin Walters <[email protected]>
1 parent 24921e5 commit 5aab4f5

31 files changed

+568
-86
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/xtask/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ xshell = { workspace = true }
2929
# Crate-specific dependencies
3030
mandown = "1.1.0"
3131
rand = "0.9"
32+
serde_yaml = "0.9"
3233
tar = "0.4"
3334

3435
[lints]

crates/xtask/src/tmt.rs

Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
use anyhow::{Context, Result};
2+
use camino::Utf8Path;
3+
use fn_error_context::context;
4+
5+
// Generation markers for integration.fmf
6+
const PLAN_MARKER_BEGIN: &str = "# BEGIN GENERATED PLANS\n";
7+
const PLAN_MARKER_END: &str = "# END GENERATED PLANS\n";
8+
9+
/// Parse tmt metadata from a test file
10+
/// Looks for:
11+
/// # number: N
12+
/// # tmt:
13+
/// # <yaml content>
14+
fn parse_tmt_metadata(content: &str) -> Result<Option<TmtMetadata>> {
15+
let mut number = None;
16+
let mut in_tmt_block = false;
17+
let mut yaml_lines = Vec::new();
18+
19+
for line in content.lines().take(50) {
20+
let trimmed = line.trim();
21+
22+
// Look for "# number: N" line
23+
if let Some(rest) = trimmed.strip_prefix("# number:") {
24+
number = Some(rest.trim().parse::<u32>()
25+
.context("Failed to parse number field")?);
26+
continue;
27+
}
28+
29+
if trimmed == "# tmt:" {
30+
in_tmt_block = true;
31+
continue;
32+
} else if in_tmt_block {
33+
// Stop if we hit a line that doesn't start with #, or is just "#"
34+
if !trimmed.starts_with('#') || trimmed == "#" {
35+
break;
36+
}
37+
// Remove the leading # and preserve indentation
38+
if let Some(yaml_line) = line.strip_prefix('#') {
39+
yaml_lines.push(yaml_line);
40+
}
41+
}
42+
}
43+
44+
let Some(number) = number else {
45+
return Ok(None);
46+
};
47+
48+
let yaml_content = yaml_lines.join("\n");
49+
let extra: serde_yaml::Value = if yaml_content.trim().is_empty() {
50+
serde_yaml::Value::Mapping(serde_yaml::Mapping::new())
51+
} else {
52+
serde_yaml::from_str(&yaml_content)
53+
.with_context(|| format!("Failed to parse tmt metadata YAML:\n{}", yaml_content))?
54+
};
55+
56+
Ok(Some(TmtMetadata { number, extra }))
57+
}
58+
59+
#[derive(Debug, serde::Deserialize)]
60+
#[serde(rename_all = "kebab-case")]
61+
struct TmtMetadata {
62+
/// Test number for ordering and naming
63+
number: u32,
64+
/// All other fmf attributes (summary, duration, adjust, require, etc.)
65+
/// Note: summary and duration are typically required by fmf
66+
#[serde(flatten)]
67+
extra: serde_yaml::Value,
68+
}
69+
70+
#[derive(Debug)]
71+
struct TestDef {
72+
number: u32,
73+
name: String,
74+
test_command: String,
75+
/// All fmf attributes to pass through (summary, duration, adjust, etc.)
76+
extra: serde_yaml::Value,
77+
}
78+
79+
/// Generate tmt/plans/integration.fmf from test definitions
80+
#[context("Updating TMT integration.fmf")]
81+
pub(crate) fn update_integration() -> Result<()> {
82+
// Define tests in order
83+
let mut tests = vec![];
84+
85+
// Scan for test-*.nu and test-*.sh files in tmt/tests/booted/
86+
let booted_dir = Utf8Path::new("tmt/tests/booted");
87+
88+
for entry in std::fs::read_dir(booted_dir)? {
89+
let entry = entry?;
90+
let path = entry.path();
91+
let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
92+
continue;
93+
};
94+
95+
// Extract stem (filename without "test-" prefix and extension)
96+
let Some(stem) = filename
97+
.strip_prefix("test-")
98+
.and_then(|s| s.strip_suffix(".nu").or_else(|| s.strip_suffix(".sh")))
99+
else {
100+
continue;
101+
};
102+
103+
let content = std::fs::read_to_string(&path)
104+
.with_context(|| format!("Reading {}", filename))?;
105+
106+
let metadata = parse_tmt_metadata(&content)
107+
.with_context(|| format!("Parsing tmt metadata from {}", filename))?
108+
.with_context(|| format!("Missing tmt metadata in {}", filename))?;
109+
110+
// Remove number prefix if present (e.g., "01-readonly" -> "readonly", "26-examples-build" -> "examples-build")
111+
let display_name = stem
112+
.split_once('-')
113+
.and_then(|(prefix, suffix)| {
114+
if prefix.chars().all(|c| c.is_ascii_digit()) {
115+
Some(suffix.to_string())
116+
} else {
117+
None
118+
}
119+
})
120+
.unwrap_or_else(|| stem.to_string());
121+
122+
// Derive relative path from booted_dir
123+
let relative_path = path
124+
.strip_prefix("tmt/tests/")
125+
.with_context(|| format!("Failed to get relative path for {}", filename))?;
126+
127+
// Determine test command based on file extension
128+
let extension = if filename.ends_with(".nu") {
129+
"nu"
130+
} else if filename.ends_with(".sh") {
131+
"bash"
132+
} else {
133+
anyhow::bail!("Unsupported test file extension: {}", filename);
134+
};
135+
136+
let test_command = format!("{} {}", extension, relative_path.display());
137+
138+
tests.push(TestDef {
139+
number: metadata.number,
140+
name: display_name,
141+
test_command,
142+
extra: metadata.extra,
143+
});
144+
}
145+
146+
// Sort tests by number
147+
tests.sort_by_key(|t| t.number);
148+
149+
// Generate single tests.fmf file
150+
let tests_dir = Utf8Path::new("tmt/tests");
151+
let tests_fmf_path = tests_dir.join("tests.fmf");
152+
let mut tests_content = String::new();
153+
154+
// Add generated code marker
155+
tests_content.push_str("# THIS IS GENERATED CODE - DO NOT EDIT\n");
156+
tests_content.push_str("# Generated by: cargo xtask tmt\n");
157+
tests_content.push_str("\n");
158+
159+
for test in &tests {
160+
tests_content.push_str(&format!("/test-{:02}-{}:\n", test.number, test.name));
161+
162+
// Serialize all fmf attributes from metadata (summary, duration, adjust, etc.)
163+
if let serde_yaml::Value::Mapping(map) = &test.extra {
164+
if !map.is_empty() {
165+
let extra_yaml = serde_yaml::to_string(&test.extra)
166+
.context("Serializing extra metadata")?;
167+
for line in extra_yaml.lines() {
168+
if !line.trim().is_empty() {
169+
tests_content.push_str(&format!(" {}\n", line));
170+
}
171+
}
172+
}
173+
}
174+
175+
// Add the test command (derived from file type, not in metadata)
176+
if test.test_command.contains('\n') {
177+
tests_content.push_str(" test: |\n");
178+
for line in test.test_command.lines() {
179+
tests_content.push_str(&format!(" {}\n", line));
180+
}
181+
} else {
182+
tests_content.push_str(&format!(" test: {}\n", test.test_command));
183+
}
184+
185+
tests_content.push_str("\n");
186+
}
187+
188+
// Only write if content changed
189+
let needs_update = match std::fs::read_to_string(&tests_fmf_path) {
190+
Ok(existing) => existing != tests_content,
191+
Err(_) => true,
192+
};
193+
194+
if needs_update {
195+
std::fs::write(&tests_fmf_path, tests_content)
196+
.context("Writing tests.fmf")?;
197+
println!("Generated {}", tests_fmf_path);
198+
} else {
199+
println!("Unchanged: {}", tests_fmf_path);
200+
}
201+
202+
// Generate plans section (at root level, no indentation)
203+
let mut plans_section = String::new();
204+
for test in &tests {
205+
plans_section.push_str(&format!("/plan-{:02}-{}:\n", test.number, test.name));
206+
207+
// Extract summary from extra metadata
208+
if let serde_yaml::Value::Mapping(map) = &test.extra {
209+
if let Some(summary) = map.get(&serde_yaml::Value::String("summary".to_string())) {
210+
if let Some(summary_str) = summary.as_str() {
211+
plans_section.push_str(&format!(" summary: {}\n", summary_str));
212+
}
213+
}
214+
}
215+
216+
plans_section.push_str(" discover:\n");
217+
plans_section.push_str(" how: fmf\n");
218+
plans_section.push_str(" test:\n");
219+
plans_section.push_str(&format!(" - /tmt/tests/tests/test-{:02}-{}\n", test.number, test.name));
220+
221+
// Extract and serialize adjust section if present
222+
if let serde_yaml::Value::Mapping(map) = &test.extra {
223+
if let Some(adjust) = map.get(&serde_yaml::Value::String("adjust".to_string())) {
224+
let adjust_yaml = serde_yaml::to_string(adjust)
225+
.context("Serializing adjust metadata")?;
226+
plans_section.push_str(" adjust:\n");
227+
for line in adjust_yaml.lines() {
228+
if !line.trim().is_empty() {
229+
plans_section.push_str(&format!(" {}\n", line));
230+
}
231+
}
232+
}
233+
}
234+
235+
plans_section.push_str("\n");
236+
}
237+
238+
// Update integration.fmf with generated plans
239+
let output_path = Utf8Path::new("tmt/plans/integration.fmf");
240+
let existing_content = std::fs::read_to_string(output_path)
241+
.context("Reading integration.fmf")?;
242+
243+
// Replace plans section
244+
let (before_plans, rest) = existing_content.split_once(PLAN_MARKER_BEGIN)
245+
.context("Missing # BEGIN GENERATED PLANS marker in integration.fmf")?;
246+
let (_old_plans, after_plans) = rest.split_once(PLAN_MARKER_END)
247+
.context("Missing # END GENERATED PLANS marker in integration.fmf")?;
248+
249+
let new_content = format!(
250+
"{}{}{}{}{}",
251+
before_plans, PLAN_MARKER_BEGIN, plans_section, PLAN_MARKER_END, after_plans
252+
);
253+
254+
// Only write if content changed
255+
let needs_update = match std::fs::read_to_string(output_path) {
256+
Ok(existing) => existing != new_content,
257+
Err(_) => true,
258+
};
259+
260+
if needs_update {
261+
std::fs::write(output_path, new_content)?;
262+
println!("Generated {}", output_path);
263+
} else {
264+
println!("Unchanged: {}", output_path);
265+
}
266+
267+
Ok(())
268+
}
269+
270+
#[cfg(test)]
271+
mod tests {
272+
use super::*;
273+
274+
#[test]
275+
fn test_parse_tmt_metadata_basic() {
276+
let content = r#"# number: 1
277+
# tmt:
278+
# summary: Execute booted readonly/nondestructive tests
279+
# duration: 30m
280+
#
281+
# Run all readonly tests in sequence
282+
use tap.nu
283+
"#;
284+
285+
let metadata = parse_tmt_metadata(content).unwrap().unwrap();
286+
assert_eq!(metadata.number, 1);
287+
288+
// Verify extra fields are captured
289+
let extra = metadata.extra.as_mapping().unwrap();
290+
assert_eq!(
291+
extra.get(&serde_yaml::Value::String("summary".to_string())),
292+
Some(&serde_yaml::Value::String("Execute booted readonly/nondestructive tests".to_string()))
293+
);
294+
assert_eq!(
295+
extra.get(&serde_yaml::Value::String("duration".to_string())),
296+
Some(&serde_yaml::Value::String("30m".to_string()))
297+
);
298+
}
299+
300+
#[test]
301+
fn test_parse_tmt_metadata_with_adjust() {
302+
let content = r#"# number: 27
303+
# tmt:
304+
# summary: Execute custom selinux policy test
305+
# duration: 30m
306+
# adjust:
307+
# - when: running_env != image_mode
308+
# enabled: false
309+
# because: these tests require features only available in image mode
310+
#
311+
use std assert
312+
"#;
313+
314+
let metadata = parse_tmt_metadata(content).unwrap().unwrap();
315+
assert_eq!(metadata.number, 27);
316+
317+
// Verify adjust section is in extra
318+
let extra = metadata.extra.as_mapping().unwrap();
319+
assert!(extra.contains_key(&serde_yaml::Value::String("adjust".to_string())));
320+
}
321+
322+
#[test]
323+
fn test_parse_tmt_metadata_no_metadata() {
324+
let content = r#"# Just a comment
325+
use std assert
326+
"#;
327+
328+
let result = parse_tmt_metadata(content).unwrap();
329+
assert!(result.is_none());
330+
}
331+
332+
#[test]
333+
fn test_parse_tmt_metadata_shell_script() {
334+
let content = r#"# number: 26
335+
# tmt:
336+
# summary: Test bootc examples build scripts
337+
# duration: 45m
338+
# adjust:
339+
# - when: running_env != image_mode
340+
# enabled: false
341+
#
342+
#!/bin/bash
343+
set -eux
344+
"#;
345+
346+
let metadata = parse_tmt_metadata(content).unwrap().unwrap();
347+
assert_eq!(metadata.number, 26);
348+
349+
let extra = metadata.extra.as_mapping().unwrap();
350+
assert_eq!(
351+
extra.get(&serde_yaml::Value::String("duration".to_string())),
352+
Some(&serde_yaml::Value::String("45m".to_string()))
353+
);
354+
assert!(extra.contains_key(&serde_yaml::Value::String("adjust".to_string())));
355+
}
356+
}

crates/xtask/src/xtask.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use rand::Rng;
1616
use xshell::{cmd, Shell};
1717

1818
mod man;
19+
mod tmt;
1920

2021
const NAME: &str = "bootc";
2122
const TAR_REPRODUCIBLE_OPTS: &[&str] = &[
@@ -399,6 +400,9 @@ fn update_generated(sh: &Shell) -> Result<()> {
399400
// Update JSON schemas
400401
update_json_schemas(sh)?;
401402

403+
// Update TMT integration.fmf
404+
tmt::update_integration()?;
405+
402406
Ok(())
403407
}
404408

0 commit comments

Comments
 (0)