Skip to main content

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 typed `containers_image_proxy::ImageReference`.
144    ///
145    /// This is the canonical way to get a properly typed image reference
146    /// from the spec's string-based representation.
147    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    /// Convert to a container reference string suitable for use with container storage APIs.
155    /// For registry transport, returns just the image name. For other transports, prepends the transport.
156    pub fn to_transport_image(&self) -> Result<String> {
157        if self.transport()? == Transport::Registry {
158            // For registry transport, the image name is already in the right format
159            Ok(self.image.clone())
160        } else {
161            // For other transports (containers-storage, oci, etc.), prepend the transport
162            Ok(format!("{}:{}", self.transport, self.image))
163        }
164    }
165
166    /// Derive a new image reference by replacing the tag.
167    ///
168    /// For transports with parseable image references (registry, containers-storage),
169    /// uses the OCI Reference API to properly handle tag replacement.
170    /// For other transports (oci, etc.), falls back to string manipulation.
171    pub fn with_tag(&self, new_tag: &str) -> Result<Self> {
172        // Try to parse as an OCI Reference (works for registry and containers-storage)
173        let new_image = if let Ok(reference) = self.image.parse::<Reference>() {
174            // Use the proper OCI API to replace the tag
175            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            // For other transports like oci: with filesystem paths,
183            // strip any digest first, then replace tag via string manipulation
184            let image_without_digest = self.image.split('@').next().unwrap_or(&self.image);
185
186            // Split on last ':' to separate image:tag
187            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/// The status of the booted image
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
205#[serde(rename_all = "camelCase")]
206pub struct ImageStatus {
207    /// The currently booted image
208    pub image: ImageReference,
209    /// The version string, if any
210    pub version: Option<String>,
211    /// The build timestamp, if any
212    pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
213    /// The digest of the fetched image (e.g. sha256:a0...);
214    pub image_digest: String,
215    /// The hardware architecture of this image
216    pub architecture: String,
217}
218
219/// A bootable entry
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct BootEntryOstree {
223    /// The name of the storage for /etc and /var content
224    pub stateroot: String,
225    /// The ostree commit checksum
226    pub checksum: String,
227    /// The deployment serial
228    pub deploy_serial: u32,
229}
230
231/// Bootloader type to determine whether system was booted via Grub or Systemd
232#[derive(
233    clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
234)]
235#[serde(rename_all = "kebab-case")]
236pub enum Bootloader {
237    /// Use Grub as the bootloader
238    #[default]
239    Grub,
240    /// Use SystemdBoot as the bootloader
241    Systemd,
242    /// Don't use a bootloader managed by bootc
243    None,
244}
245
246impl Display for Bootloader {
247    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248        let string = match self {
249            Bootloader::Grub => "grub",
250            Bootloader::Systemd => "systemd",
251            Bootloader::None => "none",
252        };
253
254        write!(f, "{}", string)
255    }
256}
257
258impl FromStr for Bootloader {
259    type Err = anyhow::Error;
260
261    fn from_str(value: &str) -> Result<Self> {
262        match value {
263            "grub" => Ok(Self::Grub),
264            "systemd" => Ok(Self::Systemd),
265            "none" => Ok(Self::None),
266            unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
267        }
268    }
269}
270
271/// A bootable entry
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
273#[serde(rename_all = "camelCase")]
274pub struct BootEntryComposefs {
275    /// The erofs verity
276    pub verity: String,
277    /// Whether this deployment is to be booted via Type1 (vmlinuz + initrd) or Type2 (UKI) entry
278    pub boot_type: BootType,
279    /// Whether we boot using systemd or grub
280    pub bootloader: Bootloader,
281    /// The sha256sum of vmlinuz + initrd
282    /// Only `Some` for Type1 boot entries
283    pub boot_digest: Option<String>,
284    /// Whether fs-verity validation is optional
285    pub missing_verity_allowed: bool,
286}
287
288/// A bootable entry
289#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
290#[serde(rename_all = "camelCase")]
291pub struct BootEntry {
292    /// The image reference
293    pub image: Option<ImageStatus>,
294    /// The last fetched cached update metadata
295    pub cached_update: Option<ImageStatus>,
296    /// Whether this boot entry is not compatible (has origin changes bootc does not understand)
297    pub incompatible: bool,
298    /// Whether this entry will be subject to garbage collection
299    pub pinned: bool,
300    /// This is true if (relative to the booted system) this is a possible target for a soft reboot
301    #[serde(default)]
302    pub soft_reboot_capable: bool,
303    /// Whether this deployment is in download-only mode (prevented from automatic finalization on shutdown).
304    /// This is set via --download-only on the CLI.
305    #[serde(default)]
306    pub download_only: bool,
307    /// The container storage backend
308    #[serde(default)]
309    pub store: Option<Store>,
310    /// If this boot entry is ostree based, the corresponding state
311    pub ostree: Option<BootEntryOstree>,
312    /// If this boot entry is composefs based, the corresponding state
313    pub composefs: Option<BootEntryComposefs>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
317#[serde(rename_all = "camelCase")]
318#[non_exhaustive]
319/// The detected type of running system.  Note that this is not exhaustive
320/// and new variants may be added in the future.
321pub enum HostType {
322    /// The current system is deployed in a bootc compatible way.
323    BootcHost,
324}
325
326/// Details of an overlay filesystem: read-only or read/write, persistent or transient.
327#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
328#[serde(rename_all = "camelCase")]
329pub struct FilesystemOverlay {
330    /// Whether the overlay is read-only or read/write
331    pub access_mode: FilesystemOverlayAccessMode,
332    /// Whether the overlay will persist across reboots
333    pub persistence: FilesystemOverlayPersistence,
334}
335
336/// The permissions mode of a /usr overlay
337#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
338#[serde(rename_all = "camelCase")]
339pub enum FilesystemOverlayAccessMode {
340    /// The overlay is mounted read-only
341    ReadOnly,
342    /// The overlay is mounted read/write
343    ReadWrite,
344}
345
346impl Display for FilesystemOverlayAccessMode {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        match self {
349            FilesystemOverlayAccessMode::ReadOnly => write!(f, "read-only"),
350            FilesystemOverlayAccessMode::ReadWrite => write!(f, "read/write"),
351        }
352    }
353}
354
355/// The persistence mode of a /usr overlay
356#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema)]
357#[serde(rename_all = "camelCase")]
358pub enum FilesystemOverlayPersistence {
359    /// Changes are temporary and will be lost on reboot
360    Transient,
361    /// Changes persist across reboots
362    Persistent,
363}
364
365impl Display for FilesystemOverlayPersistence {
366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367        match self {
368            FilesystemOverlayPersistence::Transient => write!(f, "transient"),
369            FilesystemOverlayPersistence::Persistent => write!(f, "persistent"),
370        }
371    }
372}
373
374pub(crate) fn deployment_unlocked_state_to_usr_overlay(
375    state: DeploymentUnlockedState,
376) -> Option<FilesystemOverlay> {
377    use FilesystemOverlayAccessMode::*;
378    use FilesystemOverlayPersistence::*;
379    match state {
380        DeploymentUnlockedState::None => None,
381        DeploymentUnlockedState::Development => Some(FilesystemOverlay {
382            access_mode: ReadWrite,
383            persistence: Transient,
384        }),
385        DeploymentUnlockedState::Hotfix => Some(FilesystemOverlay {
386            access_mode: ReadWrite,
387            persistence: Persistent,
388        }),
389        DeploymentUnlockedState::Transient => Some(FilesystemOverlay {
390            access_mode: ReadOnly,
391            persistence: Transient,
392        }),
393        _ => None,
394    }
395}
396
397impl Display for FilesystemOverlay {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        write!(f, "{}, {}", self.persistence, self.access_mode)
400    }
401}
402
403/// The status of the host system
404#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
405#[serde(rename_all = "camelCase")]
406pub struct HostStatus {
407    /// The staged image for the next boot
408    pub staged: Option<BootEntry>,
409    /// The booted image; this will be unset if the host is not bootc compatible.
410    pub booted: Option<BootEntry>,
411    /// The previously booted image
412    pub rollback: Option<BootEntry>,
413    /// Other deployments (i.e. pinned)
414    #[serde(skip_serializing_if = "Vec::is_empty")]
415    #[serde(default)]
416    pub other_deployments: Vec<BootEntry>,
417    /// Set to true if the rollback entry is queued for the next boot.
418    #[serde(default)]
419    pub rollback_queued: bool,
420
421    /// The detected type of system
422    #[serde(rename = "type")]
423    pub ty: Option<HostType>,
424
425    /// The state of the overlay mounted on /usr
426    pub usr_overlay: Option<FilesystemOverlay>,
427}
428
429pub(crate) struct DeploymentEntry<'a> {
430    pub(crate) ty: Option<Slot>,
431    pub(crate) deployment: &'a BootEntryComposefs,
432    pub(crate) pinned: bool,
433    pub(crate) soft_reboot_capable: bool,
434}
435
436/// The result of a `bootc container inspect` command.
437#[derive(Debug, Serialize)]
438#[serde(rename_all = "kebab-case")]
439pub(crate) struct ContainerInspect {
440    /// Kernel arguments embedded in the container image.
441    pub(crate) kargs: Vec<String>,
442    /// Information about the kernel in the container image.
443    pub(crate) kernel: Option<crate::kernel::Kernel>,
444}
445
446impl Host {
447    /// Create a new host
448    pub fn new(spec: HostSpec) -> Self {
449        let metadata = k8sapitypes::ObjectMeta {
450            name: Some(OBJECT_NAME.to_owned()),
451            ..Default::default()
452        };
453        Self {
454            resource: k8sapitypes::Resource {
455                api_version: API_VERSION.to_owned(),
456                kind: KIND.to_owned(),
457                metadata,
458            },
459            spec,
460            status: Default::default(),
461        }
462    }
463
464    /// Filter out the requested slot
465    pub fn filter_to_slot(&mut self, slot: Slot) {
466        match slot {
467            Slot::Staged => {
468                self.status.booted = None;
469                self.status.rollback = None;
470            }
471            Slot::Booted => {
472                self.status.staged = None;
473                self.status.rollback = None;
474            }
475            Slot::Rollback => {
476                self.status.staged = None;
477                self.status.booted = None;
478            }
479        }
480    }
481
482    /// Returns a vector of all deployments, i.e. staged, booted, rollback and other deployments
483    pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
484        self.status
485            .staged
486            .iter()
487            .chain(self.status.booted.iter())
488            .chain(self.status.rollback.iter())
489            .chain(self.status.other_deployments.iter())
490            .collect::<Vec<_>>()
491    }
492
493    pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
494        let cfs = self
495            .status
496            .booted
497            .as_ref()
498            .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
499            .require_composefs()?;
500
501        Ok(cfs)
502    }
503
504    /// Returns all composefs deployments in a list
505    #[fn_error_context::context("Getting all composefs deployments")]
506    pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
507        let mut all_deps = vec![];
508
509        let booted = self.require_composefs_booted()?;
510        all_deps.push(DeploymentEntry {
511            ty: Some(Slot::Booted),
512            deployment: booted,
513            pinned: false,
514            soft_reboot_capable: false,
515        });
516
517        if let Some(staged) = &self.status.staged {
518            all_deps.push(DeploymentEntry {
519                ty: Some(Slot::Staged),
520                deployment: staged.require_composefs()?,
521                pinned: false,
522                soft_reboot_capable: staged.soft_reboot_capable,
523            });
524        }
525
526        if let Some(rollback) = &self.status.rollback {
527            all_deps.push(DeploymentEntry {
528                ty: Some(Slot::Rollback),
529                deployment: rollback.require_composefs()?,
530                pinned: false,
531                soft_reboot_capable: rollback.soft_reboot_capable,
532            });
533        }
534
535        for pinned in &self.status.other_deployments {
536            all_deps.push(DeploymentEntry {
537                ty: None,
538                deployment: pinned.require_composefs()?,
539                pinned: true,
540                soft_reboot_capable: pinned.soft_reboot_capable,
541            });
542        }
543
544        Ok(all_deps)
545    }
546}
547
548impl Default for Host {
549    fn default() -> Self {
550        Self::new(Default::default())
551    }
552}
553
554impl HostSpec {
555    /// Validate a spec state transition; some changes cannot be made simultaneously,
556    /// such as fetching a new image and doing a rollback.
557    pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
558        let rollback = self.boot_order != new.boot_order;
559        let image_change = self.image != new.image;
560        if rollback && image_change {
561            anyhow::bail!("Invalid state transition: rollback and image change");
562        }
563        Ok(())
564    }
565}
566
567impl BootOrder {
568    pub(crate) fn swap(&self) -> Self {
569        match self {
570            BootOrder::Default => BootOrder::Rollback,
571            BootOrder::Rollback => BootOrder::Default,
572        }
573    }
574}
575
576impl Display for ImageReference {
577    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
578        // For the default of fetching from a remote registry, just output the image name
579        if f.alternate() && self.signature.is_none() && self.transport == "registry" {
580            self.image.fmt(f)
581        } else {
582            let ostree_imgref = OstreeImageReference::from(self.clone());
583            ostree_imgref.fmt(f)
584        }
585    }
586}
587
588impl ImageStatus {
589    pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
590        use std::str::FromStr;
591        Ok(Digest::from_str(&self.image_digest)?)
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use std::str::FromStr;
598
599    use super::*;
600
601    #[test]
602    fn test_canonicalize_reference() {
603        // expand this
604        let passthrough = [
605            ("quay.io/example/someimage:latest"),
606            ("quay.io/example/someimage"),
607            ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
608        ];
609        let mapped = [
610            (
611                "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
612                "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
613            ),
614            (
615                "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
616                "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
617            ),
618        ];
619        for &v in passthrough.iter() {
620            let reference = Reference::from_str(v).unwrap();
621            assert!(reference.tag().is_none() || reference.digest().is_none());
622            assert!(canonicalize_reference(reference).is_none());
623        }
624        for &(initial, expected) in mapped.iter() {
625            let reference = Reference::from_str(initial).unwrap();
626            assert!(reference.tag().is_some());
627            assert!(reference.digest().is_some());
628            let canonicalized = canonicalize_reference(reference).unwrap();
629            assert_eq!(canonicalized.to_string(), expected);
630        }
631    }
632
633    #[test]
634    fn test_image_reference_canonicalize() {
635        let sample_digest =
636            "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
637
638        let test_cases = [
639            // When both a tag and digest are present, the digest should be used
640            (
641                format!("quay.io/example/someimage:latest@{sample_digest}"),
642                format!("quay.io/example/someimage@{sample_digest}"),
643                "registry",
644            ),
645            // When only a digest is present, it should be used
646            (
647                format!("quay.io/example/someimage@{sample_digest}"),
648                format!("quay.io/example/someimage@{sample_digest}"),
649                "registry",
650            ),
651            // When only a tag is present, it should be preserved
652            (
653                "quay.io/example/someimage:latest".to_string(),
654                "quay.io/example/someimage:latest".to_string(),
655                "registry",
656            ),
657            // When no tag or digest is present, preserve the original image name
658            (
659                "quay.io/example/someimage".to_string(),
660                "quay.io/example/someimage".to_string(),
661                "registry",
662            ),
663            // When used with a local image (i.e. from containers-storage), the functionality should
664            // be the same as previous cases
665            (
666                "localhost/someimage:latest".to_string(),
667                "localhost/someimage:latest".to_string(),
668                "registry",
669            ),
670            (
671                format!("localhost/someimage:latest@{sample_digest}"),
672                format!("localhost/someimage@{sample_digest}"),
673                "registry",
674            ),
675            // Other cases are not canonicalized
676            (
677                format!("quay.io/example/someimage:latest@{sample_digest}"),
678                format!("quay.io/example/someimage:latest@{sample_digest}"),
679                "containers-storage",
680            ),
681            (
682                "/path/to/dir:latest".to_string(),
683                "/path/to/dir:latest".to_string(),
684                "oci",
685            ),
686            (
687                "/tmp/repo".to_string(),
688                "/tmp/repo".to_string(),
689                "oci-archive",
690            ),
691            (
692                "/tmp/image-dir".to_string(),
693                "/tmp/image-dir".to_string(),
694                "dir",
695            ),
696        ];
697
698        for (initial, expected, transport) in test_cases {
699            let imgref = ImageReference {
700                image: initial.to_string(),
701                transport: transport.to_string(),
702                signature: None,
703            };
704
705            let canonicalized = imgref.canonicalize();
706            if let Err(e) = canonicalized {
707                panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
708            }
709            let canonicalized = canonicalized.unwrap();
710            assert_eq!(
711                canonicalized.image, expected,
712                "Mismatch for transport {transport}"
713            );
714            assert_eq!(canonicalized.transport, transport);
715            assert_eq!(canonicalized.signature, None);
716        }
717    }
718
719    #[test]
720    fn test_to_image_proxy_ref() {
721        use ostree_ext::containers_image_proxy;
722
723        let cases = [
724            (
725                "registry",
726                "quay.io/example/image:latest",
727                containers_image_proxy::Transport::Registry,
728                "quay.io/example/image:latest",
729            ),
730            (
731                "containers-storage",
732                "localhost/bootc",
733                containers_image_proxy::Transport::ContainerStorage,
734                "localhost/bootc",
735            ),
736            (
737                "oci",
738                "/var/tmp/bootc-oci",
739                containers_image_proxy::Transport::OciDir,
740                "/var/tmp/bootc-oci",
741            ),
742            (
743                "docker-daemon",
744                "myimage:tag",
745                containers_image_proxy::Transport::DockerDaemon,
746                "myimage:tag",
747            ),
748        ];
749
750        for (transport, image, expected_transport, expected_name) in cases {
751            let imgref = ImageReference {
752                transport: transport.to_string(),
753                image: image.to_string(),
754                signature: None,
755            };
756            let proxy_ref = imgref.to_image_proxy_ref().unwrap();
757            assert_eq!(
758                proxy_ref.transport, expected_transport,
759                "transport mismatch for {transport}:{image}"
760            );
761            assert_eq!(
762                proxy_ref.name, expected_name,
763                "name mismatch for {transport}:{image}"
764            );
765        }
766    }
767
768    #[test]
769    fn test_unimplemented_oci_tagged_digested() {
770        let imgref = ImageReference {
771            image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
772            transport: "oci".to_string(),
773            signature: None
774        };
775        let canonicalized = imgref.clone().canonicalize().unwrap();
776        // TODO For now this is known to incorrectly pass
777        assert_eq!(imgref, canonicalized);
778    }
779
780    #[test]
781    fn test_parse_spec_v1_null() {
782        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
783        let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
784        assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
785    }
786
787    #[test]
788    fn test_parse_spec_v1a1_orig() {
789        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
790        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
791        assert_eq!(
792            host.spec.image.as_ref().unwrap().image.as_str(),
793            "quay.io/example/someimage:latest"
794        );
795    }
796
797    #[test]
798    fn test_parse_spec_v1a1() {
799        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
800        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
801        assert_eq!(
802            host.spec.image.as_ref().unwrap().image.as_str(),
803            "quay.io/otherexample/otherimage:latest"
804        );
805        assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
806    }
807
808    #[test]
809    fn test_parse_ostreeremote() {
810        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
811        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
812        assert_eq!(
813            host.spec.image.as_ref().unwrap().signature,
814            Some(ImageSignature::OstreeRemote("fedora".into()))
815        );
816    }
817
818    #[test]
819    fn test_display_imgref() {
820        let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
821        let s = OstreeImageReference::from_str(src).unwrap();
822        let s = ImageReference::from(s);
823        let displayed = format!("{s}");
824        assert_eq!(displayed.as_str(), src);
825        // Alternative display should be short form
826        assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
827
828        let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
829        let s = OstreeImageReference::from_str(src).unwrap();
830        let s = ImageReference::from(s);
831        let displayed = format!("{s}");
832        assert_eq!(displayed.as_str(), src);
833        assert_eq!(format!("{s:#}"), src);
834    }
835
836    #[test]
837    fn test_store_from_str() {
838        use clap::ValueEnum;
839
840        // should be case-insensitive, kebab-case optional
841        assert!(Store::from_str("Ostree-Container", true).is_ok());
842        assert!(Store::from_str("OstrEeContAiner", true).is_ok());
843        assert!(Store::from_str("invalid", true).is_err());
844    }
845
846    #[test]
847    fn test_host_filter_to_slot() {
848        fn create_host() -> Host {
849            let mut host = Host::default();
850            host.status.staged = Some(default_boot_entry());
851            host.status.booted = Some(default_boot_entry());
852            host.status.rollback = Some(default_boot_entry());
853            host
854        }
855
856        fn default_boot_entry() -> BootEntry {
857            BootEntry {
858                image: None,
859                cached_update: None,
860                incompatible: false,
861                soft_reboot_capable: false,
862                pinned: false,
863                download_only: false,
864                store: None,
865                ostree: None,
866                composefs: None,
867            }
868        }
869
870        fn assert_host_state(
871            host: &Host,
872            staged: Option<BootEntry>,
873            booted: Option<BootEntry>,
874            rollback: Option<BootEntry>,
875        ) {
876            assert_eq!(host.status.staged, staged);
877            assert_eq!(host.status.booted, booted);
878            assert_eq!(host.status.rollback, rollback);
879        }
880
881        let mut host = create_host();
882        host.filter_to_slot(Slot::Staged);
883        assert_host_state(&host, Some(default_boot_entry()), None, None);
884
885        let mut host = create_host();
886        host.filter_to_slot(Slot::Booted);
887        assert_host_state(&host, None, Some(default_boot_entry()), None);
888
889        let mut host = create_host();
890        host.filter_to_slot(Slot::Rollback);
891        assert_host_state(&host, None, None, Some(default_boot_entry()));
892    }
893
894    #[test]
895    fn test_to_transport_image() {
896        // Test registry transport (should return only the image name)
897        let registry_ref = ImageReference {
898            transport: "registry".to_string(),
899            image: "quay.io/example/foo:latest".to_string(),
900            signature: None,
901        };
902        assert_eq!(
903            registry_ref.to_transport_image().unwrap(),
904            "quay.io/example/foo:latest"
905        );
906
907        // Test containers-storage transport
908        let storage_ref = ImageReference {
909            transport: "containers-storage".to_string(),
910            image: "localhost/bootc".to_string(),
911            signature: None,
912        };
913        assert_eq!(
914            storage_ref.to_transport_image().unwrap(),
915            "containers-storage:localhost/bootc"
916        );
917
918        // Test oci transport
919        let oci_ref = ImageReference {
920            transport: "oci".to_string(),
921            image: "/path/to/image".to_string(),
922            signature: None,
923        };
924        assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
925    }
926}