agent-style workers
This commit is contained in:
@@ -302,3 +302,163 @@ pub fn routes() -> Router<Arc<AppState>> {
|
||||
.route("/agent/binary", get(download_agent_binary))
|
||||
.route("/agent/info", get(agent_info))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use attune_common::config::AgentConfig;
|
||||
use axum::http::{HeaderMap, HeaderValue};
|
||||
|
||||
// ── validate_arch tests ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_valid_x86_64() {
|
||||
let result = validate_arch("x86_64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "x86_64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_valid_aarch64() {
|
||||
let result = validate_arch("aarch64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "aarch64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_arm64_alias() {
|
||||
// "arm64" is an alias for "aarch64"
|
||||
let result = validate_arch("arm64");
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(result.unwrap(), "aarch64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_arch_invalid() {
|
||||
let result = validate_arch("mips");
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(body.0["error"], "Invalid architecture");
|
||||
}
|
||||
|
||||
// ── validate_token tests ────────────────────────────────────────
|
||||
|
||||
/// Helper: build a minimal Config with the given agent config.
|
||||
/// Only the `agent` field is relevant for `validate_token`.
|
||||
fn test_config(agent: Option<AgentConfig>) -> attune_common::config::Config {
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".to_string());
|
||||
let config_path = format!("{}/../../config.test.yaml", manifest_dir);
|
||||
let mut config = attune_common::config::Config::load_from_file(&config_path)
|
||||
.expect("Failed to load test config");
|
||||
config.agent = agent;
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_no_config() {
|
||||
// When no agent config is set at all, no token is required.
|
||||
let config = test_config(None);
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_no_bootstrap_token_configured() {
|
||||
// Agent config exists but bootstrap_token is None → no token required.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: None,
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_valid_from_header() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("s3cret-bootstrap".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-agent-token",
|
||||
HeaderValue::from_static("s3cret-bootstrap"),
|
||||
);
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_valid_from_query() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("s3cret-bootstrap".to_string()),
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = Some("s3cret-bootstrap".to_string());
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_invalid() {
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("correct-token".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-agent-token", HeaderValue::from_static("wrong-token"));
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body.0["error"], "Invalid token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_missing_when_required() {
|
||||
// bootstrap_token is configured but caller provides nothing.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("required-token".to_string()),
|
||||
}));
|
||||
let headers = HeaderMap::new();
|
||||
let query_token = None;
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_err());
|
||||
let (status, body) = result.unwrap_err();
|
||||
assert_eq!(status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(body.0["error"], "Token required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_token_header_takes_precedence_over_query() {
|
||||
// When both header and query provide a token, the header value is
|
||||
// checked first (it appears first in the or_else chain). Provide a
|
||||
// valid token in the header and an invalid one in the query — should
|
||||
// succeed because the header matches.
|
||||
let config = test_config(Some(AgentConfig {
|
||||
binary_dir: "/tmp/test".to_string(),
|
||||
bootstrap_token: Some("the-real-token".to_string()),
|
||||
}));
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-agent-token", HeaderValue::from_static("the-real-token"));
|
||||
let query_token = Some("wrong-token".to_string());
|
||||
|
||||
let result = validate_token(&config, &headers, &query_token);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
138
crates/api/tests/agent_tests.rs
Normal file
138
crates/api/tests/agent_tests.rs
Normal file
@@ -0,0 +1,138 @@
|
||||
//! Integration tests for agent binary distribution endpoints
|
||||
//!
|
||||
//! The agent endpoints (`/api/v1/agent/binary` and `/api/v1/agent/info`) are
|
||||
//! intentionally unauthenticated — the agent needs to download its binary
|
||||
//! before it has JWT credentials. An optional `bootstrap_token` can restrict
|
||||
//! access, but that is validated inside the handler, not via RequireAuth
|
||||
//! middleware.
|
||||
//!
|
||||
//! The test configuration (`config.test.yaml`) does NOT include an `agent`
|
||||
//! section, so both endpoints return 503 Service Unavailable. This is the
|
||||
//! correct behaviour: the endpoints are reachable (no 401/404 from middleware)
|
||||
//! but the feature is not configured.
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod helpers;
|
||||
use helpers::TestContext;
|
||||
|
||||
// ── /api/v1/agent/info ──────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_info_not_configured() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/info", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Agent config is not set in config.test.yaml, so the handler returns 503.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert_eq!(body["error"], "Not configured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_info_no_auth_required() {
|
||||
// Verify that the endpoint is reachable WITHOUT any JWT token.
|
||||
// If RequireAuth middleware were applied, this would return 401.
|
||||
// Instead we expect 503 (not configured) — proving the endpoint
|
||||
// is publicly accessible.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/info", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent/info should not require authentication"
|
||||
);
|
||||
// Should be 503 because agent config is absent.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// ── /api/v1/agent/binary ────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_not_configured() {
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Agent config is not set in config.test.yaml, so the handler returns 503.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
|
||||
let body: serde_json::Value = response.json().await.expect("Failed to parse JSON");
|
||||
assert_eq!(body["error"], "Not configured");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_no_auth_required() {
|
||||
// Same reasoning as test_agent_info_no_auth_required: the binary
|
||||
// download endpoint must be publicly accessible (no RequireAuth).
|
||||
// When no bootstrap_token is configured, any caller can reach the
|
||||
// handler. We still get 503 because the agent feature itself is
|
||||
// not configured in the test environment.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// Must NOT be 401 Unauthorized — the endpoint has no auth middleware.
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"agent/binary should not require authentication when no bootstrap_token is configured"
|
||||
);
|
||||
// Should be 503 because agent config is absent.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "integration test — requires database"]
|
||||
async fn test_agent_binary_invalid_arch() {
|
||||
// Architecture validation (`validate_arch`) rejects unsupported values
|
||||
// with 400 Bad Request. However, in the handler the execution order is:
|
||||
// 1. validate_token (passes — no bootstrap_token configured)
|
||||
// 2. check agent config (fails with 503 — not configured)
|
||||
// 3. validate_arch (never reached)
|
||||
//
|
||||
// So even with an invalid arch like "mips", we get 503 from the config
|
||||
// check before the arch is ever validated. The arch validation is covered
|
||||
// by unit tests in routes/agent.rs instead.
|
||||
let ctx = TestContext::new()
|
||||
.await
|
||||
.expect("Failed to create test context");
|
||||
|
||||
let response = ctx
|
||||
.get("/api/v1/agent/binary?arch=mips", None)
|
||||
.await
|
||||
.expect("Failed to make request");
|
||||
|
||||
// 503 from the agent-config-not-set check, NOT 400 from arch validation.
|
||||
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use crate::config::Config;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::models::Runtime;
|
||||
use crate::repositories::action::ActionRepository;
|
||||
use crate::repositories::runtime::RuntimeRepository;
|
||||
use crate::repositories::runtime::{self, RuntimeRepository};
|
||||
use crate::repositories::FindById as _;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::{PgPool, Row};
|
||||
@@ -370,20 +370,15 @@ impl PackEnvironmentManager {
|
||||
// ========================================================================
|
||||
|
||||
async fn get_runtime(&self, runtime_id: i64) -> Result<Runtime> {
|
||||
sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(runtime_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Internal(format!("Failed to fetch runtime: {}", e)))
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE id = $1",
|
||||
runtime::SELECT_COLUMNS
|
||||
);
|
||||
sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(runtime_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| Error::Internal(format!("Failed to fetch runtime: {}", e)))
|
||||
}
|
||||
|
||||
fn runtime_requires_environment(&self, runtime: &Runtime) -> Result<bool> {
|
||||
|
||||
@@ -63,19 +63,11 @@ impl FindById for RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtime = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
let query = format!("SELECT {} FROM runtime WHERE id = $1", SELECT_COLUMNS);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(id)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
@@ -87,19 +79,11 @@ impl FindByRef for RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtime = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
WHERE ref = $1
|
||||
"#,
|
||||
)
|
||||
.bind(ref_str)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
let query = format!("SELECT {} FROM runtime WHERE ref = $1", SELECT_COLUMNS);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(ref_str)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
@@ -111,18 +95,10 @@ impl List for RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
ORDER BY ref ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
let query = format!("SELECT {} FROM runtime ORDER BY ref ASC", SELECT_COLUMNS);
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(&query)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtimes)
|
||||
}
|
||||
@@ -136,31 +112,28 @@ impl Create for RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtime = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
INSERT INTO runtime (ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
"#,
|
||||
)
|
||||
.bind(&input.r#ref)
|
||||
.bind(input.pack)
|
||||
.bind(&input.pack_ref)
|
||||
.bind(&input.description)
|
||||
.bind(&input.name)
|
||||
.bind(&input.distributions)
|
||||
.bind(&input.installation)
|
||||
.bind(serde_json::json!({}))
|
||||
.bind(&input.execution_config)
|
||||
.bind(input.auto_detected)
|
||||
.bind(&input.detection_config)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
let query = format!(
|
||||
"INSERT INTO runtime (ref, pack, pack_ref, description, name, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING {}",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(&input.r#ref)
|
||||
.bind(input.pack)
|
||||
.bind(&input.pack_ref)
|
||||
.bind(&input.description)
|
||||
.bind(&input.name)
|
||||
.bind(&input.distributions)
|
||||
.bind(&input.installation)
|
||||
.bind(serde_json::json!({}))
|
||||
.bind(&input.execution_config)
|
||||
.bind(input.auto_detected)
|
||||
.bind(&input.detection_config)
|
||||
.fetch_one(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
@@ -252,12 +225,7 @@ impl Update for RuntimeRepository {
|
||||
|
||||
query.push(", updated = NOW() WHERE id = ");
|
||||
query.push_bind(id);
|
||||
query.push(
|
||||
" RETURNING id, ref, pack, pack_ref, description, name, \
|
||||
distributions, installation, installers, execution_config, \
|
||||
auto_detected, detection_config, \
|
||||
created, updated",
|
||||
);
|
||||
query.push(&format!(" RETURNING {}", SELECT_COLUMNS));
|
||||
|
||||
let runtime = query
|
||||
.build_query_as::<Runtime>()
|
||||
@@ -289,20 +257,14 @@ impl RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
WHERE pack = $1
|
||||
ORDER BY ref ASC
|
||||
"#,
|
||||
)
|
||||
.bind(pack_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE pack = $1 ORDER BY ref ASC",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(pack_id)
|
||||
.fetch_all(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtimes)
|
||||
}
|
||||
@@ -312,20 +274,14 @@ impl RuntimeRepository {
|
||||
where
|
||||
E: Executor<'e, Database = Postgres> + 'e,
|
||||
{
|
||||
let runtime = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
WHERE LOWER(name) = LOWER($1)
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(name)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE LOWER(name) = LOWER($1) LIMIT 1",
|
||||
SELECT_COLUMNS
|
||||
);
|
||||
let runtime = sqlx::query_as::<_, Runtime>(&query)
|
||||
.bind(name)
|
||||
.fetch_optional(executor)
|
||||
.await?;
|
||||
|
||||
Ok(runtime)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
use crate::models::Runtime;
|
||||
use crate::repositories::runtime::SELECT_COLUMNS;
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use std::collections::HashMap;
|
||||
@@ -161,18 +162,10 @@ impl RuntimeDetector {
|
||||
info!("Querying database for runtime definitions...");
|
||||
|
||||
// Query all runtimes from database
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(
|
||||
r#"
|
||||
SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime
|
||||
ORDER BY ref
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let query = format!("SELECT {} FROM runtime ORDER BY ref", SELECT_COLUMNS);
|
||||
let runtimes = sqlx::query_as::<_, Runtime>(&query)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
info!("Found {} runtime(s) in database", runtimes.len());
|
||||
|
||||
|
||||
@@ -113,6 +113,10 @@ async fn main() -> Result<()> {
|
||||
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);
|
||||
// SAFETY: std::env::set_var is safe in Rust 2021 edition. If upgrading
|
||||
// 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);
|
||||
|
||||
// Stash for Phase 2: pass to WorkerService for rich capability registration
|
||||
@@ -133,9 +137,10 @@ async fn main() -> Result<()> {
|
||||
println!();
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
} else if let Some(ref detected) = agent_detected_runtimes {
|
||||
print_detection_report(detected);
|
||||
} else {
|
||||
// We already ran detection above; re-run to get a fresh Vec for the report
|
||||
// (the previous one was consumed by env var setup).
|
||||
// No detection ran (empty results), run it fresh
|
||||
let detected = detect_runtimes();
|
||||
print_detection_report(&detected);
|
||||
}
|
||||
@@ -144,6 +149,7 @@ async fn main() -> Result<()> {
|
||||
|
||||
// --- Phase 2: Load configuration ---
|
||||
if let Some(config_path) = args.config {
|
||||
// SAFETY: std::env::set_var is safe in Rust 2021 edition. See note above.
|
||||
std::env::set_var("ATTUNE_CONFIG", config_path);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use attune_common::models::runtime::RuntimeExecutionConfig;
|
||||
use attune_common::models::{runtime::Runtime as RuntimeModel, Action, Execution, ExecutionStatus};
|
||||
use attune_common::repositories::artifact::{ArtifactRepository, ArtifactVersionRepository};
|
||||
use attune_common::repositories::execution::{ExecutionRepository, UpdateExecutionInput};
|
||||
use attune_common::repositories::runtime::SELECT_COLUMNS as RUNTIME_SELECT_COLUMNS;
|
||||
use attune_common::repositories::runtime_version::RuntimeVersionRepository;
|
||||
use attune_common::repositories::{FindById, Update};
|
||||
use attune_common::version_matching::select_best_version;
|
||||
@@ -410,16 +411,14 @@ impl ActionExecutor {
|
||||
|
||||
// Load runtime information if specified
|
||||
let runtime_record = if let Some(runtime_id) = action.runtime {
|
||||
match sqlx::query_as::<_, RuntimeModel>(
|
||||
r#"SELECT id, ref, pack, pack_ref, description, name,
|
||||
distributions, installation, installers, execution_config,
|
||||
auto_detected, detection_config,
|
||||
created, updated
|
||||
FROM runtime WHERE id = $1"#,
|
||||
)
|
||||
.bind(runtime_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
let query = format!(
|
||||
"SELECT {} FROM runtime WHERE id = $1",
|
||||
RUNTIME_SELECT_COLUMNS
|
||||
);
|
||||
match sqlx::query_as::<_, RuntimeModel>(&query)
|
||||
.bind(runtime_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
{
|
||||
Ok(Some(runtime)) => {
|
||||
debug!(
|
||||
|
||||
@@ -393,4 +393,72 @@ mod tests {
|
||||
|
||||
registration.deregister().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detected_runtimes_json_structure() {
|
||||
// Test the JSON structure that set_detected_runtimes builds
|
||||
let runtimes = vec![
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,17 @@ CREATE TABLE runtime (
|
||||
-- {manifest_path} - absolute path to the dependency manifest file
|
||||
execution_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Whether this runtime was auto-registered by an agent
|
||||
-- (vs. loaded from a pack's YAML file during pack registration)
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Detection metadata for auto-discovered runtimes.
|
||||
-- Stores how the agent discovered this runtime (binary path, version, etc.)
|
||||
-- enables re-verification on restart.
|
||||
-- Example: { "detected_path": "/usr/bin/ruby", "detected_name": "ruby",
|
||||
-- "detected_version": "3.3.0" }
|
||||
detection_config JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
@@ -145,6 +156,8 @@ CREATE INDEX idx_runtime_created ON runtime(created DESC);
|
||||
CREATE INDEX idx_runtime_name ON runtime(name);
|
||||
CREATE INDEX idx_runtime_verification ON runtime USING GIN ((distributions->'verification'));
|
||||
CREATE INDEX idx_runtime_execution_config ON runtime USING GIN (execution_config);
|
||||
CREATE INDEX idx_runtime_auto_detected ON runtime(auto_detected);
|
||||
CREATE INDEX idx_runtime_detection_config ON runtime USING GIN (detection_config);
|
||||
|
||||
-- Trigger
|
||||
CREATE TRIGGER update_runtime_updated
|
||||
@@ -160,6 +173,8 @@ COMMENT ON COLUMN runtime.distributions IS 'Runtime distribution metadata includ
|
||||
COMMENT ON COLUMN runtime.installation IS 'Installation requirements and instructions including package managers and setup steps';
|
||||
COMMENT ON COLUMN runtime.installers IS 'Array of installer actions to create pack-specific runtime environments. Each installer defines commands to set up isolated environments (e.g., Python venv, npm install).';
|
||||
COMMENT ON COLUMN runtime.execution_config IS 'Execution configuration: interpreter, environment setup, and dependency management. Drives how the worker executes actions and how pack install sets up environments.';
|
||||
COMMENT ON COLUMN runtime.auto_detected IS 'Whether this runtime was auto-registered by an agent (true) vs. loaded from a pack YAML (false)';
|
||||
COMMENT ON COLUMN runtime.detection_config IS 'Detection metadata for auto-discovered runtimes: binaries probed, version regex, detected path/version';
|
||||
|
||||
-- ============================================================================
|
||||
-- RUNTIME VERSION TABLE
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
-- Migration: 000012_agent_runtime_detection
|
||||
-- Adds columns to support agent auto-detected runtimes
|
||||
|
||||
-- Track whether a runtime was auto-registered by an agent
|
||||
-- (vs. loaded from a pack's YAML file during pack registration)
|
||||
ALTER TABLE runtime ADD COLUMN IF NOT EXISTS auto_detected BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Store detection configuration for auto-discovered runtimes.
|
||||
-- Used by agents to identify how they discovered the runtime and
|
||||
-- enables re-verification on restart.
|
||||
-- Example: { "binaries": ["ruby", "ruby3.2"], "version_command": "--version",
|
||||
-- "version_regex": "ruby (\\d+\\.\\d+\\.\\d+)",
|
||||
-- "detected_path": "/usr/bin/ruby",
|
||||
-- "detected_version": "3.3.0" }
|
||||
ALTER TABLE runtime ADD COLUMN IF NOT EXISTS detection_config JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
-- Index for filtering auto-detected vs. pack-registered runtimes
|
||||
CREATE INDEX IF NOT EXISTS idx_runtime_auto_detected ON runtime(auto_detected);
|
||||
|
||||
-- Comments
|
||||
COMMENT ON COLUMN runtime.auto_detected IS 'Whether this runtime was auto-registered by an agent (true) vs. loaded from a pack YAML (false)';
|
||||
COMMENT ON COLUMN runtime.detection_config IS 'Detection metadata for auto-discovered runtimes: binaries probed, version regex, detected path/version';
|
||||
Reference in New Issue
Block a user