Skip to main content

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