first iteration of agent-style worker and sensor containers.
This commit is contained in:
@@ -28,18 +28,20 @@
|
||||
//! - `--detect-only` — Run runtime detection, print results, and exit
|
||||
|
||||
use anyhow::Result;
|
||||
use attune_common::agent_bootstrap::{bootstrap_runtime_env, print_detect_only_report};
|
||||
use attune_common::config::Config;
|
||||
use clap::Parser;
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use attune_worker::dynamic_runtime::auto_register_detected_runtimes;
|
||||
use attune_worker::runtime_detect::{detect_runtimes, print_detection_report};
|
||||
use attune_worker::runtime_detect::DetectedRuntime;
|
||||
use attune_worker::service::WorkerService;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "attune-agent")]
|
||||
#[command(
|
||||
version,
|
||||
about = "Attune Universal Worker Agent - Injected into any container to auto-detect and execute actions",
|
||||
long_about = "The Attune Agent automatically discovers available runtime interpreters \
|
||||
in the current environment and registers as a worker capable of executing \
|
||||
@@ -73,119 +75,19 @@ fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
info!("Starting Attune Universal Worker Agent");
|
||||
info!("Agent binary: attune-agent {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// --- Phase 1: Runtime auto-detection (synchronous, before tokio runtime) ---
|
||||
//
|
||||
// All std::env::set_var calls MUST happen here, before we create the tokio
|
||||
// runtime, to avoid undefined behavior from mutating the process environment
|
||||
// while other threads are running.
|
||||
//
|
||||
// Check if the user has explicitly set ATTUNE_WORKER_RUNTIMES. If so, skip
|
||||
// auto-detection and respect their override. Otherwise, probe the system for
|
||||
// available interpreters.
|
||||
let runtimes_override = std::env::var("ATTUNE_WORKER_RUNTIMES").ok();
|
||||
// Safe: no async runtime or worker threads are running yet.
|
||||
std::env::set_var("ATTUNE_AGENT_MODE", "true");
|
||||
std::env::set_var("ATTUNE_AGENT_BINARY_NAME", "attune-agent");
|
||||
std::env::set_var("ATTUNE_AGENT_BINARY_VERSION", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Holds the detected runtimes so we can pass them to WorkerService later.
|
||||
// Populated in both branches: auto-detection and override (filtered to
|
||||
// match the override list).
|
||||
let mut agent_detected_runtimes: Option<Vec<attune_worker::runtime_detect::DetectedRuntime>> =
|
||||
None;
|
||||
|
||||
if let Some(ref override_value) = runtimes_override {
|
||||
info!(
|
||||
"ATTUNE_WORKER_RUNTIMES already set (override): {}",
|
||||
override_value
|
||||
);
|
||||
|
||||
// Even with an explicit override, run detection so we can register
|
||||
// the overridden runtimes in the database and advertise accurate
|
||||
// capability metadata (binary paths, versions). Without this, the
|
||||
// worker would accept work for runtimes that were never registered
|
||||
// locally — e.g. ruby/go on a fresh deployment.
|
||||
info!("Running auto-detection for override-specified runtimes...");
|
||||
let detected = detect_runtimes();
|
||||
|
||||
// Filter detected runtimes to only those matching the override list,
|
||||
// so we don't register runtimes the user explicitly excluded.
|
||||
let override_names: Vec<&str> = override_value.split(',').map(|s| s.trim()).collect();
|
||||
let filtered: Vec<_> = detected
|
||||
.into_iter()
|
||||
.filter(|rt| {
|
||||
let normalized = attune_common::runtime_detection::normalize_runtime_name(&rt.name);
|
||||
override_names.iter().any(|ov| {
|
||||
attune_common::runtime_detection::normalize_runtime_name(ov) == normalized
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
warn!(
|
||||
"None of the override runtimes ({}) were found on this system! \
|
||||
The agent may not be able to execute any actions.",
|
||||
override_value
|
||||
);
|
||||
} else {
|
||||
info!(
|
||||
"Matched {} override runtime(s) to detected interpreters:",
|
||||
filtered.len()
|
||||
);
|
||||
for rt in &filtered {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
agent_detected_runtimes = Some(filtered);
|
||||
}
|
||||
} else {
|
||||
info!("No ATTUNE_WORKER_RUNTIMES override — running auto-detection...");
|
||||
|
||||
let detected = detect_runtimes();
|
||||
|
||||
if detected.is_empty() {
|
||||
warn!("No runtimes detected! The agent may not be able to execute any actions.");
|
||||
} else {
|
||||
info!("Detected {} runtime(s):", detected.len());
|
||||
for rt in &detected {
|
||||
match &rt.version {
|
||||
Some(ver) => info!(" ✓ {} — {} ({})", rt.name, rt.path, ver),
|
||||
None => info!(" ✓ {} — {}", rt.name, rt.path),
|
||||
}
|
||||
}
|
||||
|
||||
// Build comma-separated runtime list and set the env var so that
|
||||
// Config::load() and WorkerService pick it up downstream.
|
||||
let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect();
|
||||
let runtime_csv = runtime_list.join(",");
|
||||
info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv);
|
||||
// Safe: no other threads are running yet (tokio runtime not started).
|
||||
std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv);
|
||||
|
||||
// Stash for Phase 2: pass to WorkerService for rich capability registration
|
||||
agent_detected_runtimes = Some(detected);
|
||||
}
|
||||
}
|
||||
let bootstrap = bootstrap_runtime_env("ATTUNE_WORKER_RUNTIMES");
|
||||
let agent_detected_runtimes = bootstrap.detected_runtimes.clone();
|
||||
|
||||
// --- Handle --detect-only (synchronous, no async runtime needed) ---
|
||||
if args.detect_only {
|
||||
if runtimes_override.is_some() {
|
||||
// User set an override, but --detect-only should show what's actually
|
||||
// on this system regardless, so re-run detection.
|
||||
info!(
|
||||
"--detect-only: re-running detection to show what is available on this system..."
|
||||
);
|
||||
println!("NOTE: ATTUNE_WORKER_RUNTIMES is set — auto-detection was skipped during normal startup.");
|
||||
println!(" Showing what auto-detection would find on this system:");
|
||||
println!();
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
} else if let Some(ref detected) = agent_detected_runtimes {
|
||||
print_detection_report(detected);
|
||||
} else {
|
||||
// No detection ran (empty results), run it fresh
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
}
|
||||
print_detect_only_report("ATTUNE_WORKER_RUNTIMES", &bootstrap);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -204,7 +106,7 @@ fn main() -> Result<()> {
|
||||
/// `runtime.block_on()` after all environment variable mutations are complete.
|
||||
async fn async_main(
|
||||
args: Args,
|
||||
agent_detected_runtimes: Option<Vec<attune_worker::runtime_detect::DetectedRuntime>>,
|
||||
agent_detected_runtimes: Option<Vec<DetectedRuntime>>,
|
||||
) -> Result<()> {
|
||||
// --- Phase 2: Load configuration ---
|
||||
let mut config = Config::load()?;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
//!
|
||||
//! For each detected runtime the agent found:
|
||||
//!
|
||||
//! 1. **Look up by name** in the database using alias-aware matching
|
||||
//! (via [`normalize_runtime_name`]).
|
||||
//! 1. **Look up by name** in the database using alias-aware matching.
|
||||
//! 2. **If found** → already registered (either from a pack YAML or a previous
|
||||
//! agent run). Nothing to do.
|
||||
//! 3. **If not found** → search for a runtime *template* in loaded packs whose
|
||||
@@ -29,7 +28,7 @@ use attune_common::error::Result;
|
||||
use attune_common::models::runtime::Runtime;
|
||||
use attune_common::repositories::runtime::{CreateRuntimeInput, RuntimeRepository};
|
||||
use attune_common::repositories::{Create, FindByRef, List};
|
||||
use attune_common::runtime_detection::normalize_runtime_name;
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tracing::{debug, info, warn};
|
||||
@@ -80,14 +79,17 @@ pub async fn auto_register_detected_runtimes(
|
||||
let mut registered_count = 0;
|
||||
|
||||
for detected_rt in detected {
|
||||
let canonical_name = normalize_runtime_name(&detected_rt.name);
|
||||
let canonical_name = detected_rt.name.to_ascii_lowercase();
|
||||
|
||||
// Check if a runtime with a matching name already exists in the DB.
|
||||
// We normalize both sides for alias-aware comparison.
|
||||
// normalize_runtime_name lowercases internally, so no need to pre-lowercase.
|
||||
let already_exists = existing_runtimes
|
||||
.iter()
|
||||
.any(|r| normalize_runtime_name(&r.name) == canonical_name);
|
||||
// Primary: check if the detected name appears in any existing runtime's aliases.
|
||||
// Secondary: check if the ref ends with the canonical name (e.g., "core.ruby").
|
||||
let already_exists = existing_runtimes.iter().any(|r| {
|
||||
// Primary: check if the detected name is in this runtime's aliases
|
||||
r.aliases.iter().any(|a| a == &canonical_name)
|
||||
// Secondary: check if the ref ends with the canonical name (e.g., "core.ruby")
|
||||
|| r.r#ref.ends_with(&format!(".{}", canonical_name))
|
||||
});
|
||||
|
||||
if already_exists {
|
||||
debug!(
|
||||
@@ -143,6 +145,7 @@ pub async fn auto_register_detected_runtimes(
|
||||
detected_rt.name, tmpl.r#ref
|
||||
)),
|
||||
name: tmpl.name.clone(),
|
||||
aliases: tmpl.aliases.clone(),
|
||||
distributions: tmpl.distributions.clone(),
|
||||
installation: tmpl.installation.clone(),
|
||||
execution_config: build_execution_config_from_template(&tmpl, detected_rt),
|
||||
@@ -195,6 +198,7 @@ pub async fn auto_register_detected_runtimes(
|
||||
detected_rt.name, detected_rt.path
|
||||
)),
|
||||
name: capitalize_runtime_name(&canonical_name),
|
||||
aliases: default_aliases(&canonical_name),
|
||||
distributions: build_minimal_distributions(detected_rt),
|
||||
installation: None,
|
||||
execution_config,
|
||||
@@ -285,7 +289,7 @@ fn build_execution_config_from_template(
|
||||
/// This provides enough information for `ProcessRuntime` to invoke the
|
||||
/// interpreter directly, without environment or dependency management.
|
||||
fn build_minimal_execution_config(detected: &DetectedRuntime) -> serde_json::Value {
|
||||
let canonical = normalize_runtime_name(&detected.name);
|
||||
let canonical = detected.name.to_ascii_lowercase();
|
||||
let file_ext = default_file_extension(&canonical);
|
||||
|
||||
let mut config = json!({
|
||||
@@ -319,6 +323,23 @@ fn build_minimal_distributions(detected: &DetectedRuntime) -> serde_json::Value
|
||||
})
|
||||
}
|
||||
|
||||
/// Default aliases for auto-detected runtimes that have no template.
|
||||
/// These match what the core pack YAMLs declare but serve as fallback
|
||||
/// when the template hasn't been loaded.
|
||||
fn default_aliases(canonical_name: &str) -> Vec<String> {
|
||||
match canonical_name {
|
||||
"shell" => vec!["shell".into(), "bash".into(), "sh".into()],
|
||||
"python" => vec!["python".into(), "python3".into()],
|
||||
"node" => vec!["node".into(), "nodejs".into(), "node.js".into()],
|
||||
"ruby" => vec!["ruby".into(), "rb".into()],
|
||||
"go" => vec!["go".into(), "golang".into()],
|
||||
"java" => vec!["java".into(), "jdk".into(), "openjdk".into()],
|
||||
"perl" => vec!["perl".into(), "perl5".into()],
|
||||
"r" => vec!["r".into(), "rscript".into()],
|
||||
_ => vec![canonical_name.to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Capitalize a runtime name for display (e.g., "ruby" → "Ruby", "r" → "R").
|
||||
fn capitalize_runtime_name(name: &str) -> String {
|
||||
let mut chars = name.chars();
|
||||
@@ -437,6 +458,7 @@ mod tests {
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: Some("Ruby Runtime".to_string()),
|
||||
name: "Ruby".to_string(),
|
||||
aliases: vec!["ruby".to_string(), "rb".to_string()],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
installers: json!({}),
|
||||
@@ -480,6 +502,7 @@ mod tests {
|
||||
pack_ref: Some("core".to_string()),
|
||||
description: None,
|
||||
name: "Python".to_string(),
|
||||
aliases: vec!["python".to_string(), "python3".to_string()],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
installers: json!({}),
|
||||
|
||||
@@ -35,7 +35,7 @@ use attune_common::repositories::pack::PackRepository;
|
||||
use attune_common::repositories::runtime::RuntimeRepository;
|
||||
use attune_common::repositories::runtime_version::RuntimeVersionRepository;
|
||||
use attune_common::repositories::{FindById, List};
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
|
||||
// Re-export the utility that the API also uses so callers can reach it from
|
||||
// either crate without adding a direct common dependency for this one function.
|
||||
@@ -207,7 +207,7 @@ pub async fn setup_environments_for_registered_pack(
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
if let Some(filter) = runtime_filter {
|
||||
runtime_in_filter(name, filter)
|
||||
runtime_aliases_match_filter(&[name.to_string()], filter)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
@@ -463,12 +463,12 @@ async fn process_runtime_for_pack(
|
||||
runtime_envs_dir: &Path,
|
||||
pack_result: &mut PackEnvSetupResult,
|
||||
) {
|
||||
// Apply worker runtime filter (alias-aware matching)
|
||||
// Apply worker runtime filter (alias-aware matching via declared aliases)
|
||||
if let Some(filter) = runtime_filter {
|
||||
if !runtime_in_filter(rt_name, filter) {
|
||||
if !runtime_aliases_match_filter(&rt.aliases, filter) {
|
||||
debug!(
|
||||
"Runtime '{}' not in worker filter, skipping for pack '{}'",
|
||||
rt_name, pack_ref,
|
||||
"Runtime '{}' not in worker filter (aliases: {:?}), skipping for pack '{}'",
|
||||
rt_name, rt.aliases, pack_ref,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ use tracing::{info, warn};
|
||||
|
||||
use crate::runtime_detect::DetectedRuntime;
|
||||
|
||||
const ATTUNE_AGENT_MODE_ENV: &str = "ATTUNE_AGENT_MODE";
|
||||
const ATTUNE_AGENT_BINARY_NAME_ENV: &str = "ATTUNE_AGENT_BINARY_NAME";
|
||||
const ATTUNE_AGENT_BINARY_VERSION_ENV: &str = "ATTUNE_AGENT_BINARY_VERSION";
|
||||
|
||||
/// Worker registration manager
|
||||
pub struct WorkerRegistration {
|
||||
pool: PgPool,
|
||||
@@ -29,12 +33,60 @@ pub struct WorkerRegistration {
|
||||
}
|
||||
|
||||
impl WorkerRegistration {
|
||||
fn env_truthy(name: &str) -> bool {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim().to_ascii_lowercase().as_str(), "1" | "true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn inject_agent_capabilities(capabilities: &mut HashMap<String, serde_json::Value>) {
|
||||
if Self::env_truthy(ATTUNE_AGENT_MODE_ENV) {
|
||||
capabilities.insert("agent_mode".to_string(), json!(true));
|
||||
}
|
||||
|
||||
if let Ok(binary_name) = std::env::var(ATTUNE_AGENT_BINARY_NAME_ENV) {
|
||||
let binary_name = binary_name.trim();
|
||||
if !binary_name.is_empty() {
|
||||
capabilities.insert("agent_binary_name".to_string(), json!(binary_name));
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(binary_version) = std::env::var(ATTUNE_AGENT_BINARY_VERSION_ENV) {
|
||||
let binary_version = binary_version.trim();
|
||||
if !binary_version.is_empty() {
|
||||
capabilities.insert("agent_binary_version".to_string(), json!(binary_version));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_worker_name() -> Option<String> {
|
||||
std::env::var("ATTUNE_WORKER_NAME")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn legacy_worker_type() -> Option<WorkerType> {
|
||||
let value = std::env::var("ATTUNE_WORKER_TYPE").ok()?;
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"local" => Some(WorkerType::Local),
|
||||
"remote" => Some(WorkerType::Remote),
|
||||
"container" => Some(WorkerType::Container),
|
||||
other => {
|
||||
warn!("Ignoring unrecognized ATTUNE_WORKER_TYPE value: {}", other);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new worker registration manager
|
||||
pub fn new(pool: PgPool, config: &Config) -> Self {
|
||||
let worker_name = config
|
||||
.worker
|
||||
.as_ref()
|
||||
.and_then(|w| w.name.clone())
|
||||
.or_else(Self::legacy_worker_name)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"worker-{}",
|
||||
@@ -48,6 +100,7 @@ impl WorkerRegistration {
|
||||
.worker
|
||||
.as_ref()
|
||||
.and_then(|w| w.worker_type)
|
||||
.or_else(Self::legacy_worker_type)
|
||||
.unwrap_or(WorkerType::Local);
|
||||
|
||||
let worker_role = WorkerRole::Action;
|
||||
@@ -86,6 +139,8 @@ impl WorkerRegistration {
|
||||
json!(env!("CARGO_PKG_VERSION")),
|
||||
);
|
||||
|
||||
Self::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
// Placeholder for runtimes (will be detected asynchronously)
|
||||
capabilities.insert("runtimes".to_string(), json!(Vec::<String>::new()));
|
||||
|
||||
@@ -461,4 +516,28 @@ mod tests {
|
||||
let value = json!(false);
|
||||
assert_eq!(value, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_agent_capabilities_from_env() {
|
||||
std::env::set_var(ATTUNE_AGENT_MODE_ENV, "TRUE");
|
||||
std::env::set_var(ATTUNE_AGENT_BINARY_NAME_ENV, "attune-agent");
|
||||
std::env::set_var(ATTUNE_AGENT_BINARY_VERSION_ENV, "1.2.3");
|
||||
|
||||
let mut capabilities = HashMap::new();
|
||||
WorkerRegistration::inject_agent_capabilities(&mut capabilities);
|
||||
|
||||
assert_eq!(capabilities.get("agent_mode"), Some(&json!(true)));
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_name"),
|
||||
Some(&json!("attune-agent"))
|
||||
);
|
||||
assert_eq!(
|
||||
capabilities.get("agent_binary_version"),
|
||||
Some(&json!("1.2.3"))
|
||||
);
|
||||
|
||||
std::env::remove_var(ATTUNE_AGENT_MODE_ENV);
|
||||
std::env::remove_var(ATTUNE_AGENT_BINARY_NAME_ENV);
|
||||
std::env::remove_var(ATTUNE_AGENT_BINARY_VERSION_ENV);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,544 +1,12 @@
|
||||
//! Runtime Auto-Detection Module
|
||||
//!
|
||||
//! Provides lightweight, database-free runtime detection for the Universal Worker Agent.
|
||||
//! Unlike [`attune_common::runtime_detection::RuntimeDetector`] which queries the database
|
||||
//! for runtime definitions and verification metadata, this module probes the local system
|
||||
//! directly by checking for well-known interpreter binaries on PATH.
|
||||
//!
|
||||
//! This is designed for the agent entrypoint (`attune-agent`) which is injected into
|
||||
//! arbitrary containers and must discover what runtimes are available without any
|
||||
//! database connectivity at detection time.
|
||||
//!
|
||||
//! # Detection Strategy
|
||||
//!
|
||||
//! For each candidate runtime, the detector:
|
||||
//! 1. Checks if a binary exists and is executable using `which`-style PATH lookup
|
||||
//! 2. Optionally runs a version command (e.g., `python3 --version`) to capture the version
|
||||
//! 3. Returns a list of [`DetectedRuntime`] structs with name, path, and version info
|
||||
//!
|
||||
//! # Supported Runtimes
|
||||
//!
|
||||
//! | Runtime | Binaries checked (in order) | Version command |
|
||||
//! |----------|-------------------------------|-------------------------|
|
||||
//! | shell | `bash`, `sh` | `bash --version` |
|
||||
//! | python | `python3`, `python` | `python3 --version` |
|
||||
//! | node | `node`, `nodejs` | `node --version` |
|
||||
//! | ruby | `ruby` | `ruby --version` |
|
||||
//! | go | `go` | `go version` |
|
||||
//! | java | `java` | `java -version` |
|
||||
//! | r | `Rscript` | `Rscript --version` |
|
||||
//! | perl | `perl` | `perl --version` |
|
||||
//! Compatibility wrapper around the shared agent runtime detection module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info};
|
||||
pub use attune_common::agent_runtime_detection::{
|
||||
detect_runtimes, format_as_env_value, DetectedRuntime,
|
||||
};
|
||||
|
||||
/// A runtime interpreter discovered on the local system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedRuntime {
|
||||
/// Canonical runtime name (e.g., "shell", "python", "node").
|
||||
/// These names align with the normalized names from
|
||||
/// [`attune_common::runtime_detection::normalize_runtime_name`].
|
||||
pub name: String,
|
||||
|
||||
/// Absolute path to the interpreter binary (as resolved by `which`).
|
||||
pub path: String,
|
||||
|
||||
/// Version string if a version check command succeeded (e.g., "3.12.1").
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
impl fmt::Display for DetectedRuntime {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.version {
|
||||
Some(v) => write!(f, "{} ({}, v{})", self.name, self.path, v),
|
||||
None => write!(f, "{} ({})", self.name, self.path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate runtime to probe for during detection.
|
||||
struct RuntimeCandidate {
|
||||
/// Canonical name for this runtime (used in ATTUNE_WORKER_RUNTIMES).
|
||||
name: &'static str,
|
||||
|
||||
/// Binary names to try, in priority order. The first one found wins.
|
||||
binaries: &'static [&'static str],
|
||||
|
||||
/// Arguments to pass to the binary to get a version string.
|
||||
version_args: &'static [&'static str],
|
||||
|
||||
/// How to extract the version from command output.
|
||||
version_parser: VersionParser,
|
||||
}
|
||||
|
||||
/// Strategy for parsing version output from a command.
|
||||
enum VersionParser {
|
||||
/// Extract a version pattern like "X.Y.Z" from the combined stdout+stderr output.
|
||||
/// This handles the common case where the version appears somewhere in the output
|
||||
/// (e.g., "Python 3.12.1", "node v20.11.0", "go1.22.0").
|
||||
SemverLike,
|
||||
|
||||
/// Java uses `-version` which writes to stderr, and the format is
|
||||
/// `openjdk version "21.0.1"` or `java version "1.8.0_392"`.
|
||||
JavaStyle,
|
||||
}
|
||||
|
||||
/// All candidate runtimes to probe, in detection order.
|
||||
fn candidates() -> Vec<RuntimeCandidate> {
|
||||
vec![
|
||||
RuntimeCandidate {
|
||||
name: "shell",
|
||||
binaries: &["bash", "sh"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "python",
|
||||
binaries: &["python3", "python"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "node",
|
||||
binaries: &["node", "nodejs"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "ruby",
|
||||
binaries: &["ruby"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "go",
|
||||
binaries: &["go"],
|
||||
version_args: &["version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "java",
|
||||
binaries: &["java"],
|
||||
version_args: &["-version"],
|
||||
version_parser: VersionParser::JavaStyle,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "r",
|
||||
binaries: &["Rscript"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
RuntimeCandidate {
|
||||
name: "perl",
|
||||
binaries: &["perl"],
|
||||
version_args: &["--version"],
|
||||
version_parser: VersionParser::SemverLike,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Detect available runtimes by probing the local system for known interpreter binaries.
|
||||
///
|
||||
/// This function performs synchronous subprocess calls (`std::process::Command`) since
|
||||
/// it is a one-time startup operation. It checks each candidate runtime's binaries
|
||||
/// in priority order using `which`-style PATH lookup, and optionally captures the
|
||||
/// interpreter version.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of [`DetectedRuntime`] for each runtime that was found on the system.
|
||||
/// The order matches the detection order (shell first, then python, node, etc.).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use attune_worker::runtime_detect::detect_runtimes;
|
||||
///
|
||||
/// let runtimes = detect_runtimes();
|
||||
/// for rt in &runtimes {
|
||||
/// println!("Found: {}", rt);
|
||||
/// }
|
||||
/// // Convert to ATTUNE_WORKER_RUNTIMES format
|
||||
/// let names: Vec<&str> = runtimes.iter().map(|r| r.name.as_str()).collect();
|
||||
/// println!("ATTUNE_WORKER_RUNTIMES={}", names.join(","));
|
||||
/// ```
|
||||
pub fn detect_runtimes() -> Vec<DetectedRuntime> {
|
||||
info!("Starting runtime auto-detection...");
|
||||
|
||||
let mut detected = Vec::new();
|
||||
|
||||
for candidate in candidates() {
|
||||
match detect_single_runtime(&candidate) {
|
||||
Some(runtime) => {
|
||||
info!(" ✓ Detected: {}", runtime);
|
||||
detected.push(runtime);
|
||||
}
|
||||
None => {
|
||||
debug!(" ✗ Not found: {}", candidate.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
"Runtime auto-detection complete: found {} runtime(s): [{}]",
|
||||
detected.len(),
|
||||
detected
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
/// Attempt to detect a single runtime by checking its candidate binaries.
|
||||
fn detect_single_runtime(candidate: &RuntimeCandidate) -> Option<DetectedRuntime> {
|
||||
for binary in candidate.binaries {
|
||||
if let Some(path) = which_binary(binary) {
|
||||
debug!(
|
||||
"Found {} at {} (for runtime '{}')",
|
||||
binary, path, candidate.name
|
||||
);
|
||||
|
||||
// Attempt to get version info (non-fatal if it fails)
|
||||
let version = get_version(&path, candidate.version_args, &candidate.version_parser);
|
||||
|
||||
return Some(DetectedRuntime {
|
||||
name: candidate.name.to_string(),
|
||||
path,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Look up a binary on PATH, similar to the `which` command.
|
||||
///
|
||||
/// Uses `which <binary>` on the system to resolve the full path.
|
||||
/// Returns `None` if the binary is not found or `which` fails.
|
||||
fn which_binary(binary: &str) -> Option<String> {
|
||||
// First check well-known absolute paths for shell interpreters
|
||||
// (these may not be on PATH in minimal containers)
|
||||
if binary == "bash" || binary == "sh" {
|
||||
let absolute_path = format!("/bin/{}", binary);
|
||||
if std::path::Path::new(&absolute_path).exists() {
|
||||
return Some(absolute_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to PATH lookup via `which`
|
||||
match Command::new("which").arg(binary).output() {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// `which` itself not found — try `command -v` as fallback
|
||||
debug!("'which' command failed ({}), trying 'command -v'", e);
|
||||
match Command::new("sh")
|
||||
.args(["-c", &format!("command -v {}", binary)])
|
||||
.output()
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a version command and parse the version string from the output.
|
||||
fn get_version(binary_path: &str, version_args: &[&str], parser: &VersionParser) -> Option<String> {
|
||||
let output = match Command::new(binary_path).args(version_args).output() {
|
||||
Ok(output) => output,
|
||||
Err(e) => {
|
||||
debug!("Failed to run version command for {}: {}", binary_path, e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let combined = format!("{}{}", stdout, stderr);
|
||||
|
||||
match parser {
|
||||
VersionParser::SemverLike => parse_semver_like(&combined),
|
||||
VersionParser::JavaStyle => parse_java_version(&combined),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a semver-like version (X.Y.Z or X.Y) from output text.
|
||||
///
|
||||
/// Handles common patterns:
|
||||
/// - "Python 3.12.1"
|
||||
/// - "node v20.11.0"
|
||||
/// - "go version go1.22.0 linux/amd64"
|
||||
/// - "GNU bash, version 5.2.15(1)-release"
|
||||
/// - "ruby 3.2.2 (2023-03-30 revision e51014f9c0)"
|
||||
/// - "perl 5, version 36, subversion 0 (v5.36.0)"
|
||||
fn parse_semver_like(output: &str) -> Option<String> {
|
||||
// Try to find a pattern like X.Y.Z or X.Y (with optional leading 'v')
|
||||
// Also handle go's "go1.22.0" format
|
||||
let re = regex::Regex::new(r"(?:v|go)?(\d+\.\d+(?:\.\d+)?)").ok()?;
|
||||
|
||||
if let Some(captures) = re.captures(output) {
|
||||
captures.get(1).map(|m| m.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Java's peculiar version output format.
|
||||
///
|
||||
/// Java writes to stderr and uses formats like:
|
||||
/// - `openjdk version "21.0.1" 2023-10-17`
|
||||
/// - `java version "1.8.0_392"`
|
||||
fn parse_java_version(output: &str) -> Option<String> {
|
||||
// Look for version inside quotes first
|
||||
let quoted_re = regex::Regex::new(r#"version\s+"([^"]+)""#).ok()?;
|
||||
if let Some(captures) = quoted_re.captures(output) {
|
||||
return captures.get(1).map(|m| m.as_str().to_string());
|
||||
}
|
||||
|
||||
// Fall back to semver-like parsing
|
||||
parse_semver_like(output)
|
||||
}
|
||||
|
||||
/// Format detected runtimes as a comma-separated string suitable for
|
||||
/// the `ATTUNE_WORKER_RUNTIMES` environment variable.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```no_run
|
||||
/// use attune_worker::runtime_detect::{detect_runtimes, format_as_env_value};
|
||||
///
|
||||
/// let runtimes = detect_runtimes();
|
||||
/// let env_val = format_as_env_value(&runtimes);
|
||||
/// // e.g., "shell,python,node"
|
||||
/// ```
|
||||
pub fn format_as_env_value(runtimes: &[DetectedRuntime]) -> String {
|
||||
runtimes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// Print a human-readable detection report to stdout.
|
||||
///
|
||||
/// Used by the `--detect-only` flag to show detection results and exit.
|
||||
pub fn print_detection_report(runtimes: &[DetectedRuntime]) {
|
||||
println!("=== Attune Agent Runtime Detection Report ===");
|
||||
println!();
|
||||
|
||||
if runtimes.is_empty() {
|
||||
println!("No runtimes detected!");
|
||||
println!();
|
||||
println!("The agent could not find any supported interpreter binaries.");
|
||||
println!("Ensure at least one of the following is installed and on PATH:");
|
||||
println!(" - bash / sh (shell scripts)");
|
||||
println!(" - python3 / python (Python scripts)");
|
||||
println!(" - node / nodejs (Node.js scripts)");
|
||||
println!(" - ruby (Ruby scripts)");
|
||||
println!(" - go (Go programs)");
|
||||
println!(" - java (Java programs)");
|
||||
println!(" - Rscript (R scripts)");
|
||||
println!(" - perl (Perl scripts)");
|
||||
} else {
|
||||
println!("Detected {} runtime(s):", runtimes.len());
|
||||
println!();
|
||||
for rt in runtimes {
|
||||
let version_str = rt.version.as_deref().unwrap_or("unknown version");
|
||||
println!(" ✓ {:<10} {} ({})", rt.name, rt.path, version_str);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("ATTUNE_WORKER_RUNTIMES={}", format_as_env_value(runtimes));
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_python() {
|
||||
assert_eq!(
|
||||
parse_semver_like("Python 3.12.1"),
|
||||
Some("3.12.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_node() {
|
||||
assert_eq!(parse_semver_like("v20.11.0"), Some("20.11.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_go() {
|
||||
assert_eq!(
|
||||
parse_semver_like("go version go1.22.0 linux/amd64"),
|
||||
Some("1.22.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_bash() {
|
||||
assert_eq!(
|
||||
parse_semver_like("GNU bash, version 5.2.15(1)-release (x86_64-pc-linux-gnu)"),
|
||||
Some("5.2.15".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_ruby() {
|
||||
assert_eq!(
|
||||
parse_semver_like("ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux]"),
|
||||
Some("3.2.2".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_two_part() {
|
||||
assert_eq!(
|
||||
parse_semver_like("SomeRuntime 1.5"),
|
||||
Some("1.5".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_semver_like_no_match() {
|
||||
assert_eq!(parse_semver_like("no version here"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_java_version_openjdk() {
|
||||
assert_eq!(
|
||||
parse_java_version(r#"openjdk version "21.0.1" 2023-10-17"#),
|
||||
Some("21.0.1".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_java_version_legacy() {
|
||||
assert_eq!(
|
||||
parse_java_version(r#"java version "1.8.0_392""#),
|
||||
Some("1.8.0_392".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_as_env_value_empty() {
|
||||
let runtimes: Vec<DetectedRuntime> = vec![];
|
||||
assert_eq!(format_as_env_value(&runtimes), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_as_env_value_multiple() {
|
||||
let runtimes = vec![
|
||||
DetectedRuntime {
|
||||
name: "shell".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
version: Some("5.2.15".to_string()),
|
||||
},
|
||||
DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
version: Some("3.12.1".to_string()),
|
||||
},
|
||||
DetectedRuntime {
|
||||
name: "node".to_string(),
|
||||
path: "/usr/bin/node".to_string(),
|
||||
version: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(format_as_env_value(&runtimes), "shell,python,node");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detected_runtime_display_with_version() {
|
||||
let rt = DetectedRuntime {
|
||||
name: "python".to_string(),
|
||||
path: "/usr/bin/python3".to_string(),
|
||||
version: Some("3.12.1".to_string()),
|
||||
};
|
||||
assert_eq!(format!("{}", rt), "python (/usr/bin/python3, v3.12.1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detected_runtime_display_without_version() {
|
||||
let rt = DetectedRuntime {
|
||||
name: "shell".to_string(),
|
||||
path: "/bin/bash".to_string(),
|
||||
version: None,
|
||||
};
|
||||
assert_eq!(format!("{}", rt), "shell (/bin/bash)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_runtimes_runs_without_panic() {
|
||||
// This test verifies the detection logic doesn't panic,
|
||||
// regardless of what's actually installed on the system.
|
||||
let runtimes = detect_runtimes();
|
||||
// We should at least find a shell on any Unix system
|
||||
// but we don't assert that since test environments vary.
|
||||
let _ = runtimes;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_which_binary_sh() {
|
||||
// /bin/sh should exist on virtually all Unix systems
|
||||
let result = which_binary("sh");
|
||||
assert!(result.is_some(), "Expected to find 'sh' on this system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_which_binary_nonexistent() {
|
||||
let result = which_binary("definitely_not_a_real_binary_xyz123");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_order() {
|
||||
let c = candidates();
|
||||
assert_eq!(c[0].name, "shell");
|
||||
assert_eq!(c[1].name, "python");
|
||||
assert_eq!(c[2].name, "node");
|
||||
assert_eq!(c[3].name, "ruby");
|
||||
assert_eq!(c[4].name, "go");
|
||||
assert_eq!(c[5].name, "java");
|
||||
assert_eq!(c[6].name, "r");
|
||||
assert_eq!(c[7].name, "perl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_candidates_binaries_priority() {
|
||||
let c = candidates();
|
||||
// shell prefers bash over sh
|
||||
assert_eq!(c[0].binaries, &["bash", "sh"]);
|
||||
// python prefers python3 over python
|
||||
assert_eq!(c[1].binaries, &["python3", "python"]);
|
||||
// node prefers node over nodejs
|
||||
assert_eq!(c[2].binaries, &["node", "nodejs"]);
|
||||
}
|
||||
attune_common::agent_runtime_detection::print_detection_report_for_env(
|
||||
"ATTUNE_WORKER_RUNTIMES",
|
||||
runtimes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ use attune_common::mq::{
|
||||
MessageEnvelope, MessageType, PackRegisteredPayload, Publisher, PublisherConfig,
|
||||
};
|
||||
use attune_common::repositories::{execution::ExecutionRepository, FindById};
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
@@ -253,10 +253,10 @@ impl WorkerService {
|
||||
// Uses alias-aware matching so that e.g. filter "node"
|
||||
// matches DB runtime name "Node.js" (lowercased to "node.js").
|
||||
if let Some(ref filter) = runtime_filter {
|
||||
if !runtime_in_filter(&rt_name, filter) {
|
||||
if !runtime_aliases_match_filter(&rt.aliases, filter) {
|
||||
debug!(
|
||||
"Skipping runtime '{}' (not in ATTUNE_WORKER_RUNTIMES filter)",
|
||||
rt_name
|
||||
"Skipping runtime '{}' (aliases {:?} not in ATTUNE_WORKER_RUNTIMES filter)",
|
||||
rt_name, rt.aliases
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use attune_common::models::RuntimeVersion;
|
||||
use attune_common::repositories::runtime_version::RuntimeVersionRepository;
|
||||
use attune_common::runtime_detection::runtime_in_filter;
|
||||
use attune_common::runtime_detection::runtime_aliases_match_filter;
|
||||
|
||||
/// Result of verifying all runtime versions at startup.
|
||||
#[derive(Debug)]
|
||||
@@ -95,7 +95,7 @@ pub async fn verify_all_runtime_versions(
|
||||
.to_lowercase();
|
||||
|
||||
if let Some(filter) = runtime_filter {
|
||||
if !runtime_in_filter(&rt_base_name, filter) {
|
||||
if !runtime_aliases_match_filter(&[rt_base_name.to_string()], filter) {
|
||||
debug!(
|
||||
"Skipping version '{}' of runtime '{}' (not in worker runtime filter)",
|
||||
version.version, version.runtime_ref,
|
||||
|
||||
Reference in New Issue
Block a user