use crate::cloud::client::CloudClient;
use crate::cloud::credentials::{self, ServiceQueryKey};
use chrono::Utc;
use clickhouse_cloud_api::models::{
ApiKeyPostRequest, ApiKeyPostRequestState, ApiKeyPostResponse,
InstanceServiceQueryApiEndpointsPostRequest, IpAccessListEntry,
};
const QUERY_ENDPOINT_ROLE: &str = "sql_console_admin";
const ALLOWED_ORIGINS: &str = "*";
fn require_field<T>(value: Option<T>, field: &str) -> Result<T, Box<dyn std::error::Error>> {
value.ok_or_else(|| format!("the API response is missing required field '{field}'").into())
}
fn require_credential_pair(
key_response: &ApiKeyPostResponse,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let key_id = require_field(key_response.key_id.clone(), "keyId")?;
let key_secret = require_field(key_response.key_secret.clone(), "keySecret")?;
Ok((key_id, key_secret))
}
async fn discard_api_key(client: &CloudClient, org_id: &str, api_key_uuid: &str) {
let _ = client.delete_api_key(org_id, api_key_uuid).await;
}
pub async fn ensure_service_query_setup(
client: &CloudClient,
org_id: &str,
service_id: &str,
service_name: &str,
) -> Result<ServiceQueryKey, Box<dyn std::error::Error>> {
if let Some(existing) = credentials::get_service_query_key(service_id) {
return Ok(existing);
}
let key_request = ApiKeyPostRequest {
name: format!("clickhousectl-query-{service_name}"),
assigned_role_ids: vec![],
expire_at: None,
hash_data: None,
ip_access_list: vec![IpAccessListEntry {
source: "0.0.0.0/0".to_string(),
description: Some(format!(
"clickhousectl auto-provisioned key for service {service_name}"
)),
}],
#[cfg(feature = "deprecated-fields")]
roles: None,
state: ApiKeyPostRequestState::Enabled,
};
let key_response = client.create_api_key(org_id, &key_request).await?;
let api_key_uuid =
require_field(key_response.key.as_ref().and_then(|key| key.id), "key.id")?.to_string();
let (key_id, key_secret) = match require_credential_pair(&key_response) {
Ok(pair) => pair,
Err(e) => {
discard_api_key(client, org_id, &api_key_uuid).await;
return Err(e);
}
};
let endpoint = match bind_query_endpoint(client, org_id, service_id, &api_key_uuid).await {
Ok(endpoint) => endpoint,
Err(e) => {
discard_api_key(client, 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)
}
fn existing_open_api_keys(
endpoint: Option<clickhouse_cloud_api::models::ServiceQueryAPIEndpoint>,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let incomplete = |field: &str| -> Box<dyn std::error::Error> {
format!(
"the query endpoint response is missing field '{field}', so the keys currently bound \
to the endpoint are unknown; binding a new key would revoke them"
)
.into()
};
endpoint
.ok_or_else(|| incomplete("result"))?
.open_api_keys
.ok_or_else(|| incomplete("openApiKeys"))
}
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)
.await
{
Ok(resp) => existing_open_api_keys(resp.result)?,
Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => Vec::new(),
Err(e) => return Err(client.convert_error(e).into()),
};
if !open_api_keys.iter().any(|k| k == api_key_uuid) {
open_api_keys.push(api_key_uuid.to_string());
}
let endpoint_request = InstanceServiceQueryApiEndpointsPostRequest {
roles: vec![QUERY_ENDPOINT_ROLE.to_string()],
open_api_keys,
allowed_origins: ALLOWED_ORIGINS.to_string(),
};
Ok(client
.create_query_endpoint(org_id, service_id, &endpoint_request)
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
fn key_response(key_id: Option<&str>, key_secret: Option<&str>) -> ApiKeyPostResponse {
ApiKeyPostResponse {
key: None,
key_id: key_id.map(str::to_string),
key_secret: key_secret.map(str::to_string),
}
}
#[test]
fn credential_pair_is_returned_when_both_halves_are_present() {
let (key_id, key_secret) =
require_credential_pair(&key_response(Some("k-1"), Some("s-1"))).unwrap();
assert_eq!(key_id, "k-1");
assert_eq!(key_secret, "s-1");
}
#[test]
fn credential_pair_fails_naming_the_absent_key_id() {
let err = require_credential_pair(&key_response(None, Some("s-1"))).unwrap_err();
assert_eq!(
err.to_string(),
"the API response is missing required field 'keyId'"
);
}
#[test]
fn credential_pair_fails_naming_the_absent_key_secret() {
let err = require_credential_pair(&key_response(Some("k-1"), None)).unwrap_err();
assert_eq!(
err.to_string(),
"the API response is missing required field 'keySecret'"
);
}
fn endpoint(
open_api_keys: Option<Vec<&str>>,
) -> clickhouse_cloud_api::models::ServiceQueryAPIEndpoint {
clickhouse_cloud_api::models::ServiceQueryAPIEndpoint {
allowed_origins: None,
id: Some("ep-1".to_string()),
open_api_keys: open_api_keys.map(|keys| keys.into_iter().map(str::to_string).collect()),
roles: None,
}
}
#[test]
fn existing_keys_are_returned_when_the_endpoint_reports_them() {
assert_eq!(
existing_open_api_keys(Some(endpoint(Some(vec!["uuid-a", "uuid-b"])))).unwrap(),
vec!["uuid-a".to_string(), "uuid-b".to_string()],
);
}
#[test]
fn an_explicitly_empty_key_list_is_a_real_answer() {
assert!(
existing_open_api_keys(Some(endpoint(Some(vec![]))))
.unwrap()
.is_empty()
);
}
#[test]
fn absent_open_api_keys_is_refused_rather_than_treated_as_empty() {
let err = existing_open_api_keys(Some(endpoint(None))).unwrap_err();
assert!(
err.to_string().contains("'openApiKeys'") && err.to_string().contains("revoke"),
"error should name the field and the consequence: {err}",
);
}
#[test]
fn absent_result_is_refused_rather_than_treated_as_empty() {
let err = existing_open_api_keys(None).unwrap_err();
assert!(
err.to_string().contains("'result'"),
"error should name the field: {err}",
);
}
}