1use std::fmt::Display;
4
5use std::str::FromStr;
6
7use anyhow::Result;
8use ostree_ext::container::Transport;
9use ostree_ext::oci_spec::distribution::Reference;
10use ostree_ext::oci_spec::image::Digest;
11use ostree_ext::{container::OstreeImageReference, oci_spec, ostree::DeploymentUnlockedState};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14
15use crate::bootc_composefs::boot::BootType;
16use crate::{k8sapitypes, status::Slot};
17
18const API_VERSION: &str = "org.containers.bootc/v1";
19const KIND: &str = "BootcHost";
20pub(crate) const OBJECT_NAME: &str = "host";
22
23#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
24#[serde(rename_all = "camelCase")]
25pub struct Host {
27 #[serde(flatten)]
29 pub resource: k8sapitypes::Resource,
30 #[serde(default)]
32 pub spec: HostSpec,
33 #[serde(default)]
35 pub status: HostStatus,
36}
37
38#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
41#[serde(rename_all = "camelCase")]
42pub enum BootOrder {
43 #[default]
45 Default,
46 Rollback,
48}
49
50#[derive(
51 clap::ValueEnum, Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, Default,
52)]
53#[serde(rename_all = "camelCase")]
54pub enum Store {
56 #[default]
58 #[value(alias = "ostreecontainer")] OstreeContainer,
60}
61
62#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
63#[serde(rename_all = "camelCase")]
64pub struct HostSpec {
66 pub image: Option<ImageReference>,
68 #[serde(default)]
70 pub boot_order: BootOrder,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
74#[serde(rename_all = "camelCase")]
76pub enum ImageSignature {
77 OstreeRemote(String),
79 ContainerPolicy,
81 Insecure,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct ImageReference {
89 pub image: String,
91 pub transport: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub signature: Option<ImageSignature>,
96}
97
98fn canonicalize_reference(reference: Reference) -> Option<Reference> {
100 reference.tag()?;
102
103 let digest = reference.digest()?;
105 Some(reference.clone_with_digest(digest.to_owned()))
107}
108
109impl ImageReference {
110 pub fn canonicalize(self) -> Result<Self> {
112 let transport = Transport::try_from(self.transport.as_str())?;
114 match transport {
115 Transport::Registry => {
116 let reference: oci_spec::distribution::Reference = self.image.parse()?;
117
118 let Some(reference) = canonicalize_reference(reference) else {
120 return Ok(self);
121 };
122
123 let r = ImageReference {
124 image: reference.to_string(),
125 transport: self.transport.clone(),
126 signature: self.signature.clone(),
127 };
128 Ok(r)
129 }
130 _ => {
131 Ok(self)
133 }
134 }
135 }
136
137 pub fn transport(&self) -> Result<Transport> {
139 Transport::try_from(self.transport.as_str())
140 .map_err(|e| anyhow::anyhow!("Invalid transport '{}': {}", self.transport, e))
141 }
142
143 pub fn to_image_proxy_ref(&self) -> Result<ostree_ext::containers_image_proxy::ImageReference> {
148 let s = format!("{}:{}", self.transport, self.image);
149 s.as_str()
150 .try_into()
151 .map_err(|e| anyhow::anyhow!("Parsing image reference '{}': {}", s, e))
152 }
153
154 pub fn to_transport_image(&self) -> Result<String> {
157 if self.transport()? == Transport::Registry {
158 Ok(self.image.clone())
160 } else {
161 Ok(format!("{}:{}", self.transport, self.image))
163 }
164 }
165
166 pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
172 let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
174 let new_ref = Reference::with_tag(
176 reference.registry().to_string(),
177 reference.repository().to_string(),
178 new_tag.to_string(),
179 );
180 new_ref.to_string()
181 } else {
182 let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);
185
186 let image_part = image_without_digest
188 .rsplit_once(':')
189 .map(|(base, _tag)| base)
190 .unwrap_or(image_without_digest);
191
192 format!("{}:{}", image_part, new_tag)
193 };
194
195 Ok(ImageReference {
196 image: new_image,
197 transport: self.transport.clone(),
198 signature: self.signature.clone(),
199 })
200 }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
205#[serde(rename_all = "camelCase")]
206pub struct ImageStatus {
207 pub image: ImageReference,
209 pub version: Option<String>,
211 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
213 pub image_digest: String,
215 pub architecture: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct BootEntryOstree {
223 pub stateroot: String,
225 pub checksum: String,
227 pub deploy_serial: u32,
229}
230
231#[derive(
233 clap::ValueEnum, Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
234)]
235#[serde(rename_all = "kebab-case")]
236pub enum Bootloader {
237 #[default]
239 Grub,
240 #[serde(rename = "grub-cc")]
242 GrubCC,
243 Systemd,
245 None,
247}
248
249#[derive(Debug, PartialEq, Eq)]
250pub enum BootloaderKind {
251 BLSCompatible,
254 GRUBClassic,
256}
257
258impl Display for Bootloader {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 let string = match self {
261 Bootloader::Grub => "grub",
262 Bootloader::GrubCC => "grub-cc",
263 Bootloader::Systemd => "systemd",
264 Bootloader::None => "none",
265 };
266
267 write!(f, "{}", string)
268 }
269}
270
271impl FromStr for Bootloader {
272 type Err = anyhow::Error;
273
274 fn from_str(value: &str) -> Result<Self> {
275 match value {
276 "grub" => Ok(Self::Grub),
277 "grub-cc" => Ok(Self::GrubCC),
278 "systemd" => Ok(Self::Systemd),
279 "none" => Ok(Self::None),
280 unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
281 }
282 }
283}
284
285impl Bootloader {
286 pub(crate) fn kind(&self) -> Result<BootloaderKind> {
289 match self {
290 Bootloader::Grub => Ok(BootloaderKind::GRUBClassic),
291 Bootloader::Systemd | Bootloader::GrubCC => Ok(BootloaderKind::BLSCompatible),
292 Bootloader::None => anyhow::bail!("Bootloader was None"),
293 }
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
299#[serde(rename_all = "camelCase")]
300pub struct BootEntryComposefs {
301 pub verity: String,
303 pub boot_type: BootType,
305 pub bootloader: Bootloader,
307 pub boot_digest: Option<String>,
310 pub missing_verity_allowed: bool,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
316#[serde(rename_all = "camelCase")]
317pub struct BootEntry {
318 pub image: Option<ImageStatus>,
320 pub cached_update: Option<ImageStatus>,
322 pub incompatible: bool,
324 pub pinned: bool,
326 #[serde(default)]
328 pub soft_reboot_capable: bool,
329 #[serde(default)]
332 pub download_only: bool,
333 #[serde(default)]
335 pub store: Option<Store>,
336 pub ostree: Option<BootEntryOstree>,
338 pub composefs: Option<BootEntryComposefs>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
343#[serde(rename_all = "camelCase")]
344#[non_exhaustive]
345pub enum HostType {
348 BootcHost,
350}
351
352#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
354#[serde(rename_all = "camelCase")]
355pub struct FilesystemOverlay {
356 pub access_mode: FilesystemOverlayAccessMode,
358 pub persistence: FilesystemOverlayPersistence,
360}
361
362#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
364#[serde(rename_all = "camelCase")]
365pub enum FilesystemOverlayAccessMode {
366 ReadOnly,
368 ReadWrite,
370}
371
372impl Display for FilesystemOverlayAccessMode {
373 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
374 match self {
375 FilesystemOverlayAccessMode::ReadOnly => write!(f, "read-only"),
376 FilesystemOverlayAccessMode::ReadWrite => write!(f, "read/write"),
377 }
378 }
379}
380
381#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
383#[serde(rename_all = "camelCase")]
384pub enum FilesystemOverlayPersistence {
385 Transient,
387 Persistent,
389}
390
391impl Display for FilesystemOverlayPersistence {
392 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
393 match self {
394 FilesystemOverlayPersistence::Transient => write!(f, "transient"),
395 FilesystemOverlayPersistence::Persistent => write!(f, "persistent"),
396 }
397 }
398}
399
400pub(crate) fn deployment_unlocked_state_to_usr_overlay(
401 state: DeploymentUnlockedState,
402) -> Option<FilesystemOverlay> {
403 use FilesystemOverlayAccessMode::*;
404 use FilesystemOverlayPersistence::*;
405 match state {
406 DeploymentUnlockedState::None => None,
407 DeploymentUnlockedState::Development => Some(FilesystemOverlay {
408 access_mode: ReadWrite,
409 persistence: Transient,
410 }),
411 DeploymentUnlockedState::Hotfix => Some(FilesystemOverlay {
412 access_mode: ReadWrite,
413 persistence: Persistent,
414 }),
415 DeploymentUnlockedState::Transient => Some(FilesystemOverlay {
416 access_mode: ReadOnly,
417 persistence: Transient,
418 }),
419 _ => None,
420 }
421}
422
423impl Display for FilesystemOverlay {
424 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425 write!(f, "{}, {}", self.persistence, self.access_mode)
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
431#[serde(rename_all = "camelCase")]
432pub struct HostStatus {
433 pub staged: Option<BootEntry>,
435 pub booted: Option<BootEntry>,
437 pub rollback: Option<BootEntry>,
439 #[serde(skip_serializing_if = "Vec::is_empty")]
441 #[serde(default)]
442 pub other_deployments: Vec<BootEntry>,
443 #[serde(default)]
445 pub rollback_queued: bool,
446
447 #[serde(rename = "type")]
449 pub ty: Option<HostType>,
450
451 pub usr_overlay: Option<FilesystemOverlay>,
453}
454
455pub(crate) struct DeploymentEntry<'a> {
456 pub(crate) ty: Option<Slot>,
457 pub(crate) deployment: &'a BootEntryComposefs,
458 pub(crate) pinned: bool,
459 pub(crate) soft_reboot_capable: bool,
460}
461
462#[derive(Debug, Serialize)]
464#[serde(rename_all = "kebab-case")]
465pub(crate) struct ContainerInspect {
466 pub(crate) kargs: Vec<String>,
468 pub(crate) kernel: Option<crate::kernel::Kernel>,
470}
471
472impl Host {
473 pub fn new(spec: HostSpec) -> Self {
475 let metadata = k8sapitypes::ObjectMeta {
476 name: Some(OBJECT_NAME.to_owned()),
477 ..Default::default()
478 };
479 Self {
480 resource: k8sapitypes::Resource {
481 api_version: API_VERSION.to_owned(),
482 kind: KIND.to_owned(),
483 metadata,
484 },
485 spec,
486 status: Default::default(),
487 }
488 }
489
490 pub fn filter_to_slot(&mut self, slot: Slot) {
492 match slot {
493 Slot::Staged => {
494 self.status.booted = None;
495 self.status.rollback = None;
496 }
497 Slot::Booted => {
498 self.status.staged = None;
499 self.status.rollback = None;
500 }
501 Slot::Rollback => {
502 self.status.staged = None;
503 self.status.booted = None;
504 }
505 }
506 }
507
508 pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
510 self.status
511 .staged
512 .iter()
513 .chain(self.status.booted.iter())
514 .chain(self.status.rollback.iter())
515 .chain(self.status.other_deployments.iter())
516 .collect::<Vec<_>>()
517 }
518
519 pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
520 let cfs = self
521 .status
522 .booted
523 .as_ref()
524 .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
525 .require_composefs()?;
526
527 Ok(cfs)
528 }
529
530 #[fn_error_context::context("Getting all composefs deployments")]
532 pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
533 let mut all_deps = vec![];
534
535 let booted = self.require_composefs_booted()?;
536 all_deps.push(DeploymentEntry {
537 ty: Some(Slot::Booted),
538 deployment: booted,
539 pinned: false,
540 soft_reboot_capable: false,
541 });
542
543 if let Some(staged) = &self.status.staged {
544 all_deps.push(DeploymentEntry {
545 ty: Some(Slot::Staged),
546 deployment: staged.require_composefs()?,
547 pinned: false,
548 soft_reboot_capable: staged.soft_reboot_capable,
549 });
550 }
551
552 if let Some(rollback) = &self.status.rollback {
553 all_deps.push(DeploymentEntry {
554 ty: Some(Slot::Rollback),
555 deployment: rollback.require_composefs()?,
556 pinned: false,
557 soft_reboot_capable: rollback.soft_reboot_capable,
558 });
559 }
560
561 for pinned in &self.status.other_deployments {
562 all_deps.push(DeploymentEntry {
563 ty: None,
564 deployment: pinned.require_composefs()?,
565 pinned: true,
566 soft_reboot_capable: pinned.soft_reboot_capable,
567 });
568 }
569
570 Ok(all_deps)
571 }
572}
573
574impl Default for Host {
575 fn default() -> Self {
576 Self::new(Default::default())
577 }
578}
579
580impl HostSpec {
581 pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
584 let rollback = self.boot_order != new.boot_order;
585 let image_change = self.image != new.image;
586 if rollback && image_change {
587 anyhow::bail!("Invalid state transition: rollback and image change");
588 }
589 Ok(())
590 }
591}
592
593impl BootOrder {
594 pub(crate) fn swap(&self) -> Self {
595 match self {
596 BootOrder::Default => BootOrder::Rollback,
597 BootOrder::Rollback => BootOrder::Default,
598 }
599 }
600}
601
602impl Display for ImageReference {
603 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604 if f.alternate() && self.signature.is_none() && self.transport == "registry" {
606 self.image.fmt(f)
607 } else {
608 let ostree_imgref = OstreeImageReference::from(self.clone());
609 ostree_imgref.fmt(f)
610 }
611 }
612}
613
614impl ImageStatus {
615 pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
616 use std::str::FromStr;
617 Ok(Digest::from_str(&self.image_digest)?)
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use std::str::FromStr;
624
625 use super::*;
626
627 #[test]
628 fn test_canonicalize_reference() {
629 let passthrough = [
631 ("quay.io/example/someimage:latest"),
632 ("quay.io/example/someimage"),
633 ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
634 ];
635 let mapped = [
636 (
637 "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
638 "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
639 ),
640 (
641 "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
642 "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
643 ),
644 ];
645 for &v in passthrough.iter() {
646 let reference = Reference::from_str(v).unwrap();
647 assert!(reference.tag().is_none() || reference.digest().is_none());
648 assert!(canonicalize_reference(reference).is_none());
649 }
650 for &(initial, expected) in mapped.iter() {
651 let reference = Reference::from_str(initial).unwrap();
652 assert!(reference.tag().is_some());
653 assert!(reference.digest().is_some());
654 let canonicalized = canonicalize_reference(reference).unwrap();
655 assert_eq!(canonicalized.to_string(), expected);
656 }
657 }
658
659 #[test]
660 fn test_image_reference_canonicalize() {
661 let sample_digest =
662 "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
663
664 let test_cases = [
665 (
667 format!("quay.io/example/someimage:latest@{sample_digest}"),
668 format!("quay.io/example/someimage@{sample_digest}"),
669 "registry",
670 ),
671 (
673 format!("quay.io/example/someimage@{sample_digest}"),
674 format!("quay.io/example/someimage@{sample_digest}"),
675 "registry",
676 ),
677 (
679 "quay.io/example/someimage:latest".to_string(),
680 "quay.io/example/someimage:latest".to_string(),
681 "registry",
682 ),
683 (
685 "quay.io/example/someimage".to_string(),
686 "quay.io/example/someimage".to_string(),
687 "registry",
688 ),
689 (
692 "localhost/someimage:latest".to_string(),
693 "localhost/someimage:latest".to_string(),
694 "registry",
695 ),
696 (
697 format!("localhost/someimage:latest@{sample_digest}"),
698 format!("localhost/someimage@{sample_digest}"),
699 "registry",
700 ),
701 (
703 format!("quay.io/example/someimage:latest@{sample_digest}"),
704 format!("quay.io/example/someimage:latest@{sample_digest}"),
705 "containers-storage",
706 ),
707 (
708 "/path/to/dir:latest".to_string(),
709 "/path/to/dir:latest".to_string(),
710 "oci",
711 ),
712 (
713 "/tmp/repo".to_string(),
714 "/tmp/repo".to_string(),
715 "oci-archive",
716 ),
717 (
718 "/tmp/image-dir".to_string(),
719 "/tmp/image-dir".to_string(),
720 "dir",
721 ),
722 ];
723
724 for (initial, expected, transport) in test_cases {
725 let imgref = ImageReference {
726 image: initial.to_string(),
727 transport: transport.to_string(),
728 signature: None,
729 };
730
731 let canonicalized = imgref.canonicalize();
732 if let Err(e) = canonicalized {
733 panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
734 }
735 let canonicalized = canonicalized.unwrap();
736 assert_eq!(
737 canonicalized.image, expected,
738 "Mismatch for transport {transport}"
739 );
740 assert_eq!(canonicalized.transport, transport);
741 assert_eq!(canonicalized.signature, None);
742 }
743 }
744
745 #[test]
746 fn test_to_image_proxy_ref() {
747 use ostree_ext::containers_image_proxy;
748
749 let cases = [
750 (
751 "registry",
752 "quay.io/example/image:latest",
753 containers_image_proxy::Transport::Registry,
754 "quay.io/example/image:latest",
755 ),
756 (
757 "containers-storage",
758 "localhost/bootc",
759 containers_image_proxy::Transport::ContainerStorage,
760 "localhost/bootc",
761 ),
762 (
763 "oci",
764 "/var/tmp/bootc-oci",
765 containers_image_proxy::Transport::OciDir,
766 "/var/tmp/bootc-oci",
767 ),
768 (
769 "docker-daemon",
770 "myimage:tag",
771 containers_image_proxy::Transport::DockerDaemon,
772 "myimage:tag",
773 ),
774 ];
775
776 for (transport, image, expected_transport, expected_name) in cases {
777 let imgref = ImageReference {
778 transport: transport.to_string(),
779 image: image.to_string(),
780 signature: None,
781 };
782 let proxy_ref = imgref.to_image_proxy_ref().unwrap();
783 assert_eq!(
784 proxy_ref.transport, expected_transport,
785 "transport mismatch for {transport}:{image}"
786 );
787 assert_eq!(
788 proxy_ref.name, expected_name,
789 "name mismatch for {transport}:{image}"
790 );
791 }
792 }
793
794 #[test]
795 fn test_unimplemented_oci_tagged_digested() {
796 let imgref = ImageReference {
797 image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
798 transport: "oci".to_string(),
799 signature: None
800 };
801 let canonicalized = imgref.clone().canonicalize().unwrap();
802 assert_eq!(imgref, canonicalized);
804 }
805
806 #[test]
807 fn test_parse_spec_v1_null() {
808 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
809 let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
810 assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
811 }
812
813 #[test]
814 fn test_parse_spec_v1a1_orig() {
815 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
816 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
817 assert_eq!(
818 host.spec.image.as_ref().unwrap().image.as_str(),
819 "quay.io/example/someimage:latest"
820 );
821 }
822
823 #[test]
824 fn test_parse_spec_v1a1() {
825 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
826 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
827 assert_eq!(
828 host.spec.image.as_ref().unwrap().image.as_str(),
829 "quay.io/otherexample/otherimage:latest"
830 );
831 assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
832 }
833
834 #[test]
835 fn test_parse_ostreeremote() {
836 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
837 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
838 assert_eq!(
839 host.spec.image.as_ref().unwrap().signature,
840 Some(ImageSignature::OstreeRemote("fedora".into()))
841 );
842 }
843
844 #[test]
845 fn test_display_imgref() {
846 let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
847 let s = OstreeImageReference::from_str(src).unwrap();
848 let s = ImageReference::from(s);
849 let displayed = format!("{s}");
850 assert_eq!(displayed.as_str(), src);
851 assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
853
854 let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
855 let s = OstreeImageReference::from_str(src).unwrap();
856 let s = ImageReference::from(s);
857 let displayed = format!("{s}");
858 assert_eq!(displayed.as_str(), src);
859 assert_eq!(format!("{s:#}"), src);
860 }
861
862 #[test]
863 fn test_store_from_str() {
864 use clap::ValueEnum;
865
866 assert!(Store::from_str("Ostree-Container", true).is_ok());
868 assert!(Store::from_str("OstrEeContAiner", true).is_ok());
869 assert!(Store::from_str("invalid", true).is_err());
870 }
871
872 #[test]
873 fn test_host_filter_to_slot() {
874 fn create_host() -> Host {
875 let mut host = Host::default();
876 host.status.staged = Some(default_boot_entry());
877 host.status.booted = Some(default_boot_entry());
878 host.status.rollback = Some(default_boot_entry());
879 host
880 }
881
882 fn default_boot_entry() -> BootEntry {
883 BootEntry {
884 image: None,
885 cached_update: None,
886 incompatible: false,
887 soft_reboot_capable: false,
888 pinned: false,
889 download_only: false,
890 store: None,
891 ostree: None,
892 composefs: None,
893 }
894 }
895
896 fn assert_host_state(
897 host: &Host,
898 staged: Option<BootEntry>,
899 booted: Option<BootEntry>,
900 rollback: Option<BootEntry>,
901 ) {
902 assert_eq!(host.status.staged, staged);
903 assert_eq!(host.status.booted, booted);
904 assert_eq!(host.status.rollback, rollback);
905 }
906
907 let mut host = create_host();
908 host.filter_to_slot(Slot::Staged);
909 assert_host_state(&host, Some(default_boot_entry()), None, None);
910
911 let mut host = create_host();
912 host.filter_to_slot(Slot::Booted);
913 assert_host_state(&host, None, Some(default_boot_entry()), None);
914
915 let mut host = create_host();
916 host.filter_to_slot(Slot::Rollback);
917 assert_host_state(&host, None, None, Some(default_boot_entry()));
918 }
919
920 #[test]
921 fn test_to_transport_image() {
922 let registry_ref = ImageReference {
924 transport: "registry".to_string(),
925 image: "quay.io/example/foo:latest".to_string(),
926 signature: None,
927 };
928 assert_eq!(
929 registry_ref.to_transport_image().unwrap(),
930 "quay.io/example/foo:latest"
931 );
932
933 let storage_ref = ImageReference {
935 transport: "containers-storage".to_string(),
936 image: "localhost/bootc".to_string(),
937 signature: None,
938 };
939 assert_eq!(
940 storage_ref.to_transport_image().unwrap(),
941 "containers-storage:localhost/bootc"
942 );
943
944 let oci_ref = ImageReference {
946 transport: "oci".to_string(),
947 image: "/path/to/image".to_string(),
948 signature: None,
949 };
950 assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
951 }
952}