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 HTTP API via bollard (<https://lib.rs/crates/bollard>) or similar
30/// to properly communicate with podman and get structured responses. This would
31/// also enable proper progress monitoring during pull operations.
32async fn image_exists_in_host_storage(image: &str) -> Result<bool> {
33    use tokio::process::Command as AsyncCommand;
34    let mut cmd = AsyncCommand::new("podman");
35    cmd.args(["image", "exists", image]);
36    Ok(cmd.status().await?.success())
37}
38
39#[derive(Clone, Serialize, ValueEnum)]
40enum ImageListTypeColumn {
41    Host,
42    Logical,
43}
44
45impl std::fmt::Display for ImageListTypeColumn {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        self.to_possible_value().unwrap().get_name().fmt(f)
48    }
49}
50
51#[derive(Serialize)]
52struct ImageOutput {
53    image_type: ImageListTypeColumn,
54    image: String,
55    // TODO: Add hash, size, etc? Difficult because [`ostree_ext::container::store::list_images`]
56    // only gives us the pullspec.
57}
58
59#[context("Listing host images")]
60fn list_host_images(sysroot: &crate::store::Storage) -> Result<Vec<ImageOutput>> {
61    let ostree = sysroot.get_ostree()?;
62    let repo = ostree.repo();
63    let images = ostree_ext::container::store::list_images(&repo).context("Querying images")?;
64
65    Ok(images
66        .into_iter()
67        .map(|image| ImageOutput {
68            image,
69            image_type: ImageListTypeColumn::Host,
70        })
71        .collect())
72}
73
74#[context("Listing logical images")]
75fn list_logical_images(root: &Dir) -> Result<Vec<ImageOutput>> {
76    let bound = query_bound_images(root)?;
77
78    Ok(bound
79        .into_iter()
80        .map(|image| ImageOutput {
81            image: image.image,
82            image_type: ImageListTypeColumn::Logical,
83        })
84        .collect())
85}
86
87async fn list_images(list_type: ImageListType) -> Result<Vec<ImageOutput>> {
88    let rootfs = cap_std::fs::Dir::open_ambient_dir("/", cap_std::ambient_authority())
89        .context("Opening /")?;
90
91    let sysroot: Option<crate::store::BootedStorage> =
92        if ostree_ext::container_utils::running_in_container() {
93            None
94        } else {
95            Some(crate::cli::get_storage().await?)
96        };
97
98    Ok(match (list_type, sysroot) {
99        // TODO: Should we list just logical images silently here, or error?
100        (ImageListType::All, None) => list_logical_images(&rootfs)?,
101        (ImageListType::All, Some(sysroot)) => list_host_images(&sysroot)?
102            .into_iter()
103            .chain(list_logical_images(&rootfs)?)
104            .collect(),
105        (ImageListType::Logical, _) => list_logical_images(&rootfs)?,
106        (ImageListType::Host, None) => {
107            bail!("Listing host images requires a booted bootc system")
108        }
109        (ImageListType::Host, Some(sysroot)) => list_host_images(&sysroot)?,
110    })
111}
112
113#[context("Listing images")]
114pub(crate) async fn list_entrypoint(
115    list_type: ImageListType,
116    list_format: ImageListFormat,
117) -> Result<()> {
118    let images = list_images(list_type).await?;
119
120    match list_format {
121        ImageListFormat::Table => {
122            let mut table = Table::new();
123
124            table
125                .load_preset(NOTHING)
126                .set_content_arrangement(comfy_table::ContentArrangement::Dynamic)
127                .set_header(["REPOSITORY", "TYPE"]);
128
129            for image in images {
130                table.add_row([image.image, image.image_type.to_string()]);
131            }
132
133            println!("{table}");
134        }
135        ImageListFormat::Json => {
136            let mut stdout = std::io::stdout();
137            serde_json::to_writer_pretty(&mut stdout, &images)?;
138        }
139    }
140
141    Ok(())
142}
143
144/// Returns the source and target ImageReference
145/// If the source isn't specified, we use booted image
146/// If the target isn't specified, we push to containers-storage with our default image
147pub(crate) async fn get_imgrefs_for_copy(
148    host: &Host,
149    source: Option<&str>,
150    target: Option<&str>,
151) -> Result<(ImageReference, ImageReference)> {
152    // Initialize floating c_storage early - needed for container operations
153    crate::podstorage::ensure_floating_c_storage_initialized();
154
155    // If the target isn't specified, push to containers-storage + our default image
156    let dest_imgref = match target {
157        Some(target) => ostree_ext::container::ImageReference {
158            transport: Transport::ContainerStorage,
159            name: target.to_owned(),
160        },
161        None => ostree_ext::container::ImageReference {
162            transport: Transport::ContainerStorage,
163            name: IMAGE_DEFAULT.into(),
164        },
165    };
166
167    // If the source isn't specified, we use the booted image
168    let src_imgref = match source {
169        Some(source) => ostree_ext::container::ImageReference::try_from(source)
170            .context("Parsing source image")?,
171
172        None => {
173            let booted = host
174                .status
175                .booted
176                .as_ref()
177                .ok_or_else(|| anyhow::anyhow!("Booted deployment not found"))?;
178
179            let booted_image = &booted.image.as_ref().unwrap().image;
180
181            ImageReference {
182                transport: Transport::try_from(booted_image.transport.as_str()).unwrap(),
183                name: booted_image.image.clone(),
184            }
185        }
186    };
187
188    return Ok((src_imgref, dest_imgref));
189}
190
191/// Implementation of `bootc image push-to-storage`.
192#[context("Pushing image")]
193pub(crate) async fn push_entrypoint(
194    storage: &Storage,
195    host: &Host,
196    source: Option<&str>,
197    target: Option<&str>,
198) -> Result<()> {
199    let (source, target) = get_imgrefs_for_copy(host, source, target).await?;
200
201    let ostree = storage.get_ostree()?;
202    let repo = &ostree.repo();
203
204    let mut opts = ostree_ext::container::store::ExportToOCIOpts::default();
205    opts.progress_to_stdout = true;
206    println!("Copying local image {source} to {target} ...");
207    let r = ostree_ext::container::store::export(repo, &source, &target, Some(opts)).await?;
208
209    println!("Pushed: {target} {r}");
210    Ok(())
211}
212
213/// Thin wrapper for invoking `podman image <X>` but set up for our internal
214/// image store (as distinct from /var/lib/containers default).
215pub(crate) async fn imgcmd_entrypoint(
216    storage: &CStorage,
217    arg: &str,
218    args: &[std::ffi::OsString],
219) -> std::result::Result<(), anyhow::Error> {
220    let mut cmd = storage.new_image_cmd()?;
221    cmd.arg(arg);
222    cmd.args(args);
223    cmd.run_capture_stderr()
224}
225
226/// Re-pull the currently booted image into the bootc-owned container storage.
227///
228/// This onboards the system to unified storage for host images so that
229/// upgrade/switch can use the unified path automatically when the image is present.
230#[context("Setting unified storage for booted image")]
231pub(crate) async fn set_unified_entrypoint() -> Result<()> {
232    // Initialize floating c_storage early - needed for container operations
233    crate::podstorage::ensure_floating_c_storage_initialized();
234
235    let sysroot = crate::cli::get_storage().await?;
236    set_unified(&sysroot).await
237}
238
239/// Inner implementation of set_unified that accepts a storage reference.
240#[context("Setting unified storage for booted image")]
241pub(crate) async fn set_unified(sysroot: &crate::store::Storage) -> Result<()> {
242    let ostree = sysroot.get_ostree()?;
243    let repo = &ostree.repo();
244
245    // Discover the currently booted image reference.
246    // get_status_require_booted validates that we have a booted deployment with an image.
247    let (_booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?;
248
249    // Use the booted deployment's image from the status we just retrieved.
250    // get_status_require_booted guarantees host.status.booted is Some.
251    let booted_entry = host
252        .status
253        .booted
254        .as_ref()
255        .ok_or_else(|| anyhow::anyhow!("No booted deployment found"))?;
256    let image_status = booted_entry
257        .image
258        .as_ref()
259        .ok_or_else(|| anyhow::anyhow!("Booted deployment is not from a container image"))?;
260
261    // Extract the ImageReference from the ImageStatus
262    let imgref = &image_status.image;
263
264    // Canonicalize for pull display only, but we want to preserve original pullspec
265    let imgref_display = imgref.clone().canonicalize()?;
266
267    // Pull the image from its original source into bootc storage using LBI machinery
268    let imgstore = sysroot.get_ensure_imgstore()?;
269
270    const SET_UNIFIED_JOURNAL_ID: &str = "1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d";
271    tracing::info!(
272        message_id = SET_UNIFIED_JOURNAL_ID,
273        bootc.image.reference = &imgref_display.image,
274        bootc.image.transport = &imgref_display.transport,
275        "Re-pulling booted image into bootc storage via unified path: {}",
276        imgref_display
277    );
278
279    // Determine the appropriate source for pulling the image into bootc storage.
280    //
281    // Case 1: If source transport is containers-storage, the image was installed from
282    //         local container storage. Copy it from the default containers-storage to
283    //         the bootc storage if it exists there, if not pull from ostree store.
284    // Case 2: Otherwise, pull from the specified transport (usually a remote registry).
285    let is_containers_storage = imgref.transport()? == Transport::ContainerStorage;
286
287    if is_containers_storage {
288        tracing::info!(
289            "Source transport is containers-storage; checking if image exists in host storage"
290        );
291
292        // Check if the image already exists in the default containers-storage.
293        // This can happen if someone did a local build (e.g., podman build) and
294        // we don't want to overwrite it with an export from ostree.
295        let image_exists = image_exists_in_host_storage(&imgref.image).await?;
296
297        if image_exists {
298            tracing::info!(
299                "Image {} already exists in containers-storage, skipping ostree export",
300                &imgref.image
301            );
302        } else {
303            // The image was installed from containers-storage and now only exists in ostree.
304            // We need to export from ostree to default containers-storage (/var/lib/containers)
305            tracing::info!("Image not found in containers-storage; exporting from ostree");
306            // Use image_status we already obtained above (no additional unwraps needed)
307            let source = ImageReference {
308                transport: Transport::try_from(imgref.transport.as_str())?,
309                name: imgref.image.clone(),
310            };
311            let target = ImageReference {
312                transport: Transport::ContainerStorage,
313                name: imgref.image.clone(),
314            };
315
316            let mut opts = ostree_ext::container::store::ExportToOCIOpts::default();
317            // TODO: bridge to progress API
318            opts.progress_to_stdout = true;
319            tracing::info!(
320                "Exporting ostree deployment to default containers-storage: {}",
321                &imgref.image
322            );
323            ostree_ext::container::store::export(repo, &source, &target, Some(opts)).await?;
324        }
325
326        // Now copy from default containers-storage to bootc storage
327        tracing::info!(
328            "Copying from default containers-storage to bootc storage: {}",
329            &imgref.image
330        );
331        let image_name = imgref.image.clone();
332        let copy_msg = format!("Copying {} to bootc storage", &image_name);
333        async_task_with_spinner(&copy_msg, async move {
334            imgstore.pull_from_host_storage(&image_name).await
335        })
336        .await?;
337    } else {
338        // For registry and other transports, check if the image already exists in
339        // the host's default container storage (/var/lib/containers/storage).
340        // If so, we can copy from there instead of pulling from the network,
341        // which is faster (especially after https://github.com/containers/container-libs/issues/144
342        // enables reflinks between container storages).
343        let image_in_host = image_exists_in_host_storage(&imgref.image).await?;
344
345        if image_in_host {
346            tracing::info!(
347                "Image {} found in host container storage; copying to bootc storage",
348                &imgref.image
349            );
350            let image_name = imgref.image.clone();
351            let copy_msg = format!("Copying {} to bootc storage", &image_name);
352            async_task_with_spinner(&copy_msg, async move {
353                imgstore.pull_from_host_storage(&image_name).await
354            })
355            .await?;
356        } else {
357            let img_string = imgref.to_transport_image()?;
358            let pull_msg = format!("Pulling {} to bootc storage", &img_string);
359            async_task_with_spinner(&pull_msg, async move {
360                imgstore
361                    .pull(&img_string, crate::podstorage::PullMode::Always)
362                    .await
363            })
364            .await?;
365        }
366    }
367
368    // Verify the image is now in bootc storage
369    let imgstore = sysroot.get_ensure_imgstore()?;
370    if !imgstore.exists(&imgref.image).await? {
371        anyhow::bail!(
372            "Image was pushed to bootc storage but not found: {}. \
373             This may indicate a storage configuration issue.",
374            &imgref.image
375        );
376    }
377    tracing::info!("Image verified in bootc storage: {}", &imgref.image);
378
379    // Optionally verify we can import from containers-storage by preparing in a temp importer
380    // without actually importing into the main repo; this is a lightweight validation.
381    let containers_storage_imgref = crate::spec::ImageReference {
382        transport: "containers-storage".to_string(),
383        image: imgref.image.clone(),
384        signature: imgref.signature.clone(),
385    };
386    let ostree_imgref =
387        ostree_ext::container::OstreeImageReference::from(containers_storage_imgref);
388    let _ =
389        ostree_ext::container::store::ImageImporter::new(repo, &ostree_imgref, Default::default())
390            .await?;
391
392    tracing::info!(
393        message_id = SET_UNIFIED_JOURNAL_ID,
394        bootc.status = "set_unified_complete",
395        "Unified storage set for current image. Future upgrade/switch will use it automatically."
396    );
397    Ok(())
398}