diff --git a/Cargo.lock b/Cargo.lock index 621f12177..c8634676f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4233,6 +4233,7 @@ dependencies = [ "ratatui", "rattler", "rattler_build_recipe_generator", + "rattler_build_script", "rattler_cache", "rattler_conda_types", "rattler_config", @@ -4321,6 +4322,29 @@ dependencies = [ "zip 5.1.1", ] +[[package]] +name = "rattler_build_script" +version = "0.1.0" +dependencies = [ + "clap", + "console 0.16.1", + "fs-err", + "futures", + "indexmap 2.11.4", + "insta", + "itertools 0.14.0", + "minijinja", + "rattler_conda_types", + "rattler_shell", + "serde", + "serde_yaml", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", + "which", +] + [[package]] name = "rattler_cache" version = "0.3.37" diff --git a/Cargo.toml b/Cargo.toml index 44f758296..cbe45a40c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rust-tests", "crates/rattler_build_recipe_generator"] +members = ["rust-tests", "crates/rattler_build_recipe_generator", "crates/rattler_build_script"] [workspace.package] authors = ["rattler-build contributors "] @@ -12,16 +12,20 @@ repository = "https://github.com/prefix-dev/rattler-build" async-once-cell = "0.5.4" async-recursion = "1.1.1" clap = { version = "4.5.45", features = ["derive"] } +console = { version = "0.16.1", features = ["windows-console-colors"] } flate2 = "1.1.2" fs-err = "3.1.1" +futures = "0.3.31" indexmap = "2.11.4" insta = "1.41.1" itertools = "0.14.0" miette = "7.6.0" +minijinja = { version = "2.11.0", features = ["unstable_machinery", "custom_syntax"] } rattler_conda_types = { version = "0.40.0", default-features = false } rattler_digest = { version = "1.1.5", default-features = false, features = [ "serde", ] } +rattler_shell = { version = "0.25.2", default-features = false, features = ["sysinfo"] } regex = "1.11.1" reqwest = { version = "0.12.23", default-features = false, features = ["json"] } serde = { version = "1.0.219", features = ["derive"] } @@ -31,10 +35,13 @@ serde_yaml = "0.9.34" sha2 = "0.10.9" tar = "0.4.44" tempfile = "3.21.0" +thiserror = "2.0.17" tokio = { version = "1.47.1", features = ["rt", "macros"] } +tokio-util = { version = "0.7.16", features = ["codec", "compat"] } toml = "0.9.5" tracing = "0.1.41" url = "2.5.4" +which = "8.0.0" zip = { version = "5.1.1", default-features = false, features = ["deflate"] } [package] @@ -111,10 +118,7 @@ serde_with = { workspace = true } url = { workspace = true } tracing = { workspace = true } clap = { workspace = true, features = ["env", "cargo"] } -minijinja = { version = "2.11.0", features = [ - "unstable_machinery", - "custom_syntax", -] } +minijinja = { workspace = true } tracing-subscriber = { version = "0.3.19", features = [ "env-filter", "fmt", @@ -128,10 +132,10 @@ goblin = "0.10.1" scroll = "0.13.0" pathdiff = "0.2.3" comfy-table = "7.1.4" -futures = "0.3.31" +futures = { workspace = true } indicatif = { version = "0.18.0", features = ["in_memory"] } -console = { version = "0.16.1", features = ["windows-console-colors"] } -thiserror = "2.0.17" +console = { workspace = true } +thiserror = { workspace = true } tempfile = { workspace = true } chrono = "0.4.41" sha1 = "0.10.6" @@ -144,10 +148,10 @@ petgraph = "0.8.2" indexmap = { workspace = true } dunce = "1.0.5" fs-err = { workspace = true } -which = "8.0.0" +which = { workspace = true } clap_complete = "4.5.57" clap_complete_nushell = "4.5.8" -tokio-util = { version = "0.7.16", features = ["codec", "compat"] } +tokio-util = { workspace = true } tar = { workspace = true } zip = { workspace = true } @@ -196,9 +200,7 @@ rattler_redaction = { version = "0.1.12" } rattler_repodata_gateway = { version = "0.24.7", default-features = false, features = [ "gateway", ] } -rattler_shell = { version = "0.25.2", default-features = false, features = [ - "sysinfo", -] } +rattler_shell = { workspace = true } rattler_solve = { version = "3.0.5", default-features = false, features = [ "resolvo", "serde", @@ -223,6 +225,7 @@ dialoguer = "0.12.0" rattler_build_recipe_generator = { path = "crates/rattler_build_recipe_generator", optional = true, features = [ "cli", ] } +rattler_build_script = { path = "crates/rattler_build_script" } [target.'cfg(not(target_os = "windows"))'.dependencies] sha2 = { workspace = true, features = ["asm"] } diff --git a/crates/rattler_build_script/Cargo.toml b/crates/rattler_build_script/Cargo.toml new file mode 100644 index 000000000..c571dde4c --- /dev/null +++ b/crates/rattler_build_script/Cargo.toml @@ -0,0 +1,52 @@ +[package] +name = "rattler_build_script" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +readme = "README.md" +repository.workspace = true +description = "Script execution and data model for rattler-build" + +[features] +default = ["execution"] +execution = [ + "itertools", + "thiserror", + "tracing", + "futures", + "tokio", + "tokio-util", + "rattler_conda_types", + "rattler_shell", + "fs-err", + "which", + "minijinja", +] + +[dependencies] +# Core dependencies (always needed) +indexmap = { workspace = true } +serde = { workspace = true } + +# Sandbox dependencies +console = { workspace = true } +clap = { workspace = true } + +# Execution dependencies (optional) +itertools = { workspace = true, optional = true } +thiserror = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +tokio = { workspace = true, features = ["process", "io-util", "fs"], optional = true } +tokio-util = { workspace = true, optional = true } +rattler_conda_types = { workspace = true, optional = true } +rattler_shell = { workspace = true, optional = true } +fs-err = { workspace = true, optional = true } +which = { workspace = true, optional = true } +minijinja = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros", "fs"] } +insta = { workspace = true, features = ["yaml"] } +serde_yaml = { workspace = true } diff --git a/src/script/mod.rs b/crates/rattler_build_script/src/execution.rs similarity index 68% rename from src/script/mod.rs rename to crates/rattler_build_script/src/execution.rs index e5a4cee27..2c6c8a298 100644 --- a/src/script/mod.rs +++ b/crates/rattler_build_script/src/execution.rs @@ -1,43 +1,19 @@ -//! Module for running scripts in different interpreters. -mod interpreter; -mod sandbox; -pub use interpreter::InterpreterError; -pub use sandbox::{SandboxArguments, SandboxConfiguration}; +//! Script execution types and utilities. -use crate::script::interpreter::Interpreter; +use crate::sandbox::SandboxConfiguration; +use crate::script::{Script, ScriptContent}; use futures::TryStreamExt; use indexmap::IndexMap; -use interpreter::{ - BASH_PREAMBLE, BashInterpreter, CMDEXE_PREAMBLE, CmdExeInterpreter, NodeJsInterpreter, - NuShellInterpreter, PerlInterpreter, PythonInterpreter, RInterpreter, RubyInterpreter, -}; use itertools::Itertools; -use minijinja::Value; use rattler_conda_types::Platform; -use rattler_shell::shell; -use std::{ - collections::HashMap, - collections::HashSet, - io, - path::{Path, PathBuf}, - process::Stdio, -}; +use std::collections::HashMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::process::Stdio; use tokio::io::{AsyncBufReadExt, AsyncRead}; -use tokio_util::{ - bytes::BytesMut, - codec::{Decoder, FramedRead}, - compat::FuturesAsyncReadCompatExt, -}; - -use crate::{ - env_vars::{self}, - metadata::Debug, - metadata::Output, - recipe::{ - Jinja, - parser::{Script, ScriptContent}, - }, -}; +use tokio_util::bytes::BytesMut; +use tokio_util::codec::{Decoder, FramedRead}; +use tokio_util::compat::FuturesAsyncReadCompatExt; /// Arguments for executing a script in a given interpreter. #[derive(Debug)] @@ -134,7 +110,109 @@ impl ResolvedScriptContents { } } +/// Debug mode for script execution +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Debug(bool); + +impl Debug { + /// Create a new Debug mode + pub fn new(enabled: bool) -> Self { + Self(enabled) + } + + /// Check if debug mode is enabled + pub fn is_enabled(&self) -> bool { + self.0 + } +} + +impl From for Debug { + fn from(enabled: bool) -> Self { + Self(enabled) + } +} + impl Script { + /// Run the script with the given parameters + /// + /// This is a high-level convenience method that handles the full script execution flow: + /// - Resolves script content (from file or inline) + /// - Sets up environment variables and secrets + /// - Configures the working directory + /// - Renders Jinja templates if a renderer is provided + /// - Executes the script in the appropriate interpreter + #[allow(clippy::too_many_arguments)] + pub async fn run_script( + &self, + env_vars: HashMap>, + work_dir: &Path, + recipe_dir: &Path, + run_prefix: &Path, + build_prefix: Option<&PathBuf>, + jinja_renderer: Option, + sandbox_config: Option<&SandboxConfiguration>, + debug: Debug, + ) -> Result<(), crate::InterpreterError> + where + F: Fn(&str) -> Result, + { + // Determine the valid script extensions based on the available interpreters. + let mut valid_script_extensions = Vec::new(); + if cfg!(windows) { + valid_script_extensions.push("bat"); + } else { + valid_script_extensions.push("sh"); + } + + let env_vars = env_vars + .into_iter() + .filter_map(|(k, v)| v.map(|v| (k, v))) + .chain(self.env().clone().into_iter()) + .collect::>(); + + let contents = + self.resolve_content(recipe_dir, jinja_renderer, &valid_script_extensions)?; + + let secrets = self + .secrets() + .iter() + .filter_map(|k| { + let secret = k.to_string(); + + if let Ok(value) = std::env::var(&secret) { + Some((secret, value)) + } else { + tracing::warn!("Secret {} not found in environment", secret); + None + } + }) + .collect::>(); + + let work_dir = if let Some(cwd) = self.cwd.as_ref() { + run_prefix.join(cwd) + } else { + work_dir.to_owned() + }; + + tracing::debug!("Running script in {}", work_dir.display()); + + let exec_args = ExecutionArgs { + script: contents, + env_vars, + secrets, + build_prefix: build_prefix.map(|p| p.to_owned()), + run_prefix: run_prefix.to_owned(), + execution_platform: Platform::current(), + work_dir, + sandbox_config: sandbox_config.cloned(), + debug, + }; + + crate::execution::run_script(exec_args, self.interpreter()).await?; + + Ok(()) + } + fn find_file(&self, recipe_dir: &Path, extensions: &[&str], path: &Path) -> Option { let path = if path.is_absolute() { path.to_path_buf() @@ -154,12 +232,19 @@ impl Script { } } - pub(crate) fn resolve_content( + /// Resolve the script content to actual script text + /// + /// If `jinja_renderer` is provided, it will be used to render inline scripts. + /// The renderer function takes a template string and returns the rendered result. + pub fn resolve_content( &self, recipe_dir: &Path, - jinja_context: Option, + jinja_renderer: Option, extensions: &[&str], - ) -> Result { + ) -> Result + where + F: Fn(&str) -> Result, + { let script_content = match self.contents() { // No script was specified, so we try to read the default script. If the file cannot be // found we return an empty string. @@ -227,10 +312,10 @@ impl Script { }; // render jinja if it is an inline script - if let Some(jinja_context) = jinja_context { + if let Some(renderer) = jinja_renderer { match script_content? { ResolvedScriptContents::Inline(script) => { - let rendered = jinja_context.render_str(&script).map_err(|e| { + let rendered = renderer(&script).map_err(|e| { std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to render jinja template in build `script`: {}", e), @@ -244,267 +329,6 @@ impl Script { script_content } } - - /// Run the script with the given parameters - #[allow(clippy::too_many_arguments)] - pub async fn run_script( - &self, - env_vars: HashMap>, - work_dir: &Path, - recipe_dir: &Path, - run_prefix: &Path, - build_prefix: Option<&PathBuf>, - mut jinja_config: Option, - sandbox_config: Option<&SandboxConfiguration>, - debug: Debug, - ) -> Result<(), InterpreterError> { - // Determine the valid script extensions based on the available interpreters. - let mut valid_script_extensions = Vec::new(); - if cfg!(windows) { - valid_script_extensions.push("bat"); - } else { - valid_script_extensions.push("sh"); - } - - let env_vars = env_vars - .into_iter() - .filter_map(|(k, v)| v.map(|v| (k, v))) - .chain(self.env().clone().into_iter()) - .collect::>(); - - // Get the contents of the script. - for (k, v) in &env_vars { - jinja_config.as_mut().map(|jinja| { - jinja - .context_mut() - .insert(k.clone(), Value::from_safe_string(v.clone())) - }); - } - - let contents = self.resolve_content(recipe_dir, jinja_config, &valid_script_extensions)?; - - let secrets = self - .secrets() - .iter() - .filter_map(|k| { - let secret = k.to_string(); - - if let Ok(value) = std::env::var(&secret) { - Some((secret, value)) - } else { - tracing::warn!("Secret {} not found in environment", secret); - None - } - }) - .collect::>(); - - let work_dir = if let Some(cwd) = self.cwd.as_ref() { - run_prefix.join(cwd) - } else { - work_dir.to_owned() - }; - - tracing::debug!("Running script in {}", work_dir.display()); - - let exec_args = ExecutionArgs { - script: contents, - env_vars, - secrets, - build_prefix: build_prefix.map(|p| p.to_owned()), - run_prefix: run_prefix.to_owned(), - execution_platform: Platform::current(), - work_dir, - sandbox_config: sandbox_config.cloned(), - debug, - }; - - match self.interpreter() { - "nushell" | "nu" => NuShellInterpreter.run(exec_args).await?, - "bash" => BashInterpreter.run(exec_args).await?, - "cmd" => CmdExeInterpreter.run(exec_args).await?, - "python" => PythonInterpreter.run(exec_args).await?, - "perl" => PerlInterpreter.run(exec_args).await?, - "rscript" => RInterpreter.run(exec_args).await?, - "ruby" => RubyInterpreter.run(exec_args).await?, - "node" | "nodejs" => NodeJsInterpreter.run(exec_args).await?, - _ => { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Unsupported interpreter: {}", self.interpreter()), - ) - .into()); - } - }; - - Ok(()) - } -} - -impl Output { - /// Add environment variables from the variant to the environment variables. - fn env_vars_from_variant(&self) -> HashMap> { - let languages: HashSet<&str> = - HashSet::from(["PERL", "LUA", "R", "NUMPY", "PYTHON", "RUBY", "NODEJS"]); - self.variant() - .iter() - .filter_map(|(k, v)| { - let key_upper = k.normalize().to_uppercase(); - if !languages.contains(key_upper.as_str()) { - Some((k.normalize(), Some(v.to_string()))) - } else { - None - } - }) - .collect() - } - - /// Helper method to prepare build script execution arguments - async fn prepare_build_script(&self) -> Result { - let host_prefix = self.build_configuration.directories.host_prefix.clone(); - let target_platform = self.build_configuration.target_platform; - let mut env_vars = env_vars::vars(self, "BUILD"); - env_vars.extend(env_vars::os_vars(&host_prefix, &target_platform)); - env_vars.extend(self.env_vars_from_variant()); - - let selector_config = self.build_configuration.selector_config(); - let jinja = Jinja::new(selector_config.clone()).with_context(&self.recipe.context); - - let build_prefix = if self.recipe.build().merge_build_and_host_envs() { - None - } else { - Some(&self.build_configuration.directories.build_prefix) - }; - - let work_dir = &self.build_configuration.directories.work_dir; - Ok(ExecutionArgs { - script: self.recipe.build().script().resolve_content( - &self.build_configuration.directories.recipe_dir, - Some(jinja.clone()), - if cfg!(windows) { &["bat"] } else { &["sh"] }, - )?, - env_vars: env_vars - .into_iter() - .filter_map(|(k, v)| v.map(|v| (k, v))) - .collect(), - secrets: IndexMap::new(), - build_prefix: build_prefix.map(|p| p.to_owned()), - run_prefix: host_prefix, - execution_platform: Platform::current(), - work_dir: work_dir.clone(), - sandbox_config: self.build_configuration.sandbox_config().cloned(), - debug: self.build_configuration.debug, - }) - } - - /// Run the build script for the output as defined in the recipe's build section. - /// - /// This method executes the build script with the configured environment variables, - /// working directory, and other build settings. The script execution respects the - /// configured interpreter (bash/cmd/nushell) and sandbox settings. - /// - /// # Errors - /// - /// Returns an `std::io::Error` if: - /// - The script file cannot be read or found - /// - The script execution fails - /// - The interpreter is not supported or not available - pub async fn run_build_script(&self) -> Result<(), InterpreterError> { - let span = tracing::info_span!("Running build script"); - let _enter = span.enter(); - - let exec_args = self.prepare_build_script().await?; - let build_prefix = if self.recipe.build().merge_build_and_host_envs() { - None - } else { - Some(&self.build_configuration.directories.build_prefix) - }; - - self.recipe - .build() - .script() - .run_script( - exec_args - .env_vars - .into_iter() - .map(|(k, v)| (k, Some(v))) - .collect(), - &self.build_configuration.directories.work_dir, - &self.build_configuration.directories.recipe_dir, - &self.build_configuration.directories.host_prefix, - build_prefix, - Some( - Jinja::new(self.build_configuration.selector_config()) - .with_context(&self.recipe.context), - ), - self.build_configuration.sandbox_config(), - self.build_configuration.debug, - ) - .await?; - - Ok(()) - } - - /// Create the build script files without executing them. - /// - /// This method generates the build script and environment setup files in the working - /// directory but does not execute them. This is useful for debugging or when you want - /// to inspect or modify the scripts before running them manually. - /// - /// The method creates two files: - /// - A build environment setup file (`build_env.sh`/`build_env.bat`) - /// - The main build script file (`conda_build.sh`/`conda_build.bat`) - /// - /// # Errors - /// - /// Returns an `std::io::Error` if: - /// - The script file cannot be read or found - /// - The script files cannot be written to the working directory - pub async fn create_build_script(&self) -> Result<(), std::io::Error> { - let span = tracing::info_span!("Creating build script"); - let _enter = span.enter(); - - let exec_args = self.prepare_build_script().await?; - let interpreter = if cfg!(windows) { "cmd" } else { "bash" }; - let work_dir = &self.build_configuration.directories.work_dir; - - if interpreter == "bash" { - let script = BashInterpreter.get_script(&exec_args, shell::Bash).unwrap(); - let build_env_path = work_dir.join("build_env.sh"); - let build_script_path = work_dir.join("conda_build.sh"); - - tokio::fs::write(&build_env_path, script).await?; - - let preamble = - BASH_PREAMBLE.replace("((script_path))", &build_env_path.to_string_lossy()); - let script = format!("{}\n{}", preamble, exec_args.script.script()); - tokio::fs::write(&build_script_path, script).await?; - - tracing::info!("Build script created at {}", build_script_path.display()); - } else if interpreter == "cmd" { - let script = CmdExeInterpreter - .get_script(&exec_args, shell::CmdExe) - .unwrap(); - let build_env_path = work_dir.join("build_env.bat"); - let build_script_path = work_dir.join("conda_build.bat"); - - tokio::fs::write(&build_env_path, script).await?; - - let build_script = format!( - "{}\n{}", - CMDEXE_PREAMBLE.replace("((script_path))", &build_env_path.to_string_lossy()), - exec_args.script.script() - ); - tokio::fs::write( - &build_script_path, - &build_script.replace('\n', "\r\n").as_bytes(), - ) - .await?; - - tracing::info!("Build script created at {}", build_script_path.display()); - } - - Ok(()) - } } /// An AsyncRead wrapper that replaces carriage return (\r) bytes with newline (\n) bytes. @@ -557,6 +381,82 @@ impl Decoder for CrLfNormalizer { } } +use crate::interpreter::{ + BASH_PREAMBLE, BashInterpreter, CMDEXE_PREAMBLE, CmdExeInterpreter, Interpreter, + NodeJsInterpreter, NuShellInterpreter, PerlInterpreter, PythonInterpreter, RInterpreter, + RubyInterpreter, +}; +use rattler_shell::shell; + +/// Run a script with the given execution arguments and interpreter +pub async fn run_script( + exec_args: ExecutionArgs, + interpreter: &str, +) -> Result<(), crate::InterpreterError> { + match interpreter { + "nushell" | "nu" => NuShellInterpreter.run(exec_args).await?, + "bash" => BashInterpreter.run(exec_args).await?, + "cmd" => CmdExeInterpreter.run(exec_args).await?, + "python" => PythonInterpreter.run(exec_args).await?, + "perl" => PerlInterpreter.run(exec_args).await?, + "rscript" => RInterpreter.run(exec_args).await?, + "ruby" => RubyInterpreter.run(exec_args).await?, + "node" | "nodejs" => NodeJsInterpreter.run(exec_args).await?, + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Unsupported interpreter: {}", interpreter), + ) + .into()); + } + }; + + Ok(()) +} + +/// Create build script files without executing them +pub async fn create_build_script(exec_args: ExecutionArgs) -> Result<(), std::io::Error> { + let interpreter = if cfg!(windows) { "cmd" } else { "bash" }; + let work_dir = &exec_args.work_dir; + + if interpreter == "bash" { + let script = BashInterpreter.get_script(&exec_args, shell::Bash).unwrap(); + let build_env_path = work_dir.join("build_env.sh"); + let build_script_path = work_dir.join("conda_build.sh"); + + tokio::fs::write(&build_env_path, script).await?; + + let preamble = BASH_PREAMBLE.replace("((script_path))", &build_env_path.to_string_lossy()); + let script = format!("{}\n{}", preamble, exec_args.script.script()); + tokio::fs::write(&build_script_path, script).await?; + + tracing::info!("Build script created at {}", build_script_path.display()); + } else if interpreter == "cmd" { + let script = CmdExeInterpreter + .get_script(&exec_args, shell::CmdExe) + .unwrap(); + let build_env_path = work_dir.join("build_env.bat"); + let build_script_path = work_dir.join("conda_build.bat"); + + tokio::fs::write(&build_env_path, script).await?; + + let build_script = format!( + "{}\n{}", + CMDEXE_PREAMBLE.replace("((script_path))", &build_env_path.to_string_lossy()), + exec_args.script.script() + ); + tokio::fs::write( + &build_script_path, + &build_script.replace('\n', "\r\n").as_bytes(), + ) + .await?; + + tracing::info!("Build script created at {}", build_script_path.display()); + } + + Ok(()) +} + /// Find the rattler-sandbox executable in PATH fn find_rattler_sandbox() -> Option { which::which("rattler-sandbox").ok() @@ -564,7 +464,7 @@ fn find_rattler_sandbox() -> Option { /// Spawns a process and replaces the given strings in the output with the given replacements. /// This is used to replace the host prefix with $PREFIX and the build prefix with $BUILD_PREFIX -async fn run_process_with_replacements( +pub async fn run_process_with_replacements( args: &[&str], cwd: &Path, replacements: &HashMap, @@ -676,7 +576,7 @@ mod tests { #[test] fn test_cmd_errorlevel_injected() { - use crate::recipe::parser::{Script, ScriptContent}; + use crate::script::{Script, ScriptContent}; let commands = vec!["echo Hello".to_string(), "echo World".to_string()]; let script = Script { content: ScriptContent::Commands(commands.clone()), @@ -691,7 +591,11 @@ mod tests { let extensions = &["bat"]; let resolved = script - .resolve_content(recipe_dir, None, extensions) + .resolve_content( + recipe_dir, + None:: Result>, + extensions, + ) .unwrap(); if cfg!(windows) { diff --git a/src/script/interpreter/bash.rs b/crates/rattler_build_script/src/interpreter/bash.rs similarity index 97% rename from src/script/interpreter/bash.rs rename to crates/rattler_build_script/src/interpreter/bash.rs index 7fc883dc3..11619e103 100644 --- a/src/script/interpreter/bash.rs +++ b/crates/rattler_build_script/src/interpreter/bash.rs @@ -3,11 +3,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; use rattler_shell::shell; -use crate::script::{ExecutionArgs, run_process_with_replacements}; +use crate::execution::{ExecutionArgs, run_process_with_replacements}; use super::{BASH_PREAMBLE, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct BashInterpreter; +pub struct BashInterpreter; fn print_debug_info(args: &ExecutionArgs) -> String { let mut output = String::new(); diff --git a/src/script/interpreter/cmd_exe.rs b/crates/rattler_build_script/src/interpreter/cmd_exe.rs similarity index 96% rename from src/script/interpreter/cmd_exe.rs rename to crates/rattler_build_script/src/interpreter/cmd_exe.rs index b99fffe5b..77952630a 100644 --- a/src/script/interpreter/cmd_exe.rs +++ b/crates/rattler_build_script/src/interpreter/cmd_exe.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use rattler_conda_types::Platform; use rattler_shell::shell; -use crate::script::{ExecutionArgs, run_process_with_replacements}; +use crate::execution::{ExecutionArgs, run_process_with_replacements}; use super::{CMDEXE_PREAMBLE, Interpreter, InterpreterError, find_interpreter}; @@ -35,7 +35,7 @@ fn print_debug_info(args: &ExecutionArgs) -> String { output } -pub(crate) struct CmdExeInterpreter; +pub struct CmdExeInterpreter; impl Interpreter for CmdExeInterpreter { async fn run(&self, args: ExecutionArgs) -> Result<(), InterpreterError> { diff --git a/src/script/interpreter/mod.rs b/crates/rattler_build_script/src/interpreter/mod.rs similarity index 92% rename from src/script/interpreter/mod.rs rename to crates/rattler_build_script/src/interpreter/mod.rs index 5ded43da3..9fdd9156f 100644 --- a/src/script/interpreter/mod.rs +++ b/crates/rattler_build_script/src/interpreter/mod.rs @@ -10,13 +10,13 @@ mod ruby; use std::collections::HashMap; use std::path::PathBuf; -pub(crate) use bash::BashInterpreter; -pub(crate) use cmd_exe::CmdExeInterpreter; -pub(crate) use nodejs::NodeJsInterpreter; -pub(crate) use nushell::NuShellInterpreter; -pub(crate) use perl::PerlInterpreter; -pub(crate) use python::PythonInterpreter; -pub(crate) use r::RInterpreter; +pub use bash::BashInterpreter; +pub use cmd_exe::CmdExeInterpreter; +pub use nodejs::NodeJsInterpreter; +pub use nushell::NuShellInterpreter; +pub use perl::PerlInterpreter; +pub use python::PythonInterpreter; +pub use r::RInterpreter; use rattler_conda_types::Platform; use rattler_shell::{ activation::{ @@ -25,9 +25,9 @@ use rattler_shell::{ }, shell::{self, Shell}, }; -pub(crate) use ruby::RubyInterpreter; +pub use ruby::RubyInterpreter; -use super::ExecutionArgs; +use crate::execution::ExecutionArgs; /// The error type for the interpreter #[derive(Debug, thiserror::Error)] diff --git a/src/script/interpreter/nodejs.rs b/crates/rattler_build_script/src/interpreter/nodejs.rs similarity index 91% rename from src/script/interpreter/nodejs.rs rename to crates/rattler_build_script/src/interpreter/nodejs.rs index b49e4462e..f87f9fc61 100644 --- a/src/script/interpreter/nodejs.rs +++ b/crates/rattler_build_script/src/interpreter/nodejs.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; -use crate::script::{ExecutionArgs, ResolvedScriptContents}; +use crate::execution::{ExecutionArgs, ResolvedScriptContents}; use super::{BashInterpreter, CmdExeInterpreter, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct NodeJsInterpreter; +pub struct NodeJsInterpreter; // NodeJS interpreter calls either bash or cmd.exe interpreter for activation and then runs Node script impl Interpreter for NodeJsInterpreter { diff --git a/src/script/interpreter/nushell.rs b/crates/rattler_build_script/src/interpreter/nushell.rs similarity index 98% rename from src/script/interpreter/nushell.rs rename to crates/rattler_build_script/src/interpreter/nushell.rs index 3108dc7cb..2889acb57 100644 --- a/src/script/interpreter/nushell.rs +++ b/crates/rattler_build_script/src/interpreter/nushell.rs @@ -7,11 +7,11 @@ use rattler_shell::{ shell::{self, Shell, ShellEnum}, }; -use crate::script::{ExecutionArgs, run_process_with_replacements}; +use crate::execution::{ExecutionArgs, run_process_with_replacements}; use super::{Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct NuShellInterpreter; +pub struct NuShellInterpreter; const NUSHELL_PREAMBLE: &str = r#" ## Start of bash preamble diff --git a/src/script/interpreter/perl.rs b/crates/rattler_build_script/src/interpreter/perl.rs similarity index 91% rename from src/script/interpreter/perl.rs rename to crates/rattler_build_script/src/interpreter/perl.rs index 6b3753393..a63261149 100644 --- a/src/script/interpreter/perl.rs +++ b/crates/rattler_build_script/src/interpreter/perl.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; -use crate::script::{ExecutionArgs, ResolvedScriptContents}; +use crate::execution::{ExecutionArgs, ResolvedScriptContents}; use super::{BashInterpreter, CmdExeInterpreter, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct PerlInterpreter; +pub struct PerlInterpreter; // Perl interpreter calls either bash or cmd.exe interpreter for activation and then runs Perl script impl Interpreter for PerlInterpreter { diff --git a/src/script/interpreter/python.rs b/crates/rattler_build_script/src/interpreter/python.rs similarity index 91% rename from src/script/interpreter/python.rs rename to crates/rattler_build_script/src/interpreter/python.rs index a08989638..6ba4a8805 100644 --- a/src/script/interpreter/python.rs +++ b/crates/rattler_build_script/src/interpreter/python.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; -use crate::script::{ExecutionArgs, ResolvedScriptContents}; +use crate::execution::{ExecutionArgs, ResolvedScriptContents}; use super::{BashInterpreter, CmdExeInterpreter, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct PythonInterpreter; +pub struct PythonInterpreter; // python interpreter calls either bash or cmd.exe interpreter for activation and then runs python script impl Interpreter for PythonInterpreter { diff --git a/src/script/interpreter/r.rs b/crates/rattler_build_script/src/interpreter/r.rs similarity index 92% rename from src/script/interpreter/r.rs rename to crates/rattler_build_script/src/interpreter/r.rs index 04ecc7982..05136ded7 100644 --- a/src/script/interpreter/r.rs +++ b/crates/rattler_build_script/src/interpreter/r.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; -use crate::script::{ExecutionArgs, ResolvedScriptContents}; +use crate::execution::{ExecutionArgs, ResolvedScriptContents}; use super::{BashInterpreter, CmdExeInterpreter, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct RInterpreter; +pub struct RInterpreter; // R interpreter calls either bash or cmd.exe interpreter for activation and then runs R script impl Interpreter for RInterpreter { diff --git a/src/script/interpreter/ruby.rs b/crates/rattler_build_script/src/interpreter/ruby.rs similarity index 91% rename from src/script/interpreter/ruby.rs rename to crates/rattler_build_script/src/interpreter/ruby.rs index 2cfba02cc..63290e837 100644 --- a/src/script/interpreter/ruby.rs +++ b/crates/rattler_build_script/src/interpreter/ruby.rs @@ -2,11 +2,11 @@ use std::path::PathBuf; use rattler_conda_types::Platform; -use crate::script::{ExecutionArgs, ResolvedScriptContents}; +use crate::execution::{ExecutionArgs, ResolvedScriptContents}; use super::{BashInterpreter, CmdExeInterpreter, Interpreter, InterpreterError, find_interpreter}; -pub(crate) struct RubyInterpreter; +pub struct RubyInterpreter; // Ruby interpreter calls either bash or cmd.exe interpreter for activation and then runs Ruby script impl Interpreter for RubyInterpreter { diff --git a/crates/rattler_build_script/src/lib.rs b/crates/rattler_build_script/src/lib.rs new file mode 100644 index 000000000..6d9f5d6ca --- /dev/null +++ b/crates/rattler_build_script/src/lib.rs @@ -0,0 +1,23 @@ +//! Script execution and data model for rattler-build. +//! +//! This crate provides functionality for defining, parsing, and executing build scripts +//! in various interpreters (bash, cmd, python, etc.) as part of the rattler-build process. + +pub mod sandbox; +mod script; + +pub use sandbox::{SandboxArguments, SandboxConfiguration}; +pub use script::{Script, ScriptContent, determine_interpreter_from_path}; + +#[cfg(feature = "execution")] +mod execution; +#[cfg(feature = "execution")] +mod interpreter; + +#[cfg(feature = "execution")] +pub use execution::{ + Debug, ExecutionArgs, ResolvedScriptContents, create_build_script, + run_process_with_replacements, run_script, +}; +#[cfg(feature = "execution")] +pub use interpreter::InterpreterError; diff --git a/src/script/sandbox.rs b/crates/rattler_build_script/src/sandbox.rs similarity index 100% rename from src/script/sandbox.rs rename to crates/rattler_build_script/src/sandbox.rs diff --git a/crates/rattler_build_script/src/script.rs b/crates/rattler_build_script/src/script.rs new file mode 100644 index 000000000..7114b81c1 --- /dev/null +++ b/crates/rattler_build_script/src/script.rs @@ -0,0 +1,245 @@ +//! Core script data model types. + +use indexmap::IndexMap; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::path::{Path, PathBuf}; + +/// Defines the script to run to build the package. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Script { + /// The interpreter to use for the script. + pub interpreter: Option, + /// Environment variables to set in the build environment. + pub env: IndexMap, + /// Environment variables to leak into the build environment from the host system that + /// contain sensitive information. Use with care because this might make recipes no + /// longer reproducible on other machines. + pub secrets: Vec, + /// The contents of the script, either a path or a list of commands. + pub content: ScriptContent, + + /// The current working directory for the script. + pub cwd: Option, +} + +impl Serialize for Script { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + #[derive(Serialize)] + #[serde(untagged)] + enum RawScriptContent<'a> { + Command { content: &'a String }, + Commands { content: &'a Vec }, + Path { file: &'a PathBuf }, + } + + #[derive(Serialize)] + #[serde(untagged)] + enum RawScript<'a> { + CommandOrPath(&'a String), + Commands(&'a Vec), + Object { + #[serde(skip_serializing_if = "Option::is_none")] + interpreter: Option<&'a String>, + #[serde(skip_serializing_if = "IndexMap::is_empty")] + env: &'a IndexMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + secrets: &'a Vec, + #[serde(skip_serializing_if = "Option::is_none", flatten)] + content: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + cwd: Option<&'a PathBuf>, + }, + } + + let only_content = self.interpreter.is_none() + && self.env.is_empty() + && self.secrets.is_empty() + && self.cwd.is_none(); + + let raw_script = match &self.content { + ScriptContent::CommandOrPath(content) if only_content => { + RawScript::CommandOrPath(content) + } + ScriptContent::Commands(content) if only_content => RawScript::Commands(content), + _ => RawScript::Object { + interpreter: self.interpreter.as_ref(), + env: &self.env, + secrets: &self.secrets, + cwd: self.cwd.as_ref(), + content: match &self.content { + ScriptContent::Command(content) => Some(RawScriptContent::Command { content }), + ScriptContent::Commands(content) => { + Some(RawScriptContent::Commands { content }) + } + ScriptContent::Path(file) => Some(RawScriptContent::Path { file }), + ScriptContent::Default => None, + ScriptContent::CommandOrPath(content) => { + Some(RawScriptContent::Command { content }) + } + }, + }, + }; + + raw_script.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Script { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum RawScriptContent { + Command { content: String }, + Commands { content: Vec }, + Path { file: PathBuf }, + } + + #[derive(Deserialize)] + #[serde(untagged)] + enum RawScript { + CommandOrPath(String), + Commands(Vec), + Object { + #[serde(default)] + interpreter: Option, + #[serde(default)] + env: IndexMap, + #[serde(default)] + secrets: Vec, + #[serde(default, flatten)] + content: Option, + #[serde(default)] + cwd: Option, + }, + } + + let raw_script = RawScript::deserialize(deserializer)?; + Ok(match raw_script { + RawScript::CommandOrPath(str) => ScriptContent::CommandOrPath(str).into(), + RawScript::Commands(commands) => ScriptContent::Commands(commands).into(), + RawScript::Object { + interpreter, + env, + secrets, + content, + cwd, + } => Self { + interpreter, + env, + secrets, + cwd, + content: match content { + Some(RawScriptContent::Command { content }) => ScriptContent::Command(content), + Some(RawScriptContent::Commands { content }) => { + ScriptContent::Commands(content) + } + Some(RawScriptContent::Path { file }) => ScriptContent::Path(file), + None => ScriptContent::Default, + }, + }, + }) + } +} + +impl Script { + /// Returns the interpreter to use to execute the script + pub fn interpreter(&self) -> &str { + self.interpreter + .as_deref() + .unwrap_or(if cfg!(windows) { "cmd" } else { "bash" }) + } + + /// Returns the script contents + pub fn contents(&self) -> &ScriptContent { + &self.content + } + + /// Get the environment variables to set in the build environment. + pub fn env(&self) -> &IndexMap { + &self.env + } + + /// Get the secrets environment variables. + /// + /// Environment variables to leak into the build environment from the host system that + /// contain sensitive information. + /// + /// # Warning + /// Use with care because this might make recipes no longer reproducible on other machines. + pub fn secrets(&self) -> &[String] { + self.secrets.as_slice() + } + + /// Returns true if the script references the default build script and has no additional + /// configuration. + pub fn is_default(&self) -> bool { + self.content.is_default() + && self.interpreter.is_none() + && self.env.is_empty() + && self.secrets.is_empty() + } +} + +impl From for Script { + fn from(value: ScriptContent) -> Self { + Self { + interpreter: None, + env: Default::default(), + secrets: Default::default(), + content: value, + cwd: None, + } + } +} + +/// Describes the contents of the script as defined in [`Script`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ScriptContent { + /// Uses the default build script. + #[default] + Default, + + /// Either the script contents or the path to the script. + CommandOrPath(String), + + /// A path to the script. + Path(PathBuf), + + /// The script is given as inline code. + Commands(Vec), + + /// The script is given as a string + Command(String), +} + +impl ScriptContent { + /// Check if the script content is the default. + pub const fn is_default(&self) -> bool { + matches!(self, Self::Default) + } +} + +/// Helper function to determine interpreter based on file extension +pub fn determine_interpreter_from_path(path: &Path) -> Option { + path.extension() + .and_then(|s| s.to_str()) + .map(|ext| ext.to_lowercase()) + .and_then(|ext_lower| match ext_lower.as_str() { + "py" => Some("python".to_string()), + "rb" => Some("ruby".to_string()), + "js" => Some("nodejs".to_string()), + "pl" => Some("perl".to_string()), + "r" => Some("rscript".to_string()), + "sh" | "bash" => Some("bash".to_string()), + "bat" | "cmd" => Some("cmd".to_string()), + "ps1" => Some("powershell".to_string()), + "nu" => Some("nushell".to_string()), + _ => None, + }) +} diff --git a/crates/rattler_build_script/tests/serialization_tests.rs b/crates/rattler_build_script/tests/serialization_tests.rs new file mode 100644 index 000000000..47ad7e6b2 --- /dev/null +++ b/crates/rattler_build_script/tests/serialization_tests.rs @@ -0,0 +1,219 @@ +use indexmap::IndexMap; +use rattler_build_script::{Script, ScriptContent}; +use std::path::PathBuf; + +#[test] +fn test_script_serialization_simple_command() { + let script = Script { + interpreter: None, + env: IndexMap::new(), + secrets: Vec::new(), + content: ScriptContent::Command("echo 'Hello World'".to_string()), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###"content: "echo 'Hello World'""###); +} + +#[test] +fn test_script_serialization_commands() { + let script = Script { + interpreter: None, + env: IndexMap::new(), + secrets: Vec::new(), + content: ScriptContent::Commands(vec![ + "echo 'Step 1'".to_string(), + "echo 'Step 2'".to_string(), + "echo 'Step 3'".to_string(), + ]), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###" + - "echo 'Step 1'" + - "echo 'Step 2'" + - "echo 'Step 3'" + "###); +} + +#[test] +fn test_script_serialization_with_interpreter() { + let script = Script { + interpreter: Some("python".to_string()), + env: IndexMap::new(), + secrets: Vec::new(), + content: ScriptContent::Command("print('Hello from Python')".to_string()), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###" + interpreter: python + content: "print('Hello from Python')" + "###); +} + +#[test] +fn test_script_serialization_with_env() { + let mut env = IndexMap::new(); + env.insert("MY_VAR".to_string(), "my_value".to_string()); + env.insert("ANOTHER_VAR".to_string(), "another_value".to_string()); + + let script = Script { + interpreter: None, + env, + secrets: Vec::new(), + content: ScriptContent::Commands(vec!["echo $MY_VAR".to_string()]), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###" + env: + MY_VAR: my_value + ANOTHER_VAR: another_value + content: + - echo $MY_VAR + "###); +} + +#[test] +fn test_script_serialization_with_secrets() { + let script = Script { + interpreter: None, + env: IndexMap::new(), + secrets: vec!["SECRET_TOKEN".to_string(), "API_KEY".to_string()], + content: ScriptContent::Command("deploy.sh".to_string()), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###" + secrets: + - SECRET_TOKEN + - API_KEY + content: deploy.sh + "###); +} + +#[test] +fn test_script_serialization_with_path() { + let script = Script { + interpreter: Some("bash".to_string()), + env: IndexMap::new(), + secrets: Vec::new(), + content: ScriptContent::Path(PathBuf::from("build.sh")), + cwd: None, + }; + + insta::assert_yaml_snapshot!(script, @r###" + interpreter: bash + file: build.sh + "###); +} + +#[test] +fn test_script_serialization_with_cwd() { + let script = Script { + interpreter: None, + env: IndexMap::new(), + secrets: Vec::new(), + content: ScriptContent::Command("make install".to_string()), + cwd: Some(PathBuf::from("src/subdir")), + }; + + insta::assert_yaml_snapshot!(script, @r###" + content: make install + cwd: src/subdir + "###); +} + +#[test] +fn test_script_serialization_full() { + let mut env = IndexMap::new(); + env.insert("BUILD_TYPE".to_string(), "release".to_string()); + + let script = Script { + interpreter: Some("bash".to_string()), + env, + secrets: vec!["DEPLOY_KEY".to_string()], + content: ScriptContent::Commands(vec![ + "mkdir -p build".to_string(), + "cd build".to_string(), + "cmake ..".to_string(), + "make -j$(nproc)".to_string(), + ]), + cwd: Some(PathBuf::from("project")), + }; + + insta::assert_yaml_snapshot!(script, @r###" + interpreter: bash + env: + BUILD_TYPE: release + secrets: + - DEPLOY_KEY + content: + - mkdir -p build + - cd build + - cmake .. + - make -j$(nproc) + cwd: project + "###); +} + +#[test] +fn test_script_deserialization_simple() { + let yaml = r#" + content: echo 'Hello' + "#; + + let script: Script = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(script.interpreter, None); + assert_eq!(script.env.len(), 0); + assert_eq!(script.secrets.len(), 0); + assert!(matches!(script.content, ScriptContent::Command(_))); +} + +#[test] +fn test_script_deserialization_with_all_fields() { + let yaml = r#" + interpreter: python + env: + VAR1: value1 + VAR2: value2 + secrets: + - SECRET1 + content: + - echo step1 + - echo step2 + cwd: workdir + "#; + + let script: Script = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(script.interpreter, Some("python".to_string())); + assert_eq!(script.env.len(), 2); + assert_eq!(script.secrets.len(), 1); + assert_eq!(script.cwd, Some(PathBuf::from("workdir"))); + + if let ScriptContent::Commands(commands) = script.content { + assert_eq!(commands.len(), 2); + } else { + panic!("Expected Commands variant"); + } +} + +#[test] +fn test_script_roundtrip() { + let mut env = IndexMap::new(); + env.insert("KEY".to_string(), "VALUE".to_string()); + + let original = Script { + interpreter: Some("bash".to_string()), + env, + secrets: vec!["SECRET".to_string()], + content: ScriptContent::Commands(vec!["cmd1".to_string(), "cmd2".to_string()]), + cwd: Some(PathBuf::from("dir")), + }; + + let serialized = serde_yaml::to_string(&original).unwrap(); + let deserialized: Script = serde_yaml::from_str(&serialized).unwrap(); + + assert_eq!(original, deserialized); +} diff --git a/src/cache.rs b/src/cache.rs index 22f1b093e..fc88f0e6f 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -6,6 +6,7 @@ use std::{ use fs_err as fs; use miette::{Context, IntoDiagnostic}; +use rattler_build_script::Debug as ScriptDebug; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -247,6 +248,9 @@ impl Output { Some(&self.build_configuration.directories.build_prefix) }; + let jinja_renderer = |template: &str| -> Result { + jinja.render_str(template).map_err(|e| e.to_string()) + }; cache .build .script() @@ -256,9 +260,9 @@ impl Output { &self.build_configuration.directories.recipe_dir, &self.build_configuration.directories.host_prefix, build_prefix, - Some(jinja), + Some(jinja_renderer), None, // sandbox config - self.build_configuration.debug, + ScriptDebug::new(self.build_configuration.debug.is_enabled()), ) .await .into_diagnostic()?; diff --git a/src/package_test/run_test.rs b/src/package_test/run_test.rs index bff74ea0e..1d18911cd 100644 --- a/src/package_test/run_test.rs +++ b/src/package_test/run_test.rs @@ -135,7 +135,7 @@ impl Tests { })?; script - .run_script( + .run_script:: Result>( env_vars, tmp_dir.path(), cwd, @@ -143,7 +143,7 @@ impl Tests { None, None, None, - Debug::new(false), + crate::script::ScriptDebug::new(false), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -156,7 +156,7 @@ impl Tests { }; script - .run_script( + .run_script:: Result>( env_vars, tmp_dir.path(), cwd, @@ -164,7 +164,7 @@ impl Tests { None, None, None, - Debug::new(false), + crate::script::ScriptDebug::new(false), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -640,7 +640,7 @@ impl PythonTest { let test_dir = prefix.join("test"); fs::create_dir_all(&test_dir)?; script - .run_script( + .run_script:: Result>( Default::default(), &test_dir, path, @@ -648,7 +648,7 @@ impl PythonTest { None, None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -664,7 +664,7 @@ impl PythonTest { ..Script::default() }; script - .run_script( + .run_script:: Result>( Default::default(), path, path, @@ -672,7 +672,7 @@ impl PythonTest { None, None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -741,7 +741,7 @@ impl PerlTest { let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; script - .run_script( + .run_script:: Result>( Default::default(), &test_folder, path, @@ -749,7 +749,7 @@ impl PerlTest { None, None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -853,7 +853,7 @@ impl CommandsTest { tracing::info!("Testing commands:"); self.script - .run_script( + .run_script:: Result>( env_vars, &test_dir, path, @@ -861,7 +861,7 @@ impl CommandsTest { build_prefix.as_ref(), None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -1012,7 +1012,7 @@ impl RTest { let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; script - .run_script( + .run_script:: Result>( Default::default(), &test_folder, path, @@ -1020,7 +1020,7 @@ impl RTest { None, None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; @@ -1084,7 +1084,7 @@ impl RubyTest { let test_folder = prefix.join("test_files"); fs::create_dir_all(&test_folder)?; script - .run_script( + .run_script:: Result>( Default::default(), &test_folder, path, @@ -1092,7 +1092,7 @@ impl RubyTest { None, None, None, - config.debug, + crate::script::ScriptDebug::new(config.debug.is_enabled()), ) .await .map_err(|e| TestError::TestFailed(e.to_string()))?; diff --git a/src/package_test/serialize_test.rs b/src/package_test/serialize_test.rs index 825a33540..4d8362d3c 100644 --- a/src/package_test/serialize_test.rs +++ b/src/package_test/serialize_test.rs @@ -88,9 +88,15 @@ pub(crate) fn write_test_files( // Try to render the script contents here // Note: we want to improve this with better rendering in the future + let jinja_context = default_jinja_context(output); + let jinja_renderer = |template: &str| -> Result { + jinja_context + .render_str(template) + .map_err(|e| e.to_string()) + }; let contents = command_test.script.resolve_content( &output.build_configuration.directories.recipe_dir, - Some(default_jinja_context(output)), + Some(jinja_renderer), &["sh", "bat"], )?; diff --git a/src/recipe/parser/script.rs b/src/recipe/parser/script.rs index 32903e98e..6b23830a0 100644 --- a/src/recipe/parser/script.rs +++ b/src/recipe/parser/script.rs @@ -6,204 +6,11 @@ use crate::{ }, recipe::error::{ErrorKind, PartialParsingError}, }; -use indexmap::IndexMap; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::path::Path; use std::{borrow::Cow, path::PathBuf}; -/// Defines the script to run to build the package. -#[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct Script { - /// The interpreter to use for the script. - pub interpreter: Option, - /// Environment variables to set in the build environment. - pub env: IndexMap, - /// Environment variables to leak into the build environment from the host system that - /// contain sensitive information. Use with care because this might make recipes no - /// longer reproducible on other machines. - pub secrets: Vec, - /// The contents of the script, either a path or a list of commands. - pub content: ScriptContent, - - /// The current working directory for the script. - pub cwd: Option, -} - -impl Serialize for Script { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - #[derive(Serialize)] - #[serde(untagged)] - enum RawScriptContent<'a> { - Command { content: &'a String }, - Commands { content: &'a Vec }, - Path { file: &'a PathBuf }, - } - - #[derive(Serialize)] - #[serde(untagged)] - enum RawScript<'a> { - CommandOrPath(&'a String), - Commands(&'a Vec), - Object { - #[serde(skip_serializing_if = "Option::is_none")] - interpreter: Option<&'a String>, - #[serde(skip_serializing_if = "IndexMap::is_empty")] - env: &'a IndexMap, - #[serde(skip_serializing_if = "Vec::is_empty")] - secrets: &'a Vec, - #[serde(skip_serializing_if = "Option::is_none", flatten)] - content: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - cwd: Option<&'a PathBuf>, - }, - } - - let only_content = self.interpreter.is_none() - && self.env.is_empty() - && self.secrets.is_empty() - && self.cwd.is_none(); - - let raw_script = match &self.content { - ScriptContent::CommandOrPath(content) if only_content => { - RawScript::CommandOrPath(content) - } - ScriptContent::Commands(content) if only_content => RawScript::Commands(content), - _ => RawScript::Object { - interpreter: self.interpreter.as_ref(), - env: &self.env, - secrets: &self.secrets, - cwd: self.cwd.as_ref(), - content: match &self.content { - ScriptContent::Command(content) => Some(RawScriptContent::Command { content }), - ScriptContent::Commands(content) => { - Some(RawScriptContent::Commands { content }) - } - ScriptContent::Path(file) => Some(RawScriptContent::Path { file }), - ScriptContent::Default => None, - ScriptContent::CommandOrPath(content) => { - Some(RawScriptContent::Command { content }) - } - }, - }, - }; - - raw_script.serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for Script { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(untagged)] - enum RawScriptContent { - Command { content: String }, - Commands { content: Vec }, - Path { file: PathBuf }, - } - - #[derive(Deserialize)] - #[serde(untagged)] - enum RawScript { - CommandOrPath(String), - Commands(Vec), - Object { - #[serde(default)] - interpreter: Option, - #[serde(default)] - env: IndexMap, - #[serde(default)] - secrets: Vec, - #[serde(default, flatten)] - content: Option, - #[serde(default)] - cwd: Option, - }, - } - - let raw_script = RawScript::deserialize(deserializer)?; - Ok(match raw_script { - RawScript::CommandOrPath(str) => ScriptContent::CommandOrPath(str).into(), - RawScript::Commands(commands) => ScriptContent::Commands(commands).into(), - RawScript::Object { - interpreter, - env, - secrets, - content, - cwd, - } => Self { - interpreter, - env, - secrets, - cwd, - content: match content { - Some(RawScriptContent::Command { content }) => ScriptContent::Command(content), - Some(RawScriptContent::Commands { content }) => { - ScriptContent::Commands(content) - } - Some(RawScriptContent::Path { file }) => ScriptContent::Path(file), - None => ScriptContent::Default, - }, - }, - }) - } -} - -impl Script { - /// Returns the interpreter to use to execute the script - pub fn interpreter(&self) -> &str { - self.interpreter - .as_deref() - .unwrap_or(if cfg!(windows) { "cmd" } else { "bash" }) - } - - /// Returns the script contents - pub fn contents(&self) -> &ScriptContent { - &self.content - } - - /// Get the environment variables to set in the build environment. - pub fn env(&self) -> &IndexMap { - &self.env - } - - /// Get the secrets environment variables. - /// - /// Environment variables to leak into the build environment from the host system that - /// contain sensitive information. - /// - /// # Warning - /// Use with care because this might make recipes no longer reproducible on other machines. - pub fn secrets(&self) -> &[String] { - self.secrets.as_slice() - } - - /// Returns true if the script references the default build script and has no additional - /// configuration. - pub fn is_default(&self) -> bool { - self.content.is_default() - && self.interpreter.is_none() - && self.env.is_empty() - && self.secrets.is_empty() - } -} - -impl From for Script { - fn from(value: ScriptContent) -> Self { - Self { - interpreter: None, - env: Default::default(), - secrets: Default::default(), - content: value, - cwd: None, - } - } -} +// Re-export the script types from rattler_build_script +pub use rattler_build_script::{Script, ScriptContent, determine_interpreter_from_path}; /// Helper function to validate a path for invalid UTF-8 characters fn validate_path_utf8( @@ -224,25 +31,6 @@ fn validate_path_utf8( Ok(()) } -/// Helper function to determine interpreter based on file extension -fn determine_interpreter_from_path(path: &Path) -> Option { - path.extension() - .and_then(|s| s.to_str()) - .map(|ext| ext.to_lowercase()) - .and_then(|ext_lower| match ext_lower.as_str() { - "py" => Some("python".to_string()), - "rb" => Some("ruby".to_string()), - "js" => Some("nodejs".to_string()), - "pl" => Some("perl".to_string()), - "r" => Some("rscript".to_string()), - "sh" | "bash" => Some("bash".to_string()), - "bat" | "cmd" => Some("cmd".to_string()), - "ps1" => Some("powershell".to_string()), - "nu" => Some("nushell".to_string()), - _ => None, - }) -} - impl TryConvertNode