diff --git a/crates/clickhousectl/src/cloud/services/cli.rs b/crates/clickhousectl/src/cloud/services/cli.rs index 442649ce..b778b0d3 100644 --- a/crates/clickhousectl/src/cloud/services/cli.rs +++ b/crates/clickhousectl/src/cloud/services/cli.rs @@ -21,6 +21,24 @@ pub enum ServiceCommands { service_id: NameSelector, }, + /// Write the service's connection settings to a .env file + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Writes CLICKHOUSE_HOST, CLICKHOUSE_PORT (native TLS), CLICKHOUSE_HTTP_PORT, CLICKHOUSE_USER and + CLICKHOUSE_SECURE to .env in the current directory (.env.local with --local). + No password: the API returns it only once, and `cloud service create --dotenv` writes it. + An existing file is edited in place: only these keys are replaced, so a CLICKHOUSE_PASSWORD + already in it is kept.")] + Dotenv { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Write to .env.local instead of .env + #[arg(long)] + local: bool, + }, + /// Discover available service profiles Profile { #[command(subcommand)] @@ -45,6 +63,7 @@ pub enum ServiceCommands { CONTEXT FOR AGENTS: Omitting --ip-allow opens the service to 0.0.0.0/0 (\"Allow all\"). The initial password is printed once — save it; `cloud service reset-password ` issues a new one. + --dotenv writes it and the connection settings to the file instead, keeping it out of the output. Returns before the service is up: poll `cloud service get ` until state is `running` (minutes).")] Create { /// Service name @@ -180,6 +199,17 @@ CONTEXT FOR AGENTS: /// Enable or disable service core dump collection #[arg(long)] enable_core_dumps: Option, + + /// Write connection settings and the password to FILE (.env if no FILE) + /// + /// An existing file is edited in place: only the CLICKHOUSE_* keys written are replaced. + #[arg( + long, + value_name = "FILE", + num_args = 0..=1, + default_missing_value = ".env" + )] + dotenv: Option, }, /// Delete a service @@ -850,6 +880,7 @@ impl ServiceCommands { match self { ServiceCommands::List { .. } => false, ServiceCommands::Get { .. } => false, + ServiceCommands::Dotenv { .. } => false, ServiceCommands::Profile { command } => command.is_write(), ServiceCommands::Settings { command } => command.is_write(), ServiceCommands::ScalingSchedule { command } => command.is_write(), @@ -1076,11 +1107,13 @@ mod tests { disable_endpoint, private_preview_terms_checked, enable_core_dumps, + dotenv, } = command else { panic!("expected service create"); }; + assert!(dotenv.is_none()); assert_eq!(name, "default-service"); assert_eq!(provider, "aws"); assert_eq!(region, "us-east-1"); @@ -1328,6 +1361,82 @@ mod tests { } } + #[test] + fn parses_service_dotenv_selector_and_local_flag() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "dotenv", + "svc-1", + "--org-id", + "org-1", + ]); + assert!(!command.is_write()); + let ServiceCommands::Dotenv { service_id, local } = command else { + panic!("expected service dotenv"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert!(!local); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "dotenv", + "--name", + "analytics", + "--local", + ]); + let ServiceCommands::Dotenv { service_id, local } = command else { + panic!("expected service dotenv"); + }; + assert_eq!(service_id.name.as_deref(), Some("analytics")); + assert!(service_id.id.is_none()); + assert!(local); + } + + #[test] + fn parses_service_create_dotenv_with_and_without_a_file() { + for (flags, expected) in [ + (vec!["--dotenv"], ".env"), + (vec!["--dotenv", ".env.local"], ".env.local"), + (vec!["--dotenv=config/app.env"], "config/app.env"), + // A following flag is not taken as the file. + (vec!["--dotenv", "--org-id", "org-1"], ".env"), + ] { + let mut args = vec![ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "svc", + ]; + args.extend(flags); + let ServiceCommands::Create { dotenv, .. } = parse_service(&args) else { + panic!("expected service create"); + }; + assert_eq!(dotenv.as_deref(), Some(std::path::Path::new(expected))); + } + + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "svc", + "--dotenv", + "a.env", + "--dotenv", + "b.env", + ]) + .err() + .expect("--dotenv is not repeatable"); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn parses_service_profile_list_region_or_byoc_flags() { for (flags, expected_region, expected_byoc) in [ diff --git a/crates/clickhousectl/src/cloud/services/lifecycle.rs b/crates/clickhousectl/src/cloud/services/lifecycle.rs index f67bc2b7..f2d6b002 100644 --- a/crates/clickhousectl/src/cloud/services/lifecycle.rs +++ b/crates/clickhousectl/src/cloud/services/lifecycle.rs @@ -9,15 +9,16 @@ use crate::cloud::shared::{parse_ip_access_entries, parse_serde_enum, parse_tags use clickhouse_cloud_api::models::{ AutoscalingMode, BackupEncryptionConfig, InstancePrivateEndpointsPatch, InstanceTagsPatch, IpAccessListEntry, IpAccessListPatch, Service, ServiceEndpoint, ServiceEndpointChange, - ServiceEndpointChangeProtocol, ServicePasswordPatchRequest, ServicePatchRequest, - ServicePatchRequestReleasechannel, ServicePostRequest, ServicePostRequestCompliancetype, - ServicePostRequestProfile, ServicePostRequestProvider, ServicePostRequestRegion, - ServicePostRequestReleasechannel, ServiceReplicaScalingPatchRequest, ServiceState, - ServiceStatePatchRequest, ServiceStatePatchRequestCommand, + ServiceEndpointChangeProtocol, ServiceEndpointProtocol, ServicePasswordPatchRequest, + ServicePatchRequest, ServicePatchRequestReleasechannel, ServicePostRequest, + ServicePostRequestCompliancetype, ServicePostRequestProfile, ServicePostRequestProvider, + ServicePostRequestRegion, ServicePostRequestReleasechannel, ServicePostResponse, + ServiceReplicaScalingPatchRequest, ServiceState, ServiceStatePatchRequest, + ServiceStatePatchRequestCommand, }; #[cfg(test)] use clickhouse_cloud_api::models::{IpAccessListEntryResponse, ResourceTagsV1Response}; -use std::{collections::HashSet, io::IsTerminal, time::Duration}; +use std::{collections::HashSet, io::IsTerminal, path::Path, time::Duration}; use tabled::{Table, Tabled, settings::Style}; /// Gap between `GET service` calls while `service delete --force` waits for a @@ -180,6 +181,98 @@ pub(super) async fn service_get( Ok(()) } +/// The `.env` connection settings for a service, with the same meanings as +/// `local clickhouse dotenv`: `CLICKHOUSE_PORT` is the native (TLS) port. Keys the +/// API response can't supply are left out rather than guessed. +fn service_dotenv_vars( + service: Option<&Service>, + password: Option<&str>, +) -> Vec<(&'static str, String)> { + let endpoint = |protocol: ServiceEndpointProtocol| { + service + .and_then(|service| service.endpoints.as_deref()) + .unwrap_or_default() + .iter() + .find(|endpoint| endpoint.protocol.as_ref() == Some(&protocol)) + }; + let native = endpoint(ServiceEndpointProtocol::Nativesecure); + let https = endpoint(ServiceEndpointProtocol::Https); + + let mut vars = Vec::new(); + if let Some(host) = native + .and_then(|endpoint| endpoint.host.clone()) + .or_else(|| https.and_then(|endpoint| endpoint.host.clone())) + { + vars.push(("CLICKHOUSE_HOST", host)); + } + if let Some(port) = native.and_then(|endpoint| endpoint.port) { + vars.push(("CLICKHOUSE_PORT", port.to_string())); + } + if let Some(port) = https.and_then(|endpoint| endpoint.port) { + vars.push(("CLICKHOUSE_HTTP_PORT", port.to_string())); + } + vars.push(("CLICKHOUSE_USER", "default".to_string())); + vars.push(("CLICKHOUSE_SECURE", "true".to_string())); + if let Some(password) = password { + vars.push(("CLICKHOUSE_PASSWORD", password.to_string())); + } + vars +} + +#[derive(serde::Serialize)] +struct ServiceDotenvOutput { + file: &'static str, + service: String, + vars: Vec, +} + +#[derive(serde::Serialize)] +struct ServiceDotenvVar { + key: &'static str, + value: String, +} + +pub(super) async fn service_dotenv( + client: &CloudClient, + service_id: &str, + use_local: bool, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let service = client.get_service(&org_id, service_id).await?; + let vars = service_dotenv_vars(Some(&service), None); + if !vars.iter().any(|(key, _)| *key == "CLICKHOUSE_HOST") { + return Err(CloudError::new(format!( + "the API returned no endpoints for service {service_id}, so there is no host to \ + write; check `clickhousectl cloud service get {service_id}`" + ))); + } + + let file = if use_local { ".env.local" } else { ".env" }; + crate::dotenv::write_vars(Path::new(file), "CLICKHOUSE_", &vars)?; + + let output = ServiceDotenvOutput { + file, + service: service + .name + .clone() + .unwrap_or_else(|| service_id.to_string()), + vars: vars + .into_iter() + .map(|(key, value)| ServiceDotenvVar { key, value }) + .collect(), + }; + if json { + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Wrote to {} (service '{}')", output.file, output.service); + for var in &output.vars { + println!(" {}={}", var.key, var.value); + } + } + Ok(()) +} + #[derive(Default)] pub(super) struct CreateServiceOptions { pub(super) name: String, @@ -611,17 +704,98 @@ fn service_credentials_block(password: Option<&str>, service_id: Option, json: bool, ) -> CloudResult<()> { + // The password is returned only once, so find an unwritable file before + // the service exists rather than after. + if let Some(path) = dotenv { + check_dotenv_target(path)?; + } let request = build_create_service_request(&options)?; let org_id = resolve_org_id(client).await?; validate_dynamic_byoc_profile(client, &org_id, &request).await?; let response = client.create_service(&org_id, &request).await?; + let service_id = response.service.as_ref().and_then(|service| service.id); + + let written = match dotenv { + None => None, + Some(path) => { + let vars = service_dotenv_vars(response.service.as_ref(), response.password.as_deref()); + if let Err(error) = crate::dotenv::write_vars(path, "CLICKHOUSE_", &vars) { + // Print the password as if --dotenv were absent, so the + // credential is never lost, then fail the command. + print_created_service(&response, service_id, json)?; + let message = format!( + "the service was created, but writing {} failed: {error}. The one-time \ + password is in the output above.", + path.display() + ); + return Err(CloudError { + message, + ..CloudError::from(error) + }); + } + if !vars.iter().any(|(key, _)| *key == "CLICKHOUSE_HOST") { + eprint_line(format!( + "Warning: the API response had no endpoints, so {} has no host or ports. \ + Add them later with `clickhousectl cloud service dotenv {}`.", + path.display(), + service_id.map_or_else(|| "".to_string(), |id| id.to_string()) + )); + } + Some(DotenvWritten { + file: path.display().to_string(), + keys: vars.into_iter().map(|(key, _)| key).collect(), + }) + } + }; + + match written { + None => print_created_service(&response, service_id, json)?, + Some(written) if json => { + let mut value = serde_json::to_value(&response)?; + if let Some(object) = value.as_object_mut() { + object.remove("password"); + object.insert("dotenv".to_string(), serde_json::to_value(&written)?); + } + println!("{}", serde_json::to_string_pretty(&value)?); + } + Some(written) => { + println!("Service created successfully!"); + println!(); + if let Some(service) = &response.service { + print_human(service)?; + } + println!(); + if response.password.is_some() { + println!( + "Credentials: username default; the one-time password was written to {} as \ + CLICKHOUSE_PASSWORD (not shown).", + written.file + ); + } else { + println!("{}", service_credentials_block(None, service_id)); + } + println!("Wrote {} to {}", written.keys.join(", "), written.file); + if let Some(hint) = service_query_hint(service_id) { + println!(); + println!("{}", hint); + } + } + } + Ok(()) +} +/// The create output without `--dotenv`, which shows the one-time password. +fn print_created_service( + response: &ServicePostResponse, + service_id: Option, + json: bool, +) -> CloudResult<()> { if json { - println!("{}", serde_json::to_string_pretty(&response)?); + println!("{}", serde_json::to_string_pretty(response)?); } else { - let service_id = response.service.as_ref().and_then(|service| service.id); println!("Service created successfully!"); println!(); if let Some(service) = &response.service { @@ -640,6 +814,48 @@ pub(super) async fn service_create( Ok(()) } +/// What `create --dotenv` wrote, reported in place of the password. +#[derive(serde::Serialize)] +struct DotenvWritten { + file: String, + keys: Vec<&'static str>, +} + +/// Reject a `--dotenv` target that cannot be written, before the service is +/// created. Opening for append checks write access without changing the file. +fn check_dotenv_target(path: &Path) -> CloudResult<()> { + if path.exists() { + if !path.is_file() { + return Err(CloudError::usage(format!( + "--dotenv {} is not a regular file", + path.display() + ))); + } + let with_path = |error: std::io::Error| CloudError { + message: format!("--dotenv {}: {error}", path.display()), + ..CloudError::from(error) + }; + std::fs::read_to_string(path).map_err(with_path)?; + std::fs::OpenOptions::new() + .append(true) + .open(path) + .map_err(with_path)?; + return Ok(()); + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + if !parent.is_dir() { + return Err(CloudError::usage(format!( + "--dotenv {}: directory {} does not exist", + path.display(), + parent.display() + ))); + } + Ok(()) +} + async fn validate_dynamic_byoc_profile( client: &CloudClient, org_id: &str, @@ -2007,4 +2223,83 @@ mod tests { assert_eq!(stop.command, Some(ServiceStatePatchRequestCommand::Stop)); assert_eq!(awake.command, Some(ServiceStatePatchRequestCommand::Awake)); } + + fn dotenv_test_service(endpoints: serde_json::Value) -> Service { + serde_json::from_value(serde_json::json!({ "endpoints": endpoints })).unwrap() + } + + #[test] + fn service_dotenv_vars_reads_native_and_https_endpoints() { + let service = dotenv_test_service(serde_json::json!([ + {"protocol": "https", "host": "abc.example.clickhouse.cloud", "port": 8443}, + {"protocol": "mysql", "host": "mysql.example", "port": 3306}, + {"protocol": "nativesecure", "host": "abc.example.clickhouse.cloud", "port": 9440} + ])); + assert_eq!( + service_dotenv_vars(Some(&service), Some("secret")), + vec![ + ( + "CLICKHOUSE_HOST", + "abc.example.clickhouse.cloud".to_string() + ), + ("CLICKHOUSE_PORT", "9440".to_string()), + ("CLICKHOUSE_HTTP_PORT", "8443".to_string()), + ("CLICKHOUSE_USER", "default".to_string()), + ("CLICKHOUSE_SECURE", "true".to_string()), + ("CLICKHOUSE_PASSWORD", "secret".to_string()), + ] + ); + } + + #[test] + fn service_dotenv_vars_leaves_out_what_the_response_lacks() { + let https_only = dotenv_test_service(serde_json::json!([ + {"protocol": "https", "host": "abc.example", "port": 8443}, + {"protocol": "nativesecure", "host": "abc.example"} + ])); + assert_eq!( + service_dotenv_vars(Some(&https_only), None), + vec![ + ("CLICKHOUSE_HOST", "abc.example".to_string()), + ("CLICKHOUSE_HTTP_PORT", "8443".to_string()), + ("CLICKHOUSE_USER", "default".to_string()), + ("CLICKHOUSE_SECURE", "true".to_string()), + ] + ); + + let expected_bare = vec![ + ("CLICKHOUSE_USER", "default".to_string()), + ("CLICKHOUSE_SECURE", "true".to_string()), + ]; + assert_eq!(service_dotenv_vars(None, None), expected_bare); + let no_endpoints: Service = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!( + service_dotenv_vars(Some(&no_endpoints), None), + expected_bare + ); + } + + #[test] + fn check_dotenv_target_accepts_writable_files_and_rejects_bad_paths() { + let dir = tempfile::tempdir().unwrap(); + let existing = dir.path().join(".env"); + std::fs::write(&existing, "KEEP=1\n").unwrap(); + + check_dotenv_target(&existing).expect("existing file"); + check_dotenv_target(&dir.path().join("new.env")).expect("new file in existing directory"); + check_dotenv_target(Path::new(".env")).expect("bare file name in the current directory"); + assert_eq!(std::fs::read_to_string(&existing).unwrap(), "KEEP=1\n"); + assert!(!dir.path().join("new.env").exists()); + + for path in [ + dir.path().to_path_buf(), + dir.path().join("missing").join(".env"), + ] { + let error = check_dotenv_target(&path).expect_err("must be rejected"); + assert!( + matches!(error.kind, crate::cloud::client::CloudErrorKind::Usage), + "{path:?}" + ); + } + } } diff --git a/crates/clickhousectl/src/cloud/services/mod.rs b/crates/clickhousectl/src/cloud/services/mod.rs index 5b78f7a2..4c23ec92 100644 --- a/crates/clickhousectl/src/cloud/services/mod.rs +++ b/crates/clickhousectl/src/cloud/services/mod.rs @@ -16,6 +16,8 @@ pub(super) const PERMISSIONS: &[Permission] = &[ Permission::api("service list", &[&op::INSTANCE_GET_LIST]), Permission::api("service get", &[&op::INSTANCE_GET]) .when(&[Conditional::flag("name", &[&op::INSTANCE_GET_LIST])]), + Permission::api("service dotenv", &[&op::INSTANCE_GET]) + .when(&[Conditional::flag("name", &[&op::INSTANCE_GET_LIST])]), Permission::api("service profile list", &[&op::SERVICE_PROFILES_LIST]), Permission::api( "service settings list", @@ -182,9 +184,9 @@ use endpoints::{ }; use lifecycle::{ CreateServiceOptions, ServiceResetPasswordOptions, ServiceScaleOptions, ServiceUpdateOptions, - read_backup_encryption_config, service_create, service_delete, service_get, service_list, - service_prometheus, service_reset_password, service_scale, service_start, service_stop, - service_update, service_wake, + read_backup_encryption_config, service_create, service_delete, service_dotenv, service_get, + service_list, service_prometheus, service_reset_password, service_scale, service_start, + service_stop, service_update, service_wake, }; use query::{ServiceQueryOptions, service_query, service_query_key_repair}; use scaling_schedule::{scaling_schedule_delete, scaling_schedule_get, scaling_schedule_set}; @@ -208,6 +210,15 @@ pub async fn run(client: &CloudClient, command: ServiceCommands, json: bool) -> ) .await } + ServiceCommands::Dotenv { service_id, local } => { + service_dotenv( + client, + &service_id.resolve(client, NamedResource::Service).await?, + local, + json, + ) + .await + } ServiceCommands::Profile { command } => match command { ServiceProfileCommands::List { region, byoc_id } => { service_profile_list(client, region.as_deref(), byoc_id.as_deref(), json).await @@ -319,6 +330,7 @@ pub async fn run(client: &CloudClient, command: ServiceCommands, json: bool) -> disable_endpoint, private_preview_terms_checked, enable_core_dumps, + dotenv, } => { let options = CreateServiceOptions { name, @@ -351,7 +363,7 @@ pub async fn run(client: &CloudClient, command: ServiceCommands, json: bool) -> private_preview_terms_checked, enable_core_dumps, }; - service_create(client, options, json).await + service_create(client, options, dotenv.as_deref(), json).await } ServiceCommands::Delete { service_id, force } => { service_delete( diff --git a/crates/clickhousectl/src/dotenv.rs b/crates/clickhousectl/src/dotenv.rs index 1eecc894..d79328fd 100644 --- a/crates/clickhousectl/src/dotenv.rs +++ b/crates/clickhousectl/src/dotenv.rs @@ -96,6 +96,101 @@ pub fn get() -> &'static DotenvVars { DOTENV.get_or_init(load) } +/// Format a dotenv line. Values that are plain alphanumeric tokens are written +/// bare; anything containing spaces, `#`, quotes, backslashes, or newlines is +/// double-quoted with inner `"`, `\`, and newlines escaped. +fn format_dotenv_line(prefix: &str, key: &str, val: &str) -> String { + let needs_quoting = val.is_empty() + || val + .bytes() + .any(|b| b == b' ' || b == b'#' || b == b'"' || b == b'\'' || b == b'\\' || b == b'\n'); + + if needs_quoting { + let escaped = val + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n"); + format!("{}{}=\"{}\"", prefix, key, escaped) + } else { + format!("{}{}={}", prefix, key, val) + } +} + +/// Extract a `*` key from a dotenv line, handling optional `export` +/// prefix and whitespace around `=`. Returns the bare key (e.g. "CLICKHOUSE_HOST" +/// for prefix "CLICKHOUSE_") or None if the line isn't a matching assignment. +fn extract_dotenv_key<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + let s = line.trim(); + let s = s + .strip_prefix("export") + .map(|rest| rest.trim_start()) + .unwrap_or(s); + let eq_pos = s.find('=')?; + let key = s[..eq_pos].trim_end(); + if key.starts_with(prefix) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + Some(key) + } else { + None + } +} + +/// Update an existing .env file: replace `*` vars in-place, append any +/// missing ones. Lines for the same prefix that aren't in `vars` are preserved +/// (e.g. a manually-set CLICKHOUSE_PASSWORD survives a host/port-only update). +fn update_dotenv(existing: &str, prefix: &str, vars: &[(&str, String)]) -> String { + let mut result = String::new(); + let mut written: std::collections::HashSet<&str> = std::collections::HashSet::new(); + + for line in existing.lines() { + if let Some(key) = extract_dotenv_key(line, prefix) { + if let Some((_, val)) = vars.iter().find(|(k, _)| *k == key) { + let line_prefix = if line.trim_start().starts_with("export") { + "export " + } else { + "" + }; + result.push_str(&format_dotenv_line(line_prefix, key, val)); + written.insert(key); + } else { + // A matching-prefix var we don't manage — keep as-is + result.push_str(line); + } + } else { + result.push_str(line); + } + result.push('\n'); + } + + for (key, val) in vars { + if !written.contains(key) { + result.push_str(&format_dotenv_line("", key, val)); + result.push('\n'); + } + } + + result +} + +/// Write `vars` to the dotenv file at `path`. An existing file is edited in +/// place with [`update_dotenv`]; otherwise a new file holds just these vars. +pub(crate) fn write_vars( + path: &Path, + prefix: &str, + vars: &[(&str, String)], +) -> std::io::Result<()> { + let content = if path.exists() { + let existing = std::fs::read_to_string(path)?; + update_dotenv(&existing, prefix, vars) + } else { + vars.iter() + .map(|(k, v)| format_dotenv_line("", k, v)) + .collect::>() + .join("\n") + + "\n" + }; + std::fs::write(path, content) +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +274,262 @@ mod tests { Some("value with spaces") ); } + + #[test] + fn write_vars_creates_a_file_or_edits_one_in_place() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(".env"); + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PORT", "9000".to_string()), + ]; + write_vars(&path, "CLICKHOUSE_", &vars).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\n" + ); + + fs::write( + &path, + "APP=1\nCLICKHOUSE_PASSWORD=kept\nCLICKHOUSE_HOST=old\n", + ) + .unwrap(); + write_vars(&path, "CLICKHOUSE_", &vars).unwrap(); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "APP=1\nCLICKHOUSE_PASSWORD=kept\nCLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\n" + ); + } + + #[test] + fn update_dotenv_postgres_prefix_isolates_clickhouse_vars() { + let existing = "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\nDATABASE_URL=x\n"; + let vars = vec![ + ("POSTGRES_HOST", "localhost".to_string()), + ("POSTGRES_PORT", "5432".to_string()), + ]; + let result = update_dotenv(existing, "POSTGRES_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + assert!(result.contains("CLICKHOUSE_PORT=9000")); + assert!(result.contains("POSTGRES_HOST=localhost")); + assert!(result.contains("POSTGRES_PORT=5432")); + } + + #[test] + fn extract_dotenv_key_postgres_prefix() { + assert_eq!( + extract_dotenv_key("POSTGRES_USER=postgres", "POSTGRES_"), + Some("POSTGRES_USER") + ); + assert_eq!(extract_dotenv_key("CLICKHOUSE_HOST=x", "POSTGRES_"), None); + } + + #[test] + fn update_dotenv_creates_fresh_content() { + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PORT", "9000".to_string()), + ]; + let result = update_dotenv("", "CLICKHOUSE_", &vars); + assert_eq!(result, "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\n"); + } + + #[test] + fn update_dotenv_replaces_existing_vars() { + let existing = + "CLICKHOUSE_HOST=oldhost\nDATABASE_URL=postgres://...\nCLICKHOUSE_PORT=1234\n"; + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PORT", "9000".to_string()), + ]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + assert!(result.contains("CLICKHOUSE_PORT=9000")); + assert!(result.contains("DATABASE_URL=postgres://...")); + assert!(!result.contains("oldhost")); + assert!(!result.contains("1234")); + } + + #[test] + fn update_dotenv_preserves_non_clickhouse_vars() { + let existing = "FOO=bar\nBAZ=qux\n"; + let vars = vec![("CLICKHOUSE_HOST", "localhost".to_string())]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("FOO=bar")); + assert!(result.contains("BAZ=qux")); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + } + + #[test] + fn update_dotenv_appends_missing_vars() { + let existing = "CLICKHOUSE_HOST=localhost\n"; + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PORT", "9000".to_string()), + ]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + assert!(result.contains("CLICKHOUSE_PORT=9000")); + } + + #[test] + fn update_dotenv_handles_export_prefix() { + let existing = "export CLICKHOUSE_HOST=oldhost\nexport CLICKHOUSE_PORT=1234\n"; + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PORT", "9000".to_string()), + ]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("export CLICKHOUSE_HOST=localhost")); + assert!(result.contains("export CLICKHOUSE_PORT=9000")); + assert!(!result.contains("oldhost")); + assert!(!result.contains("1234")); + } + + #[test] + fn update_dotenv_handles_spaces_around_equals() { + let existing = "CLICKHOUSE_HOST = oldhost\n"; + let vars = vec![("CLICKHOUSE_HOST", "localhost".to_string())]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + assert!(!result.contains("oldhost")); + } + + #[test] + fn update_dotenv_handles_export_with_spaces() { + let existing = "export CLICKHOUSE_PORT = 1234\nDATABASE_URL=postgres://...\n"; + let vars = vec![("CLICKHOUSE_PORT", "9000".to_string())]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("export CLICKHOUSE_PORT=9000")); + assert!(result.contains("DATABASE_URL=postgres://...")); + assert!(!result.contains("1234")); + } + + #[test] + fn update_dotenv_preserves_unmanaged_clickhouse_vars() { + let existing = "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PASSWORD=secret\n"; + // Only updating HOST — PASSWORD should be left alone + let vars = vec![("CLICKHOUSE_HOST", "newhost".to_string())]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=newhost")); + assert!(result.contains("CLICKHOUSE_PASSWORD=secret")); + } + + #[test] + fn extract_dotenv_key_simple() { + assert_eq!( + extract_dotenv_key("CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), + Some("CLICKHOUSE_HOST") + ); + } + + #[test] + fn extract_dotenv_key_with_export() { + assert_eq!( + extract_dotenv_key("export CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), + Some("CLICKHOUSE_HOST") + ); + } + + #[test] + fn extract_dotenv_key_with_spaces() { + assert_eq!( + extract_dotenv_key("CLICKHOUSE_HOST = localhost", "CLICKHOUSE_"), + Some("CLICKHOUSE_HOST") + ); + assert_eq!( + extract_dotenv_key("export CLICKHOUSE_HOST = localhost", "CLICKHOUSE_"), + Some("CLICKHOUSE_HOST") + ); + } + + #[test] + fn extract_dotenv_key_non_clickhouse() { + assert_eq!( + extract_dotenv_key("DATABASE_URL=postgres://...", "CLICKHOUSE_"), + None + ); + assert_eq!(extract_dotenv_key("export FOO=bar", "CLICKHOUSE_"), None); + } + + #[test] + fn extract_dotenv_key_comment_and_blank() { + assert_eq!( + extract_dotenv_key("# CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), + None + ); + assert_eq!(extract_dotenv_key("", "CLICKHOUSE_"), None); + } + + #[test] + fn format_dotenv_line_plain_value() { + assert_eq!(format_dotenv_line("", "KEY", "value"), "KEY=value"); + } + + #[test] + fn format_dotenv_line_with_prefix() { + assert_eq!( + format_dotenv_line("export ", "KEY", "value"), + "export KEY=value" + ); + } + + #[test] + fn format_dotenv_line_quotes_spaces() { + assert_eq!( + format_dotenv_line("", "CLICKHOUSE_PASSWORD", "my secret"), + r#"CLICKHOUSE_PASSWORD="my secret""# + ); + } + + #[test] + fn format_dotenv_line_quotes_hash() { + assert_eq!( + format_dotenv_line("", "CLICKHOUSE_PASSWORD", "pass#123"), + r#"CLICKHOUSE_PASSWORD="pass#123""# + ); + } + + #[test] + fn format_dotenv_line_escapes_quotes_and_backslashes() { + assert_eq!( + format_dotenv_line("", "CLICKHOUSE_PASSWORD", r#"a"b\c"#), + r#"CLICKHOUSE_PASSWORD="a\"b\\c""# + ); + } + + #[test] + fn format_dotenv_line_escapes_newlines() { + assert_eq!( + format_dotenv_line("", "CLICKHOUSE_PASSWORD", "line1\nline2"), + r#"CLICKHOUSE_PASSWORD="line1\nline2""# + ); + } + + #[test] + fn format_dotenv_line_quotes_empty_value() { + assert_eq!( + format_dotenv_line("", "CLICKHOUSE_PASSWORD", ""), + r#"CLICKHOUSE_PASSWORD="""# + ); + } + + #[test] + fn update_dotenv_quotes_special_values() { + let vars = vec![ + ("CLICKHOUSE_HOST", "localhost".to_string()), + ("CLICKHOUSE_PASSWORD", "my secret#123".to_string()), + ]; + let result = update_dotenv("", "CLICKHOUSE_", &vars); + assert!(result.contains("CLICKHOUSE_HOST=localhost")); + assert!(result.contains(r#"CLICKHOUSE_PASSWORD="my secret#123""#)); + } + + #[test] + fn update_dotenv_quotes_when_replacing_in_place() { + let existing = "CLICKHOUSE_PASSWORD=old\n"; + let vars = vec![("CLICKHOUSE_PASSWORD", "new pass".to_string())]; + let result = update_dotenv(existing, "CLICKHOUSE_", &vars); + assert!(result.contains(r#"CLICKHOUSE_PASSWORD="new pass""#)); + } } diff --git a/crates/clickhousectl/src/local/mod.rs b/crates/clickhousectl/src/local/mod.rs index d83a4b22..ed8684fd 100644 --- a/crates/clickhousectl/src/local/mod.rs +++ b/crates/clickhousectl/src/local/mod.rs @@ -826,18 +826,7 @@ fn dotenv_server( let filename = if use_local { ".env.local" } else { ".env" }; let path = std::path::Path::new(filename); - let content = if path.exists() { - let existing = std::fs::read_to_string(path)?; - update_dotenv(&existing, "CLICKHOUSE_", &vars) - } else { - vars.iter() - .map(|(k, v)| format_dotenv_line("", k, v)) - .collect::>() - .join("\n") - + "\n" - }; - - std::fs::write(path, &content)?; + crate::dotenv::write_vars(path, "CLICKHOUSE_", &vars)?; let out = output::ServerDotenvOutput { file: filename.to_string(), @@ -854,81 +843,6 @@ fn dotenv_server( Ok(()) } -/// Format a dotenv line. Values that are plain alphanumeric tokens are written -/// bare; anything containing spaces, `#`, quotes, backslashes, or newlines is -/// double-quoted with inner `"`, `\`, and newlines escaped. -pub(crate) fn format_dotenv_line(prefix: &str, key: &str, val: &str) -> String { - let needs_quoting = val.is_empty() - || val - .bytes() - .any(|b| b == b' ' || b == b'#' || b == b'"' || b == b'\'' || b == b'\\' || b == b'\n'); - - if needs_quoting { - let escaped = val - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', "\\n"); - format!("{}{}=\"{}\"", prefix, key, escaped) - } else { - format!("{}{}={}", prefix, key, val) - } -} - -/// Extract a `*` key from a dotenv line, handling optional `export` -/// prefix and whitespace around `=`. Returns the bare key (e.g. "CLICKHOUSE_HOST" -/// for prefix "CLICKHOUSE_") or None if the line isn't a matching assignment. -fn extract_dotenv_key<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { - let s = line.trim(); - let s = s - .strip_prefix("export") - .map(|rest| rest.trim_start()) - .unwrap_or(s); - let eq_pos = s.find('=')?; - let key = s[..eq_pos].trim_end(); - if key.starts_with(prefix) && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { - Some(key) - } else { - None - } -} - -/// Update an existing .env file: replace `*` vars in-place, append any -/// missing ones. Lines for the same prefix that aren't in `vars` are preserved -/// (e.g. a manually-set CLICKHOUSE_PASSWORD survives a host/port-only update). -pub(crate) fn update_dotenv(existing: &str, prefix: &str, vars: &[(&str, String)]) -> String { - let mut result = String::new(); - let mut written: std::collections::HashSet<&str> = std::collections::HashSet::new(); - - for line in existing.lines() { - if let Some(key) = extract_dotenv_key(line, prefix) { - if let Some((_, val)) = vars.iter().find(|(k, _)| *k == key) { - let line_prefix = if line.trim_start().starts_with("export") { - "export " - } else { - "" - }; - result.push_str(&format_dotenv_line(line_prefix, key, val)); - written.insert(key); - } else { - // A matching-prefix var we don't manage — keep as-is - result.push_str(line); - } - } else { - result.push_str(line); - } - result.push('\n'); - } - - for (key, val) in vars { - if !written.contains(key) { - result.push_str(&format_dotenv_line("", key, val)); - result.push('\n'); - } - } - - result -} - async fn run_server_commands(command: ServerCommands, json: bool) -> Result<()> { match command { ServerCommands::Start { @@ -1739,236 +1653,4 @@ mod tests { assert!(output.servers[2].stopped); assert_eq!(output.servers[2].error, None); } - - #[test] - fn update_dotenv_postgres_prefix_isolates_clickhouse_vars() { - let existing = "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\nDATABASE_URL=x\n"; - let vars = vec![ - ("POSTGRES_HOST", "localhost".to_string()), - ("POSTGRES_PORT", "5432".to_string()), - ]; - let result = update_dotenv(existing, "POSTGRES_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - assert!(result.contains("CLICKHOUSE_PORT=9000")); - assert!(result.contains("POSTGRES_HOST=localhost")); - assert!(result.contains("POSTGRES_PORT=5432")); - } - - #[test] - fn extract_dotenv_key_postgres_prefix() { - assert_eq!( - extract_dotenv_key("POSTGRES_USER=postgres", "POSTGRES_"), - Some("POSTGRES_USER") - ); - assert_eq!(extract_dotenv_key("CLICKHOUSE_HOST=x", "POSTGRES_"), None); - } - - #[test] - fn update_dotenv_creates_fresh_content() { - let vars = vec![ - ("CLICKHOUSE_HOST", "localhost".to_string()), - ("CLICKHOUSE_PORT", "9000".to_string()), - ]; - let result = update_dotenv("", "CLICKHOUSE_", &vars); - assert_eq!(result, "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PORT=9000\n"); - } - - #[test] - fn update_dotenv_replaces_existing_vars() { - let existing = - "CLICKHOUSE_HOST=oldhost\nDATABASE_URL=postgres://...\nCLICKHOUSE_PORT=1234\n"; - let vars = vec![ - ("CLICKHOUSE_HOST", "localhost".to_string()), - ("CLICKHOUSE_PORT", "9000".to_string()), - ]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - assert!(result.contains("CLICKHOUSE_PORT=9000")); - assert!(result.contains("DATABASE_URL=postgres://...")); - assert!(!result.contains("oldhost")); - assert!(!result.contains("1234")); - } - - #[test] - fn update_dotenv_preserves_non_clickhouse_vars() { - let existing = "FOO=bar\nBAZ=qux\n"; - let vars = vec![("CLICKHOUSE_HOST", "localhost".to_string())]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("FOO=bar")); - assert!(result.contains("BAZ=qux")); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - } - - #[test] - fn update_dotenv_appends_missing_vars() { - let existing = "CLICKHOUSE_HOST=localhost\n"; - let vars = vec![ - ("CLICKHOUSE_HOST", "localhost".to_string()), - ("CLICKHOUSE_PORT", "9000".to_string()), - ]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - assert!(result.contains("CLICKHOUSE_PORT=9000")); - } - - #[test] - fn update_dotenv_handles_export_prefix() { - let existing = "export CLICKHOUSE_HOST=oldhost\nexport CLICKHOUSE_PORT=1234\n"; - let vars = vec![ - ("CLICKHOUSE_HOST", "localhost".to_string()), - ("CLICKHOUSE_PORT", "9000".to_string()), - ]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("export CLICKHOUSE_HOST=localhost")); - assert!(result.contains("export CLICKHOUSE_PORT=9000")); - assert!(!result.contains("oldhost")); - assert!(!result.contains("1234")); - } - - #[test] - fn update_dotenv_handles_spaces_around_equals() { - let existing = "CLICKHOUSE_HOST = oldhost\n"; - let vars = vec![("CLICKHOUSE_HOST", "localhost".to_string())]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - assert!(!result.contains("oldhost")); - } - - #[test] - fn update_dotenv_handles_export_with_spaces() { - let existing = "export CLICKHOUSE_PORT = 1234\nDATABASE_URL=postgres://...\n"; - let vars = vec![("CLICKHOUSE_PORT", "9000".to_string())]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("export CLICKHOUSE_PORT=9000")); - assert!(result.contains("DATABASE_URL=postgres://...")); - assert!(!result.contains("1234")); - } - - #[test] - fn update_dotenv_preserves_unmanaged_clickhouse_vars() { - let existing = "CLICKHOUSE_HOST=localhost\nCLICKHOUSE_PASSWORD=secret\n"; - // Only updating HOST — PASSWORD should be left alone - let vars = vec![("CLICKHOUSE_HOST", "newhost".to_string())]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=newhost")); - assert!(result.contains("CLICKHOUSE_PASSWORD=secret")); - } - - #[test] - fn extract_dotenv_key_simple() { - assert_eq!( - extract_dotenv_key("CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), - Some("CLICKHOUSE_HOST") - ); - } - - #[test] - fn extract_dotenv_key_with_export() { - assert_eq!( - extract_dotenv_key("export CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), - Some("CLICKHOUSE_HOST") - ); - } - - #[test] - fn extract_dotenv_key_with_spaces() { - assert_eq!( - extract_dotenv_key("CLICKHOUSE_HOST = localhost", "CLICKHOUSE_"), - Some("CLICKHOUSE_HOST") - ); - assert_eq!( - extract_dotenv_key("export CLICKHOUSE_HOST = localhost", "CLICKHOUSE_"), - Some("CLICKHOUSE_HOST") - ); - } - - #[test] - fn extract_dotenv_key_non_clickhouse() { - assert_eq!( - extract_dotenv_key("DATABASE_URL=postgres://...", "CLICKHOUSE_"), - None - ); - assert_eq!(extract_dotenv_key("export FOO=bar", "CLICKHOUSE_"), None); - } - - #[test] - fn extract_dotenv_key_comment_and_blank() { - assert_eq!( - extract_dotenv_key("# CLICKHOUSE_HOST=localhost", "CLICKHOUSE_"), - None - ); - assert_eq!(extract_dotenv_key("", "CLICKHOUSE_"), None); - } - - #[test] - fn format_dotenv_line_plain_value() { - assert_eq!(format_dotenv_line("", "KEY", "value"), "KEY=value"); - } - - #[test] - fn format_dotenv_line_with_prefix() { - assert_eq!( - format_dotenv_line("export ", "KEY", "value"), - "export KEY=value" - ); - } - - #[test] - fn format_dotenv_line_quotes_spaces() { - assert_eq!( - format_dotenv_line("", "CLICKHOUSE_PASSWORD", "my secret"), - r#"CLICKHOUSE_PASSWORD="my secret""# - ); - } - - #[test] - fn format_dotenv_line_quotes_hash() { - assert_eq!( - format_dotenv_line("", "CLICKHOUSE_PASSWORD", "pass#123"), - r#"CLICKHOUSE_PASSWORD="pass#123""# - ); - } - - #[test] - fn format_dotenv_line_escapes_quotes_and_backslashes() { - assert_eq!( - format_dotenv_line("", "CLICKHOUSE_PASSWORD", r#"a"b\c"#), - r#"CLICKHOUSE_PASSWORD="a\"b\\c""# - ); - } - - #[test] - fn format_dotenv_line_escapes_newlines() { - assert_eq!( - format_dotenv_line("", "CLICKHOUSE_PASSWORD", "line1\nline2"), - r#"CLICKHOUSE_PASSWORD="line1\nline2""# - ); - } - - #[test] - fn format_dotenv_line_quotes_empty_value() { - assert_eq!( - format_dotenv_line("", "CLICKHOUSE_PASSWORD", ""), - r#"CLICKHOUSE_PASSWORD="""# - ); - } - - #[test] - fn update_dotenv_quotes_special_values() { - let vars = vec![ - ("CLICKHOUSE_HOST", "localhost".to_string()), - ("CLICKHOUSE_PASSWORD", "my secret#123".to_string()), - ]; - let result = update_dotenv("", "CLICKHOUSE_", &vars); - assert!(result.contains("CLICKHOUSE_HOST=localhost")); - assert!(result.contains(r#"CLICKHOUSE_PASSWORD="my secret#123""#)); - } - - #[test] - fn update_dotenv_quotes_when_replacing_in_place() { - let existing = "CLICKHOUSE_PASSWORD=old\n"; - let vars = vec![("CLICKHOUSE_PASSWORD", "new pass".to_string())]; - let result = update_dotenv(existing, "CLICKHOUSE_", &vars); - assert!(result.contains(r#"CLICKHOUSE_PASSWORD="new pass""#)); - } } diff --git a/crates/clickhousectl/src/local/postgres.rs b/crates/clickhousectl/src/local/postgres.rs index 2209c723..b1bc8625 100644 --- a/crates/clickhousectl/src/local/postgres.rs +++ b/crates/clickhousectl/src/local/postgres.rs @@ -1303,18 +1303,7 @@ fn dotenv(name: Option<&str>, version: Option<&str>, use_local: bool, json: bool let filename = if use_local { ".env.local" } else { ".env" }; let path = std::path::Path::new(filename); - let content = if path.exists() { - let existing = std::fs::read_to_string(path)?; - crate::local::update_dotenv(&existing, "POSTGRES_", &vars) - } else { - vars.iter() - .map(|(k, v)| crate::local::format_dotenv_line("", k, v)) - .collect::>() - .join("\n") - + "\n" - }; - - std::fs::write(path, &content)?; + crate::dotenv::write_vars(path, "POSTGRES_", &vars)?; let out = output::PostgresDotenvOutput { file: filename.to_string(), diff --git a/crates/clickhousectl/tests/cli_request_shape_test.rs b/crates/clickhousectl/tests/cli_request_shape_test.rs index 6c207b02..93ce6859 100644 --- a/crates/clickhousectl/tests/cli_request_shape_test.rs +++ b/crates/clickhousectl/tests/cli_request_shape_test.rs @@ -35044,3 +35044,247 @@ async fn org_notifications_accepts_an_empty_cursor_without_fetching_more_pages() }) ); } + +// ── Cloud service .env output (issue #1164) ──────────────────────────────── + +const DOTENV_SERVICE_ID: &str = "22222222-3333-4444-5555-666666666666"; + +fn dotenv_service_json() -> Value { + serde_json::json!({ + "id": DOTENV_SERVICE_ID, + "name": "analytics", + "endpoints": [ + {"protocol": "https", "host": "abc.us-east-1.aws.clickhouse.cloud", "port": 8443}, + {"protocol": "nativesecure", "host": "abc.us-east-1.aws.clickhouse.cloud", "port": 9440} + ] + }) +} + +fn invoke_cli_in_dir( + mock: &MockServer, + dir: &Path, + json: bool, + cli_args: &[&str], +) -> std::process::Output { + let home = dir.join("home"); + std::fs::create_dir_all(&home).unwrap(); + let url = mock.uri(); + let mut args = vec!["cloud", "--url", &url]; + if json { + args.push("--json"); + } + args.extend(cli_args); + Command::new(clickhousectl_binary()) + .env_clear() + .env("DO_NOT_TRACK", "1") + .env("HOME", home) + .env("CLICKHOUSE_CLOUD_API_KEY", "fake-key-for-tests") + .env("CLICKHOUSE_CLOUD_API_SECRET", "fake-secret-for-tests") + .current_dir(dir) + .args(args) + .output() + .expect("failed to spawn clickhousectl") +} + +async fn mount_dotenv_service_get(mock: &MockServer, service: Value) { + Mock::given(method("GET")) + .and(path(format!( + "/v1/organizations/org-1/services/{DOTENV_SERVICE_ID}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": service, "status": 200, "requestId": "stub-dotenv-get" + }))) + .mount(mock) + .await; +} + +#[tokio::test] +async fn service_dotenv_edits_env_in_place_without_a_password() { + let mock = MockServer::start().await; + mount_dotenv_service_get(&mock, dotenv_service_json()).await; + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join(".env"), + "APP=1\nCLICKHOUSE_PASSWORD=kept\nCLICKHOUSE_HOST=old\n", + ) + .unwrap(); + + let output = invoke_cli_in_dir( + &mock, + dir.path(), + true, + &["service", "dotenv", DOTENV_SERVICE_ID, "--org-id", "org-1"], + ); + assert_success(&output); + assert_eq!( + std::fs::read_to_string(dir.path().join(".env")).unwrap(), + "APP=1\nCLICKHOUSE_PASSWORD=kept\nCLICKHOUSE_HOST=abc.us-east-1.aws.clickhouse.cloud\n\ + CLICKHOUSE_PORT=9440\nCLICKHOUSE_HTTP_PORT=8443\nCLICKHOUSE_USER=default\n\ + CLICKHOUSE_SECURE=true\n" + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["file"], ".env"); + assert_eq!(result["service"], "analytics"); + let keys: Vec<&str> = result["vars"] + .as_array() + .unwrap() + .iter() + .map(|var| var["key"].as_str().unwrap()) + .collect(); + assert_eq!( + keys, + [ + "CLICKHOUSE_HOST", + "CLICKHOUSE_PORT", + "CLICKHOUSE_HTTP_PORT", + "CLICKHOUSE_USER", + "CLICKHOUSE_SECURE" + ] + ); + + let local = invoke_cli_in_dir( + &mock, + dir.path(), + false, + &[ + "service", + "dotenv", + DOTENV_SERVICE_ID, + "--org-id", + "org-1", + "--local", + ], + ); + assert_success(&local); + let written = std::fs::read_to_string(dir.path().join(".env.local")).unwrap(); + assert!(written.starts_with("CLICKHOUSE_HOST=abc.us-east-1.aws.clickhouse.cloud\n")); + assert!(!written.contains("CLICKHOUSE_PASSWORD")); +} + +#[tokio::test] +async fn service_dotenv_without_endpoints_fails_and_writes_nothing() { + let mock = MockServer::start().await; + mount_dotenv_service_get( + &mock, + serde_json::json!({"id": DOTENV_SERVICE_ID, "name": "analytics"}), + ) + .await; + let dir = tempfile::tempdir().unwrap(); + let output = invoke_cli_in_dir( + &mock, + dir.path(), + true, + &["service", "dotenv", DOTENV_SERVICE_ID, "--org-id", "org-1"], + ); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!dir.path().join(".env").exists()); +} + +async fn mount_dotenv_service_create(mock: &MockServer) { + Mock::given(method("POST")) + .and(path("/v1/organizations/org-1/services")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": {"service": dotenv_service_json(), "password": "one-time-secret"}, + "status": 200, + "requestId": "stub-dotenv-create" + }))) + .expect(1) + .mount(mock) + .await; +} + +#[tokio::test] +async fn service_create_dotenv_writes_the_password_and_keeps_it_off_stdout() { + for json in [true, false] { + let mock = MockServer::start().await; + mount_dotenv_service_create(&mock).await; + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join("app")).unwrap(); + let target = dir.path().join("app").join(".env"); + + let output = invoke_cli_in_dir( + &mock, + dir.path(), + json, + &[ + "service", + "create", + "--name", + "analytics", + "--org-id", + "org-1", + "--dotenv", + target.to_str().unwrap(), + ], + ); + assert_success(&output); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(!stdout.contains("one-time-secret"), "json={json}: {stdout}"); + assert!(!String::from_utf8_lossy(&output.stderr).contains("one-time-secret")); + assert_eq!( + std::fs::read_to_string(&target).unwrap(), + "CLICKHOUSE_HOST=abc.us-east-1.aws.clickhouse.cloud\nCLICKHOUSE_PORT=9440\n\ + CLICKHOUSE_HTTP_PORT=8443\nCLICKHOUSE_USER=default\nCLICKHOUSE_SECURE=true\n\ + CLICKHOUSE_PASSWORD=one-time-secret\n" + ); + if json { + let result: Value = serde_json::from_str(&stdout).unwrap(); + assert!(result.get("password").is_none()); + assert_eq!(result["service"]["id"], DOTENV_SERVICE_ID); + assert_eq!(result["dotenv"]["file"], target.to_str().unwrap()); + assert_eq!(result["dotenv"]["keys"][5], "CLICKHOUSE_PASSWORD"); + } + } +} + +#[tokio::test] +async fn service_create_without_dotenv_still_prints_the_password() { + let mock = MockServer::start().await; + mount_dotenv_service_create(&mock).await; + let dir = tempfile::tempdir().unwrap(); + let output = invoke_cli_in_dir( + &mock, + dir.path(), + true, + &[ + "service", + "create", + "--name", + "analytics", + "--org-id", + "org-1", + ], + ); + assert_success(&output); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["password"], "one-time-secret"); + assert!(result.get("dotenv").is_none()); + assert!(!dir.path().join(".env").exists()); +} + +#[tokio::test] +async fn service_create_dotenv_rejects_an_unwritable_target_before_creating() { + let mock = MockServer::start().await; + let dir = tempfile::tempdir().unwrap(); + let missing_dir = dir.path().join("missing").join(".env"); + for target in [dir.path().to_str().unwrap(), missing_dir.to_str().unwrap()] { + let output = invoke_cli_in_dir( + &mock, + dir.path(), + true, + &[ + "service", + "create", + "--name", + "analytics", + "--org-id", + "org-1", + "--dotenv", + target, + ], + ); + assert_eq!(output.status.code(), Some(2), "{target}"); + } + assert!(mock.received_requests().await.unwrap().is_empty()); +} diff --git a/docs/guides/cloud-service.md b/docs/guides/cloud-service.md index 1b06ba77..f528f387 100644 --- a/docs/guides/cloud-service.md +++ b/docs/guides/cloud-service.md @@ -23,6 +23,23 @@ clickhousectl cloud service query --org-id \ The HTTP Query API needs no local ClickHouse binary or service password. OAuth gives read-only SQL. API-key queries may automatically bind the caller's key to a Query API endpoint; new endpoints use `sql_console_admin`. Pass `--no-auto-enable` to require an existing binding. See [SQL and Query API behavior](../reference/query-api.md) before automating writes, using bulk input, or retrying a timed-out query. +### Write connection settings to `.env` + +The password is returned only once, so write it to your application's `.env` at creation with `--dotenv`. The password then goes only to the file, not to the terminal or JSON output. Without a file name it writes `.env` in the current directory. + +```bash +clickhousectl cloud service create --org-id --name analytics \ + --ip-allow 203.0.113.10/32 --dotenv .env.local +``` + +For an existing service, `service dotenv` writes the same settings without a password (`.env`, or `.env.local` with `--local`): + +```bash +clickhousectl cloud service dotenv --org-id --local +``` + +Both write `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT` (the native TLS port, as in `local clickhouse dotenv`), `CLICKHOUSE_HTTP_PORT`, `CLICKHOUSE_USER` (`default`) and `CLICKHOUSE_SECURE=true`; `create --dotenv` adds `CLICKHOUSE_PASSWORD`. An existing file is edited in place: only those keys are replaced, so a password written at creation survives a later `service dotenv`. If the file can't be written, `create` refuses before creating the service; if a write fails after creation, it prints the password as usual and exits 1. The file holds a credential: keep it out of version control. + ## Change capacity and settings Inspect the service and its available profiles before choosing memory and replica counts: diff --git a/docs/reference/automation.md b/docs/reference/automation.md index 0baa83e3..2dd4b593 100644 --- a/docs/reference/automation.md +++ b/docs/reference/automation.md @@ -17,6 +17,8 @@ Cloud structured output follows each command's API-shaped contract, usually came | Command | Successful output | | --- | --- | | `cloud service list --json` | Bare array | +| `cloud service create --json` | The API's `service` and one-time `password`; with `--dotenv`, `password` is omitted and `dotenv` has `file` and the written `keys` | +| `cloud service dotenv --json` | Object with `file`, `service` (name), and `vars` of `key` and `value`; never a password | | `cloud key list --json` | Object with `result` and optional pagination metadata; `--all` gives a combined array | | `cloud service settings list --json` | Object with `settings`; values retain string/number types | | `cloud clickpipe settings get --json` | Object with snake_case setting names |