diff --git a/.github/workflows/cloud-integration.yml b/.github/workflows/cloud-integration.yml index 9aa7d93..61efb78 100644 --- a/.github/workflows/cloud-integration.yml +++ b/.github/workflows/cloud-integration.yml @@ -45,3 +45,6 @@ jobs: - name: Run cloud Postgres integration suite run: cargo test -p clickhouse-cloud-api --test integration_postgres_test -- --ignored --nocapture + + - name: Run cloud ClickStack integration suite + run: cargo test -p clickhouse-cloud-api --test integration_clickstack_test -- --ignored --nocapture diff --git a/Cargo.lock b/Cargo.lock index a5aa5c7..03ea46f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,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]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -506,7 +506,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -554,7 +554,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]] @@ -1705,9 +1705,9 @@ dependencies = [ [[package]] name = "rpassword" -version = "7.5.0" +version = "7.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3bc4a3dd492cd6974dd3f32d6626ba9f36e5122e3df3b083474b104aebd139b" +checksum = "5ac5b223d9738ef56e0b98305410be40fa0941bf6036c56f1506751e43552d64" dependencies = [ "libc", "rtoolbox", @@ -1749,7 +1749,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1806,7 +1806,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2020,7 +2020,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]] @@ -2117,7 +2117,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -2593,7 +2593,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]] diff --git a/README.md b/README.md index d9ce7ef..c58e323 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,53 @@ clickhousectl cloud backup list clickhousectl cloud backup get ``` +### ClickStack + +Manage ClickStack dashboards, alerts, sources, and webhooks. All commands are scoped to a +service — get one from `clickhousectl cloud service list`. + +```bash +# Dashboards +clickhousectl cloud clickstack dashboard list +clickhousectl cloud clickstack dashboard get +clickhousectl cloud clickstack dashboard create --from-file dashboard.json +clickhousectl cloud clickstack dashboard create --from-file - < dashboard.json +clickhousectl cloud clickstack dashboard update --from-file dashboard.json +clickhousectl cloud clickstack dashboard delete + +# Alerts (email channel) +clickhousectl cloud clickstack alert list +clickhousectl cloud clickstack alert get +clickhousectl cloud clickstack alert create \ + --name "high error rate" \ + --threshold 100 --threshold-type above \ + --interval 5m --source saved_search --saved-search-id \ + --channel-type email --email oncall@example.com + +# Alerts (webhook channel, tile-based source) +clickhousectl cloud clickstack alert create \ + --name "disk pressure" \ + --threshold 90 --threshold-type above \ + --interval 5m --source tile \ + --dashboard-id --tile-id \ + --channel-type webhook --webhook-id --severity critical + +clickhousectl cloud clickstack alert update \ + --threshold 200 --threshold-type above \ + --interval 5m --source saved_search --saved-search-id \ + --channel-type email --email oncall@example.com +clickhousectl cloud clickstack alert delete + +# Sources and webhooks (read-only) +clickhousectl cloud clickstack source list +clickhousectl cloud clickstack webhook list +``` + +The `--from-file` flag for dashboards accepts a JSON body matching the +`ClickStackCreateDashboardRequest` schema (`{"name": ..., "tiles": [...], "tags": [...]}`). +Use `--name` and `--tag` to override the JSON body without editing the file. Use `-` as the +path to read JSON from stdin. + ### Members ```bash diff --git a/crates/clickhouse-cloud-api/tests/integration_clickstack_test.rs b/crates/clickhouse-cloud-api/tests/integration_clickstack_test.rs new file mode 100644 index 0000000..58ac176 --- /dev/null +++ b/crates/clickhouse-cloud-api/tests/integration_clickstack_test.rs @@ -0,0 +1,541 @@ +mod integration; + +use clickhouse_cloud_api::models::*; +use integration::support::*; + +#[tokio::test] +#[ignore = "requires live ClickHouse Cloud credentials and provisions real resources"] +async fn cloud_clickstack_crud_lifecycle() -> TestResult<()> { + let ctx = TestContext::from_env()?; + let client = create_client()?; + let mut cleanup = CleanupRegistry::default(); + + let test_result = async { + log_run_header("cloud_clickstack_crud_lifecycle", &ctx); + let mut failures = FailureRecorder::default(); + + // ── Provision service ──────────────────────────────────────── + + log_phase("Provision Service"); + + let create_body = ServicePostRequest { + name: ctx.service_name(), + provider: ServicePostRequestProvider::Unknown(ctx.provider.clone()), + region: ServicePostRequestRegion::Unknown(ctx.region.clone()), + min_replica_memory_gb: Some(8.0_f64), + max_replica_memory_gb: Some(8.0_f64), + num_replicas: Some(1.0_f64), + idle_scaling: Some(true), + idle_timeout_minutes: Some(5.0), + tags: Some(ctx.run_tags()), + ..Default::default() + }; + + let created = failures + .run(&ctx, StepKind::Blocking, "create service", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let body = create_body.clone(); + async move { + let resp = client.instance_create(&org_id, &body).await?; + resp.result + .ok_or_else(|| "service create returned no result".into()) + } + }) + .await? + .expect("blocking steps always return a value"); + + let service_id = created.service.id.to_string(); + eprintln!("service_id: "); + cleanup.register_service(service_id.clone()); + + failures + .run( + &ctx, + StepKind::Blocking, + "wait for service steady state", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let timeout = ctx.steady_state_timeout; + let interval = ctx.poll_interval; + async move { + poll_until("service steady state", timeout, interval, || { + let client = client.clone(); + let org_id = org_id.clone(); + let service_id = service_id.clone(); + async move { + let resp = client.instance_get(&org_id, &service_id).await?; + let svc = resp.result.ok_or("service get returned no result")?; + let state = svc.state.to_string(); + if matches!(state.as_str(), "running" | "idle") { + Ok(Some(())) + } else { + Ok(None) + } + } + }) + .await?; + Ok(()) + } + }, + ) + .await?; + + // ── Sources & Webhooks (read-only) ────────────────────────── + // + // These are read-only views into ClickStack state. Treated as + // NonBlocking so an unconfigured org doesn't block the CRUD + // checks that follow. + + log_phase("Sources"); + failures + .run( + &ctx, + StepKind::NonBlocking, + "list clickstack sources", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + async move { + let resp = client + .click_stack_list_sources(&org_id, &service_id) + .await?; + let sources = resp.result.unwrap_or_default(); + eprintln!("found {} clickstack source(s)", sources.len()); + Ok(()) + } + }, + ) + .await?; + + log_phase("Webhooks"); + failures + .run( + &ctx, + StepKind::NonBlocking, + "list clickstack webhooks", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + async move { + let resp = client + .click_stack_list_webhooks(&org_id, &service_id) + .await?; + let webhooks = resp.result.unwrap_or_default(); + eprintln!("found {} clickstack webhook(s)", webhooks.len()); + Ok(()) + } + }, + ) + .await?; + + // ── Dashboard CRUD ────────────────────────────────────────── + + log_phase("Dashboard CRUD"); + + let initial_dashboard_name = format!("ctl-it-dash-{}", ctx.run_id); + let renamed_dashboard_name = format!("{initial_dashboard_name}-renamed"); + + let tile = ClickStackTileInput { + h: 4, + w: 4, + x: 0, + y: 0, + name: "Welcome tile".to_string(), + config: Some(ClickStackTileConfig::ClickStackMarkdownChartConfig( + ClickStackMarkdownChartConfig { + display_type: ClickStackMarkdownChartConfigDisplaytype::Markdown, + markdown: Some("# integration test dashboard".to_string()), + }, + )), + ..Default::default() + }; + + let dashboard_create_body = ClickStackCreateDashboardRequest { + name: initial_dashboard_name.clone(), + tiles: vec![tile.clone()], + tags: Some(vec!["clickhousectl-it".to_string()]), + ..Default::default() + }; + + let dashboard = failures + .run( + &ctx, + StepKind::Blocking, + "create clickstack dashboard", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let body = dashboard_create_body.clone(); + async move { + let resp = client + .click_stack_create_dashboard(&org_id, &service_id, &body) + .await?; + resp.result + .ok_or_else(|| "dashboard create returned no result".into()) + } + }, + ) + .await? + .expect("blocking steps always return a value"); + + assert_eq!(dashboard.name, initial_dashboard_name); + assert!( + !dashboard.id.is_empty(), + "dashboard create returned empty id" + ); + let dashboard_id = dashboard.id.clone(); + let first_tile_id = dashboard + .tiles + .first() + .map(|t| t.id.clone()) + .filter(|id| !id.is_empty()); + + failures + .run(&ctx, StepKind::Blocking, "get clickstack dashboard", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let dashboard_id = dashboard_id.clone(); + let expected_name = initial_dashboard_name.clone(); + async move { + let resp = client + .click_stack_get_dashboard(&org_id, &service_id, &dashboard_id) + .await?; + let dash = resp.result.ok_or("dashboard get returned no result")?; + if dash.id != dashboard_id { + return Err(format!( + "dashboard get returned id {} but expected {dashboard_id}", + dash.id + ) + .into()); + } + if dash.name != expected_name { + return Err(format!( + "dashboard get returned name {} but expected {expected_name}", + dash.name + ) + .into()); + } + Ok(()) + } + }) + .await?; + + failures + .run( + &ctx, + StepKind::Blocking, + "list dashboards includes created", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let dashboard_id = dashboard_id.clone(); + async move { + let resp = client + .click_stack_list_dashboards(&org_id, &service_id) + .await?; + let list = resp.result.ok_or("dashboard list returned no result")?; + if !list.iter().any(|d| d.id == dashboard_id) { + return Err( + "created dashboard was not visible in dashboard list".into() + ); + } + Ok(()) + } + }, + ) + .await?; + + let update_body = ClickStackUpdateDashboardRequest { + name: renamed_dashboard_name.clone(), + tiles: vec![tile.clone()], + tags: Some(vec!["clickhousectl-it".to_string()]), + ..Default::default() + }; + + failures + .run( + &ctx, + StepKind::Blocking, + "update clickstack dashboard (rename)", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let dashboard_id = dashboard_id.clone(); + let body = update_body.clone(); + let expected_name = renamed_dashboard_name.clone(); + async move { + let resp = client + .click_stack_update_dashboard( + &org_id, + &service_id, + &dashboard_id, + &body, + ) + .await?; + let updated = resp.result.ok_or("dashboard update returned no result")?; + if updated.name != expected_name { + return Err(format!( + "dashboard update returned name {} but expected {expected_name}", + updated.name + ) + .into()); + } + Ok(()) + } + }, + ) + .await?; + + // ── Alert CRUD ────────────────────────────────────────────── + + log_phase("Alert CRUD"); + + let alert_name = format!("ctl-it-alert-{}", ctx.run_id); + let alert_create_body = ClickStackCreateAlertRequest { + name: Some(alert_name.clone()), + threshold: 1.0, + threshold_type: ClickStackCreateAlertRequestThresholdtype::Above, + interval: ClickStackCreateAlertRequestInterval::_5m, + source: ClickStackCreateAlertRequestSource::Tile, + dashboard_id: Some(dashboard_id.clone()), + tile_id: first_tile_id.clone(), + channel: ClickStackAlertChannel::ClickStackAlertChannelEmail( + ClickStackAlertChannelEmail { + email_recipients: vec!["clickhousectl-it@example.com".to_string()], + r#type: ClickStackAlertChannelEmailType::Email, + }, + ), + ..Default::default() + }; + + let alert = failures + .run(&ctx, StepKind::Blocking, "create clickstack alert", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let body = alert_create_body.clone(); + async move { + let resp = client + .click_stack_create_alert(&org_id, &service_id, &body) + .await?; + resp.result + .ok_or_else(|| "alert create returned no result".into()) + } + }) + .await? + .expect("blocking steps always return a value"); + + assert!(!alert.id.is_empty(), "alert create returned empty id"); + let alert_id = alert.id.clone(); + + failures + .run(&ctx, StepKind::Blocking, "get clickstack alert", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let alert_id = alert_id.clone(); + async move { + let resp = client + .click_stack_get_alert(&org_id, &service_id, &alert_id) + .await?; + let got = resp.result.ok_or("alert get returned no result")?; + if got.id != alert_id { + return Err(format!( + "alert get returned id {} but expected {alert_id}", + got.id + ) + .into()); + } + Ok(()) + } + }) + .await?; + + failures + .run( + &ctx, + StepKind::Blocking, + "list alerts includes created", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let alert_id = alert_id.clone(); + async move { + let resp = client.click_stack_list_alerts(&org_id, &service_id).await?; + let list = resp.result.ok_or("alert list returned no result")?; + if !list.iter().any(|a| a.id == alert_id) { + return Err("created alert was not visible in alert list".into()); + } + Ok(()) + } + }, + ) + .await?; + + let alert_update_body = ClickStackUpdateAlertRequest { + name: Some(alert_name.clone()), + threshold: 5.0, + threshold_type: ClickStackUpdateAlertRequestThresholdtype::Above, + interval: ClickStackUpdateAlertRequestInterval::_5m, + source: ClickStackUpdateAlertRequestSource::Tile, + dashboard_id: Some(dashboard_id.clone()), + tile_id: first_tile_id.clone(), + channel: ClickStackAlertChannel::ClickStackAlertChannelEmail( + ClickStackAlertChannelEmail { + email_recipients: vec!["clickhousectl-it@example.com".to_string()], + r#type: ClickStackAlertChannelEmailType::Email, + }, + ), + ..Default::default() + }; + + failures + .run( + &ctx, + StepKind::Blocking, + "update clickstack alert (raise threshold)", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let alert_id = alert_id.clone(); + let body = alert_update_body.clone(); + async move { + let resp = client + .click_stack_update_alert(&org_id, &service_id, &alert_id, &body) + .await?; + let updated = resp.result.ok_or("alert update returned no result")?; + if updated.threshold != 5.0 { + return Err(format!( + "alert update returned threshold {} but expected 5.0", + updated.threshold + ) + .into()); + } + Ok(()) + } + }, + ) + .await?; + + failures + .run(&ctx, StepKind::Blocking, "delete clickstack alert", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let alert_id = alert_id.clone(); + async move { + client + .click_stack_delete_alert(&org_id, &service_id, &alert_id) + .await?; + Ok(()) + } + }) + .await?; + + failures + .run( + &ctx, + StepKind::Blocking, + "delete clickstack dashboard", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let dashboard_id = dashboard_id.clone(); + async move { + client + .click_stack_delete_dashboard(&org_id, &service_id, &dashboard_id) + .await?; + Ok(()) + } + }, + ) + .await?; + + // ── Teardown service ──────────────────────────────────────── + + log_phase("Delete"); + failures + .run( + &ctx, + StepKind::Blocking, + "stop service before delete", + || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + let timeout = ctx.create_timeout; + let interval = ctx.poll_interval; + async move { + client + .instance_state_update( + &org_id, + &service_id, + &ServiceStatePatchRequest { + command: Some(ServiceStatePatchRequestCommand::Stop), + }, + ) + .await?; + poll_until("service stopped for delete", timeout, interval, || { + let client = client.clone(); + let org_id = org_id.clone(); + let service_id = service_id.clone(); + async move { + let resp = client.instance_get(&org_id, &service_id).await?; + let svc = resp.result.ok_or("service get returned no result")?; + let state = svc.state.to_string(); + if matches!(state.as_str(), "idle" | "stopped") { + Ok(Some(())) + } else { + Ok(None) + } + } + }) + .await?; + Ok(()) + } + }, + ) + .await?; + + failures + .run(&ctx, StepKind::Blocking, "delete service", || { + let client = client.clone(); + let org_id = ctx.org_id.clone(); + let service_id = service_id.clone(); + async move { + client.instance_delete(&org_id, &service_id).await?; + Ok(()) + } + }) + .await?; + cleanup.unregister_service(&service_id); + + failures.finish() + } + .await; + + let cleanup_result = cleanup + .cleanup(&client, &ctx.org_id, ctx.delete_timeout, ctx.poll_interval) + .await; + + match (test_result, cleanup_result) { + (Ok(()), Ok(())) => Ok(()), + (Err(error), Ok(())) => Err(error), + (Ok(()), Err(cleanup_error)) => Err(cleanup_error.into()), + (Err(error), Err(cleanup_error)) => { + Err(format!("{error}\ncleanup failed:\n{cleanup_error}").into()) + } + } +} diff --git a/crates/clickhouse-cloud-api/tests/models_test.rs b/crates/clickhouse-cloud-api/tests/models_test.rs index 4c61df6..b408e0f 100644 --- a/crates/clickhouse-cloud-api/tests/models_test.rs +++ b/crates/clickhouse-cloud-api/tests/models_test.rs @@ -352,8 +352,7 @@ fn service_state_enum_roundtrip() { ("idle", ServiceState::Idle), ]; for (json_val, expected) in states { - let parsed: ServiceState = - serde_json::from_str(&format!(r#""{json_val}""#)).unwrap(); + let parsed: ServiceState = serde_json::from_str(&format!(r#""{json_val}""#)).unwrap(); assert_eq!(parsed, expected); let serialized = serde_json::to_string(&expected).unwrap(); @@ -380,8 +379,7 @@ fn clickpipe_state_all_variants() { "Resync", ]; for s in states { - let parsed: ClickPipeState = - serde_json::from_str(&format!(r#""{s}""#)).unwrap(); + let parsed: ClickPipeState = serde_json::from_str(&format!(r#""{s}""#)).unwrap(); let serialized = serde_json::to_string(&parsed).unwrap(); assert_eq!(serialized, format!(r#""{s}""#)); } @@ -425,7 +423,10 @@ fn unknown_enum_variant_deserializes() { // An unknown service state from the API should deserialize into Unknown(String) let json = r#"{"state": "brand-new-state"}"#; let svc: Service = serde_json::from_str(json).unwrap(); - assert_eq!(svc.state, ServiceState::Unknown("brand-new-state".to_string())); + assert_eq!( + svc.state, + ServiceState::Unknown("brand-new-state".to_string()) + ); } #[test] @@ -447,7 +448,10 @@ fn known_enum_variant_still_deserializes() { #[test] fn unknown_enum_display() { assert_eq!(ServiceState::Running.to_string(), "running"); - assert_eq!(ServiceState::Unknown("brand-new".to_string()).to_string(), "brand-new"); + assert_eq!( + ServiceState::Unknown("brand-new".to_string()).to_string(), + "brand-new" + ); } // =========================================================================== @@ -895,7 +899,8 @@ fn clickpipe_minimal_response() { #[test] fn postgres_service_minimal_response() { - let pg: PostgresService = serde_json::from_str(r#"{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}"#).unwrap(); + let pg: PostgresService = + serde_json::from_str(r#"{"id":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}"#).unwrap(); assert_eq!(pg.name, ""); assert_eq!(pg.state, PgStateProperty::default()); } @@ -1055,7 +1060,10 @@ fn deserialize_reverse_private_endpoint() { }"#; let rpe: ReversePrivateEndpoint = serde_json::from_str(json).unwrap(); assert_eq!(rpe.description, "MSK endpoint"); - assert_eq!(rpe.status, ReversePrivateEndpointStatus::Other("available".to_string())); + assert_eq!( + rpe.status, + ReversePrivateEndpointStatus::Other("available".to_string()) + ); } #[test] @@ -1099,3 +1107,168 @@ fn deserialize_clickpipe_scaling() { assert_eq!(s.replicas, 3); assert_eq!(s.concurrency, 2); } + +// =========================================================================== +// ClickStack — round-trip tests for the untagged-enum-bearing types. +// These are the highest-risk shapes: the wrong variant would silently +// match if the discriminating fields are absent or shared. +// =========================================================================== + +#[test] +fn clickstack_create_alert_request_email_channel_roundtrip() { + let req = ClickStackCreateAlertRequest { + name: Some("low-error-rate".to_string()), + threshold: 5.0, + threshold_type: ClickStackCreateAlertRequestThresholdtype::default(), + interval: ClickStackCreateAlertRequestInterval::default(), + source: ClickStackCreateAlertRequestSource::default(), + channel: ClickStackAlertChannel::ClickStackAlertChannelEmail(ClickStackAlertChannelEmail { + email_recipients: vec!["oncall@example.com".to_string()], + r#type: ClickStackAlertChannelEmailType::Email, + }), + ..Default::default() + }; + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["channel"]["type"], "email"); + assert_eq!(json["channel"]["emailRecipients"][0], "oncall@example.com"); + assert!(json["channel"].get("webhookId").is_none()); + + let back: ClickStackCreateAlertRequest = serde_json::from_value(json).unwrap(); + assert!(matches!( + back.channel, + ClickStackAlertChannel::ClickStackAlertChannelEmail(_) + )); +} + +#[test] +fn clickstack_create_alert_request_webhook_channel_roundtrip() { + let req = ClickStackCreateAlertRequest { + name: Some("disk-pressure".to_string()), + threshold: 90.0, + channel: ClickStackAlertChannel::ClickStackAlertChannelWebhook( + ClickStackAlertChannelWebhook { + webhook_id: "wh-123".to_string(), + webhook_service: Some("slack".to_string()), + severity: Some(ClickStackAlertChannelWebhookSeverity::Critical), + slack_channel_id: Some("C123".to_string()), + r#type: ClickStackAlertChannelWebhookType::Webhook, + }, + ), + ..Default::default() + }; + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["channel"]["type"], "webhook"); + assert_eq!(json["channel"]["webhookId"], "wh-123"); + assert_eq!(json["channel"]["severity"], "critical"); + + let back: ClickStackCreateAlertRequest = serde_json::from_value(json).unwrap(); + assert!(matches!( + back.channel, + ClickStackAlertChannel::ClickStackAlertChannelWebhook(_) + )); +} + +#[test] +fn clickstack_alert_channel_untagged_disambiguation() { + // Email payload — missing `webhookId`, so must match the email variant. + let email_json = r#"{"type":"email","emailRecipients":["a@b.com"]}"#; + let email: ClickStackAlertChannel = serde_json::from_str(email_json).unwrap(); + assert!(matches!( + email, + ClickStackAlertChannel::ClickStackAlertChannelEmail(_) + )); + + // Webhook payload — has `webhookId`, must match the webhook variant. + let webhook_json = r#"{"type":"webhook","webhookId":"wh-1"}"#; + let webhook: ClickStackAlertChannel = serde_json::from_str(webhook_json).unwrap(); + assert!(matches!( + webhook, + ClickStackAlertChannel::ClickStackAlertChannelWebhook(_) + )); +} + +#[test] +fn clickstack_create_dashboard_request_minimal_roundtrip() { + let req = ClickStackCreateDashboardRequest { + name: "smoke".to_string(), + tiles: vec![], + ..Default::default() + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["name"], "smoke"); + assert_eq!(json["tiles"].as_array().unwrap().len(), 0); + + let back: ClickStackCreateDashboardRequest = serde_json::from_value(json).unwrap(); + assert_eq!(back.name, "smoke"); +} + +#[test] +fn clickstack_create_dashboard_request_with_markdown_tile_roundtrip() { + let tile = ClickStackTileInput { + h: 4, + w: 4, + x: 0, + y: 0, + name: "Welcome".to_string(), + config: Some(ClickStackTileConfig::ClickStackMarkdownChartConfig( + ClickStackMarkdownChartConfig { + display_type: ClickStackMarkdownChartConfigDisplaytype::Markdown, + markdown: Some("# Hello".to_string()), + }, + )), + ..Default::default() + }; + let req = ClickStackCreateDashboardRequest { + name: "Markdown dash".to_string(), + tiles: vec![tile], + tags: Some(vec!["smoke".to_string()]), + ..Default::default() + }; + + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["tiles"][0]["config"]["displayType"], "markdown"); + assert_eq!(json["tiles"][0]["config"]["markdown"], "# Hello"); + + let back: ClickStackCreateDashboardRequest = serde_json::from_value(json).unwrap(); + assert_eq!(back.tiles.len(), 1); + assert!(matches!( + back.tiles[0].config, + Some(ClickStackTileConfig::ClickStackMarkdownChartConfig(_)) + )); +} + +#[test] +fn clickstack_source_dispatches_log_variant() { + let json = r#"{ + "id": "src-1", + "name": "logs", + "kind": "log", + "connection": "default", + "defaultTableSelectExpression": "*", + "timestampValueExpression": "TimestampTime", + "from": {"databaseName": "default", "tableName": "otel_logs"} + }"#; + let src: ClickStackSource = serde_json::from_str(json).unwrap(); + assert!(matches!(src, ClickStackSource::ClickStackLogSource(_))); + if let ClickStackSource::ClickStackLogSource(log) = src { + assert_eq!(log.id, "src-1"); + assert_eq!(log.connection, "default"); + } +} + +#[test] +fn clickstack_webhook_dispatches_slack_variant() { + let json = r#"{ + "id": "wh-1", + "name": "oncall-slack", + "service": "slack", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-02T00:00:00Z" + }"#; + let wh: ClickStackWebhook = serde_json::from_str(json).unwrap(); + // Slack is the first variant the untagged enum tries — anything with a + // matching shape lands here. + assert!(matches!(wh, ClickStackWebhook::ClickStackSlackWebhook(_))); +} diff --git a/crates/clickhousectl/Cargo.toml b/crates/clickhousectl/Cargo.toml index d265835..b5d5846 100644 --- a/crates/clickhousectl/Cargo.toml +++ b/crates/clickhousectl/Cargo.toml @@ -13,7 +13,7 @@ thiserror = "2" dirs = "6" indicatif = "0.18" futures-util = "0.3" -rpassword = "7" +rpassword = "7.5.2" libc = "0.2.183" urlencoding = "2.1.3" flate2 = "1.1.9" diff --git a/crates/clickhousectl/src/cli.rs b/crates/clickhousectl/src/cli.rs index 09ade42..83383aa 100644 --- a/crates/clickhousectl/src/cli.rs +++ b/crates/clickhousectl/src/cli.rs @@ -1,9 +1,10 @@ use clap::{Args, Parser, Subcommand}; pub use crate::cloud::cli::{ - ActivityCommands, AuthCommands, BackupCommands, BackupConfigCommands, CloudArgs, CloudCommands, - InvitationCommands, KeyCommands, MemberCommands, OrgCommands, PrivateEndpointCommands, - QueryEndpointCommands, ServiceCommands, + ActivityCommands, AlertCommands, AuthCommands, BackupCommands, BackupConfigCommands, + ClickStackCommands, CloudArgs, CloudCommands, DashboardCommands, InvitationCommands, + KeyCommands, MemberCommands, OrgCommands, PrivateEndpointCommands, QueryEndpointCommands, + ServiceCommands, SourceCommands, WebhookCommands, }; pub use crate::cloud::postgres::{ CertsCommands as PostgresCertsCommands, ConfigCommands as PostgresConfigCommands, diff --git a/crates/clickhousectl/src/cloud/cli.rs b/crates/clickhousectl/src/cloud/cli.rs index b8d8cd8..15f0ef4 100644 --- a/crates/clickhousectl/src/cloud/cli.rs +++ b/crates/clickhousectl/src/cloud/cli.rs @@ -187,6 +187,20 @@ CONTEXT FOR AGENTS: #[command(subcommand)] command: crate::cloud::postgres::PostgresCommands, }, + + /// ClickStack commands (dashboards, alerts, sources, webhooks) + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Manage ClickStack resources scoped to a service. Subcommands: dashboard, alert, + source (list-only), webhook (list-only). + Every subcommand takes a service ID positionally — get one from `clickhousectl cloud service list`. + Dashboard create/update use --from-file JSON (the tile layout is too nested for flags). + Alert create/update are flag-based; --channel-type selects email or webhook. + Write commands require API key auth — OAuth is read-only.")] + Clickstack { + #[command(subcommand)] + command: ClickStackCommands, + }, } impl CloudCommands { @@ -261,6 +275,26 @@ impl CloudCommands { ActivityCommands::Get { .. } => false, }, CloudCommands::Postgres { command } => command.is_write(), + CloudCommands::Clickstack { command } => match command { + ClickStackCommands::Dashboard { command } => match command { + DashboardCommands::List { .. } | DashboardCommands::Get { .. } => false, + DashboardCommands::Create { .. } + | DashboardCommands::Update { .. } + | DashboardCommands::Delete { .. } => true, + }, + ClickStackCommands::Alert { command } => match command { + AlertCommands::List { .. } | AlertCommands::Get { .. } => false, + AlertCommands::Create { .. } + | AlertCommands::Update { .. } + | AlertCommands::Delete { .. } => true, + }, + ClickStackCommands::Source { command } => match command { + SourceCommands::List { .. } => false, + }, + ClickStackCommands::Webhook { command } => match command { + WebhookCommands::List { .. } => false, + }, + }, } } } @@ -843,7 +877,6 @@ pub enum PrivateEndpointCommands { }, } - #[derive(Subcommand)] pub enum BackupCommands { /// List backups for a service @@ -882,6 +915,371 @@ CONTEXT FOR AGENTS: }, } +#[derive(Subcommand)] +// `AlertCommands::Create` has ~17 fields, so the enum is wider than its +// sibling sub-resources. Boxing each subcommand would scatter `Box<...>` +// through main.rs dispatch without practical benefit — this enum is held +// once at parse time and discarded. +#[allow(clippy::large_enum_variant)] +pub enum ClickStackCommands { + /// Manage ClickStack dashboards + Dashboard { + #[command(subcommand)] + command: DashboardCommands, + }, + /// Manage ClickStack alerts + Alert { + #[command(subcommand)] + command: AlertCommands, + }, + /// View ClickStack sources + Source { + #[command(subcommand)] + command: SourceCommands, + }, + /// View ClickStack webhooks + Webhook { + #[command(subcommand)] + command: WebhookCommands, + }, +} + +#[derive(Subcommand)] +pub enum DashboardCommands { + /// List dashboards for a service + List { + /// Service ID + service_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Get a dashboard by ID + Get { + /// Service ID + service_id: String, + + /// Dashboard ID + dashboard_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Create a dashboard from a JSON file (use "-" for stdin) + #[command(after_help = "\ +CONTEXT FOR AGENTS: + JSON body must match the `ClickStackCreateDashboardRequest` schema: + {\"name\": \"...\", \"tiles\": [...], \"tags\": [\"...\"]} + Override --name or append --tag values to bypass editing the JSON.")] + Create { + /// Service ID + service_id: String, + + /// Path to a JSON file with the dashboard definition (use "-" for stdin) + #[arg(long, value_name = "PATH")] + from_file: String, + + /// Override the `name` field from the JSON file + #[arg(long)] + name: Option, + + /// Additional tag to append to the JSON file's tags (repeatable) + #[arg(long = "tag", value_name = "TAG")] + tags: Vec, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Update a dashboard from a JSON file (use "-" for stdin) + Update { + /// Service ID + service_id: String, + + /// Dashboard ID + dashboard_id: String, + + /// Path to a JSON file with the dashboard definition (use "-" for stdin) + #[arg(long, value_name = "PATH")] + from_file: String, + + /// Override the `name` field from the JSON file + #[arg(long)] + name: Option, + + /// Additional tag to append to the JSON file's tags (repeatable) + #[arg(long = "tag", value_name = "TAG")] + tags: Vec, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Delete a dashboard + Delete { + /// Service ID + service_id: String, + + /// Dashboard ID + dashboard_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, +} + +#[derive(Subcommand)] +pub enum AlertCommands { + /// List alerts for a service + List { + /// Service ID + service_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Get an alert by ID + Get { + /// Service ID + service_id: String, + + /// Alert ID + alert_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Create an alert + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Source values: saved_search (requires --saved-search-id) or tile (requires --dashboard-id and --tile-id). + Channel: --channel-type email (requires --email, repeatable) or webhook (requires --webhook-id). + Intervals: 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d.")] + Create { + /// Service ID + service_id: String, + + /// Alert name + #[arg(long)] + name: Option, + + /// Threshold value + #[arg(long)] + threshold: f64, + + /// Upper-bound threshold (optional companion to --threshold) + #[arg(long)] + threshold_max: Option, + + /// Threshold direction + #[arg(long, value_name = "above|below")] + threshold_type: String, + + /// Polling interval (e.g. 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d) + #[arg(long)] + interval: String, + + /// Alert source: saved_search or tile + #[arg(long, value_name = "saved_search|tile")] + source: String, + + /// Group-by expression + #[arg(long)] + group_by: Option, + + /// Custom notification message + #[arg(long)] + message: Option, + + /// Dashboard ID (required when --source=tile) + #[arg(long)] + dashboard_id: Option, + + /// Tile ID (required when --source=tile) + #[arg(long)] + tile_id: Option, + + /// Saved search ID (required when --source=saved_search) + #[arg(long)] + saved_search_id: Option, + + /// Schedule offset in minutes + #[arg(long)] + schedule_offset_minutes: Option, + + /// Schedule start time (RFC3339) + #[arg(long, value_parser = parse_datetime)] + schedule_start_at: Option, + + /// Channel type + #[arg(long, value_name = "email|webhook")] + channel_type: String, + + /// Email recipient (repeatable, required when --channel-type=email) + #[arg(long = "email", value_name = "ADDR")] + emails: Vec, + + /// Webhook ID (required when --channel-type=webhook) + #[arg(long)] + webhook_id: Option, + + /// Webhook service identifier + #[arg(long)] + webhook_service: Option, + + /// Webhook alert severity (critical, error, warning, info) + #[arg(long)] + severity: Option, + + /// Slack channel id (Slack webhooks only) + #[arg(long)] + slack_channel_id: Option, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Update an alert (same flag shape as create) + Update { + /// Service ID + service_id: String, + + /// Alert ID + alert_id: String, + + /// Alert name + #[arg(long)] + name: Option, + + /// Threshold value + #[arg(long)] + threshold: f64, + + /// Upper-bound threshold (optional companion to --threshold) + #[arg(long)] + threshold_max: Option, + + /// Threshold direction + #[arg(long, value_name = "above|below")] + threshold_type: String, + + /// Polling interval (e.g. 1m, 5m, 15m, 30m, 1h, 6h, 12h, 1d) + #[arg(long)] + interval: String, + + /// Alert source: saved_search or tile + #[arg(long, value_name = "saved_search|tile")] + source: String, + + /// Group-by expression + #[arg(long)] + group_by: Option, + + /// Custom notification message + #[arg(long)] + message: Option, + + /// Dashboard ID (required when --source=tile) + #[arg(long)] + dashboard_id: Option, + + /// Tile ID (required when --source=tile) + #[arg(long)] + tile_id: Option, + + /// Saved search ID (required when --source=saved_search) + #[arg(long)] + saved_search_id: Option, + + /// Schedule offset in minutes + #[arg(long)] + schedule_offset_minutes: Option, + + /// Schedule start time (RFC3339) + #[arg(long, value_parser = parse_datetime)] + schedule_start_at: Option, + + /// Channel type + #[arg(long, value_name = "email|webhook")] + channel_type: String, + + /// Email recipient (repeatable, required when --channel-type=email) + #[arg(long = "email", value_name = "ADDR")] + emails: Vec, + + /// Webhook ID (required when --channel-type=webhook) + #[arg(long)] + webhook_id: Option, + + /// Webhook service identifier + #[arg(long)] + webhook_service: Option, + + /// Webhook alert severity (critical, error, warning, info) + #[arg(long)] + severity: Option, + + /// Slack channel id (Slack webhooks only) + #[arg(long)] + slack_channel_id: Option, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, + + /// Delete an alert + Delete { + /// Service ID + service_id: String, + + /// Alert ID + alert_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, +} + +#[derive(Subcommand)] +pub enum SourceCommands { + /// List ClickStack sources for a service + List { + /// Service ID + service_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, +} + +#[derive(Subcommand)] +pub enum WebhookCommands { + /// List ClickStack webhooks for a service + List { + /// Service ID + service_id: String, + + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, + }, +} + #[derive(Subcommand)] pub enum MemberCommands { /// List organization members @@ -1587,21 +1985,69 @@ mod tests { // Org reads assert_write(&["clickhousectl", "cloud", "org", "list"], false); assert_write(&["clickhousectl", "cloud", "org", "get", "org-1"], false); - assert_write(&["clickhousectl", "cloud", "org", "prometheus", "org-1"], false); - assert_write(&["clickhousectl", "cloud", "org", "usage", "org-1", "--from-date", "2025-01-01", "--to-date", "2025-01-31"], false); + assert_write( + &["clickhousectl", "cloud", "org", "prometheus", "org-1"], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "org", + "usage", + "org-1", + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ], + false, + ); // Service reads assert_write(&["clickhousectl", "cloud", "service", "list"], false); - assert_write(&["clickhousectl", "cloud", "service", "get", "svc-1"], false); - assert_write(&["clickhousectl", "cloud", "service", "client", "--id", "svc-1"], false); - assert_write(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"], false); + assert_write( + &["clickhousectl", "cloud", "service", "get", "svc-1"], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "client", + "--id", + "svc-1", + ], + false, + ); + assert_write( + &["clickhousectl", "cloud", "service", "prometheus", "svc-1"], + false, + ); // Backup reads - assert_write(&["clickhousectl", "cloud", "backup", "list", "svc-1"], false); - assert_write(&["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"], false); + assert_write( + &["clickhousectl", "cloud", "backup", "list", "svc-1"], + false, + ); + assert_write( + &["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"], + false, + ); // Backup config read - assert_write(&["clickhousectl", "cloud", "service", "backup-config", "get", "svc-1"], false); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "backup-config", + "get", + "svc-1", + ], + false, + ); // Member reads assert_write(&["clickhousectl", "cloud", "member", "list"], false); @@ -1609,7 +2055,10 @@ mod tests { // Invitation reads assert_write(&["clickhousectl", "cloud", "invitation", "list"], false); - assert_write(&["clickhousectl", "cloud", "invitation", "get", "inv-1"], false); + assert_write( + &["clickhousectl", "cloud", "invitation", "get", "inv-1"], + false, + ); // Key reads assert_write(&["clickhousectl", "cloud", "key", "list"], false); @@ -1617,69 +2066,532 @@ mod tests { // Activity reads assert_write(&["clickhousectl", "cloud", "activity", "list"], false); - assert_write(&["clickhousectl", "cloud", "activity", "get", "act-1"], false); + assert_write( + &["clickhousectl", "cloud", "activity", "get", "act-1"], + false, + ); // Query endpoint read - assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "get", "svc-1"], false); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "get", + "svc-1", + ], + false, + ); // Private endpoint read - assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "get-config", "svc-1"], false); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "get-config", + "svc-1", + ], + false, + ); // Postgres reads assert_write(&["clickhousectl", "cloud", "postgres", "list"], false); - assert_write(&["clickhousectl", "cloud", "postgres", "get", "pg-1"], false); - assert_write(&["clickhousectl", "cloud", "postgres", "certs", "get", "pg-1"], false); - assert_write(&["clickhousectl", "cloud", "postgres", "config", "get", "pg-1"], false); + assert_write( + &["clickhousectl", "cloud", "postgres", "get", "pg-1"], + false, + ); + assert_write( + &["clickhousectl", "cloud", "postgres", "certs", "get", "pg-1"], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "config", + "get", + "pg-1", + ], + false, + ); + + // ClickStack reads + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "list", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "get", + "svc-1", + "dash-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "list", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "get", + "svc-1", + "alert-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "source", + "list", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "webhook", + "list", + "svc-1", + ], + false, + ); } #[test] fn is_write_command_destructive_commands() { // Org write - assert_write(&["clickhousectl", "cloud", "org", "update", "org-1", "--name", "new"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "org", + "update", + "org-1", + "--name", + "new", + ], + true, + ); // Service writes - assert_write(&["clickhousectl", "cloud", "service", "create", "--name", "s", "--provider", "aws", "--region", "us-east-1"], true); - assert_write(&["clickhousectl", "cloud", "service", "delete", "svc-1"], true); - assert_write(&["clickhousectl", "cloud", "service", "start", "svc-1"], true); - assert_write(&["clickhousectl", "cloud", "service", "stop", "svc-1"], true); - assert_write(&["clickhousectl", "cloud", "service", "update", "svc-1", "--name", "new"], true); - assert_write(&["clickhousectl", "cloud", "service", "scale", "svc-1", "--num-replicas", "2"], true); - assert_write(&["clickhousectl", "cloud", "service", "reset-password", "svc-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "s", + "--provider", + "aws", + "--region", + "us-east-1", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "service", "delete", "svc-1"], + true, + ); + assert_write( + &["clickhousectl", "cloud", "service", "start", "svc-1"], + true, + ); + assert_write( + &["clickhousectl", "cloud", "service", "stop", "svc-1"], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--name", + "new", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "scale", + "svc-1", + "--num-replicas", + "2", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "reset-password", + "svc-1", + ], + true, + ); // Backup config write - assert_write(&["clickhousectl", "cloud", "service", "backup-config", "update", "svc-1", "--backup-period-hours", "12"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "backup-config", + "update", + "svc-1", + "--backup-period-hours", + "12", + ], + true, + ); // Member writes - assert_write(&["clickhousectl", "cloud", "member", "update", "usr-1", "--role-id", "r1"], true); - assert_write(&["clickhousectl", "cloud", "member", "remove", "usr-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "member", + "update", + "usr-1", + "--role-id", + "r1", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "member", "remove", "usr-1"], + true, + ); // Invitation writes - assert_write(&["clickhousectl", "cloud", "invitation", "create", "--email", "a@b.com", "--role-id", "r1"], true); - assert_write(&["clickhousectl", "cloud", "invitation", "delete", "inv-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "invitation", + "create", + "--email", + "a@b.com", + "--role-id", + "r1", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "invitation", "delete", "inv-1"], + true, + ); // Key writes - assert_write(&["clickhousectl", "cloud", "key", "create", "--name", "k"], true); - assert_write(&["clickhousectl", "cloud", "key", "update", "key-1", "--name", "new"], true); + assert_write( + &["clickhousectl", "cloud", "key", "create", "--name", "k"], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "key", + "update", + "key-1", + "--name", + "new", + ], + true, + ); assert_write(&["clickhousectl", "cloud", "key", "delete", "key-1"], true); // Query endpoint writes - assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "create", "svc-1"], true); - assert_write(&["clickhousectl", "cloud", "service", "query-endpoint", "delete", "svc-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "create", + "svc-1", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "delete", + "svc-1", + ], + true, + ); // Private endpoint write - assert_write(&["clickhousectl", "cloud", "service", "private-endpoint", "create", "svc-1", "--endpoint-id", "ep-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + "ep-1", + ], + true, + ); // Postgres writes - assert_write(&["clickhousectl", "cloud", "postgres", "create", "--name", "pg", "--region", "us-east-1", "--size", "m7i.2xlarge", "--storage-gb", "100"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "update", "pg-1", "--name", "renamed"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "delete", "pg-1"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "config", "replace", "pg-1", "--file", "/tmp/c.json"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "config", "patch", "pg-1", "--set", "max_connections=500"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "reset-password", "pg-1", "--generate"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "read-replica", "create", "pg-1", "--name", "r1"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "restore", "pg-1", "--name", "r", "--restore-target", "2026-04-16T12:00:00Z"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "restart", "pg-1"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "promote", "pg-1"], true); - assert_write(&["clickhousectl", "cloud", "postgres", "switchover", "pg-1"], true); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "create", + "--name", + "pg", + "--region", + "us-east-1", + "--size", + "m7i.2xlarge", + "--storage-gb", + "100", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "update", + "pg-1", + "--name", + "renamed", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "postgres", "delete", "pg-1"], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "config", + "replace", + "pg-1", + "--file", + "/tmp/c.json", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "config", + "patch", + "pg-1", + "--set", + "max_connections=500", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "reset-password", + "pg-1", + "--generate", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "read-replica", + "create", + "pg-1", + "--name", + "r1", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "postgres", + "restore", + "pg-1", + "--name", + "r", + "--restore-target", + "2026-04-16T12:00:00Z", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "postgres", "restart", "pg-1"], + true, + ); + assert_write( + &["clickhousectl", "cloud", "postgres", "promote", "pg-1"], + true, + ); + assert_write( + &["clickhousectl", "cloud", "postgres", "switchover", "pg-1"], + true, + ); + + // ClickStack writes + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "create", + "svc-1", + "--from-file", + "/tmp/d.json", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "update", + "svc-1", + "dash-1", + "--from-file", + "/tmp/d.json", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "delete", + "svc-1", + "dash-1", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "create", + "svc-1", + "--threshold", + "1", + "--threshold-type", + "above", + "--interval", + "5m", + "--source", + "saved_search", + "--saved-search-id", + "ss-1", + "--channel-type", + "email", + "--email", + "a@b.com", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "update", + "svc-1", + "al-1", + "--threshold", + "1", + "--threshold-type", + "above", + "--interval", + "5m", + "--source", + "saved_search", + "--saved-search-id", + "ss-1", + "--channel-type", + "email", + "--email", + "a@b.com", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "delete", + "svc-1", + "al-1", + ], + true, + ); } } diff --git a/crates/clickhousectl/src/cloud/clickstack.rs b/crates/clickhousectl/src/cloud/clickstack.rs new file mode 100644 index 0000000..38430a6 --- /dev/null +++ b/crates/clickhousectl/src/cloud/clickstack.rs @@ -0,0 +1,946 @@ +use crate::cloud::client::CloudClient; +use crate::cloud::commands::{parse_serde_enum, resolve_org_id}; +use chrono::{DateTime, Utc}; +use clickhouse_cloud_api::models::{ + ClickStackAlertChannel, ClickStackAlertChannelEmail, ClickStackAlertChannelEmailType, + ClickStackAlertChannelWebhook, ClickStackAlertChannelWebhookSeverity, + ClickStackAlertChannelWebhookType, ClickStackAlertResponse, ClickStackCreateAlertRequest, + ClickStackCreateAlertRequestInterval, ClickStackCreateAlertRequestSource, + ClickStackCreateAlertRequestThresholdtype, ClickStackCreateDashboardRequest, + ClickStackDashboardResponse, ClickStackUpdateAlertRequest, + ClickStackUpdateAlertRequestInterval, ClickStackUpdateAlertRequestSource, + ClickStackUpdateAlertRequestThresholdtype, ClickStackUpdateDashboardRequest, +}; +use std::io::Read; +use tabled::{Table, Tabled, settings::Style}; + +const KNOWN_INTERVALS: &[&str] = &["1m", "5m", "15m", "30m", "1h", "6h", "12h", "1d"]; +const KNOWN_ALERT_SOURCES: &[&str] = &["saved_search", "tile"]; +const KNOWN_THRESHOLD_TYPES: &[&str] = &["above", "below"]; +const KNOWN_CHANNEL_TYPES: &[&str] = &["email", "webhook"]; +const KNOWN_WEBHOOK_SEVERITIES: &[&str] = &["critical", "error", "warning", "info"]; + +// =========================================================================== +// Dashboards +// =========================================================================== + +pub async fn clickstack_dashboard_list( + client: &CloudClient, + service_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let dashboards = client + .clickstack_list_dashboards(&org_id, service_id) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&dashboards)?); + } else if dashboards.is_empty() { + println!("No dashboards found"); + } else { + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Tags")] + tags: String, + #[tabled(rename = "Tiles")] + tiles: usize, + } + let rows: Vec = dashboards + .into_iter() + .map(|d| Row { + id: d.id, + name: d.name, + tags: d.tags.join(", "), + tiles: d.tiles.len(), + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +pub async fn clickstack_dashboard_get( + client: &CloudClient, + service_id: &str, + dashboard_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let dashboard = client + .clickstack_get_dashboard(&org_id, service_id, dashboard_id) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&dashboard)?); + } else { + print_dashboard(&dashboard); + } + Ok(()) +} + +pub struct DashboardWriteArgs<'a> { + pub from_file: &'a str, + pub name_override: Option<&'a str>, + pub tag_overrides: &'a [String], +} + +pub async fn clickstack_dashboard_create( + client: &CloudClient, + service_id: &str, + args: DashboardWriteArgs<'_>, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let request = + load_dashboard_create_request(args.from_file, args.name_override, args.tag_overrides)?; + let dashboard = client + .clickstack_create_dashboard(&org_id, service_id, &request) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&dashboard)?); + } else { + println!("Dashboard created"); + print_dashboard(&dashboard); + } + Ok(()) +} + +pub async fn clickstack_dashboard_update( + client: &CloudClient, + service_id: &str, + dashboard_id: &str, + args: DashboardWriteArgs<'_>, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let request = + load_dashboard_update_request(args.from_file, args.name_override, args.tag_overrides)?; + let dashboard = client + .clickstack_update_dashboard(&org_id, service_id, dashboard_id, &request) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&dashboard)?); + } else { + println!("Dashboard updated"); + print_dashboard(&dashboard); + } + Ok(()) +} + +pub async fn clickstack_dashboard_delete( + client: &CloudClient, + service_id: &str, + dashboard_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let response = client + .clickstack_delete_dashboard(&org_id, service_id, dashboard_id) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Dashboard deleted: {dashboard_id}"); + } + Ok(()) +} + +// =========================================================================== +// Alerts +// =========================================================================== + +#[allow(clippy::too_many_arguments)] +pub struct AlertCreateArgs<'a> { + pub name: Option<&'a str>, + pub threshold: f64, + pub threshold_max: Option, + pub threshold_type: &'a str, + pub interval: &'a str, + pub source: &'a str, + pub group_by: Option<&'a str>, + pub message: Option<&'a str>, + pub dashboard_id: Option<&'a str>, + pub tile_id: Option<&'a str>, + pub saved_search_id: Option<&'a str>, + pub schedule_offset_minutes: Option, + pub schedule_start_at: Option<&'a str>, + pub channel_type: &'a str, + pub emails: &'a [String], + pub webhook_id: Option<&'a str>, + pub webhook_service: Option<&'a str>, + pub severity: Option<&'a str>, + pub slack_channel_id: Option<&'a str>, +} + +pub async fn clickstack_alert_list( + client: &CloudClient, + service_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let alerts = client.clickstack_list_alerts(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&alerts)?); + } else if alerts.is_empty() { + println!("No alerts found"); + } else { + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Source")] + source: String, + #[tabled(rename = "Threshold")] + threshold: String, + #[tabled(rename = "Interval")] + interval: String, + #[tabled(rename = "Channel")] + channel: String, + } + let rows: Vec = alerts + .into_iter() + .map(|a| Row { + id: a.id, + name: a.name.unwrap_or_default(), + source: a.source.to_string(), + threshold: format!("{} {}", a.threshold_type, a.threshold), + interval: a.interval.to_string(), + channel: summarise_channel(&a.channel), + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +pub async fn clickstack_alert_get( + client: &CloudClient, + service_id: &str, + alert_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let alert = client + .clickstack_get_alert(&org_id, service_id, alert_id) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&alert)?); + } else { + print_alert(&alert); + } + Ok(()) +} + +pub async fn clickstack_alert_create( + client: &CloudClient, + service_id: &str, + args: AlertCreateArgs<'_>, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let request = build_alert_create_request(args)?; + let alert = client + .clickstack_create_alert(&org_id, service_id, &request) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&alert)?); + } else { + println!("Alert created"); + print_alert(&alert); + } + Ok(()) +} + +pub async fn clickstack_alert_update( + client: &CloudClient, + service_id: &str, + alert_id: &str, + args: AlertCreateArgs<'_>, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let request = build_alert_update_request(args)?; + let alert = client + .clickstack_update_alert(&org_id, service_id, alert_id, &request) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&alert)?); + } else { + println!("Alert updated"); + print_alert(&alert); + } + Ok(()) +} + +pub async fn clickstack_alert_delete( + client: &CloudClient, + service_id: &str, + alert_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let response = client + .clickstack_delete_alert(&org_id, service_id, alert_id) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Alert deleted: {alert_id}"); + } + Ok(()) +} + +// =========================================================================== +// Sources & Webhooks (read-only) +// =========================================================================== + +pub async fn clickstack_source_list( + client: &CloudClient, + service_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let sources = client.clickstack_list_sources(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&sources)?); + } else if sources.is_empty() { + println!("No sources found"); + } else { + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Kind")] + kind: String, + #[tabled(rename = "Name")] + name: String, + } + let rows: Vec = sources + .iter() + .map(|s| { + use clickhouse_cloud_api::models::ClickStackSource; + match s { + ClickStackSource::ClickStackLogSource(s) => Row { + id: s.id.clone(), + kind: s.kind.to_string(), + name: s.name.clone(), + }, + ClickStackSource::ClickStackTraceSource(s) => Row { + id: s.id.clone(), + kind: s.kind.to_string(), + name: s.name.clone(), + }, + ClickStackSource::ClickStackMetricSource(s) => Row { + id: s.id.clone(), + kind: s.kind.to_string(), + name: s.name.clone(), + }, + ClickStackSource::ClickStackSessionSource(s) => Row { + id: s.id.clone(), + kind: s.kind.to_string(), + name: s.name.clone(), + }, + ClickStackSource::Unknown(s) => Row { + id: String::new(), + kind: String::from("unknown"), + name: s.clone(), + }, + } + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +pub async fn clickstack_webhook_list( + client: &CloudClient, + service_id: &str, + org_id: Option<&str>, + json: bool, +) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; + let webhooks = client.clickstack_list_webhooks(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&webhooks)?); + } else if webhooks.is_empty() { + println!("No webhooks found"); + } else { + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Service")] + service: String, + #[tabled(rename = "Name")] + name: String, + } + let rows: Vec = webhooks + .iter() + .map(|w| { + use clickhouse_cloud_api::models::ClickStackWebhook; + match w { + ClickStackWebhook::ClickStackSlackWebhook(w) => Row { + id: w.id.clone(), + service: "slack".into(), + name: w.name.clone(), + }, + ClickStackWebhook::ClickStackIncidentIOWebhook(w) => Row { + id: w.id.clone(), + service: "incident.io".into(), + name: w.name.clone(), + }, + ClickStackWebhook::ClickStackGenericWebhook(w) => Row { + id: w.id.clone(), + service: "generic".into(), + name: w.name.clone(), + }, + ClickStackWebhook::ClickStackSlackAPIWebhook(w) => Row { + id: w.id.clone(), + service: "slack-api".into(), + name: w.name.clone(), + }, + ClickStackWebhook::ClickStackPagerDutyAPIWebhook(w) => Row { + id: w.id.clone(), + service: "pagerduty".into(), + name: w.name.clone(), + }, + ClickStackWebhook::Unknown(s) => Row { + id: String::new(), + service: "unknown".into(), + name: s.clone(), + }, + } + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +// =========================================================================== +// Builders & formatters +// =========================================================================== + +fn read_payload(path: &str) -> Result> { + if path == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) + } else { + Ok(std::fs::read_to_string(path)?) + } +} + +fn load_dashboard_create_request( + path: &str, + name_override: Option<&str>, + tag_overrides: &[String], +) -> Result> { + let body = read_payload(path)?; + let mut req: ClickStackCreateDashboardRequest = + serde_json::from_str(&body).map_err(|e| format!("failed to parse {}: {}", path, e))?; + if let Some(name) = name_override { + req.name = name.to_string(); + } + if !tag_overrides.is_empty() { + let mut tags = req.tags.unwrap_or_default(); + tags.extend(tag_overrides.iter().cloned()); + req.tags = Some(tags); + } + Ok(req) +} + +fn load_dashboard_update_request( + path: &str, + name_override: Option<&str>, + tag_overrides: &[String], +) -> Result> { + let body = read_payload(path)?; + let mut req: ClickStackUpdateDashboardRequest = + serde_json::from_str(&body).map_err(|e| format!("failed to parse {}: {}", path, e))?; + if let Some(name) = name_override { + req.name = name.to_string(); + } + if !tag_overrides.is_empty() { + let mut tags = req.tags.unwrap_or_default(); + tags.extend(tag_overrides.iter().cloned()); + req.tags = Some(tags); + } + Ok(req) +} + +fn build_alert_create_request( + args: AlertCreateArgs<'_>, +) -> Result> { + let interval: ClickStackCreateAlertRequestInterval = + parse_serde_enum(args.interval, "interval", KNOWN_INTERVALS)?; + let source: ClickStackCreateAlertRequestSource = + parse_serde_enum(args.source, "source", KNOWN_ALERT_SOURCES)?; + let threshold_type: ClickStackCreateAlertRequestThresholdtype = + parse_serde_enum(args.threshold_type, "threshold-type", KNOWN_THRESHOLD_TYPES)?; + let channel = build_alert_channel( + args.channel_type, + args.emails, + args.webhook_id, + args.webhook_service, + args.severity, + args.slack_channel_id, + )?; + validate_source_args( + args.source, + args.dashboard_id, + args.tile_id, + args.saved_search_id, + )?; + let schedule_start_at = parse_schedule_start_at(args.schedule_start_at)?; + + Ok(ClickStackCreateAlertRequest { + name: args.name.map(str::to_string), + threshold: args.threshold, + threshold_max: args.threshold_max, + threshold_type, + interval, + source, + group_by: args.group_by.map(str::to_string), + message: args.message.map(str::to_string), + dashboard_id: args.dashboard_id.map(str::to_string), + tile_id: args.tile_id.map(str::to_string), + saved_search_id: args.saved_search_id.map(str::to_string), + schedule_offset_minutes: args.schedule_offset_minutes, + schedule_start_at, + channel, + }) +} + +fn build_alert_update_request( + args: AlertCreateArgs<'_>, +) -> Result> { + let interval: ClickStackUpdateAlertRequestInterval = + parse_serde_enum(args.interval, "interval", KNOWN_INTERVALS)?; + let source: ClickStackUpdateAlertRequestSource = + parse_serde_enum(args.source, "source", KNOWN_ALERT_SOURCES)?; + let threshold_type: ClickStackUpdateAlertRequestThresholdtype = + parse_serde_enum(args.threshold_type, "threshold-type", KNOWN_THRESHOLD_TYPES)?; + let channel = build_alert_channel( + args.channel_type, + args.emails, + args.webhook_id, + args.webhook_service, + args.severity, + args.slack_channel_id, + )?; + validate_source_args( + args.source, + args.dashboard_id, + args.tile_id, + args.saved_search_id, + )?; + let schedule_start_at = parse_schedule_start_at(args.schedule_start_at)?; + + Ok(ClickStackUpdateAlertRequest { + name: args.name.map(str::to_string), + threshold: args.threshold, + threshold_max: args.threshold_max, + threshold_type, + interval, + source, + group_by: args.group_by.map(str::to_string), + message: args.message.map(str::to_string), + dashboard_id: args.dashboard_id.map(str::to_string), + tile_id: args.tile_id.map(str::to_string), + saved_search_id: args.saved_search_id.map(str::to_string), + schedule_offset_minutes: args.schedule_offset_minutes, + schedule_start_at, + channel, + }) +} + +fn build_alert_channel( + channel_type: &str, + emails: &[String], + webhook_id: Option<&str>, + webhook_service: Option<&str>, + severity: Option<&str>, + slack_channel_id: Option<&str>, +) -> Result> { + if !KNOWN_CHANNEL_TYPES.contains(&channel_type) { + return Err(format!( + "invalid channel-type: unknown value '{}', expected one of: {}", + channel_type, + KNOWN_CHANNEL_TYPES.join(", ") + ) + .into()); + } + match channel_type { + "email" => { + if emails.is_empty() { + return Err("--channel-type=email requires at least one --email".into()); + } + Ok(ClickStackAlertChannel::ClickStackAlertChannelEmail( + ClickStackAlertChannelEmail { + email_recipients: emails.to_vec(), + r#type: ClickStackAlertChannelEmailType::Email, + }, + )) + } + "webhook" => { + let webhook_id = webhook_id + .ok_or("--channel-type=webhook requires --webhook-id")? + .to_string(); + let severity = severity + .map(|s| parse_serde_enum(s, "severity", KNOWN_WEBHOOK_SEVERITIES)) + .transpose()?; + let severity: Option = severity; + Ok(ClickStackAlertChannel::ClickStackAlertChannelWebhook( + ClickStackAlertChannelWebhook { + webhook_id, + webhook_service: webhook_service.map(str::to_string), + severity, + slack_channel_id: slack_channel_id.map(str::to_string), + r#type: ClickStackAlertChannelWebhookType::Webhook, + }, + )) + } + _ => unreachable!("channel type already validated"), + } +} + +fn validate_source_args( + source: &str, + dashboard_id: Option<&str>, + tile_id: Option<&str>, + saved_search_id: Option<&str>, +) -> Result<(), Box> { + match source { + "tile" if dashboard_id.is_none() => { + Err("--source=tile requires --dashboard-id".into()) + } + "tile" if tile_id.is_none() => Err("--source=tile requires --tile-id".into()), + "saved_search" if saved_search_id.is_none() => { + Err("--source=saved_search requires --saved-search-id".into()) + } + _ => Ok(()), + } +} + +fn parse_schedule_start_at( + value: Option<&str>, +) -> Result>, Box> { + match value { + Some(s) => Ok(Some( + DateTime::parse_from_rfc3339(s) + .map_err(|e| format!("invalid schedule-start-at: {}", e))? + .with_timezone(&Utc), + )), + None => Ok(None), + } +} + +fn summarise_channel(channel: &ClickStackAlertChannel) -> String { + match channel { + ClickStackAlertChannel::ClickStackAlertChannelEmail(_) => "email".to_string(), + ClickStackAlertChannel::ClickStackAlertChannelWebhook(w) => { + format!("webhook({})", w.webhook_id) + } + ClickStackAlertChannel::Unknown(s) => s.clone(), + } +} + +fn print_dashboard(d: &ClickStackDashboardResponse) { + println!("Dashboard: {}", d.id); + println!(" Name: {}", d.name); + println!(" Tags: {}", d.tags.join(", ")); + println!(" Tiles: {}", d.tiles.len()); + println!(" Filters: {}", d.filters.len()); +} + +fn print_alert(a: &ClickStackAlertResponse) { + println!("Alert: {}", a.id); + if let Some(name) = &a.name { + println!(" Name: {}", name); + } + println!(" Source: {}", a.source); + println!(" Interval: {}", a.interval); + println!(" Threshold: {} {}", a.threshold_type, a.threshold); + println!(" Channel: {}", summarise_channel(&a.channel)); + if let Some(dashboard_id) = &a.dashboard_id { + println!(" Dashboard ID: {}", dashboard_id); + } + if let Some(tile_id) = &a.tile_id { + println!(" Tile ID: {}", tile_id); + } + if let Some(saved_search_id) = &a.saved_search_id { + println!(" Saved Search ID: {}", saved_search_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::Cli; + use clap::Parser; + + fn email_args<'a>() -> AlertCreateArgs<'a> { + AlertCreateArgs { + name: Some("disk"), + threshold: 90.0, + threshold_max: None, + threshold_type: "above", + interval: "5m", + source: "saved_search", + group_by: None, + message: None, + dashboard_id: None, + tile_id: None, + saved_search_id: Some("ss-1"), + schedule_offset_minutes: None, + schedule_start_at: None, + channel_type: "email", + emails: EMAIL_LIST.as_ref(), + webhook_id: None, + webhook_service: None, + severity: None, + slack_channel_id: None, + } + } + + // `emails` is a borrowed slice — keep one statically allocated for tests + // so the test arg builders can hand it out by reference without lifetime gymnastics. + static EMAIL_LIST: std::sync::LazyLock> = + std::sync::LazyLock::new(|| vec!["a@b.com".to_string()]); + + #[test] + fn build_alert_request_email_channel() { + let req = build_alert_create_request(email_args()).unwrap(); + assert_eq!(req.threshold, 90.0); + assert!(matches!( + req.channel, + ClickStackAlertChannel::ClickStackAlertChannelEmail(_) + )); + if let ClickStackAlertChannel::ClickStackAlertChannelEmail(email) = req.channel { + assert_eq!(email.email_recipients, vec!["a@b.com".to_string()]); + } + } + + #[test] + fn build_alert_request_webhook_channel_with_severity() { + let args = AlertCreateArgs { + source: "tile", + dashboard_id: Some("dash-1"), + tile_id: Some("tile-1"), + saved_search_id: None, + channel_type: "webhook", + emails: &[], + webhook_id: Some("wh-1"), + severity: Some("critical"), + slack_channel_id: Some("C123"), + ..email_args() + }; + let req = build_alert_create_request(args).unwrap(); + if let ClickStackAlertChannel::ClickStackAlertChannelWebhook(w) = req.channel { + assert_eq!(w.webhook_id, "wh-1"); + assert_eq!(w.slack_channel_id.as_deref(), Some("C123")); + assert!(matches!( + w.severity, + Some(ClickStackAlertChannelWebhookSeverity::Critical) + )); + } else { + panic!("expected webhook channel"); + } + } + + #[test] + fn build_alert_request_email_without_recipient_errors() { + let args = AlertCreateArgs { + emails: &[], + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("--email"), "got error: {err}"); + } + + #[test] + fn build_alert_request_webhook_without_id_errors() { + let args = AlertCreateArgs { + source: "tile", + dashboard_id: Some("dash-1"), + tile_id: Some("tile-1"), + saved_search_id: None, + channel_type: "webhook", + emails: &[], + webhook_id: None, + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("--webhook-id"), "got error: {err}"); + } + + #[test] + fn build_alert_request_tile_source_missing_dashboard_id_errors() { + let args = AlertCreateArgs { + source: "tile", + dashboard_id: None, + tile_id: Some("tile-1"), + saved_search_id: None, + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("--dashboard-id"), "got error: {err}"); + } + + #[test] + fn build_alert_request_saved_search_source_missing_id_errors() { + let args = AlertCreateArgs { + source: "saved_search", + saved_search_id: None, + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("--saved-search-id"), "got error: {err}"); + } + + #[test] + fn build_alert_request_invalid_interval_errors() { + let args = AlertCreateArgs { + interval: "7m", + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("interval"), "got error: {err}"); + assert!(err.contains("5m"), "expected error to list known values"); + } + + #[test] + fn build_alert_request_invalid_channel_type_errors() { + let args = AlertCreateArgs { + channel_type: "sms", + ..email_args() + }; + let err = build_alert_create_request(args).unwrap_err().to_string(); + assert!(err.contains("channel-type"), "got error: {err}"); + } + + #[test] + fn load_dashboard_request_applies_name_and_tag_overrides() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dash.json"); + let body = r#"{ + "name": "from-file", + "tiles": [], + "tags": ["original"] + }"#; + std::fs::write(&path, body).unwrap(); + let req = load_dashboard_create_request( + path.to_str().unwrap(), + Some("renamed"), + &["extra".to_string()], + ) + .unwrap(); + assert_eq!(req.name, "renamed"); + assert_eq!( + req.tags.as_deref(), + Some(["original".to_string(), "extra".to_string()].as_slice()) + ); + } + + #[test] + fn load_dashboard_request_rejects_invalid_json() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("dash.json"); + std::fs::write(&path, "{not json").unwrap(); + let err = load_dashboard_create_request(path.to_str().unwrap(), None, &[]) + .unwrap_err() + .to_string(); + assert!(err.contains("failed to parse"), "got error: {err}"); + } + + #[test] + fn cli_parses_clickstack_dashboard_create() { + let cli = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "clickstack", + "dashboard", + "create", + "svc-1", + "--from-file", + "dash.json", + "--name", + "renamed", + "--tag", + "smoke", + ]) + .expect("dashboard create should parse"); + let _ = cli; + } + + #[test] + fn cli_parses_clickstack_alert_create_webhook() { + let cli = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "clickstack", + "alert", + "create", + "svc-1", + "--threshold", + "1", + "--threshold-type", + "above", + "--interval", + "5m", + "--source", + "tile", + "--dashboard-id", + "dash-1", + "--tile-id", + "tile-1", + "--channel-type", + "webhook", + "--webhook-id", + "wh-1", + ]) + .expect("alert create webhook should parse"); + let _ = cli; + } + + #[test] + fn parse_schedule_start_at_round_trip() { + let dt = parse_schedule_start_at(Some("2026-05-12T10:00:00Z")) + .unwrap() + .unwrap(); + assert_eq!(dt.to_rfc3339(), "2026-05-12T10:00:00+00:00"); + } + + #[test] + fn parse_schedule_start_at_rejects_invalid() { + let err = parse_schedule_start_at(Some("not a date")) + .unwrap_err() + .to_string(); + assert!(err.contains("schedule-start-at"), "got error: {err}"); + } +} diff --git a/crates/clickhousectl/src/cloud/client.rs b/crates/clickhousectl/src/cloud/client.rs index 5df9b6c..8e8cbc2 100644 --- a/crates/clickhousectl/src/cloud/client.rs +++ b/crates/clickhousectl/src/cloud/client.rs @@ -236,9 +236,7 @@ impl CloudClient { } /// Unwrap an `ApiResponse` into `T`, returning an error if the result is empty. - pub fn unwrap_response( - response: clickhouse_cloud_api::models::ApiResponse, - ) -> Result { + pub fn unwrap_response(response: clickhouse_cloud_api::models::ApiResponse) -> Result { response.result.ok_or_else(|| CloudError { message: "Empty response from API".into(), }) @@ -400,6 +398,179 @@ impl CloudClient { Self::unwrap_response(response) } + // ClickStack endpoints (delegated to library client) + pub async fn clickstack_list_dashboards( + &self, + org_id: &str, + service_id: &str, + ) -> Result> { + let response = self + .api() + .click_stack_list_dashboards(org_id, service_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_get_dashboard( + &self, + org_id: &str, + service_id: &str, + dashboard_id: &str, + ) -> Result { + let response = self + .api() + .click_stack_get_dashboard(org_id, service_id, dashboard_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_create_dashboard( + &self, + org_id: &str, + service_id: &str, + request: &clickhouse_cloud_api::models::ClickStackCreateDashboardRequest, + ) -> Result { + let response = self + .api() + .click_stack_create_dashboard(org_id, service_id, request) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_update_dashboard( + &self, + org_id: &str, + service_id: &str, + dashboard_id: &str, + request: &clickhouse_cloud_api::models::ClickStackUpdateDashboardRequest, + ) -> Result { + let response = self + .api() + .click_stack_update_dashboard(org_id, service_id, dashboard_id, request) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_delete_dashboard( + &self, + org_id: &str, + service_id: &str, + dashboard_id: &str, + ) -> Result { + let response = self + .api() + .click_stack_delete_dashboard(org_id, service_id, dashboard_id) + .await + .map_err(|e| self.convert_error(e))?; + Ok(DeleteResponse { + status: response.status.unwrap_or(0.0), + request_id: response.request_id.unwrap_or_default(), + }) + } + + pub async fn clickstack_list_alerts( + &self, + org_id: &str, + service_id: &str, + ) -> Result> { + let response = self + .api() + .click_stack_list_alerts(org_id, service_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_get_alert( + &self, + org_id: &str, + service_id: &str, + alert_id: &str, + ) -> Result { + let response = self + .api() + .click_stack_get_alert(org_id, service_id, alert_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_create_alert( + &self, + org_id: &str, + service_id: &str, + request: &clickhouse_cloud_api::models::ClickStackCreateAlertRequest, + ) -> Result { + let response = self + .api() + .click_stack_create_alert(org_id, service_id, request) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_update_alert( + &self, + org_id: &str, + service_id: &str, + alert_id: &str, + request: &clickhouse_cloud_api::models::ClickStackUpdateAlertRequest, + ) -> Result { + let response = self + .api() + .click_stack_update_alert(org_id, service_id, alert_id, request) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_delete_alert( + &self, + org_id: &str, + service_id: &str, + alert_id: &str, + ) -> Result { + let response = self + .api() + .click_stack_delete_alert(org_id, service_id, alert_id) + .await + .map_err(|e| self.convert_error(e))?; + Ok(DeleteResponse { + status: response.status.unwrap_or(0.0), + request_id: response.request_id.unwrap_or_default(), + }) + } + + pub async fn clickstack_list_sources( + &self, + org_id: &str, + service_id: &str, + ) -> Result> { + let response = self + .api() + .click_stack_list_sources(org_id, service_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + + pub async fn clickstack_list_webhooks( + &self, + org_id: &str, + service_id: &str, + ) -> Result> { + let response = self + .api() + .click_stack_list_webhooks(org_id, service_id) + .await + .map_err(|e| self.convert_error(e))?; + Self::unwrap_response(response) + } + // Update service pub async fn update_service( &self, @@ -937,7 +1108,10 @@ mod tests { status: 403, message: "Forbidden".into(), }); - assert!(err.message.contains("Hint: You are authenticated via OAuth")); + assert!( + err.message + .contains("Hint: You are authenticated via OAuth") + ); } #[test] @@ -953,7 +1127,11 @@ mod tests { .describe() .contains("CLICKHOUSE_CLOUD_API_KEY") ); - assert!(AuthSource::CredentialsFile.describe().contains("credentials")); + assert!( + AuthSource::CredentialsFile + .describe() + .contains("credentials") + ); assert!(AuthSource::OAuthTokens.describe().contains("OAuth")); } diff --git a/crates/clickhousectl/src/cloud/mod.rs b/crates/clickhousectl/src/cloud/mod.rs index b626748..fad2537 100644 --- a/crates/clickhousectl/src/cloud/mod.rs +++ b/crates/clickhousectl/src/cloud/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod cli; +pub mod clickstack; pub mod client; pub mod commands; pub mod credentials; diff --git a/crates/clickhousectl/src/main.rs b/crates/clickhousectl/src/main.rs index 44dcaad..96abde5 100644 --- a/crates/clickhousectl/src/main.rs +++ b/crates/clickhousectl/src/main.rs @@ -10,13 +10,15 @@ mod user_agent; mod version_manager; use clap::Parser; +use clap::error::ErrorKind; use cli::{ - ActivityCommands, AuthCommands, BackupCommands, BackupConfigCommands, Cli, CloudArgs, - CloudCommands, Commands, InvitationCommands, KeyCommands, MemberCommands, OrgCommands, - PostgresCertsCommands, PostgresCommands, PostgresConfigCommands, PostgresReadReplicaCommands, - PrivateEndpointCommands, QueryEndpointCommands, ServiceCommands, SkillsArgs, UpdateArgs, + ActivityCommands, AlertCommands, AuthCommands, BackupCommands, BackupConfigCommands, Cli, + ClickStackCommands, CloudArgs, CloudCommands, Commands, DashboardCommands, InvitationCommands, + KeyCommands, MemberCommands, OrgCommands, PostgresCertsCommands, PostgresCommands, + PostgresConfigCommands, PostgresReadReplicaCommands, PrivateEndpointCommands, + QueryEndpointCommands, ServiceCommands, SkillsArgs, SourceCommands, UpdateArgs, + WebhookCommands, }; -use clap::error::ErrorKind; use cloud::CloudClient; use error::{Error, Result}; @@ -73,10 +75,7 @@ async fn run_update(args: UpdateArgs) -> Result<()> { if args.check { match update::check_for_update().await? { Some((current, latest)) => { - println!( - "Update available: v{} → v{}", - current, latest - ); + println!("Update available: v{} → v{}", current, latest); println!("Run `clickhousectl update` to upgrade."); } None => { @@ -147,7 +146,10 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { .map_err(|e| Error::Cloud(format!("Invalid URL: {}", e)))?; let host = parsed.host_str().unwrap_or("api.clickhouse.cloud"); let base_host = host.strip_prefix("api.").unwrap_or(host); - let url = format!("https://console.{}/signUp?utm_source=clickhousectl", base_host); + let url = format!( + "https://console.{}/signUp?utm_source=clickhousectl", + base_host + ); println!("Opening ClickHouse Cloud sign-up page..."); if open::that(&url).is_err() { println!("Could not open browser. Please visit: {}", url); @@ -193,7 +195,11 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { // CLI --api-key/--api-secret aren't relevant to `auth status` itself. let active = cloud::resolve_active_auth_source(); let mark = |src: cloud::AuthSource| -> String { - if active == Some(src) { "yes".into() } else { "-".into() } + if active == Some(src) { + "yes".into() + } else { + "-".into() + } }; let mut rows = Vec::new(); @@ -716,13 +722,8 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { invitation_id, org_id, } => { - cloud::commands::invitation_delete( - &client, - &invitation_id, - org_id.as_deref(), - json, - ) - .await + cloud::commands::invitation_delete(&client, &invitation_id, org_id.as_deref(), json) + .await } }, CloudCommands::Key { command } => match command { @@ -821,11 +822,232 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { } }, CloudCommands::Postgres { command } => run_postgres(&client, command, json).await, + CloudCommands::Clickstack { command } => run_clickstack(&client, command, json).await, }; result.map_err(|e| Error::Cloud(e.to_string())) } +async fn run_clickstack( + client: &CloudClient, + command: ClickStackCommands, + json: bool, +) -> std::result::Result<(), Box> { + use cloud::clickstack as cs; + match command { + ClickStackCommands::Dashboard { command } => match command { + DashboardCommands::List { service_id, org_id } => { + cs::clickstack_dashboard_list(client, &service_id, org_id.as_deref(), json).await + } + DashboardCommands::Get { + service_id, + dashboard_id, + org_id, + } => { + cs::clickstack_dashboard_get( + client, + &service_id, + &dashboard_id, + org_id.as_deref(), + json, + ) + .await + } + DashboardCommands::Create { + service_id, + from_file, + name, + tags, + org_id, + } => { + let args = cs::DashboardWriteArgs { + from_file: &from_file, + name_override: name.as_deref(), + tag_overrides: &tags, + }; + cs::clickstack_dashboard_create( + client, + &service_id, + args, + org_id.as_deref(), + json, + ) + .await + } + DashboardCommands::Update { + service_id, + dashboard_id, + from_file, + name, + tags, + org_id, + } => { + let args = cs::DashboardWriteArgs { + from_file: &from_file, + name_override: name.as_deref(), + tag_overrides: &tags, + }; + cs::clickstack_dashboard_update( + client, + &service_id, + &dashboard_id, + args, + org_id.as_deref(), + json, + ) + .await + } + DashboardCommands::Delete { + service_id, + dashboard_id, + org_id, + } => { + cs::clickstack_dashboard_delete( + client, + &service_id, + &dashboard_id, + org_id.as_deref(), + json, + ) + .await + } + }, + ClickStackCommands::Alert { command } => match command { + AlertCommands::List { service_id, org_id } => { + cs::clickstack_alert_list(client, &service_id, org_id.as_deref(), json).await + } + AlertCommands::Get { + service_id, + alert_id, + org_id, + } => { + cs::clickstack_alert_get(client, &service_id, &alert_id, org_id.as_deref(), json) + .await + } + AlertCommands::Create { + service_id, + name, + threshold, + threshold_max, + threshold_type, + interval, + source, + group_by, + message, + dashboard_id, + tile_id, + saved_search_id, + schedule_offset_minutes, + schedule_start_at, + channel_type, + emails, + webhook_id, + webhook_service, + severity, + slack_channel_id, + org_id, + } => { + let args = cs::AlertCreateArgs { + name: name.as_deref(), + threshold, + threshold_max, + threshold_type: &threshold_type, + interval: &interval, + source: &source, + group_by: group_by.as_deref(), + message: message.as_deref(), + dashboard_id: dashboard_id.as_deref(), + tile_id: tile_id.as_deref(), + saved_search_id: saved_search_id.as_deref(), + schedule_offset_minutes, + schedule_start_at: schedule_start_at.as_deref(), + channel_type: &channel_type, + emails: &emails, + webhook_id: webhook_id.as_deref(), + webhook_service: webhook_service.as_deref(), + severity: severity.as_deref(), + slack_channel_id: slack_channel_id.as_deref(), + }; + cs::clickstack_alert_create(client, &service_id, args, org_id.as_deref(), json) + .await + } + AlertCommands::Update { + service_id, + alert_id, + name, + threshold, + threshold_max, + threshold_type, + interval, + source, + group_by, + message, + dashboard_id, + tile_id, + saved_search_id, + schedule_offset_minutes, + schedule_start_at, + channel_type, + emails, + webhook_id, + webhook_service, + severity, + slack_channel_id, + org_id, + } => { + let args = cs::AlertCreateArgs { + name: name.as_deref(), + threshold, + threshold_max, + threshold_type: &threshold_type, + interval: &interval, + source: &source, + group_by: group_by.as_deref(), + message: message.as_deref(), + dashboard_id: dashboard_id.as_deref(), + tile_id: tile_id.as_deref(), + saved_search_id: saved_search_id.as_deref(), + schedule_offset_minutes, + schedule_start_at: schedule_start_at.as_deref(), + channel_type: &channel_type, + emails: &emails, + webhook_id: webhook_id.as_deref(), + webhook_service: webhook_service.as_deref(), + severity: severity.as_deref(), + slack_channel_id: slack_channel_id.as_deref(), + }; + cs::clickstack_alert_update( + client, + &service_id, + &alert_id, + args, + org_id.as_deref(), + json, + ) + .await + } + AlertCommands::Delete { + service_id, + alert_id, + org_id, + } => { + cs::clickstack_alert_delete(client, &service_id, &alert_id, org_id.as_deref(), json) + .await + } + }, + ClickStackCommands::Source { command } => match command { + SourceCommands::List { service_id, org_id } => { + cs::clickstack_source_list(client, &service_id, org_id.as_deref(), json).await + } + }, + ClickStackCommands::Webhook { command } => match command { + WebhookCommands::List { service_id, org_id } => { + cs::clickstack_webhook_list(client, &service_id, org_id.as_deref(), json).await + } + }, + } +} + async fn run_postgres( client: &CloudClient, command: PostgresCommands,