Skip to main content

bootc_lib/
image.rs

1//! APIs for operating on container images in the bootc storage.
2//!
3//! ## `bootc image set-unified`
4//!
5//! `set_unified_entrypoint` dispatches to `set_unified` (ostree backend) or
6//! `set_unified_composefs` (composefs backend). Both pull the currently booted
7//! image into bootc-owned containers-storage so that future upgrade/switch
8//! operations can use the unified storage path.
9//!
10//! In the planned three-store architecture (see [`crate::store`]), this will
11//! require a reflink-capable filesystem (XFS or btrfs) by default to enable
12//! block sharing. The planned `--allow-copy` flag will opt into a byte copy
13//! for environments like ext4 where podman access to the OS image matters
14//! more than disk efficiency.
15
16use anyhow::{Context, Result, bail};
17use bootc_utils::CommandRunExt;
18use cap_std_ext::cap_std::{self, fs::Dir};
19use clap::ValueEnum;
20use comfy_table::{Table, presets::NOTHING};
21use fn_error_context::context;
22use ostree_ext::container::{ImageReference, Transport};
23use serde::Serialize;
24
25use crate::{
26    boundimage::query_bound_images,
27    cli::{ImageListFormat, ImageListType},
28    podstorage::CStorage,
29    spec::Host,
30    store::Storage,
31    utils::async_task_with_spinner,
32};
33
34/// The name of the image we push to containers-storage if nothing is specified.
35pub(crate) const IMAGE_DEFAULT: &str = "localhost/bootc";
36
37/// Check if an image exists in the default containers-storage (podman storage).
38///
39/// TODO: Using exit codes to check image existence is not ideal. We should use
40/// the podman native libpod HTTP API to properly communicate with podman and
41/// get structured responses.
42async fn image_exists_in_host_storage(image: &str) -> Result<bool> {
43    use tokio::process::Command as AsyncCommand;
44    let mut cmd = AsyncCommand::new(bootc_utils::podman_bin());
45    cmd.args(["image", "exists", image]);
46    Ok(cmd.status().await?.success())
47}
48
49#[derive(Clone, Serialize, ValueEnum)]
50#[serde(rename_all = "lowercase")]
51enum ImageListTypeColumn {
52    Host,
53    Unified,
54    Logical,
55}
56
57impl std::fmt::Display for ImageListTypeColumn {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        self.to_possible_value().unwrap().get_name().fmt(f)
60    }
61}
62
63#[derive(Serialize)]
64struct ImageOutput {
65    image_type: ImageListTypeColumn,
66    image: String,
67    // TODO: Add hash, size, etc? Difficult because [`ostree_ext::container::store::list_images`]
68    // only gives us the pullspec.
69}
70
71#[context("Listing host images")]
72async fn list_host_images(sysroot: &crate::store::Storage) -> Result<Vec<ImageOutput>> {
73    let mut result = Vec::new();
74    if let Ok(ostree) = sysroot.get_ostree() {
75        let repo = ostree.repo();
76        let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?;
77        result.extend(images.into_iter().map(|image| ImageOutput {
78            image,
79            image_type: ImageListTypeColumn::Host,
80        }));
81    }
82    // Always include images from bootc-owned containers-storage (unified).
83    // On composefs-only systems these are the host images; on ostree systems
84    // they supplement the ostree images when the user has opted into unified
85    // storage via `bootc image set-unified`.
86    result.extend(list_host_images_composefs(sysroot).await?);
87    Ok(result)
88}
89
90#[context("Listing host images from containers-storage")]
91async fn list_host_images_composefs(sysroot: &crate::store::Storage) -> Result<Vec<ImageOutput>> {
92    let sysroot_dir = &sysroot.physical_root;
93    let subpath = CStorage::subpath();
94    if !sysroot_dir.try_exists(&subpath).unwrap_or(false) {
95        return Ok(Vec::new());
96    }
97    let run = Dir::open_ambient_dir("/run", cap_std::ambient_authority())?;
98    let imgstore = CStorage::create(sysroot_dir, &run, None)?;
99    let images = imgstore
100        .list_images()
101        .await
102        .context("Listing containers-storage images")?;
103    Ok(images
104        .into_iter()
105        .flat_map(|entry| {
106            entry
107                .names
108                .unwrap_or_default()
109                .into_iter()
110                .map(|name| ImageOutput {
111                    image: name,
112                    image_type: ImageListTypeColumn::Unified,
113                })
114        })
115        .collect())
116}
117
118#[context("Listing logical images")]
119fn list_logical_images(root: &Dir) -> Result<Vec<ImageOutput>> {
120    let bound = query_bound_images(root)?;
121
122    Ok(bound
123        .into_iter()
124        .map(|image| ImageOutput {
125            image: image.image,
126            image_type: ImageListTypeColumn::Logical,
127        })
128        .collect())
129}
130
131async fn list_images(list_type: ImageListType) -> Result<Vec<ImageOutput>> {
132    let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())
133        .context("Opening /")?;
134
135    let sysroot: Option<crate::store::BootedStorage> =
136        if ostree_ext::container_utils::running_in_container() {
137            None
138        } else {
139            Some(crate::cli::get_storage().await?)
140        };
141
142    Ok(match (list_type, sysroot) {
143        // TODO: Should we list just logical images silently here, or error?
144        (ImageListType::All, None) => list_logical_images(&rootfs)?,
145        (ImageListType::All, Some(sysroot)) => list_host_images(&sysroot)
146            .await?
147            .into_iter()
148            .chain(list_logical_images(&rootfs)?)
149            .collect(),
150        (ImageListType::Logical, _) => list_logical_images(&rootfs)?,
151        (ImageListType::Host, None) => {
152            bail!("Listing host images requires a booted bootc system")
153        }
154        (ImageListType::Host, Some(sysroot)) => list_host_images(&sysroot).await?,
155    })
156}
157
158#[context("Listing images")]
159pub(crate) async fn list_entrypoint(
160    list_type: ImageListType,
161    list_format: ImageListFormat,
162) -> Result<()> {
163    let images = list_images(list_type).await?;
164
165    match list_format {
166        ImageListFormat::Table => {
167            let mut table = Table::new();
168
169            table
170                .load_preset(NOTHING)
171                .set_content_arrangement(comfy_table::ContentArrangement::Dynamic)
172                .set_header(["REPOSITORY", "TYPE"]);
173
174            for image in images {
175                table.add_row([image.image, image.image_type.to_string()]);
176            }
177
178            println!("{table}");
179        }
180        ImageListFormat::Json => {
181            let mut stdout = std::io::stdout();
182            serde_json::to_writer_pretty(&mut stdout, &images)?;
183        }
184    }
185
186    Ok(())
187}
188
189/// Returns the source and target ImageReference
190/// If the source isn't specified, we use booted image
191/// If the target isn't specified, we push to containers-storage with our default image
192pub(crate) async fn get_imgrefs_for_copy(
193    host: &Host,
194    source: Option<&str>,
195    target: Option<&str>,
196) -> Result<(ImageReference, ImageReference)> {
197    // Initialize floating c_storage early - needed for container operations
198    crate::podstorage::ensure_floating_c_storage_initialized();
199
200    // If the target isn't specified, push to containers-storage + our default image
201    let dest_imgref = match target {
202        Some(target) => ostree_ext::container::ImageReference {
203            transport: Transport::ContainerStorage,
204            name: target.to_owned(),
205        },
206        None => ostree_ext::container::ImageReference {
207            transport: Transport::ContainerStorage,
208            name: IMAGE_DEFAULT.into(),
209        },
210    };
211
212    // If the source isn't specified, we use the booted image
213    let src_imgref = match source {
214        Some(source) => ostree_ext::container::ImageReference::try_from(source)
215            .context("Parsing source image")?,
216
217        None => {
218            let booted = host
219                .status
220                .booted
221                .as_ref()
222                .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))?;
223
224            let booted_image = &booted.image.as_ref().unwrap().image;
225
226            ImageReference {
227                transport: Transport::try_from(booted_image.transport.as_str()).unwrap(),
228                name: booted_image.image.clone(),
229            }
230        }
231    };
232
233    return Ok((src_imgref, dest_imgref));
234}
235
236/// Implementation of `bootc image push-to-storage`.
237#[context("Pushing image")]
238pub(crate) async fn push_entrypoint(
239    storage: &Storage,
240    host: &Host,
241    source: Option<&str>,
242    target: Option<&str>,
243) -> Result<()> {
244    let (source, target) = get_imgrefs_for_copy(host, source, target).await?;
245
246    let ostree = storage.get_ostree()?;
247    let repo = &ostree.repo();
248
249    let mut opts = ostree_ext::container::store::ExportToOCIOpts::default();
250    opts.progress_to_stdout = true;
251    println!("Copying local image {source} to {target} ...");
252    let r = ostree_ext::container::store::export(repo, &source, &target, Some(opts)).await?;
253
254    println!("Pushed: {target} {r}");
255    Ok(())
256}
257
258/// Thin wrapper for invoking `podman image <X>` but set up for our internal
259/// image store (as distinct from /var/lib/containers default).
260pub(crate) async fn imgcmd_entrypoint(
261    storage: &CStorage,
262    arg: &str,
263    args: &[std::ffi::OsString],
264) -> std::result::Result<(), anyhow::Error> {
265    let mut cmd = storage.new_image_cmd()?;
266    cmd.arg(arg);
267    cmd.args(args);
268    cmd.run_capture_stderr()
269}
270
271/// Re-pull the currently booted image into the bootc-owned container storage.
272///
273/// This onboards the system to unified storage for host images so that
274/// upgrade/switch can use the unified path automatically when the image is present.
275#[context("Setting unified storage for booted image")]
276pub(crate) async fn set_unified_entrypoint() -> Result<()> {
277    let storage = crate::cli::get_storage().await?;
278
279    if let crate::store::BootedStorageKind::Composefs(booted_cfs) = storage.kind()? {
280        return set_unified_composefs(&storage, &booted_cfs).await;
281    }
282
283    // Initialize floating c_storage early - needed for container operations
284    crate::podstorage::ensure_floating_c_storage_initialized();
285
286    set_unified(&storage).await
287}
288
289/// Composefs implementation of set_unified: pull the booted image into
290/// bootc-owned containers-storage so future upgrades use the unified
291/// (zero-copy) path automatically.
292#[context("Setting unified storage for composefs")]
293async fn set_unified_composefs(
294    storage: &crate::store::Storage,
295    booted_cfs: &crate::store::BootedComposefs,
296) -> Result<()> {
297    use crate::bootc_composefs::status::get_composefs_status;
298
299    const SET_UNIFIED_CFS_JOURNAL_ID: &str = "2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e";
300
301    let host = get_composefs_status(storage, booted_cfs)
302        .await
303        .context("Getting composefs deployment status")?;
304
305    let imgref = host
306        .spec
307        .image
308        .as_ref()
309        .ok_or_else(|| anyhow::anyhow!("No image source specified for booted deployment"))?;
310
311    tracing::info!(
312        message_id = SET_UNIFIED_CFS_JOURNAL_ID,
313        bootc.image.reference = &imgref.image,
314        bootc.image.transport = &imgref.transport,
315        "Pulling booted image into bootc containers-storage for unified storage: {}",
316        imgref,
317    );
318
319    let imgstore = storage.get_ensure_imgstore()?;
320
321    // Check if the image is already in bootc storage
322    let img_transport = imgref.to_transport_image()?;
323    if imgstore.exists(&img_transport).await? {
324        println!("Image {} is already in bootc storage.", imgref.image);
325        tracing::info!(
326            message_id = SET_UNIFIED_CFS_JOURNAL_ID,
327            bootc.status = "already_unified",
328            "Image already present in bootc containers-storage",
329        );
330        return Ok(());
331    }
332
333    // Pull into bootc-owned containers-storage.
334    // If the image exists in the host's default containers-storage
335    // (/var/lib/containers), copy from there (avoids network).
336    // Otherwise, pull from the original transport.
337    let image_in_host = image_exists_in_host_storage(&imgref.image).await?;
338
339    if image_in_host {
340        tracing::info!(
341            "Image {} found in host containers-storage; copying to bootc storage",
342            &imgref.image
343        );
344        let image_name = imgref.image.clone();
345        let copy_msg = format!("Copying {} to bootc storage", &image_name);
346        async_task_with_spinner(&copy_msg, async move {
347            imgstore.pull_from_host_storage(&image_name).await
348        })
349        .await?;
350    } else {
351        let pull_ref = img_transport;
352        let pull_msg = format!("Pulling {} to bootc storage", &pull_ref);
353        async_task_with_spinner(&pull_msg, async move {
354            imgstore.pull_with_progress(&pull_ref).await
355        })
356        .await?;
357    }
358
359    // Verify
360    let imgstore = storage.get_ensure_imgstore()?;
361    let img_transport = imgref.to_transport_image()?;
362    if !imgstore.exists(&img_transport).await? {
363        anyhow::bail!(
364            "Image was pulled but not found in bootc storage: {}",
365            &imgref.image
366        );
367    }
368
369    tracing::info!(
370        message_id = SET_UNIFIED_CFS_JOURNAL_ID,
371        bootc.status = "set_unified_complete",
372        "Unified storage set. Future upgrade/switch will use zero-copy path automatically.",
373    );
374    println!("Unified storage enabled for {}.", imgref.image);
375    Ok(())
376}
377
378/// Inner implementation of set_unified for ostree that accepts a storage reference.
379#[context("Setting unified storage for booted image")]
380pub(crate) async fn set_unified(sysroot: &crate::store::Storage) -> Result<()> {
381    let ostree = sysroot.get_ostree()?;
382    let repo = &ostree.repo();
383
384    // Discover the currently booted image reference.
385    // get_status_require_booted validates that we have a booted deployment with an image.
386    let (_booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?;
387
388    // Use the booted deployment's image from the status we just retrieved.
389    // get_status_require_booted guarantees host.status.booted is Some.
390    let booted_entry = host
391        .status
392        .booted
393        .as_ref()
394        .ok_or_else(|| anyhow::anyhow!("No booted deployment found"))?;
395    let image_status = booted_entry
396        .image
397        .as_ref()
398        .ok_or_else(|| anyhow::anyhow!("Booted deployment is not from a container image"))?;
399
400    // Extract the ImageReference from the ImageStatus
401    let imgref = &image_status.image;
402
403    // Canonicalize for pull display only, but we want to preserve original pullspec
404    let imgref_display = imgref.clone().canonicalize()?;
405
406    // Pull the image from its original source into bootc storage using LBI machinery
407    let imgstore = sysroot.get_ensure_imgstore()?;
408
409    const SET_UNIFIED_JOURNAL_ID: &str = "1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d";
410    tracing::info!(
411        message_id = SET_UNIFIED_JOURNAL_ID,
412        bootc.image.reference = &imgref_display.image,
413        bootc.image.transport = &imgref_display.transport,
414        "Re-pulling booted image into bootc storage via unified path: {}",
415        imgref_display
416    );
417
418    // Determine the appropriate source for pulling the image into bootc storage.
419    //
420    // Case 1: If source transport is containers-storage, the image was installed from
421    //         local container storage. Copy it from the default containers-storage to
422    //         the bootc storage if it exists there, if not pull from ostree store.
423    // Case 2: Otherwise, pull from the specified transport (usually a remote registry).
424    let is_containers_storage = imgref.transport()? == Transport::ContainerStorage;
425
426    if is_containers_storage {
427        tracing::info!(
428            "Source transport is containers-storage; checking if image exists in host storage"
429        );
430
431        // Check if the image already exists in the default containers-storage.
432        // This can happen if someone did a local build (e.g., podman build) and
433        // we don't want to overwrite it with an export from ostree.
434        let image_exists = image_exists_in_host_storage(&imgref.image).await?;
435
436        if image_exists {
437            tracing::info!(
438                "Image {} already exists in containers-storage, skipping ostree export",
439                &imgref.image
440            );
441        } else {
442            // The image was installed from containers-storage and now only exists in ostree.
443            // We need to export from ostree to default containers-storage (/var/lib/containers)
444            tracing::info!("Image not found in containers-storage; exporting from ostree");
445            // Use image_status we already obtained above (no additional unwraps needed)
446            let source = ImageReference {
447                transport: Transport::try_from(imgref.transport.as_str())?,
448                name: imgref.image.clone(),
449            };
450            let target = ImageReference {
451                transport: Transport::ContainerStorage,
452                name: imgref.image.clone(),
453            };
454
455            let mut opts = ostree_ext::container::store::ExportToOCIOpts::default();
456            // TODO: bridge to progress API
457            opts.progress_to_stdout = true;
458            tracing::info!(
459                "Exporting ostree deployment to default containers-storage: {}",
460                &imgref.image
461            );
462            ostree_ext::container::store::export(repo, &source, &target, Some(opts)).await?;
463        }
464
465        // Now copy from default containers-storage to bootc storage
466        tracing::info!(
467            "Copying from default containers-storage to bootc storage: {}",
468            &imgref.image
469        );
470        let image_name = imgref.image.clone();
471        let copy_msg = format!("Copying {} to bootc storage", &image_name);
472        async_task_with_spinner(&copy_msg, async move {
473            imgstore.pull_from_host_storage(&image_name).await
474        })
475        .await?;
476    } else {
477        // For registry and other transports, check if the image already exists in
478        // the host's default container storage (/var/lib/containers/storage).
479        // If so, we can copy from there instead of pulling from the network,
480        // which is faster (especially after https://github.com/containers/container-libs/issues/144
481        // enables reflinks between container storages).
482        let image_in_host = image_exists_in_host_storage(&imgref.image).await?;
483
484        if image_in_host {
485            tracing::info!(
486                "Image {} found in host container storage; copying to bootc storage",
487                &imgref.image
488            );
489            let image_name = imgref.image.clone();
490            let copy_msg = format!("Copying {} to bootc storage", &image_name);
491            async_task_with_spinner(&copy_msg, async move {
492                imgstore.pull_from_host_storage(&image_name).await
493            })
494            .await?;
495        } else {
496            let img_string = imgref.to_transport_image()?;
497            let pull_msg = format!("Pulling {} to bootc storage", &img_string);
498            async_task_with_spinner(&pull_msg, async move {
499                imgstore
500                    .pull(&img_string, crate::podstorage::PullMode::Always)
501                    .await
502            })
503            .await?;
504        }
505    }
506
507    // Verify the image is now in bootc storage
508    let imgstore = sysroot.get_ensure_imgstore()?;
509    if !imgstore.exists(&imgref.image).await? {
510        anyhow::bail!(
511            "Image was pushed to bootc storage but not found: {}. \
512             This may indicate a storage configuration issue.",
513            &imgref.image
514        );
515    }
516    tracing::info!("Image verified in bootc storage: {}", &imgref.image);
517
518    // Optionally verify we can import from containers-storage by preparing in a temp importer
519    // without actually importing into the main repo; this is a lightweight validation.
520    let containers_storage_imgref = crate::spec::ImageReference {
521        transport: "containers-storage".to_string(),
522        image: imgref.image.clone(),
523        signature: imgref.signature.clone(),
524    };
525    let ostree_imgref =
526        ostree_ext::container::OstreeImageReference::from(containers_storage_imgref);
527    let _ =
528        ostree_ext::container::store::ImageImporter::new(repo, &ostree_imgref, Default::default())
529            .await?;
530
531    tracing::info!(
532        message_id = SET_UNIFIED_JOURNAL_ID,
533        bootc.status = "set_unified_complete",
534        "Unified storage set for current image. Future upgrade/switch will use it automatically."
535    );
536    Ok(())
537}