log streams in watched cli executions
This commit is contained in:
60
Cargo.lock
generated
60
Cargo.lock
generated
@@ -201,7 +201,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -212,7 +212,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -535,6 +535,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"terminal_size",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -1131,7 +1132,7 @@ version = "3.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1795,7 +1796,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1979,7 +1980,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3006,9 +3007,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lapin"
|
||||
version = "4.3.0"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1586ef35d652d6c47ed7449277a4483805b73b84ab368c85af44205fe3457972"
|
||||
checksum = "39338badb3f992d800f6964501b056b575bdf142eb288202f973d218fe253b90"
|
||||
dependencies = [
|
||||
"amq-protocol",
|
||||
"async-rs",
|
||||
@@ -3209,9 +3210,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
@@ -3334,7 +3335,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3466,7 +3467,7 @@ version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.21.7",
|
||||
"chrono",
|
||||
"getrandom 0.2.17",
|
||||
"http",
|
||||
@@ -4204,12 +4205,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.0.5"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b36964393906eb775b89b25b05b7b95685b8dd14062f1663a31ff93e75c452e5"
|
||||
checksum = "f44e94c96d8870a387d88ce3de3fdd608cbfc0705f03cb343cdde91509d3e49a"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
"async-lock",
|
||||
"backon",
|
||||
"bytes",
|
||||
"cfg-if",
|
||||
@@ -4566,7 +4568,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4640,7 +4642,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5094,7 +5096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5456,7 +5458,17 @@ dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"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]]
|
||||
@@ -5591,9 +5603,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
version = "1.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
|
||||
checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -5624,9 +5636,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6052,9 +6064,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -6398,7 +6410,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -20,7 +20,7 @@ repository = "https://git.rdrx.app/attune-system/attune"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Async runtime
|
||||
tokio = { version = "1.50", features = ["full"] }
|
||||
tokio = { version = "1.51", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
|
||||
@@ -52,7 +52,7 @@ config = "0.15"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1.22", features = ["v4", "serde"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
|
||||
# Validation
|
||||
validator = { version = "0.20", features = ["derive"] }
|
||||
@@ -62,9 +62,9 @@ clap = { version = "4.6", features = ["derive"] }
|
||||
|
||||
# Message queue / PubSub
|
||||
# RabbitMQ
|
||||
lapin = "4.3"
|
||||
lapin = "4.4"
|
||||
# Redis
|
||||
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
|
||||
redis = { version = "1.2", features = ["tokio-comp", "connection-manager"] }
|
||||
|
||||
# JSON Schema
|
||||
schemars = { version = "1.2", features = ["chrono04"] }
|
||||
@@ -91,7 +91,7 @@ regex = "1.12"
|
||||
# HTTP client
|
||||
reqwest = { version = "0.13", features = ["json"] }
|
||||
reqwest-eventsource = "0.6"
|
||||
hyper = { version = "1.8", features = ["full"] }
|
||||
hyper = { version = "1.9", features = ["full"] }
|
||||
|
||||
# File system utilities
|
||||
walkdir = "2.5"
|
||||
|
||||
@@ -59,6 +59,7 @@ sha2 = { workspace = true }
|
||||
colored = "3.1"
|
||||
comfy-table = { version = "7.2", features = ["custom_styling"] }
|
||||
dialoguer = "0.12"
|
||||
terminal_size = "0.4"
|
||||
|
||||
# Authentication
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
@@ -175,11 +175,11 @@ attune action execute core.echo --param message="Hello World" --param count=3
|
||||
# With JSON parameters
|
||||
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
|
||||
|
||||
# Wait for completion
|
||||
attune action execute core.long_task --wait
|
||||
# Watch until completion
|
||||
attune action execute core.long_task --watch
|
||||
|
||||
# Wait with custom timeout (default 300 seconds)
|
||||
attune action execute core.long_task --wait --timeout 600
|
||||
# Watch with custom timeout (default 300 seconds)
|
||||
attune action execute core.long_task --watch --timeout 600
|
||||
```
|
||||
|
||||
## Rule Management
|
||||
|
||||
@@ -68,17 +68,17 @@ pub enum ActionCommands {
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
/// Watch execution until it completes
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
watch: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
/// Timeout in seconds when watching (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "watch")]
|
||||
timeout: u64,
|
||||
|
||||
/// Notifier WebSocket base URL (e.g. ws://localhost:8081).
|
||||
/// Derived from --api-url automatically when not set.
|
||||
#[arg(long, requires = "wait")]
|
||||
#[arg(long, requires = "watch")]
|
||||
notifier_url: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -186,7 +186,7 @@ pub async fn handle_action_command(
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
watch,
|
||||
timeout,
|
||||
notifier_url,
|
||||
} => {
|
||||
@@ -196,7 +196,7 @@ pub async fn handle_action_command(
|
||||
params_json,
|
||||
profile,
|
||||
api_url,
|
||||
wait,
|
||||
watch,
|
||||
timeout,
|
||||
notifier_url,
|
||||
output_format,
|
||||
@@ -307,7 +307,7 @@ async fn handle_show(
|
||||
if let Some(params) = action.param_schema {
|
||||
if !params.is_null() {
|
||||
output::print_section("Parameters Schema");
|
||||
println!("{}", serde_json::to_string_pretty(¶ms)?);
|
||||
output::print_schema(¶ms)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,7 @@ async fn handle_execute(
|
||||
params_json: Option<String>,
|
||||
profile: &Option<String>,
|
||||
api_url: &Option<String>,
|
||||
wait: bool,
|
||||
watch: bool,
|
||||
timeout: u64,
|
||||
notifier_url: Option<String>,
|
||||
output_format: OutputFormat,
|
||||
@@ -468,7 +468,7 @@ async fn handle_execute(
|
||||
let path = "/executions/execute".to_string();
|
||||
let execution: Execution = client.post(&path, &request).await?;
|
||||
|
||||
if !wait {
|
||||
if !watch {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
@@ -492,22 +492,22 @@ async fn handle_execute(
|
||||
));
|
||||
}
|
||||
|
||||
let verbose = matches!(output_format, OutputFormat::Table);
|
||||
let watch_task = if verbose {
|
||||
Some(spawn_execution_output_watch(
|
||||
ApiClient::from_config(&config, api_url),
|
||||
execution.id,
|
||||
verbose,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let interactive_wait = true;
|
||||
let stream_live_logs = true;
|
||||
let debug_wait = false;
|
||||
let watch_task = Some(spawn_execution_output_watch(
|
||||
ApiClient::from_config(&config, api_url),
|
||||
execution.id,
|
||||
interactive_wait,
|
||||
stream_live_logs,
|
||||
debug_wait,
|
||||
));
|
||||
let summary = wait_for_execution(WaitOptions {
|
||||
execution_id: execution.id,
|
||||
timeout_secs: timeout,
|
||||
api_client: &mut client,
|
||||
notifier_ws_url: notifier_url,
|
||||
verbose,
|
||||
verbose: debug_wait,
|
||||
})
|
||||
.await?;
|
||||
let suppress_final_stdout = watch_task
|
||||
|
||||
@@ -124,17 +124,17 @@ enum Commands {
|
||||
#[arg(long, conflicts_with = "param")]
|
||||
params_json: Option<String>,
|
||||
|
||||
/// Wait for execution to complete
|
||||
/// Watch execution until it completes
|
||||
#[arg(short, long)]
|
||||
wait: bool,
|
||||
watch: bool,
|
||||
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
/// Timeout in seconds when watching (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "watch")]
|
||||
timeout: u64,
|
||||
|
||||
/// Notifier WebSocket base URL (e.g. ws://localhost:8081).
|
||||
/// Derived from --api-url automatically when not set.
|
||||
#[arg(long, requires = "wait")]
|
||||
#[arg(long, requires = "watch")]
|
||||
notifier_url: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -243,7 +243,7 @@ async fn main() {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
watch,
|
||||
timeout,
|
||||
notifier_url,
|
||||
} => {
|
||||
@@ -254,7 +254,7 @@ async fn main() {
|
||||
action_ref,
|
||||
param,
|
||||
params_json,
|
||||
wait,
|
||||
watch,
|
||||
timeout,
|
||||
notifier_url,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ use colored::Colorize;
|
||||
use comfy_table::{modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL, Cell, Color, Table};
|
||||
use serde::Serialize;
|
||||
use std::fmt::Display;
|
||||
use terminal_size::{terminal_size, Width};
|
||||
|
||||
/// Output format for CLI commands
|
||||
#[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)>) {
|
||||
let mut table = create_table();
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub fn print_list(items: Vec<String>) {
|
||||
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
|
||||
pub fn format_timestamp(timestamp: &str) -> String {
|
||||
// Try to parse and format nicely, otherwise return as-is
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,7 @@ cargo test --package attune-cli --tests -- --test-threads=1
|
||||
- ✅ Execute with multiple parameters
|
||||
- ✅ Execute with JSON parameters
|
||||
- ✅ Execute without parameters
|
||||
- ✅ Execute with --wait flag
|
||||
- ✅ Execute with --watch flag
|
||||
- ✅ Execute with --async flag
|
||||
- ✅ List actions by pack
|
||||
- ✅ Invalid parameter formats
|
||||
|
||||
@@ -324,7 +324,7 @@ async fn test_action_execute_wait_for_completion() {
|
||||
.arg("core.echo")
|
||||
.arg("--param")
|
||||
.arg("message=test")
|
||||
.arg("--wait");
|
||||
.arg("--watch");
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
@@ -476,7 +476,7 @@ async fn test_action_execute_async_flag() {
|
||||
.arg("action")
|
||||
.arg("execute")
|
||||
.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()
|
||||
.success()
|
||||
|
||||
@@ -493,7 +493,16 @@ impl PackInstaller {
|
||||
})?;
|
||||
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!(
|
||||
"Remote URL host is not allowed: {}",
|
||||
host
|
||||
@@ -509,12 +518,14 @@ impl PackInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ip) = parsed.host().and_then(|host| match host {
|
||||
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
|
||||
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
|
||||
url::Host::Domain(_) => None,
|
||||
}) {
|
||||
ensure_public_ip(ip)?;
|
||||
if !host_is_allowlisted {
|
||||
if let Some(ip) = parsed.host().and_then(|host| match host {
|
||||
url::Host::Ipv4(ip) => Some(IpAddr::V4(ip)),
|
||||
url::Host::Ipv6(ip) => Some(IpAddr::V6(ip)),
|
||||
url::Host::Domain(_) => None,
|
||||
}) {
|
||||
ensure_public_ip(ip)?;
|
||||
}
|
||||
}
|
||||
|
||||
let port = parsed.port_or_known_default().ok_or_else(|| {
|
||||
@@ -528,7 +539,9 @@ impl PackInstaller {
|
||||
let mut saw_address = false;
|
||||
for addr in resolved {
|
||||
saw_address = true;
|
||||
ensure_public_ip(addr.ip())?;
|
||||
if !host_is_allowlisted {
|
||||
ensure_public_ip(addr.ip())?;
|
||||
}
|
||||
}
|
||||
|
||||
if !saw_address {
|
||||
@@ -557,7 +570,13 @@ impl PackInstaller {
|
||||
fn validate_remote_host(&self, host: &str) -> Result<()> {
|
||||
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!(
|
||||
"Remote host is not allowed: {}",
|
||||
host
|
||||
@@ -995,6 +1014,36 @@ mod tests {
|
||||
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]
|
||||
fn test_extract_git_host_from_scp_style_source() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -170,16 +170,11 @@ impl WorkerService {
|
||||
// Initialize worker registration
|
||||
let registration = Arc::new(RwLock::new(WorkerRegistration::new(pool.clone(), &config)));
|
||||
|
||||
// Initialize artifact manager (legacy, for stdout/stderr log storage)
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Worker artifact/config directories come from trusted process configuration, not request data.
|
||||
let artifact_base_dir = std::path::PathBuf::from(
|
||||
config
|
||||
.worker
|
||||
.as_ref()
|
||||
.and_then(|w| w.name.clone())
|
||||
.map(|name| format!("/tmp/attune/artifacts/{}", name))
|
||||
.unwrap_or_else(|| "/tmp/attune/artifacts".to_string()),
|
||||
);
|
||||
// Initialize artifact manager for execution stdout/stderr/result storage.
|
||||
// This must use the shared artifacts_dir so the API log streaming endpoints
|
||||
// and artifact download routes can see the same files the worker writes.
|
||||
// nosemgrep: rust.actix.path-traversal.tainted-path.tainted-path -- Artifact storage root is a trusted deployment configuration value.
|
||||
let artifact_base_dir = std::path::PathBuf::from(&config.artifacts_dir);
|
||||
let artifact_manager = ArtifactManager::new(artifact_base_dir);
|
||||
artifact_manager.initialize().await?;
|
||||
|
||||
|
||||
37
deny.toml
37
deny.toml
@@ -24,7 +24,6 @@ allow = [
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
"CC0-1.0",
|
||||
"OpenSSL",
|
||||
"BSL-1.0",
|
||||
"MIT-0",
|
||||
"CDLA-Permissive-2.0",
|
||||
@@ -36,12 +35,36 @@ wildcards = "allow"
|
||||
highlight = "all"
|
||||
deny = []
|
||||
skip = [
|
||||
"winnow@0.6.26",
|
||||
"winnow@0.7.15",
|
||||
"windows_x86_64_msvc@0.42.2",
|
||||
"windows_x86_64_msvc@0.48.5",
|
||||
"windows_x86_64_msvc@0.52.6",
|
||||
"windows_x86_64_msvc@0.53.1",
|
||||
"base64",
|
||||
"core-foundation",
|
||||
"cpufeatures",
|
||||
"darling",
|
||||
"darling_core",
|
||||
"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 = []
|
||||
|
||||
|
||||
@@ -282,7 +282,53 @@ services:
|
||||
args:
|
||||
SERVICE: executor
|
||||
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:
|
||||
RUST_LOG: info
|
||||
ATTUNE_CONFIG: /opt/attune/config/config.yaml
|
||||
|
||||
@@ -133,11 +133,11 @@ attune action execute core.echo --param message="Hello" --param count=3
|
||||
# With JSON parameters
|
||||
attune action execute core.echo --params-json '{"message": "Hello", "count": 5}'
|
||||
|
||||
# Wait for completion
|
||||
attune action execute core.long_task --wait
|
||||
# Watch until completion
|
||||
attune action execute core.long_task --watch
|
||||
|
||||
# Wait with timeout
|
||||
attune action execute core.long_task --wait --timeout 600
|
||||
# Watch with timeout
|
||||
attune action execute core.long_task --watch --timeout 600
|
||||
```
|
||||
|
||||
### Rule Management
|
||||
|
||||
Reference in New Issue
Block a user