From 45b6e162e411f56eeb47f7fd48c427ffb2bf98ad Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Sun, 18 Jan 2026 11:16:34 -0500 Subject: [PATCH 1/4] ensure runs-on works with sdk-arch properly --- src/commands/ext/build.rs | 10 +++ src/commands/init.rs | 144 ++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 + src/utils/container.rs | 10 ++- 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/src/commands/ext/build.rs b/src/commands/ext/build.rs index fd3324c..1642dcf 100644 --- a/src/commands/ext/build.rs +++ b/src/commands/ext/build.rs @@ -145,6 +145,8 @@ impl ExtBuildCommand { container_args: processed_container_args.clone(), dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), + runs_on: self.runs_on.clone(), + nfs_port: self.nfs_port, ..Default::default() }; @@ -548,6 +550,8 @@ impl ExtBuildCommand { container_args: processed_container_args.clone(), dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), + runs_on: self.runs_on.clone(), + nfs_port: self.nfs_port, ..Default::default() }; @@ -620,6 +624,8 @@ impl ExtBuildCommand { container_args: processed_container_args.clone(), dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), + runs_on: self.runs_on.clone(), + nfs_port: self.nfs_port, ..Default::default() }; let result = container_helper.run_in_container(config).await?; @@ -688,6 +694,8 @@ impl ExtBuildCommand { container_args: processed_container_args.clone(), dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), + runs_on: self.runs_on.clone(), + nfs_port: self.nfs_port, ..Default::default() }; let result = container_helper.run_in_container(config).await?; @@ -1681,6 +1689,8 @@ echo "Set proper permissions on authentication files""#, container_args: merged_container_args.clone(), dnf_args: self.dnf_args.clone(), sdk_arch: self.sdk_arch.clone(), + runs_on: self.runs_on.clone(), + nfs_port: self.nfs_port, ..Default::default() }; diff --git a/src/commands/init.rs b/src/commands/init.rs index 3f4278d..22b6dbe 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -1,6 +1,9 @@ use anyhow::{Context, Result}; +use std::collections::HashMap; use std::fs; use std::include_str; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use std::path::Path; const GITHUB_API_BASE: &str = "https://api.github.com"; @@ -26,6 +29,22 @@ struct GitHubContent { git_url: Option, } +/// GitHub Git Trees API response structure +#[derive(serde::Deserialize, Debug)] +struct GitHubTree { + tree: Vec, +} + +/// Individual entry in a Git tree +#[derive(serde::Deserialize, Debug)] +struct GitHubTreeEntry { + path: String, + /// The file mode (e.g., "100644" for regular file, "100755" for executable) + mode: String, + #[serde(rename = "type")] + entry_type: String, +} + /// Command to initialize a new Avocado project with configuration files. /// /// This command creates a new `avocado.yaml` configuration file in the specified @@ -183,16 +202,87 @@ impl InitCommand { Ok(response.status().is_success()) } + /// Fetches file modes from the Git Trees API for a directory. + /// + /// # Arguments + /// * `repo_owner` - The repository owner + /// * `repo_name` - The repository name + /// * `git_ref` - The git ref (branch/commit) to fetch from + /// * `path` - The path within the repository (empty for root) + /// + /// # Returns + /// * `Ok(HashMap)` mapping file paths to their modes + /// * `Err` if there was an error fetching the tree + async fn fetch_file_modes( + repo_owner: &str, + repo_name: &str, + git_ref: &str, + path: &str, + ) -> Result> { + let url = format!( + "{GITHUB_API_BASE}/repos/{repo_owner}/{repo_name}/git/trees/{git_ref}?recursive=1" + ); + + let client = reqwest::Client::builder() + .user_agent("avocado-cli") + .build()?; + + let response = client + .get(&url) + .send() + .await + .with_context(|| format!("Failed to fetch tree from {url}"))?; + + if !response.status().is_success() { + // Return empty map if we can't fetch the tree - files will just not have execute bits + return Ok(HashMap::new()); + } + + let tree: GitHubTree = response + .json() + .await + .with_context(|| "Failed to parse GitHub tree API response")?; + + let mut modes = HashMap::new(); + let prefix = if path.is_empty() { + String::new() + } else { + format!("{}/", path) + }; + + for entry in tree.tree { + if entry.entry_type == "blob" { + // Store mode relative to the requested path + if path.is_empty() { + modes.insert(entry.path, entry.mode); + } else if entry.path.starts_with(&prefix) { + let relative_path = entry.path.strip_prefix(&prefix).unwrap_or(&entry.path); + modes.insert(relative_path.to_string(), entry.mode); + } else if entry.path == path { + // Handle case where path points to a single file + modes.insert(entry.path.clone(), entry.mode); + } + } + } + + Ok(modes) + } + /// Downloads a file from GitHub and saves it to the specified path. /// /// # Arguments /// * `download_url` - The URL to download the file from /// * `dest_path` - The destination path to save the file + /// * `is_executable` - Whether the file should have execute permissions /// /// # Returns /// * `Ok(())` if successful /// * `Err` if there was an error downloading or saving the file - async fn download_file(download_url: &str, dest_path: &Path) -> Result<()> { + async fn download_file( + download_url: &str, + dest_path: &Path, + is_executable: bool, + ) -> Result<()> { let client = reqwest::Client::builder() .user_agent("avocado-cli") .build()?; @@ -218,9 +308,25 @@ impl InitCommand { .with_context(|| format!("Failed to create directory '{}'", parent.display()))?; } - fs::write(dest_path, content) + fs::write(dest_path, &content) .with_context(|| format!("Failed to write file '{}'", dest_path.display()))?; + // Set executable permissions on Unix if the file should be executable + #[cfg(unix)] + if is_executable { + let mut perms = fs::metadata(dest_path) + .with_context(|| format!("Failed to get metadata for '{}'", dest_path.display()))? + .permissions(); + // Add execute bits for owner, group, and others (respecting existing read bits) + let mode = perms.mode(); + // Set execute bit for each read bit that's set (e.g., 0o644 -> 0o755) + let new_mode = mode | ((mode & 0o444) >> 2); + perms.set_mode(new_mode); + fs::set_permissions(dest_path, perms).with_context(|| { + format!("Failed to set permissions for '{}'", dest_path.display()) + })?; + } + Ok(()) } @@ -366,6 +472,7 @@ impl InitCommand { /// * `repo_owner` - The repository owner /// * `repo_name` - The repository name /// * `git_ref` - The git ref (branch/commit) to fetch from + /// * `file_modes` - Optional map of file paths to their git modes /// /// # Returns /// * `Ok(())` if successful @@ -377,6 +484,7 @@ impl InitCommand { repo_owner: &'a str, repo_name: &'a str, git_ref: &'a str, + file_modes: &'a HashMap, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let url = format!( @@ -453,7 +561,13 @@ impl InitCommand { "file" => { if let Some(ref download_url) = item.download_url { println!(" Downloading {relative_path}..."); - Self::download_file(download_url, &local_path).await?; + // Check if this file should be executable based on its mode + let is_executable = file_modes + .get(&item.path) + .map(|mode| mode == "100755") + .unwrap_or(false); + Self::download_file(download_url, &local_path, is_executable) + .await?; } } "dir" => { @@ -469,6 +583,7 @@ impl InitCommand { repo_owner, repo_name, git_ref, + file_modes, ) .await?; } @@ -593,6 +708,9 @@ impl InitCommand { local_path: &'a Path, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { + // Fetch file modes for the submodule + let file_modes = Self::fetch_file_modes(repo_owner, repo_name, git_ref, path).await?; + let url = if path.is_empty() { format!("{GITHUB_API_BASE}/repos/{repo_owner}/{repo_name}/contents?ref={git_ref}") } else { @@ -660,7 +778,14 @@ impl InitCommand { match item.item_type.as_str() { "file" => { if let Some(ref download_url) = item.download_url { - Self::download_file(download_url, &item_local_path).await?; + // Check if this file should be executable + let is_executable = file_modes + .get(&item.path) + .or_else(|| file_modes.get(item_name)) + .map(|mode| mode == "100755") + .unwrap_or(false); + Self::download_file(download_url, &item_local_path, is_executable) + .await?; } } "dir" => { @@ -813,6 +938,16 @@ impl InitCommand { println!("✓ Target '{target}' is supported by reference '{ref_name}'."); } + // Fetch file modes for the reference directory to preserve execute permissions + let reference_path = format!("{REFERENCES_PATH}/{ref_name}"); + let file_modes = Self::fetch_file_modes( + self.get_repo_owner(), + self.get_repo_name(), + self.get_git_ref(), + &reference_path, + ) + .await?; + // Download all contents from the reference println!("Downloading reference contents..."); Self::download_reference_contents( @@ -822,6 +957,7 @@ impl InitCommand { self.get_repo_owner(), self.get_repo_name(), self.get_git_ref(), + &file_modes, ) .await?; diff --git a/src/main.rs b/src/main.rs index 4ddda6d..5d7a190 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1043,6 +1043,7 @@ async fn main() -> Result<()> { dnf_args, ) .with_no_stamps(cli.no_stamps) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) .with_sdk_arch(cli.sdk_arch.clone()); build_cmd.execute().await?; Ok(()) @@ -1243,6 +1244,7 @@ async fn main() -> Result<()> { dnf_args, ) .with_no_stamps(cli.no_stamps) + .with_runs_on(cli.runs_on.clone(), cli.nfs_port) .with_sdk_arch(cli.sdk_arch.clone()); build_cmd.execute().await?; Ok(()) diff --git a/src/utils/container.rs b/src/utils/container.rs index e90ff10..fb78dfd 100644 --- a/src/utils/container.rs +++ b/src/utils/container.rs @@ -609,7 +609,15 @@ impl SdkContainer { // Always add platform flag to ensure Docker uses the correct image variant. // This prevents Docker from caching only one variant when switching between // native and cross-arch emulated runs. - let platform = get_container_platform(config.sdk_arch.as_deref())?; + // When --sdk-arch is not provided but --runs-on is, default to the remote's architecture + let platform = if let Some(sdk_arch) = config.sdk_arch.as_deref() { + // User explicitly provided --sdk-arch, use that + get_container_platform(Some(sdk_arch))? + } else { + // No --sdk-arch provided, use the remote host's architecture + let remote_arch = context.get_host_arch().await?; + sdk_arch_to_platform(&remote_arch)? + }; extra_args.push("--platform".to_string()); extra_args.push(platform); From 3c1bddea8cca1332552afa0e95d96b2cd674bb73 Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 4 Feb 2026 16:37:44 -0500 Subject: [PATCH 2/4] update avocado build to support multiple runtimes sharing extensions --- src/commands/build.rs | 59 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/src/commands/build.rs b/src/commands/build.rs index 1781b75..0cdb207 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1071,7 +1071,8 @@ echo "Successfully created image for versioned extension '$EXT_NAME-$EXT_VERSION "Step 1/4: Analyzing runtime dependencies", OutputLevel::Normal, ); - let required_extensions = self.find_extensions_for_runtime(config, &runtime_config)?; + let required_extensions = + self.find_extensions_for_runtime(config, parsed, &runtime_config, target)?; // Note: SDK compile sections are now compiled on-demand when extensions are built // This prevents duplicate compilation when sdk.compile sections are also extension dependencies @@ -1252,12 +1253,66 @@ echo "Successfully created image for versioned extension '$EXT_NAME-$EXT_VERSION fn find_extensions_for_runtime( &self, config: &Config, + parsed: &serde_yaml::Value, runtime_config: &serde_yaml::Value, + target: &str, ) -> Result> { + use crate::utils::interpolation::interpolate_name; + let mut required_extensions = HashSet::new(); let mut visited = HashSet::new(); - // Check runtime dependencies for extensions + // Build a map of interpolated ext names to their source config + // This is needed because ext section keys may contain templates like {{ avocado.target }} + let mut ext_sources: std::collections::HashMap> = + std::collections::HashMap::new(); + if let Some(ext_section) = parsed.get("extensions").and_then(|e| e.as_mapping()) { + for (ext_key, ext_config) in ext_section { + if let Some(raw_name) = ext_key.as_str() { + // Interpolate the extension name with the target + let interpolated_name = interpolate_name(raw_name, target); + // Use parse_extension_source which properly deserializes the source field + let source = Config::parse_extension_source(&interpolated_name, ext_config) + .ok() + .flatten(); + ext_sources.insert(interpolated_name, source); + } + } + } + + // Check extensions from the new `extensions` array format + if let Some(extensions) = runtime_config + .get("extensions") + .and_then(|e| e.as_sequence()) + { + for ext in extensions { + if let Some(ext_name) = ext.as_str() { + // Check if this extension has a source: field (remote extension) + if let Some(Some(source)) = ext_sources.get(ext_name) { + // Remote extension with source field + required_extensions.insert(ExtensionDependency::Remote { + name: ext_name.to_string(), + source: source.clone(), + }); + } else { + // Local extension (defined in ext section without source, or not in ext section) + required_extensions + .insert(ExtensionDependency::Local(ext_name.to_string())); + + // Also check local extension dependencies + self.find_local_extension_dependencies( + config, + parsed, + ext_name, + &mut required_extensions, + &mut visited, + )?; + } + } + } + } + + // Check runtime dependencies for extensions (old packages format for backwards compatibility) if let Some(dependencies) = runtime_config.get("packages").and_then(|d| d.as_mapping()) { for (_dep_name, dep_spec) in dependencies { // Check for extension dependency From 25211fe77f62ba49438b2e561357fc7607379ced Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 4 Feb 2026 21:57:48 -0500 Subject: [PATCH 3/4] bump bytes to 1.11.1 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cda8b6..b787cb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,9 +205,9 @@ checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" From d8d244b783015271fa3194badecc77b516410c8f Mon Sep 17 00:00:00 2001 From: Justin Schneck Date: Wed, 4 Feb 2026 22:02:45 -0500 Subject: [PATCH 4/4] 0.24.0 release --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b787cb2..8520dce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -130,7 +130,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "avocado-cli" -version = "0.23.1" +version = "0.24.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 71a0075..7af9e97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "avocado-cli" -version = "0.23.1" +version = "0.24.0" edition = "2021" description = "Command line interface for Avocado." authors = ["Avocado"]