From 195110e34f073eb99ff4a20a1667b34e736c0b3f Mon Sep 17 00:00:00 2001 From: sdairs Date: Sat, 1 Aug 2026 11:29:30 +0100 Subject: [PATCH] Suggest --force for running service deletion --- crates/clickhousectl/src/cloud/commands.rs | 57 ++++++++++++++++++- .../tests/cli_request_shape_test.rs | 30 ++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/clickhousectl/src/cloud/commands.rs b/crates/clickhousectl/src/cloud/commands.rs index 6ca818d8..b5446e4c 100644 --- a/crates/clickhousectl/src/cloud/commands.rs +++ b/crates/clickhousectl/src/cloud/commands.rs @@ -1,4 +1,4 @@ -use crate::cloud::client::CloudClient; +use crate::cloud::client::{CloudClient, CloudError}; use crate::cloud::credentials; use crate::cloud::output::{ABSENT, or_absent, print_human}; use clickhouse_cloud_api::models::{ @@ -974,6 +974,25 @@ fn classify_stop_poll_state( Ok(false) } +/// Replace the API's running-service conflict with the CLI remedy. +/// +/// Keep other conflicts intact: the API can reject deletion for reasons that +/// `--force` cannot fix, and a forced deletion must not suggest the flag the +/// user already passed. +fn service_delete_error(error: CloudError, force: bool, service_id: &str) -> CloudError { + if !force + && error.message.starts_with("CONFLICT:") + && error.message.contains("Current state: 'running'") + { + CloudError::new(format!( + "service is running and cannot be deleted. Use --force to stop it first, or \ + `clickhousectl cloud service stop {service_id}`." + )) + } else { + error + } +} + pub async fn service_delete( client: &CloudClient, service_id: &str, @@ -1006,7 +1025,10 @@ pub async fn service_delete( } } - let response = client.delete_service(&org_id, service_id).await?; + let response = client + .delete_service(&org_id, service_id) + .await + .map_err(|error| service_delete_error(error, force, service_id))?; let _ = credentials::remove_service_query_key(service_id); if json { println!("{}", serde_json::to_string_pretty(&response)?); @@ -3387,6 +3409,37 @@ mod tests { ); } + #[test] + fn service_delete_error_suggests_force_for_a_running_service() { + let error = CloudError::new( + "CONFLICT: Only instance in one of the following states can be terminated. \ + Current state: 'running'", + ); + + let error = service_delete_error(error, false, "svc-1"); + + assert_eq!( + error.message, + "service is running and cannot be deleted. Use --force to stop it first, or \ + `clickhousectl cloud service stop svc-1`." + ); + } + + #[test] + fn service_delete_error_preserves_unrelated_and_forced_failures() { + let unrelated = CloudError::new("CONFLICT: service has dependent resources"); + assert_eq!( + service_delete_error(unrelated, false, "svc-1").message, + "CONFLICT: service has dependent resources" + ); + + let forced = CloudError::new("CONFLICT: Current state: 'running'"); + assert_eq!( + service_delete_error(forced, true, "svc-1").message, + "CONFLICT: Current state: 'running'" + ); + } + #[test] fn resolve_key_create_material_returns_the_generated_pair() { let material = diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index e11e1bb3..8049c226 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -172,6 +172,36 @@ fn invoke_cli_with_cloud_credentials(mock: &MockServer, cli_args: &[&str]) -> st .expect("failed to spawn clickhousectl") } +// ── Service deletion errors (issue #335) ────────────────────────────────── + +#[tokio::test] +async fn service_delete_running_conflict_suggests_force() { + let mock = MockServer::start().await; + Mock::given(method("DELETE")) + .and(path("/v1/organizations/org-1/services/svc-1")) + .respond_with(ResponseTemplate::new(409).set_body_json(serde_json::json!({ + "status": 409, + "error": "CONFLICT: Only instance in one of the following states: \ + 'provisioning','starting','awaking','idle','stopped','degraded','failed' \ + can be terminated. Current state: 'running'" + }))) + .expect(1) + .mount(&mock) + .await; + + let output = invoke_cli_with_cloud_credentials( + &mock, + &["service", "delete", "svc-1", "--org-id", "org-1"], + ); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + String::from_utf8_lossy(&output.stderr), + "Error: service is running and cannot be deleted. Use --force to stop it first, or \ + `clickhousectl cloud service stop svc-1`.\n" + ); +} + #[tokio::test] async fn org_prometheus_auto_detects_the_only_organization() { let mock = start_mock_org_auto_detection_api().await;