Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,12 @@ clickhousectl cloud service delete <service-id> --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.<service-id>`, 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.<service-id>`, 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`.

Expand Down
9 changes: 2 additions & 7 deletions crates/clickhousectl/src/cloud/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,11 +572,6 @@ CONTEXT FOR AGENTS:
#[arg(long)]
enable_core_dumps: Option<bool>,

/// 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<String>,
Expand Down Expand Up @@ -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 {
Expand Down
35 changes: 6 additions & 29 deletions crates/clickhousectl/src/cloud/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,6 @@ pub struct CreateServiceOptions {
pub disable_endpoints: Vec<String>,
pub private_preview_terms_checked: bool,
pub enable_core_dumps: Option<bool>,
pub no_enable_query: bool,
pub org_id: Option<String>,
}

Expand Down Expand Up @@ -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)?);
Expand All @@ -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(())
}
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
};

Expand Down
53 changes: 36 additions & 17 deletions crates/clickhousectl/src/cloud/service_query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<clickhouse_cloud_api::models::ServiceQueryAPIEndpoint, Box<dyn std::error::Error>> {
let mut open_api_keys = match client
.api()
.instance_query_endpoint_get(org_id, service_id)
Expand All @@ -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 {
Expand All @@ -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?)
}
2 changes: 0 additions & 2 deletions crates/clickhousectl/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down