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