From 8316f5bb72e0df89083b65adff245142ad6b5a9d Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 15:09:49 +0100 Subject: [PATCH] feat(cli): write Cloud service connection settings to .env (#1164) Add `cloud service dotenv` to write an existing service's host, ports, user and TLS setting to .env (or .env.local), without a password, and `cloud service create --dotenv [FILE]` to write the same settings plus the one-time password. With --dotenv, the password goes only to the file and is left out of human and JSON output; the target is checked before the service is created, and a write that still fails afterwards prints the password as before and exits 1. Move the .env writer helpers from local into the dotenv module, shared by the local and Cloud dotenv paths. Co-Authored-By: Claude Opus 5.5 --- .../clickhousectl/src/cloud/services/cli.rs | 109 ++++++ .../src/cloud/services/lifecycle.rs | 311 ++++++++++++++- .../clickhousectl/src/cloud/services/mod.rs | 20 +- crates/clickhousectl/src/dotenv.rs | 353 ++++++++++++++++++ crates/clickhousectl/src/local/mod.rs | 320 +--------------- crates/clickhousectl/src/local/postgres.rs | 13 +- .../tests/cli_request_shape_test.rs | 244 ++++++++++++ docs/guides/cloud-service.md | 17 + docs/reference/automation.md | 2 + 9 files changed, 1046 insertions(+), 343 deletions(-) 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 |