1use 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};
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";
20pub(crate) const OBJECT_NAME: &str = "host";
22
23#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
24#[serde(rename_all = "camelCase")]
25pub struct Host {
27 #[serde(flatten)]
29 pub resource: k8sapitypes::Resource,
30 #[serde(default)]
32 pub spec: HostSpec,
33 #[serde(default)]
35 pub status: HostStatus,
36}
37
38#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
41#[serde(rename_all = "camelCase")]
42pub enum BootOrder {
43 #[default]
45 Default,
46 Rollback,
48}
49
50#[derive(
51 clap::ValueEnum, Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq, JsonSchema, Default,
52)]
53#[serde(rename_all = "camelCase")]
54pub enum Store {
56 #[default]
58 #[value(alias = "ostreecontainer")] OstreeContainer,
60}
61
62#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, JsonSchema)]
63#[serde(rename_all = "camelCase")]
64pub struct HostSpec {
66 pub image: Option<ImageReference>,
68 #[serde(default)]
70 pub boot_order: BootOrder,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
74#[serde(rename_all = "camelCase")]
76pub enum ImageSignature {
77 OstreeRemote(String),
79 ContainerPolicy,
81 Insecure,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
87#[serde(rename_all = "camelCase")]
88pub struct ImageReference {
89 pub image: String,
91 pub transport: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub signature: Option<ImageSignature>,
96}
97
98fn canonicalize_reference(reference: Reference) -> Option<Reference> {
100 reference.tag()?;
102
103 let digest = reference.digest()?;
105 Some(reference.clone_with_digest(digest.to_owned()))
107}
108
109impl ImageReference {
110 pub fn canonicalize(self) -> Result<Self> {
112 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 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 Ok(self)
133 }
134 }
135 }
136
137 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 pub fn to_transport_image(&self) -> Result<String> {
146 if self.transport()? == Transport::Registry {
147 Ok(self.image.clone())
149 } else {
150 Ok(format!("{}:{}", self.transport, self.image))
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
158#[serde(rename_all = "camelCase")]
159pub struct ImageStatus {
160 pub image: ImageReference,
162 pub version: Option<String>,
164 pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
166 pub image_digest: String,
168 pub architecture: String,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
174#[serde(rename_all = "camelCase")]
175pub struct BootEntryOstree {
176 pub stateroot: String,
178 pub checksum: String,
180 pub deploy_serial: u32,
182}
183
184#[derive(
186 clap::ValueEnum, Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema,
187)]
188pub enum Bootloader {
189 #[default]
191 Grub,
192 Systemd,
194}
195
196impl Display for Bootloader {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 let string = match self {
199 Bootloader::Grub => "grub",
200 Bootloader::Systemd => "systemd",
201 };
202
203 write!(f, "{}", string)
204 }
205}
206
207impl FromStr for Bootloader {
208 type Err = anyhow::Error;
209
210 fn from_str(value: &str) -> Result<Self> {
211 match value {
212 "grub" => Ok(Self::Grub),
213 "systemd" => Ok(Self::Systemd),
214 unrecognized => Err(anyhow::anyhow!("Unrecognized bootloader: '{unrecognized}'")),
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct BootEntryComposefs {
223 pub verity: String,
225 pub boot_type: BootType,
227 pub bootloader: Bootloader,
229 pub boot_digest: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
236#[serde(rename_all = "camelCase")]
237pub struct BootEntry {
238 pub image: Option<ImageStatus>,
240 pub cached_update: Option<ImageStatus>,
242 pub incompatible: bool,
244 pub pinned: bool,
246 #[serde(default)]
248 pub soft_reboot_capable: bool,
249 #[serde(default)]
252 pub download_only: bool,
253 #[serde(default)]
255 pub store: Option<Store>,
256 pub ostree: Option<BootEntryOstree>,
258 pub composefs: Option<BootEntryComposefs>,
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
263#[serde(rename_all = "camelCase")]
264#[non_exhaustive]
265pub enum HostType {
268 BootcHost,
270}
271
272#[derive(Debug, Clone, Serialize, Default, Deserialize, PartialEq, Eq, JsonSchema)]
274#[serde(rename_all = "camelCase")]
275pub struct HostStatus {
276 pub staged: Option<BootEntry>,
278 pub booted: Option<BootEntry>,
280 pub rollback: Option<BootEntry>,
282 #[serde(skip_serializing_if = "Vec::is_empty")]
284 #[serde(default)]
285 pub other_deployments: Vec<BootEntry>,
286 #[serde(default)]
288 pub rollback_queued: bool,
289
290 #[serde(rename = "type")]
292 pub ty: Option<HostType>,
293}
294
295pub(crate) struct DeploymentEntry<'a> {
296 pub(crate) ty: Option<Slot>,
297 pub(crate) deployment: &'a BootEntryComposefs,
298 pub(crate) pinned: bool,
299 pub(crate) soft_reboot_capable: bool,
300}
301
302#[derive(Debug, Serialize)]
304#[serde(rename_all = "kebab-case")]
305pub(crate) struct ContainerInspect {
306 pub(crate) kargs: Vec<String>,
308 pub(crate) kernel: Option<crate::kernel::Kernel>,
310}
311
312impl Host {
313 pub fn new(spec: HostSpec) -> Self {
315 let metadata = k8sapitypes::ObjectMeta {
316 name: Some(OBJECT_NAME.to_owned()),
317 ..Default::default()
318 };
319 Self {
320 resource: k8sapitypes::Resource {
321 api_version: API_VERSION.to_owned(),
322 kind: KIND.to_owned(),
323 metadata,
324 },
325 spec,
326 status: Default::default(),
327 }
328 }
329
330 pub fn filter_to_slot(&mut self, slot: Slot) {
332 match slot {
333 Slot::Staged => {
334 self.status.booted = None;
335 self.status.rollback = None;
336 }
337 Slot::Booted => {
338 self.status.staged = None;
339 self.status.rollback = None;
340 }
341 Slot::Rollback => {
342 self.status.staged = None;
343 self.status.booted = None;
344 }
345 }
346 }
347
348 pub(crate) fn require_composefs_booted(&self) -> anyhow::Result<&BootEntryComposefs> {
349 let cfs = self
350 .status
351 .booted
352 .as_ref()
353 .ok_or(anyhow::anyhow!("Could not find booted deployment"))?
354 .require_composefs()?;
355
356 Ok(cfs)
357 }
358
359 #[fn_error_context::context("Getting all composefs deployments")]
361 pub(crate) fn all_composefs_deployments<'a>(&'a self) -> Result<Vec<DeploymentEntry<'a>>> {
362 let mut all_deps = vec![];
363
364 let booted = self.require_composefs_booted()?;
365 all_deps.push(DeploymentEntry {
366 ty: Some(Slot::Booted),
367 deployment: booted,
368 pinned: false,
369 soft_reboot_capable: false,
370 });
371
372 if let Some(staged) = &self.status.staged {
373 all_deps.push(DeploymentEntry {
374 ty: Some(Slot::Staged),
375 deployment: staged.require_composefs()?,
376 pinned: false,
377 soft_reboot_capable: staged.soft_reboot_capable,
378 });
379 }
380
381 if let Some(rollback) = &self.status.rollback {
382 all_deps.push(DeploymentEntry {
383 ty: Some(Slot::Rollback),
384 deployment: rollback.require_composefs()?,
385 pinned: false,
386 soft_reboot_capable: rollback.soft_reboot_capable,
387 });
388 }
389
390 for pinned in &self.status.other_deployments {
391 all_deps.push(DeploymentEntry {
392 ty: None,
393 deployment: pinned.require_composefs()?,
394 pinned: true,
395 soft_reboot_capable: pinned.soft_reboot_capable,
396 });
397 }
398
399 Ok(all_deps)
400 }
401}
402
403impl Default for Host {
404 fn default() -> Self {
405 Self::new(Default::default())
406 }
407}
408
409impl HostSpec {
410 pub(crate) fn verify_transition(&self, new: &Self) -> anyhow::Result<()> {
413 let rollback = self.boot_order != new.boot_order;
414 let image_change = self.image != new.image;
415 if rollback && image_change {
416 anyhow::bail!("Invalid state transition: rollback and image change");
417 }
418 Ok(())
419 }
420}
421
422impl BootOrder {
423 pub(crate) fn swap(&self) -> Self {
424 match self {
425 BootOrder::Default => BootOrder::Rollback,
426 BootOrder::Rollback => BootOrder::Default,
427 }
428 }
429}
430
431impl Display for ImageReference {
432 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
433 if f.alternate() && self.signature.is_none() && self.transport == "registry" {
435 self.image.fmt(f)
436 } else {
437 let ostree_imgref = OstreeImageReference::from(self.clone());
438 ostree_imgref.fmt(f)
439 }
440 }
441}
442
443impl ImageStatus {
444 pub(crate) fn digest(&self) -> anyhow::Result<Digest> {
445 use std::str::FromStr;
446 Ok(Digest::from_str(&self.image_digest)?)
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use std::str::FromStr;
453
454 use super::*;
455
456 #[test]
457 fn test_canonicalize_reference() {
458 let passthrough = [
460 ("quay.io/example/someimage:latest"),
461 ("quay.io/example/someimage"),
462 ("quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2"),
463 ];
464 let mapped = [
465 (
466 "quay.io/example/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
467 "quay.io/example/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
468 ),
469 (
470 "localhost/someimage:latest@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
471 "localhost/someimage@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2",
472 ),
473 ];
474 for &v in passthrough.iter() {
475 let reference = Reference::from_str(v).unwrap();
476 assert!(reference.tag().is_none() || reference.digest().is_none());
477 assert!(canonicalize_reference(reference).is_none());
478 }
479 for &(initial, expected) in mapped.iter() {
480 let reference = Reference::from_str(initial).unwrap();
481 assert!(reference.tag().is_some());
482 assert!(reference.digest().is_some());
483 let canonicalized = canonicalize_reference(reference).unwrap();
484 assert_eq!(canonicalized.to_string(), expected);
485 }
486 }
487
488 #[test]
489 fn test_image_reference_canonicalize() {
490 let sample_digest =
491 "sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2";
492
493 let test_cases = [
494 (
496 format!("quay.io/example/someimage:latest@{sample_digest}"),
497 format!("quay.io/example/someimage@{sample_digest}"),
498 "registry",
499 ),
500 (
502 format!("quay.io/example/someimage@{sample_digest}"),
503 format!("quay.io/example/someimage@{sample_digest}"),
504 "registry",
505 ),
506 (
508 "quay.io/example/someimage:latest".to_string(),
509 "quay.io/example/someimage:latest".to_string(),
510 "registry",
511 ),
512 (
514 "quay.io/example/someimage".to_string(),
515 "quay.io/example/someimage".to_string(),
516 "registry",
517 ),
518 (
521 "localhost/someimage:latest".to_string(),
522 "localhost/someimage:latest".to_string(),
523 "registry",
524 ),
525 (
526 format!("localhost/someimage:latest@{sample_digest}"),
527 format!("localhost/someimage@{sample_digest}"),
528 "registry",
529 ),
530 (
532 format!("quay.io/example/someimage:latest@{sample_digest}"),
533 format!("quay.io/example/someimage:latest@{sample_digest}"),
534 "containers-storage",
535 ),
536 (
537 "/path/to/dir:latest".to_string(),
538 "/path/to/dir:latest".to_string(),
539 "oci",
540 ),
541 (
542 "/tmp/repo".to_string(),
543 "/tmp/repo".to_string(),
544 "oci-archive",
545 ),
546 (
547 "/tmp/image-dir".to_string(),
548 "/tmp/image-dir".to_string(),
549 "dir",
550 ),
551 ];
552
553 for (initial, expected, transport) in test_cases {
554 let imgref = ImageReference {
555 image: initial.to_string(),
556 transport: transport.to_string(),
557 signature: None,
558 };
559
560 let canonicalized = imgref.canonicalize();
561 if let Err(e) = canonicalized {
562 panic!("Failed to canonicalize {initial} with transport {transport}: {e}");
563 }
564 let canonicalized = canonicalized.unwrap();
565 assert_eq!(
566 canonicalized.image, expected,
567 "Mismatch for transport {transport}"
568 );
569 assert_eq!(canonicalized.transport, transport);
570 assert_eq!(canonicalized.signature, None);
571 }
572 }
573
574 #[test]
575 fn test_unimplemented_oci_tagged_digested() {
576 let imgref = ImageReference {
577 image: "path/to/image:sometag@sha256:5db6d8b5f34d3cbdaa1e82ed0152a5ac980076d19317d4269db149cbde057bb2".to_string(),
578 transport: "oci".to_string(),
579 signature: None
580 };
581 let canonicalized = imgref.clone().canonicalize().unwrap();
582 assert_eq!(imgref, canonicalized);
584 }
585
586 #[test]
587 fn test_parse_spec_v1_null() {
588 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1-null.json");
589 let host: Host = serde_json::from_str(SPEC_FIXTURE).unwrap();
590 assert_eq!(host.resource.api_version, "org.containers.bootc/v1");
591 }
592
593 #[test]
594 fn test_parse_spec_v1a1_orig() {
595 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1-orig.yaml");
596 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
597 assert_eq!(
598 host.spec.image.as_ref().unwrap().image.as_str(),
599 "quay.io/example/someimage:latest"
600 );
601 }
602
603 #[test]
604 fn test_parse_spec_v1a1() {
605 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-v1a1.yaml");
606 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
607 assert_eq!(
608 host.spec.image.as_ref().unwrap().image.as_str(),
609 "quay.io/otherexample/otherimage:latest"
610 );
611 assert_eq!(host.spec.image.as_ref().unwrap().signature, None);
612 }
613
614 #[test]
615 fn test_parse_ostreeremote() {
616 const SPEC_FIXTURE: &str = include_str!("fixtures/spec-ostree-remote.yaml");
617 let host: Host = serde_yaml::from_str(SPEC_FIXTURE).unwrap();
618 assert_eq!(
619 host.spec.image.as_ref().unwrap().signature,
620 Some(ImageSignature::OstreeRemote("fedora".into()))
621 );
622 }
623
624 #[test]
625 fn test_display_imgref() {
626 let src = "ostree-unverified-registry:quay.io/example/foo:sometag";
627 let s = OstreeImageReference::from_str(src).unwrap();
628 let s = ImageReference::from(s);
629 let displayed = format!("{s}");
630 assert_eq!(displayed.as_str(), src);
631 assert_eq!(format!("{s:#}"), "quay.io/example/foo:sometag");
633
634 let src = "ostree-remote-image:fedora:docker://quay.io/example/foo:sometag";
635 let s = OstreeImageReference::from_str(src).unwrap();
636 let s = ImageReference::from(s);
637 let displayed = format!("{s}");
638 assert_eq!(displayed.as_str(), src);
639 assert_eq!(format!("{s:#}"), src);
640 }
641
642 #[test]
643 fn test_store_from_str() {
644 use clap::ValueEnum;
645
646 assert!(Store::from_str("Ostree-Container", true).is_ok());
648 assert!(Store::from_str("OstrEeContAiner", true).is_ok());
649 assert!(Store::from_str("invalid", true).is_err());
650 }
651
652 #[test]
653 fn test_host_filter_to_slot() {
654 fn create_host() -> Host {
655 let mut host = Host::default();
656 host.status.staged = Some(default_boot_entry());
657 host.status.booted = Some(default_boot_entry());
658 host.status.rollback = Some(default_boot_entry());
659 host
660 }
661
662 fn default_boot_entry() -> BootEntry {
663 BootEntry {
664 image: None,
665 cached_update: None,
666 incompatible: false,
667 soft_reboot_capable: false,
668 pinned: false,
669 download_only: false,
670 store: None,
671 ostree: None,
672 composefs: None,
673 }
674 }
675
676 fn assert_host_state(
677 host: &Host,
678 staged: Option<BootEntry>,
679 booted: Option<BootEntry>,
680 rollback: Option<BootEntry>,
681 ) {
682 assert_eq!(host.status.staged, staged);
683 assert_eq!(host.status.booted, booted);
684 assert_eq!(host.status.rollback, rollback);
685 }
686
687 let mut host = create_host();
688 host.filter_to_slot(Slot::Staged);
689 assert_host_state(&host, Some(default_boot_entry()), None, None);
690
691 let mut host = create_host();
692 host.filter_to_slot(Slot::Booted);
693 assert_host_state(&host, None, Some(default_boot_entry()), None);
694
695 let mut host = create_host();
696 host.filter_to_slot(Slot::Rollback);
697 assert_host_state(&host, None, None, Some(default_boot_entry()));
698 }
699
700 #[test]
701 fn test_to_transport_image() {
702 let registry_ref = ImageReference {
704 transport: "registry".to_string(),
705 image: "quay.io/example/foo:latest".to_string(),
706 signature: None,
707 };
708 assert_eq!(
709 registry_ref.to_transport_image().unwrap(),
710 "quay.io/example/foo:latest"
711 );
712
713 let storage_ref = ImageReference {
715 transport: "containers-storage".to_string(),
716 image: "localhost/bootc".to_string(),
717 signature: None,
718 };
719 assert_eq!(
720 storage_ref.to_transport_image().unwrap(),
721 "containers-storage:localhost/bootc"
722 );
723
724 let oci_ref = ImageReference {
726 transport: "oci".to_string(),
727 image: "/path/to/image".to_string(),
728 signature: None,
729 };
730 assert_eq!(oci_ref.to_transport_image().unwrap(), "oci:/path/to/image");
731 }
732}