Skip to content
Merged
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
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
59 changes: 57 additions & 2 deletions src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Vec<ExtensionDependency>> {
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<String, Option<ExtensionSource>> =
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
Expand Down
10 changes: 10 additions & 0 deletions src/commands/ext/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};

Expand Down Expand Up @@ -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()
};

Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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?;
Expand Down Expand Up @@ -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()
};

Expand Down
144 changes: 140 additions & 4 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -26,6 +29,22 @@ struct GitHubContent {
git_url: Option<String>,
}

/// GitHub Git Trees API response structure
#[derive(serde::Deserialize, Debug)]
struct GitHubTree {
tree: Vec<GitHubTreeEntry>,
}

/// 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
Expand Down Expand Up @@ -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<String, String>)` 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<HashMap<String, String>> {
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()?;
Expand All @@ -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(())
}

Expand Down Expand Up @@ -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
Expand All @@ -377,6 +484,7 @@ impl InitCommand {
repo_owner: &'a str,
repo_name: &'a str,
git_ref: &'a str,
file_modes: &'a HashMap<String, String>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let url = format!(
Expand Down Expand Up @@ -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" => {
Expand All @@ -469,6 +583,7 @@ impl InitCommand {
repo_owner,
repo_name,
git_ref,
file_modes,
)
.await?;
}
Expand Down Expand Up @@ -593,6 +708,9 @@ impl InitCommand {
local_path: &'a Path,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + 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 {
Expand Down Expand Up @@ -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" => {
Expand Down Expand Up @@ -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(
Expand All @@ -822,6 +957,7 @@ impl InitCommand {
self.get_repo_owner(),
self.get_repo_name(),
self.get_git_ref(),
&file_modes,
)
.await?;

Expand Down
Loading