Some checks failed
CI / Rustfmt (push) Successful in 22s
CI / Clippy (push) Successful in 2m3s
CI / Cargo Audit & Deny (push) Successful in 32s
CI / Web Blocking Checks (push) Successful in 52s
CI / Security Blocking Checks (push) Successful in 8s
CI / Web Advisory Checks (push) Successful in 36s
CI / Security Advisory Checks (push) Successful in 43s
Publish Images / Resolve Publish Metadata (push) Successful in 2s
Publish Images / Publish web (arm64) (push) Failing after 3m53s
CI / Tests (push) Successful in 8m45s
Publish Images / Build Rust Bundles (amd64) (push) Failing after 8m57s
Publish Images / Publish web (amd64) (push) Successful in 48s
Publish Images / Publish agent (amd64) (push) Has been cancelled
Publish Images / Publish api (amd64) (push) Has been cancelled
Publish Images / Publish executor (amd64) (push) Has been cancelled
Publish Images / Publish notifier (amd64) (push) Has been cancelled
Publish Images / Publish agent (arm64) (push) Has been cancelled
Publish Images / Publish api (arm64) (push) Has been cancelled
Publish Images / Publish executor (arm64) (push) Has been cancelled
Publish Images / Build Rust Bundles (arm64) (push) Has been cancelled
Publish Images / Publish notifier (arm64) (push) Has been cancelled
Publish Images / Publish manifest attune-agent (push) Has been cancelled
Publish Images / Publish manifest attune-api (push) Has been cancelled
Publish Images / Publish manifest attune-executor (push) Has been cancelled
Publish Images / Publish manifest attune-notifier (push) Has been cancelled
Publish Images / Publish manifest attune-web (push) Has been cancelled
544 lines
17 KiB
Rust
544 lines
17 KiB
Rust
//! Worker Registration Module
|
|
//!
|
|
//! Handles worker registration, discovery, and status management in the database.
|
|
//! Uses unified runtime detection from the common crate.
|
|
|
|
use attune_common::config::Config;
|
|
use attune_common::error::{Error, Result};
|
|
use attune_common::models::{Worker, WorkerRole, WorkerStatus, WorkerType};
|
|
use attune_common::runtime_detection::RuntimeDetector;
|
|
use chrono::Utc;
|
|
use serde_json::json;
|
|
use sqlx::PgPool;
|
|
use std::collections::HashMap;
|
|
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,
|
|
worker_id: Option<i64>,
|
|
worker_name: String,
|
|
worker_type: WorkerType,
|
|
worker_role: WorkerRole,
|
|
runtime_id: Option<i64>,
|
|
host: Option<String>,
|
|
port: Option<i32>,
|
|
capabilities: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
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-{}",
|
|
hostname::get()
|
|
.unwrap_or_else(|_| "unknown".into())
|
|
.to_string_lossy()
|
|
)
|
|
});
|
|
|
|
let worker_type = config
|
|
.worker
|
|
.as_ref()
|
|
.and_then(|w| w.worker_type)
|
|
.or_else(Self::legacy_worker_type)
|
|
.unwrap_or(WorkerType::Local);
|
|
|
|
let worker_role = WorkerRole::Action;
|
|
|
|
let runtime_id = config.worker.as_ref().and_then(|w| w.runtime_id);
|
|
|
|
let host = config
|
|
.worker
|
|
.as_ref()
|
|
.and_then(|w| w.host.clone())
|
|
.or_else(|| {
|
|
hostname::get()
|
|
.ok()
|
|
.map(|h| h.to_string_lossy().to_string())
|
|
});
|
|
|
|
let port = config.worker.as_ref().and_then(|w| w.port);
|
|
|
|
// Initial capabilities (will be populated asynchronously)
|
|
let mut capabilities = HashMap::new();
|
|
|
|
// Set max_concurrent_executions from config
|
|
let max_concurrent = config
|
|
.worker
|
|
.as_ref()
|
|
.map(|w| w.max_concurrent_tasks)
|
|
.unwrap_or(10);
|
|
capabilities.insert(
|
|
"max_concurrent_executions".to_string(),
|
|
json!(max_concurrent),
|
|
);
|
|
|
|
// Add worker version metadata
|
|
capabilities.insert(
|
|
"worker_version".to_string(),
|
|
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()));
|
|
|
|
Self {
|
|
pool,
|
|
worker_id: None,
|
|
worker_name,
|
|
worker_type,
|
|
worker_role,
|
|
runtime_id,
|
|
host,
|
|
port,
|
|
capabilities,
|
|
}
|
|
}
|
|
|
|
/// Store detected runtime interpreter metadata in capabilities.
|
|
///
|
|
/// This is used by the agent (`attune-agent`) to record the full details of
|
|
/// auto-detected interpreters — binary paths and versions — alongside the
|
|
/// simple `runtimes` string list used for backward compatibility.
|
|
///
|
|
/// The data is stored under the `detected_interpreters` capability key as a
|
|
/// JSON array of objects:
|
|
/// ```json
|
|
/// [
|
|
/// {"name": "python", "path": "/usr/bin/python3", "version": "3.12.1"},
|
|
/// {"name": "shell", "path": "/bin/bash", "version": "5.2.15"}
|
|
/// ]
|
|
/// ```
|
|
pub fn set_detected_runtimes(&mut self, runtimes: Vec<DetectedRuntime>) {
|
|
let interpreters: Vec<serde_json::Value> = runtimes
|
|
.iter()
|
|
.map(|rt| {
|
|
json!({
|
|
"name": rt.name,
|
|
"path": rt.path,
|
|
"version": rt.version,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
self.capabilities
|
|
.insert("detected_interpreters".to_string(), json!(interpreters));
|
|
|
|
info!(
|
|
"Stored {} detected interpreter(s) in capabilities",
|
|
runtimes.len()
|
|
);
|
|
}
|
|
|
|
/// Mark this worker as running in agent mode.
|
|
///
|
|
/// Agent-mode workers auto-detect their runtimes at startup (as opposed to
|
|
/// being configured via `ATTUNE_WORKER_RUNTIMES` or config files). Setting
|
|
/// this flag allows the system to distinguish agents from standard workers.
|
|
pub fn set_agent_mode(&mut self, is_agent: bool) {
|
|
self.capabilities
|
|
.insert("agent_mode".to_string(), json!(is_agent));
|
|
}
|
|
|
|
/// Detect available runtimes using the unified runtime detector
|
|
pub async fn detect_capabilities(&mut self, config: &Config) -> Result<()> {
|
|
info!("Detecting worker capabilities...");
|
|
|
|
let detector = RuntimeDetector::new(self.pool.clone());
|
|
|
|
// Get config capabilities if available
|
|
let config_capabilities = config.worker.as_ref().and_then(|w| w.capabilities.as_ref());
|
|
|
|
// Detect capabilities with three-tier priority:
|
|
// 1. ATTUNE_WORKER_RUNTIMES env var
|
|
// 2. Config file
|
|
// 3. Database-driven detection
|
|
let detected_capabilities = detector
|
|
.detect_capabilities(config, "ATTUNE_WORKER_RUNTIMES", config_capabilities)
|
|
.await?;
|
|
|
|
// Merge detected capabilities with existing ones
|
|
for (key, value) in detected_capabilities {
|
|
self.capabilities.insert(key, value);
|
|
}
|
|
|
|
info!("Worker capabilities detected: {:?}", self.capabilities);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Register the worker in the database
|
|
pub async fn register(&mut self) -> Result<i64> {
|
|
info!("Registering worker: {}", self.worker_name);
|
|
|
|
// Check if worker with this name already exists
|
|
let existing = sqlx::query_as::<_, Worker>(
|
|
"SELECT * FROM worker WHERE name = $1 ORDER BY created DESC LIMIT 1",
|
|
)
|
|
.bind(&self.worker_name)
|
|
.fetch_optional(&self.pool)
|
|
.await?;
|
|
|
|
let worker_id = if let Some(existing_worker) = existing {
|
|
info!(
|
|
"Worker '{}' already exists (ID: {}), updating status",
|
|
self.worker_name, existing_worker.id
|
|
);
|
|
|
|
// Update existing worker to active status with new heartbeat
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE worker
|
|
SET status = $1,
|
|
last_heartbeat = $2,
|
|
host = $3,
|
|
port = $4,
|
|
capabilities = $5,
|
|
updated = $2
|
|
WHERE id = $6
|
|
"#,
|
|
)
|
|
.bind(WorkerStatus::Active)
|
|
.bind(Utc::now())
|
|
.bind(&self.host)
|
|
.bind(self.port)
|
|
.bind(serde_json::to_value(&self.capabilities)?)
|
|
.bind(existing_worker.id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
existing_worker.id
|
|
} else {
|
|
info!("Creating new worker registration: {}", self.worker_name);
|
|
|
|
// Insert new worker
|
|
let worker = sqlx::query_as::<_, Worker>(
|
|
r#"
|
|
INSERT INTO worker (name, worker_type, worker_role, runtime, host, port, status, capabilities, last_heartbeat)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *
|
|
"#,
|
|
)
|
|
.bind(&self.worker_name)
|
|
.bind(self.worker_type)
|
|
.bind(self.worker_role)
|
|
.bind(self.runtime_id)
|
|
.bind(&self.host)
|
|
.bind(self.port)
|
|
.bind(WorkerStatus::Active)
|
|
.bind(serde_json::to_value(&self.capabilities)?)
|
|
.bind(Utc::now())
|
|
.fetch_one(&self.pool)
|
|
.await?;
|
|
|
|
worker.id
|
|
};
|
|
|
|
self.worker_id = Some(worker_id);
|
|
info!("Worker registered successfully with ID: {}", worker_id);
|
|
|
|
Ok(worker_id)
|
|
}
|
|
|
|
/// Deregister the worker (mark as inactive)
|
|
pub async fn deregister(&self) -> Result<()> {
|
|
if let Some(worker_id) = self.worker_id {
|
|
info!("Deregistering worker ID: {}", worker_id);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE worker
|
|
SET status = $1,
|
|
updated = $2
|
|
WHERE id = $3
|
|
"#,
|
|
)
|
|
.bind(WorkerStatus::Inactive)
|
|
.bind(Utc::now())
|
|
.bind(worker_id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
info!("Worker deregistered successfully");
|
|
} else {
|
|
warn!("Cannot deregister: worker not registered");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Update worker heartbeat
|
|
pub async fn update_heartbeat(&self) -> Result<()> {
|
|
if let Some(worker_id) = self.worker_id {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE worker
|
|
SET last_heartbeat = $1,
|
|
updated = $1
|
|
WHERE id = $2
|
|
"#,
|
|
)
|
|
.bind(Utc::now())
|
|
.bind(worker_id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
} else {
|
|
return Err(Error::invalid_state("Worker not registered"));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the registered worker ID
|
|
pub fn worker_id(&self) -> Option<i64> {
|
|
self.worker_id
|
|
}
|
|
|
|
/// Get the worker name
|
|
pub fn worker_name(&self) -> &str {
|
|
&self.worker_name
|
|
}
|
|
|
|
/// Add a capability to the worker
|
|
pub fn add_capability(&mut self, key: String, value: serde_json::Value) {
|
|
self.capabilities.insert(key, value);
|
|
}
|
|
|
|
/// Update worker capabilities in the database
|
|
pub async fn update_capabilities(&self) -> Result<()> {
|
|
if let Some(worker_id) = self.worker_id {
|
|
sqlx::query(
|
|
r#"
|
|
UPDATE worker
|
|
SET capabilities = $1,
|
|
updated = $2
|
|
WHERE id = $3
|
|
"#,
|
|
)
|
|
.bind(serde_json::to_value(&self.capabilities)?)
|
|
.bind(Utc::now())
|
|
.bind(worker_id)
|
|
.execute(&self.pool)
|
|
.await?;
|
|
|
|
info!("Worker capabilities updated");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for WorkerRegistration {
|
|
fn drop(&mut self) {
|
|
// Note: We can't make this async, so we just log
|
|
// The main service should call deregister() explicitly during shutdown
|
|
if self.worker_id.is_some() {
|
|
info!("WorkerRegistration dropped - worker should be deregistered");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
#[ignore] // Requires database
|
|
async fn test_worker_registration() {
|
|
let config = Config::load().unwrap();
|
|
let db = attune_common::db::Database::new(&config.database)
|
|
.await
|
|
.unwrap();
|
|
let pool = db.pool().clone();
|
|
let mut registration = WorkerRegistration::new(pool, &config);
|
|
|
|
// Detect capabilities
|
|
registration.detect_capabilities(&config).await.unwrap();
|
|
|
|
// Register worker
|
|
let worker_id = registration.register().await.unwrap();
|
|
assert!(worker_id > 0);
|
|
assert_eq!(registration.worker_id(), Some(worker_id));
|
|
|
|
// Update heartbeat
|
|
registration.update_heartbeat().await.unwrap();
|
|
|
|
// Deregister worker
|
|
registration.deregister().await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore] // Requires database
|
|
async fn test_worker_capabilities() {
|
|
let config = Config::load().unwrap();
|
|
let db = attune_common::db::Database::new(&config.database)
|
|
.await
|
|
.unwrap();
|
|
let pool = db.pool().clone();
|
|
let mut registration = WorkerRegistration::new(pool, &config);
|
|
|
|
registration.detect_capabilities(&config).await.unwrap();
|
|
registration.register().await.unwrap();
|
|
|
|
// Add capability
|
|
registration.add_capability("test_capability".to_string(), json!(true));
|
|
registration.update_capabilities().await.unwrap();
|
|
|
|
registration.deregister().await.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_detected_runtimes_json_structure() {
|
|
// Test the JSON structure that set_detected_runtimes builds
|
|
let runtimes = [
|
|
DetectedRuntime {
|
|
name: "python".to_string(),
|
|
path: "/usr/bin/python3".to_string(),
|
|
version: Some("3.12.1".to_string()),
|
|
},
|
|
DetectedRuntime {
|
|
name: "shell".to_string(),
|
|
path: "/bin/bash".to_string(),
|
|
version: None,
|
|
},
|
|
];
|
|
|
|
let interpreters: Vec<serde_json::Value> = runtimes
|
|
.iter()
|
|
.map(|rt| {
|
|
json!({
|
|
"name": rt.name,
|
|
"path": rt.path,
|
|
"version": rt.version,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let json_value = json!(interpreters);
|
|
|
|
// Verify structure
|
|
let arr = json_value.as_array().unwrap();
|
|
assert_eq!(arr.len(), 2);
|
|
assert_eq!(arr[0]["name"], "python");
|
|
assert_eq!(arr[0]["path"], "/usr/bin/python3");
|
|
assert_eq!(arr[0]["version"], "3.12.1");
|
|
assert_eq!(arr[1]["name"], "shell");
|
|
assert_eq!(arr[1]["path"], "/bin/bash");
|
|
assert!(arr[1]["version"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn test_detected_runtimes_empty() {
|
|
let runtimes: Vec<DetectedRuntime> = vec![];
|
|
let interpreters: Vec<serde_json::Value> = runtimes
|
|
.iter()
|
|
.map(|rt| {
|
|
json!({
|
|
"name": rt.name,
|
|
"path": rt.path,
|
|
"version": rt.version,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let json_value = json!(interpreters);
|
|
assert_eq!(json_value.as_array().unwrap().len(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_agent_mode_capability_value() {
|
|
// Verify the JSON value for agent_mode capability
|
|
let value = json!(true);
|
|
assert_eq!(value, true);
|
|
|
|
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);
|
|
}
|
|
}
|