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
53fn transport_to_string(transport: ostree_container::Transport) -> String {
55 match transport {
56 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 transport: img.transport.as_str().try_into().unwrap(),
91 name: img.image,
92 },
93 }
94 }
95}
96
97fn check_selinux_policy_compatible(
100 sysroot: &SysrootLock,
101 booted_deployment: &ostree::Deployment,
102 target_deployment: &ostree::Deployment,
103) -> Result<bool> {
104 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), (Some(_), None) | (None, Some(_)) => {
124 Ok(false)
126 }
127 (Some(booted_csum), Some(target_csum)) => {
128 Ok(booted_csum == target_csum)
130 }
131 }
132}
133
134fn has_soft_reboot_capability(sysroot: &SysrootLock, deployment: &ostree::Deployment) -> bool {
137 if !ostree_ext::systemd_has_soft_reboot() {
138 return false;
139 }
140
141 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 if let Some(booted_deployment) = sysroot.booted_deployment() {
162 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
173fn 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
196fn 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#[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 CachedImageStatus::default()
258 } else if let Some(image) = get_image_origin(origin)? {
259 imagestatus(sysroot, deployment, image)?
260 } else {
261 CachedImageStatus::default()
263 };
264 (cached_imagestatus, incompatible)
265 } else {
266 (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 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 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 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
326pub(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#[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 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 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 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 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 Host::default()
471 }
472 };
473
474 host.status.read_only = storage.is_ro;
477
478 Ok(host)
479}
480
481#[context("Status")]
483pub(crate) async fn status(opts: super::cli::StatusOpts) -> Result<()> {
484 match opts.format_version.unwrap_or_default() {
485 0 | 1 => {}
487 o => anyhow::bail!("Unsupported format version: {o}"),
488 };
489 let mut host = get_host().await?;
490
491 if opts.booted {
494 host.filter_to_slot(Slot::Booted);
495 }
496
497 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
540fn 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
549fn format_timestamp(t: &chrono::DateTime<chrono::Utc>) -> impl std::fmt::Display {
554 t.format("%Y-%m-%dT%H:%M:%SZ")
555}
556
557fn 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 write_row_name(&mut out, "Deploy serial", prefix_len)?;
569 writeln!(out, "{}", ostree.deploy_serial)?;
570
571 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
579fn write_soft_reboot(
581 mut out: impl Write,
582 entry: &crate::spec::BootEntry,
583 prefix_len: usize,
584) -> Result<()> {
585 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
600fn write_download_only(
602 mut out: impl Write,
603 slot: Option<Slot>,
604 entry: &crate::spec::BootEntry,
605 prefix_len: usize,
606) -> Result<()> {
607 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
636fn 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
669fn 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 let imageref = if transport == "registry" {
682 Cow::Borrowed(imagename)
683 } else {
684 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 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 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 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 if let Some(cached) = &entry.cached_update {
730 render_cached_update(&mut out, cached, image, prefix_len)?;
731 }
732
733 write_usr_overlay(&mut out, slot, host_status, prefix_len)?;
735
736 if verbose {
737 if let Some(ostree) = &entry.ostree {
739 render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?;
740
741 write_row_name(&mut out, "Commit", prefix_len)?;
743 writeln!(out, "{}", ostree.checksum)?;
744 }
745
746 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 write_soft_reboot(&mut out, entry, prefix_len)?;
764
765 write_fsverity_enforcement(&mut out, entry, prefix_len)?;
766
767 write_download_only(&mut out, slot, entry, prefix_len)?;
769 }
770
771 tracing::debug!("pinned={}", entry.pinned);
772
773 Ok(())
774}
775
776fn 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 if matches!(slot, Some(Slot::Booted)) {
785 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
794fn 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 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 write_usr_overlay(&mut out, slot, host_status, prefix_len)?;
823
824 if verbose {
825 if let Some(ostree) = &entry.ostree {
827 render_verbose_ostree_info(&mut out, ostree, slot, prefix_len)?;
828 }
829
830 write_soft_reboot(&mut out, entry, prefix_len)?;
832
833 write_download_only(&mut out, slot, entry, prefix_len)?;
835 }
836
837 tracing::debug!("pinned={}", entry.pinned);
838 Ok(())
839}
840
841fn 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 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
932fn 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
942fn container_inspect_print_human(
944 inspect: &crate::spec::ContainerInspect,
945 mut out: impl Write,
946) -> Result<()> {
947 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 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
982pub(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 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 (
1035 chrono::Utc.with_ymd_and_hms(2024, 8, 7, 12, 0, 0).unwrap(),
1036 "2024-08-07T12:00:00Z",
1037 ),
1038 (
1040 chrono::Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(),
1041 "2023-01-01T00:00:00Z",
1042 ),
1043 (
1045 chrono::Utc
1046 .with_ymd_and_hms(2025, 12, 31, 23, 59, 59)
1047 .unwrap(),
1048 "2025-12-31T23:59:59Z",
1049 ),
1050 (
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 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 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 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 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 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 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 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 let w =
1244 human_status_from_spec_fixture_verbose(include_str!("fixtures/spec-only-booted.yaml"))
1245 .expect("No spec found");
1246
1247 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 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 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 assert!(w.contains("Download-only: yes"));
1284 }
1285
1286 #[test]
1287 fn test_human_readable_staged_not_download_only_verbose() {
1288 let w = human_status_from_spec_fixture_verbose(include_str!(
1290 "fixtures/spec-staged-booted.yaml"
1291 ))
1292 .expect("No spec found");
1293
1294 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 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 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 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}