From 07fe835755c3f84b128fc0572b763dccb2ccaa4b Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 31 Jul 2026 15:23:29 +0100 Subject: [PATCH 1/2] Auto-detect org for usage and Prometheus --- README.md | 7 +- crates/clickhousectl/src/cloud/cli.rs | 72 +++++++++--- crates/clickhousectl/src/cloud/commands.rs | 10 +- crates/clickhousectl/src/main.rs | 16 ++- .../tests/cli_request_shape_test.rs | 106 ++++++++++++++++++ 5 files changed, 188 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 3acec11d..ef5a9353 100644 --- a/README.md +++ b/README.md @@ -362,10 +362,11 @@ clickhousectl cloud org update --name "Renamed Org" clickhousectl cloud org update \ --remove-private-endpoint pe-1,cloud-provider=aws,region=us-east-1 \ --enable-core-dumps false -clickhousectl cloud org prometheus --filtered-metrics true -clickhousectl cloud org usage \ +clickhousectl cloud org prometheus --filtered-metrics true +clickhousectl cloud org usage \ --from-date 2024-01-01 \ --to-date 2024-01-31 +# Add --org-id to either command when your credentials access multiple organizations. ``` ### Services @@ -959,4 +960,4 @@ export CONTINUE_ON_NON_BLOCKING_FAILURES=1 ## Requirements - macOS (aarch64, x86_64) or Linux (aarch64, x86_64) -- Cloud commands require a [ClickHouse Cloud API key](https://clickhouse.com/docs/en/cloud/manage/api) \ No newline at end of file +- Cloud commands require a [ClickHouse Cloud API key](https://clickhouse.com/docs/en/cloud/manage/api) diff --git a/crates/clickhousectl/src/cloud/cli.rs b/crates/clickhousectl/src/cloud/cli.rs index ef0f1588..a48ed13e 100644 --- a/crates/clickhousectl/src/cloud/cli.rs +++ b/crates/clickhousectl/src/cloud/cli.rs @@ -415,8 +415,9 @@ CONTEXT FOR AGENTS: /// Get organization Prometheus configuration Prometheus { - /// Organization ID - org_id: String, + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, /// Whether to request filtered metrics #[arg(long)] @@ -425,8 +426,9 @@ CONTEXT FOR AGENTS: /// Get organization usage/billing information Usage { - /// Organization ID - org_id: String, + /// Organization ID (auto-detected if not specified) + #[arg(long)] + org_id: Option, /// Start date filter in UTC (YYYY-MM-DD, e.g. 2024-01-01) #[arg(long, value_parser = parse_date_only)] @@ -2801,7 +2803,6 @@ mod tests { "cloud", "org", "usage", - "org-1", "--from-date", "2025-01-01", "--to-date", @@ -2816,15 +2817,66 @@ mod tests { panic!("expected org command"); }; let OrgCommands::Usage { - from_date, to_date, .. + org_id, + from_date, + to_date, + .. } = command else { panic!("expected org usage"); }; + assert_eq!(org_id, None); assert_eq!(from_date, "2025-01-01"); assert_eq!(to_date, "2025-01-31"); } + #[test] + fn parses_org_prometheus_and_usage_org_id_flags() { + let prometheus = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "org", + "prometheus", + "--org-id", + "org-1", + ]) + .unwrap(); + let Commands::Cloud(args) = prometheus.command else { + panic!("expected cloud command"); + }; + let CloudCommands::Org { command } = args.command else { + panic!("expected org command"); + }; + let OrgCommands::Prometheus { org_id, .. } = command else { + panic!("expected org prometheus"); + }; + assert_eq!(org_id.as_deref(), Some("org-1")); + + let usage = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "org", + "usage", + "--org-id", + "org-1", + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ]) + .unwrap(); + let Commands::Cloud(args) = usage.command else { + panic!("expected cloud command"); + }; + let CloudCommands::Org { command } = args.command else { + panic!("expected org command"); + }; + let OrgCommands::Usage { org_id, .. } = command else { + panic!("expected org usage"); + }; + assert_eq!(org_id.as_deref(), Some("org-1")); + } + #[test] fn rejects_org_usage_timestamps() { let result = Cli::try_parse_from([ @@ -2832,7 +2884,6 @@ mod tests { "cloud", "org", "usage", - "org-1", "--from-date", "2025-01-01T00:00:00Z", "--to-date", @@ -2852,7 +2903,6 @@ mod tests { "cloud", "org", "usage", - "org-1", "--from-date", "2025-02-31", "--to-date", @@ -2952,17 +3002,13 @@ 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", "prometheus"], false); assert_write( &[ "clickhousectl", "cloud", "org", "usage", - "org-1", "--from-date", "2025-01-01", "--to-date", diff --git a/crates/clickhousectl/src/cloud/commands.rs b/crates/clickhousectl/src/cloud/commands.rs index 4812c9e7..6ca818d8 100644 --- a/crates/clickhousectl/src/cloud/commands.rs +++ b/crates/clickhousectl/src/cloud/commands.rs @@ -2641,11 +2641,12 @@ pub async fn org_update( pub async fn org_prometheus( client: &CloudClient, - org_id: &str, + org_id: Option<&str>, filtered_metrics: Option, _json: bool, ) -> Result<(), Box> { - let prom = client.get_org_prometheus(org_id, filtered_metrics).await?; + let org_id = resolve_org_id(client, org_id).await?; + let prom = client.get_org_prometheus(&org_id, filtered_metrics).await?; println!("{}", prom); Ok(()) } @@ -2666,14 +2667,15 @@ pub async fn service_prometheus( pub async fn org_usage( client: &CloudClient, - org_id: &str, + org_id: Option<&str>, from_date: &str, to_date: &str, filters: &[String], json: bool, ) -> Result<(), Box> { + let org_id = resolve_org_id(client, org_id).await?; let usage = client - .get_org_usage(org_id, from_date, to_date, filters) + .get_org_usage(&org_id, from_date, to_date, filters) .await?; if json { diff --git a/crates/clickhousectl/src/main.rs b/crates/clickhousectl/src/main.rs index d92b3faa..10496287 100644 --- a/crates/clickhousectl/src/main.rs +++ b/crates/clickhousectl/src/main.rs @@ -533,15 +533,25 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { OrgCommands::Prometheus { org_id, filtered_metrics, - } => cloud::commands::org_prometheus(&client, &org_id, filtered_metrics, json).await, + } => { + cloud::commands::org_prometheus(&client, org_id.as_deref(), filtered_metrics, json) + .await + } OrgCommands::Usage { org_id, from_date, to_date, filter, } => { - cloud::commands::org_usage(&client, &org_id, &from_date, &to_date, &filter, json) - .await + cloud::commands::org_usage( + &client, + org_id.as_deref(), + &from_date, + &to_date, + &filter, + json, + ) + .await } }, CloudCommands::Service { command } => match command { diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index 80834791..141c8079 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -118,6 +118,112 @@ async fn invoke_cli_capture_body(mock: &MockServer, cli_args: &[&str]) -> Value serde_json::from_slice(&post.body).expect("POST body wasn't valid JSON") } +// ── Organization auto-detection (issue #337) ─────────────────────────────── + +const AUTO_DETECTED_ORG_ID: &str = "11111111-2222-3333-4444-555555555555"; + +async fn start_mock_org_auto_detection_api() -> MockServer { + let mock = MockServer::start().await; + let orgs = serde_json::json!({ + "result": [{ "id": AUTO_DETECTED_ORG_ID, "name": "Only org" }], + "status": 200, + "requestId": "stub-org-list", + }); + Mock::given(method("GET")) + .and(path("/v1/organizations")) + .respond_with(ResponseTemplate::new(200).set_body_json(orgs)) + .mount(&mock) + .await; + + Mock::given(method("GET")) + .and(path(format!( + "/v1/organizations/{AUTO_DETECTED_ORG_ID}/prometheus" + ))) + .respond_with(ResponseTemplate::new(200).set_body_string("metric 1\n")) + .mount(&mock) + .await; + + let usage = serde_json::json!({ + "result": { "grandTotalCHC": 0.0, "costs": [] }, + "status": 200, + "requestId": "stub-org-usage", + }); + Mock::given(method("GET")) + .and(path(format!( + "/v1/organizations/{AUTO_DETECTED_ORG_ID}/usageCost" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(usage)) + .mount(&mock) + .await; + + mock +} + +fn invoke_cli_with_cloud_credentials(mock: &MockServer, cli_args: &[&str]) -> std::process::Output { + let url = mock.uri(); + let mut args = vec!["cloud", "--url", &url, "--json"]; + args.extend(cli_args); + Command::new(clickhousectl_binary()) + .env("DO_NOT_TRACK", "1") + .env("CLICKHOUSE_CLOUD_API_KEY", "fake-key-for-tests") + .env("CLICKHOUSE_CLOUD_API_SECRET", "fake-secret-for-tests") + .args(args) + .output() + .expect("failed to spawn clickhousectl") +} + +#[tokio::test] +async fn org_prometheus_auto_detects_the_only_organization() { + let mock = start_mock_org_auto_detection_api().await; + let output = invoke_cli_with_cloud_credentials( + &mock, + &["org", "prometheus", "--filtered-metrics", "true"], + ); + assert_success(&output); + assert_eq!(String::from_utf8_lossy(&output.stdout), "metric 1\n\n"); + + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].url.path(), "/v1/organizations"); + assert_eq!( + requests[1].url.path(), + format!("/v1/organizations/{AUTO_DETECTED_ORG_ID}/prometheus") + ); + assert_eq!(requests[1].url.query(), Some("filtered_metrics=true")); +} + +#[tokio::test] +async fn org_usage_auto_detects_the_only_organization() { + let mock = start_mock_org_auto_detection_api().await; + let output = invoke_cli_with_cloud_credentials( + &mock, + &[ + "org", + "usage", + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ], + ); + assert_success(&output); + + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].url.path(), "/v1/organizations"); + assert_eq!( + requests[1].url.path(), + format!("/v1/organizations/{AUTO_DETECTED_ORG_ID}/usageCost") + ); + let query = requests[1] + .url + .query_pairs() + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect::>(); + assert!(query.contains(&("from_date".into(), "2025-01-01".into()))); + assert!(query.contains(&("to_date".into(), "2025-01-31".into()))); +} + // ── Bug 1: Postgres CDC must NOT send publicationName / replicationSlotName ─ // // `cdc` replication mode creates the slot + publication server-side; the From 1d861d7b1e151df597354b8b823613d47859ec67 Mon Sep 17 00:00:00 2001 From: sdairs Date: Mon, 3 Aug 2026 19:53:28 +0100 Subject: [PATCH 2/2] Preserve positional org ID compatibility --- crates/clickhousectl/src/cloud/cli.rs | 96 +++++++++++++++++++ crates/clickhousectl/src/main.rs | 18 ++-- .../tests/cli_request_shape_test.rs | 40 ++++++++ 3 files changed, 143 insertions(+), 11 deletions(-) diff --git a/crates/clickhousectl/src/cloud/cli.rs b/crates/clickhousectl/src/cloud/cli.rs index a48ed13e..a56671f6 100644 --- a/crates/clickhousectl/src/cloud/cli.rs +++ b/crates/clickhousectl/src/cloud/cli.rs @@ -419,6 +419,10 @@ CONTEXT FOR AGENTS: #[arg(long)] org_id: Option, + /// Organization ID (deprecated positional form; use --org-id) + #[arg(value_name = "ORG_ID", hide = true, conflicts_with = "org_id")] + legacy_org_id: Option, + /// Whether to request filtered metrics #[arg(long)] filtered_metrics: Option, @@ -430,6 +434,10 @@ CONTEXT FOR AGENTS: #[arg(long)] org_id: Option, + /// Organization ID (deprecated positional form; use --org-id) + #[arg(value_name = "ORG_ID", hide = true, conflicts_with = "org_id")] + legacy_org_id: Option, + /// Start date filter in UTC (YYYY-MM-DD, e.g. 2024-01-01) #[arg(long, value_parser = parse_date_only)] from_date: String, @@ -2818,6 +2826,7 @@ mod tests { }; let OrgCommands::Usage { org_id, + legacy_org_id, from_date, to_date, .. @@ -2826,6 +2835,7 @@ mod tests { panic!("expected org usage"); }; assert_eq!(org_id, None); + assert_eq!(legacy_org_id, None); assert_eq!(from_date, "2025-01-01"); assert_eq!(to_date, "2025-01-31"); } @@ -2877,6 +2887,92 @@ mod tests { assert_eq!(org_id.as_deref(), Some("org-1")); } + #[test] + fn parses_legacy_org_id_positionals() { + let prometheus = + Cli::try_parse_from(["clickhousectl", "cloud", "org", "prometheus", "org-1"]).unwrap(); + let Commands::Cloud(args) = prometheus.command else { + panic!("expected cloud command"); + }; + let CloudCommands::Org { command } = args.command else { + panic!("expected org command"); + }; + let OrgCommands::Prometheus { + org_id, + legacy_org_id, + .. + } = command + else { + panic!("expected org prometheus"); + }; + assert_eq!(org_id, None); + assert_eq!(legacy_org_id.as_deref(), Some("org-1")); + + let usage = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "org", + "usage", + "org-1", + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ]) + .unwrap(); + let Commands::Cloud(args) = usage.command else { + panic!("expected cloud command"); + }; + let CloudCommands::Org { command } = args.command else { + panic!("expected org command"); + }; + let OrgCommands::Usage { + org_id, + legacy_org_id, + .. + } = command + else { + panic!("expected org usage"); + }; + assert_eq!(org_id, None); + assert_eq!(legacy_org_id.as_deref(), Some("org-1")); + } + + #[test] + fn rejects_org_id_flag_with_legacy_positional() { + let prometheus = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "org", + "prometheus", + "org-1", + "--org-id", + "org-2", + ]); + match prometheus { + Ok(_) => panic!("expected conflicting org IDs to be rejected"), + Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict), + } + + let usage = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "org", + "usage", + "org-1", + "--org-id", + "org-2", + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ]); + match usage { + Ok(_) => panic!("expected conflicting org IDs to be rejected"), + Err(err) => assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict), + } + } + #[test] fn rejects_org_usage_timestamps() { let result = Cli::try_parse_from([ diff --git a/crates/clickhousectl/src/main.rs b/crates/clickhousectl/src/main.rs index 10496287..83fe78fa 100644 --- a/crates/clickhousectl/src/main.rs +++ b/crates/clickhousectl/src/main.rs @@ -532,26 +532,22 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { } OrgCommands::Prometheus { org_id, + legacy_org_id, filtered_metrics, } => { - cloud::commands::org_prometheus(&client, org_id.as_deref(), filtered_metrics, json) - .await + let org_id = org_id.as_deref().or(legacy_org_id.as_deref()); + cloud::commands::org_prometheus(&client, org_id, filtered_metrics, json).await } OrgCommands::Usage { org_id, + legacy_org_id, from_date, to_date, filter, } => { - cloud::commands::org_usage( - &client, - org_id.as_deref(), - &from_date, - &to_date, - &filter, - json, - ) - .await + let org_id = org_id.as_deref().or(legacy_org_id.as_deref()); + cloud::commands::org_usage(&client, org_id, &from_date, &to_date, &filter, json) + .await } }, CloudCommands::Service { command } => match command { diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index 141c8079..e11e1bb3 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -224,6 +224,46 @@ async fn org_usage_auto_detects_the_only_organization() { assert!(query.contains(&("to_date".into(), "2025-01-31".into()))); } +#[tokio::test] +async fn org_prometheus_accepts_legacy_positional_org_id() { + let mock = start_mock_org_auto_detection_api().await; + let output = + invoke_cli_with_cloud_credentials(&mock, &["org", "prometheus", AUTO_DETECTED_ORG_ID]); + assert_success(&output); + + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].url.path(), + format!("/v1/organizations/{AUTO_DETECTED_ORG_ID}/prometheus") + ); +} + +#[tokio::test] +async fn org_usage_accepts_legacy_positional_org_id() { + let mock = start_mock_org_auto_detection_api().await; + let output = invoke_cli_with_cloud_credentials( + &mock, + &[ + "org", + "usage", + AUTO_DETECTED_ORG_ID, + "--from-date", + "2025-01-01", + "--to-date", + "2025-01-31", + ], + ); + assert_success(&output); + + let requests = mock.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].url.path(), + format!("/v1/organizations/{AUTO_DETECTED_ORG_ID}/usageCost") + ); +} + // ── Bug 1: Postgres CDC must NOT send publicationName / replicationSlotName ─ // // `cdc` replication mode creates the slot + publication server-side; the