log streams in watched cli executions

This commit is contained in:
2026-04-07 08:55:27 -05:00
parent f93e9229d2
commit ed74dfad6c
15 changed files with 1368 additions and 153 deletions

60
Cargo.lock generated
View File

@@ -201,7 +201,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -212,7 +212,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [ dependencies = [
"anstyle", "anstyle",
"once_cell_polyfill", "once_cell_polyfill",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -535,6 +535,7 @@ dependencies = [
"sha2", "sha2",
"tar", "tar",
"tempfile", "tempfile",
"terminal_size",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tokio-test", "tokio-test",
@@ -1131,7 +1132,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1795,7 +1796,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users", "redox_users",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -1979,7 +1980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3006,9 +3007,9 @@ dependencies = [
[[package]] [[package]]
name = "lapin" name = "lapin"
version = "4.3.0" version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1586ef35d652d6c47ed7449277a4483805b73b84ab368c85af44205fe3457972" checksum = "39338badb3f992d800f6964501b056b575bdf142eb288202f973d218fe253b90"
dependencies = [ dependencies = [
"amq-protocol", "amq-protocol",
"async-rs", "async-rs",
@@ -3209,9 +3210,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@@ -3334,7 +3335,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -3466,7 +3467,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.21.7",
"chrono", "chrono",
"getrandom 0.2.17", "getrandom 0.2.17",
"http", "http",
@@ -4204,12 +4205,13 @@ dependencies = [
[[package]] [[package]]
name = "redis" name = "redis"
version = "1.0.5" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b36964393906eb775b89b25b05b7b95685b8dd14062f1663a31ff93e75c452e5" checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"arcstr", "arcstr",
"async-lock",
"backon", "backon",
"bytes", "bytes",
"cfg-if", "cfg-if",
@@ -4566,7 +4568,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -4640,7 +4642,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5094,7 +5096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5456,7 +5458,17 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.60.2",
]
[[package]]
name = "terminal_size"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.60.2",
] ]
[[package]] [[package]]
@@ -5591,9 +5603,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -5624,9 +5636,9 @@ dependencies = [
[[package]] [[package]]
name = "tokio-macros" name = "tokio-macros"
version = "2.6.1" version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -6052,9 +6064,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.22.0" version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@@ -6398,7 +6410,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.60.2",
] ]
[[package]] [[package]]

View File

@@ -20,7 +20,7 @@ repository = "https://git.rdrx.app/attune-system/attune"
[workspace.dependencies] [workspace.dependencies]
# Async runtime # Async runtime
tokio = { version = "1.50", features = ["full"] } tokio = { version = "1.51", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = { version = "0.1", features = ["sync"] } tokio-stream = { version = "0.1", features = ["sync"] }
@@ -52,7 +52,7 @@ config = "0.15"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# UUID # UUID
uuid = { version = "1.22", features = ["v4", "serde"] } uuid = { version = "1.23", features = ["v4", "serde"] }
# Validation # Validation
validator = { version = "0.20", features = ["derive"] } validator = { version = "0.20", features = ["derive"] }
@@ -62,9 +62,9 @@ clap = { version = "4.6", features = ["derive"] }
# Message queue / PubSub # Message queue / PubSub
# RabbitMQ # RabbitMQ
lapin = "4.3" lapin = "4.4"
# Redis # Redis
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] } redis = { version = "1.2", features = ["tokio-comp", "connection-manager"] }
# JSON Schema # JSON Schema
schemars = { version = "1.2", features = ["chrono04"] } schemars = { version = "1.2", features = ["chrono04"] }
@@ -91,7 +91,7 @@ regex = "1.12"
# HTTP client # HTTP client
reqwest = { version = "0.13", features = ["json"] } reqwest = { version = "0.13", features = ["json"] }
reqwest-eventsource = "0.6" reqwest-eventsource = "0.6"
hyper = { version = "1.8", features = ["full"] } hyper = { version = "1.9", features = ["full"] }
# File system utilities # File system utilities
walkdir = "2.5" walkdir = "2.5"

View File

@@ -59,6 +59,7 @@ sha2 = { workspace = true }
colored = "3.1" colored = "3.1"
comfy-table = { version = "7.2", features = ["custom_styling"] } comfy-table = { version = "7.2", features = ["custom_styling"] }
dialoguer = "0.12" dialoguer = "0.12"
terminal_size = "0.4"
# Authentication # Authentication
jsonwebtoken = { workspace = true } jsonwebtoken = { workspace = true }

View File

@@ -175,11 +175,11 @@ attune action execute core.echo --param message="Hello World" --param count=3
# With JSON parameters # With JSON parameters
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}' attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
# Wait for completion # Watch until completion
attune action execute core.long_task --wait attune action execute core.long_task --watch
# Wait with custom timeout (default 300 seconds) # Watch with custom timeout (default 300 seconds)
attune action execute core.long_task --wait --timeout 600 attune action execute core.long_task --watch --timeout 600
``` ```
## Rule Management ## Rule Management

View File

@@ -68,17 +68,17 @@ pub enum ActionCommands {
#[arg(long, conflicts_with = "param")] #[arg(long, conflicts_with = "param")]
params_json: Option<String>, params_json: Option<String>,
/// Wait for execution to complete /// Watch execution until it completes
#[arg(short, long)] #[arg(short, long)]
wait: bool, watch: bool,
/// Timeout in seconds when waiting (default: 300) /// Timeout in seconds when watching (default: 300)
#[arg(long, default_value = "300", requires = "wait")] #[arg(long, default_value = "300", requires = "watch")]
timeout: u64, timeout: u64,
/// Notifier WebSocket base URL (e.g. ws://localhost:8081). /// Notifier WebSocket base URL (e.g. ws://localhost:8081).
/// Derived from --api-url automatically when not set. /// Derived from --api-url automatically when not set.
#[arg(long, requires = "wait")] #[arg(long, requires = "watch")]
notifier_url: Option<String>, notifier_url: Option<String>,
}, },
} }
@@ -186,7 +186,7 @@ pub async fn handle_action_command(
action_ref, action_ref,
param, param,
params_json, params_json,
wait, watch,
timeout, timeout,
notifier_url, notifier_url,
} => { } => {
@@ -196,7 +196,7 @@ pub async fn handle_action_command(
params_json, params_json,
profile, profile,
api_url, api_url,
wait, watch,
timeout, timeout,
notifier_url, notifier_url,
output_format, output_format,
@@ -307,7 +307,7 @@ async fn handle_show(
if let Some(params) = action.param_schema { if let Some(params) = action.param_schema {
if !params.is_null() { if !params.is_null() {
output::print_section("Parameters Schema"); output::print_section("Parameters Schema");
println!("{}", serde_json::to_string_pretty(&params)?); output::print_schema(&params)?;
} }
} }
} }
@@ -428,7 +428,7 @@ async fn handle_execute(
params_json: Option<String>, params_json: Option<String>,
profile: &Option<String>, profile: &Option<String>,
api_url: &Option<String>, api_url: &Option<String>,
wait: bool, watch: bool,
timeout: u64, timeout: u64,
notifier_url: Option<String>, notifier_url: Option<String>,
output_format: OutputFormat, output_format: OutputFormat,
@@ -468,7 +468,7 @@ async fn handle_execute(
let path = "/executions/execute".to_string(); let path = "/executions/execute".to_string();
let execution: Execution = client.post(&path, &request).await?; let execution: Execution = client.post(&path, &request).await?;
if !wait { if !watch {
match output_format { match output_format {
OutputFormat::Json | OutputFormat::Yaml => { OutputFormat::Json | OutputFormat::Yaml => {
output::print_output(&execution, output_format)?; output::print_output(&execution, output_format)?;
@@ -492,22 +492,22 @@ async fn handle_execute(
)); ));
} }
let verbose = matches!(output_format, OutputFormat::Table); let interactive_wait = true;
let watch_task = if verbose { let stream_live_logs = true;
Some(spawn_execution_output_watch( let debug_wait = false;
ApiClient::from_config(&config, api_url), let watch_task = Some(spawn_execution_output_watch(
execution.id, ApiClient::from_config(&config, api_url),
verbose, execution.id,
)) interactive_wait,
} else { stream_live_logs,
None debug_wait,
}; ));
let summary = wait_for_execution(WaitOptions { let summary = wait_for_execution(WaitOptions {
execution_id: execution.id, execution_id: execution.id,
timeout_secs: timeout, timeout_secs: timeout,
api_client: &mut client, api_client: &mut client,
notifier_ws_url: notifier_url, notifier_ws_url: notifier_url,
verbose, verbose: debug_wait,
}) })
.await?; .await?;
let suppress_final_stdout = watch_task let suppress_final_stdout = watch_task

View File

@@ -124,17 +124,17 @@ enum Commands {
#[arg(long, conflicts_with = "param")] #[arg(long, conflicts_with = "param")]
params_json: Option<String>, params_json: Option<String>,
/// Wait for execution to complete /// Watch execution until it completes
#[arg(short, long)] #[arg(short, long)]
wait: bool, watch: bool,
/// Timeout in seconds when waiting (default: 300) /// Timeout in seconds when watching (default: 300)
#[arg(long, default_value = "300", requires = "wait")] #[arg(long, default_value = "300", requires = "watch")]
timeout: u64, timeout: u64,
/// Notifier WebSocket base URL (e.g. ws://localhost:8081). /// Notifier WebSocket base URL (e.g. ws://localhost:8081).
/// Derived from --api-url automatically when not set. /// Derived from --api-url automatically when not set.
#[arg(long, requires = "wait")] #[arg(long, requires = "watch")]
notifier_url: Option<String>, notifier_url: Option<String>,
}, },
} }
@@ -243,7 +243,7 @@ async fn main() {
action_ref, action_ref,
param, param,
params_json, params_json,
wait, watch,
timeout, timeout,
notifier_url, notifier_url,
} => { } => {
@@ -254,7 +254,7 @@ async fn main() {
action_ref, action_ref,
param, param,
params_json, params_json,
wait, watch,
timeout, timeout,
notifier_url, notifier_url,
}, },

View File

@@ -4,6 +4,7 @@ use colored::Colorize;
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table}; use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
use serde::Serialize; use serde::Serialize;
use std::fmt::Display; use std::fmt::Display;
use terminal_size::{terminal_size, Width};
/// Output format for CLI commands /// Output format for CLI commands
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq)] #[derive(Debug, Clone, Copy, ValueEnum, PartialEq)]
@@ -88,14 +89,69 @@ pub fn add_header(table: &mut Table, headers: Vec<&str>) {
pub fn print_key_value_table(pairs: Vec<(&str, String)>) { pub fn print_key_value_table(pairs: Vec<(&str, String)>) {
let mut table = create_table(); let mut table = create_table();
add_header(&mut table, vec!["Key", "Value"]); add_header(&mut table, vec!["Key", "Value"]);
let width = terminal_width();
let key_width = pairs
.iter()
.map(|(key, _)| display_width(key))
.max()
.unwrap_or(3)
.clamp(8, 18);
let value_width = width.saturating_sub(key_width + 9).max(20);
for (key, value) in pairs { for (key, value) in pairs {
table.add_row(vec![Cell::new(key).fg(Color::Yellow), Cell::new(value)]); table.add_row(vec![
Cell::new(wrap_text(key, key_width)).fg(Color::Yellow),
Cell::new(wrap_text(&value, value_width)),
]);
} }
println!("{}", table); println!("{}", table);
} }
/// Print a schema in a readable multi-line format instead of a raw JSON dump.
pub fn print_schema(schema: &serde_json::Value) -> Result<()> {
if let Some(properties) = schema.as_object() {
if properties.values().all(|value| value.is_object()) {
let width = terminal_width();
let content_width = width.saturating_sub(4).max(24);
let mut names = properties.keys().collect::<Vec<_>>();
names.sort();
for (index, name) in names.into_iter().enumerate() {
if index > 0 {
println!();
}
println!("{}", name.bold());
if let Some(definition) = properties.get(name).and_then(|value| value.as_object()) {
print_schema_field("Type", &schema_type_label(definition), content_width);
if let Some(default) = definition.get("default") {
print_schema_field("Default", &compact_json(default), content_width);
}
if let Some(description) = definition
.get("description")
.and_then(|value| value.as_str())
{
print_schema_field("Description", description, content_width);
}
let constraints = schema_constraints(definition);
if !constraints.is_empty() {
print_schema_field("Constraints", &constraints.join(", "), content_width);
}
}
}
return Ok(());
}
}
println!("{}", serde_yaml_ng::to_string(schema)?);
Ok(())
}
/// Print a simple list /// Print a simple list
pub fn print_list(items: Vec<String>) { pub fn print_list(items: Vec<String>) {
for item in items { for item in items {
@@ -137,6 +193,146 @@ pub fn truncate(s: &str, max_len: usize) -> String {
} }
} }
fn terminal_width() -> usize {
terminal_size()
.map(|(Width(width), _)| width as usize)
.filter(|width| *width > 20)
.unwrap_or(100)
}
fn display_width(value: &str) -> usize {
value.chars().count()
}
fn wrap_text(value: &str, width: usize) -> String {
let width = width.max(1);
let mut wrapped = Vec::new();
for paragraph in value.split('\n') {
if paragraph.is_empty() {
wrapped.push(String::new());
continue;
}
let mut line = String::new();
for word in paragraph.split_whitespace() {
if line.is_empty() {
append_wrapped_word(&mut wrapped, &mut line, word, width);
continue;
}
if display_width(&line) + 1 + display_width(word) <= width {
line.push(' ');
line.push_str(word);
} else {
wrapped.push(line);
line = String::new();
append_wrapped_word(&mut wrapped, &mut line, word, width);
}
}
if !line.is_empty() {
wrapped.push(line);
}
}
wrapped.join("\n")
}
fn append_wrapped_word(
lines: &mut Vec<String>,
current_line: &mut String,
word: &str,
width: usize,
) {
if display_width(word) <= width {
current_line.push_str(word);
return;
}
let mut chunk = String::new();
for ch in word.chars() {
chunk.push(ch);
if display_width(&chunk) >= width {
if current_line.is_empty() {
lines.push(std::mem::take(&mut chunk));
} else {
lines.push(std::mem::take(current_line));
lines.push(std::mem::take(&mut chunk));
}
}
}
if !chunk.is_empty() {
current_line.push_str(&chunk);
}
}
fn schema_type_label(definition: &serde_json::Map<String, serde_json::Value>) -> String {
match definition.get("type") {
Some(serde_json::Value::String(kind)) => kind.clone(),
Some(serde_json::Value::Array(kinds)) => kinds
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>()
.join(" | "),
_ => "any".to_string(),
}
}
fn schema_constraints(definition: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
let mut constraints = Vec::new();
if let Some(values) = definition.get("enum").and_then(|value| value.as_array()) {
constraints.push(format!(
"enum: {}",
values
.iter()
.map(compact_json)
.collect::<Vec<_>>()
.join(", ")
));
}
for key in [
"minimum",
"maximum",
"minLength",
"maxLength",
"pattern",
"format",
] {
if let Some(value) = definition.get(key) {
constraints.push(format!("{key}: {}", compact_json(value)));
}
}
constraints
}
fn compact_json(value: &serde_json::Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
}
fn print_schema_field(label: &str, value: &str, width: usize) {
let indent = " ";
let label_prefix = format!("{indent}{label}: ");
let continuation = " ".repeat(label_prefix.chars().count());
let wrapped = wrap_text(
value,
width.saturating_sub(label_prefix.chars().count()).max(12),
);
let mut lines = wrapped.lines();
if let Some(first_line) = lines.next() {
println!("{label_prefix}{first_line}");
}
for line in lines {
println!("{continuation}{line}");
}
}
/// Format a timestamp in a human-readable way /// Format a timestamp in a human-readable way
pub fn format_timestamp(timestamp: &str) -> String { pub fn format_timestamp(timestamp: &str) -> String {
// Try to parse and format nicely, otherwise return as-is // Try to parse and format nicely, otherwise return as-is

File diff suppressed because it is too large Load Diff

View File

@@ -109,7 +109,7 @@ cargo test --package attune-cli --tests -- --test-threads=1
- ✅ Execute with multiple parameters - ✅ Execute with multiple parameters
- ✅ Execute with JSON parameters - ✅ Execute with JSON parameters
- ✅ Execute without parameters - ✅ Execute without parameters
- ✅ Execute with --wait flag - ✅ Execute with --watch flag
- ✅ Execute with --async flag - ✅ Execute with --async flag
- ✅ List actions by pack - ✅ List actions by pack
- ✅ Invalid parameter formats - ✅ Invalid parameter formats

View File

@@ -324,7 +324,7 @@ async fn test_action_execute_wait_for_completion() {
.arg("core.echo") .arg("core.echo")
.arg("--param") .arg("--param")
.arg("message=test") .arg("message=test")
.arg("--wait"); .arg("--watch");
cmd.assert() cmd.assert()
.success() .success()
@@ -476,7 +476,7 @@ async fn test_action_execute_async_flag() {
.arg("action") .arg("action")
.arg("execute") .arg("execute")
.arg("core.long_running"); .arg("core.long_running");
// Note: default behavior is async (no --wait), so no --async flag needed // Note: default behavior is async (no --watch), so no --async flag needed
cmd.assert() cmd.assert()
.success() .success()

View File

@@ -493,7 +493,16 @@ impl PackInstaller {
})?; })?;
let normalized_host = host.to_ascii_lowercase(); let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" { // Whether the host is explicitly trusted via the allowlist. Explicitly allowlisted hosts
// bypass private-IP checks so that local/private registries (e.g. a self-hosted Gitea)
// can be used in development or air-gapped environments.
let host_is_allowlisted = self
.allowed_remote_hosts
.as_ref()
.map(|set| set.contains(&normalized_host))
.unwrap_or(false);
if normalized_host == "localhost" && !host_is_allowlisted {
return Err(Error::validation(format!( return Err(Error::validation(format!(
"Remote URL host is not allowed: {}", "Remote URL host is not allowed: {}",
host host
@@ -509,12 +518,14 @@ impl PackInstaller {
} }
} }
if let Some(ip) = parsed.host().and_then(|host| match host { if !host_is_allowlisted {
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)), if let Some(ip) = parsed.host().and_then(|host| match host {
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)), url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
url::Host::Domain(_) => None, url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
}) { url::Host::Domain(_) => None,
ensure_public_ip(ip)?; }) {
ensure_public_ip(ip)?;
}
} }
let port = parsed.port_or_known_default().ok_or_else(|| { let port = parsed.port_or_known_default().ok_or_else(|| {
@@ -528,7 +539,9 @@ impl PackInstaller {
let mut saw_address = false; let mut saw_address = false;
for addr in resolved { for addr in resolved {
saw_address = true; saw_address = true;
ensure_public_ip(addr.ip())?; if !host_is_allowlisted {
ensure_public_ip(addr.ip())?;
}
} }
if !saw_address { if !saw_address {
@@ -557,7 +570,13 @@ impl PackInstaller {
fn validate_remote_host(&self, host: &str) -> Result<()> { fn validate_remote_host(&self, host: &str) -> Result<()> {
let normalized_host = host.to_ascii_lowercase(); let normalized_host = host.to_ascii_lowercase();
if normalized_host == "localhost" { let host_is_allowlisted = self
.allowed_remote_hosts
.as_ref()
.map(|set| set.contains(&normalized_host))
.unwrap_or(false);
if normalized_host == "localhost" && !host_is_allowlisted {
return Err(Error::validation(format!( return Err(Error::validation(format!(
"Remote host is not allowed: {}", "Remote host is not allowed: {}",
host host
@@ -995,6 +1014,36 @@ mod tests {
assert!(hosts.contains("cdn.example.com")); assert!(hosts.contains("cdn.example.com"));
} }
#[tokio::test]
async fn test_validate_remote_url_allows_allowlisted_localhost() {
let temp_dir = std::env::temp_dir().join("attune-test");
let config = PackRegistryConfig {
allowed_source_hosts: vec!["localhost".to_string()],
allow_http: true,
..Default::default()
};
let installer = PackInstaller::new(&temp_dir, Some(config)).await.unwrap();
installer
.validate_remote_url("http://localhost:3000/example/repo.git")
.await
.unwrap();
}
#[test]
fn test_validate_remote_host_allows_allowlisted_localhost() {
let installer = PackInstaller {
temp_dir: std::env::temp_dir().join("attune-test"),
registry_client: None,
verify_checksums: false,
allow_http: false,
allowed_remote_hosts: Some(HashSet::from(["localhost".to_string()])),
progress_callback: None,
};
installer.validate_remote_host("localhost").unwrap();
}
#[test] #[test]
fn test_extract_git_host_from_scp_style_source() { fn test_extract_git_host_from_scp_style_source() {
assert_eq!( assert_eq!(

View File

@@ -170,16 +170,11 @@ impl WorkerService {
// Initialize worker registration // Initialize worker registration
let registration = Arc::new(RwLock::new(WorkerRegistration::new(pool.clone(), &config))); let registration = Arc::new(RwLock::new(WorkerRegistration::new(pool.clone(), &config)));
// Initialize artifact manager (legacy, for stdout/stderr log storage) // Initialize artifact manager for execution stdout/stderr/result storage.
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Worker artifact/config directories come from trusted process configuration, not request data. // This must use the shared artifacts_dir so the API log streaming endpoints
let artifact_base_dir = std::path::PathBuf::from( // and artifact download routes can see the same files the worker writes.
config // nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact storage root is a trusted deployment configuration value.
.worker let artifact_base_dir = std::path::PathBuf::from(&config.artifacts_dir);
.as_ref()
.and_then(|w| w.name.clone())
.map(|name| format!("/tmp/attune/artifacts/{}", name))
.unwrap_or_else(|| "/tmp/attune/artifacts".to_string()),
);
let artifact_manager = ArtifactManager::new(artifact_base_dir); let artifact_manager = ArtifactManager::new(artifact_base_dir);
artifact_manager.initialize().await?; artifact_manager.initialize().await?;

View File

@@ -24,7 +24,6 @@ allow = [
"Unicode-3.0", "Unicode-3.0",
"Zlib", "Zlib",
"CC0-1.0", "CC0-1.0",
"OpenSSL",
"BSL-1.0", "BSL-1.0",
"MIT-0", "MIT-0",
"CDLA-Permissive-2.0", "CDLA-Permissive-2.0",
@@ -36,12 +35,36 @@ wildcards = "allow"
highlight = "all" highlight = "all"
deny = [] deny = []
skip = [ skip = [
"winnow@0.6.26", "base64",
"winnow@0.7.15", "core-foundation",
"windows_x86_64_msvc@0.42.2", "cpufeatures",
"windows_x86_64_msvc@0.48.5", "darling",
"windows_x86_64_msvc@0.52.6", "darling_core",
"windows_x86_64_msvc@0.53.1", "darling_macro",
"foldhash",
"getrandom",
"hashbrown",
"nom",
"r-efi",
"rand",
"rand_chacha",
"rand_core",
"reqwest",
"thiserror",
"thiserror-impl",
"wasm-streams",
"webpki-roots",
"windows-sys",
"windows-targets",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"winnow"
] ]
skip-tree = [] skip-tree = []

View File

@@ -282,7 +282,53 @@ services:
args: args:
SERVICE: executor SERVICE: executor
BUILDKIT_INLINE_CACHE: 1 BUILDKIT_INLINE_CACHE: 1
container_name: attune-executor container_name: attune-executor-1
environment:
RUST_LOG: info
ATTUNE_CONFIG: /opt/attune/config/config.yaml
ATTUNE__SECURITY__JWT_SECRET: ${JWT_SECRET:-docker-dev-secret-change-in-production}
ATTUNE__SECURITY__ENCRYPTION_KEY: ${ENCRYPTION_KEY:-docker-dev-encryption-key-please-change-in-production-32plus}
ATTUNE__DATABASE__URL: postgresql://attune:attune@postgres:5432/attune
ATTUNE__MESSAGE_QUEUE__URL: amqp://attune:attune@rabbitmq:5672
ATTUNE__REDIS__URL: redis://redis:6379
ATTUNE__WORKER__WORKER_TYPE: container
volumes:
- ${ATTUNE_DOCKER_CONFIG_PATH:-./config.docker.yaml}:/opt/attune/config/config.yaml:ro
- packs_data:/opt/attune/packs:ro
- ./packs.dev:/opt/attune/packs.dev:rw
- artifacts_data:/opt/attune/artifacts:ro
- executor_logs:/opt/attune/logs
depends_on:
init-packs:
condition: service_completed_successfully
init-user:
condition: service_completed_successfully
migrations:
condition: service_completed_successfully
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "kill -0 1 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
networks:
- attune-network
restart: unless-stopped
executor-2:
build:
context: .
dockerfile: docker/Dockerfile.optimized
args:
SERVICE: executor
BUILDKIT_INLINE_CACHE: 1
container_name: attune-executor-2
environment: environment:
RUST_LOG: info RUST_LOG: info
ATTUNE_CONFIG: /opt/attune/config/config.yaml ATTUNE_CONFIG: /opt/attune/config/config.yaml

View File

@@ -133,11 +133,11 @@ attune action execute core.echo --param message="Hello" --param count=3
# With JSON parameters # With JSON parameters
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}' attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
# Wait for completion # Watch until completion
attune action execute core.long_task --wait attune action execute core.long_task --watch
# Wait with timeout # Watch with timeout
attune action execute core.long_task --wait --timeout 600 attune action execute core.long_task --watch --timeout 600
``` ```
### Rule Management ### Rule Management