first iteration of agent-style worker and sensor containers.
This commit is contained in:
107
crates/common/src/agent_bootstrap.rs
Normal file
107
crates/common/src/agent_bootstrap.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! Shared bootstrap helpers for injected agent binaries.
|
||||
|
||||
use crate::agent_runtime_detection::{
|
||||
detect_runtimes, format_as_env_value, print_detection_report_for_env, DetectedRuntime,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeBootstrapResult {
|
||||
pub runtimes_override: Option<String>,
|
||||
pub detected_runtimes: Option<Vec<DetectedRuntime>>,
|
||||
}
|
||||
|
||||
/// Detect runtimes and populate the agent runtime environment variable when needed.
|
||||
///
|
||||
/// This must run before the Tokio runtime starts because it may mutate process
|
||||
/// environment variables.
|
||||
pub fn bootstrap_runtime_env(env_var_name: &str) -> RuntimeBootstrapResult {
|
||||
let runtimes_override = std::env::var(env_var_name).ok();
|
||||
let mut detected_runtimes = None;
|
||||
|
||||
if let Some(ref override_value) = runtimes_override {
|
||||
info!(
|
||||
"{} already set (override): {}",
|
||||
env_var_name, override_value
|
||||
);
|
||||
info!("Running auto-detection for override-specified runtimes...");
|
||||
|
||||
let detected = detect_runtimes();
|
||||
let override_names: Vec<&str> = override_value.split(',').map(|s| s.trim()).collect();
|
||||
|
||||
let filtered: Vec<_> = detected
|
||||
.into_iter()
|
||||
.filter(|rt| {
|
||||
let lower_name = rt.name.to_ascii_lowercase();
|
||||
override_names
|
||||
.iter()
|
||||
.any(|ov| ov.to_ascii_lowercase() == lower_name)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
warn!(
|
||||
"None of the override runtimes ({}) were found on this system",
|
||||
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),
|
||||
}
|
||||
}
|
||||
detected_runtimes = Some(filtered);
|
||||
}
|
||||
} else {
|
||||
info!("No {} override — running auto-detection...", env_var_name);
|
||||
|
||||
let detected = detect_runtimes();
|
||||
|
||||
if detected.is_empty() {
|
||||
warn!("No runtimes detected! The agent may not be able to execute any work.");
|
||||
} 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),
|
||||
}
|
||||
}
|
||||
|
||||
let runtime_csv = format_as_env_value(&detected);
|
||||
info!("Setting {}={}", env_var_name, runtime_csv);
|
||||
std::env::set_var(env_var_name, &runtime_csv);
|
||||
detected_runtimes = Some(detected);
|
||||
}
|
||||
}
|
||||
|
||||
RuntimeBootstrapResult {
|
||||
runtimes_override,
|
||||
detected_runtimes,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_detect_only_report(env_var_name: &str, result: &RuntimeBootstrapResult) {
|
||||
if result.runtimes_override.is_some() {
|
||||
info!("--detect-only: re-running detection to show what is available on this system...");
|
||||
println!(
|
||||
"NOTE: {} is set — auto-detection was skipped during normal startup.",
|
||||
env_var_name
|
||||
);
|
||||
println!(" Showing what auto-detection would find on this system:");
|
||||
println!();
|
||||
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report_for_env(env_var_name, &detected);
|
||||
} else if let Some(ref detected) = result.detected_runtimes {
|
||||
print_detection_report_for_env(env_var_name, detected);
|
||||
} else {
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report_for_env(env_var_name, &detected);
|
||||
}
|
||||
}
|
||||
306
crates/common/src/agent_runtime_detection.rs
Normal file
306
crates/common/src/agent_runtime_detection.rs
Normal file
@@ -0,0 +1,306 @@
|
||||
//! Runtime auto-detection for injected Attune agent binaries.
|
||||
//!
|
||||
//! This module probes the local system directly for well-known interpreters,
|
||||
//! without requiring database access.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// A runtime interpreter discovered on the local system.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedRuntime {
|
||||
/// Canonical runtime name (for example, "python" or "node").
|
||||
pub name: String,
|
||||
|
||||
/// Absolute path to the interpreter binary.
|
||||
pub path: String,
|
||||
|
||||
/// Version string if the version command succeeded.
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeCandidate {
|
||||
name: &'static str,
|
||||
binaries: &'static [&'static str],
|
||||
version_args: &'static [&'static str],
|
||||
version_parser: VersionParser,
|
||||
}
|
||||
|
||||
enum VersionParser {
|
||||
SemverLike,
|
||||
JavaStyle,
|
||||
}
|
||||
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
fn detect_single_runtime(candidate: &RuntimeCandidate) -> Option<DetectedRuntime> {
|
||||
for binary in candidate.binaries {
|
||||
if let Some(path) = which_binary(binary) {
|
||||
let version = get_version(&path, candidate.version_args, &candidate.version_parser);
|
||||
|
||||
return Some(DetectedRuntime {
|
||||
name: candidate.name.to_string(),
|
||||
path,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn which_binary(binary: &str) -> Option<String> {
|
||||
if binary == "bash" || binary == "sh" {
|
||||
let absolute_path = format!("/bin/{}", binary);
|
||||
if std::path::Path::new(&absolute_path).exists() {
|
||||
return Some(absolute_path);
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(e) => {
|
||||
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() {
|
||||
None
|
||||
} else {
|
||||
Some(path)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_semver_like(output: &str) -> Option<String> {
|
||||
let re = regex::Regex::new(r"(?:v|go)?(\d+\.\d+(?:\.\d+)?)").ok()?;
|
||||
re.captures(output)
|
||||
.and_then(|captures| captures.get(1).map(|m| m.as_str().to_string()))
|
||||
}
|
||||
|
||||
fn parse_java_version(output: &str) -> Option<String> {
|
||||
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());
|
||||
}
|
||||
|
||||
parse_semver_like(output)
|
||||
}
|
||||
|
||||
pub fn format_as_env_value(runtimes: &[DetectedRuntime]) -> String {
|
||||
runtimes
|
||||
.iter()
|
||||
.map(|r| r.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
pub fn print_detection_report_for_env(env_var_name: &str, 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!("{}={}", env_var_name, 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_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_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()),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(format_as_env_value(&runtimes), "shell,python");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
//! - Configuration
|
||||
//! - Utilities
|
||||
|
||||
pub mod agent_bootstrap;
|
||||
pub mod agent_runtime_detection;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
|
||||
@@ -776,6 +776,7 @@ pub mod runtime {
|
||||
pub pack_ref: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub name: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub distributions: JsonDict,
|
||||
pub installation: Option<JsonDict>,
|
||||
pub installers: JsonDict,
|
||||
|
||||
@@ -404,6 +404,16 @@ impl<'a> PackComponentLoader<'a> {
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
|
||||
let aliases: Vec<String> = data
|
||||
.get("aliases")
|
||||
.and_then(|v| v.as_sequence())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_ascii_lowercase()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Check if runtime already exists — update in place if so
|
||||
if let Some(existing) = RuntimeRepository::find_by_ref(self.pool, &runtime_ref).await? {
|
||||
let update_input = UpdateRuntimeInput {
|
||||
@@ -418,6 +428,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
None => Patch::Clear,
|
||||
}),
|
||||
execution_config: Some(execution_config),
|
||||
aliases: Some(aliases),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -449,6 +460,7 @@ impl<'a> PackComponentLoader<'a> {
|
||||
distributions,
|
||||
installation,
|
||||
execution_config,
|
||||
aliases,
|
||||
auto_detected: false,
|
||||
detection_config: serde_json::json!({}),
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Repository for RuntimeRepository {
|
||||
|
||||
/// Columns selected for all Runtime queries. Centralised here so that
|
||||
/// schema changes only need one update.
|
||||
pub const SELECT_COLUMNS: &str = "id, ref, pack, pack_ref, description, name, \
|
||||
pub const SELECT_COLUMNS: &str = "id, ref, pack, pack_ref, description, name, aliases, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config, \
|
||||
created, updated";
|
||||
@@ -38,6 +38,7 @@ pub struct CreateRuntimeInput {
|
||||
pub pack_ref: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub name: String,
|
||||
pub aliases: Vec<String>,
|
||||
pub distributions: JsonDict,
|
||||
pub installation: Option<JsonDict>,
|
||||
pub execution_config: JsonDict,
|
||||
@@ -50,6 +51,7 @@ pub struct CreateRuntimeInput {
|
||||
pub struct UpdateRuntimeInput {
|
||||
pub description: Option<Patch<String>>,
|
||||
pub name: Option<String>,
|
||||
pub aliases: Option<Vec<String>>,
|
||||
pub distributions: Option<JsonDict>,
|
||||
pub installation: Option<Patch<JsonDict>>,
|
||||
pub execution_config: Option<JsonDict>,
|
||||
@@ -113,10 +115,10 @@ impl Create for RuntimeRepository {
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"INSERT INTO runtime (ref, pack, pack_ref, description, name, \
|
||||
"INSERT INTO runtime (ref, pack, pack_ref, description, name, aliases, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \
|
||||
RETURNING {}",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
@@ -126,6 +128,7 @@ impl Create for RuntimeRepository {
|
||||
.bind(&input.pack_ref)
|
||||
.bind(&input.description)
|
||||
.bind(&input.name)
|
||||
.bind(&input.aliases)
|
||||
.bind(&input.distributions)
|
||||
.bind(&input.installation)
|
||||
.bind(serde_json::json!({}))
|
||||
@@ -170,6 +173,15 @@ impl Update for RuntimeRepository {
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(aliases) = &input.aliases {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
}
|
||||
query.push("aliases = ");
|
||||
query.push_bind(aliases.as_slice());
|
||||
has_updates = true;
|
||||
}
|
||||
|
||||
if let Some(distributions) = &input.distributions {
|
||||
if has_updates {
|
||||
query.push(", ");
|
||||
@@ -286,6 +298,23 @@ impl RuntimeRepository {
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Find a runtime where the given alias appears in its `aliases` array.
|
||||
/// Uses PostgreSQL's `@>` (array contains) operator with a GIN index.
|
||||
pub async fn find_by_alias<'e, E>(executor: E, alias: &str) -> Result<Option<Runtime>>
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE aliases @> ARRAY[$1]::text[] LIMIT 1",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(alias)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
/// Delete runtimes belonging to a pack whose refs are NOT in the given set.
|
||||
///
|
||||
/// Used during pack reinstallation to clean up runtimes that were removed
|
||||
|
||||
@@ -6,8 +6,10 @@
|
||||
//! 2. Config file specification (medium priority)
|
||||
//! 3. Database-driven detection with verification (lowest priority)
|
||||
//!
|
||||
//! Also provides [`normalize_runtime_name`] for alias-aware runtime name
|
||||
//! comparison across the codebase (worker filters, env setup, etc.).
|
||||
//! Also provides alias-based matching functions ([`runtime_aliases_match_filter`]
|
||||
//! and [`runtime_aliases_contain`]) for comparing runtime alias lists against
|
||||
//! worker filters and capability strings. Aliases are declared per-runtime in
|
||||
//! pack manifests, so no hardcoded alias table is needed here.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
@@ -19,51 +21,26 @@ use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
/// Normalize a runtime name to its canonical short form.
|
||||
/// Check if a runtime's aliases overlap with a filter list.
|
||||
///
|
||||
/// This ensures that different ways of referring to the same runtime
|
||||
/// (e.g., "node", "nodejs", "node.js") all resolve to a single canonical
|
||||
/// name. Used by worker runtime filters and environment setup to match
|
||||
/// database runtime names against short filter values.
|
||||
/// The filter list comes from `ATTUNE_WORKER_RUNTIMES` (e.g., `["python", "shell"]`).
|
||||
/// A runtime matches if any of its declared aliases appear in the filter list.
|
||||
/// Comparison is case-insensitive.
|
||||
pub fn runtime_aliases_match_filter(aliases: &[String], filter: &[String]) -> bool {
|
||||
aliases.iter().any(|alias| {
|
||||
let lower_alias = alias.to_ascii_lowercase();
|
||||
filter.iter().any(|f| f.to_ascii_lowercase() == lower_alias)
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a runtime's aliases contain a specific name.
|
||||
///
|
||||
/// The canonical names mirror the alias groups in
|
||||
/// `PackComponentLoader::resolve_runtime`.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
/// use attune_common::runtime_detection::normalize_runtime_name;
|
||||
/// assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
/// assert_eq!(normalize_runtime_name("Python3"), "python");
|
||||
/// assert_eq!(normalize_runtime_name("Shell"), "shell");
|
||||
/// ```
|
||||
pub fn normalize_runtime_name(name: &str) -> String {
|
||||
/// Used by the scheduler to check if a worker's capability string
|
||||
/// (e.g., "python") matches a runtime's aliases (e.g., ["python", "python3"]).
|
||||
/// Comparison is case-insensitive.
|
||||
pub fn runtime_aliases_contain(aliases: &[String], name: &str) -> bool {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"node" | "nodejs" | "node.js" => "node".to_string(),
|
||||
"python" | "python3" => "python".to_string(),
|
||||
"bash" | "sh" | "shell" => "shell".to_string(),
|
||||
"native" | "builtin" | "standalone" => "native".to_string(),
|
||||
"ruby" | "rb" => "ruby".to_string(),
|
||||
"go" | "golang" => "go".to_string(),
|
||||
"java" | "jdk" | "openjdk" => "java".to_string(),
|
||||
"perl" | "perl5" => "perl".to_string(),
|
||||
"r" | "rscript" => "r".to_string(),
|
||||
_ => lower,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches a filter entry, supporting common aliases.
|
||||
///
|
||||
/// Both sides are lowercased and then normalized before comparison so that,
|
||||
/// e.g., a filter value of `"node"` matches a database runtime name `"Node.js"`.
|
||||
pub fn runtime_matches_filter(rt_name: &str, filter_entry: &str) -> bool {
|
||||
normalize_runtime_name(rt_name) == normalize_runtime_name(filter_entry)
|
||||
}
|
||||
|
||||
/// Check if a runtime name matches any entry in a filter list.
|
||||
pub fn runtime_in_filter(rt_name: &str, filter: &[String]) -> bool {
|
||||
filter.iter().any(|f| runtime_matches_filter(rt_name, f))
|
||||
aliases.iter().any(|a| a.to_ascii_lowercase() == lower)
|
||||
}
|
||||
|
||||
/// Runtime detection service
|
||||
@@ -335,125 +312,46 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_node_variants() {
|
||||
assert_eq!(normalize_runtime_name("node"), "node");
|
||||
assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||
assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||
fn test_runtime_aliases_match_filter() {
|
||||
let aliases = vec!["python".to_string(), "python3".to_string()];
|
||||
let filter = vec!["python".to_string(), "shell".to_string()];
|
||||
assert!(runtime_aliases_match_filter(&aliases, &filter));
|
||||
|
||||
let filter_no_match = vec!["node".to_string(), "ruby".to_string()];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter_no_match));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_python_variants() {
|
||||
assert_eq!(normalize_runtime_name("python"), "python");
|
||||
assert_eq!(normalize_runtime_name("python3"), "python");
|
||||
fn test_runtime_aliases_match_filter_case_insensitive() {
|
||||
let aliases = vec!["Python".to_string(), "python3".to_string()];
|
||||
let filter = vec!["python".to_string()];
|
||||
assert!(runtime_aliases_match_filter(&aliases, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_shell_variants() {
|
||||
assert_eq!(normalize_runtime_name("shell"), "shell");
|
||||
assert_eq!(normalize_runtime_name("bash"), "shell");
|
||||
assert_eq!(normalize_runtime_name("sh"), "shell");
|
||||
fn test_runtime_aliases_match_filter_empty() {
|
||||
let aliases: Vec<String> = vec![];
|
||||
let filter = vec!["python".to_string()];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter));
|
||||
|
||||
let aliases = vec!["python".to_string()];
|
||||
let filter: Vec<String> = vec![];
|
||||
assert!(!runtime_aliases_match_filter(&aliases, &filter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_native_variants() {
|
||||
assert_eq!(normalize_runtime_name("native"), "native");
|
||||
assert_eq!(normalize_runtime_name("builtin"), "native");
|
||||
assert_eq!(normalize_runtime_name("standalone"), "native");
|
||||
fn test_runtime_aliases_contain() {
|
||||
let aliases = vec!["ruby".to_string(), "rb".to_string()];
|
||||
assert!(runtime_aliases_contain(&aliases, "ruby"));
|
||||
assert!(runtime_aliases_contain(&aliases, "rb"));
|
||||
assert!(!runtime_aliases_contain(&aliases, "python"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_ruby_variants() {
|
||||
assert_eq!(normalize_runtime_name("ruby"), "ruby");
|
||||
assert_eq!(normalize_runtime_name("rb"), "ruby");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_go_variants() {
|
||||
assert_eq!(normalize_runtime_name("go"), "go");
|
||||
assert_eq!(normalize_runtime_name("golang"), "go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_java_variants() {
|
||||
assert_eq!(normalize_runtime_name("java"), "java");
|
||||
assert_eq!(normalize_runtime_name("jdk"), "java");
|
||||
assert_eq!(normalize_runtime_name("openjdk"), "java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_perl_variants() {
|
||||
assert_eq!(normalize_runtime_name("perl"), "perl");
|
||||
assert_eq!(normalize_runtime_name("perl5"), "perl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_r_variants() {
|
||||
assert_eq!(normalize_runtime_name("r"), "r");
|
||||
assert_eq!(normalize_runtime_name("rscript"), "r");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_passthrough() {
|
||||
assert_eq!(normalize_runtime_name("custom_runtime"), "custom_runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_runtime_name_case_insensitive() {
|
||||
assert_eq!(normalize_runtime_name("Node"), "node");
|
||||
assert_eq!(normalize_runtime_name("NodeJS"), "node");
|
||||
assert_eq!(normalize_runtime_name("Node.js"), "node");
|
||||
assert_eq!(normalize_runtime_name("Python"), "python");
|
||||
assert_eq!(normalize_runtime_name("Python3"), "python");
|
||||
assert_eq!(normalize_runtime_name("Shell"), "shell");
|
||||
assert_eq!(normalize_runtime_name("BASH"), "shell");
|
||||
assert_eq!(normalize_runtime_name("Ruby"), "ruby");
|
||||
assert_eq!(normalize_runtime_name("Go"), "go");
|
||||
assert_eq!(normalize_runtime_name("GoLang"), "go");
|
||||
assert_eq!(normalize_runtime_name("Java"), "java");
|
||||
assert_eq!(normalize_runtime_name("JDK"), "java");
|
||||
assert_eq!(normalize_runtime_name("Perl"), "perl");
|
||||
assert_eq!(normalize_runtime_name("R"), "r");
|
||||
assert_eq!(normalize_runtime_name("Custom_Runtime"), "custom_runtime");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter() {
|
||||
// Node.js DB name lowercased vs worker filter "node"
|
||||
assert!(runtime_matches_filter("node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "nodejs"));
|
||||
assert!(runtime_matches_filter("nodejs", "node.js"));
|
||||
// Exact match
|
||||
assert!(runtime_matches_filter("shell", "shell"));
|
||||
// No match
|
||||
assert!(!runtime_matches_filter("python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_matches_filter_case_insensitive() {
|
||||
// Database stores capitalized names (e.g., "Node.js", "Python")
|
||||
// Worker capabilities store lowercase (e.g., "node", "python")
|
||||
assert!(runtime_matches_filter("Node.js", "node"));
|
||||
assert!(runtime_matches_filter("node", "Node.js"));
|
||||
assert!(runtime_matches_filter("Python", "python"));
|
||||
assert!(runtime_matches_filter("python", "Python"));
|
||||
assert!(runtime_matches_filter("Shell", "shell"));
|
||||
assert!(runtime_matches_filter("NODEJS", "node"));
|
||||
assert!(runtime_matches_filter("Ruby", "ruby"));
|
||||
assert!(runtime_matches_filter("ruby", "rb"));
|
||||
assert!(runtime_matches_filter("Go", "golang"));
|
||||
assert!(runtime_matches_filter("R", "rscript"));
|
||||
assert!(runtime_matches_filter("Java", "jdk"));
|
||||
assert!(runtime_matches_filter("Perl", "perl5"));
|
||||
assert!(!runtime_matches_filter("Python", "node"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_runtime_in_filter() {
|
||||
let filter = vec!["shell".to_string(), "node".to_string()];
|
||||
assert!(runtime_in_filter("shell", &filter));
|
||||
assert!(runtime_in_filter("node.js", &filter));
|
||||
assert!(runtime_in_filter("nodejs", &filter));
|
||||
assert!(!runtime_in_filter("python", &filter));
|
||||
fn test_runtime_aliases_contain_case_insensitive() {
|
||||
let aliases = vec!["ruby".to_string(), "rb".to_string()];
|
||||
assert!(runtime_aliases_contain(&aliases, "Ruby"));
|
||||
assert!(runtime_aliases_contain(&aliases, "RB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -961,6 +961,7 @@ impl RuntimeFixture {
|
||||
pack_ref: self.pack_ref,
|
||||
description: self.description,
|
||||
name: self.name,
|
||||
aliases: vec![],
|
||||
distributions: self.distributions,
|
||||
installation: self.installation,
|
||||
execution_config: self.execution_config,
|
||||
|
||||
@@ -64,6 +64,7 @@ impl RuntimeFixture {
|
||||
pack_ref: None,
|
||||
description: Some(format!("Test runtime {}", seq)),
|
||||
name,
|
||||
aliases: vec![],
|
||||
distributions: json!({
|
||||
"linux": { "supported": true, "versions": ["ubuntu20.04", "ubuntu22.04"] },
|
||||
"darwin": { "supported": true, "versions": ["12", "13"] }
|
||||
@@ -95,6 +96,7 @@ impl RuntimeFixture {
|
||||
pack_ref: None,
|
||||
description: None,
|
||||
name,
|
||||
aliases: vec![],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
execution_config: json!({
|
||||
|
||||
@@ -574,6 +574,7 @@ async fn test_worker_with_runtime() {
|
||||
pack_ref: None,
|
||||
description: Some("Test runtime".to_string()),
|
||||
name: "test_runtime".to_string(),
|
||||
aliases: vec![],
|
||||
distributions: json!({}),
|
||||
installation: None,
|
||||
execution_config: json!({
|
||||
|
||||
Reference in New Issue
Block a user