Skip to content

Commit f7cd97a

Browse files
committed
feat(buildsys): embed guest variant images via build-variant guest-images
Add a declarative `guest-images` field to a host variant's `[package.metadata.build-variant]` manifest that embeds the disk-image artifacts of one or more already-built guest variants into the host's rootfs. This enables shipping variant-A images as content inside variant-B (e.g. installer media, recovery partitions, AMI-bundled secondaries) without introducing a new package type or CLI surface. Manifest schema: [package.metadata.build-variant.guest-images] inner-variant = "/usr/share/bottlerocket/guests/inner" [build-dependencies] inner-variant = { path = "../inner-variant" } `buildsys build-variant` validates each entry: the key must appear in `[build-dependencies]`, the target must itself be a `build-variant`, the value must be an absolute install path, and a variant may not reference itself. Validated entries are threaded through a `GUEST_IMAGES` build-arg (newline-delimited `<guest>:<install_path>:build/images/<arch>-<guest>/<version>`) into the `imgbuild` Dockerfile stage, where `rpm2img` (via the new `guest-images-helper`) `cp -a`s each guest's image directory into `${ROOT_MOUNT}<install_path>` after `rpm -iv` and before SELinux relabel, so embedded files inherit correct labels. Tests: - Unit: `test_build_variant_with_guest_images`, `test_build_variant_without_guest_images`, `test_build_type_default_package_unchanged`, plus `validate_guest_image_entries` and `guest_image_variant_deps` coverage for invalid names/paths, self-reference, missing build-dep, transitive-only build-dep, and the happy path. - Fixtures: `tests/projects/guest-images-kit` (host wrapper-variant embedding an inner-variant) and `guest-images-graph` (direct, transitive, and self-referential guest deps). - Integration (`#[ignore]`, requires Docker): `test_twoliter_build_variant_consumes_guest_images` asserts the guest variant builds transitively and the host variant produces image artifacts.
1 parent 0188ec0 commit f7cd97a

39 files changed

Lines changed: 1465 additions & 10 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
use crate::twoliter_build::copy_project_to_temp_dir;
2+
use std::path::Path;
3+
use tempfile::TempDir;
4+
5+
use super::{run_command, test_projects_dir, TWOLITER_PATH};
6+
7+
/// Copy the `guest-images-kit` fixture to a temp directory and run `twoliter update`/`fetch`
8+
/// so it is ready to build.
9+
fn prepare_guest_images_kit() -> TempDir {
10+
let project = test_projects_dir().join("guest-images-kit");
11+
let tmp_dir = copy_project_to_temp_dir(&project);
12+
let project_path = tmp_dir.path().join("Twoliter.toml");
13+
let project_path_str = project_path.to_str().unwrap();
14+
15+
let output = run_command(
16+
TWOLITER_PATH,
17+
["update", "--project-path", project_path_str],
18+
[],
19+
);
20+
assert!(
21+
output.status.success(),
22+
"twoliter update failed for guest-images-kit"
23+
);
24+
25+
let output = run_command(
26+
TWOLITER_PATH,
27+
["fetch", "--project-path", project_path_str],
28+
[],
29+
);
30+
assert!(
31+
output.status.success(),
32+
"twoliter fetch failed for guest-images-kit"
33+
);
34+
35+
tmp_dir
36+
}
37+
38+
/// Returns the single per-version artifact directory under
39+
/// `build/images/<arch>-<variant>/`, or `None` if no version directory yet exists.
40+
fn variant_version_dir(
41+
project_root: &Path,
42+
arch: &str,
43+
variant: &str,
44+
) -> Option<std::path::PathBuf> {
45+
let images_root = project_root.join(format!("build/images/{arch}-{variant}"));
46+
if !images_root.is_dir() {
47+
return None;
48+
}
49+
for entry in std::fs::read_dir(&images_root).ok()? {
50+
let entry = entry.ok()?;
51+
if entry.file_type().ok()?.is_dir() {
52+
return Some(entry.path());
53+
}
54+
}
55+
None
56+
}
57+
58+
/// Build the host `wrapper-variant`. Cargo's build-dependency graph drives a build of the guest
59+
/// `inner-variant` first, and during the host's image build the guest's image directory is
60+
/// copied directly into the host rootfs. We assert here that:
61+
///
62+
/// 1. The host variant build succeeds end-to-end.
63+
/// 2. The guest variant's image directory exists (proving the build-dependency triggered it).
64+
/// 3. The host variant's image directory exists and contains artifacts.
65+
#[test]
66+
#[ignore]
67+
fn test_twoliter_build_variant_consumes_guest_images() {
68+
let tmp_dir = prepare_guest_images_kit();
69+
let project_path = tmp_dir.path().join("Twoliter.toml");
70+
let project_path_str = project_path.to_str().unwrap();
71+
72+
let arch = "x86_64";
73+
74+
let output = run_command(
75+
TWOLITER_PATH,
76+
[
77+
"build",
78+
"variant",
79+
"wrapper-variant",
80+
"--project-path",
81+
project_path_str,
82+
"--arch",
83+
arch,
84+
],
85+
[],
86+
);
87+
assert!(
88+
output.status.success(),
89+
"twoliter build variant wrapper-variant failed: {}",
90+
String::from_utf8_lossy(&output.stderr)
91+
);
92+
93+
// The guest variant must have been built as a transitive build-dependency.
94+
let inner_dir = variant_version_dir(tmp_dir.path(), arch, "inner-variant")
95+
.expect("expected inner-variant images dir to exist after host build");
96+
assert!(
97+
std::fs::read_dir(&inner_dir).unwrap().next().is_some(),
98+
"no image artifacts produced for guest inner-variant under {}",
99+
inner_dir.display()
100+
);
101+
102+
// The host variant should also have produced its own image dir with artifacts.
103+
let wrapper_dir = variant_version_dir(tmp_dir.path(), arch, "wrapper-variant")
104+
.expect("expected wrapper-variant images dir to exist after host build");
105+
assert!(
106+
std::fs::read_dir(&wrapper_dir).unwrap().next().is_some(),
107+
"no image artifacts produced for host wrapper-variant under {}",
108+
wrapper_dir.display()
109+
);
110+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
use crate::run_command;
2+
use std::path::{Path, PathBuf};
3+
use tempfile::TempDir;
4+
5+
/// Path to the `guest-images-helper` shell file embedded by twoliter and sourced by `rpm2img`.
6+
fn helper_path() -> PathBuf {
7+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
8+
.parent()
9+
.unwrap()
10+
.parent()
11+
.unwrap()
12+
.join("twoliter/embedded/guest-images-helper")
13+
}
14+
15+
/// Build a synthetic guest image directory mimicking the layout produced by a real variant
16+
/// build. Includes:
17+
/// - the bootable image artifacts that should be copied
18+
/// - the build-metadata files that must NOT be copied (the bug we're guarding against)
19+
/// - a stable-name symlink (`os_image.img.lz4` -> `bottlerocket-…img.lz4`)
20+
fn populate_synthetic_guest_dir(dir: &Path) {
21+
// Bootable artifacts (these are the ones that SHOULD be copied).
22+
for f in [
23+
"bottlerocket-inner-x86_64-1.0.0-0.img.lz4",
24+
"bottlerocket-inner-x86_64-1.0.0-0-data.img.lz4",
25+
"bottlerocket-inner-x86_64-1.0.0-0-boot.ext4.lz4",
26+
"bottlerocket-inner-x86_64-1.0.0-0-root.ext4.lz4",
27+
"bottlerocket-inner-x86_64-1.0.0-0-root.verity.lz4",
28+
"bottlerocket-inner-x86_64-1.0.0-0.qcow2",
29+
"bottlerocket-inner-x86_64-1.0.0-0.vmdk",
30+
] {
31+
std::fs::write(dir.join(f), b"image-bytes").unwrap();
32+
}
33+
34+
// Stable-name symlink. After copy it should still point at the right target name.
35+
std::os::unix::fs::symlink(
36+
"bottlerocket-inner-x86_64-1.0.0-0.img.lz4",
37+
dir.join("os_image.img.lz4"),
38+
)
39+
.unwrap();
40+
41+
// Build metadata + SBOMs (these MUST NOT be copied).
42+
for f in [
43+
"application-inventory.json",
44+
"artifact-metadata.json",
45+
"pcr-predictions.json",
46+
"bottlerocket-inner-x86_64-1.0.0-0-sbom-os.spdx.json",
47+
"bottlerocket-inner-x86_64-1.0.0-0-sbom-os.cdx.json",
48+
] {
49+
std::fs::write(dir.join(f), b"metadata-bytes").unwrap();
50+
}
51+
// A stray non-image binary (e.g. left-over from a tool) — must not be copied either.
52+
std::fs::write(dir.join("README.txt"), b"text").unwrap();
53+
}
54+
55+
/// Source the helper, run `copy_guest_image_artifacts`, and return the destination directory
56+
/// for inspection.
57+
fn run_copy(src: &Path, dst: &Path) -> std::process::Output {
58+
let helper = helper_path();
59+
let script = format!(
60+
r#"
61+
set -euo pipefail
62+
source "{helper}"
63+
copy_guest_image_artifacts "{src}" "{dst}"
64+
"#,
65+
helper = helper.display(),
66+
src = src.display(),
67+
dst = dst.display(),
68+
);
69+
run_command("bash", ["-c", script.as_str()], [])
70+
}
71+
72+
fn entries_in(dir: &Path) -> Vec<String> {
73+
let mut names: Vec<String> = std::fs::read_dir(dir)
74+
.unwrap()
75+
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
76+
.collect();
77+
names.sort();
78+
names
79+
}
80+
81+
/// All bootable image artifacts (and the stable-name symlink) end up at the destination, and
82+
/// none of the JSON / SBOM / README files do. This is the regression test for the original
83+
/// `cp -a` finding that copied the entire guest image directory verbatim.
84+
#[test]
85+
fn test_copy_guest_image_artifacts_filters_metadata_and_sboms() {
86+
let temp = TempDir::new().unwrap();
87+
let src = temp.path().join("src");
88+
let dst = temp.path().join("dst");
89+
std::fs::create_dir_all(&src).unwrap();
90+
std::fs::create_dir_all(&dst).unwrap();
91+
populate_synthetic_guest_dir(&src);
92+
93+
let output = run_copy(&src, &dst);
94+
assert!(
95+
output.status.success(),
96+
"copy_guest_image_artifacts failed: stderr={}",
97+
String::from_utf8_lossy(&output.stderr)
98+
);
99+
100+
let copied = entries_in(&dst);
101+
102+
// Required: every bootable artifact + the symlink. Order independent.
103+
for required in [
104+
"bottlerocket-inner-x86_64-1.0.0-0.img.lz4",
105+
"bottlerocket-inner-x86_64-1.0.0-0-data.img.lz4",
106+
"bottlerocket-inner-x86_64-1.0.0-0-boot.ext4.lz4",
107+
"bottlerocket-inner-x86_64-1.0.0-0-root.ext4.lz4",
108+
"bottlerocket-inner-x86_64-1.0.0-0-root.verity.lz4",
109+
"bottlerocket-inner-x86_64-1.0.0-0.qcow2",
110+
"bottlerocket-inner-x86_64-1.0.0-0.vmdk",
111+
"os_image.img.lz4",
112+
] {
113+
assert!(
114+
copied.iter().any(|n| n == required),
115+
"expected {required:?} in dst, got {copied:?}"
116+
);
117+
}
118+
119+
// Forbidden: no JSON, no SBOM, no README. These would leak guest build metadata into
120+
// the host rootfs and are exactly what the `cp -a` regression copied.
121+
for forbidden in copied.iter() {
122+
assert!(
123+
!forbidden.ends_with(".json"),
124+
"metadata file {forbidden:?} must not be copied (came from src JSON)"
125+
);
126+
assert!(
127+
!forbidden.contains("-sbom-"),
128+
"SBOM file {forbidden:?} must not be copied"
129+
);
130+
assert!(
131+
forbidden != "README.txt",
132+
"non-image file {forbidden:?} must not be copied"
133+
);
134+
}
135+
136+
// The symlink must remain a symlink (so its target name is preserved verbatim) and
137+
// must point at one of the real artifacts in the same directory.
138+
let symlink_meta = std::fs::symlink_metadata(dst.join("os_image.img.lz4")).unwrap();
139+
assert!(
140+
symlink_meta.file_type().is_symlink(),
141+
"os_image.img.lz4 should remain a symlink at the destination"
142+
);
143+
let target = std::fs::read_link(dst.join("os_image.img.lz4")).unwrap();
144+
assert_eq!(
145+
target.to_string_lossy(),
146+
"bottlerocket-inner-x86_64-1.0.0-0.img.lz4",
147+
"symlink target should be preserved verbatim"
148+
);
149+
}
150+
151+
/// If a guest directory holds no recognizable image artifacts, the helper returns non-zero so
152+
/// the caller can fail the build instead of silently producing an empty install path.
153+
#[test]
154+
fn test_copy_guest_image_artifacts_fails_on_empty_input() {
155+
let temp = TempDir::new().unwrap();
156+
let src = temp.path().join("src");
157+
let dst = temp.path().join("dst");
158+
std::fs::create_dir_all(&src).unwrap();
159+
std::fs::create_dir_all(&dst).unwrap();
160+
// Only metadata files: nothing the helper considers an artifact.
161+
std::fs::write(src.join("application-inventory.json"), b"{}").unwrap();
162+
std::fs::write(src.join("README.txt"), b"text").unwrap();
163+
164+
let output = run_copy(&src, &dst);
165+
assert!(
166+
!output.status.success(),
167+
"copy_guest_image_artifacts should fail when no artifacts match"
168+
);
169+
assert!(
170+
entries_in(&dst).is_empty(),
171+
"no files should have been copied; got {:?}",
172+
entries_in(&dst)
173+
);
174+
}
175+
176+
/// Filenames containing spaces must round-trip correctly through the find/while pipeline.
177+
/// This guards against accidentally re-introducing word-splitting bugs in the copy logic.
178+
#[test]
179+
fn test_copy_guest_image_artifacts_handles_spaces_in_names() {
180+
let temp = TempDir::new().unwrap();
181+
let src = temp.path().join("src");
182+
let dst = temp.path().join("dst");
183+
std::fs::create_dir_all(&src).unwrap();
184+
std::fs::create_dir_all(&dst).unwrap();
185+
let weird = "name with spaces.img.lz4";
186+
std::fs::write(src.join(weird), b"image").unwrap();
187+
188+
let output = run_copy(&src, &dst);
189+
assert!(
190+
output.status.success(),
191+
"copy_guest_image_artifacts failed on space-in-name: {}",
192+
String::from_utf8_lossy(&output.stderr)
193+
);
194+
assert!(
195+
dst.join(weird).is_file(),
196+
"expected {weird:?} to be copied; dst contents: {:?}",
197+
entries_in(&dst)
198+
);
199+
}

tests/integration-tests/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ use tempfile::TempDir;
88

99
mod advisory_checker;
1010
mod appinventory;
11+
mod guest_images_build;
12+
mod guest_images_helper;
1113
mod imghelper;
1214
mod twoliter_build;
1315
mod twoliter_update;

tests/projects/guest-images-graph/Cargo.lock

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[workspace]
2+
resolver = "2"
3+
members = [
4+
"variants/host",
5+
"variants/direct",
6+
"variants/transitive",
7+
"kits/middle-kit",
8+
"packages/some-pkg",
9+
]
10+
11+
[profile.dev]
12+
debug = false
13+
opt-level = 'z'
14+
15+
[profile.dev.build-override]
16+
opt-level = 'z'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# guest-images-graph
2+
3+
Minimal Cargo workspace used only by buildsys unit tests to exercise the
4+
`guest-images` dependency-graph rules. The shape is:
5+
6+
```
7+
host (variant)
8+
├─ [build-deps] direct (variant) <- valid guest
9+
└─ [build-deps] middle-kit (kit)
10+
├─ [deps] transitive (variant) <- reachable but NOT a direct build-dep
11+
└─ [deps] some-pkg (package)
12+
```
13+
14+
No crate here is intended to be passed to `twoliter build`; the fixtures only
15+
need to be valid enough for `cargo metadata --offline` to succeed.

0 commit comments

Comments
 (0)