Files
attune/crates/worker/src/runtime/process.rs
David Culbreth 67a1c02543
Some checks failed
CI / Security Advisory Checks (push) Waiting to run
CI / Rust Blocking Checks (push) Failing after 47s
CI / Web Blocking Checks (push) Failing after 46s
CI / Security Blocking Checks (push) Failing after 8s
CI / Web Advisory Checks (push) Failing after 9s
trying to run a gitea workflow
2026-03-04 22:36:16 -06:00

1341 lines
50 KiB
Rust

//! Process Runtime Implementation
//!
//! A generic, configuration-driven runtime that executes actions as subprocesses.
//! Instead of having separate Rust implementations for each language (Python,
//! Node.js, etc.), this runtime reads its behavior from the database
//! `runtime.execution_config` JSONB column.
//!
//! The execution config describes:
//! - **Interpreter**: which binary to invoke and with what arguments
//! - **Environment**: how to create isolated environments (virtualenv, node_modules)
//! - **Dependencies**: how to detect and install pack dependencies
//!
//! At pack install time, the config drives environment creation and dependency
//! installation. At action execution time, it drives interpreter selection,
//! working directory, and process invocation.
use super::{
parameter_passing::{self, ParameterDeliveryConfig},
process_executor, ExecutionContext, ExecutionResult, Runtime, RuntimeError, RuntimeResult,
};
use async_trait::async_trait;
use attune_common::models::runtime::{EnvironmentConfig, RuntimeExecutionConfig};
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{debug, error, info, warn};
/// A generic runtime driven by `RuntimeExecutionConfig` from the database.
///
/// Each `ProcessRuntime` instance corresponds to a row in the `runtime` table.
/// The worker creates one per registered runtime at startup (loaded from DB).
pub struct ProcessRuntime {
/// Runtime name (lowercase, used for matching in RuntimeRegistry).
/// Corresponds to `runtime.name` lowercased (e.g., "python", "shell").
runtime_name: String,
/// Execution configuration parsed from `runtime.execution_config` JSONB.
config: RuntimeExecutionConfig,
/// Base directory where all packs are stored.
/// Action file paths are resolved relative to this.
packs_base_dir: PathBuf,
/// Base directory for isolated runtime environments (virtualenvs, etc.).
/// Environments are stored at `{runtime_envs_dir}/{pack_ref}/{runtime_name}`.
/// This keeps the pack directory clean and read-only.
runtime_envs_dir: PathBuf,
}
impl ProcessRuntime {
/// Create a new ProcessRuntime from database configuration.
///
/// # Arguments
/// * `runtime_name` - Lowercase runtime name (e.g., "python", "shell", "node")
/// * `config` - Parsed `RuntimeExecutionConfig` from the runtime table
/// * `packs_base_dir` - Base directory for pack storage
/// * `runtime_envs_dir` - Base directory for isolated runtime environments
pub fn new(
runtime_name: String,
config: RuntimeExecutionConfig,
packs_base_dir: PathBuf,
runtime_envs_dir: PathBuf,
) -> Self {
Self {
runtime_name,
config,
packs_base_dir,
runtime_envs_dir,
}
}
/// Resolve the pack directory from an action reference.
///
/// Action refs are formatted as `pack_ref.action_name`, so the pack_ref
/// is everything before the first dot.
#[allow(dead_code)] // Completes logical API surface; exercised in unit tests
fn resolve_pack_dir(&self, action_ref: &str) -> PathBuf {
let pack_ref = action_ref.split('.').next().unwrap_or(action_ref);
self.packs_base_dir.join(pack_ref)
}
/// Extract the pack_ref from an action reference.
fn extract_pack_ref<'a>(&self, action_ref: &'a str) -> &'a str {
action_ref.split('.').next().unwrap_or(action_ref)
}
/// Compute the external environment directory for a pack.
///
/// Returns `{runtime_envs_dir}/{pack_ref}/{runtime_name}`,
/// e.g., `/opt/attune/runtime_envs/python_example/python`.
fn env_dir_for_pack(&self, pack_ref: &str) -> PathBuf {
self.runtime_envs_dir
.join(pack_ref)
.join(&self.runtime_name)
}
/// Get the interpreter path, checking for an external pack environment first.
#[cfg(test)]
fn resolve_interpreter(&self, pack_dir: &Path, env_dir: Option<&Path>) -> PathBuf {
self.config.resolve_interpreter_with_env(pack_dir, env_dir)
}
/// Set up the runtime environment for a pack at an external location.
///
/// Environments are created at `{runtime_envs_dir}/{pack_ref}/{runtime_name}`
/// to keep the pack directory clean and read-only.
///
/// # Arguments
/// * `pack_dir` - Absolute path to the pack directory (for manifest files)
/// * `env_dir` - Absolute path to the environment directory to create
pub async fn setup_pack_environment(
&self,
pack_dir: &Path,
env_dir: &Path,
) -> RuntimeResult<()> {
let env_cfg = match &self.config.environment {
Some(cfg) if cfg.env_type != "none" => cfg,
_ => {
debug!(
"No environment configuration for runtime '{}', skipping setup",
self.runtime_name
);
return Ok(());
}
};
let vars = self
.config
.build_template_vars_with_env(pack_dir, Some(env_dir));
if !env_dir.exists() {
// Environment does not exist yet — create it.
self.create_environment(env_cfg, pack_dir, env_dir, &vars)
.await?;
} else {
// Environment directory exists — verify the interpreter is usable.
// A venv created by a different container may contain broken symlinks
// (e.g. python3 -> /usr/bin/python3 when this container has it at
// /usr/local/bin/python3).
if self.env_needs_recreate(env_cfg, pack_dir, env_dir) {
if let Err(e) = std::fs::remove_dir_all(env_dir) {
warn!(
"Failed to remove broken environment at {}: {}. Skipping recreate.",
env_dir.display(),
e,
);
// Still try to install dependencies even if we couldn't recreate
self.install_dependencies(pack_dir, env_dir).await?;
return Ok(());
}
self.create_environment(env_cfg, pack_dir, env_dir, &vars)
.await?;
}
}
// Install dependencies if configured and manifest file exists
self.install_dependencies(pack_dir, env_dir).await?;
Ok(())
}
/// Check whether an existing environment directory has a broken or missing
/// interpreter and needs to be recreated.
///
/// Returns `true` if the environment should be deleted and recreated.
fn env_needs_recreate(
&self,
env_cfg: &EnvironmentConfig,
pack_dir: &Path,
env_dir: &Path,
) -> bool {
let interp_template = match env_cfg.interpreter_path {
Some(ref t) => t,
None => {
debug!(
"Environment already exists at {}, skipping creation \
(no interpreter_path to verify)",
env_dir.display()
);
return false;
}
};
let mut check_vars = std::collections::HashMap::new();
check_vars.insert("env_dir", env_dir.to_string_lossy().to_string());
check_vars.insert("pack_dir", pack_dir.to_string_lossy().to_string());
let resolved = RuntimeExecutionConfig::resolve_template(interp_template, &check_vars);
let resolved_path = std::path::PathBuf::from(&resolved);
if resolved_path.exists() {
debug!(
"Environment already exists at {} with valid interpreter at {}",
env_dir.display(),
resolved_path.display(),
);
return false;
}
// Interpreter not reachable — distinguish broken symlinks for diagnostics
let is_broken_symlink = std::fs::symlink_metadata(&resolved_path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_broken_symlink {
let target = std::fs::read_link(&resolved_path)
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "<unreadable>".to_string());
warn!(
"Environment at {} has broken interpreter symlink: '{}' -> '{}'. \
Removing and recreating...",
env_dir.display(),
resolved_path.display(),
target,
);
} else {
warn!(
"Environment at {} exists but interpreter not found at '{}'. \
Removing and recreating...",
env_dir.display(),
resolved_path.display(),
);
}
true
}
/// Run the environment create_command to produce a new environment at `env_dir`.
///
/// Ensures parent directories exist, resolves the create command template,
/// executes it, and logs the result.
async fn create_environment(
&self,
env_cfg: &EnvironmentConfig,
pack_dir: &Path,
env_dir: &Path,
vars: &std::collections::HashMap<&str, String>,
) -> RuntimeResult<()> {
if env_cfg.create_command.is_empty() {
return Err(RuntimeError::SetupError(format!(
"Environment type '{}' requires a create_command but none configured",
env_cfg.env_type
)));
}
// Ensure parent directories exist
if let Some(parent) = env_dir.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
RuntimeError::SetupError(format!(
"Failed to create environment parent directory {}: {}",
parent.display(),
e
))
})?;
}
let resolved_cmd = RuntimeExecutionConfig::resolve_command(&env_cfg.create_command, vars);
info!(
"Creating {} environment at {}: {:?}",
env_cfg.env_type,
env_dir.display(),
resolved_cmd
);
let (program, args) = resolved_cmd
.split_first()
.ok_or_else(|| RuntimeError::SetupError("Empty create_command".to_string()))?;
let output = Command::new(program)
.args(args)
.current_dir(pack_dir)
.output()
.await
.map_err(|e| {
RuntimeError::SetupError(format!(
"Failed to run environment create command '{}': {}",
program, e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RuntimeError::SetupError(format!(
"Environment creation failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)));
}
info!(
"Created {} environment at {}",
env_cfg.env_type,
env_dir.display()
);
Ok(())
}
/// Install dependencies for a pack if a manifest file is present.
///
/// Reads the dependency configuration from `execution_config.dependencies`
/// and runs the install command if the manifest file (e.g., requirements.txt)
/// exists in the pack directory.
///
/// # Arguments
/// * `pack_dir` - Absolute path to the pack directory (for manifest files)
/// * `env_dir` - Absolute path to the environment directory
pub async fn install_dependencies(&self, pack_dir: &Path, env_dir: &Path) -> RuntimeResult<()> {
let dep_cfg = match &self.config.dependencies {
Some(cfg) => cfg,
None => {
debug!(
"No dependency configuration for runtime '{}', skipping",
self.runtime_name
);
return Ok(());
}
};
let manifest_path = pack_dir.join(&dep_cfg.manifest_file);
if !manifest_path.exists() {
debug!(
"No dependency manifest '{}' found in {}, skipping installation",
dep_cfg.manifest_file,
pack_dir.display()
);
return Ok(());
}
if dep_cfg.install_command.is_empty() {
warn!(
"Dependency manifest '{}' found but no install_command configured for runtime '{}'",
dep_cfg.manifest_file, self.runtime_name
);
return Ok(());
}
// Check whether dependencies have already been installed for the current
// manifest content. We store a SHA-256 checksum of the manifest file in a
// marker file inside env_dir. If the checksum matches, we skip the
// (potentially expensive) install command.
let marker_path = env_dir.join(".attune_deps_installed");
let current_checksum = Self::file_checksum(&manifest_path).await;
if let Some(ref checksum) = current_checksum {
if let Ok(stored) = tokio::fs::read_to_string(&marker_path).await {
if stored.trim() == checksum.as_str() {
debug!(
"Dependencies already installed for runtime '{}' in {} (manifest unchanged)",
self.runtime_name,
env_dir.display(),
);
return Ok(());
}
}
}
// Build template vars with the external env_dir
let vars = self
.config
.build_template_vars_with_env(pack_dir, Some(env_dir));
let resolved_cmd = RuntimeExecutionConfig::resolve_command(&dep_cfg.install_command, &vars);
info!(
"Installing dependencies for pack at {} using: {:?}",
pack_dir.display(),
resolved_cmd
);
let (program, args) = resolved_cmd
.split_first()
.ok_or_else(|| RuntimeError::SetupError("Empty install_command".to_string()))?;
let output = Command::new(program)
.args(args)
.current_dir(pack_dir)
.output()
.await
.map_err(|e| {
RuntimeError::SetupError(format!(
"Failed to run dependency install command '{}': {}",
program, e
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(RuntimeError::SetupError(format!(
"Dependency installation failed (exit {}): {}",
output.status.code().unwrap_or(-1),
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
info!(
"Dependencies installed successfully for runtime '{}' in {}",
self.runtime_name,
env_dir.display()
);
debug!("Install output: {}", stdout.trim());
// Write the checksum marker so subsequent calls skip the install.
if let Some(checksum) = current_checksum {
if let Err(e) = tokio::fs::write(&marker_path, checksum.as_bytes()).await {
warn!(
"Failed to write dependency marker file {}: {}",
marker_path.display(),
e
);
}
}
Ok(())
}
/// Compute a hex-encoded SHA-256 checksum of a file's contents.
/// Returns `None` if the file cannot be read.
async fn file_checksum(path: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let data = tokio::fs::read(path).await.ok()?;
let hash = Sha256::digest(&data);
Some(format!("{:x}", hash))
}
/// Check whether a pack has dependencies that need to be installed.
pub fn pack_has_dependencies(&self, pack_dir: &Path) -> bool {
self.config.has_dependencies(pack_dir)
}
/// Check whether the environment for a pack exists at the external location.
pub fn environment_exists(&self, pack_ref: &str) -> bool {
let env_dir = self.env_dir_for_pack(pack_ref);
env_dir.exists()
}
/// Get a reference to the execution config.
pub fn config(&self) -> &RuntimeExecutionConfig {
&self.config
}
}
#[async_trait]
impl Runtime for ProcessRuntime {
fn name(&self) -> &str {
&self.runtime_name
}
fn can_execute(&self, context: &ExecutionContext) -> bool {
// Match by runtime_name if specified in the context.
// When an explicit runtime_name is provided, it is authoritative —
// we only match if the name matches; we do NOT fall through to
// extension-based matching because the caller has already decided
// which runtime should handle this action.
if let Some(ref name) = context.runtime_name {
return name.eq_ignore_ascii_case(&self.runtime_name);
}
// No runtime_name specified — fall back to file extension matching
if let Some(ref code_path) = context.code_path {
if self.config.matches_file_extension(code_path) {
return true;
}
}
// Check entry_point extension
if self
.config
.matches_file_extension(Path::new(&context.entry_point))
{
return true;
}
false
}
async fn execute(&self, context: ExecutionContext) -> RuntimeResult<ExecutionResult> {
// Determine the effective execution config: use the version-specific
// override if the executor resolved a specific runtime version for this
// action, otherwise fall back to this ProcessRuntime's built-in config.
let effective_config: &RuntimeExecutionConfig = context
.runtime_config_override
.as_ref()
.unwrap_or(&self.config);
if let Some(ref ver) = context.selected_runtime_version {
info!(
"Executing action '{}' (execution_id: {}) with runtime '{}' version {}, \
parameter delivery: {:?}, format: {:?}, output format: {:?}",
context.action_ref,
context.execution_id,
self.runtime_name,
ver,
context.parameter_delivery,
context.parameter_format,
context.output_format,
);
} else {
info!(
"Executing action '{}' (execution_id: {}) with runtime '{}', \
parameter delivery: {:?}, format: {:?}, output format: {:?}",
context.action_ref,
context.execution_id,
self.runtime_name,
context.parameter_delivery,
context.parameter_format,
context.output_format,
);
}
let pack_ref = self.extract_pack_ref(&context.action_ref);
let pack_dir = self.packs_base_dir.join(pack_ref);
// Compute external env_dir for this pack/runtime combination.
// When a specific runtime version is selected, the env dir includes a
// version suffix (e.g., "python-3.12") for per-version isolation.
// Pattern: {runtime_envs_dir}/{pack_ref}/{runtime_name[-version]}
let env_dir = if let Some(ref suffix) = context.runtime_env_dir_suffix {
self.runtime_envs_dir.join(pack_ref).join(suffix)
} else {
self.env_dir_for_pack(pack_ref)
};
let env_dir_opt = if effective_config.environment.is_some() {
Some(env_dir.as_path())
} else {
None
};
// Runtime environments are set up proactively — either at worker startup
// (scanning all registered packs) or via pack.registered MQ events when a
// new pack is installed. We only log a warning here if the expected
// environment directory is missing so operators can investigate.
if effective_config.environment.is_some() && pack_dir.exists() && !env_dir.exists() {
warn!(
"Runtime environment for pack '{}' not found at {}. \
The environment should have been created at startup or on pack registration. \
Proceeding with system interpreter as fallback.",
context.action_ref,
env_dir.display(),
);
}
// If the environment directory exists but contains a broken interpreter
// (e.g. broken symlinks from a venv created in a different container),
// attempt to recreate it before resolving the interpreter.
if effective_config.environment.is_some() && env_dir.exists() && pack_dir.exists() {
if let Some(ref env_cfg) = effective_config.environment {
if let Some(ref interp_template) = env_cfg.interpreter_path {
let mut vars = std::collections::HashMap::new();
vars.insert("env_dir", env_dir.to_string_lossy().to_string());
vars.insert("pack_dir", pack_dir.to_string_lossy().to_string());
let resolved = RuntimeExecutionConfig::resolve_template(interp_template, &vars);
let resolved_path = std::path::PathBuf::from(&resolved);
// Check for a broken symlink: symlink_metadata succeeds for
// the link itself even when its target is missing, while
// exists() (which follows symlinks) returns false.
let is_broken_symlink = !resolved_path.exists()
&& std::fs::symlink_metadata(&resolved_path)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if is_broken_symlink {
let target = std::fs::read_link(&resolved_path)
.map(|t| t.display().to_string())
.unwrap_or_else(|_| "<unreadable>".to_string());
warn!(
"Detected broken symlink at '{}' -> '{}' in venv for pack '{}'. \
Removing broken environment and recreating...",
resolved_path.display(),
target,
context.action_ref,
);
// Remove the broken environment directory
if let Err(e) = std::fs::remove_dir_all(&env_dir) {
warn!(
"Failed to remove broken environment at {}: {}. \
Will proceed with system interpreter.",
env_dir.display(),
e,
);
} else {
// Recreate the environment using a temporary ProcessRuntime
// with the effective (possibly version-specific) config.
let setup_runtime = ProcessRuntime::new(
self.runtime_name.clone(),
effective_config.clone(),
self.packs_base_dir.clone(),
self.runtime_envs_dir.clone(),
);
match setup_runtime
.setup_pack_environment(&pack_dir, &env_dir)
.await
{
Ok(()) => {
info!(
"Successfully recreated environment for pack '{}' at {}",
context.action_ref,
env_dir.display(),
);
}
Err(e) => {
warn!(
"Failed to recreate environment for pack '{}' at {}: {}. \
Will proceed with system interpreter.",
context.action_ref,
env_dir.display(),
e,
);
}
}
}
}
}
}
}
let interpreter = effective_config.resolve_interpreter_with_env(&pack_dir, env_dir_opt);
info!(
"Resolved interpreter: {} (env_dir: {}, env_exists: {}, pack_dir: {}, version: {})",
interpreter.display(),
env_dir.display(),
env_dir.exists(),
pack_dir.display(),
context
.selected_runtime_version
.as_deref()
.unwrap_or("default"),
);
// Prepare environment and parameters according to delivery method
let mut env = context.env.clone();
// Inject runtime-specific environment variables from execution_config.
// These are template-based (e.g., NODE_PATH={env_dir}/node_modules) and
// resolved against the current pack/env directories.
if !effective_config.env_vars.is_empty() {
let vars = effective_config.build_template_vars_with_env(&pack_dir, env_dir_opt);
for (key, value_template) in &effective_config.env_vars {
let resolved = RuntimeExecutionConfig::resolve_template(value_template, &vars);
debug!(
"Setting runtime env var: {}={} (template: {})",
key, resolved, value_template
);
env.insert(key.clone(), resolved);
}
}
let param_config = ParameterDeliveryConfig {
delivery: context.parameter_delivery,
format: context.parameter_format,
};
let prepared_params =
parameter_passing::prepare_parameters(&context.parameters, &mut env, param_config)?;
let parameters_stdin = prepared_params.stdin_content();
// Determine working directory: use context override, or pack dir
let working_dir = context
.working_dir
.as_deref()
.filter(|p| p.exists())
.or_else(|| {
if pack_dir.exists() {
Some(pack_dir.as_path())
} else {
None
}
});
// Build the command based on whether we have a file or inline code
let cmd = if let Some(ref code_path) = context.code_path {
// File-based execution: interpreter [args] <action_file>
debug!("Executing file: {}", code_path.display());
process_executor::build_action_command(
&interpreter,
&effective_config.interpreter.args,
code_path,
working_dir,
&env,
)
} else if let Some(ref code) = context.code {
// Inline code execution: interpreter -c <code>
debug!("Executing inline code ({} bytes)", code.len());
let mut cmd = process_executor::build_inline_command(&interpreter, code, &env);
if let Some(dir) = working_dir {
cmd.current_dir(dir);
}
cmd
} else {
// No code_path and no inline code — try treating entry_point as a file
// relative to the pack's actions directory
let action_file = pack_dir.join("actions").join(&context.entry_point);
if action_file.exists() {
debug!("Executing action file: {}", action_file.display());
process_executor::build_action_command(
&interpreter,
&effective_config.interpreter.args,
&action_file,
working_dir,
&env,
)
} else {
error!(
"No code, code_path, or action file found for action '{}'. \
Tried: {}",
context.action_ref,
action_file.display()
);
return Err(RuntimeError::InvalidAction(format!(
"No executable content found for action '{}'. \
Expected file at: {}",
context.action_ref,
action_file.display()
)));
}
};
// Log the full command about to be executed
info!(
"Running command: {:?} (action: '{}', execution_id: {}, working_dir: {:?})",
cmd,
context.action_ref,
context.execution_id,
working_dir
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<none>".to_string()),
);
// Execute with streaming output capture (with optional cancellation support)
process_executor::execute_streaming_cancellable(
cmd,
&context.secrets,
parameters_stdin,
context.timeout,
context.max_stdout_bytes,
context.max_stderr_bytes,
context.output_format,
context.cancel_token.clone(),
)
.await
}
async fn setup(&self) -> RuntimeResult<()> {
info!("Setting up ProcessRuntime '{}'", self.runtime_name);
let binary = &self.config.interpreter.binary;
// Verify the interpreter is available on the system
let result = Command::new(binary).arg("--version").output().await;
match result {
Ok(output) => {
if output.status.success() {
let version = String::from_utf8_lossy(&output.stdout);
let stderr_version = String::from_utf8_lossy(&output.stderr);
// Some interpreters print version to stderr (e.g., Python on some systems)
let version_str = if version.trim().is_empty() {
stderr_version.trim().to_string()
} else {
version.trim().to_string()
};
info!(
"ProcessRuntime '{}' ready: {} ({})",
self.runtime_name, binary, version_str
);
} else {
warn!(
"Interpreter '{}' for runtime '{}' returned non-zero exit code \
on --version check (may still work for execution)",
binary, self.runtime_name
);
}
}
Err(e) => {
// The interpreter isn't available — this is a warning, not a hard failure,
// because the runtime might only be used in containers where the interpreter
// is available at execution time.
warn!(
"Interpreter '{}' for runtime '{}' not found: {}. \
Actions using this runtime may fail.",
binary, self.runtime_name, e
);
}
}
Ok(())
}
async fn cleanup(&self) -> RuntimeResult<()> {
info!("Cleaning up ProcessRuntime '{}'", self.runtime_name);
Ok(())
}
async fn validate(&self) -> RuntimeResult<()> {
debug!("Validating ProcessRuntime '{}'", self.runtime_name);
let binary = &self.config.interpreter.binary;
// Check if interpreter is available
let output = Command::new(binary).arg("--version").output().await;
match output {
Ok(output) if output.status.success() => Ok(()),
Ok(output) => {
// Non-zero exit but binary exists — warn but don't fail
warn!(
"Interpreter '{}' returned exit code {} on validation",
binary,
output.status.code().unwrap_or(-1)
);
Ok(())
}
Err(e) => {
warn!(
"Interpreter '{}' for runtime '{}' not available: {}",
binary, self.runtime_name, e
);
// Don't fail validation — the interpreter might be available in containers
Ok(())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use attune_common::models::runtime::{
DependencyConfig, EnvironmentConfig, InterpreterConfig, RuntimeExecutionConfig,
};
use attune_common::models::{OutputFormat, ParameterDelivery, ParameterFormat};
use std::collections::HashMap;
use tempfile::TempDir;
fn make_shell_config() -> RuntimeExecutionConfig {
RuntimeExecutionConfig {
interpreter: InterpreterConfig {
binary: "/bin/bash".to_string(),
args: vec![],
file_extension: Some(".sh".to_string()),
},
environment: None,
dependencies: None,
env_vars: HashMap::new(),
}
}
fn make_python_config() -> RuntimeExecutionConfig {
RuntimeExecutionConfig {
interpreter: InterpreterConfig {
binary: "python3".to_string(),
args: vec!["-u".to_string()],
file_extension: Some(".py".to_string()),
},
environment: Some(EnvironmentConfig {
env_type: "virtualenv".to_string(),
dir_name: ".venv".to_string(),
create_command: vec![
"python3".to_string(),
"-m".to_string(),
"venv".to_string(),
"{env_dir}".to_string(),
],
interpreter_path: Some("{env_dir}/bin/python3".to_string()),
}),
dependencies: Some(DependencyConfig {
manifest_file: "requirements.txt".to_string(),
install_command: vec![
"{interpreter}".to_string(),
"-m".to_string(),
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
"{manifest_path}".to_string(),
],
}),
env_vars: HashMap::new(),
}
}
#[test]
fn test_can_execute_by_runtime_name() {
let runtime = ProcessRuntime::new(
"python".to_string(),
make_python_config(),
PathBuf::from("/tmp/packs"),
PathBuf::from("/tmp/runtime_envs"),
);
let context = ExecutionContext {
execution_id: 1,
action_ref: "mypack.hello".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "hello.py".to_string(),
code: None,
code_path: None,
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
assert!(runtime.can_execute(&context));
}
#[test]
fn test_can_execute_by_file_extension() {
let runtime = ProcessRuntime::new(
"python".to_string(),
make_python_config(),
PathBuf::from("/tmp/packs"),
PathBuf::from("/tmp/runtime_envs"),
);
let context = ExecutionContext {
execution_id: 1,
action_ref: "mypack.hello".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "hello.py".to_string(),
code: None,
code_path: Some(PathBuf::from("/tmp/packs/mypack/actions/hello.py")),
runtime_name: None,
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
assert!(runtime.can_execute(&context));
}
#[test]
fn test_cannot_execute_wrong_extension() {
let runtime = ProcessRuntime::new(
"python".to_string(),
make_python_config(),
PathBuf::from("/tmp/packs"),
PathBuf::from("/tmp/runtime_envs"),
);
let context = ExecutionContext {
execution_id: 1,
action_ref: "mypack.hello".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "hello.sh".to_string(),
code: None,
code_path: Some(PathBuf::from("/tmp/packs/mypack/actions/hello.sh")),
runtime_name: None,
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024,
max_stderr_bytes: 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
assert!(!runtime.can_execute(&context));
}
#[test]
fn test_resolve_pack_dir() {
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
PathBuf::from("/opt/attune/packs"),
PathBuf::from("/opt/attune/runtime_envs"),
);
let pack_dir = runtime.resolve_pack_dir("mypack.echo");
assert_eq!(pack_dir, PathBuf::from("/opt/attune/packs/mypack"));
}
#[test]
fn test_resolve_interpreter_no_env() {
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
PathBuf::from("/tmp/packs"),
PathBuf::from("/tmp/runtime_envs"),
);
let interpreter = runtime.resolve_interpreter(Path::new("/tmp/packs/mypack"), None);
assert_eq!(interpreter, PathBuf::from("/bin/bash"));
}
#[test]
fn test_env_dir_for_pack() {
let runtime = ProcessRuntime::new(
"python".to_string(),
make_python_config(),
PathBuf::from("/opt/attune/packs"),
PathBuf::from("/opt/attune/runtime_envs"),
);
let env_dir = runtime.env_dir_for_pack("python_example");
assert_eq!(
env_dir,
PathBuf::from("/opt/attune/runtime_envs/python_example/python")
);
}
#[tokio::test]
async fn test_execute_shell_file() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().join("packs");
let pack_dir = packs_dir.join("testpack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
// Write a simple shell script
let script_path = actions_dir.join("hello.sh");
std::fs::write(
&script_path,
"#!/bin/bash\necho 'hello from process runtime'",
)
.unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
packs_dir,
temp_dir.path().join("runtime_envs"),
);
let context = ExecutionContext {
execution_id: 1,
action_ref: "testpack.hello".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "hello.sh".to_string(),
code: None,
code_path: Some(script_path),
runtime_name: Some("shell".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello from process runtime"));
}
#[tokio::test]
async fn test_execute_python_file() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().join("packs");
let pack_dir = packs_dir.join("testpack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
// Write a simple Python script
let script_path = actions_dir.join("hello.py");
std::fs::write(&script_path, "print('hello from python process runtime')").unwrap();
let config = RuntimeExecutionConfig {
interpreter: InterpreterConfig {
binary: "python3".to_string(),
args: vec![],
file_extension: Some(".py".to_string()),
},
environment: None,
dependencies: None,
env_vars: HashMap::new(),
};
let runtime = ProcessRuntime::new(
"python".to_string(),
config,
packs_dir,
temp_dir.path().join("runtime_envs"),
);
let context = ExecutionContext {
execution_id: 2,
action_ref: "testpack.hello".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "hello.py".to_string(),
code: None,
code_path: Some(script_path),
runtime_name: Some("python".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("hello from python process runtime"));
}
#[tokio::test]
async fn test_execute_inline_code() {
let temp_dir = TempDir::new().unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
temp_dir.path().to_path_buf(),
temp_dir.path().join("runtime_envs"),
);
let context = ExecutionContext {
execution_id: 3,
action_ref: "adhoc.test".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "inline".to_string(),
code: Some("echo 'inline shell code'".to_string()),
code_path: None,
runtime_name: Some("shell".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("inline shell code"));
}
#[tokio::test]
async fn test_execute_entry_point_fallback() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().join("packs");
let pack_dir = packs_dir.join("testpack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
// Write a script at the expected path
std::fs::write(
actions_dir.join("greet.sh"),
"#!/bin/bash\necho 'found via entry_point'",
)
.unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
packs_dir,
temp_dir.path().join("runtime_envs"),
);
// No code_path, no code — should resolve via entry_point
let context = ExecutionContext {
execution_id: 4,
action_ref: "testpack.greet".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "greet.sh".to_string(),
code: None,
code_path: None,
runtime_name: Some("shell".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
assert!(result.stdout.contains("found via entry_point"));
}
#[tokio::test]
async fn test_setup_pack_environment_no_config() {
let temp_dir = TempDir::new().unwrap();
let pack_dir = temp_dir.path().join("testpack");
let env_dir = temp_dir
.path()
.join("runtime_envs")
.join("testpack")
.join("shell");
std::fs::create_dir_all(&pack_dir).unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
temp_dir.path().to_path_buf(),
temp_dir.path().join("runtime_envs"),
);
// Should succeed immediately (no environment to create)
runtime
.setup_pack_environment(&pack_dir, &env_dir)
.await
.unwrap();
}
#[tokio::test]
async fn test_pack_has_dependencies() {
let temp_dir = TempDir::new().unwrap();
let pack_dir = temp_dir.path().join("testpack");
std::fs::create_dir_all(&pack_dir).unwrap();
let runtime = ProcessRuntime::new(
"python".to_string(),
make_python_config(),
temp_dir.path().to_path_buf(),
temp_dir.path().join("runtime_envs"),
);
// No requirements.txt yet
assert!(!runtime.pack_has_dependencies(&pack_dir));
// Create requirements.txt
std::fs::write(pack_dir.join("requirements.txt"), "requests>=2.28.0\n").unwrap();
assert!(runtime.pack_has_dependencies(&pack_dir));
}
#[tokio::test]
async fn test_setup_and_validate() {
let temp_dir = TempDir::new().unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
temp_dir.path().to_path_buf(),
temp_dir.path().join("runtime_envs"),
);
// Setup and validate should succeed for shell (bash is always available)
runtime.setup().await.unwrap();
runtime.validate().await.unwrap();
}
#[tokio::test]
async fn test_working_dir_set_to_pack_dir() {
let temp_dir = TempDir::new().unwrap();
let packs_dir = temp_dir.path().join("packs");
let pack_dir = packs_dir.join("testpack");
let actions_dir = pack_dir.join("actions");
std::fs::create_dir_all(&actions_dir).unwrap();
// Write a script that prints the working directory
let script_path = actions_dir.join("pwd.sh");
std::fs::write(&script_path, "#!/bin/bash\npwd").unwrap();
let runtime = ProcessRuntime::new(
"shell".to_string(),
make_shell_config(),
packs_dir,
temp_dir.path().join("runtime_envs"),
);
let context = ExecutionContext {
execution_id: 5,
action_ref: "testpack.pwd".to_string(),
parameters: HashMap::new(),
env: HashMap::new(),
secrets: HashMap::new(),
timeout: Some(10),
working_dir: None,
entry_point: "pwd.sh".to_string(),
code: None,
code_path: Some(script_path),
runtime_name: Some("shell".to_string()),
runtime_config_override: None,
runtime_env_dir_suffix: None,
selected_runtime_version: None,
max_stdout_bytes: 1024 * 1024,
max_stderr_bytes: 1024 * 1024,
parameter_delivery: ParameterDelivery::default(),
parameter_format: ParameterFormat::default(),
output_format: OutputFormat::default(),
cancel_token: None,
};
let result = runtime.execute(context).await.unwrap();
assert_eq!(result.exit_code, 0);
// Working dir should be the pack dir
let output_path = result.stdout.trim();
assert_eq!(
output_path,
pack_dir.to_string_lossy().as_ref(),
"Working directory should be set to the pack directory"
);
}
}