From d4ddc6441ce93ac4393ebd4ada015ccdda228470 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Thu, 5 Feb 2026 18:07:32 -0500 Subject: [PATCH 1/4] build for apple silicon --- .github/workflows/release.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a08c295..aed6f67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,6 +44,9 @@ jobs: - os: macos-latest target: x86_64-apple-darwin suffix: "" + - os: macos-14 + target: aarch64-apple-darwin + suffix: "" - os: ubuntu-22.04 target: x86_64-unknown-linux-gnu suffix: "" @@ -86,7 +89,7 @@ jobs: run: cross build --release --target ${{ matrix.target }} - name: Codesign executable - if: ${{ matrix.target == 'x86_64-apple-darwin' }} + if: ${{ contains(matrix.target, 'apple-darwin') }} env: MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} From f3b1501779289742e6a9c02b3a4752c67a24fd89 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Thu, 5 Feb 2026 18:07:49 -0500 Subject: [PATCH 2/4] return an error if unknown runtime --- src/main.rs | 69 +++++++++++++++++++- src/utils/config.rs | 151 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 5d7a190..ecf7253 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::collections::HashMap; mod commands; mod utils; +use utils::config::Config; + use commands::build::BuildCommand; use commands::clean::CleanCommand; use commands::ext::{ @@ -711,6 +713,23 @@ enum RuntimeCommands { }, } +/// Validate that a runtime exists in the configuration if provided. +/// This provides early validation with a helpful error message before command execution. +fn validate_runtime_if_provided(config_path: &str, runtime: Option<&String>) -> Result<()> { + if let Some(runtime_name) = runtime { + Config::validate_runtime_exists(config_path, runtime_name) + .with_context(|| format!("Invalid runtime specified: '{runtime_name}'"))?; + } + Ok(()) +} + +/// Validate that a runtime exists in the configuration (for required runtime arguments). +/// This provides early validation with a helpful error message before command execution. +fn validate_runtime_required(config_path: &str, runtime: &str) -> Result<()> { + Config::validate_runtime_exists(config_path, runtime) + .with_context(|| format!("Invalid runtime specified: '{runtime}'")) +} + /// Parse environment variable arguments in the format "KEY=VALUE" into a HashMap fn parse_env_vars(env_args: Option<&Vec>) -> Option> { env_args.map(|args| { @@ -800,6 +819,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let install_cmd = InstallCommand::new( config, verbose, @@ -824,6 +846,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let build_cmd = BuildCommand::new( config, verbose, @@ -848,6 +873,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let fetch_cmd = FetchCommand::new( config, verbose, @@ -878,6 +906,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let provision_cmd = ProvisionCommand::new(crate::commands::provision::ProvisionConfig { runtime, @@ -907,6 +938,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let deploy_cmd = RuntimeDeployCommand::new( runtime, config, @@ -962,6 +996,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let sign_cmd = SignCommand::new( config, verbose, @@ -990,6 +1027,9 @@ async fn main() -> Result<()> { runtime, sdk, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let unlock_cmd = UnlockCommand::new( config, verbose, @@ -1011,6 +1051,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let install_cmd = RuntimeInstallCommand::new( runtime, config, @@ -1034,6 +1077,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let build_cmd = RuntimeBuildCommand::new( runtime, config, @@ -1060,6 +1106,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let mut provision_cmd = RuntimeProvisionCommand::new( crate::commands::runtime::provision::RuntimeProvisionConfig { runtime_name: runtime, @@ -1092,6 +1141,9 @@ async fn main() -> Result<()> { runtime, target: _, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let deps_cmd = RuntimeDepsCommand::new(config, runtime); deps_cmd.execute()?; Ok(()) @@ -1105,6 +1157,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let dnf_cmd = RuntimeDnfCommand::new( config, runtime, @@ -1126,6 +1181,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let clean_cmd = RuntimeCleanCommand::new( runtime, config, @@ -1147,6 +1205,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let deploy_cmd = RuntimeDeployCommand::new( runtime, config, @@ -1169,6 +1230,9 @@ async fn main() -> Result<()> { container_args, dnf_args, } => { + // Validate runtime exists (required argument) + validate_runtime_required(&config, &runtime)?; + let sign_cmd = RuntimeSignCommand::new( runtime, config, @@ -1439,6 +1503,9 @@ async fn main() -> Result<()> { dnf_args, no_bootstrap, } => { + // Validate runtime exists if provided + validate_runtime_if_provided(&config, runtime.as_ref())?; + let cmd = if command.is_empty() { None } else { diff --git a/src/utils/config.rs b/src/utils/config.rs index 68b721b..431ba89 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -1713,6 +1713,51 @@ impl Config { self.get_merged_section(§ion_path, target, config_path) } + /// Get all runtime names defined in the configuration + /// + /// Returns a sorted list of runtime names from the `runtimes` section. + pub fn get_runtime_names(config_path: &str) -> Result> { + let content = fs::read_to_string(config_path) + .with_context(|| format!("Failed to read config file: {config_path}"))?; + let parsed: serde_yaml::Value = serde_yaml::from_str(&content) + .with_context(|| format!("Failed to parse config file: {config_path}"))?; + + let mut runtimes: Vec = parsed + .get("runtimes") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.keys() + .filter_map(|k| k.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + runtimes.sort(); + Ok(runtimes) + } + + /// Validate that a runtime exists in the configuration + /// + /// Returns an error if the runtime does not exist, with a helpful message + /// listing available runtimes. + pub fn validate_runtime_exists(config_path: &str, runtime_name: &str) -> Result<()> { + let runtimes = Self::get_runtime_names(config_path)?; + + if runtimes.contains(&runtime_name.to_string()) { + Ok(()) + } else { + let available = if runtimes.is_empty() { + "No runtimes are defined in the configuration.".to_string() + } else { + format!("Available runtimes: {}", runtimes.join(", ")) + }; + Err(anyhow::anyhow!( + "Runtime '{}' not found in configuration. {}", + runtime_name, + available + )) + } + } + /// Get merged provision configuration for a specific profile and target #[allow(dead_code)] // Future API for command integration pub fn get_merged_provision_config( @@ -6963,4 +7008,110 @@ include: _ => panic!("Expected Repo variant"), } } + + #[test] + fn test_get_runtime_names() { + let config_content = r#" +sdk: + image: "test-image" + +runtimes: + production: + target: "aarch64" + development: + target: "x86_64" + staging: + target: "aarch64" +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let runtimes = Config::get_runtime_names(temp_file.path().to_str().unwrap()).unwrap(); + + assert_eq!(runtimes.len(), 3); + // Should be sorted alphabetically + assert_eq!(runtimes[0], "development"); + assert_eq!(runtimes[1], "production"); + assert_eq!(runtimes[2], "staging"); + } + + #[test] + fn test_get_runtime_names_empty() { + let config_content = r#" +sdk: + image: "test-image" +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let runtimes = Config::get_runtime_names(temp_file.path().to_str().unwrap()).unwrap(); + + assert!(runtimes.is_empty()); + } + + #[test] + fn test_validate_runtime_exists_success() { + let config_content = r#" +sdk: + image: "test-image" + +runtimes: + production: + target: "aarch64" + development: + target: "x86_64" +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + // Valid runtime should succeed + let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "production"); + assert!(result.is_ok()); + + let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "development"); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_runtime_exists_failure() { + let config_content = r#" +sdk: + image: "test-image" + +runtimes: + production: + target: "aarch64" + development: + target: "x86_64" +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + // Non-existent runtime should fail with helpful error message + let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "nonexistent"); + assert!(result.is_err()); + + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("nonexistent")); + assert!(error_msg.contains("not found")); + assert!(error_msg.contains("development")); + assert!(error_msg.contains("production")); + } + + #[test] + fn test_validate_runtime_exists_no_runtimes() { + let config_content = r#" +sdk: + image: "test-image" +"#; + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + // Should fail with message about no runtimes defined + let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "any"); + assert!(result.is_err()); + + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("No runtimes are defined")); + } } From d4ecca7692613ed4953da8a87141ffbedf277776 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Feb 2026 17:01:17 -0500 Subject: [PATCH 3/4] update extension image creation with reproducability flags --- src/commands/build.rs | 117 +++++++++++++++++++++++++++++++- src/commands/ext/image.rs | 114 +++++++++++++++++++++++++++++-- src/commands/init.rs | 2 +- src/commands/runtime/install.rs | 2 +- src/utils/config.rs | 63 +++++++++++++++-- src/utils/target.rs | 3 + 6 files changed, 288 insertions(+), 13 deletions(-) diff --git a/src/commands/build.rs b/src/commands/build.rs index 0cdb207..6a2e203 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -796,6 +796,7 @@ rpm --root="$AVOCADO_EXT_SYSROOTS/{extension_name}" --dbpath=/var/lib/extension. })?; // Create the image creation script + let source_date_epoch = config.source_date_epoch.unwrap_or(0); let image_script = format!( r#" set -e @@ -818,12 +819,16 @@ if [ ! -d "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" ]; then exit 1 fi +# Ensure reproducible timestamps +export SOURCE_DATE_EPOCH={source_date_epoch} + # Create squashfs image from the versioned extension sysroot mksquashfs \ "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" \ "$OUTPUT_FILE" \ -noappend \ - -no-xattrs + -no-xattrs \ + -reproducible echo "Successfully created image for versioned extension '$EXT_NAME-$EXT_VERSION' at $OUTPUT_FILE" "# @@ -1791,4 +1796,114 @@ mod tests { assert_eq!(cmd.container_args, None); assert_eq!(cmd.dnf_args, None); } + + /// Helper that replicates the versioned extension image script template + /// from `create_versioned_extension_image` so we can unit-test the + /// SOURCE_DATE_EPOCH interpolation without needing a container. + fn build_versioned_image_script( + extension_name: &str, + ext_version: &str, + source_date_epoch: u64, + ) -> String { + format!( + r#" +set -e + +# Common variables +EXT_NAME="{extension_name}" +EXT_VERSION="{ext_version}" +OUTPUT_DIR="$AVOCADO_PREFIX/output/extensions" +OUTPUT_FILE="$OUTPUT_DIR/$EXT_NAME-$EXT_VERSION.raw" + +# Create output directory +mkdir -p $OUTPUT_DIR + +# Remove existing file if it exists (including any old versions) +rm -f "$OUTPUT_DIR/$EXT_NAME"*.raw + +# Check if extension sysroot exists +if [ ! -d "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" ]; then + echo "Extension sysroot does not exist: $AVOCADO_EXT_SYSROOTS/$EXT_NAME." + exit 1 +fi + +# Ensure reproducible timestamps +export SOURCE_DATE_EPOCH={source_date_epoch} + +# Create squashfs image from the versioned extension sysroot +mksquashfs \ + "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" \ + "$OUTPUT_FILE" \ + -noappend \ + -no-xattrs \ + -reproducible + +echo "Successfully created image for versioned extension '$EXT_NAME-$EXT_VERSION' at $OUTPUT_FILE" +"# + ) + } + + #[test] + fn test_versioned_image_script_source_date_epoch_default() { + let script = build_versioned_image_script("my-ext", "1.0.0", 0); + + assert!( + script.contains("export SOURCE_DATE_EPOCH=0"), + "script should set SOURCE_DATE_EPOCH=0 when default is used" + ); + assert!( + script.contains("-reproducible"), + "script should include -reproducible flag" + ); + assert!( + script.contains("mksquashfs"), + "script should invoke mksquashfs" + ); + } + + #[test] + fn test_versioned_image_script_source_date_epoch_custom() { + let script = build_versioned_image_script("my-ext", "1.0.0", 1700000000); + + assert!( + script.contains("export SOURCE_DATE_EPOCH=1700000000"), + "script should set SOURCE_DATE_EPOCH to the custom value" + ); + assert!( + !script.contains("SOURCE_DATE_EPOCH=0"), + "script should not contain the default value when a custom one is set" + ); + } + + #[test] + fn test_versioned_image_script_extension_name_and_version() { + let script = build_versioned_image_script("test-extension", "2.3.4", 0); + + assert!( + script.contains("EXT_NAME=\"test-extension\""), + "script should contain the extension name" + ); + assert!( + script.contains("EXT_VERSION=\"2.3.4\""), + "script should contain the extension version" + ); + } + + #[test] + fn test_versioned_image_script_reproducible_flags() { + let script = build_versioned_image_script("my-ext", "1.0.0", 0); + + assert!( + script.contains("-reproducible"), + "script should include -reproducible flag" + ); + assert!( + script.contains("-noappend"), + "script should include -noappend flag" + ); + assert!( + script.contains("-no-xattrs"), + "script should include -no-xattrs flag" + ); + } } diff --git a/src/commands/ext/image.rs b/src/commands/ext/image.rs index 11bc927..1b8e0c4 100644 --- a/src/commands/ext/image.rs +++ b/src/commands/ext/image.rs @@ -362,6 +362,8 @@ impl ExtImageCommand { OutputLevel::Normal, ); + let source_date_epoch = config.source_date_epoch.unwrap_or(0); + let result = self .create_image( &container_helper, @@ -372,6 +374,7 @@ impl ExtImageCommand { repo_url.as_ref(), repo_release.as_ref(), &merged_container_args, + source_date_epoch, ) .await?; @@ -441,9 +444,10 @@ impl ExtImageCommand { repo_url: Option<&String>, repo_release: Option<&String>, merged_container_args: &Option>, + source_date_epoch: u64, ) -> Result { // Create the build script - let build_script = self.create_build_script(ext_version, extension_type); + let build_script = self.create_build_script(ext_version, extension_type, source_date_epoch); // Execute the build script in the SDK container if self.verbose { @@ -470,7 +474,12 @@ impl ExtImageCommand { Ok(result) } - fn create_build_script(&self, ext_version: &str, _extension_type: &str) -> String { + fn create_build_script( + &self, + ext_version: &str, + _extension_type: &str, + source_date_epoch: u64, + ) -> String { format!( r#" set -e @@ -493,16 +502,22 @@ if [ ! -d "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" ]; then exit 1 fi +# Ensure reproducible timestamps +export SOURCE_DATE_EPOCH={source_date_epoch} + # Create squashfs image mksquashfs \ "$AVOCADO_EXT_SYSROOTS/$EXT_NAME" \ "$OUTPUT_FILE" \ -noappend \ - -no-xattrs + -no-xattrs \ + -reproducible echo "Created extension image: $OUTPUT_FILE" "#, - self.extension, ext_version + self.extension, + ext_version, + source_date_epoch = source_date_epoch ) } @@ -537,3 +552,94 @@ echo "Created extension image: $OUTPUT_FILE" Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_cmd(extension: &str) -> ExtImageCommand { + ExtImageCommand::new( + extension.to_string(), + "avocado.yaml".to_string(), + false, + None, + None, + None, + ) + } + + #[test] + fn test_create_build_script_contains_reproducible_flags() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0); + + assert!( + script.contains("-reproducible"), + "script should include -reproducible flag" + ); + assert!( + script.contains("-noappend"), + "script should include -noappend flag" + ); + assert!( + script.contains("-no-xattrs"), + "script should include -no-xattrs flag" + ); + assert!( + script.contains("mksquashfs"), + "script should invoke mksquashfs" + ); + } + + #[test] + fn test_create_build_script_source_date_epoch_default() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0); + + assert!( + script.contains("export SOURCE_DATE_EPOCH=0"), + "script should set SOURCE_DATE_EPOCH=0 when default is used" + ); + } + + #[test] + fn test_create_build_script_source_date_epoch_custom() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 1700000000); + + assert!( + script.contains("export SOURCE_DATE_EPOCH=1700000000"), + "script should set SOURCE_DATE_EPOCH to the custom value" + ); + assert!( + !script.contains("SOURCE_DATE_EPOCH=0"), + "script should not contain the default value when a custom one is set" + ); + } + + #[test] + fn test_create_build_script_extension_name_and_version() { + let cmd = make_cmd("test-extension"); + let script = cmd.create_build_script("2.3.4", "sysext", 0); + + assert!( + script.contains("EXT_NAME=\"test-extension\""), + "script should contain the extension name" + ); + assert!( + script.contains("EXT_VERSION=\"2.3.4\""), + "script should contain the extension version" + ); + } + + #[test] + fn test_create_build_script_output_path() { + let cmd = make_cmd("my-ext"); + let script = cmd.create_build_script("1.0.0", "sysext", 0); + + assert!( + script.contains("OUTPUT_FILE=\"$OUTPUT_DIR/$EXT_NAME-$EXT_VERSION.raw\""), + "script should set the output file with .raw extension" + ); + } +} diff --git a/src/commands/init.rs b/src/commands/init.rs index 22b6dbe..55a01c0 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -247,7 +247,7 @@ impl InitCommand { let prefix = if path.is_empty() { String::new() } else { - format!("{}/", path) + format!("{path}/") }; for entry in tree.tree { diff --git a/src/commands/runtime/install.rs b/src/commands/runtime/install.rs index 4dfc48b..15373b7 100644 --- a/src/commands/runtime/install.rs +++ b/src/commands/runtime/install.rs @@ -105,7 +105,7 @@ impl RuntimeInstallCommand { None => { if let Some(runtime) = &self.runtime { print_error( - &format!("Runtime '{}' not found in configuration.", runtime), + &format!("Runtime '{runtime}' not found in configuration."), OutputLevel::Normal, ); return Ok(()); diff --git a/src/utils/config.rs b/src/utils/config.rs index 431ba89..0c1a453 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -452,6 +452,7 @@ pub enum SupportedTargets { /// Main configuration structure #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { + pub source_date_epoch: Option, pub default_target: Option, pub supported_targets: Option, pub src_dir: Option, @@ -693,6 +694,7 @@ impl Config { // First deserialize just to get src_dir and default_target let temp_config: Config = serde_yaml::from_value(main_config.clone()).unwrap_or_else(|_| Config { + source_date_epoch: None, default_target: None, supported_targets: None, src_dir: None, @@ -1751,9 +1753,7 @@ impl Config { format!("Available runtimes: {}", runtimes.join(", ")) }; Err(anyhow::anyhow!( - "Runtime '{}' not found in configuration. {}", - runtime_name, - available + "Runtime '{runtime_name}' not found in configuration. {available}" )) } } @@ -7065,10 +7065,12 @@ runtimes: write!(temp_file, "{config_content}").unwrap(); // Valid runtime should succeed - let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "production"); + let result = + Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "production"); assert!(result.is_ok()); - let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "development"); + let result = + Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "development"); assert!(result.is_ok()); } @@ -7088,7 +7090,8 @@ runtimes: write!(temp_file, "{config_content}").unwrap(); // Non-existent runtime should fail with helpful error message - let result = Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "nonexistent"); + let result = + Config::validate_runtime_exists(temp_file.path().to_str().unwrap(), "nonexistent"); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); @@ -7114,4 +7117,52 @@ sdk: let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("No runtimes are defined")); } + + #[test] + fn test_source_date_epoch_set() { + let config_content = r#" +source_date_epoch: 1700000000 + +sdk: + image: "docker.io/avocadolinux/sdk:apollo-edge" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let config = Config::load(temp_file.path()).unwrap(); + assert_eq!(config.source_date_epoch, Some(1700000000)); + } + + #[test] + fn test_source_date_epoch_zero() { + let config_content = r#" +source_date_epoch: 0 + +sdk: + image: "docker.io/avocadolinux/sdk:apollo-edge" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let config = Config::load(temp_file.path()).unwrap(); + assert_eq!(config.source_date_epoch, Some(0)); + } + + #[test] + fn test_source_date_epoch_unset_defaults_to_none() { + let config_content = r#" +sdk: + image: "docker.io/avocadolinux/sdk:apollo-edge" +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let config = Config::load(temp_file.path()).unwrap(); + assert_eq!(config.source_date_epoch, None); + // When consumed, unwrap_or(0) should yield 0 + assert_eq!(config.source_date_epoch.unwrap_or(0), 0); + } } diff --git a/src/utils/target.rs b/src/utils/target.rs index 72edba5..f7391eb 100644 --- a/src/utils/target.rs +++ b/src/utils/target.rs @@ -231,6 +231,7 @@ mod tests { fn create_test_config(default_target: Option<&str>) -> Config { Config { + source_date_epoch: None, default_target: default_target.map(|s| s.to_string()), supported_targets: None, src_dir: None, @@ -245,6 +246,7 @@ mod tests { fn create_config_with_supported_targets(targets: Vec) -> Config { use crate::utils::config::SupportedTargets; Config { + source_date_epoch: None, default_target: Some("qemux86-64".to_string()), supported_targets: Some(SupportedTargets::List(targets)), src_dir: None, @@ -259,6 +261,7 @@ mod tests { fn create_config_with_supported_targets_all() -> Config { use crate::utils::config::SupportedTargets; Config { + source_date_epoch: None, default_target: Some("qemux86-64".to_string()), supported_targets: Some(SupportedTargets::All("*".to_string())), src_dir: None, From 5c39f6bf4854b1ae1df0148df4268d97e323e553 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Tue, 10 Feb 2026 19:47:26 -0500 Subject: [PATCH 4/4] add support for compile your own kernel using the sdk --- src/commands/runtime/build.rs | 190 ++++++++++++++++++++++++++ src/commands/runtime/install.rs | 25 ++++ src/utils/config.rs | 227 ++++++++++++++++++++++++++++++++ src/utils/stamps.rs | 71 +++++++++- 4 files changed, 512 insertions(+), 1 deletion(-) diff --git a/src/commands/runtime/build.rs b/src/commands/runtime/build.rs index 14cb534..7cd487c 100644 --- a/src/commands/runtime/build.rs +++ b/src/commands/runtime/build.rs @@ -1,3 +1,4 @@ +use crate::commands::sdk::SdkCompileCommand; use crate::utils::{ config::{ComposedConfig, Config}, container::{RunConfig, SdkContainer}, @@ -231,6 +232,99 @@ impl RuntimeBuildCommand { OutputLevel::Normal, ); + // Check for kernel configuration in the merged runtime config + let merged_runtime = + config.get_merged_runtime_config(&self.runtime_name, target_arch, &self.config_path)?; + let kernel_config = merged_runtime + .as_ref() + .and_then(|v| Config::get_kernel_config_from_runtime(v).ok().flatten()); + + // Handle kernel cross-compilation if kernel.compile is configured + if let Some(ref kc) = kernel_config { + if let (Some(ref compile_section), Some(ref install_script)) = + (&kc.compile, &kc.install) + { + print_info( + &format!( + "Compiling kernel via sdk.compile.{compile_section} for runtime '{}'", + self.runtime_name + ), + OutputLevel::Normal, + ); + + // Step 1: Run the SDK compile section + let compile_command = SdkCompileCommand::new( + self.config_path.clone(), + self.verbose, + vec![compile_section.clone()], + Some(target_arch.to_string()), + self.container_args.clone(), + self.dnf_args.clone(), + ) + .with_sdk_arch(self.sdk_arch.clone()); + + compile_command.execute().await.with_context(|| { + format!( + "Failed to compile kernel SDK section '{compile_section}' for runtime '{}'", + self.runtime_name + ) + })?; + + // Step 2: Run the kernel install script in the SDK container + // The install script copies kernel artifacts to $AVOCADO_RUNTIME_BUILD_DIR + let runtime_build_dir = format!( + "/opt/_avocado/{}/runtimes/{}", + target_arch, self.runtime_name + ); + let install_cmd = format!( + r#"mkdir -p "{runtime_build_dir}" && if [ -f '{install_script}' ]; then echo 'Running kernel install script: {install_script}'; export AVOCADO_RUNTIME_BUILD_DIR="{runtime_build_dir}"; bash '{install_script}'; else echo 'Kernel install script {install_script} not found.'; ls -la; exit 1; fi"# + ); + + if self.verbose { + print_info( + &format!("Running kernel install script: {install_script}"), + OutputLevel::Normal, + ); + } + + let run_config = RunConfig { + container_image: container_image.to_string(), + target: target_arch.to_string(), + command: install_cmd, + verbose: self.verbose, + source_environment: true, + interactive: false, + repo_url: repo_url.cloned(), + repo_release: repo_release.cloned(), + container_args: merged_container_args.clone(), + dnf_args: self.dnf_args.clone(), + sdk_arch: self.sdk_arch.clone(), + ..Default::default() + }; + + let install_result = + run_container_command(container_helper, run_config, runs_on_context) + .await + .context("Failed to run kernel install script")?; + + if !install_result { + return Err(anyhow::anyhow!( + "Kernel install script '{}' failed for runtime '{}'", + install_script, + self.runtime_name + )); + } + + print_success( + &format!( + "Kernel compiled and installed for runtime '{}'", + self.runtime_name + ), + OutputLevel::Normal, + ); + } + } + // Collect extensions with versions for AVOCADO_EXT_LIST // This ensures the build scripts know exactly which extension versions to use let resolved_extensions = self @@ -303,6 +397,16 @@ impl RuntimeBuildCommand { env_vars.insert("AVOCADO_DISTRO_VERSION".to_string(), distro_version.clone()); } + // Set kernel-related environment variables for the avocado-build hook + if let Some(ref kc) = kernel_config { + if kc.compile.is_some() { + env_vars.insert("AVOCADO_KERNEL_SOURCE".to_string(), "compile".to_string()); + } else if let Some(ref package) = kc.package { + env_vars.insert("AVOCADO_KERNEL_SOURCE".to_string(), "package".to_string()); + env_vars.insert("AVOCADO_KERNEL_PACKAGE".to_string(), package.clone()); + } + } + let env_vars = if env_vars.is_empty() { None } else { @@ -1072,4 +1176,90 @@ extensions: assert!(!script.contains("ln -sf /var/lib/avocado/extensions/test-ext-1.0.0.raw $SYSEXT")); assert!(!script.contains("ln -sf /var/lib/avocado/extensions/test-ext-1.0.0.raw $CONFEXT")); } + + #[test] + fn test_kernel_config_parsed_from_runtime() { + let config_content = r#" +sdk: + image: "test-image" + +runtimes: + test-runtime: + target: "x86_64" + kernel: + package: kernel-image + version: "*" + packages: + avocado-img-rootfs: "*" +"#; + let parsed: serde_yaml::Value = serde_yaml::from_str(config_content).unwrap(); + let runtime_val = parsed + .get("runtimes") + .and_then(|r| r.get("test-runtime")) + .unwrap(); + + let kernel_config = + crate::utils::config::Config::get_kernel_config_from_runtime(runtime_val).unwrap(); + assert!(kernel_config.is_some()); + let kc = kernel_config.unwrap(); + assert_eq!(kc.package.as_deref(), Some("kernel-image")); + assert_eq!(kc.version.as_deref(), Some("*")); + assert!(kc.compile.is_none()); + } + + #[test] + fn test_kernel_config_compile_mode_from_runtime() { + let config_content = r#" +sdk: + image: "test-image" + compile: + kernel-build: + compile: kernel-compile.sh + +runtimes: + test-runtime: + target: "x86_64" + kernel: + compile: kernel-build + install: kernel-install.sh + packages: + avocado-img-rootfs: "*" +"#; + let parsed: serde_yaml::Value = serde_yaml::from_str(config_content).unwrap(); + let runtime_val = parsed + .get("runtimes") + .and_then(|r| r.get("test-runtime")) + .unwrap(); + + let kernel_config = + crate::utils::config::Config::get_kernel_config_from_runtime(runtime_val).unwrap(); + assert!(kernel_config.is_some()); + let kc = kernel_config.unwrap(); + assert!(kc.package.is_none()); + assert_eq!(kc.compile.as_deref(), Some("kernel-build")); + assert_eq!(kc.install.as_deref(), Some("kernel-install.sh")); + } + + #[test] + fn test_kernel_config_absent_from_runtime() { + let config_content = r#" +sdk: + image: "test-image" + +runtimes: + test-runtime: + target: "x86_64" + packages: + avocado-img-rootfs: "*" +"#; + let parsed: serde_yaml::Value = serde_yaml::from_str(config_content).unwrap(); + let runtime_val = parsed + .get("runtimes") + .and_then(|r| r.get("test-runtime")) + .unwrap(); + + let kernel_config = + crate::utils::config::Config::get_kernel_config_from_runtime(runtime_val).unwrap(); + assert!(kernel_config.is_none()); + } } diff --git a/src/commands/runtime/install.rs b/src/commands/runtime/install.rs index 15373b7..21fd92c 100644 --- a/src/commands/runtime/install.rs +++ b/src/commands/runtime/install.rs @@ -445,6 +445,31 @@ impl RuntimeInstallCommand { package_names.push(package_name.to_string()); } + // Add kernel package if specified in the runtime kernel config + if let Some(ref merged_val) = merged_runtime { + if let Ok(Some(kernel_config)) = Config::get_kernel_config_from_runtime(merged_val) + { + if let Some(ref kernel_package) = kernel_config.package { + let kernel_version = kernel_config.version.as_deref().unwrap_or("*"); + let package_spec = build_package_spec_with_lock( + lock_file, + &target_arch, + &sysroot, + kernel_package, + kernel_version, + ); + print_info( + &format!( + "Adding kernel package '{kernel_package}' (version: {kernel_version}) for runtime '{runtime}'" + ), + OutputLevel::Normal, + ); + packages.push(package_spec); + package_names.push(kernel_package.to_string()); + } + } + } + if !packages.is_empty() { print_info( &format!( diff --git a/src/utils/config.rs b/src/utils/config.rs index 0c1a453..4b86302 100644 --- a/src/utils/config.rs +++ b/src/utils/config.rs @@ -336,6 +336,8 @@ pub enum ConfigError { #[error("Failed to parse configuration: {0}")] #[allow(dead_code)] ParseError(String), + #[error("Validation error: {0}")] + ValidationError(String), #[error("IO error: {0}")] IoError(#[from] std::io::Error), } @@ -354,6 +356,47 @@ fn default_checksum_algorithm() -> String { "sha256".to_string() } +/// Kernel configuration for a runtime. +/// +/// Two mutually exclusive modes: +/// - `package` (+`version`): kernel installed from an RPM package during `runtime install` +/// - `compile` (+`install`): kernel cross-compiled via `sdk.compile.
` during `runtime build` +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct KernelConfig { + /// Package name for kernel (e.g., "kernel-image"). Mutually exclusive with `compile`. + pub package: Option, + /// Package version spec (e.g., "*"). Only used with `package`. + pub version: Option, + /// SDK compile section name (references `sdk.compile.
`). Mutually exclusive with `package`. + pub compile: Option, + /// Install script path (copies kernel artifacts to runtime build dir). Required when `compile` is set. + pub install: Option, +} + +impl KernelConfig { + /// Validate that the kernel config is well-formed: + /// - `package` and `compile` are mutually exclusive + /// - `compile` requires `install` + pub fn validate(&self) -> Result<(), ConfigError> { + if self.package.is_some() && self.compile.is_some() { + return Err(ConfigError::ValidationError( + "kernel config: 'package' and 'compile' are mutually exclusive".to_string(), + )); + } + if self.compile.is_some() && self.install.is_none() { + return Err(ConfigError::ValidationError( + "kernel config: 'compile' requires 'install' to be set".to_string(), + )); + } + if self.package.is_none() && self.compile.is_none() { + return Err(ConfigError::ValidationError( + "kernel config: either 'package' or 'compile' must be set".to_string(), + )); + } + Ok(()) + } +} + /// Runtime configuration section #[derive(Debug, Clone, Deserialize, Serialize)] pub struct RuntimeConfig { @@ -363,6 +406,9 @@ pub struct RuntimeConfig { pub stone_manifest: Option, /// Signing configuration for this runtime pub signing: Option, + /// Optional kernel configuration. When omitted, the avocado-runtime meta-package + /// handles kernel provisioning via avocado-img-bootfiles (legacy behavior). + pub kernel: Option, } /// SDK configuration section @@ -1715,6 +1761,26 @@ impl Config { self.get_merged_section(§ion_path, target, config_path) } + /// Extract and validate the kernel configuration from a merged runtime config value. + /// + /// Returns `None` if no kernel section is present (legacy behavior applies). + /// Returns an error if the kernel section is present but invalid. + pub fn get_kernel_config_from_runtime( + merged_runtime: &serde_yaml::Value, + ) -> Result> { + match merged_runtime.get("kernel") { + Some(kernel_val) if !kernel_val.is_null() => { + let kernel_config: KernelConfig = serde_yaml::from_value(kernel_val.clone()) + .context("Failed to parse kernel configuration")?; + kernel_config + .validate() + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(Some(kernel_config)) + } + _ => Ok(None), + } + } + /// Get all runtime names defined in the configuration /// /// Returns a sorted list of runtime names from the `runtimes` section. @@ -7165,4 +7231,165 @@ sdk: // When consumed, unwrap_or(0) should yield 0 assert_eq!(config.source_date_epoch.unwrap_or(0), 0); } + + #[test] + fn test_kernel_config_validate_package_mode() { + let kc = KernelConfig { + package: Some("kernel-image".to_string()), + version: Some("*".to_string()), + compile: None, + install: None, + }; + assert!(kc.validate().is_ok()); + } + + #[test] + fn test_kernel_config_validate_compile_mode() { + let kc = KernelConfig { + package: None, + version: None, + compile: Some("kernel-build".to_string()), + install: Some("kernel-install.sh".to_string()), + }; + assert!(kc.validate().is_ok()); + } + + #[test] + fn test_kernel_config_validate_mutually_exclusive() { + let kc = KernelConfig { + package: Some("kernel-image".to_string()), + version: Some("*".to_string()), + compile: Some("kernel-build".to_string()), + install: Some("kernel-install.sh".to_string()), + }; + assert!(kc.validate().is_err()); + } + + #[test] + fn test_kernel_config_validate_compile_requires_install() { + let kc = KernelConfig { + package: None, + version: None, + compile: Some("kernel-build".to_string()), + install: None, + }; + let err = kc.validate().unwrap_err(); + assert!(err.to_string().contains("'compile' requires 'install'")); + } + + #[test] + fn test_kernel_config_validate_neither_set() { + let kc = KernelConfig { + package: None, + version: None, + compile: None, + install: None, + }; + let err = kc.validate().unwrap_err(); + assert!(err.to_string().contains("either 'package' or 'compile'")); + } + + #[test] + fn test_get_kernel_config_from_runtime_package() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +kernel: + package: kernel-image + version: "*" +packages: + avocado-img-rootfs: "*" +"#, + ) + .unwrap(); + + let result = Config::get_kernel_config_from_runtime(&yaml).unwrap(); + assert!(result.is_some()); + let kc = result.unwrap(); + assert_eq!(kc.package.as_deref(), Some("kernel-image")); + assert_eq!(kc.version.as_deref(), Some("*")); + assert!(kc.compile.is_none()); + } + + #[test] + fn test_get_kernel_config_from_runtime_compile() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +kernel: + compile: kernel-build + install: kernel-install.sh +"#, + ) + .unwrap(); + + let result = Config::get_kernel_config_from_runtime(&yaml).unwrap(); + assert!(result.is_some()); + let kc = result.unwrap(); + assert!(kc.package.is_none()); + assert_eq!(kc.compile.as_deref(), Some("kernel-build")); + assert_eq!(kc.install.as_deref(), Some("kernel-install.sh")); + } + + #[test] + fn test_get_kernel_config_from_runtime_absent() { + let yaml: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-img-rootfs: "*" +"#, + ) + .unwrap(); + + let result = Config::get_kernel_config_from_runtime(&yaml).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_kernel_config_deserialization_in_runtime() { + let config_content = r#" +runtimes: + dev: + target: qemux86-64 + kernel: + package: kernel-image + version: "*" + packages: + avocado-img-rootfs: "*" + +sdk: + image: docker.io/avocadolinux/sdk:apollo-edge +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let config = Config::load(temp_file.path()).unwrap(); + let runtimes = config.runtimes.unwrap(); + let dev = runtimes.get("dev").unwrap(); + assert!(dev.kernel.is_some()); + let kc = dev.kernel.as_ref().unwrap(); + assert_eq!(kc.package.as_deref(), Some("kernel-image")); + assert_eq!(kc.version.as_deref(), Some("*")); + } + + #[test] + fn test_kernel_config_absent_in_runtime_deserialization() { + let config_content = r#" +runtimes: + dev: + target: qemux86-64 + packages: + avocado-img-rootfs: "*" + +sdk: + image: docker.io/avocadolinux/sdk:apollo-edge +"#; + + let mut temp_file = NamedTempFile::new().unwrap(); + write!(temp_file, "{config_content}").unwrap(); + + let config = Config::load(temp_file.path()).unwrap(); + let runtimes = config.runtimes.unwrap(); + let dev = runtimes.get("dev").unwrap(); + assert!(dev.kernel.is_none()); + } } diff --git a/src/utils/stamps.rs b/src/utils/stamps.rs index e618402..a32613a 100644 --- a/src/utils/stamps.rs +++ b/src/utils/stamps.rs @@ -735,7 +735,7 @@ pub fn compute_ext_input_hash(config: &serde_yaml::Value, ext_name: &str) -> Res } /// Compute input hash for runtime install -/// Includes: runtime..dependencies (merged with target) +/// Includes: runtime..dependencies (merged with target), kernel config pub fn compute_runtime_input_hash( merged_runtime: &serde_yaml::Value, runtime_name: &str, @@ -758,6 +758,14 @@ pub fn compute_runtime_input_hash( ); } + // Include kernel config if specified (changes to kernel config should trigger rebuild) + if let Some(kernel) = merged_runtime.get("kernel") { + hash_data.insert( + serde_yaml::Value::String(format!("runtime.{runtime_name}.kernel")), + kernel.clone(), + ); + } + let config_hash = compute_config_hash(&serde_yaml::Value::Mapping(hash_data))?; Ok(StampInputs::new(config_hash)) } @@ -2178,4 +2186,65 @@ runtime/my-runtime/build.stamp:::null"#, "Expected --runs-on suggestion in: {msg}" ); } + + #[test] + fn test_runtime_input_hash_includes_kernel() { + let without_kernel: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-img-rootfs: "*" +target: "x86_64" +"#, + ) + .unwrap(); + + let with_kernel: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-img-rootfs: "*" +target: "x86_64" +kernel: + package: kernel-image + version: "*" +"#, + ) + .unwrap(); + + let hash_without = compute_runtime_input_hash(&without_kernel, "dev").unwrap(); + let hash_with = compute_runtime_input_hash(&with_kernel, "dev").unwrap(); + + // Hashes should differ when kernel config is added + assert_ne!(hash_without.config_hash, hash_with.config_hash); + } + + #[test] + fn test_runtime_input_hash_kernel_change_triggers_rebuild() { + let kernel_package: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-img-rootfs: "*" +kernel: + package: kernel-image + version: "*" +"#, + ) + .unwrap(); + + let kernel_compile: serde_yaml::Value = serde_yaml::from_str( + r#" +packages: + avocado-img-rootfs: "*" +kernel: + compile: kernel-build + install: kernel-install.sh +"#, + ) + .unwrap(); + + let hash_package = compute_runtime_input_hash(&kernel_package, "dev").unwrap(); + let hash_compile = compute_runtime_input_hash(&kernel_compile, "dev").unwrap(); + + // Switching kernel mode should produce a different hash + assert_ne!(hash_package.config_hash, hash_compile.config_hash); + } }