Skip to content

Commit 84b02c2

Browse files
authored
to-disk: Include source imgref in cache hash to prevent incorrect reuse (#141)
Different image references (imgrefs) pointing to the same digest should not share cached disk images, because the source imgref determines the upgrade source for the installed system. Add the `source_imgref` field to the cache metadata structures and include it in the cache hash computation. Additionally, add a `--dry-run` flag to `to-disk` that prints whether the disk would be regenerated (`would-regenerate`) or reused (`would-reuse`) without actually creating it. This is useful for testing and scripting. Closes: #138 Assisted-by: Claude Code (Sonnet 4.5) Signed-off-by: Colin Walters <[email protected]>
1 parent d933282 commit 84b02c2

File tree

5 files changed

+167
-16
lines changed

5 files changed

+167
-16
lines changed

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

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,89 @@ fn test_to_disk_caching() -> Result<()> {
214214
Ok(())
215215
}
216216
integration_test!(test_to_disk_caching);
217+
218+
/// Test that different image references with the same digest create separate cached disks
219+
fn test_to_disk_different_imgref_same_digest() -> Result<()> {
220+
let temp_dir = TempDir::new().expect("Failed to create temp directory");
221+
222+
// First, pull the test image
223+
let test_image = get_test_image();
224+
let output = Command::new("podman")
225+
.args(["pull", &test_image])
226+
.output()
227+
.expect("Failed to run podman pull");
228+
assert!(
229+
output.status.success(),
230+
"Failed to pull test image: {}",
231+
String::from_utf8_lossy(&output.stderr)
232+
);
233+
234+
// Create a second tag pointing to the same digest
235+
let second_tag = format!("{}-alias", test_image);
236+
let output = Command::new("podman")
237+
.args(["tag", &test_image, &second_tag])
238+
.output()
239+
.expect("Failed to run podman tag");
240+
assert!(
241+
output.status.success(),
242+
"Failed to tag image: {}",
243+
String::from_utf8_lossy(&output.stderr)
244+
);
245+
246+
// Create first disk with original image reference
247+
let disk_path = Utf8PathBuf::try_from(temp_dir.path().join("test-disk.img"))
248+
.expect("temp path is not UTF-8");
249+
250+
let output1 = run_bcvk(&[
251+
"to-disk",
252+
"--label",
253+
INTEGRATION_TEST_LABEL,
254+
&test_image,
255+
disk_path.as_str(),
256+
])?;
257+
258+
assert!(
259+
output1.success(),
260+
"First to-disk run failed with exit code: {:?}. stdout: {}, stderr: {}",
261+
output1.exit_code(),
262+
output1.stdout,
263+
output1.stderr
264+
);
265+
266+
let metadata1 =
267+
std::fs::metadata(&disk_path).expect("Failed to get disk metadata after first run");
268+
assert!(metadata1.len() > 0, "Disk image is empty");
269+
270+
// Use --dry-run with the aliased image reference (same digest, different imgref)
271+
// to verify it would regenerate instead of reusing the cache
272+
let output2 = run_bcvk(&[
273+
"to-disk",
274+
"--dry-run",
275+
"--label",
276+
INTEGRATION_TEST_LABEL,
277+
&second_tag,
278+
disk_path.as_str(),
279+
])?;
280+
281+
assert!(
282+
output2.success(),
283+
"Dry-run with different imgref failed with exit code: {:?}. stdout: {}, stderr: {}",
284+
output2.exit_code(),
285+
output2.stdout,
286+
output2.stderr
287+
);
288+
289+
// The dry-run should report it would regenerate because the source imgref is different
290+
assert!(
291+
output2.stdout.contains("would-regenerate"),
292+
"Dry-run should report 'would-regenerate' for different imgref. stdout: {}, stderr: {}",
293+
output2.stdout,
294+
output2.stderr
295+
);
296+
297+
// Clean up: remove the aliased tag
298+
let _ = Command::new("podman").args(["rmi", &second_tag]).output();
299+
300+
Ok(())
301+
}
302+
integration_test!(test_to_disk_different_imgref_same_digest);

crates/kit/src/cache_metadata.rs

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ struct CacheInputs {
3030
/// SHA256 digest of the source container image
3131
image_digest: String,
3232

33+
/// Source image reference (e.g., "quay.io/centos-bootc/centos-bootc:stream9")
34+
/// This is crucial because it determines the upgrade source for the installed system
35+
source_imgref: String,
36+
3337
/// Filesystem type used for installation (e.g., "ext4", "xfs", "btrfs")
3438
filesystem: Option<String>,
3539

@@ -52,6 +56,10 @@ pub struct DiskImageMetadata {
5256
/// SHA256 digest of the source container image
5357
pub digest: String,
5458

59+
/// Source image reference (e.g., "quay.io/centos-bootc/centos-bootc:stream9")
60+
/// This is crucial because it determines the upgrade source for the installed system
61+
pub source_imgref: String,
62+
5563
/// Filesystem type used for installation (e.g., "ext4", "xfs", "btrfs")
5664
pub filesystem: Option<String>,
5765

@@ -73,6 +81,7 @@ impl DiskImageMetadata {
7381
pub fn compute_cache_hash(&self) -> String {
7482
let inputs = CacheInputs {
7583
image_digest: self.digest.clone(),
84+
source_imgref: self.source_imgref.clone(),
7685
filesystem: self.filesystem.clone(),
7786
root_size: self.root_size.clone(),
7887
composefs_backend: self.composefs_backend,
@@ -154,11 +163,12 @@ impl DiskImageMetadata {
154163
}
155164

156165
impl DiskImageMetadata {
157-
/// Create new metadata from InstallOptions and image digest
158-
pub fn from(options: &InstallOptions, image: &str) -> Self {
166+
/// Create new metadata from InstallOptions, image digest, and source imgref
167+
pub fn from(options: &InstallOptions, image_digest: &str, source_imgref: &str) -> Self {
159168
Self {
160169
version: 1,
161-
digest: image.to_owned(),
170+
digest: image_digest.to_owned(),
171+
source_imgref: source_imgref.to_owned(),
162172
filesystem: options.filesystem.clone(),
163173
root_size: options.root_size.clone(),
164174
kernel_args: options.karg.clone(),
@@ -181,6 +191,7 @@ pub(crate) enum ValidationError {
181191
pub fn check_cached_disk(
182192
path: &Path,
183193
image_digest: &str,
194+
source_imgref: &str,
184195
install_options: &InstallOptions,
185196
) -> Result<Result<(), ValidationError>> {
186197
if !path.exists() {
@@ -189,7 +200,7 @@ pub fn check_cached_disk(
189200
}
190201

191202
// Create metadata for the current request to compute expected hash
192-
let expected_meta = DiskImageMetadata::from(install_options, image_digest);
203+
let expected_meta = DiskImageMetadata::from(install_options, image_digest, source_imgref);
193204
let expected_hash = expected_meta.compute_cache_hash();
194205

195206
// Read the cache hash from the disk image
@@ -246,28 +257,31 @@ mod tests {
246257
root_size: Some("20G".to_string()),
247258
..Default::default()
248259
};
249-
let metadata1 = DiskImageMetadata::from(&install_options1, "sha256:abc123");
260+
let metadata1 =
261+
DiskImageMetadata::from(&install_options1, "sha256:abc123", "quay.io/test/image:v1");
250262

251263
let install_options2 = InstallOptions {
252264
filesystem: Some("ext4".to_string()),
253265
root_size: Some("20G".to_string()),
254266
..Default::default()
255267
};
256-
let metadata2 = DiskImageMetadata::from(&install_options2, "sha256:abc123");
268+
let metadata2 =
269+
DiskImageMetadata::from(&install_options2, "sha256:abc123", "quay.io/test/image:v1");
257270

258271
// Same inputs should generate same hash
259272
assert_eq!(
260273
metadata1.compute_cache_hash(),
261274
metadata2.compute_cache_hash()
262275
);
263276

264-
// Different inputs should generate different hashes
277+
// Different digest should generate different hashes
265278
let install_options3 = InstallOptions {
266279
filesystem: Some("ext4".to_string()),
267280
root_size: Some("20G".to_string()),
268281
..Default::default()
269282
};
270-
let metadata3 = DiskImageMetadata::from(&install_options3, "sha256:xyz789");
283+
let metadata3 =
284+
DiskImageMetadata::from(&install_options3, "sha256:xyz789", "quay.io/test/image:v1");
271285

272286
assert_ne!(
273287
metadata1.compute_cache_hash(),
@@ -280,18 +294,38 @@ mod tests {
280294
root_size: Some("20G".to_string()),
281295
..Default::default()
282296
};
283-
let metadata4 = DiskImageMetadata::from(&install_options4, "sha256:abc123");
297+
let metadata4 =
298+
DiskImageMetadata::from(&install_options4, "sha256:abc123", "quay.io/test/image:v1");
284299

285300
assert_ne!(
286301
metadata1.compute_cache_hash(),
287302
metadata4.compute_cache_hash()
288303
);
304+
305+
// Different source imgref should generate different hash even with same digest
306+
let install_options5 = InstallOptions {
307+
filesystem: Some("ext4".to_string()),
308+
root_size: Some("20G".to_string()),
309+
..Default::default()
310+
};
311+
let metadata5 = DiskImageMetadata::from(
312+
&install_options5,
313+
"sha256:abc123",
314+
"quay.io/different/image:latest",
315+
);
316+
317+
assert_ne!(
318+
metadata1.compute_cache_hash(),
319+
metadata5.compute_cache_hash(),
320+
"Different source imgrefs with same digest should generate different cache hashes"
321+
);
289322
}
290323

291324
#[test]
292325
fn test_cache_inputs_serialization() -> Result<()> {
293326
let inputs = CacheInputs {
294327
image_digest: "sha256:abc123".to_string(),
328+
source_imgref: "quay.io/test/image:v1".to_string(),
295329
filesystem: Some("ext4".to_string()),
296330
root_size: Some("20G".to_string()),
297331
kernel_args: vec!["console=ttyS0".to_string()],
@@ -303,6 +337,7 @@ mod tests {
303337
let deserialized: CacheInputs = serde_json::from_str(&json)?;
304338

305339
assert_eq!(inputs.image_digest, deserialized.image_digest);
340+
assert_eq!(inputs.source_imgref, deserialized.source_imgref);
306341
assert_eq!(inputs.filesystem, deserialized.filesystem);
307342
assert_eq!(inputs.root_size, deserialized.root_size);
308343
assert_eq!(inputs.kernel_args, deserialized.kernel_args);

crates/kit/src/libvirt/base_disks.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub fn find_or_create_base_disk(
1919
install_options: &InstallOptions,
2020
connect_uri: Option<&str>,
2121
) -> Result<Utf8PathBuf> {
22-
let metadata = DiskImageMetadata::from(install_options, image_digest);
22+
let metadata = DiskImageMetadata::from(install_options, image_digest, source_image);
2323
let cache_hash = metadata.compute_cache_hash();
2424

2525
// Extract short hash for filename (first 16 chars after "sha256:")
@@ -43,6 +43,7 @@ pub fn find_or_create_base_disk(
4343
if crate::cache_metadata::check_cached_disk(
4444
base_disk_path.as_std_path(),
4545
image_digest,
46+
source_image,
4647
install_options,
4748
)?
4849
.is_ok()
@@ -130,6 +131,7 @@ fn create_base_disk(
130131
let metadata_valid = crate::cache_metadata::check_cached_disk(
131132
temp_disk_path.as_std_path(),
132133
image_digest,
134+
source_image,
133135
install_options,
134136
)
135137
.context("Querying cached disk")?;

crates/kit/src/to_disk.rs

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ pub struct ToDiskAdditionalOpts {
138138
help = "Add metadata to the container in key=value form"
139139
)]
140140
pub label: Vec<String>,
141+
142+
/// Check if the disk would be regenerated without actually creating it
143+
#[clap(long)]
144+
pub dry_run: bool,
141145
}
142146

143147
/// Configuration options for installing a bootc container image to disk
@@ -302,7 +306,7 @@ impl ToDiskOpts {
302306
/// for details on the installation workflow and architecture.
303307
pub fn run(opts: ToDiskOpts) -> Result<()> {
304308
// Phase 0: Check for existing cached disk image
305-
if opts.target_disk.exists() {
309+
let would_reuse = if opts.target_disk.exists() {
306310
debug!(
307311
"Target disk {} already exists, checking cache metadata",
308312
opts.target_disk
@@ -316,9 +320,14 @@ pub fn run(opts: ToDiskOpts) -> Result<()> {
316320
match crate::cache_metadata::check_cached_disk(
317321
opts.target_disk.as_std_path(),
318322
&image_digest,
323+
&opts.source_image,
319324
&opts.install,
320325
)? {
321326
Ok(()) => {
327+
if opts.additional.dry_run {
328+
println!("would-reuse");
329+
return Ok(());
330+
}
322331
println!(
323332
"Reusing existing cached disk image (digest {image_digest}) at: {}",
324333
opts.target_disk
@@ -327,12 +336,27 @@ pub fn run(opts: ToDiskOpts) -> Result<()> {
327336
}
328337
Err(e) => {
329338
debug!("Existing disk does not match requirements, recreating: {e}");
330-
// Remove the existing disk so we can recreate it
331-
std::fs::remove_file(&opts.target_disk).with_context(|| {
332-
format!("Failed to remove existing disk {}", opts.target_disk)
333-
})?;
339+
if !opts.additional.dry_run {
340+
// Remove the existing disk so we can recreate it
341+
std::fs::remove_file(&opts.target_disk).with_context(|| {
342+
format!("Failed to remove existing disk {}", opts.target_disk)
343+
})?;
344+
}
345+
false
334346
}
335347
}
348+
} else {
349+
false
350+
};
351+
352+
// In dry-run mode, report whether we would regenerate
353+
if opts.additional.dry_run {
354+
if would_reuse {
355+
println!("would-reuse");
356+
} else {
357+
println!("would-regenerate");
358+
}
359+
return Ok(());
336360
}
337361

338362
// Phase 1: Validation and preparation
@@ -512,7 +536,7 @@ fn write_disk_metadata(
512536
let digest = inspect.digest.to_string();
513537

514538
// Prepare metadata using the new helper method
515-
let metadata = DiskImageMetadata::from(install_options, &digest);
539+
let metadata = DiskImageMetadata::from(install_options, &digest, source_image);
516540

517541
// Write metadata using rustix fsetxattr
518542
let file = std::fs::OpenOptions::new()

docs/src/man/bcvk-to-disk.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ The installation process:
111111

112112
Add metadata to the container in key=value form
113113

114+
**--dry-run**
115+
116+
Check if the disk would be regenerated without actually creating it
117+
114118
<!-- END GENERATED OPTIONS -->
115119

116120
# ARGUMENTS

0 commit comments

Comments
 (0)