diff --git a/README.md b/README.md index 119c701..2172b22 100644 --- a/README.md +++ b/README.md @@ -456,13 +456,12 @@ clickhousectl cloud service delete --force | `--enable-endpoint` / `--disable-endpoint` | Toggle GA service endpoints (currently `mysql`) | | `--private-preview-terms-checked` | Accept private preview terms when required | | `--enable-core-dumps` | Enable or disable service core dump collection | -| `--no-enable-query` | Skip auto-provisioning of the Query API endpoint + per-service key | #### Query API auto-provisioning -By default, `cloud service create` provisions a Query API endpoint for the new service and creates a dedicated API key bound to it. The key (`keyId`, `keySecret`, and `endpointId`) is stored in `.clickhouse/credentials.json` under `service_query_keys.`, alongside any user-level API key. `cloud service query` then runs SQL over HTTP using that key — no `clickhouse` binary and no service password required. The key is scoped to a single service, so it can read and write (SELECT, INSERT, DDL) against that service but cannot reach any other service in the org. +The first time `cloud service query` runs against a service without a stored key, it provisions a Query API endpoint for that service and creates a dedicated API key bound to it. The key (`keyId`, `keySecret`, and `endpointId`) is stored in `.clickhouse/credentials.json` under `service_query_keys.`, alongside any user-level API key. Subsequent queries run SQL over HTTP using that key — no `clickhouse` binary and no service password required. The key is scoped to a single service, so it can read and write (SELECT, INSERT, DDL) against that service but cannot reach any other service in the org. Pass `--no-auto-enable` to fail instead of provisioning. -For existing services without a stored key, `cloud service query` provisions one lazily on first use. Pass `--no-auto-enable` to fail instead, or `--no-enable-query` on `service create` to skip the create-time hook. +Provisioning happens lazily (rather than at `service create` time) because the endpoint can only be bound once the service has finished provisioning, which can take several minutes — `service create` returns immediately instead of blocking on it. Per-service scoping is enforced at the query endpoint binding, which is created with role `sql_console_admin` (read + write inside the bound service only). The API key itself has no org-level roles, so the binding is the only thing that grants it any access. `cloud service delete` removes the stored key from `credentials.json`. diff --git a/crates/clickhousectl/src/cloud/cli.rs b/crates/clickhousectl/src/cloud/cli.rs index 85ee720..57e5e59 100644 --- a/crates/clickhousectl/src/cloud/cli.rs +++ b/crates/clickhousectl/src/cloud/cli.rs @@ -572,11 +572,6 @@ CONTEXT FOR AGENTS: #[arg(long)] enable_core_dumps: Option, - /// Skip auto-provisioning of the Query API endpoint and per-service - /// API key - #[arg(long)] - no_enable_query: bool, - /// Organization ID (auto-detected if not specified) #[arg(long)] org_id: Option, @@ -779,8 +774,8 @@ CONTEXT FOR AGENTS: CONTEXT FOR AGENTS: Runs SQL over HTTP — no local clickhouse binary or service password required. Uses a per-service API key (read+write, scoped to this service via the - query endpoint binding) auto-provisioned on first use (or on - `cloud service create`) and stored in .clickhouse/credentials.json. + query endpoint binding) auto-provisioned on first use and stored in + .clickhouse/credentials.json. SQL precedence: --query > --queries-file > stdin. Default format: PrettyCompact on a TTY, TabSeparated when piped.")] Query { diff --git a/crates/clickhousectl/src/cloud/commands.rs b/crates/clickhousectl/src/cloud/commands.rs index 13981df..0dacd8d 100644 --- a/crates/clickhousectl/src/cloud/commands.rs +++ b/crates/clickhousectl/src/cloud/commands.rs @@ -554,7 +554,6 @@ pub struct CreateServiceOptions { pub disable_endpoints: Vec, pub private_preview_terms_checked: bool, pub enable_core_dumps: Option, - pub no_enable_query: bool, pub org_id: Option, } @@ -843,12 +842,10 @@ pub async fn service_create( // Validate input before any network call so typos like --provider awss // fail locally instead of on the /organizations lookup. let request = build_create_service_request(&opts)?; - let no_enable_query = opts.no_enable_query; let org_id = resolve_org_id(client, opts.org_id.as_deref()).await?; let response = client.create_service(&org_id, &request).await?; let svc_id = response.service.id.to_string(); - let svc_name = response.service.name.clone(); if json { println!("{}", serde_json::to_string_pretty(&response)?); @@ -860,29 +857,12 @@ pub async fn service_create( println!("Credentials (save these, password shown only once):"); println!(" Username: default"); println!(" Password: {}", response.password); - } - - if !no_enable_query && !json { - match crate::cloud::service_query::ensure_service_query_setup( - client, &org_id, &svc_id, &svc_name, - ) - .await - { - Ok(_) => { - println!(); - println!( - "Query API endpoint enabled. Run SQL with: clickhousectl cloud service query --id {} --query \"SELECT 1\"", - svc_id - ); - } - Err(e) => { - eprintln!( - "warning: failed to auto-provision Query API endpoint: {e}. \ - Run `clickhousectl cloud service query --id {}` later to retry.", - svc_id - ); - } - } + println!(); + println!( + "Run SQL with: clickhousectl cloud service query --id {} --query \"SELECT 1\"", + svc_id + ); + println!("(the Query API endpoint is provisioned automatically on first use)"); } Ok(()) } @@ -2871,7 +2851,6 @@ mod tests { disable_endpoints: vec![], private_preview_terms_checked: true, enable_core_dumps: Some(true), - no_enable_query: false, org_id: None, }; @@ -2911,7 +2890,6 @@ mod tests { disable_endpoints: vec![], private_preview_terms_checked: false, enable_core_dumps: None, - no_enable_query: false, org_id: None, }; @@ -2947,7 +2925,6 @@ mod tests { disable_endpoints: vec![], private_preview_terms_checked: false, enable_core_dumps: None, - no_enable_query: false, org_id: None, }; diff --git a/crates/clickhousectl/src/cloud/service_query.rs b/crates/clickhousectl/src/cloud/service_query.rs index 3c1ff25..f6959a0 100644 --- a/crates/clickhousectl/src/cloud/service_query.rs +++ b/crates/clickhousectl/src/cloud/service_query.rs @@ -65,8 +65,38 @@ pub async fn ensure_service_query_setup( // endpoints (GET/DELETE /v1/.../keys/{keyId}) accept. let api_key_uuid = key_response.key.id.to_string(); - // Merge our new key UUID into any existing endpoint config so we don't - // silently revoke other bindings the user set up. + let endpoint = match bind_query_endpoint(client, org_id, service_id, &api_key_uuid).await { + Ok(endpoint) => endpoint, + Err(e) => { + // The key was created but never bound or persisted, so nothing + // can use it. Delete it (best-effort) so a later retry doesn't + // leave an orphaned key behind per attempt. + let _ = client.delete_api_key(org_id, &api_key_uuid).await; + return Err(e); + } + }; + + let stored = ServiceQueryKey { + key_id, + key_secret, + endpoint_id: endpoint.id, + service_name: service_name.to_string(), + created_at: Utc::now(), + }; + credentials::set_service_query_key(service_id, stored.clone())?; + + Ok(stored) +} + +/// Bind `api_key_uuid` to the service's query endpoint, merging into any +/// existing endpoint configuration so we don't silently revoke other +/// bindings the user set up. +async fn bind_query_endpoint( + client: &CloudClient, + org_id: &str, + service_id: &str, + api_key_uuid: &str, +) -> Result> { let mut open_api_keys = match client .api() .instance_query_endpoint_get(org_id, service_id) @@ -76,8 +106,8 @@ pub async fn ensure_service_query_setup( Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => Vec::new(), Err(e) => return Err(client.convert_error(e).into()), }; - if !open_api_keys.contains(&api_key_uuid) { - open_api_keys.push(api_key_uuid); + if !open_api_keys.iter().any(|k| k == api_key_uuid) { + open_api_keys.push(api_key_uuid.to_string()); } let endpoint_request = InstanceServiceQueryApiEndpointsPostRequest { @@ -86,18 +116,7 @@ pub async fn ensure_service_query_setup( allowed_origins: ALLOWED_ORIGINS.to_string(), }; - let endpoint = client + Ok(client .create_query_endpoint(org_id, service_id, &endpoint_request) - .await?; - - let stored = ServiceQueryKey { - key_id, - key_secret, - endpoint_id: endpoint.id, - service_name: service_name.to_string(), - created_at: Utc::now(), - }; - credentials::set_service_query_key(service_id, stored.clone())?; - - Ok(stored) + .await?) } diff --git a/crates/clickhousectl/src/main.rs b/crates/clickhousectl/src/main.rs index af9e2e8..4e5b7fd 100644 --- a/crates/clickhousectl/src/main.rs +++ b/crates/clickhousectl/src/main.rs @@ -434,7 +434,6 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { disable_endpoint, private_preview_terms_checked, enable_core_dumps, - no_enable_query, org_id, } => { let opts = cloud::commands::CreateServiceOptions { @@ -461,7 +460,6 @@ async fn run_cloud(args: CloudArgs) -> Result<()> { disable_endpoints: disable_endpoint, private_preview_terms_checked, enable_core_dumps, - no_enable_query, org_id, }; cloud::commands::service_create(&client, opts, json).await