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 #[serde(default)]
458 pub read_only: bool,
459}
460
461pub(crate) struct DeploymentEntry<'a> {
462 pub(crate) ty: Option<Slot>,
463 pub(crate) deployment: &'a BootEntryComposefs,
464 pub(crate) pinned: bool,
465 pub(crate) soft_reboot_capable: bool,
466}
467
468#[derive(Debug, Serialize)]
470#[serde(rename_all = "kebab-case")]
471pub(crate) struct ContainerInspect {
472 pub(crate) kargs: Vec<String>,
474 pub(crate) kernel: Option<crate::kernel::Kernel>,
476}
477
478impl Host {
479 pub fn new(spec: HostSpec) -> Self {
481 let metadata = k8sapitypes::ObjectMeta {
482 name: Some(OBJECT_NAME.to_owned()),
483 ..Default::default()
484 };
485 Self {
486 resource: k8sapitypes::Resource {
487 api_version: API_VERSION.to_owned(),
488 kind: KIND.to_owned(),
489 metadata,
490 },
491 spec,
492 status: Default::default(),
493 }
494 }
495
496 pub fn filter_to_slot(&mut self, slot: Slot) {
498 match slot {
499 Slot::Staged => {
500 self.status.booted = None;
501 self.status.rollback = None;
502 }
503 Slot::Booted => {
504 self.status.staged = None;
505 self.status.rollback = None;
506 }
507 Slot::Rollback => {
508 self.status.staged = None;
509 self.status.booted = None;
510 }
511 }
512 }
513
514 pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
516 self.status
517 .staged
518 .iter()
519 .chain(self.status.booted.iter())
520 .chain(self.status.rollback.iter())
521 .chain(self.status.other_deployments.iter())
522 .collect::<Vec<_>>()
523 }
524
525 pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
526 let cfs = self
527 .status
528 .booted
529 .as_ref()
530 .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
531 .require_composefs()?;
532
533 Ok(cfs)
534 }
535
536 #[fn_error_context::context("Getting all composefs deployments")]
538 pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
539 let mut all_deps = vec![];
540
541 let booted = self.require_composefs_booted()?;
542 all_deps.push(DeploymentEntry {
543 ty: Some(Slot::Booted),
544 deployment: booted,
545 pinned: false,
546 soft_reboot_capable: false,
547 });
548
549 if let Some(staged) = &self.status.staged {
550 all_deps.push(DeploymentEntry {
551 ty: Some(Slot::Staged),
552 deployment: staged.require_composefs()?,
553 pinned: false,
554 soft_reboot_capable: staged.soft_reboot_capable,
555 });
556 }
557
558 if let Some(rollback) = &self.status.rollback {
559 all_deps.push(DeploymentEntry {
560 ty: Some(Slot::Rollback),
561 deployment: rollback.require_composefs()?,
562 pinned: false,
563 soft_reboot_capable: rollback.soft_reboot_capable,
564 });
565 }
566
567 for pinned in &self.status.other_deployments {
568 all_deps.push(DeploymentEntry {
569 ty: None,
570 deployment: pinned.require_composefs()?,
571 pinned: true,
572 soft_reboot_capable: pinned.soft_reboot_capable,
573 });
574 }
575
576 Ok(all_deps)
577 }
578}
579
580impl Default for Host {
581 fn default() -> Self {
582 Self::new(Default::default())
583 }
584}
585
586impl HostSpec {
587 pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
590 let rollback = self.boot_order != new.boot_order;
591 let image_change = self.image != new.image;
592 if rollback && image_change {
593 anyhow::bail!("Invalid state transition: rollback and image change");
594 }
595 Ok(())
596 }
597}
598
599impl BootOrder {
600 pub(crate) fn swap(&self) -> Self {
601 match self {
602 BootOrder::Default => BootOrder::Rollback,
603 BootOrder::Rollback => BootOrder::Default,
604 }
605 }
606}
607
608impl Display for ImageReference {
609 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610 if f.alternate() && self.signature.is_none() && self.transport == "registry" {
612 self.image.fmt(f)
613 } else {
614 let ostree_imgref = OstreeImageReference::from(self.clone());
615 ostree_imgref.fmt(f)
616 }
617 }
618}
619
620impl ImageStatus {
621 pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
622 use std::str::FromStr;
623 Ok(Digest::from_str(&self.image_digest)?)
624 }
625}
626
627#[cfg(test)]
628mod tests {
629 use std::str::FromStr;
630
631 use super::*;
632
633 #[test]
634 fn test_canonicalize_reference() {
635 let passthrough = [
637 ("quay.io/example/someimage:latest"),
638 ("quay.io/example/someimage"),
639 ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
640 ];
641 let mapped = [
642 (
643 "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
644 "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
645 ),
646 (
647 "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
648 "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
649 ),
650 ];
651 for &v in passthrough.iter() {
652 let reference = Reference::from_str(v).unwrap();
653 assert!(reference.tag().is_none() || reference.digest().is_none());
654 assert!(canonicalize_reference(reference).is_none());
655 }
656 for &(initial, expected) in mapped.iter() {
657 let reference = Reference::from_str(initial).unwrap();
658 assert!(reference.tag().is_some());
659 assert!(reference.digest().is_some());
660 let canonicalized = canonicalize_reference(reference).unwrap();
661 assert_eq!(canonicalized.to_string(), expected);
662 }
663 }
664
665 #[test]
666 fn test_image_reference_canonicalize() {
667 let sample_digest =
668 "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
669
670 let test_cases = [
671 (
673 format!("quay.io/example/someimage:latest@{sample_digest}"),
674 format!("quay.io/example/someimage@{sample_digest}"),
675 "registry",
676 ),
677 (
679 format!("quay.io/example/someimage@{sample_digest}"),
680 format!("quay.io/example/someimage@{sample_digest}"),
681 "registry",
682 ),
683 (
685 "quay.io/example/someimage:latest".to_string(),
686 "quay.io/example/someimage:latest".to_string(),
687 "registry",
688 ),
689 (
691 "quay.io/example/someimage".to_string(),
692 "quay.io/example/someimage".to_string(),
693 "registry",
694 ),
695 (
698 "localhost/someimage:latest".to_string(),
699 "localhost/someimage:latest".to_string(),
700 "registry",
701 ),
702 (
703 format!("localhost/someimage:latest@{sample_digest}"),
704 format!("localhost/someimage@{sample_digest}"),
705 "registry",
706 ),
707 (
709 format!("quay.io/example/someimage:latest@{sample_digest}"),
710 format!("quay.io/example/someimage:latest@{sample_digest}"),
711 "containers-storage",
712 ),
713 (
714 "/path/to/dir:latest".to_string(),
715 "/path/to/dir:latest".to_string(),
716 "oci",
717 ),
718 (
719 "/tmp/repo".to_string(),
720 "/tmp/repo".to_string(),
721 "oci-archive",
722 ),
723 (
724 "/tmp/image-dir".to_string(),
725 "/tmp/image-dir".to_string(),
726 "dir",
727 ),
728 ];
729
730 for (initial, expected, transport) in test_cases {
731 let imgref = ImageReference {
732 image: initial.to_string(),
733 transport: transport.to_string(),
734 signature: None,
735 };
736
737 let canonicalized = imgref.canonicalize();
738 if let Err(e) = canonicalized {
739 panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
740 }
741 let canonicalized = canonicalized.unwrap();
742 assert_eq!(
743 canonicalized.image, expected,
744 "Mismatch for transport {transport}"
745 );
746 assert_eq!(canonicalized.transport, transport);
747 assert_eq!(canonicalized.signature, None);
748 }
749 }
750
751 #[test]
752 fn test_to_image_proxy_ref() {
753 use ostree_ext::containers_image_proxy;
754
755 let cases = [
756 (
757 "registry",
758 "quay.io/example/image:latest",
759 containers_image_proxy::Transport::Registry,
760 "quay.io/example/image:latest",
761 ),
762 (
763 "containers-storage",
764 "localhost/bootc",
765 containers_image_proxy::Transport::ContainerStorage,
766 "localhost/bootc",
767 ),
768 (
769 "oci",
770 "/var/tmp/bootc-oci",
771 containers_image_proxy::Transport::OciDir,
772 "/var/tmp/bootc-oci",
773 ),
774 (
775 "docker-daemon",
776 "myimage:tag",
777 containers_image_proxy::Transport::DockerDaemon,
778 "myimage:tag",
779 ),
780 ];
781
782 for (transport, image, expected_transport, expected_name) in cases {
783 let imgref = ImageReference {
784 transport: transport.to_string(),
785 image: image.to_string(),
786 signature: None,
787 };
788 let proxy_ref = imgref.to_image_proxy_ref().unwrap();
789 assert_eq!(
790 proxy_ref.transport, expected_transport,
791 "transport mismatch for {transport}:{image}"
792 );
793 assert_eq!(
794 proxy_ref.name, expected_name,
795 "name mismatch for {transport}:{image}"
796 );
797 }
798 }
799
800 #[test]
801 fn test_unimplemented_oci_tagged_digested() {
802 let imgref = ImageReference {
803 image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
804 transport: "oci".to_string(),
805 signature: None
806 };
807 let canonicalized = imgref.clone().canonicalize().unwrap();
808 assert_eq!(imgref, canonicalized);
810 }
811
812 #[test]
813 fn test_parse_spec_v1_null() {
814 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
815 let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
816 assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
817 }
818
819 #[test]
820 fn test_parse_spec_v1a1_orig() {
821 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
822 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
823 assert_eq!(
824 host.spec.image.as_ref().unwrap().image.as_str(),
825 "quay.io/example/someimage:latest"
826 );
827 }
828
829 #[test]
830 fn test_parse_spec_v1a1() {
831 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
832 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
833 assert_eq!(
834 host.spec.image.as_ref().unwrap().image.as_str(),
835 "quay.io/otherexample/otherimage:latest"
836 );
837 assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
838 }
839
840 #[test]
841 fn test_parse_ostreeremote() {
842 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
843 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
844 assert_eq!(
845 host.spec.image.as_ref().unwrap().signature,
846 Some(ImageSignature::OstreeRemote("fedora".into()))
847 );
848 }
849
850 #[test]
851 fn test_display_imgref() {
852 let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
853 let s = OstreeImageReference::from_str(src).unwrap();
854 let s = ImageReference::from(s);
855 let displayed = format!("{s}");
856 assert_eq!(displayed.as_str(), src);
857 assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
859
860 let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
861 let s = OstreeImageReference::from_str(src).unwrap();
862 let s = ImageReference::from(s);
863 let displayed = format!("{s}");
864 assert_eq!(displayed.as_str(), src);
865 assert_eq!(format!("{s:#}"), src);
866 }
867
868 #[test]
869 fn test_store_from_str() {
870 use clap::ValueEnum;
871
872 assert!(Store::from_str("Ostree-Container", true).is_ok());
874 assert!(Store::from_str("OstrEeContAiner", true).is_ok());
875 assert!(Store::from_str("invalid", true).is_err());
876 }
877
878 #[test]
879 fn test_host_filter_to_slot() {
880 fn create_host() -> Host {
881 let mut host = Host::default();
882 host.status.staged = Some(default_boot_entry());
883 host.status.booted = Some(default_boot_entry());
884 host.status.rollback = Some(default_boot_entry());
885 host
886 }
887
888 fn default_boot_entry() -> BootEntry {
889 BootEntry {
890 image: None,
891 cached_update: None,
892 incompatible: false,
893 soft_reboot_capable: false,
894 pinned: false,
895 download_only: false,
896 store: None,
897 ostree: None,
898 composefs: None,
899 }
900 }
901
902 fn assert_host_state(
903 host: &Host,
904 staged: Option<BootEntry>,
905 booted: Option<BootEntry>,
906 rollback: Option<BootEntry>,
907 ) {
908 assert_eq!(host.status.staged, staged);
909 assert_eq!(host.status.booted, booted);
910 assert_eq!(host.status.rollback, rollback);
911 }
912
913 let mut host = create_host();
914 host.filter_to_slot(Slot::Staged);
915 assert_host_state(&host, Some(default_boot_entry()), None, None);
916
917 let mut host = create_host();
918 host.filter_to_slot(Slot::Booted);
919 assert_host_state(&host, None, Some(default_boot_entry()), None);
920
921 let mut host = create_host();
922 host.filter_to_slot(Slot::Rollback);
923 assert_host_state(&host, None, None, Some(default_boot_entry()));
924 }
925
926 #[test]
927 fn test_to_transport_image() {
928 let registry_ref = ImageReference {
930 transport: "registry".to_string(),
931 image: "quay.io/example/foo:latest".to_string(),
932 signature: None,
933 };
934 assert_eq!(
935 registry_ref.to_transport_image().unwrap(),
936 "quay.io/example/foo:latest"
937 );
938
939 let storage_ref = ImageReference {
941 transport: "containers-storage".to_string(),
942 image: "localhost/bootc".to_string(),
943 signature: None,
944 };
945 assert_eq!(
946 storage_ref.to_transport_image().unwrap(),
947 "containers-storage:localhost/bootc"
948 );
949
950 let oci_ref = ImageReference {
952 transport: "oci".to_string(),
953 image: "/path/to/image".to_string(),
954 signature: None,
955 };
956 assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
957 }
958}