From dc45f645f20fa7b0d3639d732531d947726b68a5 Mon Sep 17 00:00:00 2001 From: sdairs Date: Tue, 20 Oct 2026 12:29:22 +0100 Subject: [PATCH] feat(cli): expose organization notifications (refs #1088) --- .../clickhousectl/src/cloud/organizations.rs | 243 +++++++++++++++++- .../tests/cli_request_shape_test.rs | 187 ++++++++++++++ docs/reference/authentication.md | 15 +- 3 files changed, 439 insertions(+), 6 deletions(-) diff --git a/crates/clickhousectl/src/cloud/organizations.rs b/crates/clickhousectl/src/cloud/organizations.rs index 45bd2cbf..4f983194 100644 --- a/crates/clickhousectl/src/cloud/organizations.rs +++ b/crates/clickhousectl/src/cloud/organizations.rs @@ -12,6 +12,7 @@ pub(super) const PERMISSIONS: &[Permission] = &[ "org prometheus discovery", &[&op::ORGANIZATION_PROMETHEUS_DISCOVERY_GET], ), + Permission::api("org notifications", &[&op::NOTIFICATION_GET_LIST]), Permission::api("org usage", &[&op::USAGE_COST_GET]), Permission::api("org quota list", &[&op::ORGANIZATION_QUOTAS_GET_LIST]), Permission::api("org quota get", &[&op::ORGANIZATION_QUOTA_GET]), @@ -86,7 +87,7 @@ use crate::cloud::client::{CloudClient, CloudError, ResourceLookup, Result as Cl use crate::cloud::config::read_typed_config; use crate::cloud::output::{ABSENT, or_absent, print_human}; use crate::cloud::shared::{EmailSelector, NameSelector, NamedResource}; -use crate::cloud::shared::{parse_date_only, parse_tag_filter, resolve_org_id}; +use crate::cloud::shared::{parse_date_only, parse_datetime, parse_tag_filter, resolve_org_id}; use crate::cloud::types::DeleteResponse; use clap::{Args, Subcommand}; use clickhouse_cloud_api::models::{ @@ -95,10 +96,11 @@ use clickhouse_cloud_api::models::{ ByocInfrastructurePrivateEndpointConfig, ByocInfrastructureProgress, ByocInfrastructureTags, ByocInfrastructureTagsResponse, ByocInfrastructureValidatePostRequest, ByocInfrastructureValidatePostRequestRegionid, ByocInfrastructureValidation, - InvitationPostRequest, MemberPatchRequest, NotificationType, OrganizationPatchPrivateEndpoint, - OrganizationPatchPrivateEndpointCloudprovider, OrganizationPatchPrivateEndpointRegion, - OrganizationPatchRequest, OrganizationPrivateEndpointsPatch, RBACPolicyCreateRequest, - RBACPolicyCreateRequestAllowdeny, RBACPolicyTagsRolev2, RoleCreateRequest, RoleUpdateRequest, + InvitationPostRequest, MemberPatchRequest, Notification, NotificationType, + OrganizationPatchPrivateEndpoint, OrganizationPatchPrivateEndpointCloudprovider, + OrganizationPatchPrivateEndpointRegion, OrganizationPatchRequest, + OrganizationPrivateEndpointsPatch, RBACPolicyCreateRequest, RBACPolicyCreateRequestAllowdeny, + RBACPolicyTagsRolev2, RoleCreateRequest, RoleUpdateRequest, }; use tabled::{Table, Tabled, settings::Style}; @@ -119,6 +121,14 @@ pub enum OrgCommands { /// View active credit balances (Beta) Balance, + /// List organization notifications (Beta) + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Returns one page; JSON exposes result, limit, and nextCursor when provided. + Pass nextCursor unchanged as --cursor until it is absent. + Use `cloud org notification-types` to discover notification type names.")] + Notifications(NotificationListArgs), + /// List notification types (Beta) NotificationTypes, @@ -204,6 +214,7 @@ impl OrgCommands { OrgCommands::Get => false, OrgCommands::Quota { .. } => false, OrgCommands::Balance => false, + OrgCommands::Notifications(_) => false, OrgCommands::NotificationTypes => false, OrgCommands::Byoc { command } => command.is_write(), OrgCommands::Role { command } => command.is_write(), @@ -214,6 +225,41 @@ impl OrgCommands { } } +#[derive(Args, Debug, Default)] +pub struct NotificationListArgs { + /// Maximum notifications per page, 1-250; omit for 100 + #[arg(long, value_parser = clap::value_parser!(i64).range(1..=250))] + limit: Option, + + /// Opaque nextCursor from the previous page + #[arg(long, allow_hyphen_values = true)] + cursor: Option, + + /// Notification severity + #[arg(long)] + severity: Option, + + /// Notification type from `cloud org notification-types` + #[arg(long = "type")] + notification_type: Option, + + /// Resource ID; service IDs also match their ClickPipes + #[arg(long)] + resource_id: Option, + + /// Resource kind, such as service, organization, clickpipe or postgres + #[arg(long)] + resource_type: Option, + + /// Earliest creation time, inclusive (RFC 3339) + #[arg(long, value_parser = parse_datetime)] + from_date: Option, + + /// Latest creation time, inclusive (RFC 3339) + #[arg(long, value_parser = parse_datetime)] + to_date: Option, +} + #[derive(Subcommand)] pub enum RoleCommands { /// List organization roles @@ -576,6 +622,7 @@ pub async fn run_org(client: &CloudClient, command: OrgCommands, json: bool) -> } OrgCommands::Quota { command } => run_quota(client, command, json).await, OrgCommands::Balance => org_balance(client, json).await, + OrgCommands::Notifications(args) => org_notifications(client, args, json).await, OrgCommands::NotificationTypes => org_notification_types(client, json).await, OrgCommands::Byoc { command } => run_byoc(client, command, json).await, OrgCommands::Role { command } => run_role(client, command, json).await, @@ -1267,6 +1314,74 @@ async fn quota_get(client: &CloudClient, quota_code: &str, json: bool) -> CloudR Ok(()) } +#[derive(serde::Serialize)] +struct NotificationPage { + result: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + limit: Option, + #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +async fn org_notifications( + client: &CloudClient, + args: NotificationListArgs, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let page = client.list_notifications(&org_id, &args).await?; + if json { + println!("{}", serde_json::to_string_pretty(&page)?); + } else { + if page.result.is_empty() { + println!("No notifications found"); + } else { + println!("{}", notifications_table(page.result)); + } + if let Some(cursor) = page.next_cursor { + // JSON escaping keeps opaque tokens visible without suggesting shell execution. + println!( + "Next cursor (JSON string): {}", + serde_json::to_string(&cursor)? + ); + } + } + Ok(()) +} + +fn notifications_table(notifications: Vec) -> String { + #[derive(Tabled)] + struct Row { + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "Created At")] + created_at: String, + #[tabled(rename = "Severity")] + severity: String, + #[tabled(rename = "Type")] + notification_type: String, + #[tabled(rename = "Resource Type")] + resource_type: String, + #[tabled(rename = "Resource ID")] + resource_id: String, + #[tabled(rename = "Title")] + title: String, + } + let rows: Vec = notifications + .into_iter() + .map(|notification| Row { + id: or_absent(notification.id), + created_at: or_absent(notification.created_at), + severity: or_absent(notification.severity), + notification_type: or_absent(notification.r#type), + resource_type: or_absent(notification.resource_type), + resource_id: or_absent(notification.resource_id), + title: or_absent(notification.title), + }) + .collect(); + Table::new(rows).with(Style::markdown()).to_string() +} + async fn org_notification_types(client: &CloudClient, json: bool) -> CloudResult<()> { let org_id = resolve_org_id(client).await?; let types = client.list_notification_types(&org_id).await?; @@ -1925,6 +2040,35 @@ impl CloudClient { Self::unwrap_response(response) } + async fn list_notifications( + &self, + org_id: &str, + args: &NotificationListArgs, + ) -> CloudResult { + let mut response = self + .api() + .notification_get_list( + org_id, + args.limit, + args.cursor.as_deref(), + args.severity.as_deref(), + args.notification_type.as_deref(), + args.resource_id.as_deref(), + args.resource_type.as_deref(), + args.from_date.as_deref(), + args.to_date.as_deref(), + ) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + let limit = response.limit; + let next_cursor = response.next_cursor.take(); + Ok(NotificationPage { + result: Self::unwrap_response(response)?, + limit, + next_cursor, + }) + } + pub async fn list_notification_types( &self, org_id: &str, @@ -2807,6 +2951,95 @@ mod tests { assert_eq!(quota_code, "replicas-per-warehouse"); } + #[test] + fn parses_org_notifications_filters_and_defaults() { + let CloudCommands::Org { + command: OrgCommands::Notifications(defaults), + } = parse_cloud_command(&["clickhousectl", "cloud", "org", "notifications"]) + else { + panic!("expected org notifications") + }; + assert_eq!(defaults.limit, None); + assert_eq!(defaults.cursor, None); + assert_eq!(defaults.severity, None); + assert_eq!(defaults.notification_type, None); + assert_eq!(defaults.resource_id, None); + assert_eq!(defaults.resource_type, None); + assert_eq!(defaults.from_date, None); + assert_eq!(defaults.to_date, None); + let CloudCommands::Org { + command: OrgCommands::Notifications(args), + } = parse_cloud_command(&[ + "clickhousectl", + "cloud", + "org", + "notifications", + "--limit", + "250", + "--cursor", + "-opaque +/=&?", + "--severity", + "warning", + "--type", + "future.type", + "--resource-id", + "service-1", + "--resource-type", + "service", + "--from-date", + "2026-10-01T00:00:00+02:00", + "--to-date", + "2026-10-08T00:00:00Z", + ]) + else { + panic!("expected org notifications") + }; + assert_eq!(args.limit, Some(250)); + assert_eq!(args.cursor.as_deref(), Some("-opaque +/=&?")); + assert_eq!(args.severity.as_deref(), Some("warning")); + assert_eq!(args.notification_type.as_deref(), Some("future.type")); + assert_eq!(args.resource_id.as_deref(), Some("service-1")); + assert_eq!(args.resource_type.as_deref(), Some("service")); + assert_eq!(args.from_date.as_deref(), Some("2026-10-01T00:00:00+02:00")); + assert_eq!(args.to_date.as_deref(), Some("2026-10-08T00:00:00Z")); + assert_write(&["clickhousectl", "cloud", "org", "notifications"], false); + } + + #[test] + fn rejects_invalid_or_repeated_notification_flags() { + for extra in [ + vec!["--limit", "0"], + vec!["--limit", "251"], + vec!["--from-date", "2026-10-01"], + vec!["--to-date", "yesterday"], + vec!["--limit", "1", "--limit", "2"], + ] { + assert!( + Cli::try_parse_from( + ["clickhousectl", "cloud", "org", "notifications"] + .into_iter() + .chain(extra) + ) + .is_err() + ); + } + } + + #[test] + fn notifications_table_tolerates_absent_fields() { + let table = notifications_table(vec![ + Notification::default(), + Notification { + title: Some("Backup failed".into()), + severity: Some("future".into()), + ..Default::default() + }, + ]); + assert!(table.contains("Backup failed")); + assert!(table.contains("future")); + assert!(table.contains("-")); + } + #[test] fn parses_org_notification_types_command() { let CloudCommands::Org { command } = parse_cloud_command(&[ diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index 68c8dddf..32277617 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -32830,3 +32830,190 @@ async fn bigquery_invalid_cdc_settings_fail_before_key_file_or_http() { } assert!(mock.received_requests().await.unwrap().is_empty()); } + +#[tokio::test] +async fn org_notifications_forwards_filters_as_an_oauth_read_and_keeps_pagination() { + let mock = MockServer::start().await; + let cursor = "-opaque +/=&?'雪"; + let result = serde_json::json!([ + {"title": "Backup failed", "type": "future.type", "severity": "future.severity", + "resourceType": "service", "resourceId": "service-1", "resourceName": "analytics", + "description": "Review backup settings."}, + {"id": null, "createdAt": null} + ]); + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .and(header("authorization", "Bearer test-bearer-token")) + .and(query_param("limit", "25")) + .and(query_param("cursor", cursor)) + .and(query_param("severity", "future.severity")) + .and(query_param("type", "future.type")) + .and(query_param("resource_id", "service-1")) + .and(query_param("resource_type", "service")) + .and(query_param("from_date", "2026-10-01T00:00:00+02:00")) + .and(query_param("to_date", "2026-10-08T00:00:00Z")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": result, "limit": 25, "nextCursor": "" + }))) + .expect(1) + .mount(&mock) + .await; + let output = invoke_org_with_oauth( + &mock, + &[ + "notifications", + "--org-id", + "org-1", + "--limit", + "25", + "--cursor", + cursor, + "--severity", + "future.severity", + "--type", + "future.type", + "--resource-id", + "service-1", + "--resource-type", + "service", + "--from-date", + "2026-10-01T00:00:00+02:00", + "--to-date", + "2026-10-08T00:00:00Z", + ], + ); + assert_success(&output); + let mut expected = result; + expected[1] = serde_json::json!({}); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({ + "result": expected, "limit": 25, "nextCursor": "" + }) + ); + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert!(requests[0].body.is_empty()); +} + +#[tokio::test] +async fn org_notifications_human_output_tolerates_sparse_rows_and_empty_pages() { + for (result, expected) in [ + ( + serde_json::json!([{}, {"title": "Backup failed", "severity": "warning"}]), + "Backup failed", + ), + (serde_json::json!([]), "No notifications found"), + ] { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": result, "nextCursor": "" + }))) + .expect(1) + .mount(&mock) + .await; + let output = invoke_cli_with_cloud_credentials_human( + &mock, + &["org", "notifications", "--org-id", "org-1"], + ); + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains(expected), "{stdout}"); + assert!( + stdout.contains("Next cursor (JSON string): \"\""), + "{stdout}" + ); + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests[0].url.query(), None); + } +} + +#[tokio::test] +async fn org_notifications_preserves_api_errors_and_rejects_missing_results() { + for (status, body, expected_code, message) in [ + ( + 403, + serde_json::json!({"error": "not permitted"}), + 4, + "not permitted", + ), + ( + 400, + serde_json::json!({"error": "invalid cursor"}), + 1, + "invalid cursor", + ), + ( + 200, + serde_json::json!({"result": null}), + 1, + "Empty response from API", + ), + ] { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .respond_with(ResponseTemplate::new(status).set_body_json(body)) + .expect(1) + .mount(&mock) + .await; + let output = invoke_cli_with_cloud_credentials( + &mock, + &["org", "notifications", "--org-id", "org-1"], + ); + assert_eq!(output.status.code(), Some(expected_code)); + let error: Value = serde_json::from_slice(&output.stderr).unwrap(); + assert!(output.stdout.is_empty()); + assert!( + error["error"]["message"] + .as_str() + .unwrap() + .contains(message), + "{error}" + ); + } +} + +#[tokio::test] +async fn org_notifications_rejects_bad_page_sizes_and_dates_before_network_access() { + let mock = MockServer::start().await; + for flags in [ + vec!["--limit", "0"], + vec!["--limit", "251"], + vec!["--from-date", "2026-10-01"], + vec!["--to-date", "yesterday"], + ] { + let mut args = vec!["org", "notifications", "--org-id", "org-1"]; + args.extend(flags); + let output = invoke_cli_with_cloud_credentials(&mock, &args); + assert_eq!(output.status.code(), Some(2)); + } + assert!(mock.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn org_notifications_accepts_an_empty_cursor_without_fetching_more_pages() { + let mock = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/organizations/org-1/notifications")) + .and(query_param("cursor", "")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": [], "nextCursor": "another-page" + }))) + .expect(1) + .mount(&mock) + .await; + let output = invoke_cli_with_cloud_credentials( + &mock, + &["org", "notifications", "--org-id", "org-1", "--cursor", ""], + ); + assert_success(&output); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({ + "result": [], "nextCursor": "another-page" + }) + ); +} diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index e99b7d2f..ab103700 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -101,9 +101,22 @@ Member/invitation email selectors match exactly, including case. Omitting member Custom organization roles are created/updated with `--file` (`-` for stdin). Updates are partial at the root, but supplied `actors` and `policies` replace their complete lists. Only custom roles can be updated/deleted. Policy `allowDeny` is `ALLOW` or `DENY`; SQL `roleV2` values are `sql-console-readonly` or `sql-console-admin`. Inspect `org role get` and prepare a writable request without unrelated response fields. +## Read organization notifications + +```bash +clickhousectl cloud org notification-types --org-id +clickhousectl cloud org notifications --org-id --severity warning \ + --from-date 2026-10-01T00:00:00Z --limit 100 --json +clickhousectl cloud org notifications --org-id --cursor '' --json +``` + +Notifications are Beta and support OAuth reads. Each invocation returns one page; JSON contains `result` and optional `limit`/`nextCursor`. Pass the cursor unchanged until it is absent; an empty string is still a cursor. Keep the same filters across pages. `--limit` accepts 1–250; omission uses the server default of 100. Results are retained for 180 days and include only resources the caller can access. + +Use `--type` with a name from `notification-types`, `--severity`, `--resource-id`, or `--resource-type` to narrow results. A service ID also matches its ClickPipes unless a resource kind narrows it. `--from-date` and `--to-date` are inclusive RFC 3339 timestamps. Human output shows a table and the next cursor as a JSON string; JSON preserves notification descriptions and resource names too. + ## Other organization tasks -Use `cloud org --help` for quotas, credit balance, notification types, usage, settings, BYOC infrastructure, and Prometheus discovery. BYOC `validate` is a preflight that creates nothing but requires an API key. Creation is asynchronous: find infrastructure IDs in `org get`'s `byocConfig`, then inspect `byoc get` and `byoc progress` before creating a service in it. +Use `cloud org --help` for quotas, credit balance, notifications, notification types, usage, settings, BYOC infrastructure, and Prometheus discovery. BYOC `validate` is a preflight that creates nothing but requires an API key. Creation is asynchronous: find infrastructure IDs in `org get`'s `byocConfig`, then inspect `byoc get` and `byoc progress` before creating a service in it. `byoc update` changes only the flags you pass. Its `--tag` flags replace the infrastructure's whole tag set, so read the current set with `byoc tags` first; `byoc private-endpoint-config` shows the private link endpoint service to connect to: