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