Skip to main content

bootc_lib/
deploy.rs

1//! Pull dispatch and deployment staging for the ostree backend.
2//!
3//! ## Planned Pull paths
4//!
5//! The top-level entry point for upgrade/switch will eventually select
6//! among three paths based on the `unified` flag and filesystem capability:
7//!
8//! - **Unified + reflinks** (`unified = true`, XFS/btrfs): `pull_via_composefs_unified`
9//!   — the planned three-store pipeline. Pulls into containers-storage first, then
10//!   ZeroCopy into composefs, then synthesizes the ostree commit via FICLONE.
11//!   See [`crate::store`] for the architecture diagram.
12//!
13//! - **Non-unified + reflinks** (`unified = false`, XFS/btrfs): `pull_via_composefs`
14//!   — fetches from registry directly into composefs (no containers-storage),
15//!   then synthesizes the ostree commit via FICLONE.
16//!
17//! - **No reflinks** (ext4): `pull` — the legacy ostree-native tar importer
18//!   (`ostree_container::store::ImageImporter`).
19//!
20//! ## Planned composefs → ostree synthesis
21//!
22//! The synthesis plan relies on `import_from_composefs_repo` from
23//! `ostree_ext::container::composefs_import` to walk the composefs
24//! filesystem tree and for each regular file:
25//!
26//! 1. Reads uid/gid/mode/xattrs from composefs metadata. SELinux labels are
27//!    computed in bulk before the walk via `selabel()` and looked up per-file;
28//!    a NUL terminator is appended because composefs-rs omits it but the kernel
29//!    stores it.
30//! 2. Computes the ostree content checksum in-memory (SHA-256 of
31//!    `uid:gid:mode:xattrs:file-content`).
32//! 3. Issues `ioctl(FICLONE)` from the composefs object fd into a new `O_TMPFILE`
33//!    in the ostree object directory.
34//! 4. Applies metadata (`fchown`, `fchmod`, `fsetxattr`) and links the tmpfile
35//!    into the ostree content-addressed path.
36//!
37//! `/etc` is remapped to `usr/etc`; virtual toplevel paths (`proc`, `sys`,
38//! `dev`, etc.) are excluded — matching the ostree-container tar importer.
39//!
40//! ## Auto-detection
41//!
42//! `image_exists_in_unified_storage` checks whether the target image is already
43//! present in bootc-owned containers-storage. Call sites use this to select
44//! `unified = true` automatically without requiring an explicit flag from the
45//! user once `bootc image set-unified` has been run.
46
47use std::collections::HashSet;
48use std::io::{BufRead, Write};
49use std::os::fd::{AsFd, AsRawFd};
50use std::process::Command;
51use std::sync::Arc;
52use std::time::Duration;
53
54use anyhow::{Context, Result, anyhow};
55use bootc_utils::skopeo_bin;
56use cap_std::fs::{Dir, MetadataExt};
57use cap_std_ext::cap_std;
58use cap_std_ext::dirext::CapStdExtDirExt;
59use fn_error_context::context;
60use linux_kernel_cmdline::utf8::CmdlineOwned;
61use ostree::{gio, glib};
62use ostree_container::OstreeImageReference;
63use ostree_ext::container as ostree_container;
64use ostree_ext::container::store::{ImageImporter, ImportProgress, PrepareResult, PreparedImport};
65use ostree_ext::oci_spec::image::{Descriptor, Digest};
66use ostree_ext::ostree::Deployment;
67use ostree_ext::ostree::{self, Sysroot};
68use ostree_ext::sysroot::SysrootLock;
69use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten;
70
71use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep};
72use crate::spec::ImageReference;
73use crate::spec::{BootOrder, HostSpec};
74use crate::status::labels_of_config;
75use crate::store::Storage;
76use crate::utils::async_task_with_spinner;
77
78// TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a
79const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc";
80
81// Match podman's default registry retry policy. A failed attempt has to rebuild
82// the importer, so retries are made at the whole-pull boundary instead of for
83// individual layers.
84// TODO: Read this policy from the proxy once
85// https://github.com/podman-container-tools/container-libs/pull/951 is available.
86const PULL_MAX_RETRIES: u32 = 3;
87const PULL_RETRY_INITIAL_DELAY: Duration = Duration::from_secs(2);
88
89/// Progress output shared by all attempts to pull an image.
90#[derive(Clone)]
91pub(crate) struct PullProgress {
92    bars: indicatif::MultiProgress,
93    json: ProgressWriter,
94    quiet: bool,
95}
96
97impl PullProgress {
98    pub(crate) fn new(quiet: bool, json: ProgressWriter) -> Self {
99        let bars = indicatif::MultiProgress::new();
100        if quiet {
101            bars.set_draw_target(indicatif::ProgressDrawTarget::hidden());
102        }
103        Self { bars, json, quiet }
104    }
105
106    fn println(&self, message: impl AsRef<str>) {
107        if self.quiet {
108            return;
109        }
110        if let Err(error) = self.bars.println(message) {
111            tracing::debug!(%error, "Writing pull progress message");
112        }
113    }
114
115    async fn send(&self, event: Event<'_>) {
116        self.json.send(event.clone()).await;
117        if let Event::ProgressSteps { description, .. } = &event {
118            self.println(description);
119        }
120    }
121
122    fn clear(&self) {
123        if let Err(error) = self.bars.clear() {
124            tracing::debug!(%error, "Clearing pull progress");
125        }
126    }
127}
128
129/// Create an ImageProxyConfig with bootc's user agent prefix set.
130///
131/// This allows registries to distinguish "image pulls for bootc client runs"
132/// from other skopeo/containers-image users.
133pub(crate) fn new_proxy_config() -> ostree_ext::containers_image_proxy::ImageProxyConfig {
134    let mut c = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
135    c.user_agent_prefix = Some(format!("bootc/{}", env!("CARGO_PKG_VERSION")));
136    c
137}
138
139/// Set on an ostree commit if this is a derived commit
140const BOOTC_DERIVED_KEY: &str = "bootc.derived";
141
142/// Variant of HostSpec but required to be filled out
143pub(crate) struct RequiredHostSpec<'a> {
144    pub(crate) image: &'a ImageReference,
145}
146
147/// State of a locally fetched image
148pub(crate) struct ImageState {
149    pub(crate) manifest_digest: Digest,
150    pub(crate) version: Option<String>,
151    pub(crate) ostree_commit: String,
152}
153
154impl<'a> RequiredHostSpec<'a> {
155    /// Given a (borrowed) host specification, "unwrap" its internal
156    /// options, giving a spec that is required to have a base container image.
157    pub(crate) fn from_spec(spec: &'a HostSpec) -> Result<Self> {
158        let image = spec
159            .image
160            .as_ref()
161            .ok_or_else(|| anyhow::anyhow!("Missing image in specification"))?;
162        Ok(Self { image })
163    }
164}
165
166impl From<ostree_container::store::LayeredImageState> for ImageState {
167    fn from(value: ostree_container::store::LayeredImageState) -> Self {
168        let version = value.version().map(|v| v.to_owned());
169        let ostree_commit = value.get_commit().to_owned();
170        Self {
171            manifest_digest: value.manifest_digest,
172            version,
173            ostree_commit,
174        }
175    }
176}
177
178impl ImageState {
179    /// Fetch the manifest corresponding to this image.  May not be available in all backends.
180    pub(crate) fn get_manifest(
181        &self,
182        repo: &ostree::Repo,
183    ) -> Result<Option<ostree_ext::oci_spec::image::ImageManifest>> {
184        ostree_container::store::query_image_commit(repo, &self.ostree_commit)
185            .map(|v| Some(v.manifest))
186    }
187}
188
189/// The importer settings every bootc pull uses, however the layers arrive.
190fn configure_importer(
191    imp: &mut ostree_container::store::ImageImporter,
192    booted_deployment: Option<&ostree::Deployment>,
193) {
194    imp.require_bootable();
195    // We do our own GC/prune in deploy::prune(), so skip the importer's internal one.
196    imp.disable_gc();
197    if let Some(deployment) = booted_deployment {
198        imp.set_sepolicy_commit(deployment.csum().to_string());
199    }
200}
201
202/// Wrapper for pulling a container image, wiring up status output.
203pub(crate) async fn new_importer(
204    repo: &ostree::Repo,
205    imgref: &ostree_container::OstreeImageReference,
206    booted_deployment: Option<&ostree::Deployment>,
207) -> Result<ostree_container::store::ImageImporter> {
208    new_importer_with_config(repo, imgref, new_proxy_config(), booted_deployment).await
209}
210
211/// Wrapper for pulling a container image with a custom proxy config (e.g. for unified storage).
212pub(crate) async fn new_importer_with_config(
213    repo: &ostree::Repo,
214    imgref: &ostree_container::OstreeImageReference,
215    config: ostree_ext::containers_image_proxy::ImageProxyConfig,
216    booted_deployment: Option<&ostree::Deployment>,
217) -> Result<ostree_container::store::ImageImporter> {
218    let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?;
219    configure_importer(&mut imp, booted_deployment);
220    Ok(imp)
221}
222
223pub(crate) fn check_bootc_label(config: &ostree_ext::oci_spec::image::ImageConfiguration) {
224    if let Some(label) =
225        labels_of_config(config).and_then(|labels| labels.get(crate::metadata::BOOTC_COMPAT_LABEL))
226    {
227        match label.as_str() {
228            crate::metadata::COMPAT_LABEL_V1 => {}
229            o => crate::journal::journal_print(
230                libsystemd::logging::Priority::Warning,
231                &format!(
232                    "notice: Unknown {} value {}",
233                    crate::metadata::BOOTC_COMPAT_LABEL,
234                    o
235                ),
236            ),
237        }
238    } else {
239        crate::journal::journal_print(
240            libsystemd::logging::Priority::Warning,
241            &format!(
242                "notice: Image is missing label: {}",
243                crate::metadata::BOOTC_COMPAT_LABEL
244            ),
245        )
246    }
247}
248
249fn descriptor_of_progress(p: &ImportProgress) -> &Descriptor {
250    match p {
251        ImportProgress::OstreeChunkStarted(l) => l,
252        ImportProgress::OstreeChunkCompleted(l) => l,
253        ImportProgress::DerivedLayerStarted(l) => l,
254        ImportProgress::DerivedLayerCompleted(l) => l,
255    }
256}
257
258fn prefix_of_progress(p: &ImportProgress) -> &'static str {
259    match p {
260        ImportProgress::OstreeChunkStarted(_) | ImportProgress::OstreeChunkCompleted(_) => {
261            "ostree chunk"
262        }
263        ImportProgress::DerivedLayerStarted(_) | ImportProgress::DerivedLayerCompleted(_) => {
264            "layer"
265        }
266    }
267}
268
269/// Configuration for layer progress printing
270struct LayerProgressConfig {
271    layers: tokio::sync::mpsc::Receiver<ostree_container::store::ImportProgress>,
272    layer_bytes: tokio::sync::watch::Receiver<Option<ostree_container::store::LayerProgress>>,
273    digest: Box<str>,
274    n_layers_to_fetch: usize,
275    layers_total: usize,
276    bytes_to_download: u64,
277    bytes_total: u64,
278    progress: PullProgress,
279}
280
281/// Write container fetch progress to standard output.
282async fn handle_layer_progress_print(mut config: LayerProgressConfig) {
283    let start = std::time::Instant::now();
284    let mut total_read = 0u64;
285    let bar = &config.progress.bars;
286    let layers_bar = bar.add(indicatif::ProgressBar::new(
287        config.n_layers_to_fetch.try_into().unwrap(),
288    ));
289    let byte_bar = bar.add(indicatif::ProgressBar::new(0));
290    // let byte_bar = indicatif::ProgressBar::new(0);
291    // byte_bar.set_draw_target(indicatif::ProgressDrawTarget::hidden());
292    layers_bar.set_style(
293        indicatif::ProgressStyle::default_bar()
294            .template("{prefix} {bar} {pos}/{len} {wide_msg}")
295            .unwrap(),
296    );
297    let taskname = "Fetching layers";
298    layers_bar.set_prefix(taskname);
299    layers_bar.set_message("");
300    byte_bar.set_prefix("Fetching");
301    byte_bar.set_style(
302        indicatif::ProgressStyle::default_bar()
303                .template(
304                    " └ {prefix} {bar} {binary_bytes}/{binary_total_bytes} ({binary_bytes_per_sec}) {wide_msg}",
305                )
306                .unwrap()
307        );
308
309    let mut subtasks = vec![];
310    let mut subtask: SubTaskBytes = Default::default();
311    loop {
312        tokio::select! {
313            // Always handle layer changes first.
314            biased;
315            layer = config.layers.recv() => {
316                if let Some(l) = layer {
317                    let layer = descriptor_of_progress(&l);
318                    let layer_type = prefix_of_progress(&l);
319                    let short_digest = &layer.digest().digest()[0..21];
320                    let layer_size = layer.size();
321                    if l.is_starting() {
322                        // Reset the progress bar
323                        byte_bar.reset_elapsed();
324                        byte_bar.reset_eta();
325                        byte_bar.set_length(layer_size);
326                        byte_bar.set_message(format!("{layer_type} {short_digest}"));
327
328                        subtask = SubTaskBytes {
329                            subtask: layer_type.into(),
330                            description: format!("{layer_type}: {short_digest}").clone().into(),
331                            id: short_digest.to_string().clone().into(),
332                            bytes_cached: 0,
333                            bytes: 0,
334                            bytes_total: layer_size,
335                        };
336                    } else {
337                        // Use the bar's length (actual blob size) rather than
338                        // the manifest descriptor size for completion accounting.
339                        let actual_size = byte_bar.length().unwrap_or(layer_size);
340                        byte_bar.set_position(actual_size);
341                        layers_bar.inc(1);
342                        total_read = total_read.saturating_add(actual_size);
343                        // Emit an event where bytes == total to signal completion.
344                        subtask.bytes_total = actual_size;
345                        subtask.bytes = actual_size;
346                        subtasks.push(subtask.clone());
347                        config.progress.json.send(Event::ProgressBytes {
348                            task: "pulling".into(),
349                            description: format!("Pulling Image: {}", config.digest).into(),
350                            id: (*config.digest).into(),
351                            bytes_cached: config.bytes_total - config.bytes_to_download,
352                            bytes: total_read,
353                            bytes_total: config.bytes_to_download,
354                            steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64,
355                            steps: layers_bar.position(),
356                            steps_total: config.n_layers_to_fetch as u64,
357                            subtasks: subtasks.clone(),
358                        }).await;
359                    }
360                } else {
361                    // If the receiver is disconnected, then we're done
362                    break
363                };
364            },
365            r = config.layer_bytes.changed() => {
366                if r.is_err() {
367                    // If the receiver is disconnected, then we're done
368                    break
369                }
370                let bytes = {
371                    let bytes = config.layer_bytes.borrow_and_update();
372                    bytes.as_ref().cloned()
373                };
374                if let Some(bytes) = bytes {
375                    // Update the bar length from the actual blob size, which
376                    // may differ from the manifest descriptor size (e.g.
377                    // containers-storage stores layers uncompressed).
378                    byte_bar.set_length(bytes.total);
379                    byte_bar.set_position(bytes.fetched);
380                    subtask.bytes_total = bytes.total;
381                    subtask.bytes = byte_bar.position();
382                    config.progress.json.send_lossy(Event::ProgressBytes {
383                        task: "pulling".into(),
384                        description: format!("Pulling Image: {}", config.digest).into(),
385                        id: (*config.digest).into(),
386                        bytes_cached: config.bytes_total - config.bytes_to_download,
387                        bytes: total_read + byte_bar.position(),
388                        bytes_total: config.bytes_to_download,
389                        steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64,
390                        steps: layers_bar.position(),
391                        steps_total: config.n_layers_to_fetch as u64,
392                        subtasks: subtasks.clone().into_iter().chain([subtask.clone()]).collect(),
393                    }).await;
394                }
395            }
396        }
397    }
398    byte_bar.finish_and_clear();
399    layers_bar.finish_and_clear();
400    bar.remove(&byte_bar);
401    bar.remove(&layers_bar);
402    config.progress.clear();
403    let end = std::time::Instant::now();
404    let elapsed = end.duration_since(start);
405    let persec = total_read as f64 / elapsed.as_secs_f64();
406    let persec = indicatif::HumanBytes(persec as u64);
407    config.progress.println(format!(
408        "Fetched layers: {} in {} ({}/s)",
409        indicatif::HumanBytes(total_read),
410        indicatif::HumanDuration(elapsed),
411        persec,
412    ));
413
414    // Since the progress notifier closed, we know import has started
415    // use as a heuristic to begin import progress
416    // Cannot be lossy or it is dropped
417    config
418        .progress
419        .json
420        .send(Event::ProgressSteps {
421            task: "importing".into(),
422            description: "Importing Image".into(),
423            id: (*config.digest).into(),
424            steps_cached: 0,
425            steps: 0,
426            steps_total: 1,
427            subtasks: [SubTaskStep {
428                subtask: "importing".into(),
429                description: "Importing Image".into(),
430                id: "importing".into(),
431                completed: false,
432            }]
433            .into(),
434        })
435        .await;
436}
437
438/// Gather all bound images in all deployments, then prune the image store,
439/// using the gathered images as the roots (that will not be GC'd).
440pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> {
441    let ostree = sysroot.get_ostree()?;
442    let deployments = ostree.deployments();
443    let mut all_bound_images = Vec::new();
444    for deployment in deployments {
445        let bound = crate::boundimage::query_bound_images_for_deployment(ostree, &deployment)?;
446        all_bound_images.extend(bound.into_iter());
447        // Also include the host image itself
448        // Note: Use just the image name (not the full transport:image format) because
449        // podman's image names don't include the transport prefix.
450        if let Some(host_image) = crate::status::boot_entry_from_deployment(ostree, &deployment)?
451            .image
452            .map(|i| i.image)
453        {
454            all_bound_images.push(crate::boundimage::BoundImage {
455                image: host_image.image.clone(),
456                auth_file: None,
457            });
458        }
459    }
460    // Convert to a hashset of just the image names
461    let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str()));
462    let pruned = sysroot
463        .get_ensure_imgstore()?
464        .prune_except_roots(&image_names)
465        .await?;
466    tracing::debug!("Pruned images: {}", pruned.len());
467    Ok(())
468}
469
470/// Core disk space check: verify that `bytes_to_fetch` fits within available space,
471/// leaving at least `min_free` bytes reserved.
472fn check_disk_space_inner(
473    fd: impl AsFd,
474    bytes_to_fetch: u64,
475    min_free: u64,
476    imgref: &ImageReference,
477) -> Result<()> {
478    let stat = rustix::fs::fstatvfs(fd)?;
479    let bytes_avail = stat.f_bsize.checked_mul(stat.f_bavail).unwrap_or(u64::MAX);
480    let usable = bytes_avail.saturating_sub(min_free);
481    tracing::trace!("bytes_avail: {bytes_avail} min_free: {min_free} usable: {usable}");
482
483    if bytes_to_fetch > usable {
484        anyhow::bail!(
485            "Insufficient free space for {image} (available: {available} required: {required})",
486            available = ostree_ext::glib::format_size(usable),
487            required = ostree_ext::glib::format_size(bytes_to_fetch),
488            image = imgref.image,
489        );
490    }
491    Ok(())
492}
493
494/// Verify there is sufficient disk space to pull an image into the ostree repo.
495/// Respects the repository's configured min-free-space threshold.
496pub(crate) fn check_disk_space_ostree(
497    repo: &ostree::Repo,
498    image_meta: &PreparedImportMeta,
499    imgref: &ImageReference,
500) -> Result<()> {
501    let min_free = repo.min_free_space_bytes().unwrap_or(0);
502    check_disk_space_inner(
503        repo.dfd_borrow(),
504        image_meta.bytes_to_fetch,
505        min_free,
506        imgref,
507    )
508}
509
510/// Verify there is sufficient disk space to pull an image into the composefs store
511/// via the ostree unified-storage path (uses `PreparedImportMeta`).
512pub(crate) fn check_disk_space_unified(
513    cfs: &crate::store::ComposefsRepository,
514    image_meta: &PreparedImportMeta,
515    imgref: &ImageReference,
516) -> Result<()> {
517    check_disk_space_inner(cfs.objects_dir()?, image_meta.bytes_to_fetch, 0, imgref)
518}
519
520/// Verify there is sufficient disk space to pull an image into the composefs store
521/// for the native composefs backend (uses a raw `ImageManifest`).
522pub(crate) fn check_disk_space_composefs(
523    cfs: &crate::store::ComposefsRepository,
524    manifest: &ostree_ext::oci_spec::image::ImageManifest,
525    imgref: &ImageReference,
526) -> Result<()> {
527    let bytes_to_fetch: u64 = manifest
528        .layers()
529        .iter()
530        .map(|l: &ostree_ext::oci_spec::image::Descriptor| l.size())
531        .sum();
532    check_disk_space_inner(cfs.objects_dir()?, bytes_to_fetch, 0, imgref)
533}
534
535pub(crate) struct PreparedImportMeta {
536    pub imp: ImageImporter,
537    pub prep: Box<PreparedImport>,
538    pub digest: Digest,
539    pub n_layers_to_fetch: usize,
540    pub layers_total: usize,
541    pub bytes_to_fetch: u64,
542    pub bytes_total: u64,
543}
544
545pub(crate) enum PreparedPullResult {
546    Ready(Box<PreparedImportMeta>),
547    AlreadyPresent(Box<ImageState>),
548}
549
550pub(crate) async fn prepare_for_pull(
551    repo: &ostree::Repo,
552    imgref: &ImageReference,
553    target_imgref: Option<&OstreeImageReference>,
554    booted_deployment: Option<&ostree::Deployment>,
555) -> Result<PreparedPullResult> {
556    let imgref_canonicalized = imgref.clone().canonicalize()?;
557    tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
558    let ostree_imgref = &OstreeImageReference::from(imgref_canonicalized);
559    let mut imp = new_importer(repo, ostree_imgref, booted_deployment).await?;
560    if let Some(target) = target_imgref {
561        imp.set_target(target);
562    }
563    let prep = match imp.prepare().await? {
564        PrepareResult::AlreadyPresent(c) => {
565            println!("No changes in {imgref:#} => {}", c.manifest_digest);
566            return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
567        }
568        PrepareResult::Ready(p) => p,
569    };
570    Ok(PreparedPullResult::Ready(Box::new(
571        prepared_import_meta(imp, prep).await?,
572    )))
573}
574
575/// Report on what a prepared import is going to do, and tally it up for the
576/// progress display.
577async fn prepared_import_meta(
578    imp: ImageImporter,
579    prep: Box<PreparedImport>,
580) -> Result<PreparedImportMeta> {
581    check_bootc_label(&prep.config);
582    if let Some(warning) = prep.deprecated_warning() {
583        ostree_ext::cli::print_deprecated_warning(warning).await;
584    }
585    ostree_ext::cli::print_layer_status(&prep);
586    let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
587
588    Ok(PreparedImportMeta {
589        imp,
590        n_layers_to_fetch: layers_to_fetch.len(),
591        layers_total: prep.all_layers().count(),
592        bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
593        bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
594        digest: prep.manifest_digest.clone(),
595        prep,
596    })
597}
598
599/// Prepare an import whose metadata and layer content both come from a delta
600/// file plus the source image already in `repo`; nothing is fetched.
601pub(crate) async fn prepare_for_pull_delta(
602    repo: &ostree::Repo,
603    progress: &PullProgress,
604    imgref: &ImageReference,
605    delta: Arc<crate::delta::Delta>,
606    booted_deployment: Option<&ostree::Deployment>,
607) -> Result<PreparedPullResult> {
608    delta.validate_image_reference(imgref)?;
609    let imgref_canonicalized = imgref.clone().canonicalize()?;
610    tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
611    let ostree_imgref = &OstreeImageReference::from(imgref_canonicalized);
612
613    let mut imp = ImageImporter::new_without_proxy(repo, ostree_imgref)?;
614    configure_importer(&mut imp, booted_deployment);
615
616    let layer_source = Box::new(crate::delta_ostree::DeltaLayerSource::new(
617        Arc::clone(&delta),
618        repo,
619    ));
620    let prep = match imp
621        .prepare_from_manifest(
622            delta.target_manifest_digest().clone(),
623            delta.target_manifest().clone(),
624            delta.target_config().clone(),
625            layer_source,
626        )
627        .await?
628    {
629        PrepareResult::AlreadyPresent(c) => {
630            progress.println(format!("No changes in {imgref:#} => {}", c.manifest_digest));
631            return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
632        }
633        PrepareResult::Ready(p) => p,
634    };
635
636    Ok(PreparedPullResult::Ready(Box::new(
637        prepared_import_meta(imp, prep).await?,
638    )))
639}
640
641/// Wrapper for applying a delta, wiring up status output.
642pub(crate) async fn pull_delta(
643    repo: &ostree::Repo,
644    imgref: &ImageReference,
645    delta: Arc<crate::delta::Delta>,
646    quiet: bool,
647    prog: ProgressWriter,
648    booted_deployment: Option<&ostree::Deployment>,
649) -> Result<Box<ImageState>> {
650    let progress = PullProgress::new(quiet, prog);
651    progress.println(format!("Applying delta {}", delta.describe()));
652    match prepare_for_pull_delta(repo, &progress, imgref, delta, booted_deployment).await? {
653        PreparedPullResult::AlreadyPresent(existing) => Ok(existing),
654        PreparedPullResult::Ready(prepared_image_meta) => {
655            check_disk_space_ostree(repo, &prepared_image_meta, imgref)?;
656            let r = pull_from_prepared(imgref, progress, *prepared_image_meta).await;
657            if r.is_err() {
658                // The importer publishes a layer's ref before the reconstruction
659                // that feeds it has finished being verified, so a failed apply
660                // can leave a ref to content that never passed its diff_id
661                // check. Drop the unreferenced layers so a retry cannot pick one
662                // up; failing to do so is not worth masking the real error.
663                if let Err(e) = ostree_container::store::gc_image_layers(repo) {
664                    tracing::warn!("Pruning layers after failed delta apply: {e:#}");
665                }
666            }
667            r
668        }
669    }
670}
671
672/// Check whether the image exists in bootc's unified container storage.
673///
674/// This is used for auto-detection: if the image already exists in bootc storage
675/// (e.g., from a previous `bootc image set-unified` or LBI pull), we can use
676/// the unified storage path for faster imports.
677///
678/// Returns true if the image exists in bootc storage.
679pub(crate) async fn image_exists_in_unified_storage(
680    store: &Storage,
681    imgref: &ImageReference,
682) -> Result<bool> {
683    let imgstore = store.get_ensure_imgstore()?;
684    let image_ref_str = imgref.to_transport_image()?;
685    imgstore.exists(&image_ref_str).await
686}
687
688/// Unified approach: Use bootc's CStorage to pull the image, then prepare from containers-storage.
689/// This reuses the same infrastructure as LBIs.
690pub(crate) async fn prepare_for_pull_unified(
691    repo: &ostree::Repo,
692    imgref: &ImageReference,
693    target_imgref: Option<&OstreeImageReference>,
694    store: &Storage,
695    booted_deployment: Option<&ostree::Deployment>,
696) -> Result<PreparedPullResult> {
697    // Get or initialize the bootc container storage (same as used for LBIs)
698    let imgstore = store.get_ensure_imgstore()?;
699
700    let image_ref_str = imgref.to_transport_image()?;
701
702    // Always pull to ensure we have the latest image, whether from a remote
703    // registry or a locally rebuilt image
704    tracing::info!(
705        "Unified pull: pulling from transport '{}' to bootc storage",
706        &imgref.transport
707    );
708
709    // Pull the image into bootc containers-storage with per-layer
710    // download progress via the podman native API.
711    imgstore
712        .pull_with_progress(&image_ref_str)
713        .await
714        .context("Pulling image into bootc containers-storage")?;
715
716    // Now create a containers-storage reference to read from bootc storage
717    tracing::info!("Unified pull: now importing from containers-storage transport");
718    let containers_storage_imgref = ImageReference {
719        transport: "containers-storage".to_string(),
720        image: imgref.image.clone(),
721        signature: imgref.signature.clone(),
722    };
723    let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
724
725    // Configure the importer to use bootc storage as an additional image store
726    let mut config = new_proxy_config();
727    let mut cmd = Command::new(skopeo_bin());
728    // Use the physical path to bootc storage from the Storage struct
729    let storage_path = format!(
730        "{}/{}",
731        store.physical_root_path,
732        crate::podstorage::CStorage::subpath()
733    );
734    crate::podstorage::set_additional_image_store(&mut cmd, &storage_path);
735    config.skopeo_cmd = Some(cmd);
736
737    // Use the preparation flow with the custom config
738    let mut imp = new_importer_with_config(repo, &ostree_imgref, config, booted_deployment).await?;
739    if let Some(target) = target_imgref {
740        imp.set_target(target);
741    }
742    let prep = match imp.prepare().await? {
743        PrepareResult::AlreadyPresent(c) => {
744            println!("No changes in {imgref:#} => {}", c.manifest_digest);
745            return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
746        }
747        PrepareResult::Ready(p) => p,
748    };
749    check_bootc_label(&prep.config);
750    if let Some(warning) = prep.deprecated_warning() {
751        ostree_ext::cli::print_deprecated_warning(warning).await;
752    }
753    ostree_ext::cli::print_layer_status(&prep);
754    let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
755
756    // Log that we're importing a new image from containers-storage
757    const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
758    tracing::info!(
759        message_id = PULLING_NEW_IMAGE_ID,
760        bootc.image.reference = &imgref.image,
761        bootc.image.transport = "containers-storage",
762        bootc.original_transport = &imgref.transport,
763        bootc.status = "importing_from_storage",
764        "Importing image from bootc storage: {}",
765        ostree_imgref
766    );
767
768    let prepared_image = PreparedImportMeta {
769        imp,
770        n_layers_to_fetch: layers_to_fetch.len(),
771        layers_total: prep.all_layers().count(),
772        bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
773        bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
774        digest: prep.manifest_digest.clone(),
775        prep,
776    };
777
778    Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
779}
780
781/// Unified pull: Use podman to pull to containers-storage, then read from there
782pub(crate) async fn pull_unified(
783    repo: &ostree::Repo,
784    imgref: &ImageReference,
785    target_imgref: Option<&OstreeImageReference>,
786    quiet: bool,
787    prog: ProgressWriter,
788    store: &Storage,
789    booted_deployment: Option<&ostree::Deployment>,
790) -> Result<Box<ImageState>> {
791    let progress = PullProgress::new(quiet, prog);
792    match prepare_for_pull_unified(repo, imgref, target_imgref, store, booted_deployment).await? {
793        PreparedPullResult::AlreadyPresent(existing) => {
794            // Log that the image was already present (Debug level since it's not actionable)
795            const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
796            tracing::debug!(
797                message_id = IMAGE_ALREADY_PRESENT_ID,
798                bootc.image.reference = &imgref.image,
799                bootc.image.transport = &imgref.transport,
800                bootc.status = "already_present",
801                "Image already present: {}",
802                imgref
803            );
804            Ok(existing)
805        }
806        PreparedPullResult::Ready(prepared_image_meta) => {
807            check_disk_space_unified(
808                store.get_ensure_composefs()?.as_ref(),
809                &prepared_image_meta,
810                imgref,
811            )?;
812            // To avoid duplicate success logs, pass a containers-storage imgref to the importer
813            let cs_imgref = ImageReference {
814                transport: "containers-storage".to_string(),
815                image: imgref.image.clone(),
816                signature: imgref.signature.clone(),
817            };
818            pull_from_prepared(&cs_imgref, progress, *prepared_image_meta).await
819        }
820    }
821}
822
823#[context("Pulling")]
824pub(crate) async fn pull_from_prepared(
825    imgref: &ImageReference,
826    progress: PullProgress,
827    mut prepared_image: PreparedImportMeta,
828) -> Result<Box<ImageState>> {
829    let layer_progress = prepared_image.imp.request_progress();
830    let layer_byte_progress = prepared_image.imp.request_layer_progress();
831    let digest = prepared_image.digest.clone();
832    let digest_imp = prepared_image.digest.clone();
833
834    let printer_progress = progress.clone();
835    let printer = tokio::task::spawn(async move {
836        handle_layer_progress_print(LayerProgressConfig {
837            layers: layer_progress,
838            layer_bytes: layer_byte_progress,
839            digest: digest.as_ref().into(),
840            n_layers_to_fetch: prepared_image.n_layers_to_fetch,
841            layers_total: prepared_image.layers_total,
842            bytes_to_download: prepared_image.bytes_to_fetch,
843            bytes_total: prepared_image.bytes_total,
844            progress: printer_progress,
845        })
846        .await
847    });
848    let import = prepared_image.imp.import(prepared_image.prep).await;
849    printer.await?;
850    // Both the progress and the import are done, so import is done as well
851    progress
852        .json
853        .send(Event::ProgressSteps {
854            task: "importing".into(),
855            description: "Importing Image".into(),
856            id: digest_imp.clone().as_ref().into(),
857            steps_cached: 0,
858            steps: 1,
859            steps_total: 1,
860            subtasks: [SubTaskStep {
861                subtask: "importing".into(),
862                description: "Importing Image".into(),
863                id: "importing".into(),
864                completed: true,
865            }]
866            .into(),
867        })
868        .await;
869    let import = import?;
870    let imgref_canonicalized = imgref.clone().canonicalize()?;
871    tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
872
873    // Log successful import completion (skip if using unified storage to avoid double logging)
874    let is_unified_path = imgref.transport == "containers-storage";
875    if !is_unified_path {
876        const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
877
878        tracing::info!(
879            message_id = IMPORT_COMPLETE_JOURNAL_ID,
880            bootc.image.reference = &imgref.image,
881            bootc.image.transport = &imgref.transport,
882            bootc.manifest_digest = import.manifest_digest.as_ref(),
883            bootc.ostree_commit = &import.merge_commit,
884            "Successfully imported image: {}",
885            imgref
886        );
887    }
888
889    if let Some(msg) =
890        ostree_container::store::image_filtered_content_warning(&import.filtered_files)
891            .context("Image content warning")?
892    {
893        tracing::info!("{}", msg);
894    }
895    Ok(Box::new((*import).into()))
896}
897
898fn is_retryable_pull_error(error: &anyhow::Error) -> bool {
899    // TODO: Also classify transient prepare/OpenImage failures once
900    // containers-image-proxy exposes a typed error for them. Its current
901    // RequestInitiationFailure cannot safely distinguish DNS/TCP failures from
902    // permanent errors such as a missing image or a signature-policy rejection.
903    // https://github.com/bootc-dev/bootc/issues/2466
904    error.chain().any(|source| {
905        matches!(
906            source.downcast_ref::<ostree_ext::containers_image_proxy::Error>(),
907            Some(ostree_ext::containers_image_proxy::Error::BlobError(
908                ostree_ext::containers_image_proxy::GetBlobError::Retryable(_)
909            ))
910        )
911    })
912}
913
914fn pull_retry_delay(retry: u32) -> Duration {
915    PULL_RETRY_INITIAL_DELAY.saturating_mul(2_u32.saturating_pow(retry))
916}
917
918pub(crate) async fn retry_pull_operation<F, Fut, T>(
919    progress: &PullProgress,
920    mut operation: F,
921) -> Result<T>
922where
923    F: FnMut() -> Fut,
924    Fut: std::future::Future<Output = Result<T>>,
925{
926    let mut retries = 0;
927    loop {
928        match operation().await {
929            Ok(value) => return Ok(value),
930            Err(error) if retries < PULL_MAX_RETRIES && is_retryable_pull_error(&error) => {
931                let retry_delay = pull_retry_delay(retries);
932                let attempt = retries + 1;
933                let description = format!(
934                    "Container image pull failed; retrying in {} seconds ({attempt}/{PULL_MAX_RETRIES}): {error}",
935                    retry_delay.as_secs()
936                );
937                progress
938                    .send(Event::ProgressSteps {
939                        task: "pulling".into(),
940                        description: description.into(),
941                        id: "pull-retry".into(),
942                        steps_cached: 0,
943                        steps: attempt.into(),
944                        steps_total: PULL_MAX_RETRIES.into(),
945                        subtasks: Vec::new(),
946                    })
947                    .await;
948                retries += 1;
949                tokio::time::sleep(retry_delay).await;
950            }
951            Err(error) => return Err(error),
952        }
953    }
954}
955
956async fn pull_once(
957    repo: &ostree::Repo,
958    imgref: &ImageReference,
959    target_imgref: Option<&OstreeImageReference>,
960    progress: PullProgress,
961    booted_deployment: Option<&ostree::Deployment>,
962) -> Result<Box<ImageState>> {
963    match prepare_for_pull(repo, imgref, target_imgref, booted_deployment).await? {
964        PreparedPullResult::AlreadyPresent(existing) => {
965            // Log that the image was already present (Debug level since it's not actionable)
966            const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
967            tracing::debug!(
968                message_id = IMAGE_ALREADY_PRESENT_ID,
969                bootc.image.reference = &imgref.image,
970                bootc.image.transport = &imgref.transport,
971                bootc.status = "already_present",
972                "Image already present: {}",
973                imgref
974            );
975            Ok(existing)
976        }
977        PreparedPullResult::Ready(prepared_image_meta) => {
978            // Check disk space before attempting to pull
979            check_disk_space_ostree(repo, &prepared_image_meta, imgref)?;
980            // Log that we're pulling a new image
981            const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
982            tracing::info!(
983                message_id = PULLING_NEW_IMAGE_ID,
984                bootc.image.reference = &imgref.image,
985                bootc.image.transport = &imgref.transport,
986                bootc.status = "pulling_new",
987                "Pulling new image: {}",
988                imgref
989            );
990            Ok(pull_from_prepared(imgref, progress, *prepared_image_meta).await?)
991        }
992    }
993}
994
995/// Wrapper for pulling a container image, wiring up status output.
996pub(crate) async fn pull(
997    repo: &ostree::Repo,
998    imgref: &ImageReference,
999    target_imgref: Option<&OstreeImageReference>,
1000    quiet: bool,
1001    prog: ProgressWriter,
1002    booted_deployment: Option<&ostree::Deployment>,
1003) -> Result<Box<ImageState>> {
1004    let progress = PullProgress::new(quiet, prog);
1005    let operation = || {
1006        pull_once(
1007            repo,
1008            imgref,
1009            target_imgref,
1010            progress.clone(),
1011            booted_deployment,
1012        )
1013    };
1014    if imgref.transport != "registry" {
1015        return operation().await;
1016    }
1017    retry_pull_operation(&progress, operation).await
1018}
1019
1020pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> {
1021    tokio::task::spawn_blocking(move || {
1022        sysroot
1023            .write_deployments(&[], gio::Cancellable::NONE)
1024            .context("removing deployments")
1025    })
1026    .await??;
1027
1028    Ok(())
1029}
1030
1031pub(crate) async fn cleanup(sysroot: &Storage) -> Result<()> {
1032    // Log the cleanup operation to systemd journal
1033    const CLEANUP_JOURNAL_ID: &str = "2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6";
1034
1035    tracing::info!(
1036        message_id = CLEANUP_JOURNAL_ID,
1037        "Starting cleanup of old images and deployments"
1038    );
1039
1040    let bound_prune = prune_container_store(sysroot);
1041
1042    // We create clones (just atomic reference bumps) here to move to the thread.
1043    let ostree = sysroot.get_ostree_cloned()?;
1044    let repo = ostree.repo();
1045    let repo_prune =
1046        ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
1047            let locked_sysroot = &SysrootLock::from_assumed_locked(&ostree);
1048            let cancellable = Some(cancellable);
1049            let repo = &repo;
1050            let txn = repo.auto_transaction(cancellable)?;
1051            let repo = txn.repo();
1052
1053            // Regenerate our base references.  First, we delete the ones that exist
1054            for ref_entry in repo
1055                .list_refs_ext(
1056                    Some(BASE_IMAGE_PREFIX),
1057                    ostree::RepoListRefsExtFlags::NONE,
1058                    cancellable,
1059                )
1060                .context("Listing refs")?
1061                .keys()
1062            {
1063                repo.transaction_set_refspec(ref_entry, None);
1064            }
1065
1066            // Then, for each deployment which is derived (e.g. has configmaps) we synthesize
1067            // a base ref to ensure that it's not GC'd.
1068            for (i, deployment) in ostree.deployments().into_iter().enumerate() {
1069                let commit = deployment.csum();
1070                if let Some(base) = get_base_commit(repo, &commit)? {
1071                    repo.transaction_set_refspec(&format!("{BASE_IMAGE_PREFIX}/{i}"), Some(&base));
1072                }
1073            }
1074
1075            let pruned =
1076                ostree_container::deploy::prune(locked_sysroot).context("Pruning images")?;
1077            if !pruned.is_empty() {
1078                let size = glib::format_size(pruned.objsize);
1079                println!(
1080                    "Pruned images: {} (layers: {}, objsize: {})",
1081                    pruned.n_images, pruned.n_layers, size
1082                );
1083            } else {
1084                tracing::debug!("Nothing to prune");
1085            }
1086
1087            Ok(())
1088        });
1089
1090    // We run these in parallel mostly because we can.
1091    tokio::try_join!(repo_prune, bound_prune)?;
1092    Ok(())
1093}
1094
1095/// If commit is a bootc-derived commit (e.g. has configmaps), return its base.
1096#[context("Finding base commit")]
1097pub(crate) fn get_base_commit(repo: &ostree::Repo, commit: &str) -> Result<Option<String>> {
1098    let commitv = repo.load_commit(commit)?.0;
1099    let commitmeta = commitv.child_value(0);
1100    let commitmeta = &glib::VariantDict::new(Some(&commitmeta));
1101    let r = commitmeta.lookup::<String>(BOOTC_DERIVED_KEY)?;
1102    Ok(r)
1103}
1104
1105#[context("Writing deployment")]
1106async fn deploy(
1107    sysroot: &Storage,
1108    from: MergeState,
1109    image: &ImageState,
1110    origin: &glib::KeyFile,
1111    lock_finalization: bool,
1112) -> Result<Deployment> {
1113    // Compute the kernel argument overrides. In practice today this API is always expecting
1114    // a merge deployment. The kargs code also always looks at the booted root (which
1115    // is a distinct minor issue, but not super important as right now the install path
1116    // doesn't use this API).
1117    let (stateroot, override_kargs) = match &from {
1118        MergeState::MergeDeployment(deployment) => {
1119            let kargs = crate::bootc_kargs::get_kargs(sysroot, &deployment, image)?;
1120            (deployment.stateroot().into(), Some(kargs))
1121        }
1122        MergeState::Reset { stateroot, kargs } => (stateroot.clone(), Some(kargs.clone())),
1123    };
1124    // Clone all the things to move to worker thread
1125    let ostree = sysroot.get_ostree_cloned()?;
1126    // ostree::Deployment is incorrectly !Send 😢 so convert it to an integer
1127    let merge_deployment = from.as_merge_deployment();
1128    let merge_deployment = merge_deployment.map(|d| d.index() as usize);
1129    let ostree_commit = image.ostree_commit.to_string();
1130    // GKeyFile also isn't Send! So we serialize that as a string...
1131    let origin_data = origin.to_data();
1132    let r = async_task_with_spinner(
1133        "Deploying",
1134        spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> {
1135            let ostree = ostree;
1136            let stateroot = Some(stateroot);
1137            let mut opts = ostree::SysrootDeployTreeOpts::default();
1138
1139            // Set finalization lock if requested
1140            opts.locked = lock_finalization;
1141
1142            // Because the C API expects a Vec<&str>, convert the Cmdline to string slices.
1143            // The references borrow from the Cmdline, which outlives this usage.
1144            let override_kargs_refs = override_kargs
1145                .as_ref()
1146                .map(|kargs| kargs.iter_str().collect::<Vec<_>>());
1147            if let Some(kargs) = override_kargs_refs.as_ref() {
1148                opts.override_kernel_argv = Some(kargs);
1149            }
1150
1151            let deployments = ostree.deployments();
1152            let merge_deployment = merge_deployment.map(|m| &deployments[m]);
1153            let origin = glib::KeyFile::new();
1154            origin.load_from_data(&origin_data, glib::KeyFileFlags::NONE)?;
1155            let d = ostree.stage_tree_with_options(
1156                stateroot.as_deref(),
1157                &ostree_commit,
1158                Some(&origin),
1159                merge_deployment,
1160                &opts,
1161                Some(cancellable),
1162            )?;
1163            Ok(d.index())
1164        }),
1165    )
1166    .await?;
1167    // SAFETY: We must have a staged deployment
1168    let ostree = sysroot.get_ostree()?;
1169    let staged = ostree.staged_deployment().unwrap();
1170    assert_eq!(staged.index(), r);
1171    Ok(staged)
1172}
1173
1174#[context("Generating origin")]
1175fn origin_from_imageref(imgref: &ImageReference) -> Result<glib::KeyFile> {
1176    let origin = glib::KeyFile::new();
1177    let imgref = OstreeImageReference::from(imgref.clone());
1178    origin.set_string(
1179        "origin",
1180        ostree_container::deploy::ORIGIN_CONTAINER,
1181        imgref.to_string().as_str(),
1182    );
1183    Ok(origin)
1184}
1185
1186/// The source of data for staging a new deployment
1187#[derive(Debug)]
1188pub(crate) enum MergeState {
1189    /// Use the provided merge deployment
1190    MergeDeployment(Deployment),
1191    /// Don't use a merge deployment, but only this
1192    /// provided initial state.
1193    Reset {
1194        stateroot: String,
1195        kargs: CmdlineOwned,
1196    },
1197}
1198impl MergeState {
1199    /// Initialize using the default merge deployment for the given stateroot.
1200    pub(crate) fn from_stateroot(sysroot: &Storage, stateroot: &str) -> Result<Self> {
1201        let ostree = sysroot.get_ostree()?;
1202        let merge_deployment = ostree.merge_deployment(Some(stateroot)).ok_or_else(|| {
1203            anyhow::anyhow!("No merge deployment found for stateroot {stateroot}")
1204        })?;
1205        Ok(Self::MergeDeployment(merge_deployment))
1206    }
1207
1208    /// Cast this to a merge deployment case.
1209    pub(crate) fn as_merge_deployment(&self) -> Option<&Deployment> {
1210        match self {
1211            Self::MergeDeployment(d) => Some(d),
1212            Self::Reset { .. } => None,
1213        }
1214    }
1215}
1216
1217/// Pull the bound images referenced by an imported commit before staging it.
1218#[context("Pulling bound images for ostree commit {commit}")]
1219async fn pull_bound_images_for_commit(sysroot: &Storage, commit: &str) -> Result<()> {
1220    let repo = sysroot.get_ostree()?.repo();
1221    let repo_dir = Dir::reopen_dir(&repo.dfd_borrow())?;
1222    let repo_tmp = repo_dir
1223        .open_dir("tmp")
1224        .context("Opening ostree repo tmp/")?;
1225    let td = cap_std_ext::cap_tempfile::TempDir::new_in(&repo_tmp)?;
1226    let checkout_mode = if repo.mode() == ostree::RepoMode::Bare {
1227        ostree::RepoCheckoutMode::None
1228    } else {
1229        ostree::RepoCheckoutMode::User
1230    };
1231    let checkout_opts = ostree::RepoCheckoutAtOptions {
1232        mode: checkout_mode,
1233        ..Default::default()
1234    };
1235    let root_name = "root";
1236    repo.checkout_at(
1237        Some(&checkout_opts),
1238        td.as_raw_fd(),
1239        root_name,
1240        commit,
1241        gio::Cancellable::NONE,
1242    )
1243    .context("Checking out imported commit")?;
1244    let root = td.open_dir(root_name)?;
1245    let bound_images = crate::boundimage::query_bound_images(&root)?;
1246    crate::boundimage::pull_images(sysroot, bound_images).await
1247}
1248
1249/// Stage (queue deployment of) a fetched container image.
1250#[context("Staging")]
1251pub(crate) async fn stage(
1252    sysroot: &Storage,
1253    from: MergeState,
1254    image: &ImageState,
1255    spec: &RequiredHostSpec<'_>,
1256    prog: ProgressWriter,
1257    lock_finalization: bool,
1258) -> Result<()> {
1259    // Log the staging operation to systemd journal with comprehensive upgrade information
1260    const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c";
1261
1262    tracing::info!(
1263        message_id = STAGE_JOURNAL_ID,
1264        bootc.image.reference = &spec.image.image,
1265        bootc.image.transport = &spec.image.transport,
1266        bootc.manifest_digest = image.manifest_digest.as_ref(),
1267        "Staging image for deployment: {} (digest: {})",
1268        spec.image,
1269        image.manifest_digest
1270    );
1271
1272    let mut subtask = SubTaskStep {
1273        subtask: "merging".into(),
1274        description: "Merging Image".into(),
1275        id: "fetching".into(),
1276        completed: false,
1277    };
1278    let mut subtasks = vec![];
1279    prog.send(Event::ProgressSteps {
1280        task: "staging".into(),
1281        description: "Deploying Image".into(),
1282        id: image.manifest_digest.clone().as_ref().into(),
1283        steps_cached: 0,
1284        steps: 0,
1285        steps_total: 3,
1286        subtasks: subtasks
1287            .clone()
1288            .into_iter()
1289            .chain([subtask.clone()])
1290            .collect(),
1291    })
1292    .await;
1293
1294    subtask.completed = true;
1295    subtasks.push(subtask.clone());
1296    subtask.subtask = "bound_images".into();
1297    subtask.id = "bound_images".into();
1298    subtask.description = "Pulling Bound Images".into();
1299    subtask.completed = false;
1300    prog.send(Event::ProgressSteps {
1301        task: "staging".into(),
1302        description: "Deploying Image".into(),
1303        id: image.manifest_digest.clone().as_ref().into(),
1304        steps_cached: 0,
1305        steps: 1,
1306        steps_total: 3,
1307        subtasks: subtasks
1308            .clone()
1309            .into_iter()
1310            .chain([subtask.clone()])
1311            .collect(),
1312    })
1313    .await;
1314    pull_bound_images_for_commit(sysroot, &image.ostree_commit).await?;
1315
1316    subtask.completed = true;
1317    subtasks.push(subtask.clone());
1318    subtask.subtask = "deploying".into();
1319    subtask.id = "deploying".into();
1320    subtask.description = "Deploying Image".into();
1321    subtask.completed = false;
1322    prog.send(Event::ProgressSteps {
1323        task: "staging".into(),
1324        description: "Deploying Image".into(),
1325        id: image.manifest_digest.clone().as_ref().into(),
1326        steps_cached: 0,
1327        steps: 1,
1328        steps_total: 3,
1329        subtasks: subtasks
1330            .clone()
1331            .into_iter()
1332            .chain([subtask.clone()])
1333            .collect(),
1334    })
1335    .await;
1336    let origin = origin_from_imageref(spec.image)?;
1337    crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?;
1338
1339    subtask.completed = true;
1340    subtasks.push(subtask.clone());
1341    subtask.subtask = "cleanup".into();
1342    subtask.id = "cleanup".into();
1343    subtask.description = "Removing old images".into();
1344    subtask.completed = false;
1345    prog.send(Event::ProgressSteps {
1346        task: "staging".into(),
1347        description: "Deploying Image".into(),
1348        id: image.manifest_digest.clone().as_ref().into(),
1349        steps_cached: 0,
1350        steps: 2,
1351        steps_total: 3,
1352        subtasks: subtasks
1353            .clone()
1354            .into_iter()
1355            .chain([subtask.clone()])
1356            .collect(),
1357    })
1358    .await;
1359    crate::deploy::cleanup(sysroot).await?;
1360
1361    if !lock_finalization {
1362        println!("Queued for next boot: {:#}", spec.image);
1363    } else {
1364        println!("Staged but not queued for next boot: {:#}", spec.image);
1365    }
1366
1367    if let Some(version) = image.version.as_deref() {
1368        println!("  Version: {version}");
1369    }
1370    println!("  Digest: {}", image.manifest_digest);
1371
1372    subtask.completed = true;
1373    subtasks.push(subtask.clone());
1374    prog.send(Event::ProgressSteps {
1375        task: "staging".into(),
1376        description: "Deploying Image".into(),
1377        id: image.manifest_digest.clone().as_ref().into(),
1378        steps_cached: 0,
1379        steps: 3,
1380        steps_total: 3,
1381        subtasks: subtasks
1382            .clone()
1383            .into_iter()
1384            .chain([subtask.clone()])
1385            .collect(),
1386    })
1387    .await;
1388
1389    // Unconditionally create or update /run/reboot-required to signal a reboot is needed.
1390    // This is monitored by kured (Kubernetes Reboot Daemon).
1391    write_reboot_required(&image.manifest_digest.as_ref())?;
1392
1393    Ok(())
1394}
1395
1396/// Update the /run/reboot-required file with the image that will be active after a reboot.
1397fn write_reboot_required(image: &str) -> Result<()> {
1398    let reboot_message = format!("bootc: Reboot required for image: {}", image);
1399    let run_dir = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?;
1400    run_dir
1401        .atomic_write("reboot-required", reboot_message.as_bytes())
1402        .context("Creating /run/reboot-required")?;
1403
1404    Ok(())
1405}
1406
1407pub(crate) const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468";
1408
1409/// Implementation of rollback functionality
1410pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> {
1411    let ostree = sysroot.get_ostree()?;
1412    let (booted_ostree, deployments, host) = crate::status::get_status_require_booted(ostree)?;
1413
1414    let new_spec = {
1415        let mut new_spec = host.spec.clone();
1416        new_spec.boot_order = new_spec.boot_order.swap();
1417        new_spec
1418    };
1419
1420    let repo = &booted_ostree.repo();
1421
1422    // Just to be sure
1423    host.spec.verify_transition(&new_spec)?;
1424
1425    let reverting = new_spec.boot_order == BootOrder::Default;
1426    if reverting {
1427        println!("notice: Reverting queued rollback state");
1428    }
1429    let rollback_status = host
1430        .status
1431        .rollback
1432        .ok_or_else(|| anyhow!("No rollback available"))?;
1433    let rollback_image = rollback_status
1434        .query_image(repo)?
1435        .ok_or_else(|| anyhow!("Rollback is not container image based"))?;
1436
1437    // Get current booted image for comparison
1438    let current_image = host
1439        .status
1440        .booted
1441        .as_ref()
1442        .and_then(|b| b.query_image(repo).ok()?);
1443
1444    tracing::info!(
1445        message_id = ROLLBACK_JOURNAL_ID,
1446        bootc.manifest_digest = rollback_image.manifest_digest.as_ref(),
1447        bootc.ostree_commit = &rollback_image.merge_commit,
1448        bootc.rollback_type = if reverting { "revert" } else { "rollback" },
1449        bootc.current_manifest_digest = current_image
1450            .as_ref()
1451            .map(|i| i.manifest_digest.as_ref())
1452            .unwrap_or("none"),
1453        "Rolling back to image: {}",
1454        rollback_image.manifest_digest
1455    );
1456    // SAFETY: If there's a rollback status, then there's a deployment
1457    let rollback_deployment = deployments.rollback.expect("rollback deployment");
1458    let new_deployments = if reverting {
1459        [booted_ostree.deployment, rollback_deployment]
1460    } else {
1461        [rollback_deployment, booted_ostree.deployment]
1462    };
1463    let new_deployments = new_deployments
1464        .into_iter()
1465        .chain(deployments.other)
1466        .collect::<Vec<_>>();
1467    tracing::debug!("Writing new deployments: {new_deployments:?}");
1468    booted_ostree
1469        .sysroot
1470        .write_deployments(&new_deployments, gio::Cancellable::NONE)?;
1471    if reverting {
1472        println!("Next boot: current deployment");
1473    } else {
1474        println!("Next boot: rollback deployment");
1475    }
1476
1477    write_reboot_required(rollback_image.manifest_digest.as_ref())?;
1478
1479    sysroot.update_mtime()?;
1480
1481    Ok(())
1482}
1483
1484fn find_newest_deployment_name(deploysdir: &Dir) -> Result<String> {
1485    let mut dirs = Vec::new();
1486    for ent in deploysdir.entries()? {
1487        let ent = ent?;
1488        if !ent.file_type()?.is_dir() {
1489            continue;
1490        }
1491        let name = ent.file_name();
1492        let Some(name) = name.to_str() else {
1493            continue;
1494        };
1495        dirs.push((name.to_owned(), ent.metadata()?.mtime()));
1496    }
1497    dirs.sort_unstable_by(|a, b| a.1.cmp(&b.1));
1498    if let Some((name, _ts)) = dirs.pop() {
1499        Ok(name)
1500    } else {
1501        anyhow::bail!("No deployment directory found")
1502    }
1503}
1504
1505// Implementation of `bootc switch --in-place`
1506pub(crate) fn switch_origin_inplace(root: &Dir, imgref: &ImageReference) -> Result<String> {
1507    // Log the in-place switch operation to systemd journal
1508    const SWITCH_INPLACE_JOURNAL_ID: &str = "3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7";
1509
1510    tracing::info!(
1511        message_id = SWITCH_INPLACE_JOURNAL_ID,
1512        bootc.image.reference = &imgref.image,
1513        bootc.image.transport = &imgref.transport,
1514        bootc.switch_type = "in_place",
1515        "Performing in-place switch to image: {}",
1516        imgref
1517    );
1518
1519    // First, just create the new origin file
1520    let origin = origin_from_imageref(imgref)?;
1521    let serialized_origin = origin.to_data();
1522
1523    // Now, we can't rely on being officially booted (e.g. with the `ostree=` karg)
1524    // in a scenario like running in the anaconda %post.
1525    // Eventually, we should support a setup here where ostree-prepare-root
1526    // can officially be run to "enter" an ostree root in a supportable way.
1527    // Anyways for now, the brutal hack is to just scrape through the deployments
1528    // and find the newest one, which we will mutate.  If there's more than one,
1529    // ultimately the calling tooling should be fixed to set things up correctly.
1530
1531    let mut ostree_deploys = root.open_dir("sysroot/ostree/deploy")?.entries()?;
1532    let deploydir = loop {
1533        if let Some(ent) = ostree_deploys.next() {
1534            let ent = ent?;
1535            if !ent.file_type()?.is_dir() {
1536                continue;
1537            }
1538            tracing::debug!("Checking {:?}", ent.file_name());
1539            let child_dir = ent
1540                .open_dir()
1541                .with_context(|| format!("Opening dir {:?}", ent.file_name()))?;
1542            if let Some(d) = child_dir.open_dir_optional("deploy")? {
1543                break d;
1544            }
1545        } else {
1546            anyhow::bail!("Failed to find a deployment");
1547        }
1548    };
1549    let newest_deployment = find_newest_deployment_name(&deploydir)?;
1550    let origin_path = format!("{newest_deployment}.origin");
1551    if !deploydir.try_exists(&origin_path)? {
1552        tracing::warn!("No extant origin for {newest_deployment}");
1553    }
1554    deploydir
1555        .atomic_write(&origin_path, serialized_origin.as_bytes())
1556        .context("Writing origin")?;
1557    Ok(newest_deployment)
1558}
1559
1560/// A workaround for <https://github.com/ostreedev/ostree/issues/3193>
1561/// as generated by anaconda.
1562#[context("Updating /etc/fstab for anaconda+composefs")]
1563pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> {
1564    let fstab_path = "etc/fstab";
1565    // Read the old file
1566    let fd = root
1567        .open(fstab_path)
1568        .with_context(|| format!("Opening {fstab_path}"))
1569        .map(std::io::BufReader::new)?;
1570
1571    // Helper function to possibly change a line from /etc/fstab.
1572    // Returns Ok(true) if we made a change (and we wrote the modified line)
1573    // otherwise returns Ok(false) and the caller should write the original line.
1574    fn edit_fstab_line(line: &str, mut w: impl Write) -> Result<bool> {
1575        if line.starts_with('#') {
1576            return Ok(false);
1577        }
1578        let parts = line.split_ascii_whitespace().collect::<Vec<_>>();
1579
1580        let path_idx = 1;
1581        let options_idx = 3;
1582        let (&path, &options) = match (parts.get(path_idx), parts.get(options_idx)) {
1583            (None, _) => {
1584                tracing::debug!("No path in entry: {line}");
1585                return Ok(false);
1586            }
1587            (_, None) => {
1588                tracing::debug!("No options in entry: {line}");
1589                return Ok(false);
1590            }
1591            (Some(p), Some(o)) => (p, o),
1592        };
1593        // If this is not the root, we're not matching on it
1594        if path != "/" {
1595            return Ok(false);
1596        }
1597        // If options already contains `ro`, nothing to do
1598        if options.split(',').any(|s| s == "ro") {
1599            return Ok(false);
1600        }
1601
1602        writeln!(w, "# {}", crate::generator::BOOTC_EDITED_STAMP)?;
1603
1604        // SAFETY: we unpacked the options before.
1605        // This adds `ro` to the option list
1606        assert!(!options.is_empty()); // Split wouldn't have turned this up if it was empty
1607        let options = format!("{options},ro");
1608        for (i, part) in parts.into_iter().enumerate() {
1609            // TODO: would obviously be nicer to preserve whitespace...but...eh.
1610            if i > 0 {
1611                write!(w, " ")?;
1612            }
1613            if i == options_idx {
1614                write!(w, "{options}")?;
1615            } else {
1616                write!(w, "{part}")?
1617            }
1618        }
1619        // And add the trailing newline
1620        writeln!(w)?;
1621        Ok(true)
1622    }
1623
1624    // Read the input, and atomically write a modified version
1625    root.atomic_replace_with(fstab_path, move |mut w| -> Result<()> {
1626        for line in fd.lines() {
1627            let line = line?;
1628            if !edit_fstab_line(&line, &mut w)? {
1629                writeln!(w, "{line}")?;
1630            }
1631        }
1632        Ok(())
1633    })
1634    .context("Replacing /etc/fstab")?;
1635
1636    println!("Updated /etc/fstab to add `ro` for `/`");
1637    Ok(())
1638}
1639
1640#[cfg(test)]
1641mod tests {
1642    use super::*;
1643    use tokio::io::{AsyncBufReadExt, BufReader};
1644
1645    fn get_blob_failure(error: ostree_ext::containers_image_proxy::GetBlobError) -> anyhow::Error {
1646        let error = ostree_ext::containers_image_proxy::Error::BlobError(error);
1647        anyhow::Error::from(error).context("Unencapsulating base")
1648    }
1649
1650    fn retryable_blob_failure(message: &str) -> anyhow::Error {
1651        get_blob_failure(ostree_ext::containers_image_proxy::GetBlobError::Retryable(
1652            message.into(),
1653        ))
1654    }
1655
1656    fn permanent_blob_failure(message: &str) -> anyhow::Error {
1657        get_blob_failure(ostree_ext::containers_image_proxy::GetBlobError::Other(
1658            message.into(),
1659        ))
1660    }
1661
1662    fn open_image_failure(message: &str) -> anyhow::Error {
1663        let error = ostree_ext::containers_image_proxy::Error::RequestInitiationFailure {
1664            method: "OpenImage".into(),
1665            error: message.into(),
1666        };
1667        anyhow::Error::from(error).context("Creating importer")
1668    }
1669
1670    #[test]
1671    fn test_retryable_pull_error_classification() {
1672        let cases = [
1673            (retryable_blob_failure("502 Bad Gateway"), true),
1674            (permanent_blob_failure("blob unknown"), false),
1675            (open_image_failure("image unknown"), false),
1676        ];
1677
1678        for (error, expected) in cases {
1679            assert_eq!(is_retryable_pull_error(&error), expected, "{error:#}");
1680        }
1681    }
1682
1683    #[tokio::test(start_paused = true)]
1684    async fn test_retry_pull_operation_succeeds() -> Result<()> {
1685        let attempts = std::cell::Cell::new(0);
1686        let start = tokio::time::Instant::now();
1687        let progress = PullProgress::new(true, ProgressWriter::default());
1688        let value = retry_pull_operation(&progress, || {
1689            let attempt = attempts.get() + 1;
1690            attempts.set(attempt);
1691            async move {
1692                if attempt == 1 {
1693                    Err(retryable_blob_failure("502 Bad Gateway"))
1694                } else {
1695                    Ok(42)
1696                }
1697            }
1698        })
1699        .await?;
1700
1701        assert_eq!(value, 42);
1702        assert_eq!(attempts.get(), 2);
1703        assert_eq!(start.elapsed(), pull_retry_delay(0));
1704        Ok(())
1705    }
1706
1707    #[tokio::test(start_paused = true)]
1708    async fn test_retry_pull_operation_reports_progress() -> Result<()> {
1709        let attempts = std::cell::Cell::new(0);
1710        let (send, recv) = tokio::net::unix::pipe::pipe()?;
1711        let progress = PullProgress::new(true, ProgressWriter::from(send));
1712
1713        retry_pull_operation(&progress, || {
1714            let attempt = attempts.get() + 1;
1715            attempts.set(attempt);
1716            async move {
1717                if attempt == 1 {
1718                    Err(retryable_blob_failure("502 Bad Gateway"))
1719                } else {
1720                    Ok(())
1721                }
1722            }
1723        })
1724        .await?;
1725
1726        let mut lines = BufReader::new(recv).lines();
1727        let start_line = lines.next_line().await?.unwrap();
1728        let start: Event = serde_json::from_str(&start_line)?;
1729        assert!(matches!(start, Event::Start { version } if version == "0.1.0"));
1730
1731        let retry_line = lines.next_line().await?.unwrap();
1732        let retry: Event = serde_json::from_str(&retry_line)?;
1733        assert_eq!(
1734            retry,
1735            Event::ProgressSteps {
1736                task: "pulling".into(),
1737                description:
1738                    "Container image pull failed; retrying in 2 seconds (1/3): Unencapsulating base"
1739                        .into(),
1740                id: "pull-retry".into(),
1741                steps_cached: 0,
1742                steps: 1,
1743                steps_total: 3,
1744                subtasks: Vec::new(),
1745            }
1746        );
1747        drop(progress);
1748        assert!(lines.next_line().await?.is_none());
1749        Ok(())
1750    }
1751
1752    #[tokio::test(start_paused = true)]
1753    async fn test_retry_pull_operation_stops_after_max_attempts() {
1754        let attempts = std::cell::Cell::new(0);
1755        let start = tokio::time::Instant::now();
1756        let progress = PullProgress::new(true, ProgressWriter::default());
1757        let error = retry_pull_operation(&progress, || {
1758            attempts.set(attempts.get() + 1);
1759            async { Err::<(), _>(retryable_blob_failure("registry unavailable")) }
1760        })
1761        .await
1762        .unwrap_err();
1763
1764        let expected_delay = (0..PULL_MAX_RETRIES)
1765            .map(pull_retry_delay)
1766            .fold(Duration::ZERO, Duration::saturating_add);
1767        assert_eq!(attempts.get(), PULL_MAX_RETRIES + 1);
1768        assert_eq!(start.elapsed(), expected_delay);
1769        assert_eq!(
1770            error.root_cause().to_string(),
1771            "retryable error: registry unavailable"
1772        );
1773    }
1774
1775    #[tokio::test(start_paused = true)]
1776    async fn test_retry_pull_operation_does_not_retry_permanent_blob_error() {
1777        let attempts = std::cell::Cell::new(0);
1778        let progress = PullProgress::new(true, ProgressWriter::default());
1779        let error = retry_pull_operation(&progress, || {
1780            attempts.set(attempts.get() + 1);
1781            async { Err::<(), _>(permanent_blob_failure("blob unknown")) }
1782        })
1783        .await
1784        .unwrap_err();
1785
1786        assert_eq!(attempts.get(), 1);
1787        assert_eq!(error.root_cause().to_string(), "other error: blob unknown");
1788    }
1789
1790    #[tokio::test(start_paused = true)]
1791    async fn test_retry_pull_operation_does_not_retry_opaque_prepare_error() {
1792        let attempts = std::cell::Cell::new(0);
1793        let progress = PullProgress::new(true, ProgressWriter::default());
1794        let error = retry_pull_operation(&progress, || {
1795            attempts.set(attempts.get() + 1);
1796            async { Err::<(), _>(open_image_failure("image unknown")) }
1797        })
1798        .await
1799        .unwrap_err();
1800
1801        assert_eq!(attempts.get(), 1);
1802        assert_eq!(
1803            error.root_cause().to_string(),
1804            "failed to invoke method OpenImage: image unknown"
1805        );
1806    }
1807
1808    #[test]
1809    fn test_new_proxy_config_user_agent() {
1810        let config = new_proxy_config();
1811        let prefix = config
1812            .user_agent_prefix
1813            .expect("user_agent_prefix should be set");
1814        assert!(
1815            prefix.starts_with("bootc/"),
1816            "User agent should start with bootc/"
1817        );
1818        // Verify the version is present (not just "bootc/")
1819        assert!(
1820            prefix.len() > "bootc/".len(),
1821            "Version should be present after bootc/"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_switch_inplace() -> Result<()> {
1827        use cap_std::fs::DirBuilderExt;
1828
1829        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1830        let mut builder = cap_std::fs::DirBuilder::new();
1831        let builder = builder.recursive(true).mode(0o755);
1832        let deploydir = "sysroot/ostree/deploy/default/deploy";
1833        let target_deployment =
1834            "af36eb0086bb55ac601600478c6168f834288013d60f8870b7851f44bf86c3c5.0";
1835        td.ensure_dir_with(
1836            format!("sysroot/ostree/deploy/default/deploy/{target_deployment}"),
1837            builder,
1838        )?;
1839        let deploydir = &td.open_dir(deploydir)?;
1840        let orig_imgref = ImageReference {
1841            image: "quay.io/exampleos/original:sometag".into(),
1842            transport: "registry".into(),
1843            signature: None,
1844        };
1845        {
1846            let origin = origin_from_imageref(&orig_imgref)?;
1847            deploydir.atomic_write(
1848                format!("{target_deployment}.origin"),
1849                origin.to_data().as_bytes(),
1850            )?;
1851        }
1852
1853        let target_imgref = ImageReference {
1854            image: "quay.io/someother/otherimage:latest".into(),
1855            transport: "registry".into(),
1856            signature: None,
1857        };
1858
1859        let replaced = switch_origin_inplace(&td, &target_imgref).unwrap();
1860        assert_eq!(replaced, target_deployment);
1861        Ok(())
1862    }
1863
1864    #[test]
1865    fn test_fixup_etc_fstab_default() -> Result<()> {
1866        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1867        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n";
1868        tempdir.create_dir_all("etc")?;
1869        tempdir.atomic_write("etc/fstab", default)?;
1870        fixup_etc_fstab(&tempdir).unwrap();
1871        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1872        Ok(())
1873    }
1874
1875    #[test]
1876    fn test_fixup_etc_fstab_multi() -> Result<()> {
1877        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1878        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1879UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1880        tempdir.create_dir_all("etc")?;
1881        tempdir.atomic_write("etc/fstab", default)?;
1882        fixup_etc_fstab(&tempdir).unwrap();
1883        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1884        Ok(())
1885    }
1886
1887    #[test]
1888    fn test_fixup_etc_fstab_ro() -> Result<()> {
1889        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1890        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1891UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 /                     xfs   ro 0 0\n\
1892UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1893        tempdir.create_dir_all("etc")?;
1894        tempdir.atomic_write("etc/fstab", default)?;
1895        fixup_etc_fstab(&tempdir).unwrap();
1896        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1897        Ok(())
1898    }
1899
1900    #[test]
1901    fn test_fixup_etc_fstab_rw() -> Result<()> {
1902        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1903        // This case uses `defaults`
1904        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1905UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 /                     xfs   defaults 0 0\n\
1906UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1907        let modified = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1908# Updated by bootc-fstab-edit.service\n\
1909UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 / xfs defaults,ro 0 0\n\
1910UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1911        tempdir.create_dir_all("etc")?;
1912        tempdir.atomic_write("etc/fstab", default)?;
1913        fixup_etc_fstab(&tempdir).unwrap();
1914        assert_eq!(tempdir.read_to_string("etc/fstab")?, modified);
1915        Ok(())
1916    }
1917    #[test]
1918    fn test_check_disk_space_inner() -> Result<()> {
1919        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1920        let imgref = ImageReference {
1921            image: "quay.io/exampleos/exampleos:latest".into(),
1922            transport: "registry".into(),
1923            signature: None,
1924        };
1925
1926        // 0 bytes needed always passes
1927        check_disk_space_inner(&*td, 0, 0, &imgref)?;
1928
1929        // u64::MAX bytes needed always fails
1930        assert!(check_disk_space_inner(&*td, u64::MAX, 0, &imgref).is_err());
1931
1932        // With min_free consuming all usable space, even a tiny fetch fails
1933        assert!(check_disk_space_inner(&*td, 1, u64::MAX, &imgref).is_err());
1934
1935        Ok(())
1936    }
1937}