1use 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
23pub(crate) const IMAGE_DEFAULT: &str = "localhost/bootc";
25
26async 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 }
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 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 (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
178pub(crate) async fn get_imgrefs_for_copy(
182 host: &Host,
183 source: Option<&str>,
184 target: Option<&str>,
185) -> Result<(ImageReference, ImageReference)> {
186 crate::podstorage::ensure_floating_c_storage_initialized();
188
189 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 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#[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
247pub(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#[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 crate::podstorage::ensure_floating_c_storage_initialized();
274
275 set_unified(&storage).await
276}
277
278#[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 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 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(©_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 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#[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 let (_booted_ostree, _deployments, host) = crate::status::get_status_require_booted(ostree)?;
376
377 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 let imgref = &image_status.image;
391
392 let imgref_display = imgref.clone().canonicalize()?;
394
395 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 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 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 tracing::info!("Image not found in containers-storage; exporting from ostree");
434 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 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 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(©_msg, async move {
462 imgstore.pull_from_host_storage(&image_name).await
463 })
464 .await?;
465 } else {
466 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(©_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 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 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}