bootc_lib/
spec.rs

1//! The definition for host system state.
2
3use 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";
20/// The default object name we use; there's only one.
21pub(crate) const OBJECT_NAME: &str = "host";
22
23#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
24#[serde(rename_all = "camelCase")]
25/// The core host definition
26pub struct Host {
27    /// Metadata
28    #[serde(flatten)]
29    pub resource: k8sapitypes::Resource,
30    /// The spec
31    #[serde(default)]
32    pub spec: HostSpec,
33    /// The status
34    #[serde(default)]
35    pub status: HostStatus,
36}
37
38/// Configuration for system boot ordering.
39
40#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
41#[serde(rename_all = "camelCase")]
42pub enum BootOrder {
43    /// The staged or booted deployment will be booted next
44    #[default]
45    Default,
46    /// The rollback deployment will be booted next
47    Rollback,
48}
49
50#[derive(
51    clap::ValueEnum, Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, Default,
52)]
53#[serde(rename_all = "camelCase")]
54/// The container storage backend
55pub enum Store {
56    /// Use the ostree-container storage backend.
57    #[default]
58    #[value(alias = "ostreecontainer")] // default is kebab-case
59    OstreeContainer,
60}
61
62#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
63#[serde(rename_all = "camelCase")]
64/// The host specification
65pub struct HostSpec {
66    /// The host image
67    pub image: Option<ImageReference>,
68    /// If set, and there is a rollback deployment, it will be set for the next boot.
69    #[serde(default)]
70    pub boot_order: BootOrder,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
74/// An image signature
75#[serde(rename_all = "camelCase")]
76pub enum ImageSignature {
77    /// Fetches will use the named ostree remote for signature verification of the ostree commit.
78    OstreeRemote(String),
79    /// Fetches will defer to the `containers-policy.json`, but we make a best effort to reject `default: insecureAcceptAnything` policy.
80    ContainerPolicy,
81    /// No signature verification will be performed
82    Insecure,
83}
84
85/// A container image reference with attached transport and signature verification
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct ImageReference {
89    /// The container image reference
90    pub image: String,
91    /// The container image transport
92    pub transport: String,
93    /// Signature verification type
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub signature: Option<ImageSignature>,
96}
97
98/// If the reference is in :tag@digest form, strip the tag.
99fn canonicalize_reference(reference: Reference) -> Option<Reference> {
100    // No tag? Just pass through.
101    reference.tag()?;
102
103    // No digest? Also pass through.
104    let digest = reference.digest()?;
105    // Otherwise, replace with the digest
106    Some(reference.clone_with_digest(digest.to_owned()))
107}
108
109impl ImageReference {
110    /// Returns a canonicalized version of this image reference, preferring the digest over the tag if both are present.
111    pub fn canonicalize(self) -> Result<Self> {
112        // TODO maintain a proper transport enum in the spec here
113        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                // Check if the image reference needs canonicicalization
119                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                // For other transports, we don't do any canonicalization
132                Ok(self)
133            }
134        }
135    }
136
137    /// Parse the transport string into a Transport enum.
138    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    /// Convert to a container reference string suitable for use with container storage APIs.
144    /// For registry transport, returns just the image name. For other transports, prepends the transport.
145    pub fn to_transport_image(&self) -> Result<String> {
146        if self.transport()? == Transport::Registry {
147            // For registry transport, the image name is already in the right format
148            Ok(self.image.clone())
149        } else {
150            // For other transports (containers-storage, oci, etc.), prepend the transport
151            Ok(format!("{}:{}", self.transport, self.image))
152        }
153    }
154
155    /// Derive a new image reference by replacing the tag.
156    ///
157    /// For transports with parseable image references (registry, containers-storage),
158    /// uses the OCI Reference API to properly handle tag replacement.
159    /// For other transports (oci, etc.), falls back to string manipulation.
160    pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
161        // Try to parse as an OCI Reference (works for registry and containers-storage)
162        let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
163            // Use the proper OCI API to replace the tag
164            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            // For other transports like oci: with filesystem paths,
172            // strip any digest first, then replace tag via string manipulation
173            let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);
174
175            // Split on last ':' to separate image:tag
176            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/// The status of the booted image
193#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
194#[serde(rename_all = "camelCase")]
195pub struct ImageStatus {
196    /// The currently booted image
197    pub image: ImageReference,
198    /// The version string, if any
199    pub version: Option<String>,
200    /// The build timestamp, if any
201    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
202    /// The digest of the fetched image (e.g. sha256:a0...);
203    pub image_digest: String,
204    /// The hardware architecture of this image
205    pub architecture: String,
206}
207
208/// A bootable entry
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
210#[serde(rename_all = "camelCase")]
211pub struct BootEntryOstree {
212    /// The name of the storage for /etc and /var content
213    pub stateroot: String,
214    /// The ostree commit checksum
215    pub checksum: String,
216    /// The deployment serial
217    pub deploy_serial: u32,
218}
219
220/// Bootloader type to determine whether system was booted via Grub or Systemd
221#[derive(
222    clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
223)]
224#[serde(rename_all = "kebab-case")]
225pub enum Bootloader {
226    /// Use Grub as the bootloader
227    #[default]
228    Grub,
229    /// Use SystemdBoot as the bootloader
230    Systemd,
231    /// Don't use a bootloader managed by bootc
232    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/// A bootable entry
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
262#[serde(rename_all = "camelCase")]
263pub struct BootEntryComposefs {
264    /// The erofs verity
265    pub verity: String,
266    /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry
267    pub boot_type: BootType,
268    /// Whether we boot using systemd or grub
269    pub bootloader: Bootloader,
270    /// The sha256sum of vmlinuz + initrd
271    /// Only `Some` for Type1 boot entries
272    pub boot_digest: Option<String>,
273}
274
275/// A bootable entry
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
277#[serde(rename_all = "camelCase")]
278pub struct BootEntry {
279    /// The image reference
280    pub image: Option<ImageStatus>,
281    /// The last fetched cached update metadata
282    pub cached_update: Option<ImageStatus>,
283    /// Whether this boot entry is not compatible (has origin changes bootc does not understand)
284    pub incompatible: bool,
285    /// Whether this entry will be subject to garbage collection
286    pub pinned: bool,
287    /// This is true if (relative to the booted system) this is a possible target for a soft reboot
288    #[serde(default)]
289    pub soft_reboot_capable: bool,
290    /// Whether this deployment is in download-only mode (prevented from automatic finalization on shutdown).
291    /// This is set via --download-only on the CLI.
292    #[serde(default)]
293    pub download_only: bool,
294    /// The container storage backend
295    #[serde(default)]
296    pub store: Option<Store>,
297    /// If this boot entry is ostree based, the corresponding state
298    pub ostree: Option<BootEntryOstree>,
299    /// If this boot entry is composefs based, the corresponding state
300    pub composefs: Option<BootEntryComposefs>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
304#[serde(rename_all = "camelCase")]
305#[non_exhaustive]
306/// The detected type of running system.  Note that this is not exhaustive
307/// and new variants may be added in the future.
308pub enum HostType {
309    /// The current system is deployed in a bootc compatible way.
310    BootcHost,
311}
312
313/// Details of an overlay filesystem: read-only or read/write, persistent or transient.
314#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
315#[serde(rename_all = "camelCase")]
316pub struct FilesystemOverlay {
317    /// Whether the overlay is read-only or read/write
318    pub access_mode: FilesystemOverlayAccessMode,
319    /// Whether the overlay will persist across reboots
320    pub persistence: FilesystemOverlayPersistence,
321}
322
323/// The permissions mode of a /usr overlay
324#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
325#[serde(rename_all = "camelCase")]
326pub enum FilesystemOverlayAccessMode {
327    /// The overlay is mounted read-only
328    ReadOnly,
329    /// The overlay is mounted read/write
330    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/// The persistence mode of a /usr overlay
343#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
344#[serde(rename_all = "camelCase")]
345pub enum FilesystemOverlayPersistence {
346    /// Changes are temporary and will be lost on reboot
347    Transient,
348    /// Changes persist across reboots
349    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/// The status of the host system
391#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
392#[serde(rename_all = "camelCase")]
393pub struct HostStatus {
394    /// The staged image for the next boot
395    pub staged: Option<BootEntry>,
396    /// The booted image; this will be unset if the host is not bootc compatible.
397    pub booted: Option<BootEntry>,
398    /// The previously booted image
399    pub rollback: Option<BootEntry>,
400    /// Other deployments (i.e. pinned)
401    #[serde(skip_serializing_if = "Vec::is_empty")]
402    #[serde(default)]
403    pub other_deployments: Vec<BootEntry>,
404    /// Set to true if the rollback entry is queued for the next boot.
405    #[serde(default)]
406    pub rollback_queued: bool,
407
408    /// The detected type of system
409    #[serde(rename = "type")]
410    pub ty: Option<HostType>,
411
412    /// The state of the overlay mounted on /usr
413    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/// The result of a `bootc container inspect` command.
424#[derive(Debug, Serialize)]
425#[serde(rename_all = "kebab-case")]
426pub(crate) struct ContainerInspect {
427    /// Kernel arguments embedded in the container image.
428    pub(crate) kargs: Vec<String>,
429    /// Information about the kernel in the container image.
430    pub(crate) kernel: Option<crate::kernel::Kernel>,
431}
432
433impl Host {
434    /// Create a new host
435    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    /// Filter out the requested slot
452    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    /// Returns a vector of all deployments, i.e. staged, booted, rollback and other deployments
470    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    /// Returns all composefs deployments in a list
492    #[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    /// Validate a spec state transition; some changes cannot be made simultaneously,
543    /// such as fetching a new image and doing a rollback.
544    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        // For the default of fetching from a remote registry, just output the image name
566        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        // expand this
591        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            // When both a tag and digest are present, the digest should be used
627            (
628                format!("quay.io/example/someimage:latest@{sample_digest}"),
629                format!("quay.io/example/someimage@{sample_digest}"),
630                "registry",
631            ),
632            // When only a digest is present, it should be used
633            (
634                format!("quay.io/example/someimage@{sample_digest}"),
635                format!("quay.io/example/someimage@{sample_digest}"),
636                "registry",
637            ),
638            // When only a tag is present, it should be preserved
639            (
640                "quay.io/example/someimage:latest".to_string(),
641                "quay.io/example/someimage:latest".to_string(),
642                "registry",
643            ),
644            // When no tag or digest is present, preserve the original image name
645            (
646                "quay.io/example/someimage".to_string(),
647                "quay.io/example/someimage".to_string(),
648                "registry",
649            ),
650            // When used with a local image (i.e. from containers-storage), the functionality should
651            // be the same as previous cases
652            (
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            // Other cases are not canonicalized
663            (
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        // TODO For now this is known to incorrectly pass
715        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        // Alternative display should be short form
764        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        // should be case-insensitive, kebab-case optional
779        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        // Test registry transport (should return only the image name)
835        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        // Test containers-storage transport
846        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        // Test oci transport
857        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}