diff --git a/src/fls/annotation_schema.rs b/src/fls/annotation_schema.rs new file mode 100644 index 0000000..0f85895 --- /dev/null +++ b/src/fls/annotation_schema.rs @@ -0,0 +1,159 @@ +use super::oci::manifest::Descriptor; + +#[derive(Debug, Clone)] +pub struct AnnotationSchema { + pub name: &'static str, + pub partition_key: &'static str, + pub default_partitions_key: &'static str, +} + +impl AnnotationSchema { + pub const AUTOMOTIVE: Self = Self { + name: "automotive", + partition_key: "automotive.sdv.cloud.redhat.com/partition", + default_partitions_key: "automotive.sdv.cloud.redhat.com/default-partitions", + }; + + pub const GENERIC: Self = Self { + name: "generic", + partition_key: "dev.jumpstarter.fls/partition", + default_partitions_key: "dev.jumpstarter.fls/default-partitions", + }; + + pub fn default_search_order() -> &'static [Self] { + static ORDER: [AnnotationSchema; 2] = + [AnnotationSchema::GENERIC, AnnotationSchema::AUTOMOTIVE]; + &ORDER + } +} + +pub fn resolve_annotation_schema<'a>( + layers: &[Descriptor], + schemas: &'a [AnnotationSchema], +) -> Option<&'a AnnotationSchema> { + schemas.iter().find(|schema| { + layers.iter().any(|layer| { + layer + .annotations + .as_ref() + .is_some_and(|a| a.contains_key(schema.partition_key)) + }) + }) +} + +pub fn effective_schemas(custom: &[AnnotationSchema]) -> &[AnnotationSchema] { + if custom.is_empty() { + AnnotationSchema::default_search_order() + } else { + custom + } +} + +pub fn searched_keys_display(schemas: &[AnnotationSchema]) -> String { + schemas + .iter() + .map(|s| format!("'{}' ({})", s.partition_key, s.name)) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn descriptor_with_annotation(key: &str, value: &str) -> Descriptor { + let mut annotations = HashMap::new(); + annotations.insert(key.to_string(), value.to_string()); + Descriptor { + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_string(), + digest: "sha256:abc123".to_string(), + size: 1000, + annotations: Some(annotations), + platform: None, + } + } + + fn descriptor_without_annotations() -> Descriptor { + Descriptor { + media_type: "application/vnd.oci.image.layer.v1.tar+gzip".to_string(), + digest: "sha256:abc123".to_string(), + size: 1000, + annotations: None, + platform: None, + } + } + + #[test] + fn test_presets() { + assert_eq!( + AnnotationSchema::AUTOMOTIVE.partition_key, + "automotive.sdv.cloud.redhat.com/partition" + ); + assert_eq!( + AnnotationSchema::GENERIC.partition_key, + "dev.jumpstarter.fls/partition" + ); + } + + #[test] + fn test_default_search_order_prefers_generic() { + let order = AnnotationSchema::default_search_order(); + assert_eq!(order[0].name, "generic"); + assert_eq!(order[1].name, "automotive"); + } + + #[test] + fn test_resolve_finds_generic() { + let layers = vec![descriptor_with_annotation( + "dev.jumpstarter.fls/partition", + "boot", + )]; + let schemas = AnnotationSchema::default_search_order(); + let resolved = resolve_annotation_schema(&layers, schemas).unwrap(); + assert_eq!(resolved.name, "generic"); + } + + #[test] + fn test_resolve_finds_automotive() { + let layers = vec![descriptor_with_annotation( + "automotive.sdv.cloud.redhat.com/partition", + "root", + )]; + let schemas = AnnotationSchema::default_search_order(); + let resolved = resolve_annotation_schema(&layers, schemas).unwrap(); + assert_eq!(resolved.name, "automotive"); + } + + #[test] + fn test_resolve_prefers_generic_when_both_present() { + let layers = vec![ + descriptor_with_annotation("dev.jumpstarter.fls/partition", "boot"), + descriptor_with_annotation("automotive.sdv.cloud.redhat.com/partition", "root"), + ]; + let schemas = AnnotationSchema::default_search_order(); + let resolved = resolve_annotation_schema(&layers, schemas).unwrap(); + assert_eq!(resolved.name, "generic"); + } + + #[test] + fn test_resolve_returns_none_for_no_match() { + let layers = vec![descriptor_without_annotations()]; + let schemas = AnnotationSchema::default_search_order(); + assert!(resolve_annotation_schema(&layers, schemas).is_none()); + } + + #[test] + fn test_effective_schemas_uses_default_when_empty() { + let schemas = effective_schemas(&[]); + assert_eq!(schemas.len(), 2); + } + + #[test] + fn test_effective_schemas_uses_custom_when_provided() { + let custom = vec![AnnotationSchema::AUTOMOTIVE]; + let schemas = effective_schemas(&custom); + assert_eq!(schemas.len(), 1); + assert_eq!(schemas[0].name, "automotive"); + } +} diff --git a/src/fls/fastboot.rs b/src/fls/fastboot.rs index 50b87a9..e01a8ef 100644 --- a/src/fls/fastboot.rs +++ b/src/fls/fastboot.rs @@ -88,6 +88,7 @@ fn build_oci_options(options: &FastbootOptions) -> crate::fls::options::OciOptio file_pattern: None, max_retries: crate::fls::options::DEFAULT_MAX_RETRIES, retry_delay_secs: crate::fls::options::DEFAULT_RETRY_DELAY_SECS, + annotation_schemas: vec![], } } @@ -299,21 +300,24 @@ async fn extract_files_by_auto_detection_to_dir( let oci_options = build_oci_options(options); // Use annotation-aware extraction to get files from correct layers - let partition_files = - super::oci::extract_files_by_annotations_with_overrides_to_dir( - image_ref, - &oci_options, - output_dir, - &[], - ) - .await - .map_err(|e| format!("Annotation-based extraction failed: {}", e))? - .ok_or_else(|| { - "No partitions found in OCI annotations. Expected layers with 'automotive.sdv.cloud.redhat.com/partition' annotations".to_string() - })?; + let partition_files = super::oci::extract_files_by_annotations_with_overrides_to_dir( + image_ref, + &oci_options, + output_dir, + &[], + ) + .await + .map_err(|e| format!("Annotation-based extraction failed: {}", e))? + .ok_or_else(|| { + "No partitions found in OCI annotations. No recognized annotation schema detected." + .to_string() + })?; if partition_files.is_empty() { - return Err("No partitions found in OCI annotations. Expected layers with 'automotive.sdv.cloud.redhat.com/partition' annotations".into()); + return Err( + "No partitions found in OCI annotations. No recognized annotation schema detected." + .into(), + ); } println!( diff --git a/src/fls/mod.rs b/src/fls/mod.rs index 094c85c..ee279ee 100644 --- a/src/fls/mod.rs +++ b/src/fls/mod.rs @@ -1,4 +1,5 @@ // Module declarations +pub mod annotation_schema; pub mod automotive; mod block_writer; pub mod byte_channel; diff --git a/src/fls/oci/from_oci.rs b/src/fls/oci/from_oci.rs index 2b1dfcf..0d27a4c 100644 --- a/src/fls/oci/from_oci.rs +++ b/src/fls/oci/from_oci.rs @@ -18,10 +18,12 @@ use crate::fls::byte_channel::{byte_bounded_channel, ByteBoundedReceiver, ByteBo use crate::fls::decompress::start_decompressor_for_compression; use crate::fls::download_error::{handle_download_retry, DownloadError}; -use super::manifest::{LayerCompression, Manifest}; +use super::manifest::{FlashableArtifact, LayerCompression, Manifest}; use super::reference::ImageReference; use super::registry::RegistryClient; -use crate::fls::automotive::annotations as automotive_annotations; +use crate::fls::annotation_schema::{ + effective_schemas, resolve_annotation_schema, searched_keys_display, +}; use crate::fls::block_writer::AsyncBlockWriter; use crate::fls::compression::Compression; use crate::fls::decompress::{spawn_stderr_reader, start_decompressor_process}; @@ -586,7 +588,7 @@ pub async fn extract_files_from_oci_image_to_dir( Ok(map) } -/// Extract files based on layer annotations for automotive images and write to output_dir +/// Extract files based on layer annotations and write to output_dir pub async fn extract_files_by_annotations_to_dir( image: &str, options: &OciOptions, @@ -594,13 +596,24 @@ pub async fn extract_files_by_annotations_to_dir( ) -> Result, Box> { let (client, manifest) = connect_and_resolve(image, options).await?; - // Get layers and extract from each based on annotations let layers = manifest.get_layers()?; + let schemas = effective_schemas(&options.annotation_schemas); + let schema = resolve_annotation_schema(layers, schemas).ok_or_else(|| { + format!( + "No partitions found in OCI annotations. Searched for partition keys: {}", + searched_keys_display(schemas) + ) + })?; + println!( + "Using annotation schema: {} ({})", + schema.name, schema.partition_key + ); + let mut partition_files = HashMap::new(); for layer in layers { if let Some(ref annotations) = layer.annotations { - if let Some(partition) = annotations.get(automotive_annotations::PARTITION_ANNOTATION) { + if let Some(partition) = annotations.get(schema.partition_key) { ensure_supported_layer_compression(layer.compression(), &layer.media_type)?; let sanitized_name = sanitize_partition_name(partition) .map_err(|e| format!("Invalid partition annotation '{}': {}", partition, e))?; @@ -629,7 +642,10 @@ pub async fn extract_files_by_annotations_to_dir( Ok(partition_files) } -fn parse_default_partitions(manifest: &Manifest) -> Result>, String> { +fn parse_default_partitions( + manifest: &Manifest, + schema: &crate::fls::annotation_schema::AnnotationSchema, +) -> Result>, String> { let Manifest::Image(image) = manifest else { return Ok(None); }; @@ -638,7 +654,7 @@ fn parse_default_partitions(manifest: &Manifest) -> Result Result Result>, Box> { let (client, manifest) = connect_and_resolve(image, options).await?; - let default_partitions = parse_default_partitions(&manifest) - .map_err(|e| format!("Invalid default partitions annotation: {}", e))?; + let layers = manifest.get_layers()?; + let schemas = effective_schemas(&options.annotation_schemas); + let schema = resolve_annotation_schema(layers, schemas); + + let default_partitions = if let Some(schema) = schema { + println!( + "Using annotation schema: {} ({})", + schema.name, schema.partition_key + ); + parse_default_partitions(&manifest, schema) + .map_err(|e| format!("Invalid default partitions annotation: {}", e))? + } else { + None + }; if let Some(ref partitions) = default_partitions { println!("Using default partitions from manifest: {:?}", partitions); } - // Get layers and build lookup tables - let layers = manifest.get_layers()?; let mut partition_files = HashMap::new(); let mut title_to_layer: HashMap = HashMap::new(); let mut title_to_path = HashMap::new(); @@ -735,7 +761,8 @@ pub async fn extract_files_by_annotations_with_overrides_to_dir( } } - if let Some(partition) = annotations.get(automotive_annotations::PARTITION_ANNOTATION) { + let partition_key = schema.map(|s| s.partition_key); + if let Some(partition) = partition_key.and_then(|k| annotations.get(k)) { has_partition_annotations = true; available_partitions.insert(partition.clone()); if overridden_partitions.contains(partition) { @@ -772,10 +799,11 @@ pub async fn extract_files_by_annotations_with_overrides_to_dir( if !has_partition_annotations && title_to_layer.is_empty() { if overrides.is_empty() { - return Err( - "No partitions found in OCI annotations. Expected layers with 'automotive.sdv.cloud.redhat.com/partition' annotations" - .into(), - ); + return Err(format!( + "No partitions found in OCI annotations. Searched for partition keys: {}", + searched_keys_display(schemas) + ) + .into()); } return Ok(None); } @@ -837,10 +865,11 @@ pub async fn extract_files_by_annotations_with_overrides_to_dir( ) .into()); } - return Err( - "No partitions found in OCI annotations. Expected layers with 'automotive.sdv.cloud.redhat.com/partition' annotations" - .into(), - ); + return Err(format!( + "No partitions found in OCI annotations. Searched for partition keys: {}", + searched_keys_display(schemas) + ) + .into()); } Ok(Some(partition_files)) @@ -1673,13 +1702,11 @@ pub async fn flash_from_oci( let layer = manifest.get_single_layer()?; // Validate that the selected layer is a flashable disk image - if !layer - .media_type - .starts_with("application/vnd.automotive.disk") - { + if !FlashableArtifact::is_flashable(&layer.media_type) { return Err(format!( - "Layer media type '{}' is not a flashable disk image", - layer.media_type + "Layer media type '{}' is not a flashable disk image. Supported: {}", + layer.media_type, + FlashableArtifact::supported_types().join(", ") ) .into()); } diff --git a/src/fls/oci/manifest.rs b/src/fls/oci/manifest.rs index 5e67cb8..60b8299 100644 --- a/src/fls/oci/manifest.rs +++ b/src/fls/oci/manifest.rs @@ -161,7 +161,7 @@ impl Manifest { if let Some(layer) = m .layers .iter() - .find(|l| l.media_type.starts_with("application/vnd.automotive.disk")) + .find(|l| FlashableArtifact::is_flashable(&l.media_type)) { return Ok(layer); } @@ -219,6 +219,11 @@ impl Descriptor { self.media_type == media_types::OCI_LAYER_ZSTD } + #[allow(dead_code)] + pub fn flashable_artifact(&self) -> Option { + FlashableArtifact::from_media_type(&self.media_type) + } + /// Get compression type pub fn compression(&self) -> LayerCompression { if self.is_gzip_layer() { @@ -239,6 +244,55 @@ pub enum LayerCompression { Zstd, } +/// Flashable disk image artifact types +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FlashableArtifact { + DiskRaw, + DiskQcow2, + DiskSimg, +} + +impl FlashableArtifact { + const MEDIA_TYPE_PREFIXES: &[&str] = &[ + "application/vnd.automotive.disk", + "application/vnd.embedded.disk", + ]; + + pub fn from_media_type(media_type: &str) -> Option { + let suffix = Self::MEDIA_TYPE_PREFIXES + .iter() + .find_map(|prefix| media_type.strip_prefix(prefix))?; + match suffix { + ".raw" => Some(Self::DiskRaw), + ".qcow2" => Some(Self::DiskQcow2), + ".simg" => Some(Self::DiskSimg), + _ => None, + } + } + + pub fn format_suffix(&self) -> &'static str { + match self { + Self::DiskRaw => ".raw", + Self::DiskQcow2 => ".qcow2", + Self::DiskSimg => ".simg", + } + } + + pub fn is_flashable(media_type: &str) -> bool { + Self::from_media_type(media_type).is_some() + } + + pub fn supported_types() -> Vec { + let mut types = Vec::new(); + for prefix in Self::MEDIA_TYPE_PREFIXES { + for artifact in [Self::DiskRaw, Self::DiskQcow2, Self::DiskSimg] { + types.push(format!("{}{}", prefix, artifact.format_suffix())); + } + } + types + } +} + impl From for crate::fls::compression::Compression { fn from(layer_compression: LayerCompression) -> Self { match layer_compression { @@ -448,4 +502,64 @@ mod tests { _ => panic!("Expected manifest index"), } } + + #[test] + fn test_flashable_artifact_from_media_type() { + // Automotive prefix + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.raw"), + Some(FlashableArtifact::DiskRaw) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.qcow2"), + Some(FlashableArtifact::DiskQcow2) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.simg"), + Some(FlashableArtifact::DiskSimg) + ); + + // Embedded prefix — same formats, different domain + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.embedded.disk.raw"), + Some(FlashableArtifact::DiskRaw) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.embedded.disk.qcow2"), + Some(FlashableArtifact::DiskQcow2) + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.embedded.disk.simg"), + Some(FlashableArtifact::DiskSimg) + ); + + // Unrecognized types + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.oci.image.layer.v1.tar+gzip"), + None + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.automotive.disk.vhdx"), + None + ); + assert_eq!( + FlashableArtifact::from_media_type("application/vnd.unknown.disk.raw"), + None + ); + + // Round-trip: each format suffix resolves back from both prefixes + for artifact in [ + FlashableArtifact::DiskRaw, + FlashableArtifact::DiskQcow2, + FlashableArtifact::DiskSimg, + ] { + for prefix in FlashableArtifact::MEDIA_TYPE_PREFIXES { + let media_type = format!("{}{}", prefix, artifact.format_suffix()); + assert_eq!( + FlashableArtifact::from_media_type(&media_type), + Some(artifact) + ); + } + } + } } diff --git a/src/fls/oci/mod.rs b/src/fls/oci/mod.rs index 41116b7..aa1d8d0 100644 --- a/src/fls/oci/mod.rs +++ b/src/fls/oci/mod.rs @@ -4,7 +4,7 @@ /// decompresses and extracts disk images directly to block devices. mod auth; mod from_oci; -mod manifest; +pub(crate) mod manifest; mod reference; mod registry; diff --git a/src/fls/options.rs b/src/fls/options.rs index 695ea3a..870efe4 100644 --- a/src/fls/options.rs +++ b/src/fls/options.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use super::annotation_schema::AnnotationSchema; + pub const DEFAULT_MAX_RETRIES: usize = 10; pub const DEFAULT_RETRY_DELAY_SECS: u64 = 2; @@ -68,6 +70,8 @@ pub struct OciOptions { pub file_pattern: Option, pub max_retries: usize, pub retry_delay_secs: u64, + /// Annotation schemas to try when resolving partitions. Empty = auto-detect. + pub annotation_schemas: Vec, } /// Options for fastboot flash operations diff --git a/src/main.rs b/src/main.rs index a1b5f06..2f86b4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -199,6 +199,7 @@ async fn main() { file_pattern, max_retries, retry_delay_secs: retry_delay, + annotation_schemas: vec![], }; match fls::flash_from_oci(image_ref, options).await { diff --git a/tests/oci_extract.rs b/tests/oci_extract.rs index 59d3247..be56c62 100644 --- a/tests/oci_extract.rs +++ b/tests/oci_extract.rs @@ -142,6 +142,7 @@ fn default_options(cert_dir: &Path) -> OciOptions { file_pattern: None, max_retries: DEFAULT_MAX_RETRIES, retry_delay_secs: DEFAULT_RETRY_DELAY_SECS, + annotation_schemas: vec![], } } @@ -611,6 +612,7 @@ async fn test_blob_truncation_triggers_retry_and_succeeds() { file_pattern: None, max_retries: 5, retry_delay_secs: 0, // No delay for fast testing + annotation_schemas: vec![], }; let out_dir = tempdir().expect("create temp dir");