log streams in watched cli executions
This commit is contained in:
@@ -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
|
||||
@@ -588,4 +588,4 @@ Key dependencies:
|
||||
- `colored`: Terminal colors
|
||||
- `comfy-table`: Table formatting
|
||||
- `dialoguer`: Interactive prompts
|
||||
- `indicatif`: Progress indicators (for future use)
|
||||
- `indicatif`: Progress indicators (for future use)
|
||||
|
||||
@@ -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
|
||||
@@ -287,4 +287,4 @@ For more information:
|
||||
- [CLI Usage Guide](../README.md)
|
||||
- [CLI Profile Management](../../../docs/cli-profiles.md)
|
||||
- [API Documentation](../../../docs/api-*.md)
|
||||
- [Main Project README](../../../README.md)
|
||||
- [Main Project README](../../../README.md)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user