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    /// Set to true if the physical root (`/sysroot`) is on a read-only medium
455    /// (e.g. a live ISO) and so cannot be mutated; commands that would change
456    /// the system (upgrade, switch, etc.) are not available.
457    #[serde(default)]
458    pub read_only: bool,
459}
460
461pub(crate) struct DeploymentEntry<'a> {
462    pub(crate) ty: Option<Slot>,
463    pub(crate) deployment: &'a BootEntryComposefs,
464    pub(crate) pinned: bool,
465    pub(crate) soft_reboot_capable: bool,
466}
467
468/// The result of a `bootc container inspect` command.
469#[derive(Debug, Serialize)]
470#[serde(rename_all = "kebab-case")]
471pub(crate) struct ContainerInspect {
472    /// Kernel arguments embedded in the container image.
473    pub(crate) kargs: Vec<String>,
474    /// Information about the kernel in the container image.
475    pub(crate) kernel: Option<crate::kernel::Kernel>,
476}
477
478impl Host {
479    /// Create a new host
480    pub fn new(spec: HostSpec) -> Self {
481        let metadata = k8sapitypes::ObjectMeta {
482            name: Some(OBJECT_NAME.to_owned()),
483            ..Default::default()
484        };
485        Self {
486            resource: k8sapitypes::Resource {
487                api_version: API_VERSION.to_owned(),
488                kind: KIND.to_owned(),
489                metadata,
490            },
491            spec,
492            status: Default::default(),
493        }
494    }
495
496    /// Filter out the requested slot
497    pub fn filter_to_slot(&mut self, slot: Slot) {
498        match slot {
499            Slot::Staged => {
500                self.status.booted = None;
501                self.status.rollback = None;
502            }
503            Slot::Booted => {
504                self.status.staged = None;
505                self.status.rollback = None;
506            }
507            Slot::Rollback => {
508                self.status.staged = None;
509                self.status.booted = None;
510            }
511        }
512    }
513
514    /// Returns a vector of all deployments, i.e. staged, booted, rollback and other deployments
515    pub(crate) fn list_deployments(&self) -> Vec<&BootEntry> {
516        self.status
517            .staged
518            .iter()
519            .chain(self.status.booted.iter())
520            .chain(self.status.rollback.iter())
521            .chain(self.status.other_deployments.iter())
522            .collect::<Vec<_>>()
523    }
524
525    pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
526        let cfs = self
527            .status
528            .booted
529            .as_ref()
530            .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
531            .require_composefs()?;
532
533        Ok(cfs)
534    }
535
536    /// Returns all composefs deployments in a list
537    #[fn_error_context::context("Getting all composefs deployments")]
538    pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
539        let mut all_deps = vec![];
540
541        let booted = self.require_composefs_booted()?;
542        all_deps.push(DeploymentEntry {
543            ty: Some(Slot::Booted),
544            deployment: booted,
545            pinned: false,
546            soft_reboot_capable: false,
547        });
548
549        if let Some(staged) = &self.status.staged {
550            all_deps.push(DeploymentEntry {
551                ty: Some(Slot::Staged),
552                deployment: staged.require_composefs()?,
553                pinned: false,
554                soft_reboot_capable: staged.soft_reboot_capable,
555            });
556        }
557
558        if let Some(rollback) = &self.status.rollback {
559            all_deps.push(DeploymentEntry {
560                ty: Some(Slot::Rollback),
561                deployment: rollback.require_composefs()?,
562                pinned: false,
563                soft_reboot_capable: rollback.soft_reboot_capable,
564            });
565        }
566
567        for pinned in &self.status.other_deployments {
568            all_deps.push(DeploymentEntry {
569                ty: None,
570                deployment: pinned.require_composefs()?,
571                pinned: true,
572                soft_reboot_capable: pinned.soft_reboot_capable,
573            });
574        }
575
576        Ok(all_deps)
577    }
578}
579
580impl Default for Host {
581    fn default() -> Self {
582        Self::new(Default::default())
583    }
584}
585
586impl HostSpec {
587    /// Validate a spec state transition; some changes cannot be made simultaneously,
588    /// such as fetching a new image and doing a rollback.
589    pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
590        let rollback = self.boot_order != new.boot_order;
591        let image_change = self.image != new.image;
592        if rollback && image_change {
593            anyhow::bail!("Invalid state transition: rollback and image change");
594        }
595        Ok(())
596    }
597}
598
599impl BootOrder {
600    pub(crate) fn swap(&self) -> Self {
601        match self {
602            BootOrder::Default => BootOrder::Rollback,
603            BootOrder::Rollback => BootOrder::Default,
604        }
605    }
606}
607
608impl Display for ImageReference {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        // For the default of fetching from a remote registry, just output the image name
611        if f.alternate() && self.signature.is_none() && self.transport == "registry" {
612            self.image.fmt(f)
613        } else {
614            let ostree_imgref = OstreeImageReference::from(self.clone());
615            ostree_imgref.fmt(f)
616        }
617    }
618}
619
620impl ImageStatus {
621    pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
622        use std::str::FromStr;
623        Ok(Digest::from_str(&self.image_digest)?)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use std::str::FromStr;
630
631    use super::*;
632
633    #[test]
634    fn test_canonicalize_reference() {
635        // expand this
636        let passthrough = [
637            ("quay.io/example/someimage:latest"),
638            ("quay.io/example/someimage"),
639            ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
640        ];
641        let mapped = [
642            (
643                "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
644                "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
645            ),
646            (
647                "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
648                "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
649            ),
650        ];
651        for &v in passthrough.iter() {
652            let reference = Reference::from_str(v).unwrap();
653            assert!(reference.tag().is_none() || reference.digest().is_none());
654            assert!(canonicalize_reference(reference).is_none());
655        }
656        for &(initial, expected) in mapped.iter() {
657            let reference = Reference::from_str(initial).unwrap();
658            assert!(reference.tag().is_some());
659            assert!(reference.digest().is_some());
660            let canonicalized = canonicalize_reference(reference).unwrap();
661            assert_eq!(canonicalized.to_string(), expected);
662        }
663    }
664
665    #[test]
666    fn test_image_reference_canonicalize() {
667        let sample_digest =
668            "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
669
670        let test_cases = [
671            // When both a tag and digest are present, the digest should be used
672            (
673                format!("quay.io/example/someimage:latest@{sample_digest}"),
674                format!("quay.io/example/someimage@{sample_digest}"),
675                "registry",
676            ),
677            // When only a digest is present, it should be used
678            (
679                format!("quay.io/example/someimage@{sample_digest}"),
680                format!("quay.io/example/someimage@{sample_digest}"),
681                "registry",
682            ),
683            // When only a tag is present, it should be preserved
684            (
685                "quay.io/example/someimage:latest".to_string(),
686                "quay.io/example/someimage:latest".to_string(),
687                "registry",
688            ),
689            // When no tag or digest is present, preserve the original image name
690            (
691                "quay.io/example/someimage".to_string(),
692                "quay.io/example/someimage".to_string(),
693                "registry",
694            ),
695            // When used with a local image (i.e. from containers-storage), the functionality should
696            // be the same as previous cases
697            (
698                "localhost/someimage:latest".to_string(),
699                "localhost/someimage:latest".to_string(),
700                "registry",
701            ),
702            (
703                format!("localhost/someimage:latest@{sample_digest}"),
704                format!("localhost/someimage@{sample_digest}"),
705                "registry",
706            ),
707            // Other cases are not canonicalized
708            (
709                format!("quay.io/example/someimage:latest@{sample_digest}"),
710                format!("quay.io/example/someimage:latest@{sample_digest}"),
711                "containers-storage",
712            ),
713            (
714                "/path/to/dir:latest".to_string(),
715                "/path/to/dir:latest".to_string(),
716                "oci",
717            ),
718            (
719                "/tmp/repo".to_string(),
720                "/tmp/repo".to_string(),
721                "oci-archive",
722            ),
723            (
724                "/tmp/image-dir".to_string(),
725                "/tmp/image-dir".to_string(),
726                "dir",
727            ),
728        ];
729
730        for (initial, expected, transport) in test_cases {
731            let imgref = ImageReference {
732                image: initial.to_string(),
733                transport: transport.to_string(),
734                signature: None,
735            };
736
737            let canonicalized = imgref.canonicalize();
738            if let Err(e) = canonicalized {
739                panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
740            }
741            let canonicalized = canonicalized.unwrap();
742            assert_eq!(
743                canonicalized.image, expected,
744                "Mismatch for transport {transport}"
745            );
746            assert_eq!(canonicalized.transport, transport);
747            assert_eq!(canonicalized.signature, None);
748        }
749    }
750
751    #[test]
752    fn test_to_image_proxy_ref() {
753        use ostree_ext::containers_image_proxy;
754
755        let cases = [
756            (
757                "registry",
758                "quay.io/example/image:latest",
759                containers_image_proxy::Transport::Registry,
760                "quay.io/example/image:latest",
761            ),
762            (
763                "containers-storage",
764                "localhost/bootc",
765                containers_image_proxy::Transport::ContainerStorage,
766                "localhost/bootc",
767            ),
768            (
769                "oci",
770                "/var/tmp/bootc-oci",
771                containers_image_proxy::Transport::OciDir,
772                "/var/tmp/bootc-oci",
773            ),
774            (
775                "docker-daemon",
776                "myimage:tag",
777                containers_image_proxy::Transport::DockerDaemon,
778                "myimage:tag",
779            ),
780        ];
781
782        for (transport, image, expected_transport, expected_name) in cases {
783            let imgref = ImageReference {
784                transport: transport.to_string(),
785                image: image.to_string(),
786                signature: None,
787            };
788            let proxy_ref = imgref.to_image_proxy_ref().unwrap();
789            assert_eq!(
790                proxy_ref.transport, expected_transport,
791                "transport mismatch for {transport}:{image}"
792            );
793            assert_eq!(
794                proxy_ref.name, expected_name,
795                "name mismatch for {transport}:{image}"
796            );
797        }
798    }
799
800    #[test]
801    fn test_unimplemented_oci_tagged_digested() {
802        let imgref = ImageReference {
803            image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
804            transport: "oci".to_string(),
805            signature: None
806        };
807        let canonicalized = imgref.clone().canonicalize().unwrap();
808        // TODO For now this is known to incorrectly pass
809        assert_eq!(imgref, canonicalized);
810    }
811
812    #[test]
813    fn test_parse_spec_v1_null() {
814        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
815        let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
816        assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
817    }
818
819    #[test]
820    fn test_parse_spec_v1a1_orig() {
821        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
822        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
823        assert_eq!(
824            host.spec.image.as_ref().unwrap().image.as_str(),
825            "quay.io/example/someimage:latest"
826        );
827    }
828
829    #[test]
830    fn test_parse_spec_v1a1() {
831        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
832        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
833        assert_eq!(
834            host.spec.image.as_ref().unwrap().image.as_str(),
835            "quay.io/otherexample/otherimage:latest"
836        );
837        assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
838    }
839
840    #[test]
841    fn test_parse_ostreeremote() {
842        const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
843        let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
844        assert_eq!(
845            host.spec.image.as_ref().unwrap().signature,
846            Some(ImageSignature::OstreeRemote("fedora".into()))
847        );
848    }
849
850    #[test]
851    fn test_display_imgref() {
852        let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
853        let s = OstreeImageReference::from_str(src).unwrap();
854        let s = ImageReference::from(s);
855        let displayed = format!("{s}");
856        assert_eq!(displayed.as_str(), src);
857        // Alternative display should be short form
858        assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
859
860        let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
861        let s = OstreeImageReference::from_str(src).unwrap();
862        let s = ImageReference::from(s);
863        let displayed = format!("{s}");
864        assert_eq!(displayed.as_str(), src);
865        assert_eq!(format!("{s:#}"), src);
866    }
867
868    #[test]
869    fn test_store_from_str() {
870        use clap::ValueEnum;
871
872        // should be case-insensitive, kebab-case optional
873        assert!(Store::from_str("Ostree-Container", true).is_ok());
874        assert!(Store::from_str("OstrEeContAiner", true).is_ok());
875        assert!(Store::from_str("invalid", true).is_err());
876    }
877
878    #[test]
879    fn test_host_filter_to_slot() {
880        fn create_host() -> Host {
881            let mut host = Host::default();
882            host.status.staged = Some(default_boot_entry());
883            host.status.booted = Some(default_boot_entry());
884            host.status.rollback = Some(default_boot_entry());
885            host
886        }
887
888        fn default_boot_entry() -> BootEntry {
889            BootEntry {
890                image: None,
891                cached_update: None,
892                incompatible: false,
893                soft_reboot_capable: false,
894                pinned: false,
895                download_only: false,
896                store: None,
897                ostree: None,
898                composefs: None,
899            }
900        }
901
902        fn assert_host_state(
903            host: &Host,
904            staged: Option<BootEntry>,
905            booted: Option<BootEntry>,
906            rollback: Option<BootEntry>,
907        ) {
908            assert_eq!(host.status.staged, staged);
909            assert_eq!(host.status.booted, booted);
910            assert_eq!(host.status.rollback, rollback);
911        }
912
913        let mut host = create_host();
914        host.filter_to_slot(Slot::Staged);
915        assert_host_state(&host, Some(default_boot_entry()), None, None);
916
917        let mut host = create_host();
918        host.filter_to_slot(Slot::Booted);
919        assert_host_state(&host, None, Some(default_boot_entry()), None);
920
921        let mut host = create_host();
922        host.filter_to_slot(Slot::Rollback);
923        assert_host_state(&host, None, None, Some(default_boot_entry()));
924    }
925
926    #[test]
927    fn test_to_transport_image() {
928        // Test registry transport (should return only the image name)
929        let registry_ref = ImageReference {
930            transport: "registry".to_string(),
931            image: "quay.io/example/foo:latest".to_string(),
932            signature: None,
933        };
934        assert_eq!(
935            registry_ref.to_transport_image().unwrap(),
936            "quay.io/example/foo:latest"
937        );
938
939        // Test containers-storage transport
940        let storage_ref = ImageReference {
941            transport: "containers-storage".to_string(),
942            image: "localhost/bootc".to_string(),
943            signature: None,
944        };
945        assert_eq!(
946            storage_ref.to_transport_image().unwrap(),
947            "containers-storage:localhost/bootc"
948        );
949
950        // Test oci transport
951        let oci_ref = ImageReference {
952            transport: "oci".to_string(),
953            image: "/path/to/image".to_string(),
954            signature: None,
955        };
956        assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
957    }
958}