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