Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions crates/alien-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use alien_deployment::manager_api_transport::{
combine_operation_and_finalization, final_reconcile, ManagerApiTransport,
};
use alien_deployment::runner::{RunnerPolicy, RunnerResult};
use alien_error::{AlienError, Context, IntoAlienError};
use alien_error::{AlienError, Context, ContextError, IntoAlienError};
use alien_infra::ClientConfigExt;
use alien_platform_api::Client as SdkClient;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
Expand Down Expand Up @@ -1880,12 +1880,19 @@ async fn deploy_task_with_environment(
}
LoopOutcome::Failure => {
steps.fail(2, Some(format!("{:?}", loop_result.final_status)));
return Err(AlienError::new(ErrorData::DeploymentFailed {
let failed = ErrorData::DeploymentFailed {
message: format!(
"{} failed",
describe_failed_status(&loop_result.final_status)
),
}));
};
// The final state's headline error names each failed resource and its cause.
return Err(
match alien_deployment::deployment_headline_error_from_state(&current) {
Some(cause) => cause.context(failed),
None => AlienError::new(failed),
},
);
}
LoopOutcome::Neutral if loop_result.stop_reason == LoopStopReason::Handoff => {
steps.complete(2, Some("Resources ready".to_string()));
Expand Down
13 changes: 10 additions & 3 deletions crates/alien-cli/src/commands/destroy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use alien_deployment::manager_api_transport::{
ManagerApiTransport, SetupDeleteAcquireOutcome,
};
use alien_deployment::runner::{preserve_semantic_failure, RunnerPolicy, RunnerResult};
use alien_error::{AlienError, Context, IntoAlienError};
use alien_error::{AlienError, Context, ContextError, IntoAlienError};
use alien_infra::ClientConfigExt;
use clap::Parser;
use std::str::FromStr;
Expand Down Expand Up @@ -373,9 +373,16 @@ pub async fn destroy_task(args: DestroyArgs, ctx: ExecutionMode) -> Result<()> {
}
LoopOutcome::Failure => {
steps.fail(2, Some(format!("{:?}", loop_result.final_status)));
return Err(AlienError::new(ErrorData::DeploymentFailed {
let failed = ErrorData::DeploymentFailed {
message: format!("deletion failed at status {:?}", loop_result.final_status),
}));
};
// The final state's headline error names each failed resource and its cause.
return Err(
match alien_deployment::deployment_headline_error_from_state(&current) {
Some(cause) => cause.context(failed),
None => AlienError::new(failed),
},
);
}
LoopOutcome::Neutral => {
steps.complete(2, Some("Deletion in progress".to_string()));
Expand Down
51 changes: 3 additions & 48 deletions crates/alien-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1489,24 +1489,9 @@ fn deployment_error_message(error: &serde_json::Value) -> Option<String> {
// For DEPLOYMENT_FAILED errors, extract per-resource root causes
// instead of the generic "Deployment failed: N resource error(s)..." summary.
if error.get("code").and_then(|v| v.as_str()) == Some("DEPLOYMENT_FAILED") {
if let Some(resource_errors) = error
.get("context")
.and_then(|c| c.get("resource_errors"))
.and_then(|v| v.as_array())
{
let details: Vec<String> = resource_errors
.iter()
.filter_map(|re| {
let resource_id = re.get("resourceId").and_then(|v| v.as_str())?;
let err = re.get("error")?;
let msg = root_cause_message(err)?;
Some(format!("{resource_id}: {msg}"))
})
.collect();

if !details.is_empty() {
return Some(details.join("; "));
}
let details = ui::failed_resources_from_context(error.get("context"));
if !details.is_empty() {
return Some(details.join("; "));
}
}

Expand All @@ -1517,36 +1502,6 @@ fn deployment_error_message(error: &serde_json::Value) -> Option<String> {
.or_else(|| error.as_str().map(ToOwned::to_owned))
}

/// Walk the source chain of a serialized AlienError to find the root cause message.
/// Prefers the deepest non-internal error; falls back to the deepest error overall.
fn root_cause_message(error: &serde_json::Value) -> Option<String> {
let mut deepest_non_internal: Option<&str> = None;
let mut deepest: Option<&str> = None;
let mut current = error;

loop {
let msg = current.get("message").and_then(|v| v.as_str());
let is_internal = current
.get("internal")
.and_then(|v| v.as_bool())
.unwrap_or(true);

if let Some(m) = msg {
deepest = Some(m);
if !is_internal {
deepest_non_internal = Some(m);
}
}

match current.get("source") {
Some(source) if source.is_object() => current = source,
_ => break,
}
}

deepest_non_internal.or(deepest).map(ToOwned::to_owned)
}

async fn watch_dev_deployments_until_ctrl_c(
port: u16,
primary_deployment_name: &str,
Expand Down
113 changes: 113 additions & 0 deletions crates/alien-cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ where
rendered.push_str(&build_output);
}

let failed_resources = find_failed_resources(error);
if !failed_resources.is_empty() {
rendered.push('\n');
rendered.push_str("Failed resources:");
for failure in failed_resources {
rendered.push('\n');
rendered.push_str(&format!(" - {failure}"));
}
}

if let Some(hint) = report.hint {
rendered.push('\n');
rendered.push_str("Next:");
Expand Down Expand Up @@ -325,11 +335,114 @@ fn request_id_from_context(context: Option<&Value>) -> Option<String> {
.map(ToString::to_string)
}

/// `resource: root cause` for each resource of the first error in the chain that lists failed
/// resources: a deployment failure's own message only counts them.
fn find_failed_resources<T>(error: &AlienError<T>) -> Vec<String>
where
T: AlienErrorData + Clone + std::fmt::Debug + serde::Serialize,
{
let own = failed_resources_from_context(error.context.as_ref());
if !own.is_empty() {
return own;
}
error
.source
.as_deref()
.map(find_failed_resources)
.unwrap_or_default()
}

/// The failed resources in a deployment failure's context, each with its root cause.
pub(crate) fn failed_resources_from_context(context: Option<&Value>) -> Vec<String> {
context
.and_then(|context| context.get("resource_errors"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|failure| {
let resource_id = failure.get("resourceId").and_then(Value::as_str)?;
let cause = root_cause_message(failure.get("error")?)?;
Some(format!("{resource_id}: {cause}"))
})
.collect()
}

/// Walk the source chain of a serialized AlienError to find the root cause message.
/// Prefers the deepest non-internal error; falls back to the deepest error overall.
pub(crate) fn root_cause_message(error: &Value) -> Option<String> {
let mut deepest_non_internal: Option<&str> = None;
let mut deepest: Option<&str> = None;
let mut current = error;

loop {
let msg = current.get("message").and_then(|v| v.as_str());
let is_internal = current
.get("internal")
.and_then(|v| v.as_bool())
.unwrap_or(true);

if let Some(m) = msg {
deepest = Some(m);
if !is_internal {
deepest_non_internal = Some(m);
}
}

match current.get("source") {
Some(source) if source.is_object() => current = source,
_ => break,
}
}

deepest_non_internal.or(deepest).map(ToOwned::to_owned)
}

#[cfg(test)]
mod event_tests {
use super::*;
use alien_error::Context;

/// `deployments get` renders the stored deployment error. A deployment failure's own
/// message only counts the failed resources, so each one is listed with its root cause.
#[test]
fn render_human_error_names_each_failed_resource() {
let cause = AlienError::new(crate::error::ErrorData::ValidationError {
field: "image".to_string(),
message: "the image was not found".to_string(),
})
.into_generic();
let failed = |resource_id: &str| alien_deployment::ResourceError {
resource_id: resource_id.to_string(),
resource_type: "container".to_string(),
error: Some(cause.clone()),
};
let stored = serde_json::to_value(
AlienError::new(alien_deployment::ErrorData::DeploymentFailed {
resource_errors: vec![failed("api"), failed("worker")],
total_resources: 10,
failed_resources: 2,
interrupted_resources: 4,
})
.into_generic(),
)
.expect("serialize the deployment error");
let error: AlienError =
serde_json::from_value(stored).expect("deserialize the stored deployment error");
let listed = "Failed resources:\n - api: Validation failed for image: the image was not found\n - worker: Validation failed for image: the image was not found";

let rendered = render_human_error(&error);
assert!(rendered.contains(listed), "{rendered}");

// `alien deploy` wraps the state's error in its own summary of the failed phase.
let deploy_error = Err::<(), _>(error)
.context(crate::error::ErrorData::DeploymentFailed {
message: "provisioning failed".to_string(),
})
.unwrap_err();
let rendered = render_human_error(&deploy_error);
assert!(rendered.contains(listed), "{rendered}");
}

#[test]
fn render_human_error_leaves_regular_errors_unchanged() {
let error = AlienError::new(crate::error::ErrorData::ValidationError {
Expand Down
Loading