Skip to content

Commit 9652e72

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 <walters@verbum.org>
1 parent f49a6ba commit 9652e72

30 files changed

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

0 commit comments

Comments
 (0)