Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions src/fls/annotation_schema.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<_>>()
.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");
}
}
30 changes: 17 additions & 13 deletions src/fls/fastboot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![],
}
}

Expand Down Expand Up @@ -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!(
Expand Down
1 change: 1 addition & 0 deletions src/fls/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// Module declarations
pub mod annotation_schema;
pub mod automotive;
mod block_writer;
pub mod byte_channel;
Expand Down
81 changes: 54 additions & 27 deletions src/fls/oci/from_oci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -586,21 +588,32 @@ 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,
output_dir: &Path,
) -> Result<HashMap<String, PathBuf>, Box<dyn std::error::Error>> {
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))?;
Expand Down Expand Up @@ -629,7 +642,10 @@ pub async fn extract_files_by_annotations_to_dir(
Ok(partition_files)
}

fn parse_default_partitions(manifest: &Manifest) -> Result<Option<HashSet<String>>, String> {
fn parse_default_partitions(
manifest: &Manifest,
schema: &crate::fls::annotation_schema::AnnotationSchema,
) -> Result<Option<HashSet<String>>, String> {
let Manifest::Image(image) = manifest else {
return Ok(None);
};
Expand All @@ -638,7 +654,7 @@ fn parse_default_partitions(manifest: &Manifest) -> Result<Option<HashSet<String
return Ok(None);
};

let Some(raw) = annotations.get(automotive_annotations::DEFAULT_PARTITIONS) else {
let Some(raw) = annotations.get(schema.default_partitions_key) else {
return Ok(None);
};

Expand All @@ -656,7 +672,7 @@ fn parse_default_partitions(manifest: &Manifest) -> Result<Option<HashSet<String
if partitions.is_empty() {
return Err(format!(
"Default partition annotation '{}' is empty",
automotive_annotations::DEFAULT_PARTITIONS
schema.default_partitions_key
));
}

Expand Down Expand Up @@ -700,14 +716,24 @@ pub async fn extract_files_by_annotations_with_overrides_to_dir(
) -> Result<Option<HashMap<String, PathBuf>>, Box<dyn std::error::Error>> {
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<String, &super::manifest::Descriptor> = HashMap::new();
let mut title_to_path = HashMap::new();
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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());
}
Expand Down
Loading
Loading