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 Ukiboot,
247 None,
249}
250
251#[derive(Debug, PartialEq, Eq)]
252pub enum BootloaderKind {
253 BLSCompatible,
256 GRUBClassic,
258}
259
260impl Display for Bootloader {
261 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262 let string = match self {
263 Bootloader::Grub => "grub",
264 Bootloader::GrubCC => "grub-cc",
265 Bootloader::Systemd => "systemd",
266 Bootloader::Ukiboot => "ukiboot",
267 Bootloader::None => "none",
268 };
269
270 write!(f, "{}", string)
271 }
272}
273
274impl FromStr for Bootloader {
275 type Err = anyhow::Error;
276
277 fn from_str(value: &str) -> Result<Self> {
278 match value {
279 "grub" => Ok(Self::Grub),
280 "grub-cc" => Ok(Self::GrubCC),
281 "systemd" => Ok(Self::Systemd),
282 "ukiboot" => Ok(Self::Ukiboot),
283 "none" => Ok(Self::None),
284 unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
285 }
286 }
287}
288
289impl Bootloader {
290 pub(crate) fn kind(&self) -> Result<BootloaderKind> {
293 match self {
294 Bootloader::Grub => Ok(BootloaderKind::GRUBClassic),
295 Bootloader::Systemd | Bootloader::GrubCC => Ok(BootloaderKind::BLSCompatible),
296 Bootloader::Ukiboot | Bootloader::None => {
297 anyhow::bail!("Bootloader {self} does not use BLS entries")
298 }
299 }
300 }
301
302 pub(crate) fn is_bls_conformant(&self) -> bool {
307 matches!(self, Bootloader::Systemd)
308 }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
313#[serde(rename_all = "camelCase")]
314pub struct BootEntryComposefs {
315 pub verity: String,
317 pub boot_type: BootType,
319 pub bootloader: Bootloader,
321 pub boot_digest: Option<String>,
324 pub missing_verity_allowed: bool,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
330#[serde(rename_all = "camelCase")]
331pub struct BootEntry {
332 pub image: Option<ImageStatus>,
334 pub cached_update: Option<ImageStatus>,
336 pub incompatible: bool,
338 pub pinned: bool,
340 #[serde(default)]
342 pub soft_reboot_capable: bool,
343 #[serde(default)]
346 pub download_only: bool,
347 #[serde(default)]
349 pub store: Option<Store>,
350 pub ostree: Option<BootEntryOstree>,
352 pub composefs: Option<BootEntryComposefs>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
357#[serde(rename_all = "camelCase")]
358#[non_exhaustive]
359pub enum HostType {
362 BootcHost,
364}
365
366#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
368#[serde(rename_all = "camelCase")]
369pub struct FilesystemOverlay {
370 pub access_mode: FilesystemOverlayAccessMode,
372 pub persistence: FilesystemOverlayPersistence,
374}
375
376#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
378#[serde(rename_all = "camelCase")]
379pub enum FilesystemOverlayAccessMode {
380 ReadOnly,
382 ReadWrite,
384}
385
386impl Display for FilesystemOverlayAccessMode {
387 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388 match self {
389 FilesystemOverlayAccessMode::ReadOnly => write!(f, "read-only"),
390 FilesystemOverlayAccessMode::ReadWrite => write!(f, "read/write"),
391 }
392 }
393}
394
395#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
397#[serde(rename_all = "camelCase")]
398pub enum FilesystemOverlayPersistence {
399 Transient,
401 Persistent,
403}
404
405impl Display for FilesystemOverlayPersistence {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 match self {
408 FilesystemOverlayPersistence::Transient => write!(f, "transient"),
409 FilesystemOverlayPersistence::Persistent => write!(f, "persistent"),
410 }
411 }
412}
413
414pub(crate) fn deployment_unlocked_state_to_usr_overlay(
415 state: DeploymentUnlockedState,
416) -> Option<FilesystemOverlay> {
417 use FilesystemOverlayAccessMode::*;
418 use FilesystemOverlayPersistence::*;
419 match state {
420 DeploymentUnlockedState::None => None,
421 DeploymentUnlockedState::Development => Some(FilesystemOverlay {
422 access_mode: ReadWrite,
423 persistence: Transient,
424 }),
425 DeploymentUnlockedState::Hotfix => Some(FilesystemOverlay {
426 access_mode: ReadWrite,
427 persistence: Persistent,
428 }),
429 DeploymentUnlockedState::Transient => Some(FilesystemOverlay {
430 access_mode: ReadOnly,
431 persistence: Transient,
432 }),
433 _ => None,
434 }
435}
436
437impl Display for FilesystemOverlay {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 write!(f, "{}, {}", self.persistence, self.access_mode)
440 }
441}
442
443#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
445#[serde(rename_all = "camelCase")]
446pub struct HostStatus {
447 pub staged: Option<BootEntry>,
449 pub booted: Option<BootEntry>,
451 pub rollback: Option<BootEntry>,
453 #[serde(skip_serializing_if = "Vec::is_empty")]
455 #[serde(default)]
456 pub other_deployments: Vec<BootEntry>,
457 #[serde(default)]
459 pub rollback_queued: bool,
460
461 #[serde(rename = "type")]
463 pub ty: Option<HostType>,
464
465 pub usr_overlay: Option<FilesystemOverlay>,
467
468 #[serde(default)]
472 pub read_only: bool,
473}
474
475pub(crate) struct DeploymentEntry<'a> {
476 pub(crate) ty: Option<Slot>,
477 pub(crate) deployment: &'a BootEntryComposefs,
478 pub(crate) pinned: bool,
479 pub(crate) soft_reboot_capable: bool,
480}
481
482#[derive(Debug, Serialize)]
484#[serde(rename_all = "kebab-case")]
485pub(crate) struct ContainerInspect {
486 #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
488 pub(crate) image_type: Option<crate::kernel::ContainerImageType>,
489 pub(crate) kargs: Vec<String>,
491 pub(crate) kernel: Option<crate::kernel::Kernel>,
493}
494
495impl Host {
496 pub fn new(spec: HostSpec) -> Self {
498 let metadata = k8sapitypes::ObjectMeta {
499 name: Some(OBJECT_NAME.to_owned()),
500 ..Default::default()
501 };
502 Self {
503 resource: k8sapitypes::Resource {
504 api_version: API_VERSION.to_owned(),
505 kind: KIND.to_owned(),
506 metadata,
507 },
508 spec,
509 status: Default::default(),
510 }
511 }
512
513 pub fn filter_to_slot(&mut self, slot: Slot) {
515 match slot {
516 Slot::Staged => {
517 self.status.booted = None;
518 self.status.rollback = None;
519 }
520 Slot::Booted => {
521 self.status.staged = None;
522 self.status.rollback = None;
523 }
524 Slot::Rollback => {
525 self.status.staged = None;
526 self.status.booted = None;
527 }
528 }
529 }
530
531 pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
533 self.status
534 .staged
535 .iter()
536 .chain(self.status.booted.iter())
537 .chain(self.status.rollback.iter())
538 .chain(self.status.other_deployments.iter())
539 .collect::<Vec<_>>()
540 }
541
542 pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
543 let cfs = self
544 .status
545 .booted
546 .as_ref()
547 .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
548 .require_composefs()?;
549
550 Ok(cfs)
551 }
552
553 #[fn_error_context::context("Getting all composefs deployments")]
555 pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
556 let mut all_deps = vec![];
557
558 let booted = self.require_composefs_booted()?;
559 all_deps.push(DeploymentEntry {
560 ty: Some(Slot::Booted),
561 deployment: booted,
562 pinned: false,
563 soft_reboot_capable: false,
564 });
565
566 if let Some(staged) = &self.status.staged {
567 all_deps.push(DeploymentEntry {
568 ty: Some(Slot::Staged),
569 deployment: staged.require_composefs()?,
570 pinned: false,
571 soft_reboot_capable: staged.soft_reboot_capable,
572 });
573 }
574
575 if let Some(rollback) = &self.status.rollback {
576 all_deps.push(DeploymentEntry {
577 ty: Some(Slot::Rollback),
578 deployment: rollback.require_composefs()?,
579 pinned: false,
580 soft_reboot_capable: rollback.soft_reboot_capable,
581 });
582 }
583
584 for pinned in &self.status.other_deployments {
585 all_deps.push(DeploymentEntry {
586 ty: None,
587 deployment: pinned.require_composefs()?,
588 pinned: true,
589 soft_reboot_capable: pinned.soft_reboot_capable,
590 });
591 }
592
593 Ok(all_deps)
594 }
595}
596
597impl Default for Host {
598 fn default() -> Self {
599 Self::new(Default::default())
600 }
601}
602
603impl HostSpec {
604 pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
607 let rollback = self.boot_order != new.boot_order;
608 let image_change = self.image != new.image;
609 if rollback && image_change {
610 anyhow::bail!("Invalid state transition: rollback and image change");
611 }
612 Ok(())
613 }
614}
615
616impl BootOrder {
617 pub(crate) fn swap(&self) -> Self {
618 match self {
619 BootOrder::Default => BootOrder::Rollback,
620 BootOrder::Rollback => BootOrder::Default,
621 }
622 }
623}
624
625impl Display for ImageReference {
626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627 if f.alternate() && self.signature.is_none() && self.transport == "registry" {
629 self.image.fmt(f)
630 } else {
631 let ostree_imgref = OstreeImageReference::from(self.clone());
632 ostree_imgref.fmt(f)
633 }
634 }
635}
636
637impl ImageStatus {
638 pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
639 use std::str::FromStr;
640 Ok(Digest::from_str(&self.image_digest)?)
641 }
642}
643
644#[cfg(test)]
645mod tests {
646 use std::str::FromStr;
647
648 use super::*;
649
650 #[test]
651 fn test_canonicalize_reference() {
652 let passthrough = [
654 ("quay.io/example/someimage:latest"),
655 ("quay.io/example/someimage"),
656 ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
657 ];
658 let mapped = [
659 (
660 "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
661 "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
662 ),
663 (
664 "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
665 "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
666 ),
667 ];
668 for &v in passthrough.iter() {
669 let reference = Reference::from_str(v).unwrap();
670 assert!(reference.tag().is_none() || reference.digest().is_none());
671 assert!(canonicalize_reference(reference).is_none());
672 }
673 for &(initial, expected) in mapped.iter() {
674 let reference = Reference::from_str(initial).unwrap();
675 assert!(reference.tag().is_some());
676 assert!(reference.digest().is_some());
677 let canonicalized = canonicalize_reference(reference).unwrap();
678 assert_eq!(canonicalized.to_string(), expected);
679 }
680 }
681
682 #[test]
683 fn test_image_reference_canonicalize() {
684 let sample_digest =
685 "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
686
687 let test_cases = [
688 (
690 format!("quay.io/example/someimage:latest@{sample_digest}"),
691 format!("quay.io/example/someimage@{sample_digest}"),
692 "registry",
693 ),
694 (
696 format!("quay.io/example/someimage@{sample_digest}"),
697 format!("quay.io/example/someimage@{sample_digest}"),
698 "registry",
699 ),
700 (
702 "quay.io/example/someimage:latest".to_string(),
703 "quay.io/example/someimage:latest".to_string(),
704 "registry",
705 ),
706 (
708 "quay.io/example/someimage".to_string(),
709 "quay.io/example/someimage".to_string(),
710 "registry",
711 ),
712 (
715 "localhost/someimage:latest".to_string(),
716 "localhost/someimage:latest".to_string(),
717 "registry",
718 ),
719 (
720 format!("localhost/someimage:latest@{sample_digest}"),
721 format!("localhost/someimage@{sample_digest}"),
722 "registry",
723 ),
724 (
726 format!("quay.io/example/someimage:latest@{sample_digest}"),
727 format!("quay.io/example/someimage:latest@{sample_digest}"),
728 "containers-storage",
729 ),
730 (
731 "/path/to/dir:latest".to_string(),
732 "/path/to/dir:latest".to_string(),
733 "oci",
734 ),
735 (
736 "/tmp/repo".to_string(),
737 "/tmp/repo".to_string(),
738 "oci-archive",
739 ),
740 (
741 "/tmp/image-dir".to_string(),
742 "/tmp/image-dir".to_string(),
743 "dir",
744 ),
745 ];
746
747 for (initial, expected, transport) in test_cases {
748 let imgref = ImageReference {
749 image: initial.to_string(),
750 transport: transport.to_string(),
751 signature: None,
752 };
753
754 let canonicalized = imgref.canonicalize();
755 if let Err(e) = canonicalized {
756 panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
757 }
758 let canonicalized = canonicalized.unwrap();
759 assert_eq!(
760 canonicalized.image, expected,
761 "Mismatch for transport {transport}"
762 );
763 assert_eq!(canonicalized.transport, transport);
764 assert_eq!(canonicalized.signature, None);
765 }
766 }
767
768 #[test]
769 fn test_to_image_proxy_ref() {
770 use ostree_ext::containers_image_proxy;
771
772 let cases = [
773 (
774 "registry",
775 "quay.io/example/image:latest",
776 containers_image_proxy::Transport::Registry,
777 "quay.io/example/image:latest",
778 ),
779 (
780 "containers-storage",
781 "localhost/bootc",
782 containers_image_proxy::Transport::ContainerStorage,
783 "localhost/bootc",
784 ),
785 (
786 "oci",
787 "/var/tmp/bootc-oci",
788 containers_image_proxy::Transport::OciDir,
789 "/var/tmp/bootc-oci",
790 ),
791 (
792 "docker-daemon",
793 "myimage:tag",
794 containers_image_proxy::Transport::DockerDaemon,
795 "myimage:tag",
796 ),
797 ];
798
799 for (transport, image, expected_transport, expected_name) in cases {
800 let imgref = ImageReference {
801 transport: transport.to_string(),
802 image: image.to_string(),
803 signature: None,
804 };
805 let proxy_ref = imgref.to_image_proxy_ref().unwrap();
806 assert_eq!(
807 proxy_ref.transport, expected_transport,
808 "transport mismatch for {transport}:{image}"
809 );
810 assert_eq!(
811 proxy_ref.name, expected_name,
812 "name mismatch for {transport}:{image}"
813 );
814 }
815 }
816
817 #[test]
818 fn test_unimplemented_oci_tagged_digested() {
819 let imgref = ImageReference {
820 image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
821 transport: "oci".to_string(),
822 signature: None
823 };
824 let canonicalized = imgref.clone().canonicalize().unwrap();
825 assert_eq!(imgref, canonicalized);
827 }
828
829 #[test]
830 fn test_parse_spec_v1_null() {
831 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
832 let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
833 assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
834 }
835
836 #[test]
837 fn test_parse_spec_v1a1_orig() {
838 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
839 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
840 assert_eq!(
841 host.spec.image.as_ref().unwrap().image.as_str(),
842 "quay.io/example/someimage:latest"
843 );
844 }
845
846 #[test]
847 fn test_parse_spec_v1a1() {
848 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
849 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
850 assert_eq!(
851 host.spec.image.as_ref().unwrap().image.as_str(),
852 "quay.io/otherexample/otherimage:latest"
853 );
854 assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
855 }
856
857 #[test]
858 fn test_parse_ostreeremote() {
859 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
860 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
861 assert_eq!(
862 host.spec.image.as_ref().unwrap().signature,
863 Some(ImageSignature::OstreeRemote("fedora".into()))
864 );
865 }
866
867 #[test]
868 fn test_display_imgref() {
869 let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
870 let s = OstreeImageReference::from_str(src).unwrap();
871 let s = ImageReference::from(s);
872 let displayed = format!("{s}");
873 assert_eq!(displayed.as_str(), src);
874 assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
876
877 let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
878 let s = OstreeImageReference::from_str(src).unwrap();
879 let s = ImageReference::from(s);
880 let displayed = format!("{s}");
881 assert_eq!(displayed.as_str(), src);
882 assert_eq!(format!("{s:#}"), src);
883 }
884
885 #[test]
886 fn test_store_from_str() {
887 use clap::ValueEnum;
888
889 assert!(Store::from_str("Ostree-Container", true).is_ok());
891 assert!(Store::from_str("OstrEeContAiner", true).is_ok());
892 assert!(Store::from_str("invalid", true).is_err());
893 }
894
895 #[test]
896 fn test_host_filter_to_slot() {
897 fn create_host() -> Host {
898 let mut host = Host::default();
899 host.status.staged = Some(default_boot_entry());
900 host.status.booted = Some(default_boot_entry());
901 host.status.rollback = Some(default_boot_entry());
902 host
903 }
904
905 fn default_boot_entry() -> BootEntry {
906 BootEntry {
907 image: None,
908 cached_update: None,
909 incompatible: false,
910 soft_reboot_capable: false,
911 pinned: false,
912 download_only: false,
913 store: None,
914 ostree: None,
915 composefs: None,
916 }
917 }
918
919 fn assert_host_state(
920 host: &Host,
921 staged: Option<BootEntry>,
922 booted: Option<BootEntry>,
923 rollback: Option<BootEntry>,
924 ) {
925 assert_eq!(host.status.staged, staged);
926 assert_eq!(host.status.booted, booted);
927 assert_eq!(host.status.rollback, rollback);
928 }
929
930 let mut host = create_host();
931 host.filter_to_slot(Slot::Staged);
932 assert_host_state(&host, Some(default_boot_entry()), None, None);
933
934 let mut host = create_host();
935 host.filter_to_slot(Slot::Booted);
936 assert_host_state(&host, None, Some(default_boot_entry()), None);
937
938 let mut host = create_host();
939 host.filter_to_slot(Slot::Rollback);
940 assert_host_state(&host, None, None, Some(default_boot_entry()));
941 }
942
943 #[test]
944 fn test_to_transport_image() {
945 let registry_ref = ImageReference {
947 transport: "registry".to_string(),
948 image: "quay.io/example/foo:latest".to_string(),
949 signature: None,
950 };
951 assert_eq!(
952 registry_ref.to_transport_image().unwrap(),
953 "quay.io/example/foo:latest"
954 );
955
956 let storage_ref = ImageReference {
958 transport: "containers-storage".to_string(),
959 image: "localhost/bootc".to_string(),
960 signature: None,
961 };
962 assert_eq!(
963 storage_ref.to_transport_image().unwrap(),
964 "containers-storage:localhost/bootc"
965 );
966
967 let oci_ref = ImageReference {
969 transport: "oci".to_string(),
970 image: "/path/to/image".to_string(),
971 signature: None,
972 };
973 assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
974 }
975}