artifacts!
This commit is contained in:
@@ -6,6 +6,7 @@ use std::collections::HashMap;
|
||||
use crate::client::ApiClient;
|
||||
use crate::config::CliConfig;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::wait::{wait_for_execution, WaitOptions};
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum ActionCommands {
|
||||
@@ -74,6 +75,11 @@ pub enum ActionCommands {
|
||||
/// Timeout in seconds when waiting (default: 300)
|
||||
#[arg(long, default_value = "300", requires = "wait")]
|
||||
timeout: u64,
|
||||
|
||||
/// Notifier WebSocket base URL (e.g. ws://localhost:8081).
|
||||
/// Derived from --api-url automatically when not set.
|
||||
#[arg(long, requires = "wait")]
|
||||
notifier_url: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -182,6 +188,7 @@ pub async fn handle_action_command(
|
||||
params_json,
|
||||
wait,
|
||||
timeout,
|
||||
notifier_url,
|
||||
} => {
|
||||
handle_execute(
|
||||
action_ref,
|
||||
@@ -191,6 +198,7 @@ pub async fn handle_action_command(
|
||||
api_url,
|
||||
wait,
|
||||
timeout,
|
||||
notifier_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
@@ -415,6 +423,7 @@ async fn handle_execute(
|
||||
api_url: &Option<String>,
|
||||
wait: bool,
|
||||
timeout: u64,
|
||||
notifier_url: Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
@@ -453,62 +462,61 @@ async fn handle_execute(
|
||||
}
|
||||
|
||||
let path = "/executions/execute".to_string();
|
||||
let mut execution: Execution = client.post(&path, &request).await?;
|
||||
let execution: Execution = client.post(&path, &request).await?;
|
||||
|
||||
if wait {
|
||||
if !wait {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Waiting for execution {} to complete...",
|
||||
execution.id
|
||||
));
|
||||
output::print_success(&format!("Execution {} started", execution.id));
|
||||
output::print_key_value_table(vec![
|
||||
("Execution ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Poll for completion
|
||||
let start = std::time::Instant::now();
|
||||
let timeout_duration = std::time::Duration::from_secs(timeout);
|
||||
|
||||
loop {
|
||||
if start.elapsed() > timeout_duration {
|
||||
anyhow::bail!("Execution timed out after {} seconds", timeout);
|
||||
}
|
||||
|
||||
let exec_path = format!("/executions/{}", execution.id);
|
||||
execution = client.get(&exec_path).await?;
|
||||
|
||||
if execution.status == "succeeded"
|
||||
|| execution.status == "failed"
|
||||
|| execution.status == "canceled"
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Waiting for execution {} to complete...",
|
||||
execution.id
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let verbose = matches!(output_format, OutputFormat::Table);
|
||||
let summary = wait_for_execution(WaitOptions {
|
||||
execution_id: execution.id,
|
||||
timeout_secs: timeout,
|
||||
api_client: &mut client,
|
||||
notifier_ws_url: notifier_url,
|
||||
verbose,
|
||||
})
|
||||
.await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&execution, output_format)?;
|
||||
output::print_output(&summary, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
output::print_success(&format!(
|
||||
"Execution {} {}",
|
||||
execution.id,
|
||||
if wait { "completed" } else { "started" }
|
||||
));
|
||||
output::print_success(&format!("Execution {} completed", summary.id));
|
||||
output::print_section("Execution Details");
|
||||
output::print_key_value_table(vec![
|
||||
("Execution ID", execution.id.to_string()),
|
||||
("Action", execution.action_ref.clone()),
|
||||
("Status", output::format_status(&execution.status)),
|
||||
("Created", output::format_timestamp(&execution.created)),
|
||||
("Updated", output::format_timestamp(&execution.updated)),
|
||||
("Execution ID", summary.id.to_string()),
|
||||
("Action", summary.action_ref.clone()),
|
||||
("Status", output::format_status(&summary.status)),
|
||||
("Created", output::format_timestamp(&summary.created)),
|
||||
("Updated", output::format_timestamp(&summary.updated)),
|
||||
]);
|
||||
|
||||
if let Some(result) = execution.result {
|
||||
if let Some(result) = summary.result {
|
||||
if !result.is_null() {
|
||||
output::print_section("Result");
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
|
||||
@@ -17,6 +17,14 @@ pub enum AuthCommands {
|
||||
/// Password (will prompt if not provided)
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
|
||||
/// API URL to log in to (saved into the profile for future use)
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Save credentials into a named profile (creates it if it doesn't exist)
|
||||
#[arg(long)]
|
||||
save_profile: Option<String>,
|
||||
},
|
||||
/// Log out and clear authentication tokens
|
||||
Logout,
|
||||
@@ -53,8 +61,22 @@ pub async fn handle_auth_command(
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
match command {
|
||||
AuthCommands::Login { username, password } => {
|
||||
handle_login(username, password, profile, api_url, output_format).await
|
||||
AuthCommands::Login {
|
||||
username,
|
||||
password,
|
||||
url,
|
||||
save_profile,
|
||||
} => {
|
||||
// --url is a convenient alias for --api-url at login time
|
||||
let effective_api_url = url.or_else(|| api_url.clone());
|
||||
handle_login(
|
||||
username,
|
||||
password,
|
||||
save_profile.as_ref().or(profile.as_ref()),
|
||||
&effective_api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
AuthCommands::Logout => handle_logout(profile, output_format).await,
|
||||
AuthCommands::Whoami => handle_whoami(profile, api_url, output_format).await,
|
||||
@@ -65,11 +87,44 @@ pub async fn handle_auth_command(
|
||||
async fn handle_login(
|
||||
username: String,
|
||||
password: Option<String>,
|
||||
profile: &Option<String>,
|
||||
profile: Option<&String>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
// Determine which profile name will own these credentials.
|
||||
// If --save-profile / --profile was given, use that; otherwise use the
|
||||
// currently-active profile.
|
||||
let mut config = CliConfig::load()?;
|
||||
let target_profile_name = profile
|
||||
.cloned()
|
||||
.unwrap_or_else(|| config.current_profile.clone());
|
||||
|
||||
// If a URL was provided and the target profile doesn't exist yet, create it.
|
||||
if !config.profiles.contains_key(&target_profile_name) {
|
||||
let url = api_url.clone().unwrap_or_else(|| "http://localhost:8080".to_string());
|
||||
use crate::config::Profile;
|
||||
config.set_profile(
|
||||
target_profile_name.clone(),
|
||||
Profile {
|
||||
api_url: url,
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: None,
|
||||
description: None,
|
||||
},
|
||||
)?;
|
||||
} else if let Some(url) = api_url {
|
||||
// Profile exists — update its api_url if an explicit URL was provided.
|
||||
if let Some(p) = config.profiles.get_mut(&target_profile_name) {
|
||||
p.api_url = url.clone();
|
||||
}
|
||||
config.save()?;
|
||||
}
|
||||
|
||||
// Build a temporary config view that points at the target profile so
|
||||
// ApiClient uses the right base URL.
|
||||
let mut login_config = CliConfig::load()?;
|
||||
login_config.current_profile = target_profile_name.clone();
|
||||
|
||||
// Prompt for password if not provided
|
||||
let password = match password {
|
||||
@@ -82,7 +137,7 @@ async fn handle_login(
|
||||
}
|
||||
};
|
||||
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
let mut client = ApiClient::from_config(&login_config, api_url);
|
||||
|
||||
let login_req = LoginRequest {
|
||||
login: username,
|
||||
@@ -91,12 +146,17 @@ async fn handle_login(
|
||||
|
||||
let response: LoginResponse = client.post("/auth/login", &login_req).await?;
|
||||
|
||||
// Save tokens to config
|
||||
// Persist tokens into the target profile.
|
||||
let mut config = CliConfig::load()?;
|
||||
config.set_auth(
|
||||
response.access_token.clone(),
|
||||
response.refresh_token.clone(),
|
||||
)?;
|
||||
// Ensure the profile exists (it may have just been created above and saved).
|
||||
if let Some(p) = config.profiles.get_mut(&target_profile_name) {
|
||||
p.auth_token = Some(response.access_token.clone());
|
||||
p.refresh_token = Some(response.refresh_token.clone());
|
||||
config.save()?;
|
||||
} else {
|
||||
// Fallback: set_auth writes to the current profile.
|
||||
config.set_auth(response.access_token.clone(), response.refresh_token.clone())?;
|
||||
}
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
@@ -105,6 +165,12 @@ async fn handle_login(
|
||||
OutputFormat::Table => {
|
||||
output::print_success("Successfully logged in");
|
||||
output::print_info(&format!("Token expires in {} seconds", response.expires_in));
|
||||
if target_profile_name != config.current_profile {
|
||||
output::print_info(&format!(
|
||||
"Credentials saved to profile '{}'",
|
||||
target_profile_name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Subcommand;
|
||||
use flate2::{write::GzEncoder, Compression};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -77,9 +78,9 @@ pub enum PackCommands {
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Register a pack from a local directory
|
||||
/// Register a pack from a local directory (path must be accessible by the API server)
|
||||
Register {
|
||||
/// Path to pack directory
|
||||
/// Path to pack directory (must be a path the API server can access)
|
||||
path: String,
|
||||
|
||||
/// Force re-registration if pack already exists
|
||||
@@ -90,6 +91,22 @@ pub enum PackCommands {
|
||||
#[arg(long)]
|
||||
skip_tests: bool,
|
||||
},
|
||||
/// Upload a local pack directory to the API server and register it
|
||||
///
|
||||
/// This command tarballs the local directory and streams it to the API,
|
||||
/// so it works regardless of whether the API is local or running in Docker.
|
||||
Upload {
|
||||
/// Path to the local pack directory (must contain pack.yaml)
|
||||
path: String,
|
||||
|
||||
/// Force re-registration if a pack with the same ref already exists
|
||||
#[arg(short, long)]
|
||||
force: bool,
|
||||
|
||||
/// Skip running pack tests after upload
|
||||
#[arg(long)]
|
||||
skip_tests: bool,
|
||||
},
|
||||
/// Test a pack's test suite
|
||||
Test {
|
||||
/// Pack reference (name) or path to pack directory
|
||||
@@ -256,6 +273,15 @@ struct RegisterPackRequest {
|
||||
skip_tests: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct UploadPackResponse {
|
||||
pack: Pack,
|
||||
#[serde(default)]
|
||||
test_result: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
tests_skipped: bool,
|
||||
}
|
||||
|
||||
pub async fn handle_pack_command(
|
||||
profile: &Option<String>,
|
||||
command: PackCommands,
|
||||
@@ -296,6 +322,11 @@ pub async fn handle_pack_command(
|
||||
force,
|
||||
skip_tests,
|
||||
} => handle_register(profile, path, force, skip_tests, api_url, output_format).await,
|
||||
PackCommands::Upload {
|
||||
path,
|
||||
force,
|
||||
skip_tests,
|
||||
} => handle_upload(profile, path, force, skip_tests, api_url, output_format).await,
|
||||
PackCommands::Test {
|
||||
pack,
|
||||
verbose,
|
||||
@@ -593,6 +624,160 @@ async fn handle_uninstall(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_upload(
|
||||
profile: &Option<String>,
|
||||
path: String,
|
||||
force: bool,
|
||||
skip_tests: bool,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let pack_dir = Path::new(&path);
|
||||
|
||||
// Validate the directory exists and contains pack.yaml
|
||||
if !pack_dir.exists() {
|
||||
anyhow::bail!("Path does not exist: {}", path);
|
||||
}
|
||||
if !pack_dir.is_dir() {
|
||||
anyhow::bail!("Path is not a directory: {}", path);
|
||||
}
|
||||
let pack_yaml_path = pack_dir.join("pack.yaml");
|
||||
if !pack_yaml_path.exists() {
|
||||
anyhow::bail!("No pack.yaml found in: {}", path);
|
||||
}
|
||||
|
||||
// Read pack ref from pack.yaml so we can display it
|
||||
let pack_yaml_content = std::fs::read_to_string(&pack_yaml_path)
|
||||
.context("Failed to read pack.yaml")?;
|
||||
let pack_yaml: serde_yaml_ng::Value =
|
||||
serde_yaml_ng::from_str(&pack_yaml_content).context("Failed to parse pack.yaml")?;
|
||||
let pack_ref = pack_yaml
|
||||
.get("ref")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Uploading pack '{}' from: {}",
|
||||
pack_ref, path
|
||||
));
|
||||
output::print_info("Creating archive...");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Build an in-memory tar.gz of the pack directory
|
||||
let tar_gz_bytes = {
|
||||
let buf = Vec::new();
|
||||
let enc = GzEncoder::new(buf, Compression::default());
|
||||
let mut tar = tar::Builder::new(enc);
|
||||
|
||||
// Walk the directory and add files to the archive
|
||||
// We strip the leading path so the archive root is the pack directory contents
|
||||
let abs_pack_dir = pack_dir
|
||||
.canonicalize()
|
||||
.context("Failed to resolve pack directory path")?;
|
||||
|
||||
append_dir_to_tar(&mut tar, &abs_pack_dir, &abs_pack_dir)?;
|
||||
|
||||
let encoder = tar.into_inner().context("Failed to finalise tar archive")?;
|
||||
encoder.finish().context("Failed to flush gzip stream")?
|
||||
};
|
||||
|
||||
let archive_size_kb = tar_gz_bytes.len() / 1024;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!(
|
||||
"Archive ready ({} KB), uploading...",
|
||||
archive_size_kb
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
let mut extra_fields = Vec::new();
|
||||
if force {
|
||||
extra_fields.push(("force", "true".to_string()));
|
||||
}
|
||||
if skip_tests {
|
||||
extra_fields.push(("skip_tests", "true".to_string()));
|
||||
}
|
||||
|
||||
let archive_name = format!("{}.tar.gz", pack_ref);
|
||||
let response: UploadPackResponse = client
|
||||
.multipart_post(
|
||||
"/packs/upload",
|
||||
"pack",
|
||||
tar_gz_bytes,
|
||||
&archive_name,
|
||||
"application/gzip",
|
||||
extra_fields,
|
||||
)
|
||||
.await?;
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
output::print_output(&response, output_format)?;
|
||||
}
|
||||
OutputFormat::Table => {
|
||||
println!();
|
||||
output::print_success(&format!(
|
||||
"✓ Pack '{}' uploaded and registered successfully",
|
||||
response.pack.pack_ref
|
||||
));
|
||||
output::print_info(&format!(" Version: {}", response.pack.version));
|
||||
output::print_info(&format!(" ID: {}", response.pack.id));
|
||||
|
||||
if response.tests_skipped {
|
||||
output::print_info(" ⚠ Tests were skipped");
|
||||
} else if let Some(test_result) = &response.test_result {
|
||||
if let Some(status) = test_result.get("status").and_then(|s| s.as_str()) {
|
||||
if status == "passed" {
|
||||
output::print_success(" ✓ All tests passed");
|
||||
} else if status == "failed" {
|
||||
output::print_error(" ✗ Some tests failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recursively append a directory's contents to a tar archive.
|
||||
/// `base` is the root directory being archived; `dir` is the current directory
|
||||
/// being walked. Files are stored with paths relative to `base`.
|
||||
fn append_dir_to_tar<W: std::io::Write>(
|
||||
tar: &mut tar::Builder<W>,
|
||||
base: &Path,
|
||||
dir: &Path,
|
||||
) -> Result<()> {
|
||||
for entry in std::fs::read_dir(dir).context("Failed to read directory")? {
|
||||
let entry = entry.context("Failed to read directory entry")?;
|
||||
let entry_path = entry.path();
|
||||
let relative_path = entry_path
|
||||
.strip_prefix(base)
|
||||
.context("Failed to compute relative path")?;
|
||||
|
||||
if entry_path.is_dir() {
|
||||
append_dir_to_tar(tar, base, &entry_path)?;
|
||||
} else if entry_path.is_file() {
|
||||
tar.append_path_with_name(&entry_path, relative_path)
|
||||
.with_context(|| {
|
||||
format!("Failed to add {} to archive", entry_path.display())
|
||||
})?;
|
||||
}
|
||||
// symlinks are intentionally skipped
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_register(
|
||||
profile: &Option<String>,
|
||||
path: String,
|
||||
@@ -604,19 +789,39 @@ async fn handle_register(
|
||||
let config = CliConfig::load_with_profile(profile.as_deref())?;
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Warn if the path looks like a local filesystem path that the API server
|
||||
// probably can't see (i.e. not a known container mount point).
|
||||
let looks_local = !path.starts_with("/opt/attune/")
|
||||
&& !path.starts_with("/app/")
|
||||
&& !path.starts_with("/packs");
|
||||
if looks_local {
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!("Registering pack from: {}", path));
|
||||
eprintln!(
|
||||
"⚠ Warning: '{}' looks like a local path. If the API is running in \
|
||||
Docker it may not be able to access this path.\n \
|
||||
Use `attune pack upload {}` instead to upload the pack directly.",
|
||||
path, path
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!("Registering pack from: {}", path));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let request = RegisterPackRequest {
|
||||
path: path.clone(),
|
||||
force,
|
||||
skip_tests,
|
||||
};
|
||||
|
||||
match output_format {
|
||||
OutputFormat::Table => {
|
||||
output::print_info(&format!("Registering pack from: {}", path));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let response: PackInstallResponse = client.post("/packs/register", &request).await?;
|
||||
|
||||
match output_format {
|
||||
|
||||
Reference in New Issue
Block a user