bootc_lib/
status.rs

1use std::borrow::Cow;
2use std::collections::VecDeque;
3use std::io::IsTerminal;
4use std::io::Read;
5use std::io::Write;
6
7use anyhow::{Context, Result};
8use canon_json::CanonJsonSerialize;
9use fn_error_context::context;
10use ostree::glib;
11use ostree_container::OstreeImageReference;
12use ostree_ext::container as ostree_container;
13use ostree_ext::keyfileext::KeyFileExt;
14use ostree_ext::oci_spec;
15use ostree_ext::oci_spec::image::Digest;
16use ostree_ext::oci_spec::image::ImageConfiguration;
17use ostree_ext::sysroot::SysrootLock;
18
19use ostree_ext::ostree;
20
21use crate::cli::OutputFormat;
22use crate::spec::BootEntryComposefs;
23use crate::spec::ImageStatus;
24use crate::spec::{BootEntry, BootOrder, Host, HostSpec, HostStatus, HostType};
25use crate::spec::{ImageReference, ImageSignature};
26use crate::store::BootedStorage;
27use crate::store::BootedStorageKind;
28use crate::store::CachedImageStatus;
29
30impl From<ostree_container::SignatureSource> for ImageSignature {
31    fn from(sig: ostree_container::SignatureSource) -> Self {
32        use ostree_container::SignatureSource;
33        match sig {
34            SignatureSource::OstreeRemote(r) => Self::OstreeRemote(r),
35            SignatureSource::ContainerPolicy => Self::ContainerPolicy,
36            SignatureSource::ContainerPolicyAllowInsecure => Self::Insecure,
37        }
38    }
39}
40
41impl From<ImageSignature> for ostree_container::SignatureSource {
42    fn from(sig: ImageSignature) -> Self {
43        use ostree_container::SignatureSource;
44        match sig {
45            ImageSignature::OstreeRemote(r) => SignatureSource::OstreeRemote(r),
46            ImageSignature::ContainerPolicy => Self::ContainerPolicy,
47            ImageSignature::Insecure => Self::ContainerPolicyAllowInsecure,
48        }
49    }
50}
51
52/// Fixme lower serializability into ostree-ext
53fn transport_to_string(transport: ostree_container::Transport) -> String {
54    match transport {
55        // Canonicalize to registry for our own use
56        ostree_container::Transport::Registry => "registry".to_string(),
57        o => {
58            let mut s = o.to_string();
59            s.truncate(s.rfind(':').unwrap());
60            s
61        }
62    }
63}
64
65impl From<OstreeImageReference> for ImageReference {
66    fn from(imgref: OstreeImageReference) -> Self {
67        let signature = match imgref.sigverify {
68            ostree_container::SignatureSource::ContainerPolicyAllowInsecure => None,
69            v => Some(v.into()),
70        };
71        Self {
72            signature,
73            transport: transport_to_string(imgref.imgref.transport),
74            image: imgref.imgref.name,
75        }
76    }
77}
78
79impl From<ImageReference> for OstreeImageReference {
80    fn from(img: ImageReference) -> Self {
81        let sigverify = match img.signature {
82            Some(v) => v.into(),
83            None => ostree_container::SignatureSource::ContainerPolicyAllowInsecure,
84        };
85        Self {
86            sigverify,
87            imgref: ostree_container::ImageReference {
88                // SAFETY: We validated the schema in kube-rs
89                transport: img.transport.as_str().try_into().unwrap(),
90                name: img.image,
91            },
92        }
93    }
94}
95
96/// Check if SELinux policies are compatible between booted and target deployments.
97/// Returns false if SELinux is enabled and the policies differ or have mismatched presence.
98fn check_selinux_policy_compatible(
99    sysroot: &SysrootLock,
100    booted_deployment: &ostree::Deployment,
101    target_deployment: &ostree::Deployment,
102) -> Result<bool> {
103    // Only check if SELinux is enabled
104    if !crate::lsm::selinux_enabled()? {
105        return Ok(true);
106    }
107
108    let booted_fd = crate::utils::deployment_fd(sysroot, booted_deployment)
109        .context("Failed to get file descriptor for booted deployment")?;
110    let booted_policy = crate::lsm::new_sepolicy_at(&booted_fd)
111        .context("Failed to load SELinux policy from booted deployment")?;
112    let target_fd = crate::utils::deployment_fd(sysroot, target_deployment)
113        .context("Failed to get file descriptor for target deployment")?;
114    let target_policy = crate::lsm::new_sepolicy_at(&target_fd)
115        .context("Failed to load SELinux policy from target deployment")?;
116
117    let booted_csum = booted_policy.and_then(|p| p.csum());
118    let target_csum = target_policy.and_then(|p| p.csum());
119
120    match (booted_csum, target_csum) {
121        (None, None) => Ok(true), // Both absent, compatible
122        (Some(_), None) | (None, Some(_)) => {
123            // Incompatible: one has policy, other doesn't
124            Ok(false)
125        }
126        (Some(booted_csum), Some(target_csum)) => {
127            // Both have policies, checksums must match
128            Ok(booted_csum == target_csum)
129        }
130    }
131}
132
133/// Check if a deployment has soft reboot capability
134// TODO: Lower SELinux policy check into ostree's deployment_can_soft_reboot API
135fn has_soft_reboot_capability(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> bool {
136    if !ostree_ext::systemd_has_soft_reboot() {
137        return false;
138    }
139
140    // When the ostree version is < 2025.7 and the deployment is
141    // missing the ostree= karg (happens during a factory reset),
142    // there is a bug that causes deployment_can_soft_reboot to crash.
143    // So in this case default to disabling soft reboot.
144    let has_ostree_karg = deployment
145        .bootconfig()
146        .and_then(|bootcfg| bootcfg.get("options"))
147        .map(|options| options.contains("ostree="))
148        .unwrap_or(false);
149
150    if !ostree::check_version(2025, 7) && !has_ostree_karg {
151        return false;
152    }
153
154    if !sysroot.deployment_can_soft_reboot(deployment) {
155        return false;
156    }
157
158    // Check SELinux policy compatibility with booted deployment
159    // Block soft reboot if SELinux policies differ, as policy is not reloaded across soft reboots
160    if let Some(booted_deployment) = sysroot.booted_deployment() {
161        // deployment_fd should not fail for valid deployments
162        if !check_selinux_policy_compatible(sysroot, &booted_deployment, deployment)
163            .expect("deployment_fd should not fail for valid deployments")
164        {
165            return false;
166        }
167    }
168
169    true
170}
171
172/// Parse an ostree origin file (a keyfile) and extract the targeted
173/// container image reference.
174fn get_image_origin(origin: &glib::KeyFile) -> Result<Option<OstreeImageReference>> {
175    origin
176        .optional_string("origin", ostree_container::deploy::ORIGIN_CONTAINER)
177        .context("Failed to load container image from origin")?
178        .map(|v| ostree_container::OstreeImageReference::try_from(v.as_str()))
179        .transpose()
180}
181
182pub(crate) struct Deployments {
183    pub(crate) staged: Option<ostree::Deployment>,
184    pub(crate) rollback: Option<ostree::Deployment>,
185    #[allow(dead_code)]
186    pub(crate) other: VecDeque<ostree::Deployment>,
187}
188
189pub(crate) fn labels_of_config(
190    config: &oci_spec::image::ImageConfiguration,
191) -> Option<&std::collections::HashMap<String, String>> {
192    config.config().as_ref().and_then(|c| c.labels().as_ref())
193}
194
195/// Convert between a subset of ostree-ext metadata and the exposed spec API.
196fn create_imagestatus(
197    image: ImageReference,
198    manifest_digest: &Digest,
199    config: &ImageConfiguration,
200) -> ImageStatus {
201    let labels = labels_of_config(config);
202    let timestamp = labels
203        .and_then(|l| {
204            l.get(oci_spec::image::ANNOTATION_CREATED)
205                .map(|s| s.as_str())
206        })
207        .or_else(|| config.created().as_deref())
208        .and_then(bootc_utils::try_deserialize_timestamp);
209
210    let version = ostree_container::version_for_config(config).map(ToOwned::to_owned);
211    let architecture = config.architecture().to_string();
212    ImageStatus {
213        image,
214        version,
215        timestamp,
216        image_digest: manifest_digest.to_string(),
217        architecture,
218    }
219}
220
221fn imagestatus(
222    sysroot: &SysrootLock,
223    deployment: &ostree::Deployment,
224    image: ostree_container::OstreeImageReference,
225) -> Result<CachedImageStatus> {
226    let repo = &sysroot.repo();
227    let imgstate = ostree_container::store::query_image_commit(repo, &deployment.csum())?;
228    let image = ImageReference::from(image);
229    let cached = imgstate
230        .cached_update
231        .map(|cached| create_imagestatus(image.clone(), &cached.manifest_digest, &cached.config));
232    let imagestatus = create_imagestatus(image, &imgstate.manifest_digest, &imgstate.configuration);
233
234    Ok(CachedImageStatus {
235        image: Some(imagestatus),
236        cached_update: cached,
237    })
238}
239
240/// Given an OSTree deployment, parse out metadata into our spec.
241#[context("Reading deployment metadata")]
242pub(crate) fn boot_entry_from_deployment(
243    sysroot: &SysrootLock,
244    deployment: &ostree::Deployment,
245) -> Result<BootEntry> {
246    let (
247        CachedImageStatus {
248            image,
249            cached_update,
250        },
251        incompatible,
252    ) = if let Some(origin) = deployment.origin().as_ref() {
253        let incompatible = crate::utils::origin_has_rpmostree_stuff(origin);
254        let cached_imagestatus = if incompatible {
255            // If there are local changes, we can't represent it as a bootc compatible image.
256            CachedImageStatus::default()
257        } else if let Some(image) = get_image_origin(origin)? {
258            imagestatus(sysroot, deployment, image)?
259        } else {
260            // The deployment isn't using a container image
261            CachedImageStatus::default()
262        };
263        (cached_imagestatus, incompatible)
264    } else {
265        // The deployment has no origin at all (this generally shouldn't happen)
266        (CachedImageStatus::default(), false)
267    };
268
269    let soft_reboot_capable = has_soft_reboot_capability(sysroot, deployment);
270    let download_only = deployment.is_staged() && deployment.is_finalization_locked();
271    let store = Some(crate::spec::Store::OstreeContainer);
272    let r = BootEntry {
273        image,
274        cached_update,
275        incompatible,
276        soft_reboot_capable,
277        download_only,
278        store,
279        pinned: deployment.is_pinned(),
280        ostree: Some(crate::spec::BootEntryOstree {
281            checksum: deployment.csum().into(),
282            // SAFETY: The deployserial is really unsigned
283            deploy_serial: deployment.deployserial().try_into().unwrap(),
284            stateroot: deployment.stateroot().into(),
285        }),
286        composefs: None,
287    };
288    Ok(r)
289}
290
291impl BootEntry {
292    /// Given a boot entry, find its underlying ostree container image
293    pub(crate) fn query_image(
294        &self,
295        repo: &ostree::Repo,
296    ) -> Result<Option<Box<ostree_container::store::LayeredImageState>>> {
297        if self.image.is_none() {
298            return Ok(None);
299        }
300        if let Some(checksum) = self.ostree.as_ref().map(|c| c.checksum.as_str()) {
301            ostree_container::store::query_image_commit(repo, checksum).map(Some)
302        } else {
303            Ok(None)
304        }
305    }
306
307    pub(crate) fn require_composefs(&self) -> Result<&BootEntryComposefs> {
308        self.composefs.as_ref().ok_or(anyhow::anyhow!(
309            "BootEntry is not a composefs native boot entry"
310        ))
311    }
312
313    pub(crate) fn composefs_boot_digest(&self) -> Result<&String> {
314        self.require_composefs()?
315            .boot_digest
316            .as_ref()
317            .ok_or_else(|| anyhow::anyhow!("Could not find boot digest for deployment"))
318    }
319}
320
321/// A variant of [`get_status`] that requires a booted deployment.
322pub(crate) fn get_status_require_booted(
323    sysroot: &SysrootLock,
324) -> Result<(crate::store::BootedOstree<'_>, Deployments, Host)> {
325    let booted_deployment = sysroot.require_booted_deployment()?;
326    let booted_ostree = crate::store::BootedOstree {
327        sysroot,
328        deployment: booted_deployment,
329    };
330    let (deployments, host) = get_status(&booted_ostree)?;
331    Ok((booted_ostree, deployments, host))
332}
333
334/// Gather the ostree deployment objects, but also extract metadata from them into
335/// a more native Rust structure.
336#[context("Computing status")]
337pub(crate) fn get_status(
338    booted_ostree: &crate::store::BootedOstree<'_>,
339) -> Result<(Deployments, Host)> {
340    let sysroot = booted_ostree.sysroot;
341    let booted_deployment = Some(&booted_ostree.deployment);
342    let stateroot = booted_deployment.as_ref().map(|d| d.osname());
343    let (mut related_deployments, other_deployments) = sysroot
344        .deployments()
345        .into_iter()
346        .partition::<VecDeque<_>, _>(|d| Some(d.osname()) == stateroot);
347    let staged = related_deployments
348        .iter()
349        .position(|d| d.is_staged())
350        .map(|i| related_deployments.remove(i).unwrap());
351    tracing::debug!("Staged: {staged:?}");
352    // Filter out the booted, the caller already found that
353    if let Some(booted) = booted_deployment.as_ref() {
354        related_deployments.retain(|f| !f.equal(booted));
355    }
356    let rollback = related_deployments.pop_front();
357    let rollback_queued = match (booted_deployment.as_ref(), rollback.as_ref()) {
358        (Some(booted), Some(rollback)) => rollback.index() < booted.index(),
359        _ => false,
360    };
361    let boot_order = if rollback_queued {
362        BootOrder::Rollback
363    } else {
364        BootOrder::Default
365    };
366    tracing::debug!("Rollback queued={rollback_queued:?}");
367    let other = {
368        related_deployments.extend(other_deployments);
369        related_deployments
370    };
371    let deployments = Deployments {
372        staged,
373        rollback,
374        other,
375    };
376
377    let staged = deployments
378        .staged
379        .as_ref()
380        .map(|d| boot_entry_from_deployment(sysroot, d))
381        .transpose()
382        .context("Staged deployment")?;
383    let booted = booted_deployment
384        .as_ref()
385        .map(|d| boot_entry_from_deployment(sysroot, d))
386        .transpose()
387        .context("Booted deployment")?;
388    let rollback = deployments
389        .rollback
390        .as_ref()
391        .map(|d| boot_entry_from_deployment(sysroot, d))
392        .transpose()
393        .context("Rollback deployment")?;
394    let other_deployments = deployments
395        .other
396        .iter()
397        .map(|d| boot_entry_from_deployment(sysroot, d))
398        .collect::<Result<Vec<_>>>()
399        .context("Other deployments")?;
400    let spec = staged
401        .as_ref()
402        .or(booted.as_ref())
403        .and_then(|entry| entry.image.as_ref())
404        .map(|img| HostSpec {
405            image: Some(img.image.clone()),
406            boot_order,
407        })
408        .unwrap_or_default();
409
410    let ty = if booted
411        .as_ref()
412        .map(|b| b.image.is_some())
413        .unwrap_or_default()
414    {
415        // We're only of type BootcHost if we booted via container image
416        Some(HostType::BootcHost)
417    } else {
418        None
419    };
420
421    let mut host = Host::new(spec);
422    host.status = HostStatus {
423        staged,
424        booted,
425        rollback,
426        other_deployments,
427        rollback_queued,
428        ty,
429    };
430    Ok((deployments, host))
431}
432
433pub(crate) async fn get_host() -> Result<Host> {
434    let env = crate::store::Environment::detect()?;
435    if env.needs_mount_namespace() {
436        crate::cli::prepare_for_write()?;
437    }
438
439    let Some(storage) = BootedStorage::new(env).await? else {
440        // If we're not booted, then return a default.
441        return Ok(Host::default());
442    };
443
444    let host = match storage.kind() {
445        Ok(kind) => match kind {
446            BootedStorageKind::Ostree(booted_ostree) => {
447                let (_deployments, host) = get_status(&booted_ostree)?;
448                host
449            }
450            BootedStorageKind::Composefs(booted_cfs) => {
451                crate::bootc_composefs::status::get_composefs_status(&storage, &booted_cfs).await?
452            }
453        },
454        Err(_) => {
455            // If determining storage kind fails (e.g., no booted deployment),
456            // return a default host indicating the system is not deployed via bootc
457            Host::default()
458        }
459    };
460
461    Ok(host)
462}
463
464/// Implementation of the `bootc status` CLI command.
465#[context("Status")]
466pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> {
467    match opts.format_version.unwrap_or_default() {
468        // For historical reasons, both 0 and 1 mean "v1".
469        0 | 1 => {}
470        o => anyhow::bail!("Unsupported format version: {o}"),
471    };
472    let mut host = get_host().await?;
473
474    // We could support querying the staged or rollback deployments
475    // here too, but it's not a common use case at the moment.
476    if opts.booted {
477        host.filter_to_slot(Slot::Booted);
478    }
479
480    // If we're in JSON mode, then convert the ostree data into Rust-native
481    // structures that can be serialized.
482    // Filter to just the serializable status structures.
483    let out = std::io::stdout();
484    let mut out = out.lock();
485    let legacy_opt = if opts.json {
486        OutputFormat::Json
487    } else if std::io::stdout().is_terminal() {
488        OutputFormat::HumanReadable
489    } else {
490        OutputFormat::Yaml
491    };
492    let format = opts.format.unwrap_or(legacy_opt);
493    match format {
494        OutputFormat::Json => host
495            .to_canon_json_writer(&mut out)
496            .map_err(anyhow::Error::new),
497        OutputFormat::Yaml => serde_yaml::to_writer(&mut out, &host).map_err(anyhow::Error::new),
498        OutputFormat::HumanReadable => human_readable_output(&mut out, &host, opts.verbose),
499    }
500    .context("Writing to stdout")?;
501
502    Ok(())
503}
504
505#[derive(Debug, Clone, Copy)]
506pub enum Slot {
507    Staged,
508    Booted,
509    Rollback,
510}
511
512impl std::fmt::Display for Slot {
513    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
514        let s = match self {
515            Slot::Staged => "staged",
516            Slot::Booted => "booted",
517            Slot::Rollback => "rollback",
518        };
519        f.write_str(s)
520    }
521}
522
523/// Output a row title, prefixed by spaces
524fn write_row_name(mut out: impl Write, s: &str, prefix_len: usize) -> Result<()> {
525    let n = prefix_len.saturating_sub(s.chars().count());
526    let mut spaces = std::io::repeat(b' ').take(n as u64);
527    std::io::copy(&mut spaces, &mut out)?;
528    write!(out, "{s}: ")?;
529    Ok(())
530}
531
532/// Helper function to render verbose ostree information
533fn render_verbose_ostree_info(
534    mut out: impl Write,
535    ostree: &crate::spec::BootEntryOstree,
536    slot: Option<Slot>,
537    prefix_len: usize,
538) -> Result<()> {
539    write_row_name(&mut out, "StateRoot", prefix_len)?;
540    writeln!(out, "{}", ostree.stateroot)?;
541
542    // Show deployment serial (similar to Index in rpm-ostree)
543    write_row_name(&mut out, "Deploy serial", prefix_len)?;
544    writeln!(out, "{}", ostree.deploy_serial)?;
545
546    // Show if this is staged
547    let is_staged = matches!(slot, Some(Slot::Staged));
548    write_row_name(&mut out, "Staged", prefix_len)?;
549    writeln!(out, "{}", if is_staged { "yes" } else { "no" })?;
550
551    Ok(())
552}
553
554/// Helper function to render if soft-reboot capable
555fn write_soft_reboot(
556    mut out: impl Write,
557    entry: &crate::spec::BootEntry,
558    prefix_len: usize,
559) -> Result<()> {
560    // Show soft-reboot capability
561    write_row_name(&mut out, "Soft-reboot", prefix_len)?;
562    writeln!(
563        out,
564        "{}",
565        if entry.soft_reboot_capable {
566            "yes"
567        } else {
568            "no"
569        }
570    )?;
571
572    Ok(())
573}
574
575/// Helper function to render download-only lock status
576fn write_download_only(
577    mut out: impl Write,
578    slot: Option<Slot>,
579    entry: &crate::spec::BootEntry,
580    prefix_len: usize,
581) -> Result<()> {
582    // Only staged deployments can have download-only status
583    if matches!(slot, Some(Slot::Staged)) {
584        write_row_name(&mut out, "Download-only", prefix_len)?;
585        writeln!(out, "{}", if entry.download_only { "yes" } else { "no" })?;
586    }
587    Ok(())
588}
589
590/// Write the data for a container image based status.
591fn human_render_slot(
592    mut out: impl Write,
593    slot: Option<Slot>,
594    entry: &crate::spec::BootEntry,
595    image: &crate::spec::ImageStatus,
596    verbose: bool,
597) -> Result<()> {
598    let transport = &image.image.transport;
599    let imagename = &image.image.image;
600    // Registry is the default, so don't show that
601    let imageref = if transport == "registry" {
602        Cow::Borrowed(imagename)
603    } else {
604        // But for non-registry we include the transport
605        Cow::Owned(format!("{transport}:{imagename}"))
606    };
607    let prefix = match slot {
608        Some(Slot::Staged) => "  Staged image".into(),
609        Some(Slot::Booted) => format!("{} Booted image", crate::glyph::Glyph::BlackCircle),
610        Some(Slot::Rollback) => "  Rollback image".into(),
611        _ => "   Other image".into(),
612    };
613    let prefix_len = prefix.chars().count();
614    writeln!(out, "{prefix}: {imageref}")?;
615
616    let arch = image.architecture.as_str();
617    write_row_name(&mut out, "Digest", prefix_len)?;
618    let digest = &image.image_digest;
619    writeln!(out, "{digest} ({arch})")?;
620
621    // Write the EROFS verity if present
622    if let Some(composefs) = &entry.composefs {
623        write_row_name(&mut out, "Verity", prefix_len)?;
624        writeln!(out, "{}", composefs.verity)?;
625    }
626
627    // Format the timestamp without nanoseconds since those are just irrelevant noise for human
628    // consumption - that time scale should basically never matter for container builds.
629    let timestamp = image
630        .timestamp
631        .as_ref()
632        // This format is the same as RFC3339, just without nanos.
633        .map(|t| t.to_utc().format("%Y-%m-%dT%H:%M:%SZ"));
634    // If we have a version, combine with timestamp
635    if let Some(version) = image.version.as_deref() {
636        write_row_name(&mut out, "Version", prefix_len)?;
637        if let Some(timestamp) = timestamp {
638            writeln!(out, "{version} ({timestamp})")?;
639        } else {
640            writeln!(out, "{version}")?;
641        }
642    } else if let Some(timestamp) = timestamp {
643        // Otherwise just output timestamp
644        write_row_name(&mut out, "Timestamp", prefix_len)?;
645        writeln!(out, "{timestamp}")?;
646    }
647
648    if entry.pinned {
649        write_row_name(&mut out, "Pinned", prefix_len)?;
650        writeln!(out, "yes")?;
651    }
652
653    if verbose {
654        // Show additional information in verbose mode similar to rpm-ostree
655        if let Some(ostree) = &entry.ostree {
656            render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?;
657
658            // Show the commit (equivalent to Base Commit in rpm-ostree)
659            write_row_name(&mut out, "Commit", prefix_len)?;
660            writeln!(out, "{}", ostree.checksum)?;
661        }
662
663        // Show signature information if available
664        if let Some(signature) = &image.image.signature {
665            write_row_name(&mut out, "Signature", prefix_len)?;
666            match signature {
667                crate::spec::ImageSignature::OstreeRemote(remote) => {
668                    writeln!(out, "ostree-remote:{remote}")?;
669                }
670                crate::spec::ImageSignature::ContainerPolicy => {
671                    writeln!(out, "container-policy")?;
672                }
673                crate::spec::ImageSignature::Insecure => {
674                    writeln!(out, "insecure")?;
675                }
676            }
677        }
678
679        // Show soft-reboot capability
680        write_soft_reboot(&mut out, entry, prefix_len)?;
681
682        // Show download-only lock status
683        write_download_only(&mut out, slot, entry, prefix_len)?;
684    }
685
686    tracing::debug!("pinned={}", entry.pinned);
687
688    Ok(())
689}
690
691/// Output a rendering of a non-container boot entry.
692fn human_render_slot_ostree(
693    mut out: impl Write,
694    slot: Option<Slot>,
695    entry: &crate::spec::BootEntry,
696    ostree_commit: &str,
697    verbose: bool,
698) -> Result<()> {
699    // TODO consider rendering more ostree stuff here like rpm-ostree status does
700    let prefix = match slot {
701        Some(Slot::Staged) => "  Staged ostree".into(),
702        Some(Slot::Booted) => format!("{} Booted ostree", crate::glyph::Glyph::BlackCircle),
703        Some(Slot::Rollback) => "  Rollback ostree".into(),
704        _ => " Other ostree".into(),
705    };
706    let prefix_len = prefix.len();
707    writeln!(out, "{prefix}")?;
708    write_row_name(&mut out, "Commit", prefix_len)?;
709    writeln!(out, "{ostree_commit}")?;
710
711    if entry.pinned {
712        write_row_name(&mut out, "Pinned", prefix_len)?;
713        writeln!(out, "yes")?;
714    }
715
716    if verbose {
717        // Show additional information in verbose mode similar to rpm-ostree
718        if let Some(ostree) = &entry.ostree {
719            render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?;
720        }
721
722        // Show soft-reboot capability
723        write_soft_reboot(&mut out, entry, prefix_len)?;
724
725        // Show download-only lock status
726        write_download_only(&mut out, slot, entry, prefix_len)?;
727    }
728
729    tracing::debug!("pinned={}", entry.pinned);
730    Ok(())
731}
732
733/// Output a rendering of a non-container composefs boot entry.
734fn human_render_slot_composefs(
735    mut out: impl Write,
736    slot: Slot,
737    entry: &crate::spec::BootEntry,
738    erofs_verity: &str,
739) -> Result<()> {
740    // TODO consider rendering more ostree stuff here like rpm-ostree status does
741    let prefix = match slot {
742        Slot::Staged => "  Staged composefs".into(),
743        Slot::Booted => format!("{} Booted composefs", crate::glyph::Glyph::BlackCircle),
744        Slot::Rollback => "  Rollback composefs".into(),
745    };
746    let prefix_len = prefix.len();
747    writeln!(out, "{prefix}")?;
748    write_row_name(&mut out, "Commit", prefix_len)?;
749    writeln!(out, "{erofs_verity}")?;
750    tracing::debug!("pinned={}", entry.pinned);
751    Ok(())
752}
753
754fn human_readable_output_booted(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> {
755    let mut first = true;
756    for (slot_name, status) in [
757        (Slot::Staged, &host.status.staged),
758        (Slot::Booted, &host.status.booted),
759        (Slot::Rollback, &host.status.rollback),
760    ] {
761        if let Some(host_status) = status {
762            if first {
763                first = false;
764            } else {
765                writeln!(out)?;
766            }
767
768            if let Some(image) = &host_status.image {
769                human_render_slot(&mut out, Some(slot_name), host_status, image, verbose)?;
770            } else if let Some(ostree) = host_status.ostree.as_ref() {
771                human_render_slot_ostree(
772                    &mut out,
773                    Some(slot_name),
774                    host_status,
775                    &ostree.checksum,
776                    verbose,
777                )?;
778            } else if let Some(composefs) = &host_status.composefs {
779                human_render_slot_composefs(&mut out, slot_name, host_status, &composefs.verity)?;
780            } else {
781                writeln!(out, "Current {slot_name} state is unknown")?;
782            }
783        }
784    }
785
786    if !host.status.other_deployments.is_empty() {
787        for entry in &host.status.other_deployments {
788            writeln!(out)?;
789
790            if let Some(image) = &entry.image {
791                human_render_slot(&mut out, None, entry, image, verbose)?;
792            } else if let Some(ostree) = entry.ostree.as_ref() {
793                human_render_slot_ostree(&mut out, None, entry, &ostree.checksum, verbose)?;
794            }
795        }
796    }
797
798    Ok(())
799}
800
801/// Implementation of rendering our host structure in a "human readable" way.
802fn human_readable_output(mut out: impl Write, host: &Host, verbose: bool) -> Result<()> {
803    if host.status.booted.is_some() {
804        human_readable_output_booted(out, host, verbose)?;
805    } else {
806        writeln!(out, "System is not deployed via bootc.")?;
807    }
808    Ok(())
809}
810
811/// Output container inspection in human-readable format
812fn container_inspect_print_human(
813    inspect: &crate::spec::ContainerInspect,
814    mut out: impl Write,
815) -> Result<()> {
816    // Collect rows to determine the max label width
817    let mut rows: Vec<(&str, String)> = Vec::new();
818
819    if let Some(kernel) = &inspect.kernel {
820        rows.push(("Kernel", kernel.version.clone()));
821        let kernel_type = if kernel.unified { "UKI" } else { "vmlinuz" };
822        rows.push(("Type", kernel_type.to_string()));
823    } else {
824        rows.push(("Kernel", "<none>".to_string()));
825    }
826
827    let kargs = if inspect.kargs.is_empty() {
828        "<none>".to_string()
829    } else {
830        inspect.kargs.join(" ")
831    };
832    rows.push(("Kargs", kargs));
833
834    // Find the max label width for right-alignment
835    let max_label_len = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0);
836
837    for (label, value) in rows {
838        write_row_name(&mut out, label, max_label_len)?;
839        writeln!(out, "{value}")?;
840    }
841
842    Ok(())
843}
844
845/// Inspect a container image and output information about it.
846pub(crate) fn container_inspect(
847    rootfs: &camino::Utf8Path,
848    json: bool,
849    format: Option<OutputFormat>,
850) -> Result<()> {
851    let root = cap_std_ext::cap_std::fs::Dir::open_ambient_dir(
852        rootfs,
853        cap_std_ext::cap_std::ambient_authority(),
854    )?;
855    let kargs = crate::bootc_kargs::get_kargs_in_root(&root, std::env::consts::ARCH)?;
856    let kargs: Vec<String> = kargs.iter_str().map(|s| s.to_owned()).collect();
857    let kernel = crate::kernel::find_kernel(&root)?;
858    let inspect = crate::spec::ContainerInspect { kargs, kernel };
859
860    // Determine output format: explicit --format wins, then --json, then default to human-readable
861    let format = format.unwrap_or(if json {
862        OutputFormat::Json
863    } else {
864        OutputFormat::HumanReadable
865    });
866
867    let mut out = std::io::stdout().lock();
868    match format {
869        OutputFormat::Json => {
870            serde_json::to_writer_pretty(&mut out, &inspect)?;
871        }
872        OutputFormat::Yaml => {
873            serde_yaml::to_writer(&mut out, &inspect)?;
874        }
875        OutputFormat::HumanReadable => {
876            container_inspect_print_human(&inspect, &mut out)?;
877        }
878    }
879    Ok(())
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    fn human_status_from_spec_fixture(spec_fixture: &str) -> Result<String> {
887        let host: Host = serde_yaml::from_str(spec_fixture).unwrap();
888        let mut w = Vec::new();
889        human_readable_output(&mut w, &host, false).unwrap();
890        let w = String::from_utf8(w).unwrap();
891        Ok(w)
892    }
893
894    /// Helper function to generate human-readable status output with verbose mode enabled
895    /// from a YAML fixture string. Used for testing verbose output formatting.
896    fn human_status_from_spec_fixture_verbose(spec_fixture: &str) -> Result<String> {
897        let host: Host = serde_yaml::from_str(spec_fixture).unwrap();
898        let mut w = Vec::new();
899        human_readable_output(&mut w, &host, true).unwrap();
900        let w = String::from_utf8(w).unwrap();
901        Ok(w)
902    }
903
904    #[test]
905    fn test_human_readable_base_spec() {
906        // Tests Staged and Booted, null Rollback
907        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-staged-booted.yaml"))
908            .expect("No spec found");
909        let expected = indoc::indoc! { r"
910            Staged image: quay.io/example/someimage:latest
911                  Digest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 (arm64)
912                 Version: nightly (2023-10-14T19:22:15Z)
913
914          ● Booted image: quay.io/example/someimage:latest
915                  Digest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 (arm64)
916                 Version: nightly (2023-09-30T19:22:16Z)
917        "};
918        similar_asserts::assert_eq!(w, expected);
919    }
920
921    #[test]
922    fn test_human_readable_rfe_spec() {
923        // Basic rhel for edge bootc install with nothing
924        let w = human_status_from_spec_fixture(include_str!(
925            "fixtures/spec-rfe-ostree-deployment.yaml"
926        ))
927        .expect("No spec found");
928        let expected = indoc::indoc! { r"
929            Staged ostree
930                   Commit: 1c24260fdd1be20f72a4a97a75c582834ee3431fbb0fa8e4f482bb219d633a45
931
932          ● Booted ostree
933                     Commit: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791
934        "};
935        similar_asserts::assert_eq!(w, expected);
936    }
937
938    #[test]
939    fn test_human_readable_staged_spec() {
940        // staged image, no boot/rollback
941        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-ostree-to-bootc.yaml"))
942            .expect("No spec found");
943        let expected = indoc::indoc! { r"
944            Staged image: quay.io/centos-bootc/centos-bootc:stream9
945                  Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (s390x)
946                 Version: stream9.20240807.0
947
948          ● Booted ostree
949                     Commit: f9fa3a553ceaaaf30cf85bfe7eed46a822f7b8fd7e14c1e3389cbc3f6d27f791
950        "};
951        similar_asserts::assert_eq!(w, expected);
952    }
953
954    #[test]
955    fn test_human_readable_booted_spec() {
956        // booted image, no staged/rollback
957        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-only-booted.yaml"))
958            .expect("No spec found");
959        let expected = indoc::indoc! { r"
960          ● Booted image: quay.io/centos-bootc/centos-bootc:stream9
961                  Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64)
962                 Version: stream9.20240807.0
963        "};
964        similar_asserts::assert_eq!(w, expected);
965    }
966
967    #[test]
968    fn test_human_readable_staged_rollback_spec() {
969        // staged/rollback image, no booted
970        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-staged-rollback.yaml"))
971            .expect("No spec found");
972        let expected = "System is not deployed via bootc.\n";
973        similar_asserts::assert_eq!(w, expected);
974    }
975
976    #[test]
977    fn test_via_oci() {
978        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-via-local-oci.yaml"))
979            .unwrap();
980        let expected = indoc::indoc! { r"
981          ● Booted image: oci:/var/mnt/osupdate
982                  Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (amd64)
983                 Version: stream9.20240807.0
984        "};
985        similar_asserts::assert_eq!(w, expected);
986    }
987
988    #[test]
989    fn test_convert_signatures() {
990        use std::str::FromStr;
991        let ir_unverified = &OstreeImageReference::from_str(
992            "ostree-unverified-registry:quay.io/someexample/foo:latest",
993        )
994        .unwrap();
995        let ir_ostree = &OstreeImageReference::from_str(
996            "ostree-remote-registry:fedora:quay.io/fedora/fedora-coreos:stable",
997        )
998        .unwrap();
999
1000        let ir = ImageReference::from(ir_unverified.clone());
1001        assert_eq!(ir.image, "quay.io/someexample/foo:latest");
1002        assert_eq!(ir.signature, None);
1003
1004        let ir = ImageReference::from(ir_ostree.clone());
1005        assert_eq!(ir.image, "quay.io/fedora/fedora-coreos:stable");
1006        assert_eq!(
1007            ir.signature,
1008            Some(ImageSignature::OstreeRemote("fedora".into()))
1009        );
1010    }
1011
1012    #[test]
1013    fn test_human_readable_booted_pinned_spec() {
1014        // booted image, no staged/rollback
1015        let w = human_status_from_spec_fixture(include_str!("fixtures/spec-booted-pinned.yaml"))
1016            .expect("No spec found");
1017        let expected = indoc::indoc! { r"
1018          ● Booted image: quay.io/centos-bootc/centos-bootc:stream9
1019                  Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b38 (arm64)
1020                 Version: stream9.20240807.0
1021                  Pinned: yes
1022
1023             Other image: quay.io/centos-bootc/centos-bootc:stream9
1024                  Digest: sha256:47e5ed613a970b6574bfa954ab25bb6e85656552899aa518b5961d9645102b37 (arm64)
1025                 Version: stream9.20240807.0
1026                  Pinned: yes
1027        "};
1028        similar_asserts::assert_eq!(w, expected);
1029    }
1030
1031    #[test]
1032    fn test_human_readable_verbose_spec() {
1033        // Test verbose output includes additional fields
1034        let w =
1035            human_status_from_spec_fixture_verbose(include_str!("fixtures/spec-only-booted.yaml"))
1036                .expect("No spec found");
1037
1038        // Verbose output should include StateRoot, Deploy serial, Staged, and Commit
1039        assert!(w.contains("StateRoot:"));
1040        assert!(w.contains("Deploy serial:"));
1041        assert!(w.contains("Staged:"));
1042        assert!(w.contains("Commit:"));
1043        assert!(w.contains("Soft-reboot:"));
1044    }
1045
1046    #[test]
1047    fn test_human_readable_staged_download_only() {
1048        // Test that download-only staged deployment shows the status in non-verbose mode
1049        // Download-only status is only shown in verbose mode per design
1050        let w =
1051            human_status_from_spec_fixture(include_str!("fixtures/spec-staged-download-only.yaml"))
1052                .expect("No spec found");
1053        let expected = indoc::indoc! { r"
1054            Staged image: quay.io/example/someimage:latest
1055                  Digest: sha256:16dc2b6256b4ff0d2ec18d2dbfb06d117904010c8cf9732cdb022818cf7a7566 (arm64)
1056                 Version: nightly (2023-10-14T19:22:15Z)
1057
1058          ● Booted image: quay.io/example/someimage:latest
1059                  Digest: sha256:736b359467c9437c1ac915acaae952aad854e07eb4a16a94999a48af08c83c34 (arm64)
1060                 Version: nightly (2023-09-30T19:22:16Z)
1061        "};
1062        similar_asserts::assert_eq!(w, expected);
1063    }
1064
1065    #[test]
1066    fn test_human_readable_staged_download_only_verbose() {
1067        // Test that download-only status is shown in verbose mode for staged deployments
1068        let w = human_status_from_spec_fixture_verbose(include_str!(
1069            "fixtures/spec-staged-download-only.yaml"
1070        ))
1071        .expect("No spec found");
1072
1073        // Verbose output should include download-only status
1074        assert!(w.contains("Download-only: yes"));
1075    }
1076
1077    #[test]
1078    fn test_human_readable_staged_not_download_only_verbose() {
1079        // Test that staged deployment not in download-only mode shows "Download-only: no" in verbose mode
1080        let w = human_status_from_spec_fixture_verbose(include_str!(
1081            "fixtures/spec-staged-booted.yaml"
1082        ))
1083        .expect("No spec found");
1084
1085        // Verbose output should include download-only status as "no" for normal staged deployments
1086        assert!(w.contains("Download-only: no"));
1087    }
1088
1089    #[test]
1090    fn test_container_inspect_human_readable() {
1091        let inspect = crate::spec::ContainerInspect {
1092            kargs: vec!["console=ttyS0".into(), "quiet".into()],
1093            kernel: Some(crate::kernel::Kernel {
1094                version: "6.12.0-100.fc41.x86_64".into(),
1095                unified: false,
1096            }),
1097        };
1098        let mut w = Vec::new();
1099        container_inspect_print_human(&inspect, &mut w).unwrap();
1100        let output = String::from_utf8(w).unwrap();
1101        let expected = indoc::indoc! { r"
1102            Kernel: 6.12.0-100.fc41.x86_64
1103              Type: vmlinuz
1104             Kargs: console=ttyS0 quiet
1105        "};
1106        similar_asserts::assert_eq!(output, expected);
1107    }
1108
1109    #[test]
1110    fn test_container_inspect_human_readable_uki() {
1111        let inspect = crate::spec::ContainerInspect {
1112            kargs: vec![],
1113            kernel: Some(crate::kernel::Kernel {
1114                version: "6.12.0-100.fc41.x86_64".into(),
1115                unified: true,
1116            }),
1117        };
1118        let mut w = Vec::new();
1119        container_inspect_print_human(&inspect, &mut w).unwrap();
1120        let output = String::from_utf8(w).unwrap();
1121        let expected = indoc::indoc! { r"
1122            Kernel: 6.12.0-100.fc41.x86_64
1123              Type: UKI
1124             Kargs: <none>
1125        "};
1126        similar_asserts::assert_eq!(output, expected);
1127    }
1128
1129    #[test]
1130    fn test_container_inspect_human_readable_no_kernel() {
1131        let inspect = crate::spec::ContainerInspect {
1132            kargs: vec!["console=ttyS0".into()],
1133            kernel: None,
1134        };
1135        let mut w = Vec::new();
1136        container_inspect_print_human(&inspect, &mut w).unwrap();
1137        let output = String::from_utf8(w).unwrap();
1138        let expected = indoc::indoc! { r"
1139            Kernel: <none>
1140             Kargs: console=ttyS0
1141        "};
1142        similar_asserts::assert_eq!(output, expected);
1143    }
1144}