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_transport_image(&self) -> Result<String> {
146 if self.transport()? == Transport::Registry {
147 Ok(self.image.clone())
149 } else {
150 Ok(format!("{}:{}", self.transport, self.image))
152 }
153 }
154
155 pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
161 let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
163 let new_ref = Reference::with_tag(
165 reference.registry().to_string(),
166 reference.repository().to_string(),
167 new_tag.to_string(),
168 );
169 new_ref.to_string()
170 } else {
171 let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);
174
175 let image_part = image_without_digest
177 .rsplit_once(':')
178 .map(|(base, _tag)| base)
179 .unwrap_or(image_without_digest);
180
181 format!("{}:{}", image_part, new_tag)
182 };
183
184 Ok(ImageReference {
185 image: new_image,
186 transport: self.transport.clone(),
187 signature: self.signature.clone(),
188 })
189 }
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct ImageStatus {
196 pub image: ImageReference,
198 pub version: Option<String>,
200 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
202 pub image_digest: String,
204 pub architecture: String,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
210#[serde(rename_all = "camelCase")]
211pub struct BootEntryOstree {
212 pub stateroot: String,
214 pub checksum: String,
216 pub deploy_serial: u32,
218}
219
220#[derive(
222 clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
223)]
224#[serde(rename_all = "kebab-case")]
225pub enum Bootloader {
226 #[default]
228 Grub,
229 Systemd,
231 None,
233}
234
235impl Display for Bootloader {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 let string = match self {
238 Bootloader::Grub => "grub",
239 Bootloader::Systemd => "systemd",
240 Bootloader::None => "none",
241 };
242
243 write!(f, "{}", string)
244 }
245}
246
247impl FromStr for Bootloader {
248 type Err = anyhow::Error;
249
250 fn from_str(value: &str) -> Result<Self> {
251 match value {
252 "grub" => Ok(Self::Grub),
253 "systemd" => Ok(Self::Systemd),
254 "none" => Ok(Self::None),
255 unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
256 }
257 }
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
262#[serde(rename_all = "camelCase")]
263pub struct BootEntryComposefs {
264 pub verity: String,
266 pub boot_type: BootType,
268 pub bootloader: Bootloader,
270 pub boot_digest: Option<String>,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub struct BootEntry {
279 pub image: Option<ImageStatus>,
281 pub cached_update: Option<ImageStatus>,
283 pub incompatible: bool,
285 pub pinned: bool,
287 #[serde(default)]
289 pub soft_reboot_capable: bool,
290 #[serde(default)]
293 pub download_only: bool,
294 #[serde(default)]
296 pub store: Option<Store>,
297 pub ostree: Option<BootEntryOstree>,
299 pub composefs: Option<BootEntryComposefs>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
304#[serde(rename_all = "camelCase")]
305#[non_exhaustive]
306pub enum HostType {
309 BootcHost,
311}
312
313#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
315#[serde(rename_all = "camelCase")]
316pub struct FilesystemOverlay {
317 pub access_mode: FilesystemOverlayAccessMode,
319 pub persistence: FilesystemOverlayPersistence,
321}
322
323#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
325#[serde(rename_all = "camelCase")]
326pub enum FilesystemOverlayAccessMode {
327 ReadOnly,
329 ReadWrite,
331}
332
333impl Display for FilesystemOverlayAccessMode {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 match self {
336 FilesystemOverlayAccessMode::ReadOnly => write!(f, "read-only"),
337 FilesystemOverlayAccessMode::ReadWrite => write!(f, "read/write"),
338 }
339 }
340}
341
342#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
344#[serde(rename_all = "camelCase")]
345pub enum FilesystemOverlayPersistence {
346 Transient,
348 Persistent,
350}
351
352impl Display for FilesystemOverlayPersistence {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 match self {
355 FilesystemOverlayPersistence::Transient => write!(f, "transient"),
356 FilesystemOverlayPersistence::Persistent => write!(f, "persistent"),
357 }
358 }
359}
360
361pub(crate) fn deployment_unlocked_state_to_usr_overlay(
362 state: DeploymentUnlockedState,
363) -> Option<FilesystemOverlay> {
364 use FilesystemOverlayAccessMode::*;
365 use FilesystemOverlayPersistence::*;
366 match state {
367 DeploymentUnlockedState::None => None,
368 DeploymentUnlockedState::Development => Some(FilesystemOverlay {
369 access_mode: ReadWrite,
370 persistence: Transient,
371 }),
372 DeploymentUnlockedState::Hotfix => Some(FilesystemOverlay {
373 access_mode: ReadWrite,
374 persistence: Persistent,
375 }),
376 DeploymentUnlockedState::Transient => Some(FilesystemOverlay {
377 access_mode: ReadOnly,
378 persistence: Transient,
379 }),
380 _ => None,
381 }
382}
383
384impl Display for FilesystemOverlay {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 write!(f, "{}, {}", self.persistence, self.access_mode)
387 }
388}
389
390#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
392#[serde(rename_all = "camelCase")]
393pub struct HostStatus {
394 pub staged: Option<BootEntry>,
396 pub booted: Option<BootEntry>,
398 pub rollback: Option<BootEntry>,
400 #[serde(skip_serializing_if = "Vec::is_empty")]
402 #[serde(default)]
403 pub other_deployments: Vec<BootEntry>,
404 #[serde(default)]
406 pub rollback_queued: bool,
407
408 #[serde(rename = "type")]
410 pub ty: Option<HostType>,
411
412 pub usr_overlay: Option<FilesystemOverlay>,
414}
415
416pub(crate) struct DeploymentEntry<'a> {
417 pub(crate) ty: Option<Slot>,
418 pub(crate) deployment: &'a BootEntryComposefs,
419 pub(crate) pinned: bool,
420 pub(crate) soft_reboot_capable: bool,
421}
422
423#[derive(Debug, Serialize)]
425#[serde(rename_all = "kebab-case")]
426pub(crate) struct ContainerInspect {
427 pub(crate) kargs: Vec<String>,
429 pub(crate) kernel: Option<crate::kernel::Kernel>,
431}
432
433impl Host {
434 pub fn new(spec: HostSpec) -> Self {
436 let metadata = k8sapitypes::ObjectMeta {
437 name: Some(OBJECT_NAME.to_owned()),
438 ..Default::default()
439 };
440 Self {
441 resource: k8sapitypes::Resource {
442 api_version: API_VERSION.to_owned(),
443 kind: KIND.to_owned(),
444 metadata,
445 },
446 spec,
447 status: Default::default(),
448 }
449 }
450
451 pub fn filter_to_slot(&mut self, slot: Slot) {
453 match slot {
454 Slot::Staged => {
455 self.status.booted = None;
456 self.status.rollback = None;
457 }
458 Slot::Booted => {
459 self.status.staged = None;
460 self.status.rollback = None;
461 }
462 Slot::Rollback => {
463 self.status.staged = None;
464 self.status.booted = None;
465 }
466 }
467 }
468
469 pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
471 self.status
472 .staged
473 .iter()
474 .chain(self.status.booted.iter())
475 .chain(self.status.rollback.iter())
476 .chain(self.status.other_deployments.iter())
477 .collect::<Vec<_>>()
478 }
479
480 pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
481 let cfs = self
482 .status
483 .booted
484 .as_ref()
485 .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
486 .require_composefs()?;
487
488 Ok(cfs)
489 }
490
491 #[fn_error_context::context("Getting all composefs deployments")]
493 pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
494 let mut all_deps = vec![];
495
496 let booted = self.require_composefs_booted()?;
497 all_deps.push(DeploymentEntry {
498 ty: Some(Slot::Booted),
499 deployment: booted,
500 pinned: false,
501 soft_reboot_capable: false,
502 });
503
504 if let Some(staged) = &self.status.staged {
505 all_deps.push(DeploymentEntry {
506 ty: Some(Slot::Staged),
507 deployment: staged.require_composefs()?,
508 pinned: false,
509 soft_reboot_capable: staged.soft_reboot_capable,
510 });
511 }
512
513 if let Some(rollback) = &self.status.rollback {
514 all_deps.push(DeploymentEntry {
515 ty: Some(Slot::Rollback),
516 deployment: rollback.require_composefs()?,
517 pinned: false,
518 soft_reboot_capable: rollback.soft_reboot_capable,
519 });
520 }
521
522 for pinned in &self.status.other_deployments {
523 all_deps.push(DeploymentEntry {
524 ty: None,
525 deployment: pinned.require_composefs()?,
526 pinned: true,
527 soft_reboot_capable: pinned.soft_reboot_capable,
528 });
529 }
530
531 Ok(all_deps)
532 }
533}
534
535impl Default for Host {
536 fn default() -> Self {
537 Self::new(Default::default())
538 }
539}
540
541impl HostSpec {
542 pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
545 let rollback = self.boot_order != new.boot_order;
546 let image_change = self.image != new.image;
547 if rollback && image_change {
548 anyhow::bail!("Invalid state transition: rollback and image change");
549 }
550 Ok(())
551 }
552}
553
554impl BootOrder {
555 pub(crate) fn swap(&self) -> Self {
556 match self {
557 BootOrder::Default => BootOrder::Rollback,
558 BootOrder::Rollback => BootOrder::Default,
559 }
560 }
561}
562
563impl Display for ImageReference {
564 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
565 if f.alternate() && self.signature.is_none() && self.transport == "registry" {
567 self.image.fmt(f)
568 } else {
569 let ostree_imgref = OstreeImageReference::from(self.clone());
570 ostree_imgref.fmt(f)
571 }
572 }
573}
574
575impl ImageStatus {
576 pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
577 use std::str::FromStr;
578 Ok(Digest::from_str(&self.image_digest)?)
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use std::str::FromStr;
585
586 use super::*;
587
588 #[test]
589 fn test_canonicalize_reference() {
590 let passthrough = [
592 ("quay.io/example/someimage:latest"),
593 ("quay.io/example/someimage"),
594 ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
595 ];
596 let mapped = [
597 (
598 "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
599 "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
600 ),
601 (
602 "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
603 "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
604 ),
605 ];
606 for &v in passthrough.iter() {
607 let reference = Reference::from_str(v).unwrap();
608 assert!(reference.tag().is_none() || reference.digest().is_none());
609 assert!(canonicalize_reference(reference).is_none());
610 }
611 for &(initial, expected) in mapped.iter() {
612 let reference = Reference::from_str(initial).unwrap();
613 assert!(reference.tag().is_some());
614 assert!(reference.digest().is_some());
615 let canonicalized = canonicalize_reference(reference).unwrap();
616 assert_eq!(canonicalized.to_string(), expected);
617 }
618 }
619
620 #[test]
621 fn test_image_reference_canonicalize() {
622 let sample_digest =
623 "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
624
625 let test_cases = [
626 (
628 format!("quay.io/example/someimage:latest@{sample_digest}"),
629 format!("quay.io/example/someimage@{sample_digest}"),
630 "registry",
631 ),
632 (
634 format!("quay.io/example/someimage@{sample_digest}"),
635 format!("quay.io/example/someimage@{sample_digest}"),
636 "registry",
637 ),
638 (
640 "quay.io/example/someimage:latest".to_string(),
641 "quay.io/example/someimage:latest".to_string(),
642 "registry",
643 ),
644 (
646 "quay.io/example/someimage".to_string(),
647 "quay.io/example/someimage".to_string(),
648 "registry",
649 ),
650 (
653 "localhost/someimage:latest".to_string(),
654 "localhost/someimage:latest".to_string(),
655 "registry",
656 ),
657 (
658 format!("localhost/someimage:latest@{sample_digest}"),
659 format!("localhost/someimage@{sample_digest}"),
660 "registry",
661 ),
662 (
664 format!("quay.io/example/someimage:latest@{sample_digest}"),
665 format!("quay.io/example/someimage:latest@{sample_digest}"),
666 "containers-storage",
667 ),
668 (
669 "/path/to/dir:latest".to_string(),
670 "/path/to/dir:latest".to_string(),
671 "oci",
672 ),
673 (
674 "/tmp/repo".to_string(),
675 "/tmp/repo".to_string(),
676 "oci-archive",
677 ),
678 (
679 "/tmp/image-dir".to_string(),
680 "/tmp/image-dir".to_string(),
681 "dir",
682 ),
683 ];
684
685 for (initial, expected, transport) in test_cases {
686 let imgref = ImageReference {
687 image: initial.to_string(),
688 transport: transport.to_string(),
689 signature: None,
690 };
691
692 let canonicalized = imgref.canonicalize();
693 if let Err(e) = canonicalized {
694 panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
695 }
696 let canonicalized = canonicalized.unwrap();
697 assert_eq!(
698 canonicalized.image, expected,
699 "Mismatch for transport {transport}"
700 );
701 assert_eq!(canonicalized.transport, transport);
702 assert_eq!(canonicalized.signature, None);
703 }
704 }
705
706 #[test]
707 fn test_unimplemented_oci_tagged_digested() {
708 let imgref = ImageReference {
709 image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
710 transport: "oci".to_string(),
711 signature: None
712 };
713 let canonicalized = imgref.clone().canonicalize().unwrap();
714 assert_eq!(imgref, canonicalized);
716 }
717
718 #[test]
719 fn test_parse_spec_v1_null() {
720 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
721 let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
722 assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
723 }
724
725 #[test]
726 fn test_parse_spec_v1a1_orig() {
727 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
728 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
729 assert_eq!(
730 host.spec.image.as_ref().unwrap().image.as_str(),
731 "quay.io/example/someimage:latest"
732 );
733 }
734
735 #[test]
736 fn test_parse_spec_v1a1() {
737 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
738 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
739 assert_eq!(
740 host.spec.image.as_ref().unwrap().image.as_str(),
741 "quay.io/otherexample/otherimage:latest"
742 );
743 assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
744 }
745
746 #[test]
747 fn test_parse_ostreeremote() {
748 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
749 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
750 assert_eq!(
751 host.spec.image.as_ref().unwrap().signature,
752 Some(ImageSignature::OstreeRemote("fedora".into()))
753 );
754 }
755
756 #[test]
757 fn test_display_imgref() {
758 let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
759 let s = OstreeImageReference::from_str(src).unwrap();
760 let s = ImageReference::from(s);
761 let displayed = format!("{s}");
762 assert_eq!(displayed.as_str(), src);
763 assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
765
766 let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
767 let s = OstreeImageReference::from_str(src).unwrap();
768 let s = ImageReference::from(s);
769 let displayed = format!("{s}");
770 assert_eq!(displayed.as_str(), src);
771 assert_eq!(format!("{s:#}"), src);
772 }
773
774 #[test]
775 fn test_store_from_str() {
776 use clap::ValueEnum;
777
778 assert!(Store::from_str("Ostree-Container", true).is_ok());
780 assert!(Store::from_str("OstrEeContAiner", true).is_ok());
781 assert!(Store::from_str("invalid", true).is_err());
782 }
783
784 #[test]
785 fn test_host_filter_to_slot() {
786 fn create_host() -> Host {
787 let mut host = Host::default();
788 host.status.staged = Some(default_boot_entry());
789 host.status.booted = Some(default_boot_entry());
790 host.status.rollback = Some(default_boot_entry());
791 host
792 }
793
794 fn default_boot_entry() -> BootEntry {
795 BootEntry {
796 image: None,
797 cached_update: None,
798 incompatible: false,
799 soft_reboot_capable: false,
800 pinned: false,
801 download_only: false,
802 store: None,
803 ostree: None,
804 composefs: None,
805 }
806 }
807
808 fn assert_host_state(
809 host: &Host,
810 staged: Option<BootEntry>,
811 booted: Option<BootEntry>,
812 rollback: Option<BootEntry>,
813 ) {
814 assert_eq!(host.status.staged, staged);
815 assert_eq!(host.status.booted, booted);
816 assert_eq!(host.status.rollback, rollback);
817 }
818
819 let mut host = create_host();
820 host.filter_to_slot(Slot::Staged);
821 assert_host_state(&host, Some(default_boot_entry()), None, None);
822
823 let mut host = create_host();
824 host.filter_to_slot(Slot::Booted);
825 assert_host_state(&host, None, Some(default_boot_entry()), None);
826
827 let mut host = create_host();
828 host.filter_to_slot(Slot::Rollback);
829 assert_host_state(&host, None, None, Some(default_boot_entry()));
830 }
831
832 #[test]
833 fn test_to_transport_image() {
834 let registry_ref = ImageReference {
836 transport: "registry".to_string(),
837 image: "quay.io/example/foo:latest".to_string(),
838 signature: None,
839 };
840 assert_eq!(
841 registry_ref.to_transport_image().unwrap(),
842 "quay.io/example/foo:latest"
843 );
844
845 let storage_ref = ImageReference {
847 transport: "containers-storage".to_string(),
848 image: "localhost/bootc".to_string(),
849 signature: None,
850 };
851 assert_eq!(
852 storage_ref.to_transport_image().unwrap(),
853 "containers-storage:localhost/bootc"
854 );
855
856 let oci_ref = ImageReference {
858 transport: "oci".to_string(),
859 image: "/path/to/image".to_string(),
860 signature: None,
861 };
862 assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
863 }
864}