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;
50use std::process::Command;
51
52use anyhow::{Context, Result, anyhow};
53use bootc_kernel_cmdline::utf8::CmdlineOwned;
54use bootc_utils::skopeo_bin;
55use cap_std::fs::{Dir, MetadataExt};
56use cap_std_ext::cap_std;
57use cap_std_ext::dirext::CapStdExtDirExt;
58use fn_error_context::context;
59use ostree::{gio, glib};
60use ostree_container::OstreeImageReference;
61use ostree_ext::container as ostree_container;
62use ostree_ext::container::store::{ImageImporter, ImportProgress, PrepareResult, PreparedImport};
63use ostree_ext::oci_spec::image::{Descriptor, Digest};
64use ostree_ext::ostree::Deployment;
65use ostree_ext::ostree::{self, Sysroot};
66use ostree_ext::sysroot::SysrootLock;
67use ostree_ext::tokio_util::spawn_blocking_cancellable_flatten;
68
69use crate::progress_jsonl::{Event, ProgressWriter, SubTaskBytes, SubTaskStep};
70use crate::spec::ImageReference;
71use crate::spec::{BootOrder, HostSpec};
72use crate::status::labels_of_config;
73use crate::store::Storage;
74use crate::utils::async_task_with_spinner;
75
76// TODO use https://github.com/ostreedev/ostree-rs-ext/pull/493/commits/afc1837ff383681b947de30c0cefc70080a4f87a
77const BASE_IMAGE_PREFIX: &str = "ostree/container/baseimage/bootc";
78
79/// Create an ImageProxyConfig with bootc's user agent prefix set.
80///
81/// This allows registries to distinguish "image pulls for bootc client runs"
82/// from other skopeo/containers-image users.
83pub(crate) fn new_proxy_config() -> ostree_ext::containers_image_proxy::ImageProxyConfig {
84    let mut c = ostree_ext::containers_image_proxy::ImageProxyConfig::default();
85    c.user_agent_prefix = Some(format!("bootc/{}", env!("CARGO_PKG_VERSION")));
86    c
87}
88
89/// Set on an ostree commit if this is a derived commit
90const BOOTC_DERIVED_KEY: &str = "bootc.derived";
91
92/// Variant of HostSpec but required to be filled out
93pub(crate) struct RequiredHostSpec<'a> {
94    pub(crate) image: &'a ImageReference,
95}
96
97/// State of a locally fetched image
98pub(crate) struct ImageState {
99    pub(crate) manifest_digest: Digest,
100    pub(crate) version: Option<String>,
101    pub(crate) ostree_commit: String,
102}
103
104impl<'a> RequiredHostSpec<'a> {
105    /// Given a (borrowed) host specification, "unwrap" its internal
106    /// options, giving a spec that is required to have a base container image.
107    pub(crate) fn from_spec(spec: &'a HostSpec) -> Result<Self> {
108        let image = spec
109            .image
110            .as_ref()
111            .ok_or_else(|| anyhow::anyhow!("Missing image in specification"))?;
112        Ok(Self { image })
113    }
114}
115
116impl From<ostree_container::store::LayeredImageState> for ImageState {
117    fn from(value: ostree_container::store::LayeredImageState) -> Self {
118        let version = value.version().map(|v| v.to_owned());
119        let ostree_commit = value.get_commit().to_owned();
120        Self {
121            manifest_digest: value.manifest_digest,
122            version,
123            ostree_commit,
124        }
125    }
126}
127
128impl ImageState {
129    /// Fetch the manifest corresponding to this image.  May not be available in all backends.
130    pub(crate) fn get_manifest(
131        &self,
132        repo: &ostree::Repo,
133    ) -> Result<Option<ostree_ext::oci_spec::image::ImageManifest>> {
134        ostree_container::store::query_image_commit(repo, &self.ostree_commit)
135            .map(|v| Some(v.manifest))
136    }
137}
138
139/// Wrapper for pulling a container image, wiring up status output.
140pub(crate) async fn new_importer(
141    repo: &ostree::Repo,
142    imgref: &ostree_container::OstreeImageReference,
143    booted_deployment: Option<&ostree::Deployment>,
144) -> Result<ostree_container::store::ImageImporter> {
145    let config = new_proxy_config();
146    let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?;
147    imp.require_bootable();
148    // We do our own GC/prune in deploy::prune(), so skip the importer's internal one.
149    imp.disable_gc();
150    if let Some(deployment) = booted_deployment {
151        imp.set_sepolicy_commit(deployment.csum().to_string());
152    }
153    Ok(imp)
154}
155
156/// Wrapper for pulling a container image with a custom proxy config (e.g. for unified storage).
157pub(crate) async fn new_importer_with_config(
158    repo: &ostree::Repo,
159    imgref: &ostree_container::OstreeImageReference,
160    config: ostree_ext::containers_image_proxy::ImageProxyConfig,
161    booted_deployment: Option<&ostree::Deployment>,
162) -> Result<ostree_container::store::ImageImporter> {
163    let mut imp = ostree_container::store::ImageImporter::new(repo, imgref, config).await?;
164    imp.require_bootable();
165    // We do our own GC/prune in deploy::prune(), so skip the importer's internal one.
166    imp.disable_gc();
167    if let Some(deployment) = booted_deployment {
168        imp.set_sepolicy_commit(deployment.csum().to_string());
169    }
170    Ok(imp)
171}
172
173pub(crate) fn check_bootc_label(config: &ostree_ext::oci_spec::image::ImageConfiguration) {
174    if let Some(label) =
175        labels_of_config(config).and_then(|labels| labels.get(crate::metadata::BOOTC_COMPAT_LABEL))
176    {
177        match label.as_str() {
178            crate::metadata::COMPAT_LABEL_V1 => {}
179            o => crate::journal::journal_print(
180                libsystemd::logging::Priority::Warning,
181                &format!(
182                    "notice: Unknown {} value {}",
183                    crate::metadata::BOOTC_COMPAT_LABEL,
184                    o
185                ),
186            ),
187        }
188    } else {
189        crate::journal::journal_print(
190            libsystemd::logging::Priority::Warning,
191            &format!(
192                "notice: Image is missing label: {}",
193                crate::metadata::BOOTC_COMPAT_LABEL
194            ),
195        )
196    }
197}
198
199fn descriptor_of_progress(p: &ImportProgress) -> &Descriptor {
200    match p {
201        ImportProgress::OstreeChunkStarted(l) => l,
202        ImportProgress::OstreeChunkCompleted(l) => l,
203        ImportProgress::DerivedLayerStarted(l) => l,
204        ImportProgress::DerivedLayerCompleted(l) => l,
205    }
206}
207
208fn prefix_of_progress(p: &ImportProgress) -> &'static str {
209    match p {
210        ImportProgress::OstreeChunkStarted(_) | ImportProgress::OstreeChunkCompleted(_) => {
211            "ostree chunk"
212        }
213        ImportProgress::DerivedLayerStarted(_) | ImportProgress::DerivedLayerCompleted(_) => {
214            "layer"
215        }
216    }
217}
218
219/// Configuration for layer progress printing
220struct LayerProgressConfig {
221    layers: tokio::sync::mpsc::Receiver<ostree_container::store::ImportProgress>,
222    layer_bytes: tokio::sync::watch::Receiver<Option<ostree_container::store::LayerProgress>>,
223    digest: Box<str>,
224    n_layers_to_fetch: usize,
225    layers_total: usize,
226    bytes_to_download: u64,
227    bytes_total: u64,
228    prog: ProgressWriter,
229    quiet: bool,
230}
231
232/// Write container fetch progress to standard output.
233async fn handle_layer_progress_print(mut config: LayerProgressConfig) -> ProgressWriter {
234    let start = std::time::Instant::now();
235    let mut total_read = 0u64;
236    let bar = indicatif::MultiProgress::new();
237    if config.quiet {
238        bar.set_draw_target(indicatif::ProgressDrawTarget::hidden());
239    }
240    let layers_bar = bar.add(indicatif::ProgressBar::new(
241        config.n_layers_to_fetch.try_into().unwrap(),
242    ));
243    let byte_bar = bar.add(indicatif::ProgressBar::new(0));
244    // let byte_bar = indicatif::ProgressBar::new(0);
245    // byte_bar.set_draw_target(indicatif::ProgressDrawTarget::hidden());
246    layers_bar.set_style(
247        indicatif::ProgressStyle::default_bar()
248            .template("{prefix} {bar} {pos}/{len} {wide_msg}")
249            .unwrap(),
250    );
251    let taskname = "Fetching layers";
252    layers_bar.set_prefix(taskname);
253    layers_bar.set_message("");
254    byte_bar.set_prefix("Fetching");
255    byte_bar.set_style(
256        indicatif::ProgressStyle::default_bar()
257                .template(
258                    " └ {prefix} {bar} {binary_bytes}/{binary_total_bytes} ({binary_bytes_per_sec}) {wide_msg}",
259                )
260                .unwrap()
261        );
262
263    let mut subtasks = vec![];
264    let mut subtask: SubTaskBytes = Default::default();
265    loop {
266        tokio::select! {
267            // Always handle layer changes first.
268            biased;
269            layer = config.layers.recv() => {
270                if let Some(l) = layer {
271                    let layer = descriptor_of_progress(&l);
272                    let layer_type = prefix_of_progress(&l);
273                    let short_digest = &layer.digest().digest()[0..21];
274                    let layer_size = layer.size();
275                    if l.is_starting() {
276                        // Reset the progress bar
277                        byte_bar.reset_elapsed();
278                        byte_bar.reset_eta();
279                        byte_bar.set_length(layer_size);
280                        byte_bar.set_message(format!("{layer_type} {short_digest}"));
281
282                        subtask = SubTaskBytes {
283                            subtask: layer_type.into(),
284                            description: format!("{layer_type}: {short_digest}").clone().into(),
285                            id: short_digest.to_string().clone().into(),
286                            bytes_cached: 0,
287                            bytes: 0,
288                            bytes_total: layer_size,
289                        };
290                    } else {
291                        // Use the bar's length (actual blob size) rather than
292                        // the manifest descriptor size for completion accounting.
293                        let actual_size = byte_bar.length().unwrap_or(layer_size);
294                        byte_bar.set_position(actual_size);
295                        layers_bar.inc(1);
296                        total_read = total_read.saturating_add(actual_size);
297                        // Emit an event where bytes == total to signal completion.
298                        subtask.bytes_total = actual_size;
299                        subtask.bytes = actual_size;
300                        subtasks.push(subtask.clone());
301                        config.prog.send(Event::ProgressBytes {
302                            task: "pulling".into(),
303                            description: format!("Pulling Image: {}", config.digest).into(),
304                            id: (*config.digest).into(),
305                            bytes_cached: config.bytes_total - config.bytes_to_download,
306                            bytes: total_read,
307                            bytes_total: config.bytes_to_download,
308                            steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64,
309                            steps: layers_bar.position(),
310                            steps_total: config.n_layers_to_fetch as u64,
311                            subtasks: subtasks.clone(),
312                        }).await;
313                    }
314                } else {
315                    // If the receiver is disconnected, then we're done
316                    break
317                };
318            },
319            r = config.layer_bytes.changed() => {
320                if r.is_err() {
321                    // If the receiver is disconnected, then we're done
322                    break
323                }
324                let bytes = {
325                    let bytes = config.layer_bytes.borrow_and_update();
326                    bytes.as_ref().cloned()
327                };
328                if let Some(bytes) = bytes {
329                    // Update the bar length from the actual blob size, which
330                    // may differ from the manifest descriptor size (e.g.
331                    // containers-storage stores layers uncompressed).
332                    byte_bar.set_length(bytes.total);
333                    byte_bar.set_position(bytes.fetched);
334                    subtask.bytes_total = bytes.total;
335                    subtask.bytes = byte_bar.position();
336                    config.prog.send_lossy(Event::ProgressBytes {
337                        task: "pulling".into(),
338                        description: format!("Pulling Image: {}", config.digest).into(),
339                        id: (*config.digest).into(),
340                        bytes_cached: config.bytes_total - config.bytes_to_download,
341                        bytes: total_read + byte_bar.position(),
342                        bytes_total: config.bytes_to_download,
343                        steps_cached: (config.layers_total - config.n_layers_to_fetch) as u64,
344                        steps: layers_bar.position(),
345                        steps_total: config.n_layers_to_fetch as u64,
346                        subtasks: subtasks.clone().into_iter().chain([subtask.clone()]).collect(),
347                    }).await;
348                }
349            }
350        }
351    }
352    byte_bar.finish_and_clear();
353    layers_bar.finish_and_clear();
354    if let Err(e) = bar.clear() {
355        tracing::warn!("clearing bar: {e}");
356    }
357    let end = std::time::Instant::now();
358    let elapsed = end.duration_since(start);
359    let persec = total_read as f64 / elapsed.as_secs_f64();
360    let persec = indicatif::HumanBytes(persec as u64);
361    if let Err(e) = bar.println(&format!(
362        "Fetched layers: {} in {} ({}/s)",
363        indicatif::HumanBytes(total_read),
364        indicatif::HumanDuration(elapsed),
365        persec,
366    )) {
367        tracing::warn!("writing to stdout: {e}");
368    }
369
370    // Since the progress notifier closed, we know import has started
371    // use as a heuristic to begin import progress
372    // Cannot be lossy or it is dropped
373    config
374        .prog
375        .send(Event::ProgressSteps {
376            task: "importing".into(),
377            description: "Importing Image".into(),
378            id: (*config.digest).into(),
379            steps_cached: 0,
380            steps: 0,
381            steps_total: 1,
382            subtasks: [SubTaskStep {
383                subtask: "importing".into(),
384                description: "Importing Image".into(),
385                id: "importing".into(),
386                completed: false,
387            }]
388            .into(),
389        })
390        .await;
391
392    // Return the writer
393    config.prog
394}
395
396/// Gather all bound images in all deployments, then prune the image store,
397/// using the gathered images as the roots (that will not be GC'd).
398pub(crate) async fn prune_container_store(sysroot: &Storage) -> Result<()> {
399    let ostree = sysroot.get_ostree()?;
400    let deployments = ostree.deployments();
401    let mut all_bound_images = Vec::new();
402    for deployment in deployments {
403        let bound = crate::boundimage::query_bound_images_for_deployment(ostree, &deployment)?;
404        all_bound_images.extend(bound.into_iter());
405        // Also include the host image itself
406        // Note: Use just the image name (not the full transport:image format) because
407        // podman's image names don't include the transport prefix.
408        if let Some(host_image) = crate::status::boot_entry_from_deployment(ostree, &deployment)?
409            .image
410            .map(|i| i.image)
411        {
412            all_bound_images.push(crate::boundimage::BoundImage {
413                image: host_image.image.clone(),
414                auth_file: None,
415            });
416        }
417    }
418    // Convert to a hashset of just the image names
419    let image_names = HashSet::from_iter(all_bound_images.iter().map(|img| img.image.as_str()));
420    let pruned = sysroot
421        .get_ensure_imgstore()?
422        .prune_except_roots(&image_names)
423        .await?;
424    tracing::debug!("Pruned images: {}", pruned.len());
425    Ok(())
426}
427
428/// Core disk space check: verify that `bytes_to_fetch` fits within available space,
429/// leaving at least `min_free` bytes reserved.
430fn check_disk_space_inner(
431    fd: impl AsFd,
432    bytes_to_fetch: u64,
433    min_free: u64,
434    imgref: &ImageReference,
435) -> Result<()> {
436    let stat = rustix::fs::fstatvfs(fd)?;
437    let bytes_avail = stat.f_bsize.checked_mul(stat.f_bavail).unwrap_or(u64::MAX);
438    let usable = bytes_avail.saturating_sub(min_free);
439    tracing::trace!("bytes_avail: {bytes_avail} min_free: {min_free} usable: {usable}");
440
441    if bytes_to_fetch > usable {
442        anyhow::bail!(
443            "Insufficient free space for {image} (available: {available} required: {required})",
444            available = ostree_ext::glib::format_size(usable),
445            required = ostree_ext::glib::format_size(bytes_to_fetch),
446            image = imgref.image,
447        );
448    }
449    Ok(())
450}
451
452/// Verify there is sufficient disk space to pull an image into the ostree repo.
453/// Respects the repository's configured min-free-space threshold.
454pub(crate) fn check_disk_space_ostree(
455    repo: &ostree::Repo,
456    image_meta: &PreparedImportMeta,
457    imgref: &ImageReference,
458) -> Result<()> {
459    let min_free = repo.min_free_space_bytes().unwrap_or(0);
460    check_disk_space_inner(
461        repo.dfd_borrow(),
462        image_meta.bytes_to_fetch,
463        min_free,
464        imgref,
465    )
466}
467
468/// Verify there is sufficient disk space to pull an image into the composefs store
469/// via the ostree unified-storage path (uses `PreparedImportMeta`).
470pub(crate) fn check_disk_space_unified(
471    cfs: &crate::store::ComposefsRepository,
472    image_meta: &PreparedImportMeta,
473    imgref: &ImageReference,
474) -> Result<()> {
475    check_disk_space_inner(cfs.objects_dir()?, image_meta.bytes_to_fetch, 0, imgref)
476}
477
478/// Verify there is sufficient disk space to pull an image into the composefs store
479/// for the native composefs backend (uses a raw `ImageManifest`).
480pub(crate) fn check_disk_space_composefs(
481    cfs: &crate::store::ComposefsRepository,
482    manifest: &ostree_ext::oci_spec::image::ImageManifest,
483    imgref: &ImageReference,
484) -> Result<()> {
485    let bytes_to_fetch: u64 = manifest
486        .layers()
487        .iter()
488        .map(|l: &ostree_ext::oci_spec::image::Descriptor| l.size())
489        .sum();
490    check_disk_space_inner(cfs.objects_dir()?, bytes_to_fetch, 0, imgref)
491}
492
493pub(crate) struct PreparedImportMeta {
494    pub imp: ImageImporter,
495    pub prep: Box<PreparedImport>,
496    pub digest: Digest,
497    pub n_layers_to_fetch: usize,
498    pub layers_total: usize,
499    pub bytes_to_fetch: u64,
500    pub bytes_total: u64,
501}
502
503pub(crate) enum PreparedPullResult {
504    Ready(Box<PreparedImportMeta>),
505    AlreadyPresent(Box<ImageState>),
506}
507
508pub(crate) async fn prepare_for_pull(
509    repo: &ostree::Repo,
510    imgref: &ImageReference,
511    target_imgref: Option<&OstreeImageReference>,
512    booted_deployment: Option<&ostree::Deployment>,
513) -> Result<PreparedPullResult> {
514    let imgref_canonicalized = imgref.clone().canonicalize()?;
515    tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
516    let ostree_imgref = &OstreeImageReference::from(imgref_canonicalized);
517    let mut imp = new_importer(repo, ostree_imgref, booted_deployment).await?;
518    if let Some(target) = target_imgref {
519        imp.set_target(target);
520    }
521    let prep = match imp.prepare().await? {
522        PrepareResult::AlreadyPresent(c) => {
523            println!("No changes in {imgref:#} => {}", c.manifest_digest);
524            return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
525        }
526        PrepareResult::Ready(p) => p,
527    };
528    check_bootc_label(&prep.config);
529    if let Some(warning) = prep.deprecated_warning() {
530        ostree_ext::cli::print_deprecated_warning(warning).await;
531    }
532    ostree_ext::cli::print_layer_status(&prep);
533    let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
534
535    let prepared_image = PreparedImportMeta {
536        imp,
537        n_layers_to_fetch: layers_to_fetch.len(),
538        layers_total: prep.all_layers().count(),
539        bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
540        bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
541        digest: prep.manifest_digest.clone(),
542        prep,
543    };
544
545    Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
546}
547
548/// Check whether the image exists in bootc's unified container storage.
549///
550/// This is used for auto-detection: if the image already exists in bootc storage
551/// (e.g., from a previous `bootc image set-unified` or LBI pull), we can use
552/// the unified storage path for faster imports.
553///
554/// Returns true if the image exists in bootc storage.
555pub(crate) async fn image_exists_in_unified_storage(
556    store: &Storage,
557    imgref: &ImageReference,
558) -> Result<bool> {
559    let imgstore = store.get_ensure_imgstore()?;
560    let image_ref_str = imgref.to_transport_image()?;
561    imgstore.exists(&image_ref_str).await
562}
563
564/// Unified approach: Use bootc's CStorage to pull the image, then prepare from containers-storage.
565/// This reuses the same infrastructure as LBIs.
566pub(crate) async fn prepare_for_pull_unified(
567    repo: &ostree::Repo,
568    imgref: &ImageReference,
569    target_imgref: Option<&OstreeImageReference>,
570    store: &Storage,
571    booted_deployment: Option<&ostree::Deployment>,
572) -> Result<PreparedPullResult> {
573    // Get or initialize the bootc container storage (same as used for LBIs)
574    let imgstore = store.get_ensure_imgstore()?;
575
576    let image_ref_str = imgref.to_transport_image()?;
577
578    // Always pull to ensure we have the latest image, whether from a remote
579    // registry or a locally rebuilt image
580    tracing::info!(
581        "Unified pull: pulling from transport '{}' to bootc storage",
582        &imgref.transport
583    );
584
585    // Pull the image into bootc containers-storage with per-layer
586    // download progress via the podman native API.
587    imgstore
588        .pull_with_progress(&image_ref_str)
589        .await
590        .context("Pulling image into bootc containers-storage")?;
591
592    // Now create a containers-storage reference to read from bootc storage
593    tracing::info!("Unified pull: now importing from containers-storage transport");
594    let containers_storage_imgref = ImageReference {
595        transport: "containers-storage".to_string(),
596        image: imgref.image.clone(),
597        signature: imgref.signature.clone(),
598    };
599    let ostree_imgref = OstreeImageReference::from(containers_storage_imgref);
600
601    // Configure the importer to use bootc storage as an additional image store
602    let mut config = new_proxy_config();
603    let mut cmd = Command::new(skopeo_bin());
604    // Use the physical path to bootc storage from the Storage struct
605    let storage_path = format!(
606        "{}/{}",
607        store.physical_root_path,
608        crate::podstorage::CStorage::subpath()
609    );
610    crate::podstorage::set_additional_image_store(&mut cmd, &storage_path);
611    config.skopeo_cmd = Some(cmd);
612
613    // Use the preparation flow with the custom config
614    let mut imp = new_importer_with_config(repo, &ostree_imgref, config, booted_deployment).await?;
615    if let Some(target) = target_imgref {
616        imp.set_target(target);
617    }
618    let prep = match imp.prepare().await? {
619        PrepareResult::AlreadyPresent(c) => {
620            println!("No changes in {imgref:#} => {}", c.manifest_digest);
621            return Ok(PreparedPullResult::AlreadyPresent(Box::new((*c).into())));
622        }
623        PrepareResult::Ready(p) => p,
624    };
625    check_bootc_label(&prep.config);
626    if let Some(warning) = prep.deprecated_warning() {
627        ostree_ext::cli::print_deprecated_warning(warning).await;
628    }
629    ostree_ext::cli::print_layer_status(&prep);
630    let layers_to_fetch = prep.layers_to_fetch().collect::<Result<Vec<_>>>()?;
631
632    // Log that we're importing a new image from containers-storage
633    const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
634    tracing::info!(
635        message_id = PULLING_NEW_IMAGE_ID,
636        bootc.image.reference = &imgref.image,
637        bootc.image.transport = "containers-storage",
638        bootc.original_transport = &imgref.transport,
639        bootc.status = "importing_from_storage",
640        "Importing image from bootc storage: {}",
641        ostree_imgref
642    );
643
644    let prepared_image = PreparedImportMeta {
645        imp,
646        n_layers_to_fetch: layers_to_fetch.len(),
647        layers_total: prep.all_layers().count(),
648        bytes_to_fetch: layers_to_fetch.iter().map(|(l, _)| l.layer.size()).sum(),
649        bytes_total: prep.all_layers().map(|l| l.layer.size()).sum(),
650        digest: prep.manifest_digest.clone(),
651        prep,
652    };
653
654    Ok(PreparedPullResult::Ready(Box::new(prepared_image)))
655}
656
657/// Unified pull: Use podman to pull to containers-storage, then read from there
658pub(crate) async fn pull_unified(
659    repo: &ostree::Repo,
660    imgref: &ImageReference,
661    target_imgref: Option<&OstreeImageReference>,
662    quiet: bool,
663    prog: ProgressWriter,
664    store: &Storage,
665    booted_deployment: Option<&ostree::Deployment>,
666) -> Result<Box<ImageState>> {
667    match prepare_for_pull_unified(repo, imgref, target_imgref, store, booted_deployment).await? {
668        PreparedPullResult::AlreadyPresent(existing) => {
669            // Log that the image was already present (Debug level since it's not actionable)
670            const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
671            tracing::debug!(
672                message_id = IMAGE_ALREADY_PRESENT_ID,
673                bootc.image.reference = &imgref.image,
674                bootc.image.transport = &imgref.transport,
675                bootc.status = "already_present",
676                "Image already present: {}",
677                imgref
678            );
679            Ok(existing)
680        }
681        PreparedPullResult::Ready(prepared_image_meta) => {
682            check_disk_space_unified(
683                store.get_ensure_composefs()?.as_ref(),
684                &prepared_image_meta,
685                imgref,
686            )?;
687            // To avoid duplicate success logs, pass a containers-storage imgref to the importer
688            let cs_imgref = ImageReference {
689                transport: "containers-storage".to_string(),
690                image: imgref.image.clone(),
691                signature: imgref.signature.clone(),
692            };
693            pull_from_prepared(&cs_imgref, quiet, prog, *prepared_image_meta).await
694        }
695    }
696}
697
698#[context("Pulling")]
699pub(crate) async fn pull_from_prepared(
700    imgref: &ImageReference,
701    quiet: bool,
702    prog: ProgressWriter,
703    mut prepared_image: PreparedImportMeta,
704) -> Result<Box<ImageState>> {
705    let layer_progress = prepared_image.imp.request_progress();
706    let layer_byte_progress = prepared_image.imp.request_layer_progress();
707    let digest = prepared_image.digest.clone();
708    let digest_imp = prepared_image.digest.clone();
709
710    let printer = tokio::task::spawn(async move {
711        handle_layer_progress_print(LayerProgressConfig {
712            layers: layer_progress,
713            layer_bytes: layer_byte_progress,
714            digest: digest.as_ref().into(),
715            n_layers_to_fetch: prepared_image.n_layers_to_fetch,
716            layers_total: prepared_image.layers_total,
717            bytes_to_download: prepared_image.bytes_to_fetch,
718            bytes_total: prepared_image.bytes_total,
719            prog,
720            quiet,
721        })
722        .await
723    });
724    let import = prepared_image.imp.import(prepared_image.prep).await;
725    let prog = printer.await?;
726    // Both the progress and the import are done, so import is done as well
727    prog.send(Event::ProgressSteps {
728        task: "importing".into(),
729        description: "Importing Image".into(),
730        id: digest_imp.clone().as_ref().into(),
731        steps_cached: 0,
732        steps: 1,
733        steps_total: 1,
734        subtasks: [SubTaskStep {
735            subtask: "importing".into(),
736            description: "Importing Image".into(),
737            id: "importing".into(),
738            completed: true,
739        }]
740        .into(),
741    })
742    .await;
743    let import = import?;
744    let imgref_canonicalized = imgref.clone().canonicalize()?;
745    tracing::debug!("Canonicalized image reference: {imgref_canonicalized:#}");
746
747    // Log successful import completion (skip if using unified storage to avoid double logging)
748    let is_unified_path = imgref.transport == "containers-storage";
749    if !is_unified_path {
750        const IMPORT_COMPLETE_JOURNAL_ID: &str = "4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8";
751
752        tracing::info!(
753            message_id = IMPORT_COMPLETE_JOURNAL_ID,
754            bootc.image.reference = &imgref.image,
755            bootc.image.transport = &imgref.transport,
756            bootc.manifest_digest = import.manifest_digest.as_ref(),
757            bootc.ostree_commit = &import.merge_commit,
758            "Successfully imported image: {}",
759            imgref
760        );
761    }
762
763    if let Some(msg) =
764        ostree_container::store::image_filtered_content_warning(&import.filtered_files)
765            .context("Image content warning")?
766    {
767        tracing::info!("{}", msg);
768    }
769    Ok(Box::new((*import).into()))
770}
771
772/// Wrapper for pulling a container image, wiring up status output.
773pub(crate) async fn pull(
774    repo: &ostree::Repo,
775    imgref: &ImageReference,
776    target_imgref: Option<&OstreeImageReference>,
777    quiet: bool,
778    prog: ProgressWriter,
779    booted_deployment: Option<&ostree::Deployment>,
780) -> Result<Box<ImageState>> {
781    match prepare_for_pull(repo, imgref, target_imgref, booted_deployment).await? {
782        PreparedPullResult::AlreadyPresent(existing) => {
783            // Log that the image was already present (Debug level since it's not actionable)
784            const IMAGE_ALREADY_PRESENT_ID: &str = "5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9";
785            tracing::debug!(
786                message_id = IMAGE_ALREADY_PRESENT_ID,
787                bootc.image.reference = &imgref.image,
788                bootc.image.transport = &imgref.transport,
789                bootc.status = "already_present",
790                "Image already present: {}",
791                imgref
792            );
793            Ok(existing)
794        }
795        PreparedPullResult::Ready(prepared_image_meta) => {
796            // Check disk space before attempting to pull
797            check_disk_space_ostree(repo, &prepared_image_meta, imgref)?;
798            // Log that we're pulling a new image
799            const PULLING_NEW_IMAGE_ID: &str = "6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a0";
800            tracing::info!(
801                message_id = PULLING_NEW_IMAGE_ID,
802                bootc.image.reference = &imgref.image,
803                bootc.image.transport = &imgref.transport,
804                bootc.status = "pulling_new",
805                "Pulling new image: {}",
806                imgref
807            );
808            Ok(pull_from_prepared(imgref, quiet, prog, *prepared_image_meta).await?)
809        }
810    }
811}
812
813pub(crate) async fn wipe_ostree(sysroot: Sysroot) -> Result<()> {
814    tokio::task::spawn_blocking(move || {
815        sysroot
816            .write_deployments(&[], gio::Cancellable::NONE)
817            .context("removing deployments")
818    })
819    .await??;
820
821    Ok(())
822}
823
824pub(crate) async fn cleanup(sysroot: &Storage) -> Result<()> {
825    // Log the cleanup operation to systemd journal
826    const CLEANUP_JOURNAL_ID: &str = "2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6";
827
828    tracing::info!(
829        message_id = CLEANUP_JOURNAL_ID,
830        "Starting cleanup of old images and deployments"
831    );
832
833    let bound_prune = prune_container_store(sysroot);
834
835    // We create clones (just atomic reference bumps) here to move to the thread.
836    let ostree = sysroot.get_ostree_cloned()?;
837    let repo = ostree.repo();
838    let repo_prune =
839        ostree_ext::tokio_util::spawn_blocking_cancellable_flatten(move |cancellable| {
840            let locked_sysroot = &SysrootLock::from_assumed_locked(&ostree);
841            let cancellable = Some(cancellable);
842            let repo = &repo;
843            let txn = repo.auto_transaction(cancellable)?;
844            let repo = txn.repo();
845
846            // Regenerate our base references.  First, we delete the ones that exist
847            for ref_entry in repo
848                .list_refs_ext(
849                    Some(BASE_IMAGE_PREFIX),
850                    ostree::RepoListRefsExtFlags::NONE,
851                    cancellable,
852                )
853                .context("Listing refs")?
854                .keys()
855            {
856                repo.transaction_set_refspec(ref_entry, None);
857            }
858
859            // Then, for each deployment which is derived (e.g. has configmaps) we synthesize
860            // a base ref to ensure that it's not GC'd.
861            for (i, deployment) in ostree.deployments().into_iter().enumerate() {
862                let commit = deployment.csum();
863                if let Some(base) = get_base_commit(repo, &commit)? {
864                    repo.transaction_set_refspec(&format!("{BASE_IMAGE_PREFIX}/{i}"), Some(&base));
865                }
866            }
867
868            let pruned =
869                ostree_container::deploy::prune(locked_sysroot).context("Pruning images")?;
870            if !pruned.is_empty() {
871                let size = glib::format_size(pruned.objsize);
872                println!(
873                    "Pruned images: {} (layers: {}, objsize: {})",
874                    pruned.n_images, pruned.n_layers, size
875                );
876            } else {
877                tracing::debug!("Nothing to prune");
878            }
879
880            Ok(())
881        });
882
883    // We run these in parallel mostly because we can.
884    tokio::try_join!(repo_prune, bound_prune)?;
885    Ok(())
886}
887
888/// If commit is a bootc-derived commit (e.g. has configmaps), return its base.
889#[context("Finding base commit")]
890pub(crate) fn get_base_commit(repo: &ostree::Repo, commit: &str) -> Result<Option<String>> {
891    let commitv = repo.load_commit(commit)?.0;
892    let commitmeta = commitv.child_value(0);
893    let commitmeta = &glib::VariantDict::new(Some(&commitmeta));
894    let r = commitmeta.lookup::<String>(BOOTC_DERIVED_KEY)?;
895    Ok(r)
896}
897
898#[context("Writing deployment")]
899async fn deploy(
900    sysroot: &Storage,
901    from: MergeState,
902    image: &ImageState,
903    origin: &glib::KeyFile,
904    lock_finalization: bool,
905) -> Result<Deployment> {
906    // Compute the kernel argument overrides. In practice today this API is always expecting
907    // a merge deployment. The kargs code also always looks at the booted root (which
908    // is a distinct minor issue, but not super important as right now the install path
909    // doesn't use this API).
910    let (stateroot, override_kargs) = match &from {
911        MergeState::MergeDeployment(deployment) => {
912            let kargs = crate::bootc_kargs::get_kargs(sysroot, &deployment, image)?;
913            (deployment.stateroot().into(), Some(kargs))
914        }
915        MergeState::Reset { stateroot, kargs } => (stateroot.clone(), Some(kargs.clone())),
916    };
917    // Clone all the things to move to worker thread
918    let ostree = sysroot.get_ostree_cloned()?;
919    // ostree::Deployment is incorrectly !Send 😢 so convert it to an integer
920    let merge_deployment = from.as_merge_deployment();
921    let merge_deployment = merge_deployment.map(|d| d.index() as usize);
922    let ostree_commit = image.ostree_commit.to_string();
923    // GKeyFile also isn't Send! So we serialize that as a string...
924    let origin_data = origin.to_data();
925    let r = async_task_with_spinner(
926        "Deploying",
927        spawn_blocking_cancellable_flatten(move |cancellable| -> Result<_> {
928            let ostree = ostree;
929            let stateroot = Some(stateroot);
930            let mut opts = ostree::SysrootDeployTreeOpts::default();
931
932            // Set finalization lock if requested
933            opts.locked = lock_finalization;
934
935            // Because the C API expects a Vec<&str>, convert the Cmdline to string slices.
936            // The references borrow from the Cmdline, which outlives this usage.
937            let override_kargs_refs = override_kargs
938                .as_ref()
939                .map(|kargs| kargs.iter_str().collect::<Vec<_>>());
940            if let Some(kargs) = override_kargs_refs.as_ref() {
941                opts.override_kernel_argv = Some(kargs);
942            }
943
944            let deployments = ostree.deployments();
945            let merge_deployment = merge_deployment.map(|m| &deployments[m]);
946            let origin = glib::KeyFile::new();
947            origin.load_from_data(&origin_data, glib::KeyFileFlags::NONE)?;
948            let d = ostree.stage_tree_with_options(
949                stateroot.as_deref(),
950                &ostree_commit,
951                Some(&origin),
952                merge_deployment,
953                &opts,
954                Some(cancellable),
955            )?;
956            Ok(d.index())
957        }),
958    )
959    .await?;
960    // SAFETY: We must have a staged deployment
961    let ostree = sysroot.get_ostree()?;
962    let staged = ostree.staged_deployment().unwrap();
963    assert_eq!(staged.index(), r);
964    Ok(staged)
965}
966
967#[context("Generating origin")]
968fn origin_from_imageref(imgref: &ImageReference) -> Result<glib::KeyFile> {
969    let origin = glib::KeyFile::new();
970    let imgref = OstreeImageReference::from(imgref.clone());
971    origin.set_string(
972        "origin",
973        ostree_container::deploy::ORIGIN_CONTAINER,
974        imgref.to_string().as_str(),
975    );
976    Ok(origin)
977}
978
979/// The source of data for staging a new deployment
980#[derive(Debug)]
981pub(crate) enum MergeState {
982    /// Use the provided merge deployment
983    MergeDeployment(Deployment),
984    /// Don't use a merge deployment, but only this
985    /// provided initial state.
986    Reset {
987        stateroot: String,
988        kargs: CmdlineOwned,
989    },
990}
991impl MergeState {
992    /// Initialize using the default merge deployment for the given stateroot.
993    pub(crate) fn from_stateroot(sysroot: &Storage, stateroot: &str) -> Result<Self> {
994        let ostree = sysroot.get_ostree()?;
995        let merge_deployment = ostree.merge_deployment(Some(stateroot)).ok_or_else(|| {
996            anyhow::anyhow!("No merge deployment found for stateroot {stateroot}")
997        })?;
998        Ok(Self::MergeDeployment(merge_deployment))
999    }
1000
1001    /// Cast this to a merge deployment case.
1002    pub(crate) fn as_merge_deployment(&self) -> Option<&Deployment> {
1003        match self {
1004            Self::MergeDeployment(d) => Some(d),
1005            Self::Reset { .. } => None,
1006        }
1007    }
1008}
1009
1010/// Stage (queue deployment of) a fetched container image.
1011#[context("Staging")]
1012pub(crate) async fn stage(
1013    sysroot: &Storage,
1014    from: MergeState,
1015    image: &ImageState,
1016    spec: &RequiredHostSpec<'_>,
1017    prog: ProgressWriter,
1018    lock_finalization: bool,
1019) -> Result<()> {
1020    // Log the staging operation to systemd journal with comprehensive upgrade information
1021    const STAGE_JOURNAL_ID: &str = "8f7a2b1c3d4e5f6a7b8c9d0e1f2a3b4c";
1022
1023    tracing::info!(
1024        message_id = STAGE_JOURNAL_ID,
1025        bootc.image.reference = &spec.image.image,
1026        bootc.image.transport = &spec.image.transport,
1027        bootc.manifest_digest = image.manifest_digest.as_ref(),
1028        "Staging image for deployment: {} (digest: {})",
1029        spec.image,
1030        image.manifest_digest
1031    );
1032
1033    let mut subtask = SubTaskStep {
1034        subtask: "merging".into(),
1035        description: "Merging Image".into(),
1036        id: "fetching".into(),
1037        completed: false,
1038    };
1039    let mut subtasks = vec![];
1040    prog.send(Event::ProgressSteps {
1041        task: "staging".into(),
1042        description: "Deploying Image".into(),
1043        id: image.manifest_digest.clone().as_ref().into(),
1044        steps_cached: 0,
1045        steps: 0,
1046        steps_total: 3,
1047        subtasks: subtasks
1048            .clone()
1049            .into_iter()
1050            .chain([subtask.clone()])
1051            .collect(),
1052    })
1053    .await;
1054
1055    subtask.completed = true;
1056    subtasks.push(subtask.clone());
1057    subtask.subtask = "deploying".into();
1058    subtask.id = "deploying".into();
1059    subtask.description = "Deploying Image".into();
1060    subtask.completed = false;
1061    prog.send(Event::ProgressSteps {
1062        task: "staging".into(),
1063        description: "Deploying Image".into(),
1064        id: image.manifest_digest.clone().as_ref().into(),
1065        steps_cached: 0,
1066        steps: 1,
1067        steps_total: 3,
1068        subtasks: subtasks
1069            .clone()
1070            .into_iter()
1071            .chain([subtask.clone()])
1072            .collect(),
1073    })
1074    .await;
1075    let origin = origin_from_imageref(spec.image)?;
1076    let deployment =
1077        crate::deploy::deploy(sysroot, from, image, &origin, lock_finalization).await?;
1078
1079    subtask.completed = true;
1080    subtasks.push(subtask.clone());
1081    subtask.subtask = "bound_images".into();
1082    subtask.id = "bound_images".into();
1083    subtask.description = "Pulling Bound Images".into();
1084    subtask.completed = false;
1085    prog.send(Event::ProgressSteps {
1086        task: "staging".into(),
1087        description: "Deploying Image".into(),
1088        id: image.manifest_digest.clone().as_ref().into(),
1089        steps_cached: 0,
1090        steps: 1,
1091        steps_total: 3,
1092        subtasks: subtasks
1093            .clone()
1094            .into_iter()
1095            .chain([subtask.clone()])
1096            .collect(),
1097    })
1098    .await;
1099    crate::boundimage::pull_bound_images(sysroot, &deployment).await?;
1100
1101    subtask.completed = true;
1102    subtasks.push(subtask.clone());
1103    subtask.subtask = "cleanup".into();
1104    subtask.id = "cleanup".into();
1105    subtask.description = "Removing old images".into();
1106    subtask.completed = false;
1107    prog.send(Event::ProgressSteps {
1108        task: "staging".into(),
1109        description: "Deploying Image".into(),
1110        id: image.manifest_digest.clone().as_ref().into(),
1111        steps_cached: 0,
1112        steps: 2,
1113        steps_total: 3,
1114        subtasks: subtasks
1115            .clone()
1116            .into_iter()
1117            .chain([subtask.clone()])
1118            .collect(),
1119    })
1120    .await;
1121    crate::deploy::cleanup(sysroot).await?;
1122    println!("Queued for next boot: {:#}", spec.image);
1123    if let Some(version) = image.version.as_deref() {
1124        println!("  Version: {version}");
1125    }
1126    println!("  Digest: {}", image.manifest_digest);
1127
1128    subtask.completed = true;
1129    subtasks.push(subtask.clone());
1130    prog.send(Event::ProgressSteps {
1131        task: "staging".into(),
1132        description: "Deploying Image".into(),
1133        id: image.manifest_digest.clone().as_ref().into(),
1134        steps_cached: 0,
1135        steps: 3,
1136        steps_total: 3,
1137        subtasks: subtasks
1138            .clone()
1139            .into_iter()
1140            .chain([subtask.clone()])
1141            .collect(),
1142    })
1143    .await;
1144
1145    // Unconditionally create or update /run/reboot-required to signal a reboot is needed.
1146    // This is monitored by kured (Kubernetes Reboot Daemon).
1147    write_reboot_required(&image.manifest_digest.as_ref())?;
1148
1149    Ok(())
1150}
1151
1152/// Update the /run/reboot-required file with the image that will be active after a reboot.
1153fn write_reboot_required(image: &str) -> Result<()> {
1154    let reboot_message = format!("bootc: Reboot required for image: {}", image);
1155    let run_dir = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?;
1156    run_dir
1157        .atomic_write("reboot-required", reboot_message.as_bytes())
1158        .context("Creating /run/reboot-required")?;
1159
1160    Ok(())
1161}
1162
1163pub(crate) const ROLLBACK_JOURNAL_ID: &str = "26f3b1eb24464d12aa5e7b544a6b5468";
1164
1165/// Implementation of rollback functionality
1166pub(crate) async fn rollback(sysroot: &Storage) -> Result<()> {
1167    let ostree = sysroot.get_ostree()?;
1168    let (booted_ostree, deployments, host) = crate::status::get_status_require_booted(ostree)?;
1169
1170    let new_spec = {
1171        let mut new_spec = host.spec.clone();
1172        new_spec.boot_order = new_spec.boot_order.swap();
1173        new_spec
1174    };
1175
1176    let repo = &booted_ostree.repo();
1177
1178    // Just to be sure
1179    host.spec.verify_transition(&new_spec)?;
1180
1181    let reverting = new_spec.boot_order == BootOrder::Default;
1182    if reverting {
1183        println!("notice: Reverting queued rollback state");
1184    }
1185    let rollback_status = host
1186        .status
1187        .rollback
1188        .ok_or_else(|| anyhow!("No rollback available"))?;
1189    let rollback_image = rollback_status
1190        .query_image(repo)?
1191        .ok_or_else(|| anyhow!("Rollback is not container image based"))?;
1192
1193    // Get current booted image for comparison
1194    let current_image = host
1195        .status
1196        .booted
1197        .as_ref()
1198        .and_then(|b| b.query_image(repo).ok()?);
1199
1200    tracing::info!(
1201        message_id = ROLLBACK_JOURNAL_ID,
1202        bootc.manifest_digest = rollback_image.manifest_digest.as_ref(),
1203        bootc.ostree_commit = &rollback_image.merge_commit,
1204        bootc.rollback_type = if reverting { "revert" } else { "rollback" },
1205        bootc.current_manifest_digest = current_image
1206            .as_ref()
1207            .map(|i| i.manifest_digest.as_ref())
1208            .unwrap_or("none"),
1209        "Rolling back to image: {}",
1210        rollback_image.manifest_digest
1211    );
1212    // SAFETY: If there's a rollback status, then there's a deployment
1213    let rollback_deployment = deployments.rollback.expect("rollback deployment");
1214    let new_deployments = if reverting {
1215        [booted_ostree.deployment, rollback_deployment]
1216    } else {
1217        [rollback_deployment, booted_ostree.deployment]
1218    };
1219    let new_deployments = new_deployments
1220        .into_iter()
1221        .chain(deployments.other)
1222        .collect::<Vec<_>>();
1223    tracing::debug!("Writing new deployments: {new_deployments:?}");
1224    booted_ostree
1225        .sysroot
1226        .write_deployments(&new_deployments, gio::Cancellable::NONE)?;
1227    if reverting {
1228        println!("Next boot: current deployment");
1229    } else {
1230        println!("Next boot: rollback deployment");
1231    }
1232
1233    write_reboot_required(rollback_image.manifest_digest.as_ref())?;
1234
1235    sysroot.update_mtime()?;
1236
1237    Ok(())
1238}
1239
1240fn find_newest_deployment_name(deploysdir: &Dir) -> Result<String> {
1241    let mut dirs = Vec::new();
1242    for ent in deploysdir.entries()? {
1243        let ent = ent?;
1244        if !ent.file_type()?.is_dir() {
1245            continue;
1246        }
1247        let name = ent.file_name();
1248        let Some(name) = name.to_str() else {
1249            continue;
1250        };
1251        dirs.push((name.to_owned(), ent.metadata()?.mtime()));
1252    }
1253    dirs.sort_unstable_by(|a, b| a.1.cmp(&b.1));
1254    if let Some((name, _ts)) = dirs.pop() {
1255        Ok(name)
1256    } else {
1257        anyhow::bail!("No deployment directory found")
1258    }
1259}
1260
1261// Implementation of `bootc switch --in-place`
1262pub(crate) fn switch_origin_inplace(root: &Dir, imgref: &ImageReference) -> Result<String> {
1263    // Log the in-place switch operation to systemd journal
1264    const SWITCH_INPLACE_JOURNAL_ID: &str = "3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7";
1265
1266    tracing::info!(
1267        message_id = SWITCH_INPLACE_JOURNAL_ID,
1268        bootc.image.reference = &imgref.image,
1269        bootc.image.transport = &imgref.transport,
1270        bootc.switch_type = "in_place",
1271        "Performing in-place switch to image: {}",
1272        imgref
1273    );
1274
1275    // First, just create the new origin file
1276    let origin = origin_from_imageref(imgref)?;
1277    let serialized_origin = origin.to_data();
1278
1279    // Now, we can't rely on being officially booted (e.g. with the `ostree=` karg)
1280    // in a scenario like running in the anaconda %post.
1281    // Eventually, we should support a setup here where ostree-prepare-root
1282    // can officially be run to "enter" an ostree root in a supportable way.
1283    // Anyways for now, the brutal hack is to just scrape through the deployments
1284    // and find the newest one, which we will mutate.  If there's more than one,
1285    // ultimately the calling tooling should be fixed to set things up correctly.
1286
1287    let mut ostree_deploys = root.open_dir("sysroot/ostree/deploy")?.entries()?;
1288    let deploydir = loop {
1289        if let Some(ent) = ostree_deploys.next() {
1290            let ent = ent?;
1291            if !ent.file_type()?.is_dir() {
1292                continue;
1293            }
1294            tracing::debug!("Checking {:?}", ent.file_name());
1295            let child_dir = ent
1296                .open_dir()
1297                .with_context(|| format!("Opening dir {:?}", ent.file_name()))?;
1298            if let Some(d) = child_dir.open_dir_optional("deploy")? {
1299                break d;
1300            }
1301        } else {
1302            anyhow::bail!("Failed to find a deployment");
1303        }
1304    };
1305    let newest_deployment = find_newest_deployment_name(&deploydir)?;
1306    let origin_path = format!("{newest_deployment}.origin");
1307    if !deploydir.try_exists(&origin_path)? {
1308        tracing::warn!("No extant origin for {newest_deployment}");
1309    }
1310    deploydir
1311        .atomic_write(&origin_path, serialized_origin.as_bytes())
1312        .context("Writing origin")?;
1313    Ok(newest_deployment)
1314}
1315
1316/// A workaround for <https://github.com/ostreedev/ostree/issues/3193>
1317/// as generated by anaconda.
1318#[context("Updating /etc/fstab for anaconda+composefs")]
1319pub(crate) fn fixup_etc_fstab(root: &Dir) -> Result<()> {
1320    let fstab_path = "etc/fstab";
1321    // Read the old file
1322    let fd = root
1323        .open(fstab_path)
1324        .with_context(|| format!("Opening {fstab_path}"))
1325        .map(std::io::BufReader::new)?;
1326
1327    // Helper function to possibly change a line from /etc/fstab.
1328    // Returns Ok(true) if we made a change (and we wrote the modified line)
1329    // otherwise returns Ok(false) and the caller should write the original line.
1330    fn edit_fstab_line(line: &str, mut w: impl Write) -> Result<bool> {
1331        if line.starts_with('#') {
1332            return Ok(false);
1333        }
1334        let parts = line.split_ascii_whitespace().collect::<Vec<_>>();
1335
1336        let path_idx = 1;
1337        let options_idx = 3;
1338        let (&path, &options) = match (parts.get(path_idx), parts.get(options_idx)) {
1339            (None, _) => {
1340                tracing::debug!("No path in entry: {line}");
1341                return Ok(false);
1342            }
1343            (_, None) => {
1344                tracing::debug!("No options in entry: {line}");
1345                return Ok(false);
1346            }
1347            (Some(p), Some(o)) => (p, o),
1348        };
1349        // If this is not the root, we're not matching on it
1350        if path != "/" {
1351            return Ok(false);
1352        }
1353        // If options already contains `ro`, nothing to do
1354        if options.split(',').any(|s| s == "ro") {
1355            return Ok(false);
1356        }
1357
1358        writeln!(w, "# {}", crate::generator::BOOTC_EDITED_STAMP)?;
1359
1360        // SAFETY: we unpacked the options before.
1361        // This adds `ro` to the option list
1362        assert!(!options.is_empty()); // Split wouldn't have turned this up if it was empty
1363        let options = format!("{options},ro");
1364        for (i, part) in parts.into_iter().enumerate() {
1365            // TODO: would obviously be nicer to preserve whitespace...but...eh.
1366            if i > 0 {
1367                write!(w, " ")?;
1368            }
1369            if i == options_idx {
1370                write!(w, "{options}")?;
1371            } else {
1372                write!(w, "{part}")?
1373            }
1374        }
1375        // And add the trailing newline
1376        writeln!(w)?;
1377        Ok(true)
1378    }
1379
1380    // Read the input, and atomically write a modified version
1381    root.atomic_replace_with(fstab_path, move |mut w| -> Result<()> {
1382        for line in fd.lines() {
1383            let line = line?;
1384            if !edit_fstab_line(&line, &mut w)? {
1385                writeln!(w, "{line}")?;
1386            }
1387        }
1388        Ok(())
1389    })
1390    .context("Replacing /etc/fstab")?;
1391
1392    println!("Updated /etc/fstab to add `ro` for `/`");
1393    Ok(())
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    #[test]
1401    fn test_new_proxy_config_user_agent() {
1402        let config = new_proxy_config();
1403        let prefix = config
1404            .user_agent_prefix
1405            .expect("user_agent_prefix should be set");
1406        assert!(
1407            prefix.starts_with("bootc/"),
1408            "User agent should start with bootc/"
1409        );
1410        // Verify the version is present (not just "bootc/")
1411        assert!(
1412            prefix.len() > "bootc/".len(),
1413            "Version should be present after bootc/"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_switch_inplace() -> Result<()> {
1419        use cap_std::fs::DirBuilderExt;
1420
1421        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1422        let mut builder = cap_std::fs::DirBuilder::new();
1423        let builder = builder.recursive(true).mode(0o755);
1424        let deploydir = "sysroot/ostree/deploy/default/deploy";
1425        let target_deployment =
1426            "af36eb0086bb55ac601600478c6168f834288013d60f8870b7851f44bf86c3c5.0";
1427        td.ensure_dir_with(
1428            format!("sysroot/ostree/deploy/default/deploy/{target_deployment}"),
1429            builder,
1430        )?;
1431        let deploydir = &td.open_dir(deploydir)?;
1432        let orig_imgref = ImageReference {
1433            image: "quay.io/exampleos/original:sometag".into(),
1434            transport: "registry".into(),
1435            signature: None,
1436        };
1437        {
1438            let origin = origin_from_imageref(&orig_imgref)?;
1439            deploydir.atomic_write(
1440                format!("{target_deployment}.origin"),
1441                origin.to_data().as_bytes(),
1442            )?;
1443        }
1444
1445        let target_imgref = ImageReference {
1446            image: "quay.io/someother/otherimage:latest".into(),
1447            transport: "registry".into(),
1448            signature: None,
1449        };
1450
1451        let replaced = switch_origin_inplace(&td, &target_imgref).unwrap();
1452        assert_eq!(replaced, target_deployment);
1453        Ok(())
1454    }
1455
1456    #[test]
1457    fn test_fixup_etc_fstab_default() -> Result<()> {
1458        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1459        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n";
1460        tempdir.create_dir_all("etc")?;
1461        tempdir.atomic_write("etc/fstab", default)?;
1462        fixup_etc_fstab(&tempdir).unwrap();
1463        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1464        Ok(())
1465    }
1466
1467    #[test]
1468    fn test_fixup_etc_fstab_multi() -> Result<()> {
1469        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1470        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1471UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1472        tempdir.create_dir_all("etc")?;
1473        tempdir.atomic_write("etc/fstab", default)?;
1474        fixup_etc_fstab(&tempdir).unwrap();
1475        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1476        Ok(())
1477    }
1478
1479    #[test]
1480    fn test_fixup_etc_fstab_ro() -> Result<()> {
1481        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1482        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1483UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 /                     xfs   ro 0 0\n\
1484UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1485        tempdir.create_dir_all("etc")?;
1486        tempdir.atomic_write("etc/fstab", default)?;
1487        fixup_etc_fstab(&tempdir).unwrap();
1488        assert_eq!(tempdir.read_to_string("etc/fstab")?, default);
1489        Ok(())
1490    }
1491
1492    #[test]
1493    fn test_fixup_etc_fstab_rw() -> Result<()> {
1494        let tempdir = cap_std_ext::cap_tempfile::tempdir(cap_std::ambient_authority())?;
1495        // This case uses `defaults`
1496        let default = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1497UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 /                     xfs   defaults 0 0\n\
1498UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1499        let modified = "UUID=f7436547-20ac-43cb-aa2f-eac9632183f6 /boot auto ro 0 0\n\
1500# Updated by bootc-fstab-edit.service\n\
1501UUID=1eef9f42-40e3-4bd8-ae20-e9f2325f8b52 / xfs defaults,ro 0 0\n\
1502UUID=6907-17CA          /boot/efi               vfat    umask=0077,shortname=winnt 0 2\n";
1503        tempdir.create_dir_all("etc")?;
1504        tempdir.atomic_write("etc/fstab", default)?;
1505        fixup_etc_fstab(&tempdir).unwrap();
1506        assert_eq!(tempdir.read_to_string("etc/fstab")?, modified);
1507        Ok(())
1508    }
1509    #[test]
1510    fn test_check_disk_space_inner() -> Result<()> {
1511        let td = cap_std_ext::cap_tempfile::TempDir::new(cap_std::ambient_authority())?;
1512        let imgref = ImageReference {
1513            image: "quay.io/exampleos/exampleos:latest".into(),
1514            transport: "registry".into(),
1515            signature: None,
1516        };
1517
1518        // 0 bytes needed always passes
1519        check_disk_space_inner(&*td, 0, 0, &imgref)?;
1520
1521        // u64::MAX bytes needed always fails
1522        assert!(check_disk_space_inner(&*td, u64::MAX, 0, &imgref).is_err());
1523
1524        // With min_free consuming all usable space, even a tiny fetch fails
1525        assert!(check_disk_space_inner(&*td, 1, u64::MAX, &imgref).is_err());
1526
1527        Ok(())
1528    }
1529}