Skip to main content

bootc_lib/
image.rs

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