agent workers
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -490,6 +490,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"subtle",
|
||||||
"tar",
|
"tar",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ spec:
|
|||||||
- name: ATTUNE_WORKER_TYPE
|
- name: ATTUNE_WORKER_TYPE
|
||||||
value: container
|
value: container
|
||||||
- name: ATTUNE_WORKER_NAME
|
- name: ATTUNE_WORKER_NAME
|
||||||
value: agent-worker-{{ .name }}-01
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
- name: ATTUNE_API_URL
|
- name: ATTUNE_API_URL
|
||||||
value: http://{{ include "attune.apiServiceName" $ }}:{{ $.Values.api.service.port }}
|
value: http://{{ include "attune.apiServiceName" $ }}:{{ $.Values.api.service.port }}
|
||||||
- name: RUST_LOG
|
- name: RUST_LOG
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ hmac = "0.12"
|
|||||||
sha1 = "0.10"
|
sha1 = "0.10"
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
subtle = "2.6"
|
||||||
|
|
||||||
# OpenAPI/Swagger
|
# OpenAPI/Swagger
|
||||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use subtle::ConstantTimeEq;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio_util::io::ReaderStream;
|
use tokio_util::io::ReaderStream;
|
||||||
use utoipa::{IntoParams, ToSchema};
|
use utoipa::{IntoParams, ToSchema};
|
||||||
@@ -83,7 +84,18 @@ fn validate_token(
|
|||||||
|
|
||||||
let expected_token = match expected_token {
|
let expected_token = match expected_token {
|
||||||
Some(t) => t,
|
Some(t) => t,
|
||||||
None => return Ok(()), // No token configured, allow access
|
None => {
|
||||||
|
use std::sync::Once;
|
||||||
|
static WARN_ONCE: Once = Once::new();
|
||||||
|
WARN_ONCE.call_once(|| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Agent binary download endpoint has no bootstrap_token configured. \
|
||||||
|
Anyone with network access to the API can download the agent binary. \
|
||||||
|
Set agent.bootstrap_token in config to restrict access."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check X-Agent-Token header first, then query param
|
// Check X-Agent-Token header first, then query param
|
||||||
@@ -94,7 +106,7 @@ fn validate_token(
|
|||||||
.or_else(|| query_token.clone());
|
.or_else(|| query_token.clone());
|
||||||
|
|
||||||
match provided_token {
|
match provided_token {
|
||||||
Some(ref t) if t == expected_token => Ok(()),
|
Some(ref t) if bool::from(t.as_bytes().ct_eq(expected_token.as_bytes())) => Ok(()),
|
||||||
Some(_) => Err((
|
Some(_) => Err((
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(serde_json::json!({
|
Json(serde_json::json!({
|
||||||
@@ -152,15 +164,19 @@ pub async fn download_agent_binary(
|
|||||||
|
|
||||||
let binary_dir = std::path::Path::new(&agent_config.binary_dir);
|
let binary_dir = std::path::Path::new(&agent_config.binary_dir);
|
||||||
|
|
||||||
// Try arch-specific binary first, then fall back to generic name
|
// Try arch-specific binary first, then fall back to generic name.
|
||||||
|
// IMPORTANT: The generic `attune-agent` binary is only safe to serve for
|
||||||
|
// x86_64 requests, because the current build pipeline produces an
|
||||||
|
// x86_64-unknown-linux-musl binary. Serving it for aarch64/arm64 would
|
||||||
|
// give the caller an incompatible executable (exec format error).
|
||||||
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
|
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
|
||||||
let generic = binary_dir.join("attune-agent");
|
let generic = binary_dir.join("attune-agent");
|
||||||
|
|
||||||
let binary_path = if arch_specific.exists() {
|
let binary_path = if arch_specific.exists() {
|
||||||
arch_specific
|
arch_specific
|
||||||
} else if generic.exists() {
|
} else if arch == "x86_64" && generic.exists() {
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
"Arch-specific binary not found at {:?}, falling back to {:?}",
|
"Arch-specific binary not found at {:?}, falling back to generic {:?} (safe for x86_64)",
|
||||||
arch_specific,
|
arch_specific,
|
||||||
generic
|
generic
|
||||||
);
|
);
|
||||||
@@ -269,12 +285,14 @@ pub async fn agent_info(
|
|||||||
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
|
let arch_specific = binary_dir.join(format!("attune-agent-{}", arch));
|
||||||
let generic = binary_dir.join("attune-agent");
|
let generic = binary_dir.join("attune-agent");
|
||||||
|
|
||||||
|
// Only fall back to the generic binary for x86_64, since the build
|
||||||
|
// pipeline currently produces x86_64-only generic binaries.
|
||||||
let (available, size_bytes) = if arch_specific.exists() {
|
let (available, size_bytes) = if arch_specific.exists() {
|
||||||
match fs::metadata(&arch_specific).await {
|
match fs::metadata(&arch_specific).await {
|
||||||
Ok(m) => (true, m.len()),
|
Ok(m) => (true, m.len()),
|
||||||
Err(_) => (false, 0),
|
Err(_) => (false, 0),
|
||||||
}
|
}
|
||||||
} else if generic.exists() {
|
} else if *arch == "x86_64" && generic.exists() {
|
||||||
match fs::metadata(&generic).await {
|
match fs::metadata(&generic).await {
|
||||||
Ok(m) => (true, m.len()),
|
Ok(m) => (true, m.len()),
|
||||||
Err(_) => (false, 0),
|
Err(_) => (false, 0),
|
||||||
|
|||||||
@@ -34,21 +34,22 @@ use tracing::{debug, info, warn};
|
|||||||
/// use attune_common::runtime_detection::normalize_runtime_name;
|
/// use attune_common::runtime_detection::normalize_runtime_name;
|
||||||
/// assert_eq!(normalize_runtime_name("node.js"), "node");
|
/// assert_eq!(normalize_runtime_name("node.js"), "node");
|
||||||
/// assert_eq!(normalize_runtime_name("nodejs"), "node");
|
/// assert_eq!(normalize_runtime_name("nodejs"), "node");
|
||||||
/// assert_eq!(normalize_runtime_name("python3"), "python");
|
/// assert_eq!(normalize_runtime_name("Python3"), "python");
|
||||||
/// assert_eq!(normalize_runtime_name("shell"), "shell");
|
/// assert_eq!(normalize_runtime_name("Shell"), "shell");
|
||||||
/// ```
|
/// ```
|
||||||
pub fn normalize_runtime_name(name: &str) -> &str {
|
pub fn normalize_runtime_name(name: &str) -> String {
|
||||||
match name {
|
let lower = name.to_ascii_lowercase();
|
||||||
"node" | "nodejs" | "node.js" => "node",
|
match lower.as_str() {
|
||||||
"python" | "python3" => "python",
|
"node" | "nodejs" | "node.js" => "node".to_string(),
|
||||||
"bash" | "sh" | "shell" => "shell",
|
"python" | "python3" => "python".to_string(),
|
||||||
"native" | "builtin" | "standalone" => "native",
|
"bash" | "sh" | "shell" => "shell".to_string(),
|
||||||
"ruby" | "rb" => "ruby",
|
"native" | "builtin" | "standalone" => "native".to_string(),
|
||||||
"go" | "golang" => "go",
|
"ruby" | "rb" => "ruby".to_string(),
|
||||||
"java" | "jdk" | "openjdk" => "java",
|
"go" | "golang" => "go".to_string(),
|
||||||
"perl" | "perl5" => "perl",
|
"java" | "jdk" | "openjdk" => "java".to_string(),
|
||||||
"r" | "rscript" => "r",
|
"perl" | "perl5" => "perl".to_string(),
|
||||||
other => other,
|
"r" | "rscript" => "r".to_string(),
|
||||||
|
_ => lower,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +58,7 @@ pub fn normalize_runtime_name(name: &str) -> &str {
|
|||||||
/// Both sides are lowercased and then normalized before comparison so that,
|
/// 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"`.
|
/// 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 {
|
pub fn runtime_matches_filter(rt_name: &str, filter_entry: &str) -> bool {
|
||||||
let rt_lower = rt_name.to_ascii_lowercase();
|
normalize_runtime_name(rt_name) == normalize_runtime_name(filter_entry)
|
||||||
let filter_lower = filter_entry.to_ascii_lowercase();
|
|
||||||
normalize_runtime_name(&rt_lower) == normalize_runtime_name(&filter_lower)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a runtime name matches any entry in a filter list.
|
/// Check if a runtime name matches any entry in a filter list.
|
||||||
@@ -398,6 +397,25 @@ mod tests {
|
|||||||
assert_eq!(normalize_runtime_name("custom_runtime"), "custom_runtime");
|
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]
|
#[test]
|
||||||
fn test_runtime_matches_filter() {
|
fn test_runtime_matches_filter() {
|
||||||
// Node.js DB name lowercased vs worker filter "node"
|
// Node.js DB name lowercased vs worker filter "node"
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ struct Args {
|
|||||||
detect_only: bool,
|
detect_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
fn main() -> Result<()> {
|
||||||
async fn main() -> Result<()> {
|
|
||||||
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
||||||
attune_common::auth::install_crypto_provider();
|
attune_common::auth::install_crypto_provider();
|
||||||
|
|
||||||
@@ -75,7 +74,11 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
info!("Starting Attune Universal Worker Agent");
|
info!("Starting Attune Universal Worker Agent");
|
||||||
|
|
||||||
// --- Phase 1: Runtime auto-detection ---
|
// --- 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
|
// Check if the user has explicitly set ATTUNE_WORKER_RUNTIMES. If so, skip
|
||||||
// auto-detection and respect their override. Otherwise, probe the system for
|
// auto-detection and respect their override. Otherwise, probe the system for
|
||||||
@@ -83,15 +86,57 @@ async fn main() -> Result<()> {
|
|||||||
let runtimes_override = std::env::var("ATTUNE_WORKER_RUNTIMES").ok();
|
let runtimes_override = std::env::var("ATTUNE_WORKER_RUNTIMES").ok();
|
||||||
|
|
||||||
// Holds the detected runtimes so we can pass them to WorkerService later.
|
// Holds the detected runtimes so we can pass them to WorkerService later.
|
||||||
// Populated only when auto-detection actually runs (no env var override).
|
// 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>> =
|
let mut agent_detected_runtimes: Option<Vec<attune_worker::runtime_detect::DetectedRuntime>> =
|
||||||
None;
|
None;
|
||||||
|
|
||||||
if let Some(ref override_value) = runtimes_override {
|
if let Some(ref override_value) = runtimes_override {
|
||||||
info!(
|
info!(
|
||||||
"ATTUNE_WORKER_RUNTIMES already set (override), skipping auto-detection: {}",
|
"ATTUNE_WORKER_RUNTIMES already set (override): {}",
|
||||||
override_value
|
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 {
|
} else {
|
||||||
info!("No ATTUNE_WORKER_RUNTIMES override — running auto-detection...");
|
info!("No ATTUNE_WORKER_RUNTIMES override — running auto-detection...");
|
||||||
|
|
||||||
@@ -113,10 +158,7 @@ async fn main() -> Result<()> {
|
|||||||
let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect();
|
let runtime_list: Vec<&str> = detected.iter().map(|r| r.name.as_str()).collect();
|
||||||
let runtime_csv = runtime_list.join(",");
|
let runtime_csv = runtime_list.join(",");
|
||||||
info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv);
|
info!("Setting ATTUNE_WORKER_RUNTIMES={}", runtime_csv);
|
||||||
// SAFETY: std::env::set_var is safe in Rust 2021 edition. If upgrading
|
// Safe: no other threads are running yet (tokio runtime not started).
|
||||||
// to edition 2024+, this call will need to be wrapped in `unsafe {}`.
|
|
||||||
// It's sound here because detection runs single-threaded before tokio
|
|
||||||
// starts any worker tasks.
|
|
||||||
std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv);
|
std::env::set_var("ATTUNE_WORKER_RUNTIMES", &runtime_csv);
|
||||||
|
|
||||||
// Stash for Phase 2: pass to WorkerService for rich capability registration
|
// Stash for Phase 2: pass to WorkerService for rich capability registration
|
||||||
@@ -124,7 +166,7 @@ async fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handle --detect-only ---
|
// --- Handle --detect-only (synchronous, no async runtime needed) ---
|
||||||
if args.detect_only {
|
if args.detect_only {
|
||||||
if runtimes_override.is_some() {
|
if runtimes_override.is_some() {
|
||||||
// User set an override, but --detect-only should show what's actually
|
// User set an override, but --detect-only should show what's actually
|
||||||
@@ -147,12 +189,24 @@ async fn main() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Phase 2: Load configuration ---
|
// --- Set config path env var (synchronous, before tokio runtime) ---
|
||||||
if let Some(config_path) = args.config {
|
if let Some(ref config_path) = args.config {
|
||||||
// SAFETY: std::env::set_var is safe in Rust 2021 edition. See note above.
|
// Safe: no other threads are running yet (tokio runtime not started).
|
||||||
std::env::set_var("ATTUNE_CONFIG", config_path);
|
std::env::set_var("ATTUNE_CONFIG", config_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Build the tokio runtime and run the async portion ---
|
||||||
|
let runtime = tokio::runtime::Runtime::new()?;
|
||||||
|
runtime.block_on(async_main(args, agent_detected_runtimes))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The async portion of the agent entrypoint. Called from `main()` via
|
||||||
|
/// `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>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
// --- Phase 2: Load configuration ---
|
||||||
let mut config = Config::load()?;
|
let mut config = Config::load()?;
|
||||||
config.validate()?;
|
config.validate()?;
|
||||||
|
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ pub async fn auto_register_detected_runtimes(
|
|||||||
|
|
||||||
// Check if a runtime with a matching name already exists in the DB.
|
// Check if a runtime with a matching name already exists in the DB.
|
||||||
// We normalize both sides for alias-aware comparison.
|
// We normalize both sides for alias-aware comparison.
|
||||||
let already_exists = existing_runtimes.iter().any(|r| {
|
// normalize_runtime_name lowercases internally, so no need to pre-lowercase.
|
||||||
let db_name = r.name.to_ascii_lowercase();
|
let already_exists = existing_runtimes
|
||||||
normalize_runtime_name(&db_name) == canonical_name
|
.iter()
|
||||||
});
|
.any(|r| normalize_runtime_name(&r.name) == canonical_name);
|
||||||
|
|
||||||
if already_exists {
|
if already_exists {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -194,7 +194,7 @@ pub async fn auto_register_detected_runtimes(
|
|||||||
"Auto-detected {} runtime at {}",
|
"Auto-detected {} runtime at {}",
|
||||||
detected_rt.name, detected_rt.path
|
detected_rt.name, detected_rt.path
|
||||||
)),
|
)),
|
||||||
name: capitalize_runtime_name(canonical_name),
|
name: capitalize_runtime_name(&canonical_name),
|
||||||
distributions: build_minimal_distributions(detected_rt),
|
distributions: build_minimal_distributions(detected_rt),
|
||||||
installation: None,
|
installation: None,
|
||||||
execution_config,
|
execution_config,
|
||||||
@@ -286,7 +286,7 @@ fn build_execution_config_from_template(
|
|||||||
/// interpreter directly, without environment or dependency management.
|
/// interpreter directly, without environment or dependency management.
|
||||||
fn build_minimal_execution_config(detected: &DetectedRuntime) -> serde_json::Value {
|
fn build_minimal_execution_config(detected: &DetectedRuntime) -> serde_json::Value {
|
||||||
let canonical = normalize_runtime_name(&detected.name);
|
let canonical = normalize_runtime_name(&detected.name);
|
||||||
let file_ext = default_file_extension(canonical);
|
let file_ext = default_file_extension(&canonical);
|
||||||
|
|
||||||
let mut config = json!({
|
let mut config = json!({
|
||||||
"interpreter": {
|
"interpreter": {
|
||||||
|
|||||||
@@ -24,9 +24,27 @@ use attune_common::models::runtime::{
|
|||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
/// Per-directory locks for lazy environment setup to prevent concurrent
|
||||||
|
/// setup of the same environment from corrupting it. When two executions
|
||||||
|
/// for the same pack arrive concurrently (e.g. in agent mode), both may
|
||||||
|
/// see `!env_dir.exists()` and race to run `setup_pack_environment`.
|
||||||
|
/// This map provides a per-directory async mutex so that only one setup
|
||||||
|
/// runs at a time for each env_dir path.
|
||||||
|
static ENV_SETUP_LOCKS: OnceLock<StdMutex<HashMap<PathBuf, Arc<tokio::sync::Mutex<()>>>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
|
fn get_env_setup_lock(env_dir: &Path) -> Arc<tokio::sync::Mutex<()>> {
|
||||||
|
let locks = ENV_SETUP_LOCKS.get_or_init(|| StdMutex::new(HashMap::new()));
|
||||||
|
let mut map = locks.lock().unwrap();
|
||||||
|
map.entry(env_dir.to_path_buf())
|
||||||
|
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
fn bash_single_quote_escape(s: &str) -> String {
|
fn bash_single_quote_escape(s: &str) -> String {
|
||||||
s.replace('\'', "'\\''")
|
s.replace('\'', "'\\''")
|
||||||
}
|
}
|
||||||
@@ -620,7 +638,15 @@ impl Runtime for ProcessRuntime {
|
|||||||
// create it on-demand. This is the primary code path for agent mode where
|
// create it on-demand. This is the primary code path for agent mode where
|
||||||
// proactive startup setup is skipped, but it also serves as a safety net
|
// proactive startup setup is skipped, but it also serves as a safety net
|
||||||
// for standard workers if the environment was somehow missed.
|
// for standard workers if the environment was somehow missed.
|
||||||
if effective_config.environment.is_some() && pack_dir.exists() && !env_dir.exists() {
|
// Acquire a per-directory async lock to serialize environment setup.
|
||||||
|
// This prevents concurrent executions for the same pack from racing
|
||||||
|
// to create or repair the environment simultaneously.
|
||||||
|
if effective_config.environment.is_some() && pack_dir.exists() {
|
||||||
|
let env_lock = get_env_setup_lock(&env_dir);
|
||||||
|
let _guard = env_lock.lock().await;
|
||||||
|
|
||||||
|
// --- Lazy environment creation (double-checked after lock) ---
|
||||||
|
if !env_dir.exists() {
|
||||||
info!(
|
info!(
|
||||||
"Runtime environment for pack '{}' not found at {}. \
|
"Runtime environment for pack '{}' not found at {}. \
|
||||||
Creating on first use (lazy setup).",
|
Creating on first use (lazy setup).",
|
||||||
@@ -657,16 +683,18 @@ impl Runtime for ProcessRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Broken-symlink repair (also under the per-directory lock) ---
|
||||||
// If the environment directory exists but contains a broken interpreter
|
// If the environment directory exists but contains a broken interpreter
|
||||||
// (e.g. broken symlinks from a venv created in a different container),
|
// (e.g. broken symlinks from a venv created in a different container),
|
||||||
// attempt to recreate it before resolving the interpreter.
|
// attempt to recreate it before resolving the interpreter.
|
||||||
if effective_config.environment.is_some() && env_dir.exists() && pack_dir.exists() {
|
if env_dir.exists() {
|
||||||
if let Some(ref env_cfg) = effective_config.environment {
|
if let Some(ref env_cfg) = effective_config.environment {
|
||||||
if let Some(ref interp_template) = env_cfg.interpreter_path {
|
if let Some(ref interp_template) = env_cfg.interpreter_path {
|
||||||
let mut vars = std::collections::HashMap::new();
|
let mut vars = std::collections::HashMap::new();
|
||||||
vars.insert("env_dir", env_dir.to_string_lossy().to_string());
|
vars.insert("env_dir", env_dir.to_string_lossy().to_string());
|
||||||
vars.insert("pack_dir", pack_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 =
|
||||||
|
RuntimeExecutionConfig::resolve_template(interp_template, &vars);
|
||||||
let resolved_path = std::path::PathBuf::from(&resolved);
|
let resolved_path = std::path::PathBuf::from(&resolved);
|
||||||
|
|
||||||
// Check for a broken symlink: symlink_metadata succeeds for
|
// Check for a broken symlink: symlink_metadata succeeds for
|
||||||
@@ -732,6 +760,7 @@ impl Runtime for ProcessRuntime {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let interpreter = effective_config.resolve_interpreter_with_env(&pack_dir, env_dir_opt);
|
let interpreter = effective_config.resolve_interpreter_with_env(&pack_dir, env_dir_opt);
|
||||||
|
|
||||||
|
|||||||
@@ -223,8 +223,6 @@ services:
|
|||||||
- api_logs:/opt/attune/logs
|
- api_logs:/opt/attune/logs
|
||||||
- agent_bin:/opt/attune/agent:ro
|
- agent_bin:/opt/attune/agent:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
init-agent:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
init-packs:
|
init-packs:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
init-user:
|
init-user:
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ set -e
|
|||||||
AGENT_DIR="${ATTUNE_AGENT_DIR:-/opt/attune/agent}"
|
AGENT_DIR="${ATTUNE_AGENT_DIR:-/opt/attune/agent}"
|
||||||
AGENT_BIN="$AGENT_DIR/attune-agent"
|
AGENT_BIN="$AGENT_DIR/attune-agent"
|
||||||
AGENT_URL="${ATTUNE_AGENT_URL:-http://attune-api:8080/api/v1/agent/binary}"
|
AGENT_URL="${ATTUNE_AGENT_URL:-http://attune-api:8080/api/v1/agent/binary}"
|
||||||
|
# SECURITY: The default URL uses plain HTTP, which is fine for internal Docker
|
||||||
|
# networking. For cross-network or production deployments, set ATTUNE_AGENT_URL
|
||||||
|
# to an HTTPS endpoint and consider setting ATTUNE_AGENT_TOKEN to authenticate.
|
||||||
AGENT_TOKEN="${ATTUNE_AGENT_TOKEN:-}"
|
AGENT_TOKEN="${ATTUNE_AGENT_TOKEN:-}"
|
||||||
|
|
||||||
# Auto-detect architecture if not specified
|
# Auto-detect architecture if not specified
|
||||||
@@ -70,21 +73,25 @@ while [ $ATTEMPT -lt $MAX_RETRIES ]; do
|
|||||||
ATTEMPT=$((ATTEMPT + 1))
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1; then
|
if command -v curl >/dev/null 2>&1; then
|
||||||
CURL_ARGS="-fsSL --retry 3 --retry-delay 2 -o $AGENT_BIN"
|
|
||||||
if [ -n "$AUTH_HEADER" ]; then
|
if [ -n "$AUTH_HEADER" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -H \"$AUTH_HEADER\""
|
if curl -fsSL --retry 3 --retry-delay 2 -o "$AGENT_BIN" -H "$AUTH_HEADER" "$DOWNLOAD_URL" 2>/dev/null; then
|
||||||
fi
|
|
||||||
if eval curl $CURL_ARGS "$DOWNLOAD_URL" 2>/dev/null; then
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if curl -fsSL --retry 3 --retry-delay 2 -o "$AGENT_BIN" "$DOWNLOAD_URL" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
elif command -v wget >/dev/null 2>&1; then
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
WGET_ARGS="-q -O $AGENT_BIN"
|
|
||||||
if [ -n "$AUTH_HEADER" ]; then
|
if [ -n "$AUTH_HEADER" ]; then
|
||||||
WGET_ARGS="$WGET_ARGS --header=\"$AUTH_HEADER\""
|
if wget -q -O "$AGENT_BIN" --header="$AUTH_HEADER" "$DOWNLOAD_URL" 2>/dev/null; then
|
||||||
fi
|
|
||||||
if eval wget $WGET_ARGS "$DOWNLOAD_URL" 2>/dev/null; then
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
else
|
||||||
|
if wget -q -O "$AGENT_BIN" "$DOWNLOAD_URL" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "[attune] ERROR: Neither curl nor wget available. Cannot download agent." >&2
|
echo "[attune] ERROR: Neither curl nor wget available. Cannot download agent." >&2
|
||||||
echo "[attune] Install curl or wget, or mount the agent binary via volume." >&2
|
echo "[attune] Install curl or wget, or mount the agent binary via volume." >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user