From c42eb292efe180b7561705590ffb1eff7aabf10e Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 15:13:18 +0100 Subject: [PATCH 1/4] refactor(cli): move cloud services module into a directory (#1157) Move `cloud/services.rs` to `cloud/services/mod.rs` and `cloud/service_query.rs` to `cloud/services/query_key.rs`, so blame follows both files into the new layout. Only the module declaration and the `service_query` paths change. Co-Authored-By: Claude Opus 5.5 --- crates/clickhousectl/src/cloud/mod.rs | 1 - .../src/cloud/{services.rs => services/mod.rs} | 17 +++++++++++------ .../{service_query.rs => services/query_key.rs} | 0 3 files changed, 11 insertions(+), 7 deletions(-) rename crates/clickhousectl/src/cloud/{services.rs => services/mod.rs} (99%) rename crates/clickhousectl/src/cloud/{service_query.rs => services/query_key.rs} (100%) diff --git a/crates/clickhousectl/src/cloud/mod.rs b/crates/clickhousectl/src/cloud/mod.rs index acda62e5..fc3d79b6 100644 --- a/crates/clickhousectl/src/cloud/mod.rs +++ b/crates/clickhousectl/src/cloud/mod.rs @@ -15,7 +15,6 @@ pub(crate) mod permissions; pub mod postgres; pub mod query_api_endpoints; pub mod saved_queries; -pub mod service_query; pub mod services; mod shared; pub mod types; diff --git a/crates/clickhousectl/src/cloud/services.rs b/crates/clickhousectl/src/cloud/services/mod.rs similarity index 99% rename from crates/clickhousectl/src/cloud/services.rs rename to crates/clickhousectl/src/cloud/services/mod.rs index 978a8d12..1a3cbc78 100644 --- a/crates/clickhousectl/src/cloud/services.rs +++ b/crates/clickhousectl/src/cloud/services/mod.rs @@ -1,3 +1,5 @@ +pub mod query_key; + use super::permissions::{Conditional, Declaration as Permission}; use clickhouse_cloud_api::meta::operations as op; @@ -168,7 +170,9 @@ use crate::cloud::credentials; use crate::cloud::output::{ ABSENT, CloudErrorCode, CloudErrorDetail, eprint_line, or_absent, print_human, print_line, }; -use crate::cloud::service_query::{CallerKeyBinding, RepairVerification, existing_open_api_keys}; +use crate::cloud::services::query_key::{ + CallerKeyBinding, RepairVerification, existing_open_api_keys, +}; use crate::cloud::shared::{NameSelector, NamedResource, PollProgress}; use crate::cloud::shared::{ parse_ip_access_entries, parse_serde_enum, parse_tag_filter, parse_tags, resolve_org_id, @@ -3340,7 +3344,8 @@ async fn service_query_key_repair( ) -> CloudResult<()> { let org_id = resolve_org_id(client).await?; let result = - crate::cloud::service_query::repair_service_query_key(client, &org_id, service_id).await?; + crate::cloud::services::query_key::repair_service_query_key(client, &org_id, service_id) + .await?; // A retired key that could not be deleted is a warning, not a failure: the // replacement is active and its ID is stored for the retry (#527). The // warning goes to stderr in both modes; the JSON result carries the IDs. @@ -3967,7 +3972,7 @@ async fn service_query(client: &CloudClient, options: ServiceQueryOptions) -> Cl // the record; finish that here, quietly, before the query (#527). // The list is empty on every ordinary run, so this costs nothing // unless a retirement is actually outstanding. - crate::cloud::service_query::retry_pending_query_key_cleanup( + crate::cloud::services::query_key::retry_pending_query_key_cleanup( client, &key, &service_id, @@ -3993,7 +3998,7 @@ async fn service_query(client: &CloudClient, options: ServiceQueryOptions) -> Cl // decides, and nothing is replaced automatically (#528). Some(status) => { return Err( - crate::cloud::service_query::rejected_stored_query_key_error( + crate::cloud::services::query_key::rejected_stored_query_key_error( client, &key, &service_id, @@ -4042,7 +4047,7 @@ async fn service_query(client: &CloudClient, options: ServiceQueryOptions) -> Cl // endpoint and the query re-runs with it (#1043). The bind // records `BoundCallerKey` itself once it succeeds. failure::set_provisioning_state(ProvisioningState::Provisioning); - let binding = crate::cloud::service_query::bind_caller_query_key( + let binding = crate::cloud::services::query_key::bind_caller_query_key( client, &org_id, &service_id, @@ -4080,7 +4085,7 @@ async fn service_query(client: &CloudClient, options: ServiceQueryOptions) -> Cl target, QUERY_ENDPOINT_READINESS, |waited| { - crate::cloud::service_query::bound_caller_key_rejected_error( + crate::cloud::services::query_key::bound_caller_key_rejected_error( &service_id, &org_id, &caller_uuid, diff --git a/crates/clickhousectl/src/cloud/service_query.rs b/crates/clickhousectl/src/cloud/services/query_key.rs similarity index 100% rename from crates/clickhousectl/src/cloud/service_query.rs rename to crates/clickhousectl/src/cloud/services/query_key.rs From 30bd640d06f010ddbcbcf59c0a403329748e72d2 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 15:25:55 +0100 Subject: [PATCH 2/4] refactor(cli): split cloud services into per-command-family files (#1157) Move the `service` command code out of `cloud/services/mod.rs` into one file per command family, each with the tests for its code: - `cli.rs`: clap definitions and private endpoint ID value parser - `client.rs`: `CloudClient` wrappers - `lifecycle.rs`: list, get, create, update, delete, start, stop, wake, scale, reset-password and prometheus - `settings.rs`, `scaling_schedule.rs`, `endpoints.rs` - `upgrade_window.rs`: upgrade window, replica-aware routing and profiles - `query.rs`: `service query` and `service repair-query-key` `mod.rs` keeps `PERMISSIONS`, the `run` dispatch and re-exports of the command enums. Only visibility and imports change; no behaviour change. Co-Authored-By: Claude Opus 5.5 --- .../clickhousectl/src/cloud/services/cli.rs | 3199 ++++++ .../src/cloud/services/client.rs | 510 + .../src/cloud/services/endpoints.rs | 334 + .../src/cloud/services/lifecycle.rs | 2010 ++++ .../clickhousectl/src/cloud/services/mod.rs | 9036 +---------------- .../clickhousectl/src/cloud/services/query.rs | 2077 ++++ .../src/cloud/services/scaling_schedule.rs | 371 + .../src/cloud/services/settings.rs | 317 + .../src/cloud/services/upgrade_window.rs | 291 + 9 files changed, 9140 insertions(+), 9005 deletions(-) create mode 100644 crates/clickhousectl/src/cloud/services/cli.rs create mode 100644 crates/clickhousectl/src/cloud/services/client.rs create mode 100644 crates/clickhousectl/src/cloud/services/endpoints.rs create mode 100644 crates/clickhousectl/src/cloud/services/lifecycle.rs create mode 100644 crates/clickhousectl/src/cloud/services/query.rs create mode 100644 crates/clickhousectl/src/cloud/services/scaling_schedule.rs create mode 100644 crates/clickhousectl/src/cloud/services/settings.rs create mode 100644 crates/clickhousectl/src/cloud/services/upgrade_window.rs diff --git a/crates/clickhousectl/src/cloud/services/cli.rs b/crates/clickhousectl/src/cloud/services/cli.rs new file mode 100644 index 00000000..442649ce --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/cli.rs @@ -0,0 +1,3199 @@ +use crate::cloud::backups::{BackupConfigCommands, SnapshotCommands}; +use crate::cloud::shared::NameSelector; +use crate::cloud::shared::parse_tag_filter; +use clap::builder::PossibleValuesParser; +use clap::{ArgGroup, Subcommand}; +use clickhouse_cloud_api::models::QueryEndpointRole; + +#[derive(Subcommand)] +pub enum ServiceCommands { + /// List services in the organization + List { + /// Filter by resource tag: `tag:KEY=VALUE` or `tag:KEY` (repeatable) + #[arg(long, value_parser = parse_tag_filter)] + filter: Vec, + }, + + /// Get service details + Get { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Discover available service profiles + Profile { + #[command(subcommand)] + command: ServiceProfileCommands, + }, + + /// Manage ClickHouse settings + Settings { + #[command(subcommand)] + command: ServiceSettingsCommands, + }, + + /// Manage the service scaling schedule + #[command(name = "scaling-schedule")] + ScalingSchedule { + #[command(subcommand)] + command: ScalingScheduleCommands, + }, + + /// Create a service + #[command(after_help = "\ +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. + Returns before the service is up: poll `cloud service get ` until state is `running` (minutes).")] + Create { + /// Service name + #[arg(long)] + name: String, + + /// Cloud provider: aws, gcp, azure + #[arg(long, default_value = "aws")] + provider: String, + + /// Region, e.g. us-east-1, eu-west-1, us-central1 + #[arg(long, default_value = "us-east-1")] + region: String, + + /// Minimum memory per replica in GB + /// + /// A multiple of 4, at least 8. Horizontal autoscaling requires it equal to + /// --max-replica-memory-gb. + #[arg(long)] + min_replica_memory_gb: Option, + + /// Maximum memory per replica in GB + /// + /// A multiple of 4; at most 120 for unpaid and 356 for paid services. Horizontal + /// autoscaling requires it equal to --min-replica-memory-gb. + #[arg(long)] + max_replica_memory_gb: Option, + + /// Fixed replica count for vertical autoscaling (up to 50) + /// + /// The first service in a warehouse needs at least 2; existing warehouses allow 1. + /// Tier and per-warehouse limits may lower the maximum. Conflicts with the horizontal band. + #[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])] + num_replicas: Option, + + /// Minimum replicas for horizontal autoscaling (1-50) + /// + /// Requires --max-replicas and conflicts with --num-replicas. Needs the horizontal + /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and + /// per-warehouse limits may lower the maximum. + #[arg(long, conflicts_with = "num_replicas")] + min_replicas: Option, + + /// Maximum replicas for horizontal autoscaling (1-50) + /// + /// Requires --min-replicas and conflicts with --num-replicas. Needs the horizontal + /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and + /// per-warehouse limits may lower the maximum. + #[arg(long, conflicts_with = "num_replicas")] + max_replicas: Option, + + /// Autoscaling mode (vertical when omitted) + /// + /// vertical scales memory between --min-replica-memory-gb and --max-replica-memory-gb at a + /// fixed --num-replicas count; horizontal scales replicas between --min-replicas and + /// --max-replicas at a fixed memory (the two memory flags equal). + #[arg( + long, + value_parser = PossibleValuesParser::new( + clickhouse_cloud_api::models::AutoscalingMode::VALUES + ) + )] + autoscaling_mode: Option, + + /// Allow scale to zero when idle (default: true) + #[arg(long)] + idle_scaling: Option, + + /// Minimum idle timeout in minutes (>= 5) + #[arg(long)] + idle_timeout_minutes: Option, + + /// Allowed IP/CIDR, optionally IP_OR_CIDR=DESCRIPTION (repeatable) + #[arg(long = "ip-allow", value_name = "IP_OR_CIDR[=DESCRIPTION]")] + ip_allow: Vec, + + /// Backup ID to restore from + #[arg(long)] + backup_id: Option, + + /// Backup's encryption_config.json file, or - for stdin (private preview) + /// + /// Only with --backup-id, for a backup of a TDE-enabled service in your own + /// bucket. The restored service has TDE enabled. + #[arg(long, value_name = "PATH", requires = "backup_id")] + backup_encryption_config: Option, + + /// Release channel: slow, default, fast (production services only) + #[arg(long)] + release_channel: Option, + + /// Data warehouse ID (for creating read replicas) + #[arg(long)] + data_warehouse_id: Option, + + /// Make service read-only (requires --data-warehouse-id) + #[arg(long)] + readonly: bool, + + /// Enable Transparent Data Encryption (enterprise only) + #[arg(long)] + enable_tde: bool, + + /// Compliance type: hipaa, pci + #[arg(long)] + compliance_type: Option, + + /// Instance profile (enterprise only): v1-default, v1-highmem-xs, etc. + #[arg(long)] + profile: Option, + + /// BYOC infrastructure ID + #[arg(long)] + byoc_id: Option, + + /// Tag to attach to the service. Format: key or key=value (repeatable) + #[arg(long = "tag", value_name = "KEY[=VALUE]")] + tag: Vec, + + /// Enable a toggleable endpoint protocol; currently mysql (repeatable) + #[arg(long = "enable-endpoint")] + enable_endpoint: Vec, + + /// Disable a toggleable endpoint protocol; currently mysql (repeatable) + #[arg(long = "disable-endpoint")] + disable_endpoint: Vec, + + /// Accept private preview terms (needed for the org's first service in a + /// private preview) + #[arg(long)] + private_preview_terms_checked: bool, + + /// Enable or disable service core dump collection + #[arg(long)] + enable_core_dumps: Option, + }, + + /// Delete a service + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Irreversible. A running service cannot be deleted: stop it first, or pass --force. + --force stops the service and polls until the stop finishes — minutes on a real service, with + progress lines on stderr.")] + Delete { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Stop the service first if it is running, then delete (waits for the stop) + #[arg(long)] + force: bool, + }, + + /// Start a service + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Resumes a stopped service; an idle service wakes on its own when queried. + Returns as soon as the start is accepted: poll `cloud service get ` until state is `running`.")] + Start { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Wake an idled service + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Wakes an idle service; use `cloud service start ` for a stopped service. + Returns as soon as the wake is accepted: poll `cloud service get ` until state is `running`.")] + Wake { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Stop a service + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Idles the service: compute billing stops, data is preserved. + Returns as soon as the stop is accepted: poll `cloud service get ` until state is `stopped` + or `idle`.")] + Stop { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Update service configuration + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Only the flags you pass are changed; everything else is left as it is. + --remove-ip-allow/--remove-private-endpoint-id/--remove-tag are idempotent: removing something + absent exits 0 with a warning on stderr.")] + Update { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// New service name + #[arg(long = "new-name", id = "new_name")] + name: Option, + + /// IP/CIDR to add, optionally IP_OR_CIDR=DESCRIPTION (repeatable) + #[arg(long = "add-ip-allow", value_name = "IP_OR_CIDR[=DESCRIPTION]")] + add_ip_allow: Vec, + + /// IP or CIDR to remove from the service allow list (repeatable) + #[arg(long = "remove-ip-allow", value_name = "IP_OR_CIDR")] + remove_ip_allow: Vec, + + /// Private endpoint ID to add; format-checked before the request (repeatable) + #[arg(long = "add-private-endpoint-id", value_parser = parse_private_endpoint_id_arg)] + add_private_endpoint_id: Vec, + + /// Private endpoint ID to remove (repeatable) + #[arg(long = "remove-private-endpoint-id")] + remove_private_endpoint_id: Vec, + + /// Release channel: slow, default, fast + #[arg(long)] + release_channel: Option, + + /// Enable a toggleable endpoint protocol; currently mysql (repeatable) + #[arg(long = "enable-endpoint")] + enable_endpoint: Vec, + + /// Disable a toggleable endpoint protocol; currently mysql (repeatable) + #[arg(long = "disable-endpoint")] + disable_endpoint: Vec, + + /// Transparent Data Encryption key ID to rotate to + #[arg(long)] + transparent_data_encryption_key_id: Option, + + /// Tag to add (repeatable; cannot be combined with --remove-tag) + #[arg( + long = "add-tag", + value_name = "KEY[=VALUE]", + conflicts_with = "remove_tag" + )] + add_tag: Vec, + + /// Tag to remove (repeatable; cannot be combined with --add-tag) + #[arg( + long = "remove-tag", + value_name = "KEY[=VALUE]", + conflicts_with = "add_tag" + )] + remove_tag: Vec, + + /// Enable or disable service core dump collection + #[arg(long)] + enable_core_dumps: Option, + }, + + /// Update service scaling and idle behaviour + Scale { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Minimum memory per replica in GB + /// + /// A multiple of 4, at least 8. Horizontal autoscaling requires it equal to + /// --max-replica-memory-gb. + #[arg(long)] + min_replica_memory_gb: Option, + + /// Maximum memory per replica in GB + /// + /// A multiple of 4; at most 120 for unpaid and 356 for paid services. Horizontal + /// autoscaling requires it equal to --min-replica-memory-gb. + #[arg(long)] + max_replica_memory_gb: Option, + + /// Fixed replica count for vertical autoscaling (up to 50) + /// + /// The first service in a warehouse needs at least 2; existing warehouses allow 1. + /// Tier and per-warehouse limits may lower the maximum. Conflicts with the horizontal band. + #[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])] + num_replicas: Option, + + /// Minimum replicas for horizontal autoscaling (1-50) + /// + /// Requires --max-replicas and conflicts with --num-replicas. Needs the horizontal + /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and + /// per-warehouse limits may lower the maximum. + #[arg(long, conflicts_with = "num_replicas")] + min_replicas: Option, + + /// Maximum replicas for horizontal autoscaling (1-50) + /// + /// Requires --min-replicas and conflicts with --num-replicas. Needs the horizontal + /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and + /// per-warehouse limits may lower the maximum. + #[arg(long, conflicts_with = "num_replicas")] + max_replicas: Option, + + /// Target autoscaling mode + /// + /// Omit to keep the service on its current mode. See + /// `cloud service create --autoscaling-mode` for what each mode scales. + #[arg( + long, + value_parser = PossibleValuesParser::new( + clickhouse_cloud_api::models::AutoscalingMode::VALUES + ) + )] + autoscaling_mode: Option, + + /// Allow scale to zero when idle + #[arg(long)] + idle_scaling: Option, + + /// Minimum idle timeout in minutes (>= 5) + #[arg(long)] + idle_timeout_minutes: Option, + }, + + /// Reset the default user password + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Omit --new-password-hash and the API generates a password, printed once — save it. + With --new-password-hash no plaintext is returned; --new-double-sha1-hash alone still triggers + generation.")] + ResetPassword { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// SHA256 password hash encoded as base64 + #[arg(long)] + new_password_hash: Option, + + /// MySQL-compatible double SHA1 password hash + #[arg(long)] + new_double_sha1_hash: Option, + }, + + /// Manage the Query API endpoint + #[command( + name = "query-endpoint", + after_help = "\ +CONTEXT FOR AGENTS: + Only needed to share Query API access with other tools: `cloud service query` binds the + authenticated API key itself. + Hand edits can unbind the key `cloud service query` uses; the next query rebinds it unless + `--no-auto-enable` is passed. + A stored per-service key is repaired with `cloud service repair-query-key `." + )] + QueryEndpoint { + #[command(subcommand)] + command: QueryEndpointCommands, + }, + + /// Manage private endpoints + #[command(name = "private-endpoint")] + PrivateEndpoint { + #[command(subcommand)] + command: PrivateEndpointCommands, + }, + + /// Manage service snapshots (Beta) + Snapshot { + #[command(subcommand)] + command: SnapshotCommands, + }, + + /// Manage backup configuration + #[command(name = "backup-config")] + BackupConfig { + #[command(subcommand)] + command: BackupConfigCommands, + }, + + /// Manage the service upgrade window + #[command(name = "upgrade-window")] + UpgradeWindow { + #[command(subcommand)] + command: UpgradeWindowCommands, + }, + + /// Manage replica aware routing (Beta) + #[command( + name = "replica-aware-routing", + after_help = "\ +CONTEXT FOR AGENTS: + With HTTP routing enabled, every HTTP client must send the X-ClickHouse-Replica-Tag header. + Update needs the organization feature enabled; an omitted flag leaves that protocol unchanged." + )] + ReplicaAwareRouting { + #[command(subcommand)] + command: ReplicaAwareRoutingCommands, + }, + + /// Get Prometheus metrics + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Output is always raw Prometheus exposition text; --json is accepted for consistency but ignored.")] + Prometheus { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Whether to request filtered metrics + #[arg(long)] + filtered_metrics: Option, + }, + + /// Run SQL over HTTP via the Query API + #[command( + group(ArgGroup::new("service_selector").required(true).args(["name", "id", "service_id"])), + after_help = "\ +CONTEXT FOR AGENTS: + Run one SQL statement; a timeout may follow execution or wake delays, and is never retried. + Stored-key 401/403 never rotates keys automatically; deliberate repair: `cloud service repair-query-key `." + )] + Query { + /// Service ID to query + #[arg(conflicts_with_all = ["name", "id"])] + service_id: Option, + + /// Service name to query + #[arg(long, conflicts_with_all = ["id", "service_id"])] + name: Option, + + /// Service ID to query + #[arg(long, hide = true, conflicts_with_all = ["name", "service_id"])] + id: Option, + + /// SQL to run: a single statement, and never read from stdin + #[arg(long, short, conflicts_with = "queries_file")] + query: Option, + + /// File containing one SQL statement ("-" reads stdin) + #[arg(long, conflicts_with = "query")] + queries_file: Option, + + /// Target database + #[arg(long)] + database: Option, + + /// Response format (e.g. JSONEachRow, CSV, TabSeparated, PrettyCompact) + #[arg(long, conflicts_with = "json")] + format: Option, + + /// Fail rather than bind the API key to the Query API endpoint + #[arg(long)] + no_auto_enable: bool, + }, + + /// Replace clickhousectl's stored Query API key + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Deliberately replaces this service's owned key/binding; preserves other bindings and credentials. + Records without exact ownership metadata are refused; legacy and external keys cannot be repaired. + Verification timeout exits 1 but repair stands: retry `cloud service query `, not repair.")] + RepairQueryKey { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, +} + +#[derive(Subcommand)] +pub enum QueryEndpointCommands { + /// Get the Query API endpoint configuration + Get { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Create or update the Query API endpoint + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Existing API keys are preserved unless --replace-open-api-keys is set. + Roles replace the endpoint-wide role list for every authorized key. + First creation requires --allowed-origins; use '*' explicitly for all. + Avoid concurrent changes to the same endpoint.")] + Create { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Endpoint-wide role to grant (required, repeatable) + #[arg(long, required = true, value_parser = PossibleValuesParser::new(QueryEndpointRole::VALUES))] + role: Vec, + + /// API key ID to authorize (repeatable) + #[arg(long = "open-api-key", value_parser = clap::builder::NonEmptyStringValueParser::new())] + open_api_key: Vec, + + /// Replace existing API keys (requires --open-api-key) + #[arg(long, requires = "open_api_key")] + replace_open_api_keys: bool, + + /// Browser origins; preserve when omitted on an existing endpoint + #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] + allowed_origins: Option, + }, + + /// Delete the Query API endpoint + Delete { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, +} + +#[derive(Subcommand)] +pub enum PrivateEndpointCommands { + /// Create a private endpoint connection + Create { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Private endpoint ID (AWS VPC endpoint ID, GCP PSC connection ID, or + /// Azure private endpoint Resource ID / resourceGuid) + #[arg(long, value_parser = parse_private_endpoint_id_arg)] + endpoint_id: String, + + /// Description + #[arg(long)] + description: Option, + }, + + /// Get the private endpoint configuration + GetConfig { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, +} + +#[derive(Subcommand)] +pub enum ServiceProfileCommands { + /// List available service profiles + List { + /// Region ID (required without --byoc-id) + #[arg(long, required_unless_present = "byoc_id")] + region: Option, + + /// BYOC infrastructure ID (required without --region) + /// + /// Cloud validates any supplied region against this infrastructure. + #[arg(long, required_unless_present = "region")] + byoc_id: Option, + }, +} + +#[derive(Subcommand)] +pub enum ServiceSettingsCommands { + /// List configured ClickHouse settings + List { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Get a ClickHouse setting + Get { + #[command(flatten)] + target: ServiceSettingTarget, + }, + + /// Get the configurable ClickHouse settings schema + Schema { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Set one or more ClickHouse settings + #[command( + group(ArgGroup::new("settings_input").required(true).args(["setting", "settings_file"])), + after_help = "\ +CONTEXT FOR AGENTS: + Discover supported names and types with `settings schema `. + --setting values are JSON literals; quote string values inside the argument. + --file reads a JSON settings map; `-` reads stdin. Only named settings change." + )] + Set { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Setting as NAME=JSON_VALUE (repeatable) + #[arg(long, value_name = "NAME=JSON_VALUE")] + setting: Vec, + + /// JSON settings map file (`-` reads stdin) + #[arg(long = "file", alias = "settings-file", value_name = "PATH")] + settings_file: Option, + }, + + /// Reset a ClickHouse setting to its platform default + Unset { + #[command(flatten)] + target: ServiceSettingTarget, + }, +} + +/// Keep the natural setting key required while allowing options between the +/// service ID and key. Clap's allow_missing_positional would shift the ID into +/// the key slot when an option follows it; the explicit --name decides grammar. +#[derive(clap::Args)] +pub struct ServiceSettingTarget { + /// Service ID, or setting name with --name + #[arg(value_name = "ID_OR_SETTING_NAME")] + id_or_setting_name: String, + /// Setting name when selecting by service ID + #[arg( + value_name = "SETTING_NAME", + required_unless_present = "name", + conflicts_with = "name" + )] + setting_name: Option, + /// Exact service name within the selected organization + #[arg(long)] + name: Option, +} + +impl ServiceSettingTarget { + pub(in crate::cloud) fn parts(&self) -> (NameSelector, &str) { + match &self.name { + Some(name) => ( + NameSelector { + id: None, + name: Some(name.clone()), + }, + &self.id_or_setting_name, + ), + None => ( + NameSelector { + id: Some(self.id_or_setting_name.clone()), + name: None, + }, + self.setting_name + .as_deref() + .expect("clap requires the setting name with a positional ID"), + ), + } + } +} + +#[derive(Subcommand)] +pub enum ScalingScheduleCommands { + /// Get the scaling schedule + Get { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Create or replace the scaling schedule + #[command(after_help = "\ +CONTEXT FOR AGENTS: + --file is a JSON object with an entries array; '-' reads it from stdin. + Set replaces every existing entry. Get, edit only entries, then set to preserve other entries. + Hours and weekdays are UTC. + Omit response-only baseConfig and each entry's id and isActiveNow when setting a schedule.")] + Set { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// JSON request file (use "-" for stdin) + #[arg(long, value_name = "PATH")] + file: String, + }, + + /// Delete the scaling schedule + #[command(after_help = "\ +CONTEXT FOR AGENTS: + Deletes every scheduled entry. An active entry returns to the service's base scaling config.")] + Delete { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, +} + +#[derive(Subcommand)] +pub enum UpgradeWindowCommands { + /// Get the service upgrade window + Get { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Set the service upgrade window + Set { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Start day: 0=Sunday through 6=Saturday + #[arg( + long, + allow_hyphen_values = true, + value_parser = clap::value_parser!(i64).range(0..=6) + )] + weekday: i64, + + /// UTC start hour + #[arg(long, value_parser = PossibleValuesParser::new(["0", "6", "12", "18"]))] + start_hour: String, + }, + + /// Delete the service upgrade window + #[command( + after_help = "CONTEXT FOR AGENTS:\n Restores the default upgrade scheduling behaviour." + )] + Delete { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, +} + +#[derive(Subcommand)] +pub enum ReplicaAwareRoutingCommands { + /// Get replica aware routing settings (Beta) + Get { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + }, + + /// Update replica aware routing settings (Beta) + #[command(group( + ArgGroup::new("routing_change") + .required(true) + .multiple(true) + .args(["http_enabled", "native_enabled"]) + ))] + Update { + /// Service ID + #[command(flatten)] + service_id: NameSelector, + + /// Turn routing on or off for HTTP connections + #[arg(long, value_name = "BOOL")] + http_enabled: Option, + + /// Turn routing on or off for native protocol connections + #[arg(long, value_name = "BOOL")] + native_enabled: Option, + }, +} + +impl ReplicaAwareRoutingCommands { + fn is_write(&self) -> bool { + match self { + ReplicaAwareRoutingCommands::Get { .. } => false, + ReplicaAwareRoutingCommands::Update { .. } => true, + } + } +} + +impl UpgradeWindowCommands { + fn is_write(&self) -> bool { + match self { + UpgradeWindowCommands::Get { .. } => false, + UpgradeWindowCommands::Set { .. } | UpgradeWindowCommands::Delete { .. } => true, + } + } +} + +impl ServiceProfileCommands { + fn is_write(&self) -> bool { + match self { + ServiceProfileCommands::List { .. } => false, + } + } +} + +impl ServiceSettingsCommands { + fn is_write(&self) -> bool { + match self { + ServiceSettingsCommands::List { .. } + | ServiceSettingsCommands::Get { .. } + | ServiceSettingsCommands::Schema { .. } => false, + ServiceSettingsCommands::Set { .. } | ServiceSettingsCommands::Unset { .. } => true, + } + } +} + +impl ScalingScheduleCommands { + fn is_write(&self) -> bool { + match self { + ScalingScheduleCommands::Get { .. } => false, + ScalingScheduleCommands::Set { .. } | ScalingScheduleCommands::Delete { .. } => true, + } + } +} + +impl ServiceCommands { + pub fn is_write(&self) -> bool { + match self { + ServiceCommands::List { .. } => false, + ServiceCommands::Get { .. } => false, + ServiceCommands::Profile { command } => command.is_write(), + ServiceCommands::Settings { command } => command.is_write(), + ServiceCommands::ScalingSchedule { command } => command.is_write(), + ServiceCommands::Prometheus { .. } => false, + ServiceCommands::Query { .. } => false, + ServiceCommands::RepairQueryKey { .. } => true, + ServiceCommands::Create { .. } => true, + ServiceCommands::Delete { .. } => true, + ServiceCommands::Start { .. } => true, + ServiceCommands::Wake { .. } => true, + ServiceCommands::Stop { .. } => true, + ServiceCommands::Update { .. } => true, + ServiceCommands::Scale { .. } => true, + ServiceCommands::ResetPassword { .. } => true, + ServiceCommands::QueryEndpoint { command } => match command { + QueryEndpointCommands::Get { .. } => false, + QueryEndpointCommands::Create { .. } => true, + QueryEndpointCommands::Delete { .. } => true, + }, + ServiceCommands::PrivateEndpoint { command } => match command { + PrivateEndpointCommands::Create { .. } => true, + PrivateEndpointCommands::GetConfig { .. } => false, + }, + ServiceCommands::BackupConfig { command } => command.is_write(), + ServiceCommands::Snapshot { command } => command.is_write(), + ServiceCommands::UpgradeWindow { command } => command.is_write(), + ServiceCommands::ReplicaAwareRouting { command } => command.is_write(), + } + } +} + +/// Prefix of an AWS PrivateLink VPC endpoint ID. +const AWS_VPC_ENDPOINT_PREFIX: &str = "vpce-"; + +/// Clap `value_parser` for flags that *add* a private endpoint ID to a service. +/// +/// Adding an ID registers it org-wide and the API accepts any string, so a typo +/// silently installs a dud entry that has to be unpicked from both the service +/// and the organization (issue #611). Reject the cases that cannot be a real +/// endpoint ID before the request is sent; clap reports this as a usage error. +/// +/// Removal flags deliberately skip this check — a bogus ID that is already +/// registered must stay removable. +fn parse_private_endpoint_id_arg(value: &str) -> std::result::Result { + validate_private_endpoint_id(value).map(|()| value.to_string()) +} + +/// The three providers use unrelated endpoint ID formats, and the provider is +/// not known at parse time, so only checks that hold for every provider are +/// applied: +/// +/// * AWS PrivateLink IDs are `vpce-` plus 8 or 17 lowercase hex characters. The +/// prefix is unmistakable, so a value carrying it anywhere must be exactly one +/// well-formed AWS ID — that catches truncated IDs, uppercased IDs and a +/// pasted VPC endpoint ARN. +/// * GCP uses a numeric Private Service Connect connection ID and Azure uses a +/// private endpoint Resource ID or `resourceGuid`. Neither has a marker that +/// distinguishes a typo from a valid value, so they are only checked for +/// emptiness and whitespace (illegal in all three formats). An Azure Resource +/// ID is an absolute path and can name a resource `vpce-...`, so it is exempt +/// from the AWS check. +/// +/// Whether the endpoint exists and belongs to the caller is not checkable from +/// the client; that remains an upstream API gap. +fn validate_private_endpoint_id(value: &str) -> std::result::Result<(), String> { + if value.trim().is_empty() { + return Err("private endpoint ID must not be empty".to_string()); + } + if value.chars().any(char::is_whitespace) { + return Err(format!( + "invalid private endpoint ID '{value}': IDs cannot contain whitespace \ + (pass each endpoint ID as its own flag value)" + )); + } + // Azure Resource IDs are absolute paths and can legitimately name a + // resource `vpce-...`, so they are exempt from the AWS-shaped check below. + if value.starts_with('/') { + return Ok(()); + } + if value.to_ascii_lowercase().contains(AWS_VPC_ENDPOINT_PREFIX) + && !is_aws_vpc_endpoint_id(value) + { + return Err(format!( + "invalid AWS VPC endpoint ID '{value}': expected exactly '{AWS_VPC_ENDPOINT_PREFIX}' \ + followed by 8 or 17 lowercase hex characters (for example vpce-0123456789abcdef0). GCP \ + uses the numeric Private Service Connect connection ID and Azure the private \ + endpoint Resource ID or resourceGuid" + )); + } + Ok(()) +} + +/// `vpce-` plus 8 (legacy) or 17 (current) lowercase hex characters, and nothing +/// else. +fn is_aws_vpc_endpoint_id(value: &str) -> bool { + let Some(suffix) = value.strip_prefix(AWS_VPC_ENDPOINT_PREFIX) else { + return false; + }; + matches!(suffix.len(), 8 | 17) + && suffix + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')) +} + +#[cfg(test)] +mod tests { + #[test] + fn primary_json_file_argument_contract() { + crate::cloud::config::assert_primary_json_input( + &["cloud", "service", "settings", "set", "svc-1"], + "settings_file", + &["settings-file"], + ); + crate::cloud::config::assert_primary_json_input( + &["cloud", "service", "scaling-schedule", "set", "svc-1"], + "file", + &[], + ); + } + + use super::*; + use crate::cli::{Cli, Commands}; + use crate::cloud::cli::CloudCommands; + use clap::Parser; + + fn parse_service(args: &[&str]) -> crate::cloud::cli::ServiceCommands { + let cli = Cli::try_parse_from(args).expect("parse"); + let Commands::Cloud(cloud_args) = cli.command else { + panic!("expected cloud command"); + }; + crate::cloud::cli::tests::assert_org_selector(&cloud_args, args); + let CloudCommands::Service { command } = cloud_args.command else { + panic!("expected service command"); + }; + command + } + + fn assert_write(args: &[&str], expected: bool) { + let cli = Cli::try_parse_from(args).expect("parse"); + let Commands::Cloud(cloud_args) = cli.command else { + panic!("expected cloud command"); + }; + assert!(matches!(&cloud_args.command, CloudCommands::Service { .. })); + assert_eq!( + cloud_args.command.is_write_command(), + expected, + "wrong classification for: {}", + args.join(" ") + ); + } + + /// The `CONTEXT FOR AGENTS:` block on `service query` stays inside its + /// 8-line budget, whatever the lines say. + #[test] + fn service_query_agent_context_block_stays_within_its_line_budget() { + let error = Cli::try_parse_from(["clickhousectl", "cloud", "service", "query", "--help"]) + .err() + .expect("--help should stop parsing"); + assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); + let help = error.to_string(); + + let block = help + .split_once("CONTEXT FOR AGENTS:\n") + .expect("after_help block") + .1; + let lines = block.lines().filter(|line| !line.trim().is_empty()).count(); + assert!(lines <= 8, "{lines} lines: {help}"); + } + + #[test] + fn repair_query_key_has_a_positional_id_selector() { + use clap::CommandFactory; + let mut root = Cli::command(); + root.build(); + let command = root + .find_subcommand("cloud") + .unwrap() + .find_subcommand("service") + .unwrap() + .find_subcommand("repair-query-key") + .unwrap(); + let id = command + .get_arguments() + .find(|arg| arg.get_id() == "resource_id") + .unwrap(); + assert!(id.is_positional()); + assert_eq!(id.get_value_names().unwrap(), &["ID"]); + } + + #[test] + fn parses_service_create_defaults() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "default-service", + ]); + let crate::cloud::cli::ServiceCommands::Create { + name, + provider, + region, + min_replica_memory_gb, + max_replica_memory_gb, + num_replicas, + min_replicas, + max_replicas, + autoscaling_mode, + idle_scaling, + idle_timeout_minutes, + ip_allow, + backup_id, + backup_encryption_config, + release_channel, + data_warehouse_id, + readonly, + enable_tde, + compliance_type, + profile, + byoc_id, + tag, + enable_endpoint, + disable_endpoint, + private_preview_terms_checked, + enable_core_dumps, + } = command + else { + panic!("expected service create"); + }; + + assert_eq!(name, "default-service"); + assert_eq!(provider, "aws"); + assert_eq!(region, "us-east-1"); + assert!(min_replica_memory_gb.is_none()); + assert!(max_replica_memory_gb.is_none()); + assert!(num_replicas.is_none()); + assert!(min_replicas.is_none()); + assert!(max_replicas.is_none()); + assert!(autoscaling_mode.is_none()); + assert!(idle_scaling.is_none()); + assert!(idle_timeout_minutes.is_none()); + assert!(ip_allow.is_empty()); + assert!(backup_id.is_none()); + assert!(backup_encryption_config.is_none()); + assert!(release_channel.is_none()); + assert!(data_warehouse_id.is_none()); + assert!(!readonly); + assert!(!enable_tde); + assert!(compliance_type.is_none()); + assert!(profile.is_none()); + assert!(byoc_id.is_none()); + assert!(tag.is_empty()); + assert!(enable_endpoint.is_empty()); + assert!(disable_endpoint.is_empty()); + assert!(!private_preview_terms_checked); + assert!(enable_core_dumps.is_none()); + } + + #[test] + fn parses_service_create_maximal_vertical_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "maximal-service", + "--provider", + "azure", + "--region", + "eastus", + "--min-replica-memory-gb", + "16", + "--max-replica-memory-gb", + "32", + "--num-replicas", + "3", + "--autoscaling-mode", + "vertical", + "--idle-scaling", + "false", + "--idle-timeout-minutes", + "10", + "--ip-allow", + "10.0.0.0/8=office", + "--ip-allow", + "2001:db8::/32=\u{6771}\u{4eac}", + "--backup-id", + "backup-1", + "--backup-encryption-config", + "-", + "--release-channel", + "fast", + "--data-warehouse-id", + "dw-1", + "--readonly", + "--enable-tde", + "--compliance-type", + "pci", + "--profile", + "v1-highmem-xs", + "--byoc-id", + "byoc-1", + "--tag", + "env=prod", + "--tag", + "team=analytics", + "--enable-endpoint", + "mysql", + "--disable-endpoint", + "mysql", + "--private-preview-terms-checked", + "--enable-core-dumps", + "false", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::Create { + name, + provider, + region, + min_replica_memory_gb, + max_replica_memory_gb, + num_replicas, + autoscaling_mode, + idle_scaling, + idle_timeout_minutes, + ip_allow, + backup_id, + backup_encryption_config, + release_channel, + data_warehouse_id, + readonly, + enable_tde, + compliance_type, + profile, + byoc_id, + tag, + enable_endpoint, + disable_endpoint, + private_preview_terms_checked, + enable_core_dumps, + .. + } = command + else { + panic!("expected service create"); + }; + + assert_eq!(name, "maximal-service"); + assert_eq!(provider, "azure"); + assert_eq!(region, "eastus"); + assert_eq!(min_replica_memory_gb, Some(16)); + assert_eq!(max_replica_memory_gb, Some(32)); + assert_eq!(num_replicas, Some(3)); + assert_eq!(autoscaling_mode.as_deref(), Some("vertical")); + assert_eq!(idle_scaling, Some(false)); + assert_eq!(idle_timeout_minutes, Some(10)); + assert_eq!( + ip_allow, + vec!["10.0.0.0/8=office", "2001:db8::/32=\u{6771}\u{4eac}"] + ); + assert_eq!(backup_id.as_deref(), Some("backup-1")); + assert_eq!(backup_encryption_config.as_deref(), Some("-")); + assert_eq!(release_channel.as_deref(), Some("fast")); + assert_eq!(data_warehouse_id.as_deref(), Some("dw-1")); + assert!(readonly); + assert!(enable_tde); + assert_eq!(compliance_type.as_deref(), Some("pci")); + assert_eq!(profile.as_deref(), Some("v1-highmem-xs")); + assert_eq!(byoc_id.as_deref(), Some("byoc-1")); + assert_eq!(tag, vec!["env=prod", "team=analytics"]); + assert_eq!(enable_endpoint, vec!["mysql"]); + assert_eq!(disable_endpoint, vec!["mysql"]); + assert!(private_preview_terms_checked); + assert_eq!(enable_core_dumps, Some(false)); + } + + #[test] + fn parses_service_create_dynamic_byoc_profile_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "byoc-service", + "--profile", + "v1-standard-byoc-4", + "--byoc-id", + "byoc-1", + "--min-replica-memory-gb", + "48", + "--max-replica-memory-gb", + "48", + ]); + let ServiceCommands::Create { + profile, + byoc_id, + min_replica_memory_gb, + max_replica_memory_gb, + .. + } = command + else { + panic!("expected service create"); + }; + assert_eq!(profile.as_deref(), Some("v1-standard-byoc-4")); + assert_eq!(byoc_id.as_deref(), Some("byoc-1")); + assert_eq!(min_replica_memory_gb, Some(48)); + assert_eq!(max_replica_memory_gb, Some(48)); + } + + #[test] + fn parses_service_list_filters_and_org_id() { + let command = parse_service(&["clickhousectl", "cloud", "service", "list"]); + let crate::cloud::cli::ServiceCommands::List { filter } = command else { + panic!("expected service list"); + }; + + assert!(filter.is_empty()); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "list", + "--filter", + "tag:env=prod", + "--filter", + "tag:team=analytics", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::List { filter } = command else { + panic!("expected service list"); + }; + + assert_eq!(filter, vec!["tag:env=prod", "tag:team=analytics"]); + } + + #[test] + fn service_list_tag_filters_preserve_api_grammar() { + for value in ["tag:env=prod", "tag:active", "tag:empty=", "tag:expr=a=b"] { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "list", + "--filter", + value, + ]); + let ServiceCommands::List { filter, .. } = command else { + panic!("expected service list"); + }; + assert_eq!(filter, [value]); + } + for value in [ + "garbage", + "state=running", + "env=prod", + "tag:", + "tag:=x", + "tag: =x", + ] { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "list", + "--filter", + value, + ]) + .err() + .expect("malformed filter must fail"); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + } + + #[test] + fn parses_service_profile_list_region_or_byoc_flags() { + for (flags, expected_region, expected_byoc) in [ + (vec!["--region", "eu-west-1"], Some("eu-west-1"), None), + (vec!["--byoc-id", "byoc-1"], None, Some("byoc-1")), + ( + vec!["--region", "eu-west-1", "--byoc-id", "byoc-1"], + Some("eu-west-1"), + Some("byoc-1"), + ), + ] { + let mut args = vec!["clickhousectl", "cloud", "service", "profile", "list"]; + args.extend(flags); + let command = parse_service(&args); + assert!(!command.is_write()); + let ServiceCommands::Profile { + command: ServiceProfileCommands::List { region, byoc_id }, + } = command + else { + panic!("expected service profile list"); + }; + assert_eq!(region.as_deref(), expected_region); + assert_eq!(byoc_id.as_deref(), expected_byoc); + } + + let error = Cli::try_parse_from(["clickhousectl", "cloud", "service", "profile", "list"]) + .err() + .expect("missing both region and BYOC should fail"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn parses_scaling_schedule_commands_and_classifies_writes() { + let get = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "scaling-schedule", + "get", + "svc-1", + ]); + let ServiceCommands::ScalingSchedule { + command: ScalingScheduleCommands::Get { service_id }, + } = get + else { + panic!("expected scaling schedule get"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + + let set = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "scaling-schedule", + "set", + "svc-1", + "--file", + "-", + "--org-id", + "org-1", + ]); + let ServiceCommands::ScalingSchedule { + command: ScalingScheduleCommands::Set { service_id, file }, + } = set + else { + panic!("expected scaling schedule set"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(file, "-"); + + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "scaling-schedule", + "get", + "svc-1", + ], + false, + ); + for leaf in ["set", "delete"] { + let mut args = vec![ + "clickhousectl", + "cloud", + "service", + "scaling-schedule", + leaf, + "svc-1", + ]; + if leaf == "set" { + args.extend(["--file", "schedule.json"]); + } + assert_write(&args, true); + } + } + + #[test] + fn scaling_schedule_set_requires_file() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "scaling-schedule", + "set", + "svc-1", + ]) + .err() + .expect("missing --file should fail"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn parses_upgrade_window_commands_and_classifies_writes() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "set", + "svc-1", + "--weekday", + "3", + "--start-hour", + "12", + "--org-id", + "org-1", + ]); + let ServiceCommands::UpgradeWindow { + command: + UpgradeWindowCommands::Set { + service_id, + weekday, + start_hour, + }, + } = command + else { + panic!("expected upgrade-window set"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(weekday, 3); + assert_eq!(start_hour, "12"); + + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "get", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "set", + "svc-1", + "--weekday", + "0", + "--start-hour", + "0", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "delete", + "svc-1", + ], + true, + ); + } + + #[test] + fn parses_replica_aware_routing_commands_and_classifies_writes() { + let base = ["clickhousectl", "cloud", "service", "replica-aware-routing"]; + let parse_update = |flags: &[&str]| { + let mut args = base.to_vec(); + args.extend(["update", "svc-1"]); + args.extend(flags); + match parse_service(&args) { + ServiceCommands::ReplicaAwareRouting { + command: + ReplicaAwareRoutingCommands::Update { + service_id, + http_enabled, + native_enabled, + }, + } => { + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + (http_enabled, native_enabled) + } + _ => panic!("expected replica-aware-routing update"), + } + }; + assert_eq!( + parse_update(&["--http-enabled", "true"]), + (Some(true), None) + ); + assert_eq!( + parse_update(&["--native-enabled", "false"]), + (None, Some(false)) + ); + assert_eq!( + parse_update(&["--http-enabled", "false", "--native-enabled", "true"]), + (Some(false), Some(true)) + ); + + let ServiceCommands::ReplicaAwareRouting { + command: ReplicaAwareRoutingCommands::Get { service_id }, + } = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "replica-aware-routing", + "get", + "--name", + "analytics", + ]) + else { + panic!("expected replica-aware-routing get"); + }; + assert_eq!(service_id.name.as_deref(), Some("analytics")); + + let mut get = base.to_vec(); + get.extend(["get", "svc-1"]); + assert_write(&get, false); + let mut update = base.to_vec(); + update.extend(["update", "svc-1", "--native-enabled", "true"]); + assert_write(&update, true); + } + + #[test] + fn replica_aware_routing_update_requires_a_change_and_boolean_values() { + let base = [ + "clickhousectl", + "cloud", + "service", + "replica-aware-routing", + "update", + "svc-1", + ]; + let error = Cli::try_parse_from(base) + .err() + .expect("no change should fail"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + for flag in ["--http-enabled", "--native-enabled"] { + let mut args = base.to_vec(); + args.extend([flag, "yes"]); + let error = Cli::try_parse_from(args) + .err() + .expect("non-bool should fail"); + assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue); + let mut args = base.to_vec(); + args.push(flag); + assert!(Cli::try_parse_from(args).is_err(), "{flag} needs a value"); + } + } + + #[test] + fn upgrade_window_set_rejects_missing_and_out_of_range_values() { + for args in [ + vec![ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "set", + "svc-1", + "--start-hour", + "6", + ], + vec![ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "set", + "svc-1", + "--weekday", + "2", + ], + ] { + let error = Cli::try_parse_from(args).err().expect("should fail"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + for (flag, value) in [ + ("--weekday", "7"), + ("--weekday", "-1"), + ("--start-hour", "3"), + ] { + let mut args = vec![ + "clickhousectl", + "cloud", + "service", + "upgrade-window", + "set", + "svc-1", + "--weekday", + "2", + "--start-hour", + "6", + ]; + let index = args.iter().position(|arg| *arg == flag).unwrap() + 1; + args[index] = value; + let error = Cli::try_parse_from(args).err().expect("should fail"); + assert!(matches!( + error.kind(), + clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue + )); + } + } + + #[test] + fn parses_service_delete_force_and_org_id() { + let command = parse_service(&["clickhousectl", "cloud", "service", "delete", "svc-1"]); + let crate::cloud::cli::ServiceCommands::Delete { force, .. } = command else { + panic!("expected service delete"); + }; + assert!(!force); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "delete", + "svc-1", + "--force", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::Delete { service_id, force } = command else { + panic!("expected service delete"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert!(force); + } + + #[test] + fn parses_service_body_command_defaults() { + let update = parse_service(&["clickhousectl", "cloud", "service", "update", "svc-1"]); + let crate::cloud::cli::ServiceCommands::Update { + name, + add_ip_allow, + remove_ip_allow, + add_private_endpoint_id, + remove_private_endpoint_id, + release_channel, + enable_endpoint, + disable_endpoint, + transparent_data_encryption_key_id, + add_tag, + remove_tag, + enable_core_dumps, + .. + } = update + else { + panic!("expected service update"); + }; + assert!(name.is_none()); + assert!(add_ip_allow.is_empty()); + assert!(remove_ip_allow.is_empty()); + assert!(add_private_endpoint_id.is_empty()); + assert!(remove_private_endpoint_id.is_empty()); + assert!(release_channel.is_none()); + assert!(enable_endpoint.is_empty()); + assert!(disable_endpoint.is_empty()); + assert!(transparent_data_encryption_key_id.is_none()); + assert!(add_tag.is_empty()); + assert!(remove_tag.is_empty()); + assert!(enable_core_dumps.is_none()); + + let scale = parse_service(&["clickhousectl", "cloud", "service", "scale", "svc-1"]); + let crate::cloud::cli::ServiceCommands::Scale { + min_replica_memory_gb, + max_replica_memory_gb, + num_replicas, + min_replicas, + max_replicas, + autoscaling_mode, + idle_scaling, + idle_timeout_minutes, + .. + } = scale + else { + panic!("expected service scale"); + }; + assert!(min_replica_memory_gb.is_none()); + assert!(max_replica_memory_gb.is_none()); + assert!(num_replicas.is_none()); + assert!(min_replicas.is_none()); + assert!(max_replicas.is_none()); + assert!(autoscaling_mode.is_none()); + assert!(idle_scaling.is_none()); + assert!(idle_timeout_minutes.is_none()); + + let reset = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "reset-password", + "svc-1", + ]); + let crate::cloud::cli::ServiceCommands::ResetPassword { + new_password_hash, + new_double_sha1_hash, + .. + } = reset + else { + panic!("expected service reset-password"); + }; + assert!(new_password_hash.is_none()); + assert!(new_double_sha1_hash.is_none()); + + let query_endpoint = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "create", + "svc-1", + "--role", + "sql_console_read_only", + ]); + let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = query_endpoint else { + panic!("expected query-endpoint command"); + }; + let crate::cloud::cli::QueryEndpointCommands::Create { + role, + open_api_key, + allowed_origins, + replace_open_api_keys, + .. + } = command + else { + panic!("expected query-endpoint create"); + }; + assert_eq!(role, ["sql_console_read_only"]); + assert!(!replace_open_api_keys); + assert!(open_api_key.is_empty()); + assert!(allowed_origins.is_none()); + + let private_endpoint = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + "vpce-0123456789abcdef0", + ]); + let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = private_endpoint + else { + panic!("expected private-endpoint command"); + }; + let crate::cloud::cli::PrivateEndpointCommands::Create { description, .. } = command else { + panic!("expected private-endpoint create"); + }; + assert!(description.is_none()); + } + + #[test] + fn parses_service_query_json_mode() { + let cli = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + "--query", + "SELECT 1", + "--json", + ]) + .unwrap(); + let Commands::Cloud(args) = cli.command else { + panic!("expected cloud command"); + }; + assert!(args.json); + let CloudCommands::Service { command } = args.command else { + panic!("expected service command"); + }; + let ServiceCommands::Query { format, .. } = command else { + panic!("expected service query"); + }; + assert!(format.is_none()); + } + + #[test] + fn rejects_service_query_json_with_explicit_format() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + "--query", + "SELECT 1", + "--json", + "--format", + "CSV", + ]) + .err() + .expect("--json and --format should conflict"); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + assert!(error.to_string().contains("--json")); + assert!(error.to_string().contains("--format")); + } + + #[test] + fn parses_service_query_explicit_format_without_json() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + "--query", + "SELECT 1", + "--format", + "CSV", + ]); + let ServiceCommands::Query { format, .. } = command else { + panic!("expected service query"); + }; + assert_eq!(format.as_deref(), Some("CSV")); + } + + #[test] + fn parses_service_update_ga_patch_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--add-ip-allow", + "2001:db8::/32=office", + "--remove-ip-allow", + "0.0.0.0/0", + "--add-private-endpoint-id", + "pe-1", + "--remove-private-endpoint-id", + "pe-2", + "--release-channel", + "fast", + "--enable-endpoint", + "mysql", + "--add-tag", + "env=prod", + "--enable-core-dumps", + "true", + ]); + let ServiceCommands::Update { + service_id, + add_ip_allow, + remove_ip_allow, + add_private_endpoint_id, + remove_private_endpoint_id, + release_channel, + enable_endpoint, + add_tag, + enable_core_dumps, + .. + } = command + else { + panic!("expected service update"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(add_ip_allow, vec!["2001:db8::/32=office"]); + assert_eq!(remove_ip_allow, vec!["0.0.0.0/0"]); + assert_eq!(add_private_endpoint_id, vec!["pe-1"]); + assert_eq!(remove_private_endpoint_id, vec!["pe-2"]); + assert_eq!(release_channel.as_deref(), Some("fast")); + assert_eq!(enable_endpoint, vec!["mysql"]); + assert_eq!(add_tag, vec!["env=prod"]); + assert_eq!(enable_core_dumps, Some(true)); + } + + #[test] + fn parses_service_update_maximal_and_repeatable_add_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--new-name", + "renamed", + "--add-ip-allow", + "10.0.0.0/8=office", + "--add-ip-allow", + "2001:db8::/32=\u{6771}\u{4eac}", + "--remove-ip-allow", + "0.0.0.0/0", + "--add-private-endpoint-id", + "pe-1", + "--add-private-endpoint-id", + "pe-2", + "--remove-private-endpoint-id", + "pe-3", + "--release-channel", + "slow", + "--enable-endpoint", + "mysql", + "--disable-endpoint", + "mysql", + "--transparent-data-encryption-key-id", + "tde-1", + "--add-tag", + "env=prod", + "--add-tag", + "team=analytics", + "--enable-core-dumps", + "false", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::Update { + service_id, + name, + add_ip_allow, + remove_ip_allow, + add_private_endpoint_id, + remove_private_endpoint_id, + release_channel, + enable_endpoint, + disable_endpoint, + transparent_data_encryption_key_id, + add_tag, + remove_tag, + enable_core_dumps, + } = command + else { + panic!("expected service update"); + }; + + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(name.as_deref(), Some("renamed")); + assert_eq!( + add_ip_allow, + vec!["10.0.0.0/8=office", "2001:db8::/32=\u{6771}\u{4eac}"] + ); + assert_eq!(remove_ip_allow, vec!["0.0.0.0/0"]); + assert_eq!(add_private_endpoint_id, vec!["pe-1", "pe-2"]); + assert_eq!(remove_private_endpoint_id, vec!["pe-3"]); + assert_eq!(release_channel.as_deref(), Some("slow")); + assert_eq!(enable_endpoint, vec!["mysql"]); + assert_eq!(disable_endpoint, vec!["mysql"]); + assert_eq!(transparent_data_encryption_key_id.as_deref(), Some("tde-1")); + assert_eq!(add_tag, vec!["env=prod", "team=analytics"]); + assert!(remove_tag.is_empty()); + assert_eq!(enable_core_dumps, Some(false)); + } + + #[test] + fn parses_service_update_repeatable_tag_removals_with_other_removals() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--remove-tag", + "legacy", + "--remove-tag", + "owner=former-team", + "--remove-ip-allow", + "10.0.0.0/8", + "--remove-private-endpoint-id", + "pe-1", + ]); + let ServiceCommands::Update { + add_tag, + remove_tag, + remove_ip_allow, + remove_private_endpoint_id, + .. + } = command + else { + panic!("expected service update"); + }; + + assert!(add_tag.is_empty()); + assert_eq!(remove_tag, vec!["legacy", "owner=former-team"]); + assert_eq!(remove_ip_allow, vec!["10.0.0.0/8"]); + assert_eq!(remove_private_endpoint_id, vec!["pe-1"]); + } + + #[test] + fn rejects_combining_service_update_tag_additions_and_removals() { + for (first_flag, first_value, second_flag, second_value) in [ + ("--add-tag", "env=prod", "--remove-tag", "env=prod"), + ("--remove-tag", "env=prod", "--add-tag", "env=prod"), + ("--add-tag", "env=prod", "--remove-tag", "env=staging"), + ("--remove-tag", "env=staging", "--add-tag", "env=prod"), + ("--add-tag", "env=prod", "--remove-tag", "team=analytics"), + ("--remove-tag", "team=analytics", "--add-tag", "env=prod"), + ("--add-tag", "env", "--remove-tag", "team"), + ("--remove-tag", "team", "--add-tag", "env"), + ] { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + first_flag, + first_value, + second_flag, + second_value, + ]) + .err() + .expect("mixed tag operations must conflict"); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::ArgumentConflict, + "{first_flag} {first_value} followed by {second_flag} {second_value}" + ); + } + } + + #[test] + fn parses_service_create_horizontal_autoscaling_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "s", + "--min-replicas", + "2", + "--max-replicas", + "8", + "--autoscaling-mode", + "horizontal", + ]); + let ServiceCommands::Create { + min_replicas, + max_replicas, + autoscaling_mode, + num_replicas, + min_replica_memory_gb, + max_replica_memory_gb, + .. + } = command + else { + panic!("expected service create"); + }; + assert_eq!(min_replicas, Some(2)); + assert_eq!(max_replicas, Some(8)); + assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); + assert!(num_replicas.is_none()); + assert!(min_replica_memory_gb.is_none()); + assert!(max_replica_memory_gb.is_none()); + } + + #[test] + fn rejects_service_create_horizontal_vertical_mix() { + assert!( + Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "s", + "--min-replicas", + "2", + "--max-replicas", + "8", + "--num-replicas", + "3", + ]) + .is_err() + ); + } + + #[test] + fn parses_service_create_horizontal_mode_with_memory_bounds() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "s", + "--autoscaling-mode", + "horizontal", + "--min-replicas", + "2", + "--max-replicas", + "8", + "--min-replica-memory-gb", + "16", + "--max-replica-memory-gb", + "16", + ]); + let ServiceCommands::Create { + min_replicas, + max_replicas, + autoscaling_mode, + min_replica_memory_gb, + max_replica_memory_gb, + .. + } = command + else { + panic!("expected service create"); + }; + assert_eq!(min_replicas, Some(2)); + assert_eq!(max_replicas, Some(8)); + assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); + assert_eq!(min_replica_memory_gb, Some(16)); + assert_eq!(max_replica_memory_gb, Some(16)); + } + + #[test] + fn rejects_service_create_invalid_autoscaling_mode() { + assert!( + Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "s", + "--min-replicas", + "2", + "--max-replicas", + "8", + "--autoscaling-mode", + "turbo", + ]) + .is_err() + ); + } + + #[test] + fn parses_service_scale_horizontal_autoscaling_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "scale", + "svc-1", + "--min-replicas", + "2", + "--max-replicas", + "8", + "--autoscaling-mode", + "horizontal", + ]); + let ServiceCommands::Scale { + service_id, + min_replicas, + max_replicas, + autoscaling_mode, + num_replicas, + .. + } = command + else { + panic!("expected service scale"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(min_replicas, Some(2)); + assert_eq!(max_replicas, Some(8)); + assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); + assert!(num_replicas.is_none()); + } + + #[test] + fn parses_service_scale_switch_to_vertical_in_one_call() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "scale", + "svc-1", + "--autoscaling-mode", + "vertical", + "--num-replicas", + "3", + "--min-replica-memory-gb", + "8", + "--max-replica-memory-gb", + "32", + "--idle-scaling", + "false", + "--idle-timeout-minutes", + "15", + "--org-id", + "org-1", + ]); + let ServiceCommands::Scale { + autoscaling_mode, + num_replicas, + min_replica_memory_gb, + max_replica_memory_gb, + min_replicas, + max_replicas, + idle_scaling, + idle_timeout_minutes, + .. + } = command + else { + panic!("expected service scale"); + }; + assert_eq!(autoscaling_mode.as_deref(), Some("vertical")); + assert_eq!(num_replicas, Some(3)); + assert_eq!(min_replica_memory_gb, Some(8)); + assert_eq!(max_replica_memory_gb, Some(32)); + assert!(min_replicas.is_none()); + assert!(max_replicas.is_none()); + assert_eq!(idle_scaling, Some(false)); + assert_eq!(idle_timeout_minutes, Some(15)); + } + + #[test] + fn rejects_service_scale_num_replicas_with_replica_band() { + assert!( + Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "scale", + "svc-1", + "--num-replicas", + "3", + "--min-replicas", + "2", + "--max-replicas", + "8", + ]) + .is_err() + ); + } + + #[test] + fn parses_private_endpoint_config_and_password_hash_flags() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "reset-password", + "svc-1", + "--new-password-hash", + "sha256", + "--new-double-sha1-hash", + "sha1", + "--org-id", + "org-1", + ]); + let ServiceCommands::ResetPassword { + new_password_hash, + new_double_sha1_hash, + .. + } = command + else { + panic!("expected reset-password"); + }; + assert_eq!(new_password_hash.as_deref(), Some("sha256")); + assert_eq!(new_double_sha1_hash.as_deref(), Some("sha1")); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "get-config", + "svc-1", + "--org-id", + "org-1", + ]); + let ServiceCommands::PrivateEndpoint { command } = command else { + panic!("expected private-endpoint command"); + }; + let PrivateEndpointCommands::GetConfig { service_id } = command else { + panic!("expected get-config"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + } + + #[test] + fn parses_service_query_options() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + ]); + let crate::cloud::cli::ServiceCommands::Query { + name, + id, + query, + queries_file, + database, + format, + no_auto_enable, + .. + } = command + else { + panic!("expected service query"); + }; + assert!(name.is_none()); + assert_eq!(id.as_deref(), Some("svc-1")); + assert!(query.is_none()); + assert!(queries_file.is_none()); + assert!(database.is_none()); + assert!(format.is_none()); + + assert!(!no_auto_enable); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query", + "--name", + "analytics", + "--query", + "SELECT 1", + "--database", + "default", + "--format", + "CSV", + "--org-id", + "org-1", + "--no-auto-enable", + ]); + let crate::cloud::cli::ServiceCommands::Query { + name, + id, + query, + queries_file, + database, + format, + no_auto_enable, + .. + } = command + else { + panic!("expected service query"); + }; + + assert_eq!(name.as_deref(), Some("analytics")); + assert!(id.is_none()); + assert_eq!(query.as_deref(), Some("SELECT 1")); + assert!(queries_file.is_none()); + assert_eq!(database.as_deref(), Some("default")); + assert_eq!(format.as_deref(), Some("CSV")); + + assert!(no_auto_enable); + } + + #[test] + fn parses_service_query_key_repair_scope() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "repair-query-key", + "svc-1", + "--org-id", + "org-1", + ]); + let ServiceCommands::RepairQueryKey { service_id } = command else { + panic!("expected query-key repair"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + + let ServiceCommands::RepairQueryKey { .. } = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "repair-query-key", + "svc-1", + ]) else { + panic!("expected query-key repair"); + }; + } + + #[test] + fn parses_service_query_file_source() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query", + "--name", + "analytics", + "--queries-file", + "queries.sql", + ]); + let ServiceCommands::Query { + query, + queries_file, + .. + } = command + else { + panic!("expected service query"); + }; + + assert!(query.is_none()); + assert_eq!(queries_file.as_deref(), Some("queries.sql")); + } + + #[test] + fn rejects_service_query_with_inline_and_file_sources() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + "--query", + "SELECT 1", + "--queries-file", + "queries.sql", + ]) + .err() + .expect("service query with conflicting SQL sources should fail"); + + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + let message = error.to_string(); + assert!(message.contains("--query "), "{message}"); + assert!( + message.contains("--queries-file "), + "{message}" + ); + } + + #[test] + fn rejects_service_query_without_name_or_id() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query", + "--query", + "SELECT 1", + ]) + .err() + .expect("service query without a selector should fail"); + + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + let message = error.to_string(); + assert!(message.contains("--name "), "{message}"); + assert!(message.contains("--id "), "{message}"); + } + + #[test] + fn rejects_service_query_name_with_id() { + let result = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query", + "--name", + "analytics", + "--id", + "svc-1", + "--query", + "SELECT 1", + ]); + assert!(result.is_err()); + } + + #[test] + fn parses_query_endpoint_arguments() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "create", + "svc-1", + "--role", + "sql_console_read_only", + "--role", + "sql_console_admin", + "--open-api-key", + "key-1", + "--open-api-key", + "key-2", + "--replace-open-api-keys", + "--allowed-origins", + "https://example.com", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = command else { + panic!("expected query-endpoint command"); + }; + let crate::cloud::cli::QueryEndpointCommands::Create { + service_id, + role, + open_api_key, + allowed_origins, + replace_open_api_keys, + } = command + else { + panic!("expected query-endpoint create"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert!(replace_open_api_keys); + assert_eq!(role, vec!["sql_console_read_only", "sql_console_admin"]); + assert_eq!(open_api_key, vec!["key-1", "key-2"]); + assert_eq!(allowed_origins.as_deref(), Some("https://example.com")); + + for action in ["get", "delete"] { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + action, + "svc-1", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = command else { + panic!("expected query-endpoint command"); + }; + match command { + crate::cloud::cli::QueryEndpointCommands::Get { service_id } + | crate::cloud::cli::QueryEndpointCommands::Delete { service_id } => { + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + } + crate::cloud::cli::QueryEndpointCommands::Create { .. } => { + panic!("expected query-endpoint {action}") + } + } + } + } + + #[test] + fn query_endpoint_create_requires_roles_and_nonempty_explicit_values() { + let prefix = [ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "create", + "svc-1", + ]; + let missing_role = Cli::try_parse_from(prefix).err().unwrap(); + assert_eq!( + missing_role.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + for (flags, expected) in [ + ( + vec!["--role", "sql_console_read_only", "--replace-open-api-keys"], + clap::error::ErrorKind::MissingRequiredArgument, + ), + ( + vec!["--role", "sql_console_read_only", "--open-api-key", ""], + clap::error::ErrorKind::InvalidValue, + ), + ( + vec!["--role", "sql_console_read_only", "--allowed-origins", ""], + clap::error::ErrorKind::InvalidValue, + ), + ] { + let error = Cli::try_parse_from(prefix.into_iter().chain(flags)) + .err() + .unwrap(); + assert_eq!(error.kind(), expected); + } + } + + #[test] + fn rejects_unknown_query_endpoint_role() { + let result = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "create", + "svc-1", + "--role", + "admin", + ]); + + assert!(result.is_err()); + } + + #[test] + fn parses_private_endpoint_create_values() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + "vpce-0123456789abcdef0", + "--description", + "production", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = command else { + panic!("expected private-endpoint command"); + }; + let crate::cloud::cli::PrivateEndpointCommands::Create { + service_id, + endpoint_id, + description, + } = command + else { + panic!("expected private-endpoint create"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(endpoint_id, "vpce-0123456789abcdef0"); + assert_eq!(description.as_deref(), Some("production")); + } + + /// Non-AWS endpoint IDs have no marker that separates a typo from a valid + /// value, so they must pass through untouched (issue #611). + #[test] + fn parses_non_aws_private_endpoint_ids_unchanged() { + for id in [ + // GCP Private Service Connect connection ID. + "102600141743718403", + // Azure private endpoint Resource ID. + "/subscriptions/11111111-2222-3333-4444-555555555555/resourceGroups/rg/providers/\ + Microsoft.Network/privateEndpoints/pe-demo", + // Azure resourceGuid (legacy form). + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + // Legacy 8-character AWS VPC endpoint ID. + "vpce-0123abcd", + ] { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + id, + ]); + let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = command else { + panic!("expected private-endpoint command"); + }; + let crate::cloud::cli::PrivateEndpointCommands::Create { endpoint_id, .. } = command + else { + panic!("expected private-endpoint create"); + }; + assert_eq!(endpoint_id, id, "{id} must be accepted verbatim"); + } + } + + #[test] + fn rejects_malformed_private_endpoint_create_endpoint_id() { + for id in ["vpce-1", "vpce-bogus", "VPCE-0123456789ABCDEF0", "", " "] { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + id, + ]) + .err() + .unwrap_or_else(|| panic!("--endpoint-id {id:?} must be rejected")); + assert_eq!( + error.kind(), + clap::error::ErrorKind::ValueValidation, + "--endpoint-id {id:?} must fail as a usage error" + ); + } + } + + #[test] + fn rejects_malformed_added_private_endpoint_id_on_update() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--add-private-endpoint-id", + "vpce-nope", + ]) + .err() + .expect("malformed --add-private-endpoint-id must be rejected"); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + + /// Removing an ID must never be format-checked: a bogus ID that is already + /// registered has to stay removable (issue #611). + #[test] + fn accepts_malformed_removed_private_endpoint_id_on_update() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "update", + "svc-1", + "--remove-private-endpoint-id", + "vpce-nope", + ]); + let crate::cloud::cli::ServiceCommands::Update { + remove_private_endpoint_id, + .. + } = command + else { + panic!("expected service update"); + }; + assert_eq!(remove_private_endpoint_id, vec!["vpce-nope"]); + } + + #[test] + fn validate_private_endpoint_id_accepts_every_provider_format() { + for id in [ + "vpce-0123456789abcdef0", + "vpce-0123abcd", + "102600141743718403", + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateEndpoints/pe", + // An Azure resource may itself be named `vpce-...`. + "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateEndpoints/\ + vpce-mine", + ] { + assert!( + validate_private_endpoint_id(id).is_ok(), + "{id} must be accepted" + ); + } + } + + #[test] + fn validate_private_endpoint_id_rejects_empty_values() { + for id in ["", " ", "\t"] { + let error = validate_private_endpoint_id(id).expect_err("empty must be rejected"); + assert!(error.contains("must not be empty"), "{error}"); + } + } + + #[test] + fn validate_private_endpoint_id_rejects_malformed_values() { + for id in [ + // Two IDs squeezed into one quoted flag value. + "vpce-0123456789abcdef0 vpce-0123abcd", + "vpce-", + "vpce-1", + "vpce-xyz", + // 16 hex characters: one short of the current AWS length. + "vpce-0123456789abcdef", + // 18 hex characters: one too many. + "vpce-0123456789abcdef01", + // Non-hex character inside an otherwise correct-length ID. + "vpce-0123456789abcdefg", + // AWS never issues uppercase IDs, so this would register a dud. + "VPCE-0123456789ABCDEF0", + // Endpoint *service* name pasted instead of the endpoint ID. + "com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0", + // Full ARN pasted instead of the endpoint ID. + "arn:aws:ec2:us-east-1:123456789012:vpc-endpoint/vpce-0123456789abcdef0", + ] { + let error = validate_private_endpoint_id(id) + .expect_err(&format!("{id} must be rejected, not accepted")); + // The message names the offending value so the fix is obvious. + assert!( + error.contains(id), + "error for {id} should quote the value: {error}" + ); + } + } + + #[test] + fn parses_service_identity_lifecycle_and_prometheus_options() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "get", + "svc-1", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::Get { service_id } = command else { + panic!("expected service get"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + + for action in ["start", "wake", "stop"] { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + action, + "svc-1", + "--org-id", + "org-1", + ]); + match command { + crate::cloud::cli::ServiceCommands::Start { service_id } + | crate::cloud::cli::ServiceCommands::Wake { service_id } + | crate::cloud::cli::ServiceCommands::Stop { service_id } => { + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + } + _ => panic!("expected service {action}"), + } + } + + let command = parse_service(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"]); + let crate::cloud::cli::ServiceCommands::Prometheus { + filtered_metrics, .. + } = command + else { + panic!("expected service prometheus"); + }; + + assert!(filtered_metrics.is_none()); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "prometheus", + "svc-1", + "--filtered-metrics", + "true", + "--org-id", + "org-1", + ]); + let crate::cloud::cli::ServiceCommands::Prometheus { + service_id, + filtered_metrics, + } = command + else { + panic!("expected service prometheus"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + + assert_eq!(filtered_metrics, Some(true)); + } + + #[test] + fn top_level_write_classification_covers_every_service_command() { + assert_write(&["clickhousectl", "cloud", "service", "list"], false); + assert_write( + &["clickhousectl", "cloud", "service", "get", "svc-1"], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "profile", + "list", + "--region", + "us-east-1", + ], + false, + ); + for action in ["list", "schema"] { + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "settings", + action, + "svc-1", + ], + false, + ); + } + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "settings", + "get", + "svc-1", + "compatibility", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "settings", + "set", + "svc-1", + "--setting", + "compatibility=\"24.8\"", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "settings", + "unset", + "svc-1", + "compatibility", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "repair-query-key", + "svc-1", + ], + true, + ); + assert_write( + &["clickhousectl", "cloud", "service", "prometheus", "svc-1"], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "query", + "--id", + "svc-1", + "--query", + "SELECT 1", + ], + false, + ); + assert_write( + &["clickhousectl", "cloud", "service", "create", "--name", "s"], + true, + ); + for action in [ + "delete", + "start", + "wake", + "stop", + "update", + "scale", + "reset-password", + ] { + assert_write( + &["clickhousectl", "cloud", "service", action, "svc-1"], + true, + ); + } + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + "get", + "svc-1", + ], + false, + ); + for action in ["create", "delete"] { + let mut args = vec![ + "clickhousectl", + "cloud", + "service", + "query-endpoint", + action, + "svc-1", + ]; + if action == "create" { + args.extend(["--role", "sql_console_read_only"]); + } + assert_write(&args, true); + } + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "get-config", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "private-endpoint", + "create", + "svc-1", + "--endpoint-id", + "ep-1", + ], + true, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "backup-config", + "get", + "svc-1", + ], + false, + ); + assert_write( + &[ + "clickhousectl", + "cloud", + "service", + "backup-config", + "update", + "svc-1", + "--backup-period-hours", + "24", + ], + true, + ); + } + + #[test] + fn backup_encryption_config_requires_backup_id() { + let error = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "create", + "--name", + "svc", + "--backup-encryption-config", + "config.json", + ]) + .err() + .expect("--backup-encryption-config without --backup-id should fail"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn parses_service_settings_set_inputs() { + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "settings", + "set", + "svc-1", + "--setting", + "compatibility=\"24.8\"", + "--setting", + "enable_analyzer=1", + "--org-id", + "org-1", + ]); + let ServiceCommands::Settings { + command: + ServiceSettingsCommands::Set { + service_id, + setting, + settings_file, + }, + } = command + else { + panic!("expected settings set"); + }; + assert_eq!(service_id.id.as_deref(), Some("svc-1")); + assert_eq!(setting, ["compatibility=\"24.8\"", "enable_analyzer=1"]); + assert!(settings_file.is_none()); + + let command = parse_service(&[ + "clickhousectl", + "cloud", + "service", + "settings", + "set", + "svc-1", + "--file", + "-", + ]); + let ServiceCommands::Settings { + command: ServiceSettingsCommands::Set { settings_file, .. }, + } = command + else { + panic!("expected settings set"); + }; + assert_eq!(settings_file.as_deref(), Some("-")); + } + + #[test] + fn service_settings_set_requires_exactly_one_input_kind() { + let missing = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "settings", + "set", + "svc-1", + ]) + .err() + .expect("missing settings input should fail"); + assert_eq!( + missing.kind(), + clap::error::ErrorKind::MissingRequiredArgument + ); + + let conflict = Cli::try_parse_from([ + "clickhousectl", + "cloud", + "service", + "settings", + "set", + "svc-1", + "--setting", + "compatibility=\"24.8\"", + "--file", + "settings.json", + ]) + .err() + .expect("conflicting settings inputs should fail"); + assert_eq!(conflict.kind(), clap::error::ErrorKind::ArgumentConflict); + } +} diff --git a/crates/clickhousectl/src/cloud/services/client.rs b/crates/clickhousectl/src/cloud/services/client.rs new file mode 100644 index 00000000..98b3ef81 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/client.rs @@ -0,0 +1,510 @@ +use super::lifecycle::{build_service_state_patch_request, service_delete_error}; +use crate::cloud::client::{CloudClient, ResourceKind, ResourceLookup}; +use crate::cloud::types::DeleteResponse; +use clickhouse_cloud_api::models::{ + InstanceServiceQueryApiEndpointsPostRequest, ReplicaAwareRouting, + ReplicaAwareRoutingPatchRequest, ScalingSchedule, ScalingSchedulePostRequest, + ServicPrivateEndpointePostRequest, Service, ServiceClickhouseSetting, + ServiceClickhouseSettingsList, ServiceClickhouseSettingsPatchRequest, + ServiceClickhouseSettingsPatchResponse, ServiceClickhouseSettingsSchema, + ServicePasswordPatchRequest, ServicePatchRequest, ServicePostRequest, ServiceProfile, + ServiceReplicaScalingPatchRequest, ServiceStatePatchRequestCommand, UpgradeWindow, + UpgradeWindowPutRequest, +}; + +impl CloudClient { + /// The Query API host the library would send queries to, when one is + /// configured or derivable from the control-plane URL. + pub(super) fn configured_query_host(&self) -> Option { + self.api().configured_query_host() + } + + /// Send one statement to a service's Query API with an API key. + /// + /// Unlike every other wrapper, this returns the library error as-is and + /// the response unread: the caller streams the body, and the query paths + /// classify the raw error themselves (idle and stopped services, wake + /// retries, readiness polling, key rejections) before converting it + /// through `convert_query_error`, which records the telemetry failure. + #[allow(clippy::too_many_arguments)] + pub(super) async fn run_service_query( + &self, + service_id: &str, + key_id: &str, + key_secret: &str, + sql: &str, + database: Option<&str>, + format: &str, + wake_service: bool, + ) -> Result { + self.api() + .run_query( + service_id, + key_id, + key_secret, + sql, + database, + format, + wake_service, + ) + .await + } + + /// [`Self::run_service_query`] with the client's OAuth bearer token, + /// returning the raw library error for the same reason. + pub(super) async fn run_service_query_bearer( + &self, + service_id: &str, + sql: &str, + database: Option<&str>, + format: &str, + wake_service: bool, + ) -> Result { + self.api() + .run_query_bearer(service_id, sql, database, format, wake_service) + .await + } + + pub async fn get_scaling_schedule( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .scaling_schedule_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn set_scaling_schedule( + &self, + org_id: &str, + service_id: &str, + request: &ScalingSchedulePostRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .scaling_schedule_upsert(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn delete_scaling_schedule( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .scaling_schedule_delete(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Ok(DeleteResponse { + status: response.status, + request_id: response.request_id, + }) + } + + pub async fn list_service_profiles( + &self, + org_id: &str, + region_id: Option<&str>, + byoc_id: Option<&str>, + ) -> crate::cloud::client::Result> { + let response = self + .api() + .service_profiles_list(org_id, region_id, byoc_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn list_services(&self, org_id: &str) -> crate::cloud::client::Result> { + let response = self + .api() + .instance_get_list(org_id, &[]) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn list_clickhouse_settings( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .service_clickhouse_settings_list_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_clickhouse_setting( + &self, + org_id: &str, + service_id: &str, + setting_name: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .service_clickhouse_setting_get(org_id, service_id, setting_name) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_clickhouse_settings_schema( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .service_clickhouse_settings_schema_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn update_clickhouse_settings( + &self, + org_id: &str, + service_id: &str, + request: &ServiceClickhouseSettingsPatchRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .service_clickhouse_settings_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn reset_clickhouse_setting( + &self, + org_id: &str, + service_id: &str, + setting_name: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .service_clickhouse_setting_delete(org_id, service_id, setting_name) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Ok(DeleteResponse { + status: response.status, + request_id: response.request_id, + }) + } + + pub async fn list_services_filtered( + &self, + org_id: &str, + filters: &[String], + ) -> crate::cloud::client::Result> { + let filters: Vec<&str> = filters.iter().map(String::as_str).collect(); + let response = self + .api() + .instance_get_list(org_id, &filters) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_service( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_get(org_id, service_id) + .await + .map_err(|error| { + // This path identifies the service itself, not a subresource. + self.convert_error_for_lookup( + error, + ResourceLookup::in_org(ResourceKind::Service, service_id, org_id), + ) + })?; + Self::unwrap_response(response) + } + + pub async fn get_upgrade_window( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .upgrade_window_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn set_upgrade_window( + &self, + org_id: &str, + service_id: &str, + request: &UpgradeWindowPutRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .upgrade_window_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_replica_aware_routing( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .replica_aware_routing_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn update_replica_aware_routing( + &self, + org_id: &str, + service_id: &str, + request: &ReplicaAwareRoutingPatchRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .replica_aware_routing_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn delete_upgrade_window( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .upgrade_window_delete(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Ok(DeleteResponse { + status: response.status, + request_id: response.request_id, + }) + } + + pub async fn get_service_if_exists( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result> { + let lookup = ResourceLookup::in_org(ResourceKind::Service, service_id, org_id); + match self.api().instance_get(org_id, service_id).await { + Ok(response) => Self::unwrap_response(response).map(Some), + Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => Ok(None), + // The API answers 400 rather than 404 for a well-formed id no + // service has, and this GET has no other user-controlled input, + // so that rejection is its way of saying the same thing (#666). + // Without this, `service delete --force` aborted on an id + // `service get` reports as missing. + Err(error) if lookup.rejected_well_formed_ids(&error) => Ok(None), + Err(error) => Err(self.convert_error_for_lookup(error, lookup)), + } + } + + pub async fn create_service( + &self, + org_id: &str, + request: &ServicePostRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_create(org_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn delete_service( + &self, + org_id: &str, + service_id: &str, + force: bool, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_delete(org_id, service_id) + .await + .map_err(|error| service_delete_error(self, error, force, service_id, org_id))?; + Ok(DeleteResponse { + status: response.status, + request_id: response.request_id, + }) + } + + pub async fn change_service_state( + &self, + org_id: &str, + service_id: &str, + command: ServiceStatePatchRequestCommand, + ) -> crate::cloud::client::Result { + let request = build_service_state_patch_request(command); + let response = self + .api() + .instance_state_update(org_id, service_id, &request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn update_service( + &self, + org_id: &str, + service_id: &str, + request: &ServicePatchRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn update_replica_scaling( + &self, + org_id: &str, + service_id: &str, + request: &ServiceReplicaScalingPatchRequest, + ) -> crate::cloud::client::Result + { + let response = self + .api() + .instance_replica_scaling_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn reset_password( + &self, + org_id: &str, + service_id: &str, + request: &ServicePasswordPatchRequest, + ) -> crate::cloud::client::Result + { + let response = self + .api() + .instance_password_update(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_query_endpoint( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + match self + .api() + .instance_query_endpoint_get(org_id, service_id) + .await + { + Ok(response) => Self::unwrap_response(response), + Err(error) => { + // A 404 can mean the endpoint is absent on an existing service. + // Only the service lookup can establish that its parent is missing. + let lookup = ResourceLookup::in_org(ResourceKind::Service, service_id, org_id); + if matches!(&error, clickhouse_cloud_api::Error::Api { status: 404, .. }) + && lookup.rejected_well_formed_ids(&error) + { + self.get_service(org_id, service_id).await?; + } + Err(self.convert_error_for_organization(error, org_id)) + } + } + } + + pub async fn create_query_endpoint( + &self, + org_id: &str, + service_id: &str, + request: &InstanceServiceQueryApiEndpointsPostRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_query_endpoint_upsert(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn delete_query_endpoint( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_query_endpoint_delete(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Ok(DeleteResponse { + status: response.status, + request_id: response.request_id, + }) + } + + pub async fn create_private_endpoint( + &self, + org_id: &str, + service_id: &str, + request: &ServicPrivateEndpointePostRequest, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_private_endpoint_create(org_id, service_id, request) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_service_private_endpoint_config( + &self, + org_id: &str, + service_id: &str, + ) -> crate::cloud::client::Result { + let response = self + .api() + .instance_private_endpoint_config_get(org_id, service_id) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id))?; + Self::unwrap_response(response) + } + + pub async fn get_service_prometheus( + &self, + org_id: &str, + service_id: &str, + filtered_metrics: Option, + ) -> crate::cloud::client::Result { + let filtered = filtered_metrics.map(|value| value.to_string()); + self.api() + .instance_prometheus_get(org_id, service_id, filtered.as_deref()) + .await + .map_err(|error| self.convert_error_for_organization(error, org_id)) + } +} diff --git a/crates/clickhousectl/src/cloud/services/endpoints.rs b/crates/clickhousectl/src/cloud/services/endpoints.rs new file mode 100644 index 00000000..6525cf01 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/endpoints.rs @@ -0,0 +1,334 @@ +use crate::cloud::client::{CloudClient, CloudError, Result as CloudResult}; +use crate::cloud::output::{or_absent, print_human}; +use crate::cloud::services::query_key::existing_open_api_keys; +use crate::cloud::shared::{parse_serde_enum, resolve_org_id}; +use crate::failure::FailureStage; +use clickhouse_cloud_api::models::{ + InstanceServiceQueryApiEndpointsPostRequest, QueryEndpointRole, + ServicPrivateEndpointePostRequest, ServiceQueryAPIEndpoint, +}; +use std::collections::HashSet; + +#[derive(Default)] +pub(super) struct QueryEndpointCreateOptions { + pub(super) roles: Vec, + pub(super) open_api_keys: Vec, + pub(super) replace_open_api_keys: bool, + pub(super) allowed_origins: Option, +} + +fn build_query_endpoint_create_request( + options: &QueryEndpointCreateOptions, + existing: Option<&ServiceQueryAPIEndpoint>, +) -> CloudResult { + if options.roles.is_empty() { + return Err(CloudError::new("at least one --role is required")); + } + let mut open_api_keys = match existing { + Some(endpoint) if !options.replace_open_api_keys => { + existing_open_api_keys(endpoint.clone()) + .map_err(|error| error.at_stage(FailureStage::EndpointGet))? + } + _ => Vec::new(), + }; + open_api_keys.extend(options.open_api_keys.iter().cloned()); + let mut seen = HashSet::new(); + open_api_keys.retain(|key| seen.insert(key.clone())); + + // `allowedOrigins` is optional on both sides of the round trip, so an + // existing endpoint that reports none is written back without one rather + // than refused or given a default. + let allowed_origins = match (&options.allowed_origins, existing) { + (Some(origins), _) => Some(origins.clone()), + (None, Some(endpoint)) => endpoint.allowed_origins.clone(), + (None, None) => { + return Err(CloudError::new( + "--allowed-origins is required when creating a new query endpoint; \ + pass browser origins or '*' explicitly to allow all origins", + )); + } + }; + Ok(InstanceServiceQueryApiEndpointsPostRequest { + roles: options + .roles + .iter() + .map(|role| parse_serde_enum(role, "role", QueryEndpointRole::VALUES)) + .collect::>()?, + open_api_keys, + allowed_origins, + }) +} + +fn build_private_endpoint_create_request( + endpoint_id: &str, + description: Option<&str>, +) -> ServicPrivateEndpointePostRequest { + ServicPrivateEndpointePostRequest { + id: endpoint_id.to_string(), + // The spec makes `description` optional; keep sending `""` when the + // flag is absent so the create request is unchanged. + description: Some(description.map(String::from).unwrap_or_default()), + } +} + +pub(super) async fn query_endpoint_get( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let endpoint = client.get_query_endpoint(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&endpoint)?); + } else { + print_human(&endpoint)?; + } + Ok(()) +} + +pub(super) async fn query_endpoint_create( + client: &CloudClient, + service_id: &str, + options: QueryEndpointCreateOptions, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let existing = client + .get_query_endpoint_for_binding(&org_id, service_id) + .await + .map_err(|error| error.at_stage(FailureStage::EndpointGet))?; + let request = build_query_endpoint_create_request(&options, existing.as_ref())?; + let endpoint = client + .create_query_endpoint(&org_id, service_id, &request) + .await + .map_err(|error| error.at_stage(FailureStage::EndpointUpsert))?; + + if json { + println!("{}", serde_json::to_string_pretty(&endpoint)?); + } else { + print_human(&endpoint)?; + } + Ok(()) +} + +pub(super) async fn query_endpoint_delete( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let response = client.delete_query_endpoint(&org_id, service_id).await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Query endpoint deleted for service {}", service_id); + } + Ok(()) +} + +pub(super) async fn private_endpoint_create( + client: &CloudClient, + service_id: &str, + endpoint_id: &str, + description: Option<&str>, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let request = build_private_endpoint_create_request(endpoint_id, description); + let endpoint = client + .create_private_endpoint(&org_id, service_id, &request) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&endpoint)?); + } else { + println!("Private endpoint created for service {}", service_id); + println!(" Endpoint ID: {}", or_absent(endpoint.id.as_deref())); + println!( + " Description: {}", + or_absent(endpoint.description.as_deref()) + ); + } + Ok(()) +} + +pub(super) async fn private_endpoint_get_config( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let config = client + .get_service_private_endpoint_config(&org_id, service_id) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&config)?); + } else { + print_human(&config)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_query_endpoint_create_request_supports_minimal_fields() { + let request = build_query_endpoint_create_request( + &QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into()], + allowed_origins: Some("https://example.com".into()), + ..Default::default() + }, + None, + ) + .unwrap(); + + assert_eq!(request.roles, [QueryEndpointRole::SqlConsoleReadOnly]); + assert!(request.open_api_keys.is_empty()); + assert_eq!( + request.allowed_origins.as_deref(), + Some("https://example.com") + ); + } + + #[test] + fn build_query_endpoint_create_request_supports_maximal_fields() { + let request = build_query_endpoint_create_request( + &QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into(), "sql_console_admin".into()], + open_api_keys: vec!["key-1".into(), "key-2".into(), "key-1".into()], + replace_open_api_keys: true, + allowed_origins: Some("*".into()), + }, + Some(&ServiceQueryAPIEndpoint { + open_api_keys: Some(vec!["old-key".into()]), + allowed_origins: Some("https://before.example.com".into()), + ..Default::default() + }), + ) + .unwrap(); + + assert_eq!( + request.roles, + [ + QueryEndpointRole::SqlConsoleReadOnly, + QueryEndpointRole::SqlConsoleAdmin + ] + ); + assert_eq!(request.open_api_keys, ["key-1", "key-2"]); + assert_eq!(request.allowed_origins.as_deref(), Some("*")); + } + + #[test] + fn build_query_endpoint_create_request_merges_keys_and_preserves_origins() { + let request = build_query_endpoint_create_request( + &QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into()], + open_api_keys: vec!["key-2".into(), "key-3".into(), "key-3".into()], + ..Default::default() + }, + Some(&ServiceQueryAPIEndpoint { + roles: Some(vec![QueryEndpointRole::SqlConsoleAdmin]), + open_api_keys: Some(vec!["key-1".into(), "key-2".into(), "key-1".into()]), + allowed_origins: Some("https://before.example.com".into()), + ..Default::default() + }), + ) + .unwrap(); + + assert_eq!(request.roles, [QueryEndpointRole::SqlConsoleReadOnly]); + assert_eq!(request.open_api_keys, ["key-1", "key-2", "key-3"]); + assert_eq!( + request.allowed_origins.as_deref(), + Some("https://before.example.com") + ); + } + + #[test] + fn build_query_endpoint_create_request_requires_roles_and_first_origins() { + assert!( + build_query_endpoint_create_request(&QueryEndpointCreateOptions::default(), None) + .is_err() + ); + assert!( + build_query_endpoint_create_request( + &QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into()], + ..Default::default() + }, + None + ) + .is_err() + ); + } + + #[test] + fn build_query_endpoint_create_request_preserves_explicitly_empty_state() { + let request = build_query_endpoint_create_request( + &QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into()], + ..Default::default() + }, + Some(&ServiceQueryAPIEndpoint { + open_api_keys: Some(vec![]), + allowed_origins: Some(String::new()), + ..Default::default() + }), + ) + .unwrap(); + assert!(request.open_api_keys.is_empty()); + assert_eq!(request.allowed_origins.as_deref(), Some("")); + } + + #[test] + fn build_query_endpoint_create_request_rejects_unknown_keys_and_passes_absent_origins() { + let existing = ServiceQueryAPIEndpoint::default(); + let mut options = QueryEndpointCreateOptions { + roles: vec!["sql_console_read_only".into()], + open_api_keys: vec!["key-1".into()], + ..Default::default() + }; + assert!(build_query_endpoint_create_request(&options, Some(&existing)).is_err()); + options.replace_open_api_keys = true; + // An existing endpoint that reports no `allowedOrigins` is written back + // without the field. + let request = build_query_endpoint_create_request(&options, Some(&existing)).unwrap(); + assert_eq!(request.allowed_origins, None); + assert!( + serde_json::to_value(&request) + .unwrap() + .get("allowedOrigins") + .is_none() + ); + options.allowed_origins = Some("https://example.com".into()); + let request = build_query_endpoint_create_request(&options, Some(&existing)).unwrap(); + assert_eq!(request.open_api_keys, ["key-1"]); + assert_eq!( + request.allowed_origins.as_deref(), + Some("https://example.com") + ); + } + + #[test] + fn build_private_endpoint_create_request_supports_minimal_fields() { + let request = build_private_endpoint_create_request("vpce-0123456789abcdef0", None); + + assert_eq!(request.id, "vpce-0123456789abcdef0"); + assert_eq!(request.description.as_deref(), Some("")); + // The spec makes `description` optional; the wire request still sends `""`. + assert_eq!(serde_json::to_value(&request).unwrap()["description"], ""); + } + + #[test] + fn build_private_endpoint_create_request_supports_maximal_fields() { + let request = + build_private_endpoint_create_request("vpce-0123456789abcdef0", Some("production")); + + assert_eq!(request.id, "vpce-0123456789abcdef0"); + assert_eq!(request.description.as_deref(), Some("production")); + } +} diff --git a/crates/clickhousectl/src/cloud/services/lifecycle.rs b/crates/clickhousectl/src/cloud/services/lifecycle.rs new file mode 100644 index 00000000..f67bc2b7 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/lifecycle.rs @@ -0,0 +1,2010 @@ +use crate::cloud::api_keys::{cleanup_service_query_key, service_query_key_cleanup}; +use crate::cloud::client::{ + CloudClient, CloudError, ResourceKind, ResourceLookup, Result as CloudResult, +}; +use crate::cloud::credentials; +use crate::cloud::output::{ABSENT, eprint_line, or_absent, print_human, print_line}; +use crate::cloud::shared::PollProgress; +use crate::cloud::shared::{parse_ip_access_entries, parse_serde_enum, parse_tags, resolve_org_id}; +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, +}; +#[cfg(test)] +use clickhouse_cloud_api::models::{IpAccessListEntryResponse, ResourceTagsV1Response}; +use std::{collections::HashSet, io::IsTerminal, time::Duration}; +use tabled::{Table, Tabled, settings::Style}; + +/// Gap between `GET service` calls while `service delete --force` waits for a +/// stop to finish. A real stop takes minutes, so the loop can emit dozens of +/// progress lines — see [`crate::cloud::output::eprint_line`] for why none of +/// them may panic. +const STOP_POLL_INTERVAL: Duration = Duration::from_secs(5); + +/// `host:port` of a service's first endpoint, for list tables. +/// +/// Renders [`ABSENT`] when the API returned no endpoints, and keeps whichever +/// half of a partial endpoint it did return (`host` alone, or `-:port`). +fn first_endpoint(endpoints: Option<&[ServiceEndpoint]>) -> String { + endpoints + .and_then(|endpoints| endpoints.first()) + .map(|endpoint| match (endpoint.host.as_deref(), endpoint.port) { + (Some(host), Some(port)) => format!("{host}:{port}"), + (Some(host), None) => host.to_string(), + (None, Some(port)) => format!("{ABSENT}:{port}"), + (None, None) => ABSENT.to_string(), + }) + .unwrap_or_else(|| ABSENT.to_string()) +} + +fn parse_ip_access_list_patch( + add: &[String], + remove: &[String], +) -> CloudResult> { + let patch = IpAccessListPatch { + add: parse_ip_access_entries(add)?.unwrap_or_default(), + remove: parse_ip_access_entries(remove)?.unwrap_or_default(), + }; + + Ok((!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch)) +} + +fn parse_private_endpoint_ids_patch( + add: &[String], + remove: &[String], +) -> Option { + let patch = InstancePrivateEndpointsPatch { + add: if add.is_empty() { vec![] } else { add.to_vec() }, + remove: if remove.is_empty() { + vec![] + } else { + remove.to_vec() + }, + }; + + (!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch) +} + +fn parse_service_endpoint_changes( + enable: &[String], + disable: &[String], +) -> CloudResult>> { + let mut changes = Vec::new(); + + for protocol in enable { + changes.push(ServiceEndpointChange { + protocol: parse_serde_enum::( + protocol, + "endpoint", + &["mysql"], + )?, + enabled: true, + }); + } + + for protocol in disable { + changes.push(ServiceEndpointChange { + protocol: parse_serde_enum::( + protocol, + "endpoint", + &["mysql"], + )?, + enabled: false, + }); + } + + Ok((!changes.is_empty()).then_some(changes)) +} + +fn parse_instance_tags_patch( + add: &[String], + remove: &[String], +) -> CloudResult> { + let patch = InstanceTagsPatch { + add: parse_tags(add)?.unwrap_or_default(), + remove: parse_tags(remove)?.unwrap_or_default(), + }; + + Ok((!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch)) +} + +pub(super) async fn service_list( + client: &CloudClient, + filters: &[String], + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + + let services = if filters.is_empty() { + client.list_services(&org_id).await? + } else { + client.list_services_filtered(&org_id, filters).await? + }; + + if json { + println!("{}", serde_json::to_string_pretty(&services)?); + } else { + if services.is_empty() { + println!("No services found"); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "ID")] + id: String, + #[tabled(rename = "State")] + state: String, + #[tabled(rename = "Provider")] + provider: String, + #[tabled(rename = "Region")] + region: String, + #[tabled(rename = "Endpoint")] + endpoint: String, + } + let rows: Vec = services + .into_iter() + .map(|service| Row { + name: or_absent(service.name.as_deref()), + id: or_absent(service.id), + state: or_absent(service.state.as_ref()), + provider: or_absent(service.provider.as_ref()), + region: or_absent(service.region.as_ref()), + endpoint: first_endpoint(service.endpoints.as_deref()), + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +pub(super) async fn service_get( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let service = client.get_service(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + print_human(&service)?; + } + Ok(()) +} + +#[derive(Default)] +pub(super) struct CreateServiceOptions { + pub(super) name: String, + pub(super) provider: String, + pub(super) region: String, + pub(super) min_replica_memory_gb: Option, + pub(super) max_replica_memory_gb: Option, + pub(super) num_replicas: Option, + pub(super) min_replicas: Option, + pub(super) max_replicas: Option, + pub(super) autoscaling_mode: Option, + pub(super) idle_scaling: Option, + pub(super) idle_timeout_minutes: Option, + pub(super) ip_allow: Vec, + pub(super) backup_id: Option, + pub(super) backup_encryption_config: Option, + pub(super) release_channel: Option, + pub(super) data_warehouse_id: Option, + pub(super) is_readonly: bool, + pub(super) enable_tde: bool, + pub(super) compliance_type: Option, + pub(super) profile: Option, + pub(super) byoc_id: Option, + pub(super) tags: Vec, + pub(super) enable_endpoints: Vec, + pub(super) disable_endpoints: Vec, + pub(super) private_preview_terms_checked: bool, + pub(super) enable_core_dumps: Option, +} + +#[derive(Default)] +pub(super) struct ServiceUpdateOptions { + pub(super) name: Option, + pub(super) add_ip_allow: Vec, + pub(super) remove_ip_allow: Vec, + pub(super) add_private_endpoint_ids: Vec, + pub(super) remove_private_endpoint_ids: Vec, + pub(super) release_channel: Option, + pub(super) enable_endpoints: Vec, + pub(super) disable_endpoints: Vec, + pub(super) transparent_data_encryption_key_id: Option, + pub(super) add_tags: Vec, + pub(super) remove_tags: Vec, + pub(super) enable_core_dumps: Option, +} + +#[derive(Default)] +pub(super) struct ServiceResetPasswordOptions { + pub(super) new_password_hash: Option, + pub(super) new_double_sha1_hash: Option, +} + +struct HorizontalAutoscaling { + autoscaling_mode: Option, + min_replicas: Option, + max_replicas: Option, +} + +fn resolve_horizontal_autoscaling( + autoscaling_mode: Option<&str>, + min_replicas: Option, + max_replicas: Option, +) -> CloudResult { + if min_replicas.is_some() != max_replicas.is_some() { + return Err(CloudError::new( + "--min-replicas and --max-replicas must be specified together", + )); + } + let autoscaling_mode = autoscaling_mode + .map(|value| { + parse_serde_enum::(value, "autoscaling_mode", AutoscalingMode::VALUES) + }) + .transpose()?; + Ok(HorizontalAutoscaling { + autoscaling_mode, + min_replicas: min_replicas.map(i64::from), + max_replicas: max_replicas.map(i64::from), + }) +} + +/// Read a backup's `encryption_config.json` from a file or stdin (`-`), passing +/// the object through unchanged. Every failure is a usage error raised before +/// any request is sent. +pub(super) fn read_backup_encryption_config(path: &str) -> CloudResult { + use std::io::Read as _; + + let (raw, source) = if path == "-" { + let mut raw = String::new(); + std::io::stdin().read_to_string(&mut raw).map_err(|error| { + CloudError::usage(format!( + "failed to read --backup-encryption-config from stdin: {error}" + )) + })?; + (raw, "stdin".to_string()) + } else { + ( + std::fs::read_to_string(path).map_err(|error| { + CloudError::usage(format!( + "failed to read --backup-encryption-config file '{path}': {error}" + )) + })?, + format!("file '{path}'"), + ) + }; + match serde_json::from_str::(&raw) { + Ok(serde_json::Value::Object(object)) => Ok(object.into_iter().collect()), + Ok(_) => Err(CloudError::usage(format!( + "--backup-encryption-config {source} must contain a JSON object" + ))), + Err(error) => Err(CloudError::usage(format!( + "--backup-encryption-config {source} is not valid JSON: {error}" + ))), + } +} + +fn build_create_service_request(options: &CreateServiceOptions) -> CloudResult { + let ip_access_list = if options.ip_allow.is_empty() { + vec![IpAccessListEntry { + source: "0.0.0.0/0".to_string(), + description: Some("Allow all (created by clickhousectl)".to_string()), + }] + } else { + parse_ip_access_entries(&options.ip_allow)?.unwrap_or_default() + }; + + let horizontal = resolve_horizontal_autoscaling( + options.autoscaling_mode.as_deref(), + options.min_replicas, + options.max_replicas, + )?; + + if options.byoc_id.as_deref().is_some_and(str::is_empty) { + return Err(CloudError::new("--byoc-id cannot be empty")); + } + if options.byoc_id.is_some() + && (options.min_replica_memory_gb.is_none() || options.max_replica_memory_gb.is_none()) + { + return Err(CloudError::new( + "--byoc-id requires --min-replica-memory-gb and --max-replica-memory-gb", + )); + } + + let profile = options + .profile + .as_deref() + .map(|value| parse_create_service_profile(value, options.byoc_id.as_deref())) + .transpose()?; + if matches!(profile, Some(ServicePostRequestProfile::Unknown(_))) + && options.min_replica_memory_gb != options.max_replica_memory_gb + { + return Err(CloudError::new( + "a dynamic BYOC --profile requires equal --min-replica-memory-gb and --max-replica-memory-gb", + )); + } + + Ok(ServicePostRequest { + name: options.name.clone(), + provider: parse_serde_enum::( + &options.provider, + "provider", + ServicePostRequestProvider::VALUES, + )?, + region: parse_serde_enum::( + &options.region, + "region", + ServicePostRequestRegion::VALUES, + )?, + ip_access_list, + min_replica_memory_gb: options.min_replica_memory_gb.map(f64::from), + max_replica_memory_gb: options.max_replica_memory_gb.map(f64::from), + num_replicas: options.num_replicas.map(i64::from), + idle_scaling: options.idle_scaling, + idle_timeout_minutes: options.idle_timeout_minutes.map(f64::from), + backup_id: options + .backup_id + .as_deref() + .map(uuid::Uuid::parse_str) + .transpose() + .map_err(|error| CloudError::new(format!("invalid backup_id: {}", error)))?, + release_channel: match options.release_channel.as_deref() { + Some(value) => Some(parse_serde_enum::( + value, + "release_channel", + ServicePostRequestReleasechannel::VALUES, + )?), + None => None, + }, + tags: parse_tags(&options.tags)?, + data_warehouse_id: options.data_warehouse_id.clone(), + is_readonly: if options.is_readonly { + Some(true) + } else { + None + }, + has_transparent_data_encryption: if options.enable_tde { Some(true) } else { None }, + compliance_type: match options.compliance_type.as_deref() { + Some(value) => Some(parse_serde_enum::( + value, + "compliance_type", + ServicePostRequestCompliancetype::VALUES, + )?), + None => None, + }, + profile, + private_preview_terms_checked: if options.private_preview_terms_checked { + Some(true) + } else { + None + }, + endpoints: parse_service_endpoint_changes( + &options.enable_endpoints, + &options.disable_endpoints, + )?, + enable_core_dumps: options.enable_core_dumps, + autoscaling_mode: horizontal.autoscaling_mode, + byoc_id: options.byoc_id.clone(), + backup_encryption_config: options.backup_encryption_config.clone(), + min_replicas: horizontal.min_replicas, + max_replicas: horizontal.max_replicas, + #[cfg(feature = "deprecated-fields")] + max_total_memory_gb: None, + #[cfg(feature = "deprecated-fields")] + min_total_memory_gb: None, + #[cfg(feature = "deprecated-fields")] + private_endpoint_ids: None, + #[cfg(feature = "deprecated-fields")] + tier: None, + }) +} + +fn parse_create_service_profile( + value: &str, + byoc_id: Option<&str>, +) -> CloudResult { + if ServicePostRequestProfile::VALUES.contains(&value) { + return parse_serde_enum::( + value, + "profile", + ServicePostRequestProfile::VALUES, + ); + } + if byoc_id.is_none() { + return Err(CloudError::new(format!( + "invalid profile: unknown standard profile '{value}'; dynamic profiles require --byoc-id" + ))); + } + if value.is_empty() { + return Err(CloudError::new("--profile cannot be empty")); + } + serde_json::from_value(serde_json::Value::String(value.to_string())) + .map_err(|error| CloudError::new(format!("invalid profile: {error}"))) +} + +fn build_update_service_request( + options: &ServiceUpdateOptions, +) -> CloudResult { + Ok(ServicePatchRequest { + name: options.name.clone(), + ip_access_list: parse_ip_access_list_patch( + &options.add_ip_allow, + &options.remove_ip_allow, + )?, + private_endpoint_ids: parse_private_endpoint_ids_patch( + &options.add_private_endpoint_ids, + &options.remove_private_endpoint_ids, + ), + release_channel: options + .release_channel + .as_deref() + .map(|value| { + parse_serde_enum::( + value, + "release_channel", + ServicePatchRequestReleasechannel::VALUES, + ) + }) + .transpose()?, + endpoints: parse_service_endpoint_changes( + &options.enable_endpoints, + &options.disable_endpoints, + )?, + transparent_data_encryption_key_id: options.transparent_data_encryption_key_id.clone(), + tags: parse_instance_tags_patch(&options.add_tags, &options.remove_tags)?, + enable_core_dumps: options.enable_core_dumps, + }) +} + +/// Whether `options` requests removing anything from a service, so callers +/// can decide whether a pre-update `GET` is needed to check the removals +/// against the current state. +fn has_removals(options: &ServiceUpdateOptions) -> bool { + !options.remove_ip_allow.is_empty() + || !options.remove_private_endpoint_ids.is_empty() + || !options.remove_tags.is_empty() +} + +/// Requested values that do not match anything in `current` (by exact string +/// equality). The service update API silently no-ops a removal that matches +/// nothing, which is idempotent but can hide a typo, so the caller surfaces +/// each unmatched entry as a warning instead of failing. +fn unmatched<'a>(requested: &'a [String], current: &HashSet<&str>) -> Vec<&'a str> { + requested + .iter() + .map(String::as_str) + .filter(|value| !current.contains(value)) + .collect() +} + +/// Human-readable warnings for every `--remove-*` value that does not match +/// anything in `current`. IP entries come from the validated request so the +/// comparison uses the same parsed source that the API receives. +fn unmatched_removal_warnings( + options: &ServiceUpdateOptions, + validated_request: &ServicePatchRequest, + current: &Service, +) -> Vec { + let mut warnings = Vec::new(); + + let current_ip_allow: HashSet<&str> = current + .ip_access_list + .iter() + .flatten() + .filter_map(|entry| entry.source.as_deref()) + .collect(); + let remove_ip_allow = validated_request + .ip_access_list + .as_ref() + .map(|patch| patch.remove.as_slice()) + .unwrap_or_default(); + for entry in remove_ip_allow { + if current_ip_allow.contains(entry.source.as_str()) { + continue; + } + let value = &entry.source; + warnings.push(format!( + "--remove-ip-allow {value} did not match any entry in the service's current IP allow list; no entry was removed" + )); + } + + let current_private_endpoint_ids: HashSet<&str> = current + .private_endpoint_ids + .iter() + .flatten() + .map(String::as_str) + .collect(); + for value in unmatched( + &options.remove_private_endpoint_ids, + ¤t_private_endpoint_ids, + ) { + warnings.push(format!( + "--remove-private-endpoint-id {value} did not match any private endpoint on the service; nothing was removed" + )); + } + + let current_tags: HashSet<&str> = current + .tags + .iter() + .flatten() + .filter_map(|tag| tag.key.as_deref()) + .collect(); + let remove_tag_keys: Vec = options + .remove_tags + .iter() + .map(|value| tag_key(value).to_string()) + .collect(); + for value in unmatched(&remove_tag_keys, ¤t_tags) { + warnings.push(format!( + "--remove-tag {value} did not match any tag on the service; nothing was removed" + )); + } + + warnings +} + +/// The key portion of a `key` or `key=value` tag argument, matching how +/// [`crate::cloud::shared::parse_tag`] splits removal/addition arguments. +fn tag_key(value: &str) -> &str { + value.split_once('=').map_or(value, |(key, _)| key).trim() +} + +fn build_service_password_patch_request( + options: &ServiceResetPasswordOptions, +) -> ServicePasswordPatchRequest { + ServicePasswordPatchRequest { + new_password_hash: options.new_password_hash.clone(), + new_double_sha1_hash: options.new_double_sha1_hash.clone(), + } +} + +pub(super) fn build_service_state_patch_request( + command: ServiceStatePatchRequestCommand, +) -> ServiceStatePatchRequest { + ServiceStatePatchRequest { + command: Some(command), + } +} + +fn service_query_hint(service_id: Option) -> Option { + service_id.map(|id| { + format!( + "Run SQL with: clickhousectl cloud service query {} --query \"SELECT 1\"\n\ + (the Query API endpoint is provisioned automatically on first use)", + id + ) + }) +} + +fn service_credentials_block(password: Option<&str>, service_id: Option) -> String { + match (password, service_id) { + (Some(password), _) => format!( + "Credentials (save these, password shown only once):\n Username: default\n \ + Password: {}", + password + ), + (None, Some(id)) => format!( + "WARNING: the API response omitted the one-time password, so it cannot be shown.\n\ + The service was created; reset the password to get a usable credential:\n \ + clickhousectl cloud service reset-password {}", + id + ), + (None, None) => "WARNING: the API response omitted the one-time password, so it cannot be \ + shown.\nThe service was created; once you have its id, reset the password \ + with `clickhousectl cloud service reset-password ` to get a \ + usable credential." + .to_string(), + } +} + +pub(super) async fn service_create( + client: &CloudClient, + options: CreateServiceOptions, + json: bool, +) -> CloudResult<()> { + 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?; + + if json { + 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 { + print_human(service)?; + } + println!(); + println!( + "{}", + service_credentials_block(response.password.as_deref(), service_id) + ); + if let Some(hint) = service_query_hint(service_id) { + println!(); + println!("{}", hint); + } + } + Ok(()) +} + +async fn validate_dynamic_byoc_profile( + client: &CloudClient, + org_id: &str, + request: &ServicePostRequest, +) -> CloudResult<()> { + let Some(ServicePostRequestProfile::Unknown(profile_name)) = request.profile.as_ref() else { + return Ok(()); + }; + let byoc_id = request + .byoc_id + .as_deref() + .ok_or_else(|| CloudError::new("dynamic profiles require --byoc-id"))?; + let region = request.region.to_string(); + let profiles = client + .list_service_profiles(org_id, Some(®ion), Some(byoc_id)) + .await?; + let profile = profiles + .iter() + .find(|candidate| candidate.profile.as_deref() == Some(profile_name.as_str())) + .ok_or_else(|| { + CloudError::new(format!( + "profile '{profile_name}' is not available for BYOC infrastructure '{byoc_id}' in region '{region}'" + )) + })?; + let memory_gi = profile.memory_gi.ok_or_else(|| { + CloudError::new(format!( + "profile '{profile_name}' did not include its memory size; cannot validate service memory bounds" + )) + })?; + let (Some(min_memory), Some(max_memory)) = + (request.min_replica_memory_gb, request.max_replica_memory_gb) + else { + return Err(CloudError::new( + "dynamic BYOC profiles require both replica memory bounds", + )); + }; + if min_memory != memory_gi || max_memory != memory_gi { + return Err(CloudError::new(format!( + "profile '{profile_name}' requires both replica memory bounds to equal {memory_gi} GiB" + ))); + } + Ok(()) +} + +fn classify_stop_poll_state(state: Option<&ServiceState>) -> CloudResult { + let state = state + .ok_or_else(|| { + CloudError::new( + "the API response omitted the service state while waiting for the service to stop, \ + so the stop cannot be confirmed", + ) + })? + .to_string(); + if matches!(state.as_str(), "stopped" | "idle") { + return Ok(true); + } + if matches!(state.as_str(), "terminated" | "failed" | "deleted") { + return Err(CloudError::new(format!( + "service entered unexpected state '{}' while waiting for stop", + state + ))); + } + Ok(false) +} + +fn service_delete_conflict(error: CloudError, service_id: &str) -> CloudError { + let api_message = error.message.clone(); + let message = format!( + "service could not be deleted because of a conflict. If it is running, use --force to \ + stop it first, or `clickhousectl cloud service stop {service_id}`. API response: \ + {api_message}" + ); + CloudError { message, ..error } +} + +pub(super) fn service_delete_error( + client: &CloudClient, + error: clickhouse_cloud_api::Error, + force: bool, + service_id: &str, + org_id: &str, +) -> CloudError { + let is_conflict = matches!(&error, clickhouse_cloud_api::Error::Api { status: 409, .. }); + let error = client.convert_error_for_lookup( + error, + ResourceLookup::in_org(ResourceKind::Service, service_id, org_id), + ); + if is_conflict && !force { + service_delete_conflict(error, service_id) + } else { + error + } +} + +pub(super) async fn service_delete( + client: &CloudClient, + service_id: &str, + force: bool, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let (query_key_ids, retain_query_key) = service_query_key_cleanup(&org_id, service_id)?; + + if force { + let service = client.get_service_if_exists(&org_id, service_id).await?; + let state = service + .as_ref() + .map(|service| or_absent(service.state.as_ref())) + .unwrap_or_default(); + if matches!(state.as_str(), "running" | "idle" | "starting") { + // Every write below goes through `eprint_line`/`print_line`, never + // `eprintln!`/`println!`: this loop streams progress for as long as + // the stop takes (minutes), so it routinely outlives whatever was + // reading its output, and a panicking write turned a successful + // delete into exit 101 (#598). + eprint_line(format!( + "Stopping service {} before deletion...", + service_id + )); + client + .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Stop) + .await?; + + let verbose_polling = + std::io::stderr().is_terminal() && !json && std::env::var_os("CI").is_none(); + let mut progress = PollProgress::default(); + loop { + tokio::time::sleep(STOP_POLL_INTERVAL).await; + let service = client.get_service(&org_id, service_id).await?; + let state = or_absent(service.state.as_ref()); + if let Some(line) = progress.render(&state, verbose_polling) { + eprint_line(line); + } + if classify_stop_poll_state(service.state.as_ref())? { + break; + } + } + } + } + + let response = client.delete_service(&org_id, service_id, force).await?; + cleanup_service_query_key(client, &org_id, service_id, &query_key_ids).await?; + if !retain_query_key { + credentials::remove_service_query_key(service_id)?; + } + // The service is already gone by here, so a closed stdout must not turn a + // completed deletion into a panic — see `print_line` and #598. + if json { + print_line(serde_json::to_string_pretty(&response)?); + } else { + print_line(format!("Service {} deletion initiated", service_id)); + } + Ok(()) +} + +pub(super) async fn service_start( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let service = client + .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Start) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + println!( + "Service {} start request accepted (returned state: {})", + or_absent(service.name.as_deref()), + or_absent(service.state.as_ref()) + ); + } + Ok(()) +} + +pub(super) async fn service_stop( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let service = client + .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Stop) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + println!( + "Service {} stop request accepted (returned state: {})", + or_absent(service.name.as_deref()), + or_absent(service.state.as_ref()) + ); + } + Ok(()) +} + +pub(super) async fn service_wake( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let service = client + .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Awake) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + println!( + "Service {} waking (state: {})", + or_absent(service.name.as_deref()), + or_absent(service.state.as_ref()) + ); + } + Ok(()) +} + +pub(super) async fn service_update( + client: &CloudClient, + service_id: &str, + options: ServiceUpdateOptions, + json: bool, +) -> CloudResult<()> { + let request = build_update_service_request(&options)?; + let org_id = resolve_org_id(client).await?; + + if has_removals(&options) { + let current = client.get_service(&org_id, service_id).await?; + for warning in unmatched_removal_warnings(&options, &request, ¤t) { + eprint_line(format!("Warning: {warning}")); + } + } + + let service = client.update_service(&org_id, service_id, &request).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + println!("Service {} updated", or_absent(service.name.as_deref())); + println!(" ID: {}", or_absent(service.id)); + println!(" State: {}", or_absent(service.state.as_ref())); + } + Ok(()) +} + +#[derive(Default)] +pub(super) struct ServiceScaleOptions { + pub(super) min_replica_memory_gb: Option, + pub(super) max_replica_memory_gb: Option, + pub(super) num_replicas: Option, + pub(super) min_replicas: Option, + pub(super) max_replicas: Option, + pub(super) autoscaling_mode: Option, + pub(super) idle_scaling: Option, + pub(super) idle_timeout_minutes: Option, +} + +fn build_service_scale_request( + options: &ServiceScaleOptions, +) -> CloudResult { + let horizontal = resolve_horizontal_autoscaling( + options.autoscaling_mode.as_deref(), + options.min_replicas, + options.max_replicas, + )?; + + Ok(ServiceReplicaScalingPatchRequest { + autoscaling_mode: horizontal.autoscaling_mode, + min_replica_memory_gb: options.min_replica_memory_gb.map(f64::from), + max_replica_memory_gb: options.max_replica_memory_gb.map(f64::from), + min_replicas: horizontal.min_replicas, + max_replicas: horizontal.max_replicas, + num_replicas: options.num_replicas.map(i64::from), + idle_scaling: options.idle_scaling, + idle_timeout_minutes: options.idle_timeout_minutes.map(f64::from), + }) +} + +pub(super) async fn service_scale( + client: &CloudClient, + service_id: &str, + options: ServiceScaleOptions, + json: bool, +) -> CloudResult<()> { + let request = build_service_scale_request(&options)?; + let org_id = resolve_org_id(client).await?; + let service = client + .update_replica_scaling(&org_id, service_id, &request) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&service)?); + } else { + println!( + "Service {} scaling updated", + or_absent(service.name.as_deref()) + ); + println!( + " Autoscaling Mode: {}", + or_absent(service.autoscaling_mode.as_ref()) + ); + match service.autoscaling_mode { + Some(AutoscalingMode::Horizontal) => { + println!(" Min Replicas: {}", or_absent(service.min_replicas)); + println!(" Max Replicas: {}", or_absent(service.max_replicas)); + println!( + " Memory/Replica: {} GB", + or_absent(service.replica_memory_gb) + ); + } + Some(AutoscalingMode::Vertical) => { + println!( + " Min Memory/Replica: {} GB", + or_absent(service.min_replica_memory_gb) + ); + println!( + " Max Memory/Replica: {} GB", + or_absent(service.max_replica_memory_gb) + ); + println!(" Replicas: {}", or_absent(service.num_replicas)); + } + _ => {} + } + } + Ok(()) +} + +pub(super) async fn service_reset_password( + client: &CloudClient, + service_id: &str, + options: ServiceResetPasswordOptions, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let request = build_service_password_patch_request(&options); + let response = client.reset_password(&org_id, service_id, &request).await?; + let outcome = resolve_reset_password_outcome( + generation_requested(&request), + response.password.as_deref(), + )?; + + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Password reset for service {}", service_id); + match outcome { + ResetPasswordOutcome::Generated(password) => { + println!(" New password: {}", password) + } + ResetPasswordOutcome::HashUpdated => { + println!(" Password hash updated; no plaintext password returned") + } + } + } + Ok(()) +} + +#[derive(Debug)] +enum ResetPasswordOutcome<'a> { + Generated(&'a str), + HashUpdated, +} + +fn generation_requested(request: &ServicePasswordPatchRequest) -> bool { + request.new_password_hash.is_none() +} + +fn resolve_reset_password_outcome( + generation_requested: bool, + password: Option<&str>, +) -> CloudResult> { + if !generation_requested { + return Ok(ResetPasswordOutcome::HashUpdated); + } + match password { + Some(password) => Ok(ResetPasswordOutcome::Generated(password)), + None => Err(CloudError::new( + "the API response omitted the generated password, so it cannot be shown: the \ + service password may already have been rotated — run the reset again to get \ + a password you can use", + )), + } +} + +pub(super) async fn service_prometheus( + client: &CloudClient, + service_id: &str, + filtered_metrics: Option, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let prometheus = client + .get_service_prometheus(&org_id, service_id, filtered_metrics) + .await?; + println!("{}", prometheus); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cloud::output::{CloudErrorCode, CloudErrorDetail}; + use crate::failure::{ApiFailure, FailureKind}; + + #[test] + fn first_endpoint_keeps_the_present_half_of_a_partial_endpoint() { + let endpoint = |host: Option<&str>, port: Option| ServiceEndpoint { + host: host.map(str::to_string), + port, + ..Default::default() + }; + assert_eq!(first_endpoint(None), ABSENT); + assert_eq!(first_endpoint(Some(&[])), ABSENT); + assert_eq!( + first_endpoint(Some(&[endpoint(Some("host"), Some(9440))])), + "host:9440" + ); + assert_eq!( + first_endpoint(Some(&[endpoint(Some("host"), None)])), + "host" + ); + assert_eq!( + first_endpoint(Some(&[endpoint(None, Some(9440))])), + format!("{ABSENT}:9440") + ); + assert_eq!(first_endpoint(Some(&[endpoint(None, None)])), ABSENT); + } + + #[test] + fn service_query_hint_is_dropped_when_the_id_is_absent() { + assert_eq!(service_query_hint(None), None); + let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); + let hint = service_query_hint(Some(id)).unwrap(); + assert!(hint.contains("query a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")); + assert!(hint.contains("provisioned automatically on first use")); + } + + #[test] + fn service_credentials_block_shows_the_password_the_api_sent() { + let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); + assert_eq!( + service_credentials_block(Some("s3cret"), Some(id)), + "Credentials (save these, password shown only once):\n Username: default\n \ + Password: s3cret" + ); + } + + #[test] + fn service_credentials_block_treats_an_empty_password_as_sent() { + assert_eq!( + service_credentials_block(Some(""), None), + "Credentials (save these, password shown only once):\n Username: default\n \ + Password: " + ); + } + + #[test] + fn service_credentials_block_warns_with_the_reset_command_when_the_password_is_absent() { + let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); + let block = service_credentials_block(None, Some(id)); + assert!(!block.contains(&format!("Password: {ABSENT}"))); + assert!(block.contains( + "clickhousectl cloud service reset-password a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6" + )); + } + + #[test] + fn service_credentials_block_warns_generically_when_the_service_id_is_absent() { + assert!( + service_credentials_block(None, None) + .contains("clickhousectl cloud service reset-password ") + ); + } + + #[test] + fn classify_stop_poll_state_fails_on_an_absent_state() { + let error = classify_stop_poll_state(None).unwrap_err(); + assert!(error.to_string().contains("omitted the service state")); + } + + #[test] + fn classify_stop_poll_state_separates_stopped_waiting_and_failed() { + assert!(classify_stop_poll_state(Some(&ServiceState::Stopped)).unwrap()); + assert!(classify_stop_poll_state(Some(&ServiceState::Idle)).unwrap()); + assert!(!classify_stop_poll_state(Some(&ServiceState::Stopping)).unwrap()); + assert!(!classify_stop_poll_state(Some(&ServiceState::Running)).unwrap()); + assert!( + !classify_stop_poll_state(Some(&ServiceState::Unknown("hibernating".into()))).unwrap() + ); + assert!(classify_stop_poll_state(Some(&ServiceState::Failed)).is_err()); + assert!(classify_stop_poll_state(Some(&ServiceState::Unknown("deleted".into()))).is_err()); + } + + #[test] + fn service_delete_error_uses_the_api_status_and_preserves_classification() { + let client = CloudClient::new( + Some("test-key"), + Some("test-secret"), + Some("https://api.example.com/v1"), + ) + .unwrap(); + let error = clickhouse_cloud_api::Error::Api { + status: 409, + message: "opaque conflict response".into(), + }; + let rewritten = service_delete_error(&client, error, false, "svc-1", "org-1"); + + assert_eq!( + rewritten.message, + "service could not be deleted because of a conflict. If it is running, use --force \ + to stop it first, or `clickhousectl cloud service stop svc-1`. API response: opaque \ + conflict response" + ); + assert_eq!( + rewritten.kind, + crate::cloud::client::CloudErrorKind::Generic + ); + assert_eq!( + rewritten.failure, + Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) + ); + } + + #[test] + fn service_delete_error_does_not_classify_from_message_text() { + let client = CloudClient::new( + Some("test-key"), + Some("test-secret"), + Some("https://api.example.com/v1"), + ) + .unwrap(); + let running_prose = || clickhouse_cloud_api::Error::Api { + status: 400, + message: "CONFLICT: Current state: 'running'".into(), + }; + + let ordinary = service_delete_error(&client, running_prose(), false, "svc-1", "org-1"); + assert_eq!(ordinary.message, "CONFLICT: Current state: 'running'"); + assert_eq!( + ordinary.failure, + Some(ApiFailure::with_status(FailureKind::Http4xx, 400)) + ); + + let forced = service_delete_error( + &client, + clickhouse_cloud_api::Error::Api { + status: 409, + message: "opaque conflict response".into(), + }, + true, + "svc-1", + "org-1", + ); + assert_eq!(forced.message, "opaque conflict response"); + assert_eq!( + forced.failure, + Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) + ); + } + + #[test] + fn service_delete_conflict_preserves_converted_error_metadata() { + let detail = CloudErrorDetail { + code: CloudErrorCode::ResourceNotFound, + message: "structured error".into(), + host: None, + port: None, + command: Some("cloud service list".into()), + api_key_id: None, + ip_access_list: None, + }; + let error = CloudError::auth("opaque conflict response") + .with_failure(ApiFailure::with_status(FailureKind::Http4xx, 409)) + .with_details(detail); + + let rewritten = service_delete_conflict(error, "svc-1"); + + assert_eq!(rewritten.kind, crate::cloud::client::CloudErrorKind::Auth); + assert_eq!( + rewritten.failure, + Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) + ); + let detail = rewritten.details.as_deref().expect("structured details"); + assert_eq!(detail.code, CloudErrorCode::ResourceNotFound); + assert_eq!(detail.message, "structured error"); + assert_eq!(detail.command.as_deref(), Some("cloud service list")); + } + + #[test] + fn generation_is_requested_unless_a_password_hash_is_sent() { + let request = |new_password_hash: Option<&str>, new_double_sha1_hash: Option<&str>| { + ServicePasswordPatchRequest { + new_password_hash: new_password_hash.map(str::to_string), + new_double_sha1_hash: new_double_sha1_hash.map(str::to_string), + } + }; + assert!(generation_requested(&request(None, None))); + assert!(generation_requested(&request(None, Some("sha1")))); + assert!(!generation_requested(&request(Some("sha256"), None))); + assert!(!generation_requested(&request( + Some("sha256"), + Some("sha1") + ))); + } + + #[test] + fn resolve_reset_password_outcome_returns_the_generated_password() { + assert!(matches!( + resolve_reset_password_outcome(true, Some("s3cret")).unwrap(), + ResetPasswordOutcome::Generated("s3cret") + )); + assert!(matches!( + resolve_reset_password_outcome(true, Some("")).unwrap(), + ResetPasswordOutcome::Generated("") + )); + } + + #[test] + fn resolve_reset_password_outcome_reports_hash_updated_regardless_of_response() { + assert!(matches!( + resolve_reset_password_outcome(false, None).unwrap(), + ResetPasswordOutcome::HashUpdated + )); + assert!(matches!( + resolve_reset_password_outcome(false, Some("s3cret")).unwrap(), + ResetPasswordOutcome::HashUpdated + )); + } + + #[test] + fn resolve_reset_password_outcome_fails_when_the_generated_password_is_absent() { + let error = resolve_reset_password_outcome(true, None).unwrap_err(); + assert!(error.to_string().contains("omitted the generated password")); + } + + fn minimal_create_options() -> CreateServiceOptions { + CreateServiceOptions { + name: "svc".to_string(), + provider: "aws".to_string(), + region: "us-east-1".to_string(), + ..Default::default() + } + } + + #[test] + fn build_create_service_request_supports_minimal_fields() { + let request = build_create_service_request(&minimal_create_options()).unwrap(); + + assert_eq!(request.name, "svc"); + assert_eq!(request.provider, ServicePostRequestProvider::Aws); + assert_eq!(request.region, ServicePostRequestRegion::Us_east_1); + assert_eq!(request.ip_access_list.len(), 1); + assert_eq!(request.ip_access_list[0].source, "0.0.0.0/0"); + assert_eq!( + request.ip_access_list[0].description.as_deref(), + Some("Allow all (created by clickhousectl)") + ); + assert!(request.autoscaling_mode.is_none()); + assert!(request.min_replica_memory_gb.is_none()); + assert!(request.max_replica_memory_gb.is_none()); + assert!(request.num_replicas.is_none()); + assert!(request.min_replicas.is_none()); + assert!(request.max_replicas.is_none()); + assert!(request.idle_scaling.is_none()); + assert!(request.idle_timeout_minutes.is_none()); + assert!(request.backup_id.is_none()); + assert!(request.backup_encryption_config.is_none()); + assert!(request.release_channel.is_none()); + assert!(request.tags.is_none()); + assert!(request.data_warehouse_id.is_none()); + assert!(request.is_readonly.is_none()); + assert!(request.has_transparent_data_encryption.is_none()); + assert!(request.compliance_type.is_none()); + assert!(request.profile.is_none()); + assert!(request.private_preview_terms_checked.is_none()); + assert!(request.endpoints.is_none()); + assert!(request.enable_core_dumps.is_none()); + assert!(request.byoc_id.is_none()); + } + + #[test] + fn build_create_service_request_supports_maximal_fields() { + let options = CreateServiceOptions { + min_replica_memory_gb: Some(24), + max_replica_memory_gb: Some(48), + num_replicas: Some(3), + autoscaling_mode: Some("vertical".to_string()), + idle_scaling: Some(true), + idle_timeout_minutes: Some(10), + ip_allow: vec!["2001:db8::/32=office".to_string()], + backup_id: Some("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6".to_string()), + backup_encryption_config: Some(BackupEncryptionConfig::from([ + ("schema_version".to_string(), serde_json::json!(1)), + ( + "restore_key_pairs".to_string(), + serde_json::json!([{ "encrypted_dek": "wrapped" }]), + ), + ])), + release_channel: Some("fast".to_string()), + data_warehouse_id: Some("dw-1".to_string()), + is_readonly: true, + enable_tde: true, + compliance_type: Some("hipaa".to_string()), + profile: Some("v1-default".to_string()), + tags: vec!["env=prod".to_string()], + enable_endpoints: vec!["mysql".to_string()], + disable_endpoints: vec!["mysql".to_string()], + private_preview_terms_checked: true, + enable_core_dumps: Some(true), + ..minimal_create_options() + }; + let request = build_create_service_request(&options).unwrap(); + + assert_eq!(request.name, "svc"); + assert_eq!(request.provider, ServicePostRequestProvider::Aws); + assert_eq!(request.region, ServicePostRequestRegion::Us_east_1); + assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Vertical)); + assert_eq!(request.min_replica_memory_gb, Some(24.0)); + assert_eq!(request.max_replica_memory_gb, Some(48.0)); + assert_eq!(request.num_replicas, Some(3)); + assert!(request.min_replicas.is_none()); + assert!(request.max_replicas.is_none()); + assert_eq!(request.idle_scaling, Some(true)); + assert_eq!(request.idle_timeout_minutes, Some(10.0)); + assert_eq!(request.ip_access_list.len(), 1); + assert_eq!(request.ip_access_list[0].source, "2001:db8::/32"); + assert_eq!( + request.ip_access_list[0].description.as_deref(), + Some("office") + ); + assert_eq!( + request.backup_id, + Some(uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap()) + ); + assert_eq!( + serde_json::to_value(request.backup_encryption_config.as_ref().unwrap()).unwrap(), + serde_json::json!({ + "schema_version": 1, + "restore_key_pairs": [{ "encrypted_dek": "wrapped" }] + }) + ); + assert_eq!( + request.release_channel, + Some(ServicePostRequestReleasechannel::Fast) + ); + assert_eq!(request.data_warehouse_id.as_deref(), Some("dw-1")); + assert_eq!(request.is_readonly, Some(true)); + assert_eq!(request.has_transparent_data_encryption, Some(true)); + assert_eq!( + request.compliance_type, + Some(ServicePostRequestCompliancetype::Hipaa) + ); + assert_eq!(request.profile, Some(ServicePostRequestProfile::V1_default)); + let tags = request.tags.as_ref().unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].key, "env"); + assert_eq!(tags[0].value.as_deref(), Some("prod")); + let endpoints = request.endpoints.as_ref().unwrap(); + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0].protocol, ServiceEndpointChangeProtocol::Mysql); + assert!(endpoints[0].enabled); + assert_eq!(endpoints[1].protocol, ServiceEndpointChangeProtocol::Mysql); + assert!(!endpoints[1].enabled); + assert_eq!(request.private_preview_terms_checked, Some(true)); + assert_eq!(request.enable_core_dumps, Some(true)); + assert!(request.byoc_id.is_none()); + } + + #[test] + fn read_backup_encryption_config_passes_a_json_object_through_unchanged() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("encryption_config.json"); + let document = serde_json::json!({ + "schema_version": 1, + "restore_key_pairs": [{ + "customer_managed_encryption_key": { "aws_kms_key_arn": "arn:aws:kms:k" }, + "encrypted_dek": "d2VsbA==", + "future_field": null + }] + }); + std::fs::write(&path, document.to_string()).unwrap(); + + let config = read_backup_encryption_config(path.to_str().unwrap()).unwrap(); + assert_eq!(serde_json::to_value(&config).unwrap(), document); + } + + #[test] + fn read_backup_encryption_config_rejects_bad_input_as_usage_errors() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing.json"); + let array = directory.path().join("array.json"); + let invalid = directory.path().join("invalid.json"); + std::fs::write(&array, "[1, 2]").unwrap(); + std::fs::write(&invalid, "{not json").unwrap(); + + for path in [&missing, &array, &invalid] { + let error = read_backup_encryption_config(path.to_str().unwrap()).unwrap_err(); + assert_eq!( + error.kind, + crate::cloud::client::CloudErrorKind::Usage, + "{path:?}: {error:?}" + ); + } + } + + #[test] + fn build_create_service_request_accepts_a_dynamic_byoc_profile() { + let request = build_create_service_request(&CreateServiceOptions { + min_replica_memory_gb: Some(48), + max_replica_memory_gb: Some(48), + profile: Some("v1-standard-byoc-4".to_string()), + byoc_id: Some("byoc-1".to_string()), + ..minimal_create_options() + }) + .unwrap(); + + assert_eq!(request.byoc_id.as_deref(), Some("byoc-1")); + assert_eq!( + request.profile, + Some(ServicePostRequestProfile::Unknown( + "v1-standard-byoc-4".to_string() + )) + ); + assert_eq!(request.min_replica_memory_gb, Some(48.0)); + assert_eq!(request.max_replica_memory_gb, Some(48.0)); + } + + #[test] + fn build_create_service_request_preserves_standard_profile_validation() { + let standard = build_create_service_request(&CreateServiceOptions { + profile: Some("v1-highmem-xs".to_string()), + ..minimal_create_options() + }) + .unwrap(); + assert_eq!( + standard.profile, + Some(ServicePostRequestProfile::V1_highmem_xs) + ); + + let error = build_create_service_request(&CreateServiceOptions { + profile: Some("future-standard-profile".to_string()), + ..minimal_create_options() + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("dynamic profiles require --byoc-id") + ); + } + + #[test] + fn build_create_service_request_enforces_byoc_memory_constraints() { + for options in [ + CreateServiceOptions { + byoc_id: Some("byoc-1".to_string()), + ..minimal_create_options() + }, + CreateServiceOptions { + byoc_id: Some("byoc-1".to_string()), + min_replica_memory_gb: Some(48), + ..minimal_create_options() + }, + CreateServiceOptions { + byoc_id: Some("byoc-1".to_string()), + min_replica_memory_gb: Some(48), + max_replica_memory_gb: Some(116), + profile: Some("v1-standard-byoc-4".to_string()), + ..minimal_create_options() + }, + ] { + assert!(build_create_service_request(&options).is_err()); + } + } + + #[test] + fn build_create_service_request_trims_tag_keys() { + let request = build_create_service_request(&CreateServiceOptions { + tags: vec![" env =prod".to_string()], + ..minimal_create_options() + }) + .unwrap(); + let json = serde_json::to_value(&request).unwrap(); + assert_eq!(json["tags"][0]["key"], "env"); + assert_eq!(json["tags"][0]["value"], "prod"); + } + + #[test] + fn build_create_service_request_rejects_empty_tag_keys() { + let error = build_create_service_request(&CreateServiceOptions { + tags: vec!["=prod".to_string()], + ..minimal_create_options() + }) + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid tag '=prod': tag key cannot be empty" + ); + } + + #[test] + fn build_update_service_request_supports_minimal_fields() { + let request = build_update_service_request(&ServiceUpdateOptions::default()).unwrap(); + + assert!(request.name.is_none()); + assert!(request.ip_access_list.is_none()); + assert!(request.private_endpoint_ids.is_none()); + assert!(request.release_channel.is_none()); + assert!(request.endpoints.is_none()); + assert!(request.transparent_data_encryption_key_id.is_none()); + assert!(request.tags.is_none()); + assert!(request.enable_core_dumps.is_none()); + } + + #[test] + fn build_update_service_request_supports_maximal_fields() { + let options = ServiceUpdateOptions { + name: Some("updated".to_string()), + add_ip_allow: vec!["10.0.0.0/8=office".to_string()], + remove_ip_allow: vec!["0.0.0.0/0".to_string()], + add_private_endpoint_ids: vec!["pe-1".to_string()], + remove_private_endpoint_ids: vec!["pe-2".to_string()], + release_channel: Some("default".to_string()), + enable_endpoints: vec!["mysql".to_string()], + disable_endpoints: vec!["mysql".to_string()], + transparent_data_encryption_key_id: Some("tde-1".to_string()), + add_tags: vec!["env=staging".to_string()], + remove_tags: vec!["old=tag".to_string()], + enable_core_dumps: Some(false), + }; + let request = build_update_service_request(&options).unwrap(); + + assert_eq!(request.name.as_deref(), Some("updated")); + let ip_access_list = request.ip_access_list.as_ref().unwrap(); + assert_eq!(ip_access_list.add.len(), 1); + assert_eq!(ip_access_list.add[0].source, "10.0.0.0/8"); + assert_eq!(ip_access_list.add[0].description.as_deref(), Some("office")); + assert_eq!(ip_access_list.remove.len(), 1); + assert_eq!(ip_access_list.remove[0].source, "0.0.0.0/0"); + assert!(ip_access_list.remove[0].description.is_none()); + let private_endpoint_ids = request.private_endpoint_ids.as_ref().unwrap(); + assert_eq!(private_endpoint_ids.add, vec!["pe-1"]); + assert_eq!(private_endpoint_ids.remove, vec!["pe-2"]); + assert_eq!( + request.release_channel, + Some(ServicePatchRequestReleasechannel::Default) + ); + let endpoints = request.endpoints.as_ref().unwrap(); + assert_eq!(endpoints.len(), 2); + assert_eq!(endpoints[0].protocol, ServiceEndpointChangeProtocol::Mysql); + assert!(endpoints[0].enabled); + assert_eq!(endpoints[1].protocol, ServiceEndpointChangeProtocol::Mysql); + assert!(!endpoints[1].enabled); + assert_eq!( + request.transparent_data_encryption_key_id.as_deref(), + Some("tde-1") + ); + let tags = request.tags.as_ref().unwrap(); + assert_eq!(tags.add.len(), 1); + assert_eq!(tags.add[0].key, "env"); + assert_eq!(tags.add[0].value.as_deref(), Some("staging")); + assert_eq!(tags.remove.len(), 1); + assert_eq!(tags.remove[0].key, "old"); + assert_eq!(tags.remove[0].value.as_deref(), Some("tag")); + assert_eq!(request.enable_core_dumps, Some(false)); + } + + #[test] + fn build_update_service_request_rejects_empty_tag_keys() { + let error = build_update_service_request(&ServiceUpdateOptions { + add_tags: vec![" =prod".to_string()], + ..Default::default() + }) + .unwrap_err(); + assert_eq!( + error.to_string(), + "invalid tag ' =prod': tag key cannot be empty" + ); + } + + #[test] + fn build_update_service_request_rejects_invalid_release_channel() { + let error = build_update_service_request(&ServiceUpdateOptions { + release_channel: Some("turbo".to_string()), + ..Default::default() + }) + .unwrap_err(); + assert!(error.to_string().contains("turbo")); + } + + #[test] + fn has_removals_is_false_when_no_remove_flags_set() { + assert!(!has_removals(&ServiceUpdateOptions::default())); + assert!(!has_removals(&ServiceUpdateOptions { + add_ip_allow: vec!["10.0.0.0/8".to_string()], + ..Default::default() + })); + } + + #[test] + fn has_removals_is_true_when_any_remove_flag_set() { + assert!(has_removals(&ServiceUpdateOptions { + remove_ip_allow: vec!["10.0.0.0/8".to_string()], + ..Default::default() + })); + assert!(has_removals(&ServiceUpdateOptions { + remove_private_endpoint_ids: vec!["pe-1".to_string()], + ..Default::default() + })); + assert!(has_removals(&ServiceUpdateOptions { + remove_tags: vec!["env".to_string()], + ..Default::default() + })); + } + + #[test] + fn unmatched_returns_only_values_absent_from_current() { + let current: HashSet<&str> = ["a", "b"].into_iter().collect(); + assert_eq!( + unmatched(&["a".to_string(), "c".to_string()], ¤t), + vec!["c"] + ); + assert!(unmatched(&["a".to_string(), "b".to_string()], ¤t).is_empty()); + } + + #[test] + fn tag_key_strips_value_and_trims() { + assert_eq!(tag_key("env"), "env"); + assert_eq!(tag_key("env=prod"), "env"); + assert_eq!(tag_key(" env = prod"), "env"); + } + + fn service_with_ip_allow_endpoints_and_tags() -> Service { + Service { + ip_access_list: Some(vec![IpAccessListEntryResponse { + source: Some("10.0.0.0/8".to_string()), + description: None, + }]), + private_endpoint_ids: Some(vec!["pe-1".to_string()]), + tags: Some(vec![ResourceTagsV1Response { + key: Some("env".to_string()), + value: Some("prod".to_string()), + }]), + ..Default::default() + } + } + + #[test] + fn unmatched_removal_warnings_is_empty_when_everything_matches() { + let current = service_with_ip_allow_endpoints_and_tags(); + let options = ServiceUpdateOptions { + remove_ip_allow: vec![" 10.0.0.0/8 =retired office".to_string()], + remove_private_endpoint_ids: vec!["pe-1".to_string()], + remove_tags: vec!["env".to_string()], + ..Default::default() + }; + let request = build_update_service_request(&options).unwrap(); + assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); + } + + #[test] + fn unmatched_removal_warnings_match_parsed_ipv4_and_ipv6_sources() { + let current = Service { + ip_access_list: Some(vec![ + IpAccessListEntryResponse { + source: Some("192.0.2.0/24".to_string()), + description: None, + }, + IpAccessListEntryResponse { + source: Some("2001:db8::/32".to_string()), + description: None, + }, + ]), + ..Default::default() + }; + let options = ServiceUpdateOptions { + remove_ip_allow: vec![ + " 192.0.2.0/24 =old office".to_string(), + " 2001:db8::/32 =old ipv6 range".to_string(), + ], + ..Default::default() + }; + let request = build_update_service_request(&options).unwrap(); + + assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); + } + + #[test] + fn unmatched_removal_warnings_names_every_unmatched_entry() { + let current = service_with_ip_allow_endpoints_and_tags(); + let options = ServiceUpdateOptions { + remove_ip_allow: vec![" 10.99.99.99/32 =old office".to_string()], + remove_private_endpoint_ids: vec!["pe-missing".to_string()], + remove_tags: vec!["missing=value".to_string()], + ..Default::default() + }; + let request = build_update_service_request(&options).unwrap(); + let warnings = unmatched_removal_warnings(&options, &request, ¤t); + assert_eq!(warnings.len(), 3); + assert!( + warnings[0].contains("--remove-ip-allow 10.99.99.99/32") + && warnings[0].contains("did not match"), + "{warnings:?}" + ); + assert!( + warnings[1].contains("--remove-private-endpoint-id pe-missing"), + "{warnings:?}" + ); + assert!(warnings[2].contains("--remove-tag missing"), "{warnings:?}"); + } + + #[test] + fn unmatched_removal_warnings_matches_tag_removal_by_key_only() { + // Removing "env=staging" should still match a current "env=prod" tag: + // the key is what the server matches on for removal. + let current = service_with_ip_allow_endpoints_and_tags(); + let options = ServiceUpdateOptions { + remove_tags: vec!["env=staging".to_string()], + ..Default::default() + }; + let request = build_update_service_request(&options).unwrap(); + assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); + } + + #[test] + fn unmatched_removal_warnings_handles_empty_current_lists() { + let options = ServiceUpdateOptions { + remove_ip_allow: vec!["10.0.0.0/8".to_string()], + ..Default::default() + }; + let request = build_update_service_request(&options).unwrap(); + let warnings = unmatched_removal_warnings(&options, &request, &Service::default()); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("--remove-ip-allow 10.0.0.0/8")); + } + + #[test] + fn build_create_service_request_rejects_invalid_provider() { + let error = build_create_service_request(&CreateServiceOptions { + provider: "awss".to_string(), + ..minimal_create_options() + }) + .unwrap_err(); + assert!(error.to_string().contains("awss")); + } + + #[test] + fn build_create_service_request_rejects_invalid_region() { + let error = build_create_service_request(&CreateServiceOptions { + region: "us-east-99".to_string(), + ..minimal_create_options() + }) + .unwrap_err(); + assert!(error.to_string().contains("us-east-99")); + } + + #[test] + fn build_create_service_request_rejects_invalid_release_channel() { + let error = build_create_service_request(&CreateServiceOptions { + release_channel: Some("turbo".to_string()), + ..minimal_create_options() + }) + .unwrap_err(); + assert!(error.to_string().contains("turbo")); + } + + #[test] + fn build_create_service_request_rejects_invalid_autoscaling_mode() { + let error = build_create_service_request(&CreateServiceOptions { + min_replicas: Some(2), + max_replicas: Some(8), + autoscaling_mode: Some("turbo".to_string()), + ..minimal_create_options() + }) + .unwrap_err(); + assert!(error.to_string().contains("turbo")); + } + + #[test] + fn build_create_service_request_supports_horizontal_fields() { + let request = build_create_service_request(&CreateServiceOptions { + min_replicas: Some(2), + max_replicas: Some(8), + autoscaling_mode: Some("horizontal".to_string()), + ..minimal_create_options() + }) + .unwrap(); + assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); + assert_eq!(request.min_replicas, Some(2)); + assert_eq!(request.max_replicas, Some(8)); + assert!(request.num_replicas.is_none()); + assert!(request.min_replica_memory_gb.is_none()); + assert!(request.max_replica_memory_gb.is_none()); + } + + #[test] + fn build_create_service_request_replica_pair_without_mode_omits_mode() { + let request = build_create_service_request(&CreateServiceOptions { + min_replicas: Some(1), + max_replicas: Some(4), + ..minimal_create_options() + }) + .unwrap(); + assert!(request.autoscaling_mode.is_none()); + assert_eq!(request.min_replicas, Some(1)); + assert_eq!(request.max_replicas, Some(4)); + } + + #[test] + fn build_create_service_request_vertical_omits_horizontal_fields() { + let request = build_create_service_request(&CreateServiceOptions { + num_replicas: Some(3), + min_replica_memory_gb: Some(24), + max_replica_memory_gb: Some(48), + ..minimal_create_options() + }) + .unwrap(); + assert!(request.autoscaling_mode.is_none()); + assert!(request.min_replicas.is_none()); + assert!(request.max_replicas.is_none()); + assert_eq!(request.num_replicas, Some(3)); + assert_eq!(request.min_replica_memory_gb, Some(24.0)); + assert_eq!(request.max_replica_memory_gb, Some(48.0)); + } + + #[test] + fn build_create_service_request_rejects_min_without_max_replicas() { + for options in [ + CreateServiceOptions { + min_replicas: Some(2), + ..minimal_create_options() + }, + CreateServiceOptions { + max_replicas: Some(8), + ..minimal_create_options() + }, + ] { + let error = build_create_service_request(&options).unwrap_err(); + assert!(error.to_string().contains("--min-replicas")); + } + } + + #[test] + fn resolve_horizontal_autoscaling_explicit_vertical_with_no_replicas() { + let resolved = resolve_horizontal_autoscaling(Some("vertical"), None, None).unwrap(); + assert_eq!(resolved.autoscaling_mode, Some(AutoscalingMode::Vertical)); + assert!(resolved.min_replicas.is_none()); + assert!(resolved.max_replicas.is_none()); + } + + #[test] + fn build_service_scale_request_supports_minimal_fields() { + let request = build_service_scale_request(&ServiceScaleOptions::default()).unwrap(); + + assert!(request.autoscaling_mode.is_none()); + assert!(request.min_replica_memory_gb.is_none()); + assert!(request.max_replica_memory_gb.is_none()); + assert!(request.num_replicas.is_none()); + assert!(request.min_replicas.is_none()); + assert!(request.max_replicas.is_none()); + assert!(request.idle_scaling.is_none()); + assert!(request.idle_timeout_minutes.is_none()); + } + + #[test] + fn build_service_scale_request_supports_horizontal_fields() { + let request = build_service_scale_request(&ServiceScaleOptions { + min_replicas: Some(2), + max_replicas: Some(8), + autoscaling_mode: Some("horizontal".to_string()), + ..Default::default() + }) + .unwrap(); + + assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); + assert_eq!(request.min_replicas, Some(2)); + assert_eq!(request.max_replicas, Some(8)); + assert!(request.min_replica_memory_gb.is_none()); + assert!(request.max_replica_memory_gb.is_none()); + assert!(request.num_replicas.is_none()); + } + + #[test] + fn build_service_scale_request_supports_maximal_vertical_fields() { + let request = build_service_scale_request(&ServiceScaleOptions { + autoscaling_mode: Some("vertical".to_string()), + num_replicas: Some(3), + min_replica_memory_gb: Some(8), + max_replica_memory_gb: Some(32), + idle_scaling: Some(false), + idle_timeout_minutes: Some(15), + ..Default::default() + }) + .unwrap(); + + assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Vertical)); + assert_eq!(request.num_replicas, Some(3)); + assert_eq!(request.min_replica_memory_gb, Some(8.0)); + assert_eq!(request.max_replica_memory_gb, Some(32.0)); + assert!(request.min_replicas.is_none()); + assert!(request.max_replicas.is_none()); + assert_eq!(request.idle_scaling, Some(false)); + assert_eq!(request.idle_timeout_minutes, Some(15.0)); + } + + #[test] + fn build_service_scale_request_supports_horizontal_memory_fields() { + let request = build_service_scale_request(&ServiceScaleOptions { + autoscaling_mode: Some("horizontal".to_string()), + min_replicas: Some(2), + max_replicas: Some(8), + min_replica_memory_gb: Some(16), + max_replica_memory_gb: Some(16), + ..Default::default() + }) + .unwrap(); + + assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); + assert_eq!(request.min_replicas, Some(2)); + assert_eq!(request.max_replicas, Some(8)); + assert_eq!(request.min_replica_memory_gb, Some(16.0)); + assert_eq!(request.max_replica_memory_gb, Some(16.0)); + assert!(request.num_replicas.is_none()); + } + + #[test] + fn build_service_scale_request_rejects_min_without_max_replicas() { + let error = build_service_scale_request(&ServiceScaleOptions { + max_replicas: Some(8), + ..Default::default() + }) + .unwrap_err(); + assert!(error.to_string().contains("--min-replicas")); + } + + #[test] + fn build_service_password_patch_request_supports_minimal_fields() { + let request = build_service_password_patch_request(&ServiceResetPasswordOptions::default()); + + assert!(request.new_password_hash.is_none()); + assert!(request.new_double_sha1_hash.is_none()); + } + + #[test] + fn build_service_password_patch_request_supports_maximal_fields() { + let request = build_service_password_patch_request(&ServiceResetPasswordOptions { + new_password_hash: Some("sha256".to_string()), + new_double_sha1_hash: Some("sha1".to_string()), + }); + + assert_eq!(request.new_password_hash.as_deref(), Some("sha256")); + assert_eq!(request.new_double_sha1_hash.as_deref(), Some("sha1")); + } + + #[test] + fn build_service_state_patch_request_preserves_every_command() { + let start = build_service_state_patch_request(ServiceStatePatchRequestCommand::Start); + let stop = build_service_state_patch_request(ServiceStatePatchRequestCommand::Stop); + let awake = build_service_state_patch_request(ServiceStatePatchRequestCommand::Awake); + + assert_eq!(start.command, Some(ServiceStatePatchRequestCommand::Start)); + assert_eq!(stop.command, Some(ServiceStatePatchRequestCommand::Stop)); + assert_eq!(awake.command, Some(ServiceStatePatchRequestCommand::Awake)); + } +} diff --git a/crates/clickhousectl/src/cloud/services/mod.rs b/crates/clickhousectl/src/cloud/services/mod.rs index 1a3cbc78..5b78f7a2 100644 --- a/crates/clickhousectl/src/cloud/services/mod.rs +++ b/crates/clickhousectl/src/cloud/services/mod.rs @@ -1,4 +1,12 @@ +mod cli; +mod client; +mod endpoints; +mod lifecycle; +mod query; pub mod query_key; +mod scaling_schedule; +mod settings; +mod upgrade_window; use super::permissions::{Conditional, Declaration as Permission}; use clickhouse_cloud_api::meta::operations as op; @@ -160,947 +168,35 @@ pub(super) const PERMISSIONS: &[Permission] = &[ ]), ]; -use crate::cloud::api_keys::{cleanup_service_query_key, service_query_key_cleanup}; -use crate::cloud::backups::{BackupConfigCommands, SnapshotCommands}; -use crate::cloud::client::{ - CloudClient, CloudError, ResourceKind, ResourceLookup, Result as CloudResult, +pub use cli::{ + PrivateEndpointCommands, QueryEndpointCommands, ReplicaAwareRoutingCommands, + ScalingScheduleCommands, ServiceCommands, ServiceProfileCommands, ServiceSettingsCommands, + UpgradeWindowCommands, }; -use crate::cloud::config::{deserialize_strict_config, read_config_value}; -use crate::cloud::credentials; -use crate::cloud::output::{ - ABSENT, CloudErrorCode, CloudErrorDetail, eprint_line, or_absent, print_human, print_line, -}; -use crate::cloud::services::query_key::{ - CallerKeyBinding, RepairVerification, existing_open_api_keys, -}; -use crate::cloud::shared::{NameSelector, NamedResource, PollProgress}; -use crate::cloud::shared::{ - parse_ip_access_entries, parse_serde_enum, parse_tag_filter, parse_tags, resolve_org_id, + +use crate::cloud::client::{CloudClient, Result as CloudResult}; +use crate::cloud::shared::NamedResource; +use endpoints::{ + QueryEndpointCreateOptions, private_endpoint_create, private_endpoint_get_config, + query_endpoint_create, query_endpoint_delete, query_endpoint_get, }; -use crate::cloud::types::DeleteResponse; -use crate::failure::{self, ApiFailure, FailureKind, FailureStage, ProvisioningState}; -use clap::builder::PossibleValuesParser; -use clap::{ArgGroup, Subcommand}; -use clickhouse_cloud_api::models::{ - AutoscalingMode, BackupEncryptionConfig, InstancePrivateEndpointsPatch, - InstanceServiceQueryApiEndpointsPostRequest, InstanceTagsPatch, IpAccessListEntry, - IpAccessListPatch, QueryEndpointRole, ReplicaAwareRouting, ReplicaAwareRoutingPatchRequest, - ScalingSchedule, ScalingScheduleEntryRequest, ScalingSchedulePostRequest, - ServicPrivateEndpointePostRequest, Service, ServiceClickhouseSetting, - ServiceClickhouseSettingsList, ServiceClickhouseSettingsPatchRequest, - ServiceClickhouseSettingsPatchResponse, ServiceClickhouseSettingsSchema, ServiceEndpoint, - ServiceEndpointChange, ServiceEndpointChangeProtocol, ServiceEndpointProtocol, - ServicePasswordPatchRequest, ServicePatchRequest, ServicePatchRequestReleasechannel, - ServicePostRequest, ServicePostRequestCompliancetype, ServicePostRequestProfile, - ServicePostRequestProvider, ServicePostRequestRegion, ServicePostRequestReleasechannel, - ServiceProfile, ServiceQueryAPIEndpoint, ServiceReplicaScalingPatchRequest, ServiceState, - ServiceStatePatchRequest, ServiceStatePatchRequestCommand, UpgradeWindow, - UpgradeWindowPutRequest, UpgradeWindowStartHourUtc, +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, }; -#[cfg(test)] -use clickhouse_cloud_api::models::{IpAccessListEntryResponse, ResourceTagsV1Response}; -use std::{ - collections::{BTreeMap, HashSet}, - io::IsTerminal, - time::Duration, +use query::{ServiceQueryOptions, service_query, service_query_key_repair}; +use scaling_schedule::{scaling_schedule_delete, scaling_schedule_get, scaling_schedule_set}; +use settings::{ + service_setting_get, service_setting_unset, service_settings_list, service_settings_schema, + service_settings_set, }; -use tabled::{Table, Tabled, settings::Style}; - -#[derive(Clone, Copy)] -struct QueryEndpointReadiness { - timeout: Duration, - initial_backoff: Duration, - max_backoff: Duration, -} - -/// Gap between `GET service` calls while `service delete --force` waits for a -/// stop to finish. A real stop takes minutes, so the loop can emit dozens of -/// progress lines — see [`crate::cloud::output::eprint_line`] for why none of -/// them may panic. -const STOP_POLL_INTERVAL: Duration = Duration::from_secs(5); - -const QUERY_ENDPOINT_READINESS: QueryEndpointReadiness = QueryEndpointReadiness { - timeout: Duration::from_secs(120), - initial_backoff: Duration::from_millis(200), - max_backoff: Duration::from_secs(5), +use upgrade_window::{ + build_replica_aware_routing_request, replica_aware_routing_get, replica_aware_routing_update, + service_profile_list, upgrade_window_delete, upgrade_window_get, upgrade_window_set, }; -#[derive(Subcommand)] -pub enum ServiceCommands { - /// List services in the organization - List { - /// Filter by resource tag: `tag:KEY=VALUE` or `tag:KEY` (repeatable) - #[arg(long, value_parser = parse_tag_filter)] - filter: Vec, - }, - - /// Get service details - Get { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Discover available service profiles - Profile { - #[command(subcommand)] - command: ServiceProfileCommands, - }, - - /// Manage ClickHouse settings - Settings { - #[command(subcommand)] - command: ServiceSettingsCommands, - }, - - /// Manage the service scaling schedule - #[command(name = "scaling-schedule")] - ScalingSchedule { - #[command(subcommand)] - command: ScalingScheduleCommands, - }, - - /// Create a service - #[command(after_help = "\ -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. - Returns before the service is up: poll `cloud service get ` until state is `running` (minutes).")] - Create { - /// Service name - #[arg(long)] - name: String, - - /// Cloud provider: aws, gcp, azure - #[arg(long, default_value = "aws")] - provider: String, - - /// Region, e.g. us-east-1, eu-west-1, us-central1 - #[arg(long, default_value = "us-east-1")] - region: String, - - /// Minimum memory per replica in GB - /// - /// A multiple of 4, at least 8. Horizontal autoscaling requires it equal to - /// --max-replica-memory-gb. - #[arg(long)] - min_replica_memory_gb: Option, - - /// Maximum memory per replica in GB - /// - /// A multiple of 4; at most 120 for unpaid and 356 for paid services. Horizontal - /// autoscaling requires it equal to --min-replica-memory-gb. - #[arg(long)] - max_replica_memory_gb: Option, - - /// Fixed replica count for vertical autoscaling (up to 50) - /// - /// The first service in a warehouse needs at least 2; existing warehouses allow 1. - /// Tier and per-warehouse limits may lower the maximum. Conflicts with the horizontal band. - #[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])] - num_replicas: Option, - - /// Minimum replicas for horizontal autoscaling (1-50) - /// - /// Requires --max-replicas and conflicts with --num-replicas. Needs the horizontal - /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and - /// per-warehouse limits may lower the maximum. - #[arg(long, conflicts_with = "num_replicas")] - min_replicas: Option, - - /// Maximum replicas for horizontal autoscaling (1-50) - /// - /// Requires --min-replicas and conflicts with --num-replicas. Needs the horizontal - /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and - /// per-warehouse limits may lower the maximum. - #[arg(long, conflicts_with = "num_replicas")] - max_replicas: Option, - - /// Autoscaling mode (vertical when omitted) - /// - /// vertical scales memory between --min-replica-memory-gb and --max-replica-memory-gb at a - /// fixed --num-replicas count; horizontal scales replicas between --min-replicas and - /// --max-replicas at a fixed memory (the two memory flags equal). - #[arg( - long, - value_parser = PossibleValuesParser::new( - clickhouse_cloud_api::models::AutoscalingMode::VALUES - ) - )] - autoscaling_mode: Option, - - /// Allow scale to zero when idle (default: true) - #[arg(long)] - idle_scaling: Option, - - /// Minimum idle timeout in minutes (>= 5) - #[arg(long)] - idle_timeout_minutes: Option, - - /// Allowed IP/CIDR, optionally IP_OR_CIDR=DESCRIPTION (repeatable) - #[arg(long = "ip-allow", value_name = "IP_OR_CIDR[=DESCRIPTION]")] - ip_allow: Vec, - - /// Backup ID to restore from - #[arg(long)] - backup_id: Option, - - /// Backup's encryption_config.json file, or - for stdin (private preview) - /// - /// Only with --backup-id, for a backup of a TDE-enabled service in your own - /// bucket. The restored service has TDE enabled. - #[arg(long, value_name = "PATH", requires = "backup_id")] - backup_encryption_config: Option, - - /// Release channel: slow, default, fast (production services only) - #[arg(long)] - release_channel: Option, - - /// Data warehouse ID (for creating read replicas) - #[arg(long)] - data_warehouse_id: Option, - - /// Make service read-only (requires --data-warehouse-id) - #[arg(long)] - readonly: bool, - - /// Enable Transparent Data Encryption (enterprise only) - #[arg(long)] - enable_tde: bool, - - /// Compliance type: hipaa, pci - #[arg(long)] - compliance_type: Option, - - /// Instance profile (enterprise only): v1-default, v1-highmem-xs, etc. - #[arg(long)] - profile: Option, - - /// BYOC infrastructure ID - #[arg(long)] - byoc_id: Option, - - /// Tag to attach to the service. Format: key or key=value (repeatable) - #[arg(long = "tag", value_name = "KEY[=VALUE]")] - tag: Vec, - - /// Enable a toggleable endpoint protocol; currently mysql (repeatable) - #[arg(long = "enable-endpoint")] - enable_endpoint: Vec, - - /// Disable a toggleable endpoint protocol; currently mysql (repeatable) - #[arg(long = "disable-endpoint")] - disable_endpoint: Vec, - - /// Accept private preview terms (needed for the org's first service in a - /// private preview) - #[arg(long)] - private_preview_terms_checked: bool, - - /// Enable or disable service core dump collection - #[arg(long)] - enable_core_dumps: Option, - }, - - /// Delete a service - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Irreversible. A running service cannot be deleted: stop it first, or pass --force. - --force stops the service and polls until the stop finishes — minutes on a real service, with - progress lines on stderr.")] - Delete { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Stop the service first if it is running, then delete (waits for the stop) - #[arg(long)] - force: bool, - }, - - /// Start a service - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Resumes a stopped service; an idle service wakes on its own when queried. - Returns as soon as the start is accepted: poll `cloud service get ` until state is `running`.")] - Start { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Wake an idled service - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Wakes an idle service; use `cloud service start ` for a stopped service. - Returns as soon as the wake is accepted: poll `cloud service get ` until state is `running`.")] - Wake { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Stop a service - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Idles the service: compute billing stops, data is preserved. - Returns as soon as the stop is accepted: poll `cloud service get ` until state is `stopped` - or `idle`.")] - Stop { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Update service configuration - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Only the flags you pass are changed; everything else is left as it is. - --remove-ip-allow/--remove-private-endpoint-id/--remove-tag are idempotent: removing something - absent exits 0 with a warning on stderr.")] - Update { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// New service name - #[arg(long = "new-name", id = "new_name")] - name: Option, - - /// IP/CIDR to add, optionally IP_OR_CIDR=DESCRIPTION (repeatable) - #[arg(long = "add-ip-allow", value_name = "IP_OR_CIDR[=DESCRIPTION]")] - add_ip_allow: Vec, - - /// IP or CIDR to remove from the service allow list (repeatable) - #[arg(long = "remove-ip-allow", value_name = "IP_OR_CIDR")] - remove_ip_allow: Vec, - - /// Private endpoint ID to add; format-checked before the request (repeatable) - #[arg(long = "add-private-endpoint-id", value_parser = parse_private_endpoint_id_arg)] - add_private_endpoint_id: Vec, - - /// Private endpoint ID to remove (repeatable) - #[arg(long = "remove-private-endpoint-id")] - remove_private_endpoint_id: Vec, - - /// Release channel: slow, default, fast - #[arg(long)] - release_channel: Option, - - /// Enable a toggleable endpoint protocol; currently mysql (repeatable) - #[arg(long = "enable-endpoint")] - enable_endpoint: Vec, - - /// Disable a toggleable endpoint protocol; currently mysql (repeatable) - #[arg(long = "disable-endpoint")] - disable_endpoint: Vec, - - /// Transparent Data Encryption key ID to rotate to - #[arg(long)] - transparent_data_encryption_key_id: Option, - - /// Tag to add (repeatable; cannot be combined with --remove-tag) - #[arg( - long = "add-tag", - value_name = "KEY[=VALUE]", - conflicts_with = "remove_tag" - )] - add_tag: Vec, - - /// Tag to remove (repeatable; cannot be combined with --add-tag) - #[arg( - long = "remove-tag", - value_name = "KEY[=VALUE]", - conflicts_with = "add_tag" - )] - remove_tag: Vec, - - /// Enable or disable service core dump collection - #[arg(long)] - enable_core_dumps: Option, - }, - - /// Update service scaling and idle behaviour - Scale { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Minimum memory per replica in GB - /// - /// A multiple of 4, at least 8. Horizontal autoscaling requires it equal to - /// --max-replica-memory-gb. - #[arg(long)] - min_replica_memory_gb: Option, - - /// Maximum memory per replica in GB - /// - /// A multiple of 4; at most 120 for unpaid and 356 for paid services. Horizontal - /// autoscaling requires it equal to --min-replica-memory-gb. - #[arg(long)] - max_replica_memory_gb: Option, - - /// Fixed replica count for vertical autoscaling (up to 50) - /// - /// The first service in a warehouse needs at least 2; existing warehouses allow 1. - /// Tier and per-warehouse limits may lower the maximum. Conflicts with the horizontal band. - #[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])] - num_replicas: Option, - - /// Minimum replicas for horizontal autoscaling (1-50) - /// - /// Requires --max-replicas and conflicts with --num-replicas. Needs the horizontal - /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and - /// per-warehouse limits may lower the maximum. - #[arg(long, conflicts_with = "num_replicas")] - min_replicas: Option, - - /// Maximum replicas for horizontal autoscaling (1-50) - /// - /// Requires --min-replicas and conflicts with --num-replicas. Needs the horizontal - /// autoscaling org feature unless the band is equal and the mode is vertical. Tier and - /// per-warehouse limits may lower the maximum. - #[arg(long, conflicts_with = "num_replicas")] - max_replicas: Option, - - /// Target autoscaling mode - /// - /// Omit to keep the service on its current mode. See - /// `cloud service create --autoscaling-mode` for what each mode scales. - #[arg( - long, - value_parser = PossibleValuesParser::new( - clickhouse_cloud_api::models::AutoscalingMode::VALUES - ) - )] - autoscaling_mode: Option, - - /// Allow scale to zero when idle - #[arg(long)] - idle_scaling: Option, - - /// Minimum idle timeout in minutes (>= 5) - #[arg(long)] - idle_timeout_minutes: Option, - }, - - /// Reset the default user password - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Omit --new-password-hash and the API generates a password, printed once — save it. - With --new-password-hash no plaintext is returned; --new-double-sha1-hash alone still triggers - generation.")] - ResetPassword { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// SHA256 password hash encoded as base64 - #[arg(long)] - new_password_hash: Option, - - /// MySQL-compatible double SHA1 password hash - #[arg(long)] - new_double_sha1_hash: Option, - }, - - /// Manage the Query API endpoint - #[command( - name = "query-endpoint", - after_help = "\ -CONTEXT FOR AGENTS: - Only needed to share Query API access with other tools: `cloud service query` binds the - authenticated API key itself. - Hand edits can unbind the key `cloud service query` uses; the next query rebinds it unless - `--no-auto-enable` is passed. - A stored per-service key is repaired with `cloud service repair-query-key `." - )] - QueryEndpoint { - #[command(subcommand)] - command: QueryEndpointCommands, - }, - - /// Manage private endpoints - #[command(name = "private-endpoint")] - PrivateEndpoint { - #[command(subcommand)] - command: PrivateEndpointCommands, - }, - - /// Manage service snapshots (Beta) - Snapshot { - #[command(subcommand)] - command: SnapshotCommands, - }, - - /// Manage backup configuration - #[command(name = "backup-config")] - BackupConfig { - #[command(subcommand)] - command: BackupConfigCommands, - }, - - /// Manage the service upgrade window - #[command(name = "upgrade-window")] - UpgradeWindow { - #[command(subcommand)] - command: UpgradeWindowCommands, - }, - - /// Manage replica aware routing (Beta) - #[command( - name = "replica-aware-routing", - after_help = "\ -CONTEXT FOR AGENTS: - With HTTP routing enabled, every HTTP client must send the X-ClickHouse-Replica-Tag header. - Update needs the organization feature enabled; an omitted flag leaves that protocol unchanged." - )] - ReplicaAwareRouting { - #[command(subcommand)] - command: ReplicaAwareRoutingCommands, - }, - - /// Get Prometheus metrics - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Output is always raw Prometheus exposition text; --json is accepted for consistency but ignored.")] - Prometheus { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Whether to request filtered metrics - #[arg(long)] - filtered_metrics: Option, - }, - - /// Run SQL over HTTP via the Query API - #[command( - group(ArgGroup::new("service_selector").required(true).args(["name", "id", "service_id"])), - after_help = "\ -CONTEXT FOR AGENTS: - Run one SQL statement; a timeout may follow execution or wake delays, and is never retried. - Stored-key 401/403 never rotates keys automatically; deliberate repair: `cloud service repair-query-key `." - )] - Query { - /// Service ID to query - #[arg(conflicts_with_all = ["name", "id"])] - service_id: Option, - - /// Service name to query - #[arg(long, conflicts_with_all = ["id", "service_id"])] - name: Option, - - /// Service ID to query - #[arg(long, hide = true, conflicts_with_all = ["name", "service_id"])] - id: Option, - - /// SQL to run: a single statement, and never read from stdin - #[arg(long, short, conflicts_with = "queries_file")] - query: Option, - - /// File containing one SQL statement ("-" reads stdin) - #[arg(long, conflicts_with = "query")] - queries_file: Option, - - /// Target database - #[arg(long)] - database: Option, - - /// Response format (e.g. JSONEachRow, CSV, TabSeparated, PrettyCompact) - #[arg(long, conflicts_with = "json")] - format: Option, - - /// Fail rather than bind the API key to the Query API endpoint - #[arg(long)] - no_auto_enable: bool, - }, - - /// Replace clickhousectl's stored Query API key - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Deliberately replaces this service's owned key/binding; preserves other bindings and credentials. - Records without exact ownership metadata are refused; legacy and external keys cannot be repaired. - Verification timeout exits 1 but repair stands: retry `cloud service query `, not repair.")] - RepairQueryKey { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, -} - -#[derive(Subcommand)] -pub enum QueryEndpointCommands { - /// Get the Query API endpoint configuration - Get { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Create or update the Query API endpoint - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Existing API keys are preserved unless --replace-open-api-keys is set. - Roles replace the endpoint-wide role list for every authorized key. - First creation requires --allowed-origins; use '*' explicitly for all. - Avoid concurrent changes to the same endpoint.")] - Create { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Endpoint-wide role to grant (required, repeatable) - #[arg(long, required = true, value_parser = PossibleValuesParser::new(QueryEndpointRole::VALUES))] - role: Vec, - - /// API key ID to authorize (repeatable) - #[arg(long = "open-api-key", value_parser = clap::builder::NonEmptyStringValueParser::new())] - open_api_key: Vec, - - /// Replace existing API keys (requires --open-api-key) - #[arg(long, requires = "open_api_key")] - replace_open_api_keys: bool, - - /// Browser origins; preserve when omitted on an existing endpoint - #[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())] - allowed_origins: Option, - }, - - /// Delete the Query API endpoint - Delete { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, -} - -#[derive(Subcommand)] -pub enum PrivateEndpointCommands { - /// Create a private endpoint connection - Create { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Private endpoint ID (AWS VPC endpoint ID, GCP PSC connection ID, or - /// Azure private endpoint Resource ID / resourceGuid) - #[arg(long, value_parser = parse_private_endpoint_id_arg)] - endpoint_id: String, - - /// Description - #[arg(long)] - description: Option, - }, - - /// Get the private endpoint configuration - GetConfig { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, -} - -#[derive(Subcommand)] -pub enum ServiceProfileCommands { - /// List available service profiles - List { - /// Region ID (required without --byoc-id) - #[arg(long, required_unless_present = "byoc_id")] - region: Option, - - /// BYOC infrastructure ID (required without --region) - /// - /// Cloud validates any supplied region against this infrastructure. - #[arg(long, required_unless_present = "region")] - byoc_id: Option, - }, -} - -#[derive(Subcommand)] -pub enum ServiceSettingsCommands { - /// List configured ClickHouse settings - List { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Get a ClickHouse setting - Get { - #[command(flatten)] - target: ServiceSettingTarget, - }, - - /// Get the configurable ClickHouse settings schema - Schema { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Set one or more ClickHouse settings - #[command( - group(ArgGroup::new("settings_input").required(true).args(["setting", "settings_file"])), - after_help = "\ -CONTEXT FOR AGENTS: - Discover supported names and types with `settings schema `. - --setting values are JSON literals; quote string values inside the argument. - --file reads a JSON settings map; `-` reads stdin. Only named settings change." - )] - Set { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Setting as NAME=JSON_VALUE (repeatable) - #[arg(long, value_name = "NAME=JSON_VALUE")] - setting: Vec, - - /// JSON settings map file (`-` reads stdin) - #[arg(long = "file", alias = "settings-file", value_name = "PATH")] - settings_file: Option, - }, - - /// Reset a ClickHouse setting to its platform default - Unset { - #[command(flatten)] - target: ServiceSettingTarget, - }, -} - -/// Keep the natural setting key required while allowing options between the -/// service ID and key. Clap's allow_missing_positional would shift the ID into -/// the key slot when an option follows it; the explicit --name decides grammar. -#[derive(clap::Args)] -pub struct ServiceSettingTarget { - /// Service ID, or setting name with --name - #[arg(value_name = "ID_OR_SETTING_NAME")] - id_or_setting_name: String, - /// Setting name when selecting by service ID - #[arg( - value_name = "SETTING_NAME", - required_unless_present = "name", - conflicts_with = "name" - )] - setting_name: Option, - /// Exact service name within the selected organization - #[arg(long)] - name: Option, -} - -impl ServiceSettingTarget { - pub(super) fn parts(&self) -> (NameSelector, &str) { - match &self.name { - Some(name) => ( - NameSelector { - id: None, - name: Some(name.clone()), - }, - &self.id_or_setting_name, - ), - None => ( - NameSelector { - id: Some(self.id_or_setting_name.clone()), - name: None, - }, - self.setting_name - .as_deref() - .expect("clap requires the setting name with a positional ID"), - ), - } - } -} - -#[derive(Subcommand)] -pub enum ScalingScheduleCommands { - /// Get the scaling schedule - Get { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Create or replace the scaling schedule - #[command(after_help = "\ -CONTEXT FOR AGENTS: - --file is a JSON object with an entries array; '-' reads it from stdin. - Set replaces every existing entry. Get, edit only entries, then set to preserve other entries. - Hours and weekdays are UTC. - Omit response-only baseConfig and each entry's id and isActiveNow when setting a schedule.")] - Set { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// JSON request file (use "-" for stdin) - #[arg(long, value_name = "PATH")] - file: String, - }, - - /// Delete the scaling schedule - #[command(after_help = "\ -CONTEXT FOR AGENTS: - Deletes every scheduled entry. An active entry returns to the service's base scaling config.")] - Delete { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, -} - -#[derive(Subcommand)] -pub enum UpgradeWindowCommands { - /// Get the service upgrade window - Get { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Set the service upgrade window - Set { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Start day: 0=Sunday through 6=Saturday - #[arg( - long, - allow_hyphen_values = true, - value_parser = clap::value_parser!(i64).range(0..=6) - )] - weekday: i64, - - /// UTC start hour - #[arg(long, value_parser = PossibleValuesParser::new(["0", "6", "12", "18"]))] - start_hour: String, - }, - - /// Delete the service upgrade window - #[command( - after_help = "CONTEXT FOR AGENTS:\n Restores the default upgrade scheduling behaviour." - )] - Delete { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, -} - -#[derive(Subcommand)] -pub enum ReplicaAwareRoutingCommands { - /// Get replica aware routing settings (Beta) - Get { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - }, - - /// Update replica aware routing settings (Beta) - #[command(group( - ArgGroup::new("routing_change") - .required(true) - .multiple(true) - .args(["http_enabled", "native_enabled"]) - ))] - Update { - /// Service ID - #[command(flatten)] - service_id: NameSelector, - - /// Turn routing on or off for HTTP connections - #[arg(long, value_name = "BOOL")] - http_enabled: Option, - - /// Turn routing on or off for native protocol connections - #[arg(long, value_name = "BOOL")] - native_enabled: Option, - }, -} - -impl ReplicaAwareRoutingCommands { - fn is_write(&self) -> bool { - match self { - ReplicaAwareRoutingCommands::Get { .. } => false, - ReplicaAwareRoutingCommands::Update { .. } => true, - } - } -} - -impl UpgradeWindowCommands { - fn is_write(&self) -> bool { - match self { - UpgradeWindowCommands::Get { .. } => false, - UpgradeWindowCommands::Set { .. } | UpgradeWindowCommands::Delete { .. } => true, - } - } -} - -impl ServiceProfileCommands { - fn is_write(&self) -> bool { - match self { - ServiceProfileCommands::List { .. } => false, - } - } -} - -impl ServiceSettingsCommands { - fn is_write(&self) -> bool { - match self { - ServiceSettingsCommands::List { .. } - | ServiceSettingsCommands::Get { .. } - | ServiceSettingsCommands::Schema { .. } => false, - ServiceSettingsCommands::Set { .. } | ServiceSettingsCommands::Unset { .. } => true, - } - } -} - -impl ScalingScheduleCommands { - fn is_write(&self) -> bool { - match self { - ScalingScheduleCommands::Get { .. } => false, - ScalingScheduleCommands::Set { .. } | ScalingScheduleCommands::Delete { .. } => true, - } - } -} - -impl ServiceCommands { - pub fn is_write(&self) -> bool { - match self { - ServiceCommands::List { .. } => false, - ServiceCommands::Get { .. } => false, - ServiceCommands::Profile { command } => command.is_write(), - ServiceCommands::Settings { command } => command.is_write(), - ServiceCommands::ScalingSchedule { command } => command.is_write(), - ServiceCommands::Prometheus { .. } => false, - ServiceCommands::Query { .. } => false, - ServiceCommands::RepairQueryKey { .. } => true, - ServiceCommands::Create { .. } => true, - ServiceCommands::Delete { .. } => true, - ServiceCommands::Start { .. } => true, - ServiceCommands::Wake { .. } => true, - ServiceCommands::Stop { .. } => true, - ServiceCommands::Update { .. } => true, - ServiceCommands::Scale { .. } => true, - ServiceCommands::ResetPassword { .. } => true, - ServiceCommands::QueryEndpoint { command } => match command { - QueryEndpointCommands::Get { .. } => false, - QueryEndpointCommands::Create { .. } => true, - QueryEndpointCommands::Delete { .. } => true, - }, - ServiceCommands::PrivateEndpoint { command } => match command { - PrivateEndpointCommands::Create { .. } => true, - PrivateEndpointCommands::GetConfig { .. } => false, - }, - ServiceCommands::BackupConfig { command } => command.is_write(), - ServiceCommands::Snapshot { command } => command.is_write(), - ServiceCommands::UpgradeWindow { command } => command.is_write(), - ServiceCommands::ReplicaAwareRouting { command } => command.is_write(), - } - } -} - pub async fn run(client: &CloudClient, command: ServiceCommands, json: bool) -> CloudResult<()> { match command { ServiceCommands::List { filter } => service_list(client, &filter, json).await, @@ -1539,8073 +635,3 @@ pub async fn run(client: &CloudClient, command: ServiceCommands, json: bool) -> } } } - -/// `host:port` of a service's first endpoint, for list tables. -/// -/// Renders [`ABSENT`] when the API returned no endpoints, and keeps whichever -/// half of a partial endpoint it did return (`host` alone, or `-:port`). -fn first_endpoint(endpoints: Option<&[ServiceEndpoint]>) -> String { - endpoints - .and_then(|endpoints| endpoints.first()) - .map(|endpoint| match (endpoint.host.as_deref(), endpoint.port) { - (Some(host), Some(port)) => format!("{host}:{port}"), - (Some(host), None) => host.to_string(), - (None, Some(port)) => format!("{ABSENT}:{port}"), - (None, None) => ABSENT.to_string(), - }) - .unwrap_or_else(|| ABSENT.to_string()) -} - -/// Resolve a service by name or ID within the given org. -/// Exactly one of `name` or `id` must be provided. -async fn resolve_service( - client: &CloudClient, - org_id: &str, - name: Option<&str>, - id: Option<&str>, -) -> CloudResult { - match (name, id) { - (Some(name), None) => { - let services = client.list_services(org_id).await?; - let id = crate::cloud::shared::select_named_id( - "service", - name, - services - .iter() - .map(|service| (service.name.as_deref(), service.id.as_ref())), - )?; - services - .into_iter() - .find(|service| { - service - .id - .as_ref() - .is_some_and(|candidate| candidate.to_string() == id) - }) - .ok_or_else(|| CloudError::new("resolved service is missing from the list")) - } - (None, Some(id)) => Ok(client.get_service(org_id, id).await?), - (Some(_), Some(_)) => Err(CloudError::new( - "supply either a positional service ID or --name", - )), - // Clap rejects this state; retain the check for any future non-CLI caller. - (None, None) => Err(CloudError::new( - "supply a positional service ID or --name to identify the service", - )), - } -} - -fn parse_ip_access_list_patch( - add: &[String], - remove: &[String], -) -> CloudResult> { - let patch = IpAccessListPatch { - add: parse_ip_access_entries(add)?.unwrap_or_default(), - remove: parse_ip_access_entries(remove)?.unwrap_or_default(), - }; - - Ok((!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch)) -} - -/// Prefix of an AWS PrivateLink VPC endpoint ID. -const AWS_VPC_ENDPOINT_PREFIX: &str = "vpce-"; - -/// Clap `value_parser` for flags that *add* a private endpoint ID to a service. -/// -/// Adding an ID registers it org-wide and the API accepts any string, so a typo -/// silently installs a dud entry that has to be unpicked from both the service -/// and the organization (issue #611). Reject the cases that cannot be a real -/// endpoint ID before the request is sent; clap reports this as a usage error. -/// -/// Removal flags deliberately skip this check — a bogus ID that is already -/// registered must stay removable. -fn parse_private_endpoint_id_arg(value: &str) -> std::result::Result { - validate_private_endpoint_id(value).map(|()| value.to_string()) -} - -/// The three providers use unrelated endpoint ID formats, and the provider is -/// not known at parse time, so only checks that hold for every provider are -/// applied: -/// -/// * AWS PrivateLink IDs are `vpce-` plus 8 or 17 lowercase hex characters. The -/// prefix is unmistakable, so a value carrying it anywhere must be exactly one -/// well-formed AWS ID — that catches truncated IDs, uppercased IDs and a -/// pasted VPC endpoint ARN. -/// * GCP uses a numeric Private Service Connect connection ID and Azure uses a -/// private endpoint Resource ID or `resourceGuid`. Neither has a marker that -/// distinguishes a typo from a valid value, so they are only checked for -/// emptiness and whitespace (illegal in all three formats). An Azure Resource -/// ID is an absolute path and can name a resource `vpce-...`, so it is exempt -/// from the AWS check. -/// -/// Whether the endpoint exists and belongs to the caller is not checkable from -/// the client; that remains an upstream API gap. -fn validate_private_endpoint_id(value: &str) -> std::result::Result<(), String> { - if value.trim().is_empty() { - return Err("private endpoint ID must not be empty".to_string()); - } - if value.chars().any(char::is_whitespace) { - return Err(format!( - "invalid private endpoint ID '{value}': IDs cannot contain whitespace \ - (pass each endpoint ID as its own flag value)" - )); - } - // Azure Resource IDs are absolute paths and can legitimately name a - // resource `vpce-...`, so they are exempt from the AWS-shaped check below. - if value.starts_with('/') { - return Ok(()); - } - if value.to_ascii_lowercase().contains(AWS_VPC_ENDPOINT_PREFIX) - && !is_aws_vpc_endpoint_id(value) - { - return Err(format!( - "invalid AWS VPC endpoint ID '{value}': expected exactly '{AWS_VPC_ENDPOINT_PREFIX}' \ - followed by 8 or 17 lowercase hex characters (for example vpce-0123456789abcdef0). GCP \ - uses the numeric Private Service Connect connection ID and Azure the private \ - endpoint Resource ID or resourceGuid" - )); - } - Ok(()) -} - -/// `vpce-` plus 8 (legacy) or 17 (current) lowercase hex characters, and nothing -/// else. -fn is_aws_vpc_endpoint_id(value: &str) -> bool { - let Some(suffix) = value.strip_prefix(AWS_VPC_ENDPOINT_PREFIX) else { - return false; - }; - matches!(suffix.len(), 8 | 17) - && suffix - .chars() - .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')) -} - -fn parse_private_endpoint_ids_patch( - add: &[String], - remove: &[String], -) -> Option { - let patch = InstancePrivateEndpointsPatch { - add: if add.is_empty() { vec![] } else { add.to_vec() }, - remove: if remove.is_empty() { - vec![] - } else { - remove.to_vec() - }, - }; - - (!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch) -} - -fn parse_service_endpoint_changes( - enable: &[String], - disable: &[String], -) -> CloudResult>> { - let mut changes = Vec::new(); - - for protocol in enable { - changes.push(ServiceEndpointChange { - protocol: parse_serde_enum::( - protocol, - "endpoint", - &["mysql"], - )?, - enabled: true, - }); - } - - for protocol in disable { - changes.push(ServiceEndpointChange { - protocol: parse_serde_enum::( - protocol, - "endpoint", - &["mysql"], - )?, - enabled: false, - }); - } - - Ok((!changes.is_empty()).then_some(changes)) -} - -fn parse_instance_tags_patch( - add: &[String], - remove: &[String], -) -> CloudResult> { - let patch = InstanceTagsPatch { - add: parse_tags(add)?.unwrap_or_default(), - remove: parse_tags(remove)?.unwrap_or_default(), - }; - - Ok((!patch.add.is_empty() || !patch.remove.is_empty()).then_some(patch)) -} - -fn build_upgrade_window_request( - weekday: i64, - start_hour: &str, -) -> CloudResult { - let start_hour_utc = match start_hour { - "0" => UpgradeWindowStartHourUtc::Hour0, - "6" => UpgradeWindowStartHourUtc::Hour6, - "12" => UpgradeWindowStartHourUtc::Hour12, - "18" => UpgradeWindowStartHourUtc::Hour18, - value => { - return Err(CloudError::new(format!( - "invalid start_hour '{value}': expected one of 0, 6, 12, 18" - ))); - } - }; - if !(0..=6).contains(&weekday) { - return Err(CloudError::new(format!( - "invalid weekday '{weekday}': expected a number from 0 through 6" - ))); - } - Ok(UpgradeWindowPutRequest { - start_hour_utc, - weekday, - }) -} - -fn build_replica_aware_routing_request( - http_enabled: Option, - native_enabled: Option, -) -> ReplicaAwareRoutingPatchRequest { - ReplicaAwareRoutingPatchRequest { - http_enabled, - native_enabled, - } -} - -#[derive(serde::Serialize)] -struct UpgradeWindowHumanView { - duration: String, - #[serde(rename = "startHourUtc")] - start_hour_utc: String, - weekday: String, -} - -fn upgrade_window_human_view(window: &UpgradeWindow) -> UpgradeWindowHumanView { - let duration = window - .duration - .map(|duration| { - let hours = i64::from(duration); - format!("{hours} hour{}", if hours == 1 { "" } else { "s" }) - }) - .unwrap_or_else(|| ABSENT.to_string()); - let weekday = window - .weekday - .map(|weekday| match weekday { - 0 => "Sunday (0)".to_string(), - 1 => "Monday (1)".to_string(), - 2 => "Tuesday (2)".to_string(), - 3 => "Wednesday (3)".to_string(), - 4 => "Thursday (4)".to_string(), - 5 => "Friday (5)".to_string(), - 6 => "Saturday (6)".to_string(), - unknown => unknown.to_string(), - }) - .unwrap_or_else(|| ABSENT.to_string()); - - UpgradeWindowHumanView { - duration, - start_hour_utc: or_absent(window.start_hour_utc.as_ref()), - weekday, - } -} - -async fn service_list(client: &CloudClient, filters: &[String], json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - - let services = if filters.is_empty() { - client.list_services(&org_id).await? - } else { - client.list_services_filtered(&org_id, filters).await? - }; - - if json { - println!("{}", serde_json::to_string_pretty(&services)?); - } else { - if services.is_empty() { - println!("No services found"); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Name")] - name: String, - #[tabled(rename = "ID")] - id: String, - #[tabled(rename = "State")] - state: String, - #[tabled(rename = "Provider")] - provider: String, - #[tabled(rename = "Region")] - region: String, - #[tabled(rename = "Endpoint")] - endpoint: String, - } - let rows: Vec = services - .into_iter() - .map(|service| Row { - name: or_absent(service.name.as_deref()), - id: or_absent(service.id), - state: or_absent(service.state.as_ref()), - provider: or_absent(service.provider.as_ref()), - region: or_absent(service.region.as_ref()), - endpoint: first_endpoint(service.endpoints.as_deref()), - }) - .collect(); - println!("{}", Table::new(rows).with(Style::markdown())); - } - Ok(()) -} - -async fn service_get(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let service = client.get_service(&org_id, service_id).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - print_human(&service)?; - } - Ok(()) -} - -async fn upgrade_window_get(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let window = client.get_upgrade_window(&org_id, service_id).await?; - if json { - println!("{}", serde_json::to_string_pretty(&window)?); - } else { - print_human(&upgrade_window_human_view(&window))?; - } - Ok(()) -} - -async fn upgrade_window_set( - client: &CloudClient, - service_id: &str, - weekday: i64, - start_hour: &str, - json: bool, -) -> CloudResult<()> { - let request = build_upgrade_window_request(weekday, start_hour)?; - let org_id = resolve_org_id(client).await?; - let window = client - .set_upgrade_window(&org_id, service_id, &request) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&window)?); - } else { - print_human(&upgrade_window_human_view(&window))?; - } - Ok(()) -} - -async fn replica_aware_routing_get( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let routing = client - .get_replica_aware_routing(&org_id, service_id) - .await?; - print_replica_aware_routing(&routing, json) -} - -async fn replica_aware_routing_update( - client: &CloudClient, - service_id: &str, - request: ReplicaAwareRoutingPatchRequest, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let routing = client - .update_replica_aware_routing(&org_id, service_id, &request) - .await?; - print_replica_aware_routing(&routing, json) -} - -fn print_replica_aware_routing(routing: &ReplicaAwareRouting, json: bool) -> CloudResult<()> { - if json { - println!("{}", serde_json::to_string_pretty(routing)?); - } else { - print_human(routing)?; - } - Ok(()) -} - -async fn upgrade_window_delete( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let response = client.delete_upgrade_window(&org_id, service_id).await?; - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - println!("Upgrade window deleted for service {service_id}"); - } - Ok(()) -} - -async fn service_profile_list( - client: &CloudClient, - region_id: Option<&str>, - byoc_id: Option<&str>, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let profiles = client - .list_service_profiles(&org_id, region_id, byoc_id) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&profiles)?); - } else { - if profiles.is_empty() { - println!("No service profiles found"); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Profile")] - profile: String, - #[tabled(rename = "CPU cores")] - cpu_cores: String, - #[tabled(rename = "Memory GiB")] - memory_gi: String, - } - let rows: Vec = profiles - .into_iter() - .map(|profile| Row { - profile: or_absent(profile.profile), - cpu_cores: or_absent(profile.cpu_cores), - memory_gi: or_absent(profile.memory_gi), - }) - .collect(); - println!("{}", Table::new(rows).with(Style::markdown())); - } - Ok(()) -} - -// serde_json preserves i64/u64 integers, but falls back to f64 for overflow -// and exponent/decimal literals. Reject that representation before it can be -// rounded and sent, including numbers nested in future setting value shapes. -fn validate_setting_numbers(value: &serde_json::Value, source: &str) -> CloudResult<()> { - match value { - serde_json::Value::Number(number) if number.is_f64() => Err(CloudError::new(format!( - "{source}: numeric settings must be integers from {} to {}; decimal and exponent values must be JSON strings", - i64::MIN, - u64::MAX - ))), - serde_json::Value::Array(values) => { - for value in values { - validate_setting_numbers(value, source)?; - } - Ok(()) - } - serde_json::Value::Object(values) => { - for value in values.values() { - validate_setting_numbers(value, source)?; - } - Ok(()) - } - _ => Ok(()), - } -} - -fn parse_settings_map_document( - raw: &str, - source: &str, -) -> CloudResult> { - let value: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| CloudError::new(format!("invalid JSON in {source}: {error}")))?; - validate_setting_numbers(&value, source)?; - let object = value.as_object().ok_or_else(|| { - CloudError::new(format!( - "{source} must contain a JSON object mapping setting names to values" - )) - })?; - if object.is_empty() { - return Err(CloudError::new(format!( - "{source} must contain at least one setting" - ))); - } - if object.len() == 1 - && object - .get("settings") - .is_some_and(|value| value.is_string() || value.is_object()) - { - return Err(CloudError::new(format!( - "{source} looks like the API request wrapper; provide the decoded settings map, for example {{\"compatibility\":\"24.8\"}}" - ))); - } - Ok(object - .iter() - .map(|(name, value)| (name.clone(), value.clone())) - .collect()) -} - -fn parse_setting_assignments( - assignments: &[String], -) -> CloudResult> { - let mut settings = BTreeMap::new(); - for (index, assignment) in assignments.iter().enumerate() { - let position = index + 1; - let (name, raw_value) = assignment.split_once('=').ok_or_else(|| { - CloudError::new(format!("--setting #{position} must use NAME=JSON_VALUE")) - })?; - if name.trim().is_empty() { - return Err(CloudError::new(format!( - "--setting #{position} has an empty setting name" - ))); - } - let value = serde_json::from_str(raw_value).map_err(|error| { - CloudError::new(format!( - "--setting #{position} value is not valid JSON: {error}; quote string values, for example compatibility=\"24.8\"" - )) - })?; - validate_setting_numbers(&value, &format!("--setting #{position} ('{name}')"))?; - if settings.insert(name.to_string(), value).is_some() { - return Err(CloudError::new(format!( - "setting '{name}' was provided more than once" - ))); - } - } - Ok(settings) -} - -fn read_service_settings( - assignments: &[String], - settings_file: Option<&str>, -) -> CloudResult> { - if let Some(path) = settings_file { - use std::io::Read as _; - - let (raw, source) = if path == "-" { - let mut raw = String::new(); - std::io::stdin().read_to_string(&mut raw).map_err(|error| { - CloudError::new(format!("failed to read settings from stdin: {error}")) - })?; - (raw, "stdin".to_string()) - } else { - ( - std::fs::read_to_string(path).map_err(|error| { - CloudError::new(format!("failed to read settings file '{path}': {error}")) - })?, - format!("settings file '{path}'"), - ) - }; - return parse_settings_map_document(&raw, &source); - } - - parse_setting_assignments(assignments) -} - -fn build_service_settings_patch_request( - settings: &BTreeMap, -) -> CloudResult { - if settings.is_empty() { - return Err(CloudError::new("provide at least one ClickHouse setting")); - } - Ok(ServiceClickhouseSettingsPatchRequest { - settings: Some(settings.clone()), - }) -} - -async fn service_settings_list( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let response = client.list_clickhouse_settings(&org_id, service_id).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - let Some(settings) = response.settings else { - println!("No ClickHouse settings returned"); - return Ok(()); - }; - if settings.is_empty() { - println!("No ClickHouse settings configured"); - return Ok(()); - } - #[derive(Tabled)] - struct Row { - #[tabled(rename = "Name")] - name: String, - #[tabled(rename = "Value")] - value: String, - } - let rows = settings - .into_iter() - .map(|setting| Row { - name: or_absent(setting.name), - value: or_absent(setting.value.map(|value| match value { - serde_json::Value::String(value) => value, - value => value.to_string(), - })), - }) - .collect::>(); - println!("{}", Table::new(rows).with(Style::markdown())); - } - Ok(()) -} - -async fn service_setting_get( - client: &CloudClient, - service_id: &str, - setting_name: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let setting = client - .get_clickhouse_setting(&org_id, service_id, setting_name) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&setting)?); - } else { - print_human(&setting)?; - } - Ok(()) -} - -async fn service_settings_schema( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let schema = client - .get_clickhouse_settings_schema(&org_id, service_id) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&schema)?); - } else { - print_human(&schema)?; - } - Ok(()) -} - -async fn service_settings_set( - client: &CloudClient, - service_id: &str, - assignments: &[String], - settings_file: Option<&str>, - json: bool, -) -> CloudResult<()> { - // Parse before organization discovery so malformed local input never - // causes an HTTP request, even when --org-id was omitted. - let settings = read_service_settings(assignments, settings_file)?; - let request = build_service_settings_patch_request(&settings)?; - let org_id = resolve_org_id(client).await?; - let response = client - .update_clickhouse_settings(&org_id, service_id, &request) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - print_human(&response)?; - } - Ok(()) -} - -async fn service_setting_unset( - client: &CloudClient, - service_id: &str, - setting_name: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let response = client - .reset_clickhouse_setting(&org_id, service_id, setting_name) - .await?; - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - println!("Reset ClickHouse setting '{setting_name}' to its platform default"); - } - Ok(()) -} - -#[derive(Default)] -struct CreateServiceOptions { - name: String, - provider: String, - region: String, - min_replica_memory_gb: Option, - max_replica_memory_gb: Option, - num_replicas: Option, - min_replicas: Option, - max_replicas: Option, - autoscaling_mode: Option, - idle_scaling: Option, - idle_timeout_minutes: Option, - ip_allow: Vec, - backup_id: Option, - backup_encryption_config: Option, - release_channel: Option, - data_warehouse_id: Option, - is_readonly: bool, - enable_tde: bool, - compliance_type: Option, - profile: Option, - byoc_id: Option, - tags: Vec, - enable_endpoints: Vec, - disable_endpoints: Vec, - private_preview_terms_checked: bool, - enable_core_dumps: Option, -} - -#[derive(Default)] -struct ServiceUpdateOptions { - name: Option, - add_ip_allow: Vec, - remove_ip_allow: Vec, - add_private_endpoint_ids: Vec, - remove_private_endpoint_ids: Vec, - release_channel: Option, - enable_endpoints: Vec, - disable_endpoints: Vec, - transparent_data_encryption_key_id: Option, - add_tags: Vec, - remove_tags: Vec, - enable_core_dumps: Option, -} - -#[derive(Default)] -struct ServiceResetPasswordOptions { - new_password_hash: Option, - new_double_sha1_hash: Option, -} - -#[derive(Default)] -struct QueryEndpointCreateOptions { - roles: Vec, - open_api_keys: Vec, - replace_open_api_keys: bool, - allowed_origins: Option, -} - -struct HorizontalAutoscaling { - autoscaling_mode: Option, - min_replicas: Option, - max_replicas: Option, -} - -fn resolve_horizontal_autoscaling( - autoscaling_mode: Option<&str>, - min_replicas: Option, - max_replicas: Option, -) -> CloudResult { - if min_replicas.is_some() != max_replicas.is_some() { - return Err(CloudError::new( - "--min-replicas and --max-replicas must be specified together", - )); - } - let autoscaling_mode = autoscaling_mode - .map(|value| { - parse_serde_enum::(value, "autoscaling_mode", AutoscalingMode::VALUES) - }) - .transpose()?; - Ok(HorizontalAutoscaling { - autoscaling_mode, - min_replicas: min_replicas.map(i64::from), - max_replicas: max_replicas.map(i64::from), - }) -} - -/// Read a backup's `encryption_config.json` from a file or stdin (`-`), passing -/// the object through unchanged. Every failure is a usage error raised before -/// any request is sent. -fn read_backup_encryption_config(path: &str) -> CloudResult { - use std::io::Read as _; - - let (raw, source) = if path == "-" { - let mut raw = String::new(); - std::io::stdin().read_to_string(&mut raw).map_err(|error| { - CloudError::usage(format!( - "failed to read --backup-encryption-config from stdin: {error}" - )) - })?; - (raw, "stdin".to_string()) - } else { - ( - std::fs::read_to_string(path).map_err(|error| { - CloudError::usage(format!( - "failed to read --backup-encryption-config file '{path}': {error}" - )) - })?, - format!("file '{path}'"), - ) - }; - match serde_json::from_str::(&raw) { - Ok(serde_json::Value::Object(object)) => Ok(object.into_iter().collect()), - Ok(_) => Err(CloudError::usage(format!( - "--backup-encryption-config {source} must contain a JSON object" - ))), - Err(error) => Err(CloudError::usage(format!( - "--backup-encryption-config {source} is not valid JSON: {error}" - ))), - } -} - -fn build_create_service_request(options: &CreateServiceOptions) -> CloudResult { - let ip_access_list = if options.ip_allow.is_empty() { - vec![IpAccessListEntry { - source: "0.0.0.0/0".to_string(), - description: Some("Allow all (created by clickhousectl)".to_string()), - }] - } else { - parse_ip_access_entries(&options.ip_allow)?.unwrap_or_default() - }; - - let horizontal = resolve_horizontal_autoscaling( - options.autoscaling_mode.as_deref(), - options.min_replicas, - options.max_replicas, - )?; - - if options.byoc_id.as_deref().is_some_and(str::is_empty) { - return Err(CloudError::new("--byoc-id cannot be empty")); - } - if options.byoc_id.is_some() - && (options.min_replica_memory_gb.is_none() || options.max_replica_memory_gb.is_none()) - { - return Err(CloudError::new( - "--byoc-id requires --min-replica-memory-gb and --max-replica-memory-gb", - )); - } - - let profile = options - .profile - .as_deref() - .map(|value| parse_create_service_profile(value, options.byoc_id.as_deref())) - .transpose()?; - if matches!(profile, Some(ServicePostRequestProfile::Unknown(_))) - && options.min_replica_memory_gb != options.max_replica_memory_gb - { - return Err(CloudError::new( - "a dynamic BYOC --profile requires equal --min-replica-memory-gb and --max-replica-memory-gb", - )); - } - - Ok(ServicePostRequest { - name: options.name.clone(), - provider: parse_serde_enum::( - &options.provider, - "provider", - ServicePostRequestProvider::VALUES, - )?, - region: parse_serde_enum::( - &options.region, - "region", - ServicePostRequestRegion::VALUES, - )?, - ip_access_list, - min_replica_memory_gb: options.min_replica_memory_gb.map(f64::from), - max_replica_memory_gb: options.max_replica_memory_gb.map(f64::from), - num_replicas: options.num_replicas.map(i64::from), - idle_scaling: options.idle_scaling, - idle_timeout_minutes: options.idle_timeout_minutes.map(f64::from), - backup_id: options - .backup_id - .as_deref() - .map(uuid::Uuid::parse_str) - .transpose() - .map_err(|error| CloudError::new(format!("invalid backup_id: {}", error)))?, - release_channel: match options.release_channel.as_deref() { - Some(value) => Some(parse_serde_enum::( - value, - "release_channel", - ServicePostRequestReleasechannel::VALUES, - )?), - None => None, - }, - tags: parse_tags(&options.tags)?, - data_warehouse_id: options.data_warehouse_id.clone(), - is_readonly: if options.is_readonly { - Some(true) - } else { - None - }, - has_transparent_data_encryption: if options.enable_tde { Some(true) } else { None }, - compliance_type: match options.compliance_type.as_deref() { - Some(value) => Some(parse_serde_enum::( - value, - "compliance_type", - ServicePostRequestCompliancetype::VALUES, - )?), - None => None, - }, - profile, - private_preview_terms_checked: if options.private_preview_terms_checked { - Some(true) - } else { - None - }, - endpoints: parse_service_endpoint_changes( - &options.enable_endpoints, - &options.disable_endpoints, - )?, - enable_core_dumps: options.enable_core_dumps, - autoscaling_mode: horizontal.autoscaling_mode, - byoc_id: options.byoc_id.clone(), - backup_encryption_config: options.backup_encryption_config.clone(), - min_replicas: horizontal.min_replicas, - max_replicas: horizontal.max_replicas, - #[cfg(feature = "deprecated-fields")] - max_total_memory_gb: None, - #[cfg(feature = "deprecated-fields")] - min_total_memory_gb: None, - #[cfg(feature = "deprecated-fields")] - private_endpoint_ids: None, - #[cfg(feature = "deprecated-fields")] - tier: None, - }) -} - -fn parse_create_service_profile( - value: &str, - byoc_id: Option<&str>, -) -> CloudResult { - if ServicePostRequestProfile::VALUES.contains(&value) { - return parse_serde_enum::( - value, - "profile", - ServicePostRequestProfile::VALUES, - ); - } - if byoc_id.is_none() { - return Err(CloudError::new(format!( - "invalid profile: unknown standard profile '{value}'; dynamic profiles require --byoc-id" - ))); - } - if value.is_empty() { - return Err(CloudError::new("--profile cannot be empty")); - } - serde_json::from_value(serde_json::Value::String(value.to_string())) - .map_err(|error| CloudError::new(format!("invalid profile: {error}"))) -} - -fn build_update_service_request( - options: &ServiceUpdateOptions, -) -> CloudResult { - Ok(ServicePatchRequest { - name: options.name.clone(), - ip_access_list: parse_ip_access_list_patch( - &options.add_ip_allow, - &options.remove_ip_allow, - )?, - private_endpoint_ids: parse_private_endpoint_ids_patch( - &options.add_private_endpoint_ids, - &options.remove_private_endpoint_ids, - ), - release_channel: options - .release_channel - .as_deref() - .map(|value| { - parse_serde_enum::( - value, - "release_channel", - ServicePatchRequestReleasechannel::VALUES, - ) - }) - .transpose()?, - endpoints: parse_service_endpoint_changes( - &options.enable_endpoints, - &options.disable_endpoints, - )?, - transparent_data_encryption_key_id: options.transparent_data_encryption_key_id.clone(), - tags: parse_instance_tags_patch(&options.add_tags, &options.remove_tags)?, - enable_core_dumps: options.enable_core_dumps, - }) -} - -/// Whether `options` requests removing anything from a service, so callers -/// can decide whether a pre-update `GET` is needed to check the removals -/// against the current state. -fn has_removals(options: &ServiceUpdateOptions) -> bool { - !options.remove_ip_allow.is_empty() - || !options.remove_private_endpoint_ids.is_empty() - || !options.remove_tags.is_empty() -} - -/// Requested values that do not match anything in `current` (by exact string -/// equality). The service update API silently no-ops a removal that matches -/// nothing, which is idempotent but can hide a typo, so the caller surfaces -/// each unmatched entry as a warning instead of failing. -fn unmatched<'a>(requested: &'a [String], current: &HashSet<&str>) -> Vec<&'a str> { - requested - .iter() - .map(String::as_str) - .filter(|value| !current.contains(value)) - .collect() -} - -/// Human-readable warnings for every `--remove-*` value that does not match -/// anything in `current`. IP entries come from the validated request so the -/// comparison uses the same parsed source that the API receives. -fn unmatched_removal_warnings( - options: &ServiceUpdateOptions, - validated_request: &ServicePatchRequest, - current: &Service, -) -> Vec { - let mut warnings = Vec::new(); - - let current_ip_allow: HashSet<&str> = current - .ip_access_list - .iter() - .flatten() - .filter_map(|entry| entry.source.as_deref()) - .collect(); - let remove_ip_allow = validated_request - .ip_access_list - .as_ref() - .map(|patch| patch.remove.as_slice()) - .unwrap_or_default(); - for entry in remove_ip_allow { - if current_ip_allow.contains(entry.source.as_str()) { - continue; - } - let value = &entry.source; - warnings.push(format!( - "--remove-ip-allow {value} did not match any entry in the service's current IP allow list; no entry was removed" - )); - } - - let current_private_endpoint_ids: HashSet<&str> = current - .private_endpoint_ids - .iter() - .flatten() - .map(String::as_str) - .collect(); - for value in unmatched( - &options.remove_private_endpoint_ids, - ¤t_private_endpoint_ids, - ) { - warnings.push(format!( - "--remove-private-endpoint-id {value} did not match any private endpoint on the service; nothing was removed" - )); - } - - let current_tags: HashSet<&str> = current - .tags - .iter() - .flatten() - .filter_map(|tag| tag.key.as_deref()) - .collect(); - let remove_tag_keys: Vec = options - .remove_tags - .iter() - .map(|value| tag_key(value).to_string()) - .collect(); - for value in unmatched(&remove_tag_keys, ¤t_tags) { - warnings.push(format!( - "--remove-tag {value} did not match any tag on the service; nothing was removed" - )); - } - - warnings -} - -/// The key portion of a `key` or `key=value` tag argument, matching how -/// [`crate::cloud::shared::parse_tag`] splits removal/addition arguments. -fn tag_key(value: &str) -> &str { - value.split_once('=').map_or(value, |(key, _)| key).trim() -} - -fn build_service_password_patch_request( - options: &ServiceResetPasswordOptions, -) -> ServicePasswordPatchRequest { - ServicePasswordPatchRequest { - new_password_hash: options.new_password_hash.clone(), - new_double_sha1_hash: options.new_double_sha1_hash.clone(), - } -} - -fn build_query_endpoint_create_request( - options: &QueryEndpointCreateOptions, - existing: Option<&ServiceQueryAPIEndpoint>, -) -> CloudResult { - if options.roles.is_empty() { - return Err(CloudError::new("at least one --role is required")); - } - let mut open_api_keys = match existing { - Some(endpoint) if !options.replace_open_api_keys => { - existing_open_api_keys(endpoint.clone()) - .map_err(|error| error.at_stage(FailureStage::EndpointGet))? - } - _ => Vec::new(), - }; - open_api_keys.extend(options.open_api_keys.iter().cloned()); - let mut seen = HashSet::new(); - open_api_keys.retain(|key| seen.insert(key.clone())); - - // `allowedOrigins` is optional on both sides of the round trip, so an - // existing endpoint that reports none is written back without one rather - // than refused or given a default. - let allowed_origins = match (&options.allowed_origins, existing) { - (Some(origins), _) => Some(origins.clone()), - (None, Some(endpoint)) => endpoint.allowed_origins.clone(), - (None, None) => { - return Err(CloudError::new( - "--allowed-origins is required when creating a new query endpoint; \ - pass browser origins or '*' explicitly to allow all origins", - )); - } - }; - Ok(InstanceServiceQueryApiEndpointsPostRequest { - roles: options - .roles - .iter() - .map(|role| parse_serde_enum(role, "role", QueryEndpointRole::VALUES)) - .collect::>()?, - open_api_keys, - allowed_origins, - }) -} - -fn build_private_endpoint_create_request( - endpoint_id: &str, - description: Option<&str>, -) -> ServicPrivateEndpointePostRequest { - ServicPrivateEndpointePostRequest { - id: endpoint_id.to_string(), - // The spec makes `description` optional; keep sending `""` when the - // flag is absent so the create request is unchanged. - description: Some(description.map(String::from).unwrap_or_default()), - } -} - -fn build_service_state_patch_request( - command: ServiceStatePatchRequestCommand, -) -> ServiceStatePatchRequest { - ServiceStatePatchRequest { - command: Some(command), - } -} - -fn build_scaling_schedule_request( - value: serde_json::Value, - source: &str, -) -> CloudResult { - let request: ScalingSchedulePostRequest = deserialize_strict_config(value, source)?; - for (index, entry) in request.entries.iter().enumerate() { - validate_scaling_schedule_entry(entry, index, source)?; - } - Ok(request) -} - -fn validate_scaling_schedule_entry( - entry: &ScalingScheduleEntryRequest, - index: usize, - source: &str, -) -> CloudResult<()> { - let invalid = |message: String| { - CloudError::new(format!( - "invalid request body in config {source}: entries[{index}] {message}" - )) - }; - - if entry.weekdays.is_empty() { - return Err(invalid("weekdays must contain at least one day".into())); - } - if let Some(day) = entry.weekdays.iter().find(|&&day| !(0..=6).contains(&day)) { - return Err(invalid(format!( - "weekdays contains {day}; each day must be between 0 (Sunday) and 6 (Saturday)" - ))); - } - if !(0..=23).contains(&entry.start_hour_utc) { - return Err(invalid(format!( - "startHourUtc must be between 0 and 23, got {}", - entry.start_hour_utc - ))); - } - if !(1..=24).contains(&entry.end_hour_utc) { - return Err(invalid(format!( - "endHourUtc must be between 1 and 24, got {}", - entry.end_hour_utc - ))); - } - if entry.start_hour_utc == entry.end_hour_utc { - return Err(invalid( - "startHourUtc and endHourUtc must be different".into(), - )); - } - - let memory = match (entry.min_replica_memory_gb, entry.max_replica_memory_gb) { - (None, None) => None, - (Some(min), Some(max)) => { - for (field, value) in [("minReplicaMemoryGb", min), ("maxReplicaMemoryGb", max)] { - if !(8.0..=356.0).contains(&value) || value % 4.0 != 0.0 { - return Err(invalid(format!( - "{field} must be a multiple of 4 between 8 and 356, got {value}" - ))); - } - } - if min > max { - return Err(invalid(format!( - "minReplicaMemoryGb ({min}) must not exceed maxReplicaMemoryGb ({max})" - ))); - } - Some((min, max)) - } - _ => { - return Err(invalid( - "minReplicaMemoryGb and maxReplicaMemoryGb must be provided together or both omitted" - .into(), - )); - } - }; - - let replicas = match (entry.min_replicas, entry.max_replicas) { - (None, None) => None, - (Some(min), Some(max)) => { - if min < 1 || max < 1 { - return Err(invalid(format!( - "minReplicas and maxReplicas must each be at least 1, got {min} and {max}" - ))); - } - if min > max { - return Err(invalid(format!( - "minReplicas ({min}) must not exceed maxReplicas ({max})" - ))); - } - Some((min, max)) - } - _ => { - return Err(invalid( - "minReplicas and maxReplicas must be provided together or both omitted".into(), - )); - } - }; - - if entry.num_replicas.is_some() && replicas.is_some() { - return Err(invalid( - "numReplicas is mutually exclusive with minReplicas and maxReplicas".into(), - )); - } - if let Some(count) = entry.num_replicas - && count < 1 - { - return Err(invalid(format!( - "numReplicas must be at least 1, got {count}" - ))); - } - - match entry.autoscaling_mode.as_ref() { - Some(AutoscalingMode::Unknown(value)) => { - return Err(invalid(format!( - "autoscalingMode must be one of {}, got {value}", - AutoscalingMode::VALUES.join(", ") - ))); - } - Some(AutoscalingMode::Horizontal) => { - let Some((min_memory, max_memory)) = memory else { - return Err(invalid( - "horizontal autoscaling requires minReplicaMemoryGb and maxReplicaMemoryGb" - .into(), - )); - }; - if min_memory != max_memory { - return Err(invalid( - "horizontal autoscaling requires equal minReplicaMemoryGb and maxReplicaMemoryGb" - .into(), - )); - } - if replicas.is_none() { - return Err(invalid( - "horizontal autoscaling requires minReplicas and maxReplicas".into(), - )); - } - if entry.num_replicas.is_some() { - return Err(invalid( - "horizontal autoscaling does not accept numReplicas".into(), - )); - } - } - Some(AutoscalingMode::Vertical) | None => { - if let Some((min, max)) = replicas - && min != max - { - return Err(invalid( - "vertical autoscaling accepts minReplicas and maxReplicas only as an equal fixed count" - .into(), - )); - } - } - } - - Ok(()) -} - -fn service_query_hint(service_id: Option) -> Option { - service_id.map(|id| { - format!( - "Run SQL with: clickhousectl cloud service query {} --query \"SELECT 1\"\n\ - (the Query API endpoint is provisioned automatically on first use)", - id - ) - }) -} - -fn service_credentials_block(password: Option<&str>, service_id: Option) -> String { - match (password, service_id) { - (Some(password), _) => format!( - "Credentials (save these, password shown only once):\n Username: default\n \ - Password: {}", - password - ), - (None, Some(id)) => format!( - "WARNING: the API response omitted the one-time password, so it cannot be shown.\n\ - The service was created; reset the password to get a usable credential:\n \ - clickhousectl cloud service reset-password {}", - id - ), - (None, None) => "WARNING: the API response omitted the one-time password, so it cannot be \ - shown.\nThe service was created; once you have its id, reset the password \ - with `clickhousectl cloud service reset-password ` to get a \ - usable credential." - .to_string(), - } -} - -async fn service_create( - client: &CloudClient, - options: CreateServiceOptions, - json: bool, -) -> CloudResult<()> { - 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?; - - if json { - 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 { - print_human(service)?; - } - println!(); - println!( - "{}", - service_credentials_block(response.password.as_deref(), service_id) - ); - if let Some(hint) = service_query_hint(service_id) { - println!(); - println!("{}", hint); - } - } - Ok(()) -} - -async fn validate_dynamic_byoc_profile( - client: &CloudClient, - org_id: &str, - request: &ServicePostRequest, -) -> CloudResult<()> { - let Some(ServicePostRequestProfile::Unknown(profile_name)) = request.profile.as_ref() else { - return Ok(()); - }; - let byoc_id = request - .byoc_id - .as_deref() - .ok_or_else(|| CloudError::new("dynamic profiles require --byoc-id"))?; - let region = request.region.to_string(); - let profiles = client - .list_service_profiles(org_id, Some(®ion), Some(byoc_id)) - .await?; - let profile = profiles - .iter() - .find(|candidate| candidate.profile.as_deref() == Some(profile_name.as_str())) - .ok_or_else(|| { - CloudError::new(format!( - "profile '{profile_name}' is not available for BYOC infrastructure '{byoc_id}' in region '{region}'" - )) - })?; - let memory_gi = profile.memory_gi.ok_or_else(|| { - CloudError::new(format!( - "profile '{profile_name}' did not include its memory size; cannot validate service memory bounds" - )) - })?; - let (Some(min_memory), Some(max_memory)) = - (request.min_replica_memory_gb, request.max_replica_memory_gb) - else { - return Err(CloudError::new( - "dynamic BYOC profiles require both replica memory bounds", - )); - }; - if min_memory != memory_gi || max_memory != memory_gi { - return Err(CloudError::new(format!( - "profile '{profile_name}' requires both replica memory bounds to equal {memory_gi} GiB" - ))); - } - Ok(()) -} - -fn classify_stop_poll_state(state: Option<&ServiceState>) -> CloudResult { - let state = state - .ok_or_else(|| { - CloudError::new( - "the API response omitted the service state while waiting for the service to stop, \ - so the stop cannot be confirmed", - ) - })? - .to_string(); - if matches!(state.as_str(), "stopped" | "idle") { - return Ok(true); - } - if matches!(state.as_str(), "terminated" | "failed" | "deleted") { - return Err(CloudError::new(format!( - "service entered unexpected state '{}' while waiting for stop", - state - ))); - } - Ok(false) -} - -fn service_delete_conflict(error: CloudError, service_id: &str) -> CloudError { - let api_message = error.message.clone(); - let message = format!( - "service could not be deleted because of a conflict. If it is running, use --force to \ - stop it first, or `clickhousectl cloud service stop {service_id}`. API response: \ - {api_message}" - ); - CloudError { message, ..error } -} - -fn service_delete_error( - client: &CloudClient, - error: clickhouse_cloud_api::Error, - force: bool, - service_id: &str, - org_id: &str, -) -> CloudError { - let is_conflict = matches!(&error, clickhouse_cloud_api::Error::Api { status: 409, .. }); - let error = client.convert_error_for_lookup( - error, - ResourceLookup::in_org(ResourceKind::Service, service_id, org_id), - ); - if is_conflict && !force { - service_delete_conflict(error, service_id) - } else { - error - } -} - -async fn service_delete( - client: &CloudClient, - service_id: &str, - force: bool, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let (query_key_ids, retain_query_key) = service_query_key_cleanup(&org_id, service_id)?; - - if force { - let service = client.get_service_if_exists(&org_id, service_id).await?; - let state = service - .as_ref() - .map(|service| or_absent(service.state.as_ref())) - .unwrap_or_default(); - if matches!(state.as_str(), "running" | "idle" | "starting") { - // Every write below goes through `eprint_line`/`print_line`, never - // `eprintln!`/`println!`: this loop streams progress for as long as - // the stop takes (minutes), so it routinely outlives whatever was - // reading its output, and a panicking write turned a successful - // delete into exit 101 (#598). - eprint_line(format!( - "Stopping service {} before deletion...", - service_id - )); - client - .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Stop) - .await?; - - let verbose_polling = - std::io::stderr().is_terminal() && !json && std::env::var_os("CI").is_none(); - let mut progress = PollProgress::default(); - loop { - tokio::time::sleep(STOP_POLL_INTERVAL).await; - let service = client.get_service(&org_id, service_id).await?; - let state = or_absent(service.state.as_ref()); - if let Some(line) = progress.render(&state, verbose_polling) { - eprint_line(line); - } - if classify_stop_poll_state(service.state.as_ref())? { - break; - } - } - } - } - - let response = client.delete_service(&org_id, service_id, force).await?; - cleanup_service_query_key(client, &org_id, service_id, &query_key_ids).await?; - if !retain_query_key { - credentials::remove_service_query_key(service_id)?; - } - // The service is already gone by here, so a closed stdout must not turn a - // completed deletion into a panic — see `print_line` and #598. - if json { - print_line(serde_json::to_string_pretty(&response)?); - } else { - print_line(format!("Service {} deletion initiated", service_id)); - } - Ok(()) -} - -async fn service_start(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let service = client - .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Start) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - println!( - "Service {} start request accepted (returned state: {})", - or_absent(service.name.as_deref()), - or_absent(service.state.as_ref()) - ); - } - Ok(()) -} - -async fn service_stop(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let service = client - .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Stop) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - println!( - "Service {} stop request accepted (returned state: {})", - or_absent(service.name.as_deref()), - or_absent(service.state.as_ref()) - ); - } - Ok(()) -} - -async fn service_wake(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let service = client - .change_service_state(&org_id, service_id, ServiceStatePatchRequestCommand::Awake) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - println!( - "Service {} waking (state: {})", - or_absent(service.name.as_deref()), - or_absent(service.state.as_ref()) - ); - } - Ok(()) -} - -async fn service_update( - client: &CloudClient, - service_id: &str, - options: ServiceUpdateOptions, - json: bool, -) -> CloudResult<()> { - let request = build_update_service_request(&options)?; - let org_id = resolve_org_id(client).await?; - - if has_removals(&options) { - let current = client.get_service(&org_id, service_id).await?; - for warning in unmatched_removal_warnings(&options, &request, ¤t) { - eprint_line(format!("Warning: {warning}")); - } - } - - let service = client.update_service(&org_id, service_id, &request).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - println!("Service {} updated", or_absent(service.name.as_deref())); - println!(" ID: {}", or_absent(service.id)); - println!(" State: {}", or_absent(service.state.as_ref())); - } - Ok(()) -} - -#[derive(Default)] -struct ServiceScaleOptions { - min_replica_memory_gb: Option, - max_replica_memory_gb: Option, - num_replicas: Option, - min_replicas: Option, - max_replicas: Option, - autoscaling_mode: Option, - idle_scaling: Option, - idle_timeout_minutes: Option, -} - -fn build_service_scale_request( - options: &ServiceScaleOptions, -) -> CloudResult { - let horizontal = resolve_horizontal_autoscaling( - options.autoscaling_mode.as_deref(), - options.min_replicas, - options.max_replicas, - )?; - - Ok(ServiceReplicaScalingPatchRequest { - autoscaling_mode: horizontal.autoscaling_mode, - min_replica_memory_gb: options.min_replica_memory_gb.map(f64::from), - max_replica_memory_gb: options.max_replica_memory_gb.map(f64::from), - min_replicas: horizontal.min_replicas, - max_replicas: horizontal.max_replicas, - num_replicas: options.num_replicas.map(i64::from), - idle_scaling: options.idle_scaling, - idle_timeout_minutes: options.idle_timeout_minutes.map(f64::from), - }) -} - -async fn service_scale( - client: &CloudClient, - service_id: &str, - options: ServiceScaleOptions, - json: bool, -) -> CloudResult<()> { - let request = build_service_scale_request(&options)?; - let org_id = resolve_org_id(client).await?; - let service = client - .update_replica_scaling(&org_id, service_id, &request) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&service)?); - } else { - println!( - "Service {} scaling updated", - or_absent(service.name.as_deref()) - ); - println!( - " Autoscaling Mode: {}", - or_absent(service.autoscaling_mode.as_ref()) - ); - match service.autoscaling_mode { - Some(AutoscalingMode::Horizontal) => { - println!(" Min Replicas: {}", or_absent(service.min_replicas)); - println!(" Max Replicas: {}", or_absent(service.max_replicas)); - println!( - " Memory/Replica: {} GB", - or_absent(service.replica_memory_gb) - ); - } - Some(AutoscalingMode::Vertical) => { - println!( - " Min Memory/Replica: {} GB", - or_absent(service.min_replica_memory_gb) - ); - println!( - " Max Memory/Replica: {} GB", - or_absent(service.max_replica_memory_gb) - ); - println!(" Replicas: {}", or_absent(service.num_replicas)); - } - _ => {} - } - } - Ok(()) -} - -async fn service_reset_password( - client: &CloudClient, - service_id: &str, - options: ServiceResetPasswordOptions, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let request = build_service_password_patch_request(&options); - let response = client.reset_password(&org_id, service_id, &request).await?; - let outcome = resolve_reset_password_outcome( - generation_requested(&request), - response.password.as_deref(), - )?; - - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - println!("Password reset for service {}", service_id); - match outcome { - ResetPasswordOutcome::Generated(password) => { - println!(" New password: {}", password) - } - ResetPasswordOutcome::HashUpdated => { - println!(" Password hash updated; no plaintext password returned") - } - } - } - Ok(()) -} - -#[derive(Debug)] -enum ResetPasswordOutcome<'a> { - Generated(&'a str), - HashUpdated, -} - -fn generation_requested(request: &ServicePasswordPatchRequest) -> bool { - request.new_password_hash.is_none() -} - -fn resolve_reset_password_outcome( - generation_requested: bool, - password: Option<&str>, -) -> CloudResult> { - if !generation_requested { - return Ok(ResetPasswordOutcome::HashUpdated); - } - match password { - Some(password) => Ok(ResetPasswordOutcome::Generated(password)), - None => Err(CloudError::new( - "the API response omitted the generated password, so it cannot be shown: the \ - service password may already have been rotated — run the reset again to get \ - a password you can use", - )), - } -} - -async fn query_endpoint_get(client: &CloudClient, service_id: &str, json: bool) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let endpoint = client.get_query_endpoint(&org_id, service_id).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); - } else { - print_human(&endpoint)?; - } - Ok(()) -} - -async fn query_endpoint_create( - client: &CloudClient, - service_id: &str, - options: QueryEndpointCreateOptions, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let existing = client - .get_query_endpoint_for_binding(&org_id, service_id) - .await - .map_err(|error| error.at_stage(FailureStage::EndpointGet))?; - let request = build_query_endpoint_create_request(&options, existing.as_ref())?; - let endpoint = client - .create_query_endpoint(&org_id, service_id, &request) - .await - .map_err(|error| error.at_stage(FailureStage::EndpointUpsert))?; - - if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); - } else { - print_human(&endpoint)?; - } - Ok(()) -} - -async fn query_endpoint_delete( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let response = client.delete_query_endpoint(&org_id, service_id).await?; - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - println!("Query endpoint deleted for service {}", service_id); - } - Ok(()) -} - -async fn service_query_key_repair( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let result = - crate::cloud::services::query_key::repair_service_query_key(client, &org_id, service_id) - .await?; - // A retired key that could not be deleted is a warning, not a failure: the - // replacement is active and its ID is stored for the retry (#527). The - // warning goes to stderr in both modes; the JSON result carries the IDs. - let mut result = result; - if let Some(warning) = &result.cleanup_warning { - eprint_line(warning); - } - // The repair is committed before the probe runs, so its result is printed - // whatever the probe says (#658): a `--json` consumer must never lose the - // IDs of a repair that happened. Only a probe that kept being rejected - // for the whole readiness window is an error, and it is reported after - // the result. - let mut verification_error = None; - if result.status == "repaired" { - match verify_repaired_query_key( - client, - &org_id, - service_id, - &result.api_key_id, - QUERY_ENDPOINT_READINESS, - ) - .await - { - Ok(verification) => result.verification = Some(verification), - Err(error) => { - result.verification = Some(RepairVerification::Failed); - verification_error = Some(error); - } - } - } - if json { - println!("{}", serde_json::to_string_pretty(&result)?); - } else { - match result.status { - "already_repaired" => println!( - "Query key for service {} was already repaired by another process (active API key: {})", - result.service_id, result.api_key_id - ), - _ => { - println!("Query key repaired for service {}", result.service_id); - println!( - " Replaced API key: {}", - or_absent(result.replaced_api_key_id.as_deref()) - ); - println!(" New API key: {}", result.api_key_id); - println!(" Query endpoint: {}", result.endpoint_id); - if !result.deleted_api_key_ids.is_empty() { - println!( - " Deleted API keys: {}", - result.deleted_api_key_ids.join(", ") - ); - } - if !result.pending_cleanup_api_key_ids.is_empty() { - println!( - " API keys awaiting deletion: {}", - result.pending_cleanup_api_key_ids.join(", ") - ); - } - if let Some(verification) = result.verification { - println!(" Query API check: {}", verification.as_str()); - } - } - } - } - match verification_error { - Some(error) => Err(error), - None => Ok(()), - } -} - -/// Confirm that the Query API accepts the key a repair just committed (#658). -/// -/// The new binding reaches the Query API host a moment after the upsert; a -/// query issued in between is rejected, and the stored-key classifier (#528) -/// would then read an enabled, bound key as "IP allowlist or secret -/// mismatch", a verdict that is wrong and points nowhere useful. So repair -/// probes the endpoint with the new key the way first-use provisioning does. -/// -/// The repair is committed before this runs and nothing here undoes it. The -/// outcome is reported, not acted on: `Verified` when a probe succeeded; -/// `Skipped` when the key was not probed (no Query API host configured, the -/// service not `running` or its state unreadable, the record unreadable or -/// replaced by a concurrent repair, or the probe answered idle/stopped); -/// `Failed` when the probe failed for a reason unrelated to readiness. Only a -/// key the Query API kept rejecting for the whole readiness window is an -/// error, and even then the message says the repair stands and points at -/// `cloud service query`, which classifies the rejection — never at rerunning -/// the repair, which would rotate a key that may simply not have propagated -/// yet. -async fn verify_repaired_query_key( - client: &CloudClient, - org_id: &str, - service_id: &str, - api_key_id: &str, - readiness: QueryEndpointReadiness, -) -> CloudResult { - let stored = credentials::try_get_service_query_key(service_id); - let (key_id, key_secret) = match repaired_query_credential(stored, service_id, api_key_id) { - Ok(credential) => credential, - Err(reason) => { - return Ok(skip_repair_verification( - org_id, service_id, api_key_id, &reason, - )); - } - }; - probe_repaired_query_key( - client, - org_id, - service_id, - api_key_id, - &key_id, - &key_secret, - readiness, - ) - .await -} - -/// The credential pair to probe with, if the record still names the key the -/// repair just stored. A record that vanished, that a concurrent repair -/// replaced, or that could not be read back at all is not this run's to -/// verify; the `Err` carries the reason the probe is skipped. The repair is -/// already committed, so re-reading it is never what turns the run into a -/// failure. -fn repaired_query_credential( - stored: CloudResult>, - service_id: &str, - api_key_id: &str, -) -> Result<(String, String), String> { - let stored = stored.map_err(|error| { - format!("the stored query key for service {service_id} could not be read ({error})") - })?; - let changed = || { - format!( - "the stored query key for service {service_id} changed before the replacement could \ - be probed" - ) - }; - let stored = stored.ok_or_else(changed)?; - if stored.api_key_id.as_deref() != Some(api_key_id) { - return Err(changed()); - } - Ok((stored.key_id, stored.key_secret)) -} - -fn next_query_command(service_id: &str, org_id: &str) -> String { - format!("clickhousectl cloud service query {service_id} --org-id {org_id} --query \"SELECT 1\"") -} - -/// Print the one `Note:` line every skipped verification gets, and return -/// `Skipped`. A closed stderr must not panic: the repair is already done. -fn skip_repair_verification( - org_id: &str, - service_id: &str, - api_key_id: &str, - reason: &str, -) -> RepairVerification { - eprint_line(format!( - "Note: {reason}, so the repaired query key was not probed; the replacement (API key \ - {api_key_id}) is stored in .clickhouse/credentials.json and is verified by the next `{}`", - next_query_command(service_id, org_id) - )); - RepairVerification::Skipped -} - -#[allow(clippy::too_many_arguments)] -async fn probe_repaired_query_key( - client: &CloudClient, - org_id: &str, - service_id: &str, - api_key_id: &str, - key_id: &str, - key_secret: &str, - readiness: QueryEndpointReadiness, -) -> CloudResult { - // Never probe production by accident: a control plane the query host - // cannot be derived from (a local mock, an unusual base URL) means the - // library would fall back to the production host. - if client.configured_query_host().is_none() { - return Ok(skip_repair_verification( - org_id, - service_id, - api_key_id, - &format!( - "no Query API host is configured for {} (set CLICKHOUSE_CLOUD_QUERY_HOST)", - client.base_url() - ), - )); - } - let state = match client.get_service(org_id, service_id).await { - Ok(service) => service.state, - Err(error) => { - return Ok(skip_repair_verification( - org_id, - service_id, - api_key_id, - &format!("could not read service {service_id}: {error}"), - )); - } - }; - if state != Some(ServiceState::Running) { - // A probe would wake an idle service, and a stopped one cannot answer. - let state = state - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "in an unknown state".to_string()); - return Ok(skip_repair_verification( - org_id, - service_id, - api_key_id, - &format!("service {service_id} is {state}"), - )); - } - - let outcome = wait_for_query_endpoint_readiness(readiness, || { - client.run_service_query( - service_id, - key_id, - key_secret, - "SELECT 1", - None, - "TabSeparated", - false, - ) - }) - .await; - let repaired = format!( - "the query key for service {service_id} was replaced (new API key {api_key_id}) and \ - stored in .clickhouse/credentials.json" - ); - match outcome { - Ok(false) => Ok(RepairVerification::Verified), - Ok(true) => Ok(skip_repair_verification( - org_id, - service_id, - api_key_id, - &format!("service {service_id} is idle"), - )), - Err(QueryReadinessError::Api(clickhouse_cloud_api::Error::ServiceStopped)) => { - Ok(skip_repair_verification( - org_id, - service_id, - api_key_id, - &format!("service {service_id} is stopped"), - )) - } - Err(QueryReadinessError::Api(error)) => { - let converted = client.convert_error(error); - eprint_line(format!( - "Warning: {repaired}, but probing it failed: {converted}; it is verified by the \ - next `{}`", - next_query_command(service_id, org_id) - )); - Ok(RepairVerification::Failed) - } - Err(QueryReadinessError::TimedOut(timeout)) => { - let command = next_query_command(service_id, org_id); - let message = format!( - "{repaired}, but the Query API kept rejecting it for {timeout:?}. Do not rerun \ - repair-query-key: that would rotate a key that may only be slow to propagate. \ - Run `{command}`, which classifies the rejection" - ); - Err(CloudError::new(message.clone()) - .with_failure(ApiFailure::new(FailureKind::Timeout)) - .with_details(CloudErrorDetail { - code: CloudErrorCode::QueryKeyRepairUnverified, - message, - host: None, - port: None, - command: Some(command), - api_key_id: Some(api_key_id.to_string()), - ip_access_list: None, - }) - .at_stage(FailureStage::QueryRequest)) - } - } -} - -struct ServiceQueryOptions { - name: Option, - id: Option, - query: Option, - queries_file: Option, - database: Option, - format: Option, - json: bool, - - no_auto_enable: bool, -} - -#[allow(clippy::too_many_arguments)] -async fn run_basic_service_query( - client: &CloudClient, - service_id: &str, - key_id: &str, - key_secret: &str, - sql: &str, - database: Option<&str>, - format: &str, - service_name: &str, - confirmed_idle: bool, -) -> Result { - run_service_query_with_wake(service_name, confirmed_idle, |wake| { - client.run_service_query(service_id, key_id, key_secret, sql, database, format, wake) - }) - .await -} - -/// Preserve an explicit pre-execution idle response when the following wake -/// attempt fails. A timeout alone never permits another SQL submission. -struct QueryRequestError { - error: clickhouse_cloud_api::Error, - wake_requested: bool, -} - -impl QueryRequestError { - fn into_cloud_error(self, client: &CloudClient, target: QueryTarget<'_>) -> CloudError { - convert_query_error( - client, - self.error, - QueryTarget { - may_be_waking: target.may_be_waking || self.wake_requested, - ..target - }, - ) - } -} - -async fn run_service_query_with_wake( - service_name: &str, - confirmed_idle: bool, - run: F, -) -> Result -where - F: Fn(bool) -> Fut, - Fut: std::future::Future>, -{ - let waking_error = |error| QueryRequestError { - error, - wake_requested: true, - }; - if confirmed_idle { - eprint_waking_service(service_name); - failure::note_retry(); - return run(true).await.map_err(waking_error); - } - - match run(false).await { - Err(clickhouse_cloud_api::Error::ServiceIdle) => { - eprint_waking_service(service_name); - failure::note_retry(); - run(true).await.map_err(waking_error) - } - other => other.map_err(|error| QueryRequestError { - error, - wake_requested: false, - }), - } -} - -/// Why the readiness wait ended without the endpoint accepting a probe. -/// -/// The deadline is the CLI's own: no HTTP response stands behind it, so it is -/// a variant of its own rather than a synthesized `Error::Api { status: 408 }` -/// — telemetry reports `http_status` as the exact status the server sent, and -/// a made-up one would be a lie there (#450). -#[derive(Debug)] -enum QueryReadinessError { - /// The endpoint was still rejecting probes when the deadline elapsed. - TimedOut(Duration), - /// A probe failed for a reason other than "not ready yet". - Api(clickhouse_cloud_api::Error), -} - -fn query_readiness_timeout_error(timeout: Duration) -> CloudError { - CloudError::new(format!( - "Query API endpoint did not become ready within {timeout:?}" - )) - .with_failure(ApiFailure::new(FailureKind::Timeout)) -} - -async fn wait_for_query_endpoint_readiness( - readiness: QueryEndpointReadiness, - mut probe: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: std::future::Future>, -{ - let deadline = tokio::time::Instant::now() + readiness.timeout; - let mut backoff = readiness.initial_backoff; - let mut waiting = false; - - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - return Err(QueryReadinessError::TimedOut(readiness.timeout)); - } - - match tokio::time::timeout(remaining, probe()).await { - Ok(Ok(_)) => return Ok(false), - Ok(Err(clickhouse_cloud_api::Error::ServiceIdle)) => return Ok(true), - Ok(Err(error)) if query_endpoint_readiness_error(&error) => {} - Ok(Err(error)) => return Err(QueryReadinessError::Api(error)), - Err(_) => return Err(QueryReadinessError::TimedOut(readiness.timeout)), - } - - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - return Err(QueryReadinessError::TimedOut(readiness.timeout)); - } - if !waiting { - eprint_line("Waiting for the Query API endpoint to become ready..."); - waiting = true; - } - // One more probe is about to be sent: the retry bucket is how a - // dashboard sees "this run waited on propagation" (#450). - failure::note_retry(); - tokio::time::sleep(backoff.min(remaining)).await; - backoff = backoff.saturating_mul(2).min(readiness.max_backoff); - } -} - -#[allow(clippy::too_many_arguments)] -async fn run_just_provisioned_service_query( - client: &CloudClient, - key_id: &str, - key_secret: &str, - sql: &str, - database: Option<&str>, - format: &str, - target: QueryTarget<'_>, - readiness: QueryEndpointReadiness, -) -> CloudResult { - run_just_provisioned_service_query_or( - client, - key_id, - key_secret, - sql, - database, - format, - target, - readiness, - query_readiness_timeout_error, - ) - .await -} - -/// [`run_just_provisioned_service_query`], reporting an exhausted readiness -/// window with `on_timeout` (given the window) instead of the generic -/// timeout error. Every other failure converts as usual. -#[allow(clippy::too_many_arguments)] -async fn run_just_provisioned_service_query_or( - client: &CloudClient, - key_id: &str, - key_secret: &str, - sql: &str, - database: Option<&str>, - format: &str, - target: QueryTarget<'_>, - readiness: QueryEndpointReadiness, - on_timeout: impl FnOnce(Duration) -> CloudError, -) -> CloudResult { - let confirmed_idle = wait_for_query_endpoint_readiness(readiness, || { - client.run_service_query( - target.service_id, - key_id, - key_secret, - "SELECT 1", - None, - "TabSeparated", - false, - ) - }) - .await - .map_err(|error| match error { - QueryReadinessError::TimedOut(timeout) => on_timeout(timeout), - QueryReadinessError::Api(error) => convert_query_error(client, error, target), - })?; - - run_basic_service_query( - client, - target.service_id, - key_id, - key_secret, - sql, - database, - format, - target.service_name, - confirmed_idle, - ) - .await - .map_err(|error| error.into_cloud_error(client, target)) -} - -/// Whether an error means "the Query API endpoint is not (yet) usable by this -/// credential" rather than "the query itself failed". -/// -/// The test is structural: a SQL-level rejection arrives as -/// `Error::Sql`, which proves the service ran the request, so the endpoint is -/// demonstrably usable. It used to be told apart by sniffing the formatted -/// message for a `SQL error ` prefix (#450). -fn query_endpoint_readiness_error(error: &clickhouse_cloud_api::Error) -> bool { - matches!( - error, - clickhouse_cloud_api::Error::Api { - status: 401 | 403 | 404, - .. - } - ) -} - -fn stored_query_key_rejection_status(error: &clickhouse_cloud_api::Error) -> Option { - match error { - clickhouse_cloud_api::Error::Api { - status: status @ (401 | 403), - .. - } => Some(*status), - _ => None, - } -} - -/// The `--no-auto-enable` refusal: the Query API rejected the management key -/// (401/403/404) and binding that key to the endpoint would have fixed that, -/// but the caller forbade it. The message is the CLI's; the classification stays the API's actual -/// rejection, carried across from the variant rather than reset to `other` -/// (#450). -fn refused_query_provisioning_error( - service_id: &str, - rejection: &clickhouse_cloud_api::Error, -) -> CloudError { - CloudError::new(format!( - "the authenticated API key cannot use the Query API endpoint for service {service_id}, \ - and --no-auto-enable prevents binding it to the endpoint" - )) - .with_failure(failure::classify_api_error(rejection)) -} - -async fn service_query(client: &CloudClient, options: ServiceQueryOptions) -> CloudResult<()> { - // Anchor the duration bucket of any failure classification (#450) at the - // start of the run rather than at process start, so it measures the query - // and not the CLI's own startup. - failure::start_span(); - - let sql = read_query_sql(options.query.as_deref(), options.queries_file.as_deref()) - .map_err(|error| error.at_stage(FailureStage::SqlInput))?; - let org_id = resolve_org_id(client) - .await - .map_err(|error| error.at_stage(FailureStage::OrgResolution))?; - let service = resolve_service( - client, - &org_id, - options.name.as_deref(), - options.id.as_deref(), - ) - .await - .map_err(|error| error.at_stage(FailureStage::ServiceResolution))?; - let service_id = match service.id { - Some(id) => id.to_string(), - None => options.id.clone().ok_or_else(|| { - CloudError::new("the API response is missing the service id") - .at_stage(FailureStage::ServiceResolution) - })?, - }; - let service_name = or_absent(service.name.as_deref()); - // Resolved once, from the `GET service` response the query already needed: - // the gateway-timeout hint spells out a `clickhouse client` command, and - // it must use the service's real native endpoint when the API returned - // one (#644). - let native = native_secure_endpoint(service.endpoints.as_deref()); - let target = QueryTarget { - service_name: &service_name, - service_id: &service_id, - org_id: &org_id, - native: native.as_ref(), - // Use the service lookup already required for this query. This is - // evidence of a possible wake delay, not proof the SQL never ran. - may_be_waking: matches!( - service.state, - Some(ServiceState::Idle | ServiceState::Awaking) - ), - }; - - let format = options.format.unwrap_or_else(|| { - if options.json { - "JSONEachRow".to_string() - } else { - default_query_format() - } - }); - - // The `query_request` classifications below are a *fallback*: recording - // is first-write-wins, so an inner boundary that knows the exact stage - // (`whoami`, `endpoint_get`, ...) keeps its record and only failures - // no boundary claimed -- an unusable local credential store, say -- land - // on the coarse `query_request` stage rather than going unclassified - // (#450). - let response = if client.is_bearer_auth() { - // OAuth carries no write access, so no key or endpoint provisioning is - // possible on this path at all. - failure::set_provisioning_state(ProvisioningState::Bearer); - let run = |wake: bool| { - client.run_service_query_bearer( - &service_id, - &sql, - options.database.as_deref(), - &format, - wake, - ) - }; - run_service_query_with_wake(&service_name, false, run) - .await - .map_err(|error| { - error - .into_cloud_error(client, target) - .at_stage(FailureStage::QueryRequest) - })? - } else { - let convert = |error: QueryRequestError| error.into_cloud_error(client, target); - let result = if let Some(key) = credentials::try_get_service_query_key(&service_id) - .map_err(|error| error.at_stage(FailureStage::QueryRequest))? - { - failure::set_provisioning_state(ProvisioningState::StoredKey); - // A repair that could not delete a superseded key left its ID on - // the record; finish that here, quietly, before the query (#527). - // The list is empty on every ordinary run, so this costs nothing - // unless a retirement is actually outstanding. - crate::cloud::services::query_key::retry_pending_query_key_cleanup( - client, - &key, - &service_id, - &org_id, - ) - .await; - match run_basic_service_query( - client, - &service_id, - &key.key_id, - &key.key_secret, - &sql, - options.database.as_deref(), - &format, - &service_name, - false, - ) - .await - { - Err(error) => match stored_query_key_rejection_status(&error.error) { - // The 401/403 alone does not say whether the key is stale - // or was deliberately revoked; the management record - // decides, and nothing is replaced automatically (#528). - Some(status) => { - return Err( - crate::cloud::services::query_key::rejected_stored_query_key_error( - client, - &key, - &service_id, - &org_id, - status, - ) - .await - .at_stage(FailureStage::QueryRequest), - ); - } - None => Err(convert(error)), - }, - Ok(response) => Ok(response), - } - } else { - failure::set_provisioning_state(ProvisioningState::ManagementKey); - let (key_id, key_secret) = client.basic_auth_credentials().ok_or_else(|| { - CloudError::new("API key credentials are unavailable") - .at_stage(FailureStage::QueryRequest) - })?; - match run_basic_service_query( - client, - &service_id, - key_id, - key_secret, - &sql, - options.database.as_deref(), - &format, - &service_name, - false, - ) - .await - { - Err(error) if query_endpoint_readiness_error(&error.error) => { - if options.no_auto_enable { - failure::set_provisioning_state(ProvisioningState::Refused); - return Err(refused_query_provisioning_error(&service_id, &error.error) - .at_stage(FailureStage::QueryRequest)); - } - eprint_line(format!( - "Binding the authenticated API key to the Query API endpoint for \ - service '{}'...", - service_name - )); - // No key is created: the caller's own key is bound to the - // endpoint and the query re-runs with it (#1043). The bind - // records `BoundCallerKey` itself once it succeeds. - failure::set_provisioning_state(ProvisioningState::Provisioning); - let binding = crate::cloud::services::query_key::bind_caller_query_key( - client, - &org_id, - &service_id, - ) - .await - .map_err(|error| error.at_stage(FailureStage::QueryRequest))?; - match binding { - CallerKeyBinding::Bound => { - run_just_provisioned_service_query( - client, - key_id, - key_secret, - &sql, - options.database.as_deref(), - &format, - target, - QUERY_ENDPOINT_READINESS, - ) - .await - } - // Nothing was written. A concurrent run may have bound - // the key while this one waited for the lock, so wait - // for the endpoint as the binder does; only a key - // still rejected for the whole window means rebinding - // cannot help. - CallerKeyBinding::AlreadyBound { caller_uuid } => { - failure::set_provisioning_state(ProvisioningState::ManagementKey); - run_just_provisioned_service_query_or( - client, - key_id, - key_secret, - &sql, - options.database.as_deref(), - &format, - target, - QUERY_ENDPOINT_READINESS, - |waited| { - crate::cloud::services::query_key::bound_caller_key_rejected_error( - &service_id, - &org_id, - &caller_uuid, - waited, - ) - .at_stage(FailureStage::EndpointGet) - }, - ) - .await - } - } - } - other => other.map_err(convert), - } - }; - result.map_err(|error| error.at_stage(FailureStage::QueryRequest))? - }; - - use futures_util::StreamExt; - use std::io::Write as _; - let mut stream = response.bytes_stream(); - let mut handle = crate::stdout::stdout(); - let mut byte_count = 0; - let mut last_byte = None; - // Both failure modes of the stream are classified here, at the boundary - // that knows which one happened (#450): a truncated body is a transport - // failure, a refused write is local I/O. - let stream_failure = |error: CloudError| error.at_stage(FailureStage::ResponseStream); - while let Some(chunk) = stream.next().await { - let bytes = chunk.map_err(|error| { - stream_failure( - CloudError::new(format!("Failed to read query response: {error}")) - .with_failure(ApiFailure::new(FailureKind::Transport)), - ) - })?; - handle - .write_all(&bytes) - .map_err(|error| stream_failure(CloudError::from(error)))?; - byte_count += bytes.len(); - if let Some(last) = bytes.last() { - last_byte = Some(*last); - } - } - match query_output_completion(&format, byte_count, last_byte) { - QueryOutputCompletion::None => {} - QueryOutputCompletion::Newline => handle - .write_all(b"\n") - .map_err(|error| stream_failure(CloudError::from(error)))?, - QueryOutputCompletion::Acknowledge => eprint_line("OK"), - } - handle - .flush() - .map_err(|error| stream_failure(CloudError::from(error)))?; - Ok(()) -} - -#[derive(Debug, PartialEq, Eq)] -enum QueryOutputCompletion { - None, - Newline, - Acknowledge, -} - -fn query_output_completion( - format: &str, - byte_count: usize, - last_byte: Option, -) -> QueryOutputCompletion { - if byte_count == 0 { - return QueryOutputCompletion::Acknowledge; - } - if query_format_uses_text_lines(format) && last_byte != Some(b'\n') { - QueryOutputCompletion::Newline - } else { - QueryOutputCompletion::None - } -} - -fn query_format_uses_text_lines(format: &str) -> bool { - matches!( - format.to_ascii_lowercase().as_str(), - "jsoneachrow" | "prettycompact" | "tabseparated" - ) -} - -fn eprint_waking_service(service_name: &str) { - eprint_line(format!( - "Service '{service_name}' is idle; waking it (this may take a minute)..." - )); -} - -/// A service's native-protocol (`nativesecure`) endpoint. -/// -/// Both halves are required. A hint that named the real host but guessed the -/// port would hand the user a command that does not connect, which is worse -/// than a command that visibly needs filling in. -#[derive(Debug, Clone, PartialEq, Eq)] -struct NativeEndpoint { - host: String, - port: i64, -} - -/// What the query path knows about its target service when it has to render -/// an error: enough to name the service, to point at `service start`, and to -/// spell out the native-protocol command that routes around the Query API -/// gateway timeout (#644). -#[derive(Debug, Clone, Copy)] -struct QueryTarget<'a> { - service_name: &'a str, - service_id: &'a str, - org_id: &'a str, - /// The service's `nativesecure` endpoint, when the `GET service` - /// response carried a complete one. - native: Option<&'a NativeEndpoint>, - /// An idle/awaking service lookup, or an explicit pre-execution idle - /// response followed by a wake request. Neither proves the SQL did not run. - may_be_waking: bool, -} - -/// The service's `nativesecure` endpoint, when the API returned a complete -/// one. -/// -/// Every field of a response model is optional, so a partial endpoint (a host -/// with no port, or the reverse) counts as absent here rather than being -/// half-rendered into a command. -fn native_secure_endpoint(endpoints: Option<&[ServiceEndpoint]>) -> Option { - endpoints?.iter().find_map(|endpoint| { - if endpoint.protocol != Some(ServiceEndpointProtocol::Nativesecure) { - return None; - } - Some(NativeEndpoint { - host: endpoint.host.clone()?, - port: endpoint.port?, - }) - }) -} - -/// Stand-in for a host the API did not return. A placeholder, never a guess. -const NATIVE_HOST_PLACEHOLDER: &str = ""; - -/// The standard native-protocol TLS port. Only used in the placeholder -/// command, where there is no endpoint to read a real port from. -const DEFAULT_NATIVE_SECURE_PORT: i64 = 9440; - -/// The `clickhouse client` command that reruns the statement over the native -/// protocol. -/// -/// The SQL and the password are always literal placeholders: the user's -/// statement is never echoed back into an error message, and no credential is -/// ever rendered. -fn native_client_command(native: Option<&NativeEndpoint>) -> String { - let (host, port) = match native { - Some(endpoint) => (endpoint.host.as_str(), endpoint.port), - None => (NATIVE_HOST_PLACEHOLDER, DEFAULT_NATIVE_SECURE_PORT), - }; - format!( - "clickhouse client --host {host} --secure --port {port} --user default \ - --password '' --query ''" - ) -} - -/// Diagnose a typed gateway timeout using the state observed before the -/// request (#826). A possible wake delay does not prove the statement was -/// never executed, so neither diagnosis permits an automatic retry. -fn query_timeout_error(target: QueryTarget<'_>) -> CloudError { - let details = if target.may_be_waking { - let command = format!( - "clickhousectl cloud service get {} --org-id {}", - target.service_id, target.org_id - ); - let message = format!( - "the query timed out and service '{}' may still be waking; it was idle or awaking \ - before the query attempt.\nThe statement may already have executed or may still be \ - running; it was not retried after the timeout.\nCheck the service state with \ - `{command}`. Once it is running, verify the statement's outcome before deciding \ - whether it is safe to run again.", - target.service_name - ); - CloudErrorDetail { - command: Some(command), - ..CloudErrorDetail::new(CloudErrorCode::QueryTimeout, message) - } - } else { - let command = native_client_command(target.native); - let endpoint_guidance = if target.native.is_some() { - String::new() - } else { - format!( - "\nThe API response carried no native endpoint for this service; read the host and \ - port from `clickhousectl cloud service get {} --org-id {}`.", - target.service_id, target.org_id - ) - }; - let message = format!( - "the query timed out at the Query API gateway, which stops waiting after about 30 \ - seconds.\nThe statement may still be running on the service or may already have \ - completed; check `SELECT query_id, elapsed FROM system.processes` and verify its \ - outcome before running it again.\n\nHint: run long statements over the native \ - protocol instead. Install the client with\n clickhousectl local use latest\nthen \ - rerun the query with\n {command}{endpoint_guidance}\nThe password is the one shown \ - when the service was created; `clickhousectl cloud service reset-password {}` \ - issues a new one.", - target.service_id - ); - CloudErrorDetail { - code: CloudErrorCode::QueryTimeout, - message, - host: target.native.map(|native| native.host.clone()), - port: target.native.map(|native| native.port), - command: Some(command), - api_key_id: None, - ip_access_list: None, - } - }; - - CloudError::new(details.message.clone()) - // Both diagnoses retain the typed timeout classification (#450). - .with_failure(failure::classify_api_error( - &clickhouse_cloud_api::Error::QueryTimeout, - )) - .with_details(details) -} - -fn convert_query_error( - client: &CloudClient, - error: clickhouse_cloud_api::Error, - target: QueryTarget<'_>, -) -> CloudError { - match error { - clickhouse_cloud_api::Error::ServiceStopped => CloudError::new(format!( - "service '{}' is stopped; start it with `clickhousectl cloud service start {} --org-id {}` and retry", - target.service_name, target.service_id, target.org_id - )) - // Rewriting the message must not lose the classification the variant - // already established (#450). - .with_failure(ApiFailure::new(FailureKind::ServiceStopped)), - clickhouse_cloud_api::Error::QueryTimeout => query_timeout_error(target), - other => client.convert_error(other), - } -} - -/// What stdin holds when `--query` is validated (#641). Derived from the file -/// descriptor only, never from anything the user typed. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StdinInput { - /// An interactive terminal: nothing was piped or redirected in, and stdin - /// must not be read at all — a read would block on the human. - Terminal, - /// Not a terminal, and no byte arrived within - /// [`STDIN_FIRST_BYTE_TIMEOUT_MS`]: `< /dev/null`, a closed pipe, or a CI - /// runner or coding agent that has no tty and piped nothing. - Empty, - /// Not a terminal and at least one byte is available to read. - Pending, -} - -/// The message for `--query` plus data on stdin. The Query API takes a single -/// request body, so a statement and a separate data stream can never both be -/// sent; the message therefore leads with what does work. -const INLINE_QUERY_WITH_STDIN_ERROR: &str = "--query cannot be combined with SQL or data on stdin. \ - The Query API sends one request body, so redirected data is never read. Pipe the statement \ - and its data together on stdin instead: \ - printf 'INSERT INTO t FORMAT CSV\\n' | cat - data.csv | clickhousectl cloud service query \ - --id . Or read a whole statement from stdin with --queries-file -."; - -/// Whether `--query` conflicts with what is on stdin. Pure, so the decision is -/// unit-tested without a real file descriptor. Only bytes that are actually -/// waiting count: a non-terminal but empty stdin is the normal shape for CI -/// and for coding agents, so it must stay a valid way to run `--query`. -fn inline_query_conflicts_with_stdin(inline: Option<&str>, stdin: StdinInput) -> bool { - inline.is_some() && matches!(stdin, StdinInput::Pending) -} - -/// How long to wait for the first byte before deciding stdin carries nothing. -/// -/// A zero timeout would reintroduce #641 as a race: in `cat data.csv | -/// clickhousectl ... --query ...` the shell starts both processes at once, so -/// the CLI can reach `poll` before `cat` has written anything and would go on -/// to send the empty INSERT. A bounded wait closes that window. Nothing that -/// already knows its answer pays it: a regular file, `/dev/null`, a closed -/// pipe (`POLLHUP`) and a pipe that already holds data all make `poll` return -/// at once. The only invocation that waits is `--query` whose stdin is an open -/// pipe with nothing written yet — the harness case that must never hang — and -/// it waits this long rather than forever. -const STDIN_FIRST_BYTE_TIMEOUT_MS: libc::c_int = 250; - -/// Classify stdin without consuming it and without blocking indefinitely. -/// -/// `poll` answers "would a read block?" without reading; only once it says a -/// read is ready do we `fill_buf`, which performs one syscall and leaves the -/// bytes in the shared stdin buffer, so the other SQL sources are unaffected. -/// A hangup without readable data (`POLLHUP` alone) is an empty stdin, not -/// input. The deliberate residual is now narrow: a producer that has not -/// written its first byte within [`STDIN_FIRST_BYTE_TIMEOUT_MS`] still reads -/// as no input, which is the price of never hanging on a pipe nobody is -/// writing to. -fn stdin_input() -> StdinInput { - use std::io::BufRead as _; - - if std::io::stdin().is_terminal() { - return StdinInput::Terminal; - } - - if !fd_becomes_readable(libc::STDIN_FILENO, STDIN_FIRST_BYTE_TIMEOUT_MS) { - return StdinInput::Empty; - } - - match std::io::stdin().lock().fill_buf() { - Ok(buffered) if !buffered.is_empty() => StdinInput::Pending, - _ => StdinInput::Empty, - } -} - -/// Whether a read on `fd` would find something within `timeout_ms`. A hangup -/// with nothing readable (`POLLHUP` alone, a closed pipe) reports `false`, and -/// so does a `poll` that is interrupted or errors: the CLI fails open to "no -/// input" rather than refusing a run it cannot justify refusing. Split out -/// from [`stdin_input`] so the timeout is testable against a real pipe without -/// touching the process's own stdin. -fn fd_becomes_readable(fd: libc::c_int, timeout_ms: libc::c_int) -> bool { - let mut poll_fd = libc::pollfd { - fd, - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: a single initialized `pollfd` is passed with a matching length, - // and the bounded timeout keeps the call from borrowing anything beyond - // its own return. - let ready = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) }; - ready > 0 && poll_fd.revents & libc::POLLIN != 0 -} - -/// Decode SQL at the Query API boundary without replacing invalid bytes. -/// -/// This happens before service lookup, so the native fallback deliberately -/// uses placeholders instead of guessing an endpoint or handling credentials. -fn decode_query_sql(bytes: Vec, source: &str) -> CloudResult { - String::from_utf8(bytes).map_err(|_| { - CloudError::new(format!( - concat!( - "SQL from {} is not valid UTF-8. The Query API accepts only UTF-8 SQL ", - "and text-format data; use the native protocol for binary formats such as ", - "RowBinary or Native, and for bulk input. Install the client with ", - "`clickhousectl local use latest`, then stream the statement and data with:\n ", - "printf 'INSERT INTO FORMAT RowBinary\\n' | cat - | ", - "clickhousectl local client --host --port 9440 -- --secure ", - "--user default --password ''\nFind the native host and port with ", - "`clickhousectl cloud service get `." - ), - source - )) - .with_failure(ApiFailure::new(FailureKind::Io)) - }) -} - -fn read_query_sql(inline: Option<&str>, queries_file: Option<&str>) -> CloudResult { - use std::io::Read as _; - - if let Some(query) = inline { - if query.trim().is_empty() { - return Err(CloudError::new("--query was empty")); - } - if inline_query_conflicts_with_stdin(inline, stdin_input()) { - return Err(CloudError::new(INLINE_QUERY_WITH_STDIN_ERROR)); - } - return Ok(query.to_string()); - } - - if let Some(path) = queries_file { - let (bytes, source) = if path == "-" { - let mut bytes = Vec::new(); - std::io::stdin().read_to_end(&mut bytes)?; - (bytes, "stdin".to_string()) - } else { - (std::fs::read(path)?, format!("queries file '{}'", path)) - }; - let content = decode_query_sql(bytes, &source)?; - if content.trim().is_empty() { - return Err(CloudError::new("queries file was empty")); - } - return Ok(content); - } - - if std::io::stdin().is_terminal() { - return Err(CloudError::new( - "no SQL provided. Pass --query, --queries-file, or pipe SQL on stdin.", - )); - } - - let mut bytes = Vec::new(); - std::io::stdin().read_to_end(&mut bytes)?; - let content = decode_query_sql(bytes, "stdin")?; - if content.trim().is_empty() { - return Err(CloudError::new("no SQL received on stdin")); - } - Ok(content) -} - -fn default_query_format() -> String { - if std::io::stdout().is_terminal() { - "PrettyCompact".to_string() - } else { - "TabSeparated".to_string() - } -} - -async fn private_endpoint_create( - client: &CloudClient, - service_id: &str, - endpoint_id: &str, - description: Option<&str>, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let request = build_private_endpoint_create_request(endpoint_id, description); - let endpoint = client - .create_private_endpoint(&org_id, service_id, &request) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&endpoint)?); - } else { - println!("Private endpoint created for service {}", service_id); - println!(" Endpoint ID: {}", or_absent(endpoint.id.as_deref())); - println!( - " Description: {}", - or_absent(endpoint.description.as_deref()) - ); - } - Ok(()) -} - -async fn private_endpoint_get_config( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let config = client - .get_service_private_endpoint_config(&org_id, service_id) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&config)?); - } else { - print_human(&config)?; - } - Ok(()) -} - -async fn scaling_schedule_get( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let schedule = client.get_scaling_schedule(&org_id, service_id).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&schedule)?); - } else { - print_human(&schedule)?; - } - Ok(()) -} - -async fn scaling_schedule_set( - client: &CloudClient, - service_id: &str, - file: &str, - json: bool, -) -> CloudResult<()> { - let request = build_scaling_schedule_request(read_config_value(file)?, file)?; - let org_id = resolve_org_id(client).await?; - let schedule = client - .set_scaling_schedule(&org_id, service_id, &request) - .await?; - - if json { - println!("{}", serde_json::to_string_pretty(&schedule)?); - } else { - print_human(&schedule)?; - } - Ok(()) -} - -async fn scaling_schedule_delete( - client: &CloudClient, - service_id: &str, - json: bool, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let response = client.delete_scaling_schedule(&org_id, service_id).await?; - - if json { - println!("{}", serde_json::to_string_pretty(&response)?); - } else { - print_line(format!( - "Scaling schedule deleted from service {service_id}" - )); - } - Ok(()) -} - -async fn service_prometheus( - client: &CloudClient, - service_id: &str, - filtered_metrics: Option, -) -> CloudResult<()> { - let org_id = resolve_org_id(client).await?; - let prometheus = client - .get_service_prometheus(&org_id, service_id, filtered_metrics) - .await?; - println!("{}", prometheus); - Ok(()) -} - -impl CloudClient { - /// The Query API host the library would send queries to, when one is - /// configured or derivable from the control-plane URL. - fn configured_query_host(&self) -> Option { - self.api().configured_query_host() - } - - /// Send one statement to a service's Query API with an API key. - /// - /// Unlike every other wrapper, this returns the library error as-is and - /// the response unread: the caller streams the body, and the query paths - /// classify the raw error themselves (idle and stopped services, wake - /// retries, readiness polling, key rejections) before converting it - /// through `convert_query_error`, which records the telemetry failure. - #[allow(clippy::too_many_arguments)] - async fn run_service_query( - &self, - service_id: &str, - key_id: &str, - key_secret: &str, - sql: &str, - database: Option<&str>, - format: &str, - wake_service: bool, - ) -> Result { - self.api() - .run_query( - service_id, - key_id, - key_secret, - sql, - database, - format, - wake_service, - ) - .await - } - - /// [`Self::run_service_query`] with the client's OAuth bearer token, - /// returning the raw library error for the same reason. - async fn run_service_query_bearer( - &self, - service_id: &str, - sql: &str, - database: Option<&str>, - format: &str, - wake_service: bool, - ) -> Result { - self.api() - .run_query_bearer(service_id, sql, database, format, wake_service) - .await - } - - pub async fn get_scaling_schedule( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .scaling_schedule_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn set_scaling_schedule( - &self, - org_id: &str, - service_id: &str, - request: &ScalingSchedulePostRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .scaling_schedule_upsert(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn delete_scaling_schedule( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .scaling_schedule_delete(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Ok(DeleteResponse { - status: response.status, - request_id: response.request_id, - }) - } - - pub async fn list_service_profiles( - &self, - org_id: &str, - region_id: Option<&str>, - byoc_id: Option<&str>, - ) -> crate::cloud::client::Result> { - let response = self - .api() - .service_profiles_list(org_id, region_id, byoc_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn list_services(&self, org_id: &str) -> crate::cloud::client::Result> { - let response = self - .api() - .instance_get_list(org_id, &[]) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn list_clickhouse_settings( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .service_clickhouse_settings_list_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_clickhouse_setting( - &self, - org_id: &str, - service_id: &str, - setting_name: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .service_clickhouse_setting_get(org_id, service_id, setting_name) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_clickhouse_settings_schema( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .service_clickhouse_settings_schema_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn update_clickhouse_settings( - &self, - org_id: &str, - service_id: &str, - request: &ServiceClickhouseSettingsPatchRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .service_clickhouse_settings_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn reset_clickhouse_setting( - &self, - org_id: &str, - service_id: &str, - setting_name: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .service_clickhouse_setting_delete(org_id, service_id, setting_name) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Ok(DeleteResponse { - status: response.status, - request_id: response.request_id, - }) - } - - pub async fn list_services_filtered( - &self, - org_id: &str, - filters: &[String], - ) -> crate::cloud::client::Result> { - let filters: Vec<&str> = filters.iter().map(String::as_str).collect(); - let response = self - .api() - .instance_get_list(org_id, &filters) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_service( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_get(org_id, service_id) - .await - .map_err(|error| { - // This path identifies the service itself, not a subresource. - self.convert_error_for_lookup( - error, - ResourceLookup::in_org(ResourceKind::Service, service_id, org_id), - ) - })?; - Self::unwrap_response(response) - } - - pub async fn get_upgrade_window( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .upgrade_window_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn set_upgrade_window( - &self, - org_id: &str, - service_id: &str, - request: &UpgradeWindowPutRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .upgrade_window_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_replica_aware_routing( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .replica_aware_routing_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn update_replica_aware_routing( - &self, - org_id: &str, - service_id: &str, - request: &ReplicaAwareRoutingPatchRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .replica_aware_routing_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn delete_upgrade_window( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .upgrade_window_delete(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Ok(DeleteResponse { - status: response.status, - request_id: response.request_id, - }) - } - - pub async fn get_service_if_exists( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result> { - let lookup = ResourceLookup::in_org(ResourceKind::Service, service_id, org_id); - match self.api().instance_get(org_id, service_id).await { - Ok(response) => Self::unwrap_response(response).map(Some), - Err(clickhouse_cloud_api::Error::Api { status: 404, .. }) => Ok(None), - // The API answers 400 rather than 404 for a well-formed id no - // service has, and this GET has no other user-controlled input, - // so that rejection is its way of saying the same thing (#666). - // Without this, `service delete --force` aborted on an id - // `service get` reports as missing. - Err(error) if lookup.rejected_well_formed_ids(&error) => Ok(None), - Err(error) => Err(self.convert_error_for_lookup(error, lookup)), - } - } - - pub async fn create_service( - &self, - org_id: &str, - request: &ServicePostRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_create(org_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn delete_service( - &self, - org_id: &str, - service_id: &str, - force: bool, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_delete(org_id, service_id) - .await - .map_err(|error| service_delete_error(self, error, force, service_id, org_id))?; - Ok(DeleteResponse { - status: response.status, - request_id: response.request_id, - }) - } - - pub async fn change_service_state( - &self, - org_id: &str, - service_id: &str, - command: ServiceStatePatchRequestCommand, - ) -> crate::cloud::client::Result { - let request = build_service_state_patch_request(command); - let response = self - .api() - .instance_state_update(org_id, service_id, &request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn update_service( - &self, - org_id: &str, - service_id: &str, - request: &ServicePatchRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn update_replica_scaling( - &self, - org_id: &str, - service_id: &str, - request: &ServiceReplicaScalingPatchRequest, - ) -> crate::cloud::client::Result - { - let response = self - .api() - .instance_replica_scaling_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn reset_password( - &self, - org_id: &str, - service_id: &str, - request: &ServicePasswordPatchRequest, - ) -> crate::cloud::client::Result - { - let response = self - .api() - .instance_password_update(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_query_endpoint( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - match self - .api() - .instance_query_endpoint_get(org_id, service_id) - .await - { - Ok(response) => Self::unwrap_response(response), - Err(error) => { - // A 404 can mean the endpoint is absent on an existing service. - // Only the service lookup can establish that its parent is missing. - let lookup = ResourceLookup::in_org(ResourceKind::Service, service_id, org_id); - if matches!(&error, clickhouse_cloud_api::Error::Api { status: 404, .. }) - && lookup.rejected_well_formed_ids(&error) - { - self.get_service(org_id, service_id).await?; - } - Err(self.convert_error_for_organization(error, org_id)) - } - } - } - - pub async fn create_query_endpoint( - &self, - org_id: &str, - service_id: &str, - request: &InstanceServiceQueryApiEndpointsPostRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_query_endpoint_upsert(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn delete_query_endpoint( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_query_endpoint_delete(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Ok(DeleteResponse { - status: response.status, - request_id: response.request_id, - }) - } - - pub async fn create_private_endpoint( - &self, - org_id: &str, - service_id: &str, - request: &ServicPrivateEndpointePostRequest, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_private_endpoint_create(org_id, service_id, request) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_service_private_endpoint_config( - &self, - org_id: &str, - service_id: &str, - ) -> crate::cloud::client::Result { - let response = self - .api() - .instance_private_endpoint_config_get(org_id, service_id) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id))?; - Self::unwrap_response(response) - } - - pub async fn get_service_prometheus( - &self, - org_id: &str, - service_id: &str, - filtered_metrics: Option, - ) -> crate::cloud::client::Result { - let filtered = filtered_metrics.map(|value| value.to_string()); - self.api() - .instance_prometheus_get(org_id, service_id, filtered.as_deref()) - .await - .map_err(|error| self.convert_error_for_organization(error, org_id)) - } -} - -#[cfg(test)] -mod tests { - #[test] - fn primary_json_file_argument_contract() { - crate::cloud::config::assert_primary_json_input( - &["cloud", "service", "settings", "set", "svc-1"], - "settings_file", - &["settings-file"], - ); - crate::cloud::config::assert_primary_json_input( - &["cloud", "service", "scaling-schedule", "set", "svc-1"], - "file", - &[], - ); - } - - use super::*; - use crate::cli::{Cli, Commands}; - use crate::cloud::cli::CloudCommands; - use clap::Parser; - - fn parse_service(args: &[&str]) -> crate::cloud::cli::ServiceCommands { - let cli = Cli::try_parse_from(args).expect("parse"); - let Commands::Cloud(cloud_args) = cli.command else { - panic!("expected cloud command"); - }; - crate::cloud::cli::tests::assert_org_selector(&cloud_args, args); - let CloudCommands::Service { command } = cloud_args.command else { - panic!("expected service command"); - }; - command - } - - fn assert_write(args: &[&str], expected: bool) { - let cli = Cli::try_parse_from(args).expect("parse"); - let Commands::Cloud(cloud_args) = cli.command else { - panic!("expected cloud command"); - }; - assert!(matches!(&cloud_args.command, CloudCommands::Service { .. })); - assert_eq!( - cloud_args.command.is_write_command(), - expected, - "wrong classification for: {}", - args.join(" ") - ); - } - - /// The `CONTEXT FOR AGENTS:` block on `service query` stays inside its - /// 8-line budget, whatever the lines say. - #[test] - fn service_query_agent_context_block_stays_within_its_line_budget() { - let error = Cli::try_parse_from(["clickhousectl", "cloud", "service", "query", "--help"]) - .err() - .expect("--help should stop parsing"); - assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp); - let help = error.to_string(); - - let block = help - .split_once("CONTEXT FOR AGENTS:\n") - .expect("after_help block") - .1; - let lines = block.lines().filter(|line| !line.trim().is_empty()).count(); - assert!(lines <= 8, "{lines} lines: {help}"); - } - - #[test] - fn repair_query_key_has_a_positional_id_selector() { - use clap::CommandFactory; - let mut root = Cli::command(); - root.build(); - let command = root - .find_subcommand("cloud") - .unwrap() - .find_subcommand("service") - .unwrap() - .find_subcommand("repair-query-key") - .unwrap(); - let id = command - .get_arguments() - .find(|arg| arg.get_id() == "resource_id") - .unwrap(); - assert!(id.is_positional()); - assert_eq!(id.get_value_names().unwrap(), &["ID"]); - } - - #[test] - fn parses_service_create_defaults() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "default-service", - ]); - let crate::cloud::cli::ServiceCommands::Create { - name, - provider, - region, - min_replica_memory_gb, - max_replica_memory_gb, - num_replicas, - min_replicas, - max_replicas, - autoscaling_mode, - idle_scaling, - idle_timeout_minutes, - ip_allow, - backup_id, - backup_encryption_config, - release_channel, - data_warehouse_id, - readonly, - enable_tde, - compliance_type, - profile, - byoc_id, - tag, - enable_endpoint, - disable_endpoint, - private_preview_terms_checked, - enable_core_dumps, - } = command - else { - panic!("expected service create"); - }; - - assert_eq!(name, "default-service"); - assert_eq!(provider, "aws"); - assert_eq!(region, "us-east-1"); - assert!(min_replica_memory_gb.is_none()); - assert!(max_replica_memory_gb.is_none()); - assert!(num_replicas.is_none()); - assert!(min_replicas.is_none()); - assert!(max_replicas.is_none()); - assert!(autoscaling_mode.is_none()); - assert!(idle_scaling.is_none()); - assert!(idle_timeout_minutes.is_none()); - assert!(ip_allow.is_empty()); - assert!(backup_id.is_none()); - assert!(backup_encryption_config.is_none()); - assert!(release_channel.is_none()); - assert!(data_warehouse_id.is_none()); - assert!(!readonly); - assert!(!enable_tde); - assert!(compliance_type.is_none()); - assert!(profile.is_none()); - assert!(byoc_id.is_none()); - assert!(tag.is_empty()); - assert!(enable_endpoint.is_empty()); - assert!(disable_endpoint.is_empty()); - assert!(!private_preview_terms_checked); - assert!(enable_core_dumps.is_none()); - } - - #[test] - fn parses_service_create_maximal_vertical_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "maximal-service", - "--provider", - "azure", - "--region", - "eastus", - "--min-replica-memory-gb", - "16", - "--max-replica-memory-gb", - "32", - "--num-replicas", - "3", - "--autoscaling-mode", - "vertical", - "--idle-scaling", - "false", - "--idle-timeout-minutes", - "10", - "--ip-allow", - "10.0.0.0/8=office", - "--ip-allow", - "2001:db8::/32=\u{6771}\u{4eac}", - "--backup-id", - "backup-1", - "--backup-encryption-config", - "-", - "--release-channel", - "fast", - "--data-warehouse-id", - "dw-1", - "--readonly", - "--enable-tde", - "--compliance-type", - "pci", - "--profile", - "v1-highmem-xs", - "--byoc-id", - "byoc-1", - "--tag", - "env=prod", - "--tag", - "team=analytics", - "--enable-endpoint", - "mysql", - "--disable-endpoint", - "mysql", - "--private-preview-terms-checked", - "--enable-core-dumps", - "false", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::Create { - name, - provider, - region, - min_replica_memory_gb, - max_replica_memory_gb, - num_replicas, - autoscaling_mode, - idle_scaling, - idle_timeout_minutes, - ip_allow, - backup_id, - backup_encryption_config, - release_channel, - data_warehouse_id, - readonly, - enable_tde, - compliance_type, - profile, - byoc_id, - tag, - enable_endpoint, - disable_endpoint, - private_preview_terms_checked, - enable_core_dumps, - .. - } = command - else { - panic!("expected service create"); - }; - - assert_eq!(name, "maximal-service"); - assert_eq!(provider, "azure"); - assert_eq!(region, "eastus"); - assert_eq!(min_replica_memory_gb, Some(16)); - assert_eq!(max_replica_memory_gb, Some(32)); - assert_eq!(num_replicas, Some(3)); - assert_eq!(autoscaling_mode.as_deref(), Some("vertical")); - assert_eq!(idle_scaling, Some(false)); - assert_eq!(idle_timeout_minutes, Some(10)); - assert_eq!( - ip_allow, - vec!["10.0.0.0/8=office", "2001:db8::/32=\u{6771}\u{4eac}"] - ); - assert_eq!(backup_id.as_deref(), Some("backup-1")); - assert_eq!(backup_encryption_config.as_deref(), Some("-")); - assert_eq!(release_channel.as_deref(), Some("fast")); - assert_eq!(data_warehouse_id.as_deref(), Some("dw-1")); - assert!(readonly); - assert!(enable_tde); - assert_eq!(compliance_type.as_deref(), Some("pci")); - assert_eq!(profile.as_deref(), Some("v1-highmem-xs")); - assert_eq!(byoc_id.as_deref(), Some("byoc-1")); - assert_eq!(tag, vec!["env=prod", "team=analytics"]); - assert_eq!(enable_endpoint, vec!["mysql"]); - assert_eq!(disable_endpoint, vec!["mysql"]); - assert!(private_preview_terms_checked); - assert_eq!(enable_core_dumps, Some(false)); - } - - #[test] - fn parses_service_create_dynamic_byoc_profile_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "byoc-service", - "--profile", - "v1-standard-byoc-4", - "--byoc-id", - "byoc-1", - "--min-replica-memory-gb", - "48", - "--max-replica-memory-gb", - "48", - ]); - let ServiceCommands::Create { - profile, - byoc_id, - min_replica_memory_gb, - max_replica_memory_gb, - .. - } = command - else { - panic!("expected service create"); - }; - assert_eq!(profile.as_deref(), Some("v1-standard-byoc-4")); - assert_eq!(byoc_id.as_deref(), Some("byoc-1")); - assert_eq!(min_replica_memory_gb, Some(48)); - assert_eq!(max_replica_memory_gb, Some(48)); - } - - #[test] - fn parses_service_list_filters_and_org_id() { - let command = parse_service(&["clickhousectl", "cloud", "service", "list"]); - let crate::cloud::cli::ServiceCommands::List { filter } = command else { - panic!("expected service list"); - }; - - assert!(filter.is_empty()); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "list", - "--filter", - "tag:env=prod", - "--filter", - "tag:team=analytics", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::List { filter } = command else { - panic!("expected service list"); - }; - - assert_eq!(filter, vec!["tag:env=prod", "tag:team=analytics"]); - } - - #[test] - fn service_list_tag_filters_preserve_api_grammar() { - for value in ["tag:env=prod", "tag:active", "tag:empty=", "tag:expr=a=b"] { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "list", - "--filter", - value, - ]); - let ServiceCommands::List { filter, .. } = command else { - panic!("expected service list"); - }; - assert_eq!(filter, [value]); - } - for value in [ - "garbage", - "state=running", - "env=prod", - "tag:", - "tag:=x", - "tag: =x", - ] { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "list", - "--filter", - value, - ]) - .err() - .expect("malformed filter must fail"); - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); - } - } - - #[test] - fn parses_service_profile_list_region_or_byoc_flags() { - for (flags, expected_region, expected_byoc) in [ - (vec!["--region", "eu-west-1"], Some("eu-west-1"), None), - (vec!["--byoc-id", "byoc-1"], None, Some("byoc-1")), - ( - vec!["--region", "eu-west-1", "--byoc-id", "byoc-1"], - Some("eu-west-1"), - Some("byoc-1"), - ), - ] { - let mut args = vec!["clickhousectl", "cloud", "service", "profile", "list"]; - args.extend(flags); - let command = parse_service(&args); - assert!(!command.is_write()); - let ServiceCommands::Profile { - command: ServiceProfileCommands::List { region, byoc_id }, - } = command - else { - panic!("expected service profile list"); - }; - assert_eq!(region.as_deref(), expected_region); - assert_eq!(byoc_id.as_deref(), expected_byoc); - } - - let error = Cli::try_parse_from(["clickhousectl", "cloud", "service", "profile", "list"]) - .err() - .expect("missing both region and BYOC should fail"); - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn parses_scaling_schedule_commands_and_classifies_writes() { - let get = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "scaling-schedule", - "get", - "svc-1", - ]); - let ServiceCommands::ScalingSchedule { - command: ScalingScheduleCommands::Get { service_id }, - } = get - else { - panic!("expected scaling schedule get"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - - let set = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "scaling-schedule", - "set", - "svc-1", - "--file", - "-", - "--org-id", - "org-1", - ]); - let ServiceCommands::ScalingSchedule { - command: ScalingScheduleCommands::Set { service_id, file }, - } = set - else { - panic!("expected scaling schedule set"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(file, "-"); - - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "scaling-schedule", - "get", - "svc-1", - ], - false, - ); - for leaf in ["set", "delete"] { - let mut args = vec![ - "clickhousectl", - "cloud", - "service", - "scaling-schedule", - leaf, - "svc-1", - ]; - if leaf == "set" { - args.extend(["--file", "schedule.json"]); - } - assert_write(&args, true); - } - } - - #[test] - fn scaling_schedule_set_requires_file() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "scaling-schedule", - "set", - "svc-1", - ]) - .err() - .expect("missing --file should fail"); - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn parses_upgrade_window_commands_and_classifies_writes() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "set", - "svc-1", - "--weekday", - "3", - "--start-hour", - "12", - "--org-id", - "org-1", - ]); - let ServiceCommands::UpgradeWindow { - command: - UpgradeWindowCommands::Set { - service_id, - weekday, - start_hour, - }, - } = command - else { - panic!("expected upgrade-window set"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(weekday, 3); - assert_eq!(start_hour, "12"); - - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "get", - "svc-1", - ], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "set", - "svc-1", - "--weekday", - "0", - "--start-hour", - "0", - ], - true, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "delete", - "svc-1", - ], - true, - ); - } - - #[test] - fn parses_replica_aware_routing_commands_and_classifies_writes() { - let base = ["clickhousectl", "cloud", "service", "replica-aware-routing"]; - let parse_update = |flags: &[&str]| { - let mut args = base.to_vec(); - args.extend(["update", "svc-1"]); - args.extend(flags); - match parse_service(&args) { - ServiceCommands::ReplicaAwareRouting { - command: - ReplicaAwareRoutingCommands::Update { - service_id, - http_enabled, - native_enabled, - }, - } => { - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - (http_enabled, native_enabled) - } - _ => panic!("expected replica-aware-routing update"), - } - }; - assert_eq!( - parse_update(&["--http-enabled", "true"]), - (Some(true), None) - ); - assert_eq!( - parse_update(&["--native-enabled", "false"]), - (None, Some(false)) - ); - assert_eq!( - parse_update(&["--http-enabled", "false", "--native-enabled", "true"]), - (Some(false), Some(true)) - ); - - let ServiceCommands::ReplicaAwareRouting { - command: ReplicaAwareRoutingCommands::Get { service_id }, - } = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "replica-aware-routing", - "get", - "--name", - "analytics", - ]) - else { - panic!("expected replica-aware-routing get"); - }; - assert_eq!(service_id.name.as_deref(), Some("analytics")); - - let mut get = base.to_vec(); - get.extend(["get", "svc-1"]); - assert_write(&get, false); - let mut update = base.to_vec(); - update.extend(["update", "svc-1", "--native-enabled", "true"]); - assert_write(&update, true); - } - - #[test] - fn replica_aware_routing_update_requires_a_change_and_boolean_values() { - let base = [ - "clickhousectl", - "cloud", - "service", - "replica-aware-routing", - "update", - "svc-1", - ]; - let error = Cli::try_parse_from(base) - .err() - .expect("no change should fail"); - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - for flag in ["--http-enabled", "--native-enabled"] { - let mut args = base.to_vec(); - args.extend([flag, "yes"]); - let error = Cli::try_parse_from(args) - .err() - .expect("non-bool should fail"); - assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue); - let mut args = base.to_vec(); - args.push(flag); - assert!(Cli::try_parse_from(args).is_err(), "{flag} needs a value"); - } - } - - #[test] - fn build_replica_aware_routing_request_supports_minimal_and_maximal_values() { - let minimal = build_replica_aware_routing_request(None, Some(false)); - assert_eq!(minimal.http_enabled, None); - assert_eq!(minimal.native_enabled, Some(false)); - assert_eq!( - serde_json::to_value(&minimal).unwrap(), - serde_json::json!({"nativeEnabled": false}) - ); - - let maximal = build_replica_aware_routing_request(Some(true), Some(true)); - assert_eq!(maximal.http_enabled, Some(true)); - assert_eq!(maximal.native_enabled, Some(true)); - } - - #[test] - fn upgrade_window_set_rejects_missing_and_out_of_range_values() { - for args in [ - vec![ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "set", - "svc-1", - "--start-hour", - "6", - ], - vec![ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "set", - "svc-1", - "--weekday", - "2", - ], - ] { - let error = Cli::try_parse_from(args).err().expect("should fail"); - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - } - - for (flag, value) in [ - ("--weekday", "7"), - ("--weekday", "-1"), - ("--start-hour", "3"), - ] { - let mut args = vec![ - "clickhousectl", - "cloud", - "service", - "upgrade-window", - "set", - "svc-1", - "--weekday", - "2", - "--start-hour", - "6", - ]; - let index = args.iter().position(|arg| *arg == flag).unwrap() + 1; - args[index] = value; - let error = Cli::try_parse_from(args).err().expect("should fail"); - assert!(matches!( - error.kind(), - clap::error::ErrorKind::ValueValidation | clap::error::ErrorKind::InvalidValue - )); - } - } - - #[test] - fn build_upgrade_window_request_supports_minimal_and_maximal_values() { - let earliest = build_upgrade_window_request(0, "0").unwrap(); - assert_eq!(earliest.weekday, 0); - assert_eq!(earliest.start_hour_utc, UpgradeWindowStartHourUtc::Hour0); - - let latest = build_upgrade_window_request(6, "18").unwrap(); - assert_eq!(latest.weekday, 6); - assert_eq!(latest.start_hour_utc, UpgradeWindowStartHourUtc::Hour18); - } - - #[test] - fn upgrade_window_human_view_names_every_documented_weekday() { - let expected = [ - "Sunday (0)", - "Monday (1)", - "Tuesday (2)", - "Wednesday (3)", - "Thursday (4)", - "Friday (5)", - "Saturday (6)", - ]; - - for (weekday, expected) in expected.into_iter().enumerate() { - let view = upgrade_window_human_view(&UpgradeWindow { - duration: None, - start_hour_utc: None, - weekday: Some(weekday as i64), - }); - assert_eq!(view.weekday, expected); - } - } - - #[test] - fn upgrade_window_human_view_labels_duration_and_preserves_sparse_unknowns() { - use clickhouse_cloud_api::models::UpgradeWindowDuration; - - let known = upgrade_window_human_view(&UpgradeWindow { - duration: Some(UpgradeWindowDuration::SixHours), - start_hour_utc: Some(UpgradeWindowStartHourUtc::Hour12), - weekday: Some(3), - }); - assert_eq!( - serde_json::to_value(known).unwrap(), - serde_json::json!({ - "duration": "6 hours", - "startHourUtc": "12", - "weekday": "Wednesday (3)" - }) - ); - - let sparse_unknown = upgrade_window_human_view(&UpgradeWindow { - duration: Some(UpgradeWindowDuration::Unknown(1)), - start_hour_utc: None, - weekday: Some(9), - }); - assert_eq!( - serde_json::to_value(sparse_unknown).unwrap(), - serde_json::json!({ - "duration": "1 hour", - "startHourUtc": "-", - "weekday": "9" - }) - ); - } - - #[test] - fn parses_service_delete_force_and_org_id() { - let command = parse_service(&["clickhousectl", "cloud", "service", "delete", "svc-1"]); - let crate::cloud::cli::ServiceCommands::Delete { force, .. } = command else { - panic!("expected service delete"); - }; - assert!(!force); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "delete", - "svc-1", - "--force", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::Delete { service_id, force } = command else { - panic!("expected service delete"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert!(force); - } - - #[test] - fn parses_service_body_command_defaults() { - let update = parse_service(&["clickhousectl", "cloud", "service", "update", "svc-1"]); - let crate::cloud::cli::ServiceCommands::Update { - name, - add_ip_allow, - remove_ip_allow, - add_private_endpoint_id, - remove_private_endpoint_id, - release_channel, - enable_endpoint, - disable_endpoint, - transparent_data_encryption_key_id, - add_tag, - remove_tag, - enable_core_dumps, - .. - } = update - else { - panic!("expected service update"); - }; - assert!(name.is_none()); - assert!(add_ip_allow.is_empty()); - assert!(remove_ip_allow.is_empty()); - assert!(add_private_endpoint_id.is_empty()); - assert!(remove_private_endpoint_id.is_empty()); - assert!(release_channel.is_none()); - assert!(enable_endpoint.is_empty()); - assert!(disable_endpoint.is_empty()); - assert!(transparent_data_encryption_key_id.is_none()); - assert!(add_tag.is_empty()); - assert!(remove_tag.is_empty()); - assert!(enable_core_dumps.is_none()); - - let scale = parse_service(&["clickhousectl", "cloud", "service", "scale", "svc-1"]); - let crate::cloud::cli::ServiceCommands::Scale { - min_replica_memory_gb, - max_replica_memory_gb, - num_replicas, - min_replicas, - max_replicas, - autoscaling_mode, - idle_scaling, - idle_timeout_minutes, - .. - } = scale - else { - panic!("expected service scale"); - }; - assert!(min_replica_memory_gb.is_none()); - assert!(max_replica_memory_gb.is_none()); - assert!(num_replicas.is_none()); - assert!(min_replicas.is_none()); - assert!(max_replicas.is_none()); - assert!(autoscaling_mode.is_none()); - assert!(idle_scaling.is_none()); - assert!(idle_timeout_minutes.is_none()); - - let reset = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "reset-password", - "svc-1", - ]); - let crate::cloud::cli::ServiceCommands::ResetPassword { - new_password_hash, - new_double_sha1_hash, - .. - } = reset - else { - panic!("expected service reset-password"); - }; - assert!(new_password_hash.is_none()); - assert!(new_double_sha1_hash.is_none()); - - let query_endpoint = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - "create", - "svc-1", - "--role", - "sql_console_read_only", - ]); - let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = query_endpoint else { - panic!("expected query-endpoint command"); - }; - let crate::cloud::cli::QueryEndpointCommands::Create { - role, - open_api_key, - allowed_origins, - replace_open_api_keys, - .. - } = command - else { - panic!("expected query-endpoint create"); - }; - assert_eq!(role, ["sql_console_read_only"]); - assert!(!replace_open_api_keys); - assert!(open_api_key.is_empty()); - assert!(allowed_origins.is_none()); - - let private_endpoint = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "create", - "svc-1", - "--endpoint-id", - "vpce-0123456789abcdef0", - ]); - let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = private_endpoint - else { - panic!("expected private-endpoint command"); - }; - let crate::cloud::cli::PrivateEndpointCommands::Create { description, .. } = command else { - panic!("expected private-endpoint create"); - }; - assert!(description.is_none()); - } - - #[test] - fn parses_service_query_json_mode() { - let cli = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - "--query", - "SELECT 1", - "--json", - ]) - .unwrap(); - let Commands::Cloud(args) = cli.command else { - panic!("expected cloud command"); - }; - assert!(args.json); - let CloudCommands::Service { command } = args.command else { - panic!("expected service command"); - }; - let ServiceCommands::Query { format, .. } = command else { - panic!("expected service query"); - }; - assert!(format.is_none()); - } - - #[test] - fn rejects_service_query_json_with_explicit_format() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - "--query", - "SELECT 1", - "--json", - "--format", - "CSV", - ]) - .err() - .expect("--json and --format should conflict"); - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); - assert!(error.to_string().contains("--json")); - assert!(error.to_string().contains("--format")); - } - - #[test] - fn parses_service_query_explicit_format_without_json() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - "--query", - "SELECT 1", - "--format", - "CSV", - ]); - let ServiceCommands::Query { format, .. } = command else { - panic!("expected service query"); - }; - assert_eq!(format.as_deref(), Some("CSV")); - } - - #[test] - fn parses_service_update_ga_patch_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - "--add-ip-allow", - "2001:db8::/32=office", - "--remove-ip-allow", - "0.0.0.0/0", - "--add-private-endpoint-id", - "pe-1", - "--remove-private-endpoint-id", - "pe-2", - "--release-channel", - "fast", - "--enable-endpoint", - "mysql", - "--add-tag", - "env=prod", - "--enable-core-dumps", - "true", - ]); - let ServiceCommands::Update { - service_id, - add_ip_allow, - remove_ip_allow, - add_private_endpoint_id, - remove_private_endpoint_id, - release_channel, - enable_endpoint, - add_tag, - enable_core_dumps, - .. - } = command - else { - panic!("expected service update"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(add_ip_allow, vec!["2001:db8::/32=office"]); - assert_eq!(remove_ip_allow, vec!["0.0.0.0/0"]); - assert_eq!(add_private_endpoint_id, vec!["pe-1"]); - assert_eq!(remove_private_endpoint_id, vec!["pe-2"]); - assert_eq!(release_channel.as_deref(), Some("fast")); - assert_eq!(enable_endpoint, vec!["mysql"]); - assert_eq!(add_tag, vec!["env=prod"]); - assert_eq!(enable_core_dumps, Some(true)); - } - - #[test] - fn parses_service_update_maximal_and_repeatable_add_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - "--new-name", - "renamed", - "--add-ip-allow", - "10.0.0.0/8=office", - "--add-ip-allow", - "2001:db8::/32=\u{6771}\u{4eac}", - "--remove-ip-allow", - "0.0.0.0/0", - "--add-private-endpoint-id", - "pe-1", - "--add-private-endpoint-id", - "pe-2", - "--remove-private-endpoint-id", - "pe-3", - "--release-channel", - "slow", - "--enable-endpoint", - "mysql", - "--disable-endpoint", - "mysql", - "--transparent-data-encryption-key-id", - "tde-1", - "--add-tag", - "env=prod", - "--add-tag", - "team=analytics", - "--enable-core-dumps", - "false", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::Update { - service_id, - name, - add_ip_allow, - remove_ip_allow, - add_private_endpoint_id, - remove_private_endpoint_id, - release_channel, - enable_endpoint, - disable_endpoint, - transparent_data_encryption_key_id, - add_tag, - remove_tag, - enable_core_dumps, - } = command - else { - panic!("expected service update"); - }; - - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(name.as_deref(), Some("renamed")); - assert_eq!( - add_ip_allow, - vec!["10.0.0.0/8=office", "2001:db8::/32=\u{6771}\u{4eac}"] - ); - assert_eq!(remove_ip_allow, vec!["0.0.0.0/0"]); - assert_eq!(add_private_endpoint_id, vec!["pe-1", "pe-2"]); - assert_eq!(remove_private_endpoint_id, vec!["pe-3"]); - assert_eq!(release_channel.as_deref(), Some("slow")); - assert_eq!(enable_endpoint, vec!["mysql"]); - assert_eq!(disable_endpoint, vec!["mysql"]); - assert_eq!(transparent_data_encryption_key_id.as_deref(), Some("tde-1")); - assert_eq!(add_tag, vec!["env=prod", "team=analytics"]); - assert!(remove_tag.is_empty()); - assert_eq!(enable_core_dumps, Some(false)); - } - - #[test] - fn parses_service_update_repeatable_tag_removals_with_other_removals() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - "--remove-tag", - "legacy", - "--remove-tag", - "owner=former-team", - "--remove-ip-allow", - "10.0.0.0/8", - "--remove-private-endpoint-id", - "pe-1", - ]); - let ServiceCommands::Update { - add_tag, - remove_tag, - remove_ip_allow, - remove_private_endpoint_id, - .. - } = command - else { - panic!("expected service update"); - }; - - assert!(add_tag.is_empty()); - assert_eq!(remove_tag, vec!["legacy", "owner=former-team"]); - assert_eq!(remove_ip_allow, vec!["10.0.0.0/8"]); - assert_eq!(remove_private_endpoint_id, vec!["pe-1"]); - } - - #[test] - fn rejects_combining_service_update_tag_additions_and_removals() { - for (first_flag, first_value, second_flag, second_value) in [ - ("--add-tag", "env=prod", "--remove-tag", "env=prod"), - ("--remove-tag", "env=prod", "--add-tag", "env=prod"), - ("--add-tag", "env=prod", "--remove-tag", "env=staging"), - ("--remove-tag", "env=staging", "--add-tag", "env=prod"), - ("--add-tag", "env=prod", "--remove-tag", "team=analytics"), - ("--remove-tag", "team=analytics", "--add-tag", "env=prod"), - ("--add-tag", "env", "--remove-tag", "team"), - ("--remove-tag", "team", "--add-tag", "env"), - ] { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - first_flag, - first_value, - second_flag, - second_value, - ]) - .err() - .expect("mixed tag operations must conflict"); - - assert_eq!( - error.kind(), - clap::error::ErrorKind::ArgumentConflict, - "{first_flag} {first_value} followed by {second_flag} {second_value}" - ); - } - } - - #[test] - fn parses_service_create_horizontal_autoscaling_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "s", - "--min-replicas", - "2", - "--max-replicas", - "8", - "--autoscaling-mode", - "horizontal", - ]); - let ServiceCommands::Create { - min_replicas, - max_replicas, - autoscaling_mode, - num_replicas, - min_replica_memory_gb, - max_replica_memory_gb, - .. - } = command - else { - panic!("expected service create"); - }; - assert_eq!(min_replicas, Some(2)); - assert_eq!(max_replicas, Some(8)); - assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); - assert!(num_replicas.is_none()); - assert!(min_replica_memory_gb.is_none()); - assert!(max_replica_memory_gb.is_none()); - } - - #[test] - fn rejects_service_create_horizontal_vertical_mix() { - assert!( - Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "s", - "--min-replicas", - "2", - "--max-replicas", - "8", - "--num-replicas", - "3", - ]) - .is_err() - ); - } - - #[test] - fn parses_service_create_horizontal_mode_with_memory_bounds() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "s", - "--autoscaling-mode", - "horizontal", - "--min-replicas", - "2", - "--max-replicas", - "8", - "--min-replica-memory-gb", - "16", - "--max-replica-memory-gb", - "16", - ]); - let ServiceCommands::Create { - min_replicas, - max_replicas, - autoscaling_mode, - min_replica_memory_gb, - max_replica_memory_gb, - .. - } = command - else { - panic!("expected service create"); - }; - assert_eq!(min_replicas, Some(2)); - assert_eq!(max_replicas, Some(8)); - assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); - assert_eq!(min_replica_memory_gb, Some(16)); - assert_eq!(max_replica_memory_gb, Some(16)); - } - - #[test] - fn rejects_service_create_invalid_autoscaling_mode() { - assert!( - Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "s", - "--min-replicas", - "2", - "--max-replicas", - "8", - "--autoscaling-mode", - "turbo", - ]) - .is_err() - ); - } - - #[test] - fn parses_service_scale_horizontal_autoscaling_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "scale", - "svc-1", - "--min-replicas", - "2", - "--max-replicas", - "8", - "--autoscaling-mode", - "horizontal", - ]); - let ServiceCommands::Scale { - service_id, - min_replicas, - max_replicas, - autoscaling_mode, - num_replicas, - .. - } = command - else { - panic!("expected service scale"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(min_replicas, Some(2)); - assert_eq!(max_replicas, Some(8)); - assert_eq!(autoscaling_mode.as_deref(), Some("horizontal")); - assert!(num_replicas.is_none()); - } - - #[test] - fn parses_service_scale_switch_to_vertical_in_one_call() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "scale", - "svc-1", - "--autoscaling-mode", - "vertical", - "--num-replicas", - "3", - "--min-replica-memory-gb", - "8", - "--max-replica-memory-gb", - "32", - "--idle-scaling", - "false", - "--idle-timeout-minutes", - "15", - "--org-id", - "org-1", - ]); - let ServiceCommands::Scale { - autoscaling_mode, - num_replicas, - min_replica_memory_gb, - max_replica_memory_gb, - min_replicas, - max_replicas, - idle_scaling, - idle_timeout_minutes, - .. - } = command - else { - panic!("expected service scale"); - }; - assert_eq!(autoscaling_mode.as_deref(), Some("vertical")); - assert_eq!(num_replicas, Some(3)); - assert_eq!(min_replica_memory_gb, Some(8)); - assert_eq!(max_replica_memory_gb, Some(32)); - assert!(min_replicas.is_none()); - assert!(max_replicas.is_none()); - assert_eq!(idle_scaling, Some(false)); - assert_eq!(idle_timeout_minutes, Some(15)); - } - - #[test] - fn rejects_service_scale_num_replicas_with_replica_band() { - assert!( - Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "scale", - "svc-1", - "--num-replicas", - "3", - "--min-replicas", - "2", - "--max-replicas", - "8", - ]) - .is_err() - ); - } - - #[test] - fn parses_private_endpoint_config_and_password_hash_flags() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "reset-password", - "svc-1", - "--new-password-hash", - "sha256", - "--new-double-sha1-hash", - "sha1", - "--org-id", - "org-1", - ]); - let ServiceCommands::ResetPassword { - new_password_hash, - new_double_sha1_hash, - .. - } = command - else { - panic!("expected reset-password"); - }; - assert_eq!(new_password_hash.as_deref(), Some("sha256")); - assert_eq!(new_double_sha1_hash.as_deref(), Some("sha1")); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "get-config", - "svc-1", - "--org-id", - "org-1", - ]); - let ServiceCommands::PrivateEndpoint { command } = command else { - panic!("expected private-endpoint command"); - }; - let PrivateEndpointCommands::GetConfig { service_id } = command else { - panic!("expected get-config"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - } - - #[test] - fn parses_service_query_options() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - ]); - let crate::cloud::cli::ServiceCommands::Query { - name, - id, - query, - queries_file, - database, - format, - no_auto_enable, - .. - } = command - else { - panic!("expected service query"); - }; - assert!(name.is_none()); - assert_eq!(id.as_deref(), Some("svc-1")); - assert!(query.is_none()); - assert!(queries_file.is_none()); - assert!(database.is_none()); - assert!(format.is_none()); - - assert!(!no_auto_enable); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query", - "--name", - "analytics", - "--query", - "SELECT 1", - "--database", - "default", - "--format", - "CSV", - "--org-id", - "org-1", - "--no-auto-enable", - ]); - let crate::cloud::cli::ServiceCommands::Query { - name, - id, - query, - queries_file, - database, - format, - no_auto_enable, - .. - } = command - else { - panic!("expected service query"); - }; - - assert_eq!(name.as_deref(), Some("analytics")); - assert!(id.is_none()); - assert_eq!(query.as_deref(), Some("SELECT 1")); - assert!(queries_file.is_none()); - assert_eq!(database.as_deref(), Some("default")); - assert_eq!(format.as_deref(), Some("CSV")); - - assert!(no_auto_enable); - } - - #[test] - fn parses_service_query_key_repair_scope() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "repair-query-key", - "svc-1", - "--org-id", - "org-1", - ]); - let ServiceCommands::RepairQueryKey { service_id } = command else { - panic!("expected query-key repair"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - - let ServiceCommands::RepairQueryKey { .. } = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "repair-query-key", - "svc-1", - ]) else { - panic!("expected query-key repair"); - }; - } - - #[test] - fn only_a_401_or_403_counts_as_a_stored_query_key_rejection() { - for status in [401, 403] { - let error = clickhouse_cloud_api::Error::Api { - status, - message: "rejected".into(), - }; - assert_eq!(stored_query_key_rejection_status(&error), Some(status)); - } - assert_eq!( - stored_query_key_rejection_status(&clickhouse_cloud_api::Error::Api { - status: 404, - message: "missing".into(), - }), - None - ); - } - - #[test] - fn parses_service_query_file_source() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query", - "--name", - "analytics", - "--queries-file", - "queries.sql", - ]); - let ServiceCommands::Query { - query, - queries_file, - .. - } = command - else { - panic!("expected service query"); - }; - - assert!(query.is_none()); - assert_eq!(queries_file.as_deref(), Some("queries.sql")); - } - - #[test] - fn rejects_service_query_with_inline_and_file_sources() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - "--query", - "SELECT 1", - "--queries-file", - "queries.sql", - ]) - .err() - .expect("service query with conflicting SQL sources should fail"); - - assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); - let message = error.to_string(); - assert!(message.contains("--query "), "{message}"); - assert!( - message.contains("--queries-file "), - "{message}" - ); - } - - #[test] - fn rejects_service_query_without_name_or_id() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query", - "--query", - "SELECT 1", - ]) - .err() - .expect("service query without a selector should fail"); - - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - let message = error.to_string(); - assert!(message.contains("--name "), "{message}"); - assert!(message.contains("--id "), "{message}"); - } - - #[test] - fn rejects_service_query_name_with_id() { - let result = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query", - "--name", - "analytics", - "--id", - "svc-1", - "--query", - "SELECT 1", - ]); - assert!(result.is_err()); - } - - #[test] - fn parses_query_endpoint_arguments() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - "create", - "svc-1", - "--role", - "sql_console_read_only", - "--role", - "sql_console_admin", - "--open-api-key", - "key-1", - "--open-api-key", - "key-2", - "--replace-open-api-keys", - "--allowed-origins", - "https://example.com", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = command else { - panic!("expected query-endpoint command"); - }; - let crate::cloud::cli::QueryEndpointCommands::Create { - service_id, - role, - open_api_key, - allowed_origins, - replace_open_api_keys, - } = command - else { - panic!("expected query-endpoint create"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert!(replace_open_api_keys); - assert_eq!(role, vec!["sql_console_read_only", "sql_console_admin"]); - assert_eq!(open_api_key, vec!["key-1", "key-2"]); - assert_eq!(allowed_origins.as_deref(), Some("https://example.com")); - - for action in ["get", "delete"] { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - action, - "svc-1", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::QueryEndpoint { command } = command else { - panic!("expected query-endpoint command"); - }; - match command { - crate::cloud::cli::QueryEndpointCommands::Get { service_id } - | crate::cloud::cli::QueryEndpointCommands::Delete { service_id } => { - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - } - crate::cloud::cli::QueryEndpointCommands::Create { .. } => { - panic!("expected query-endpoint {action}") - } - } - } - } - - #[test] - fn query_endpoint_create_requires_roles_and_nonempty_explicit_values() { - let prefix = [ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - "create", - "svc-1", - ]; - let missing_role = Cli::try_parse_from(prefix).err().unwrap(); - assert_eq!( - missing_role.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - for (flags, expected) in [ - ( - vec!["--role", "sql_console_read_only", "--replace-open-api-keys"], - clap::error::ErrorKind::MissingRequiredArgument, - ), - ( - vec!["--role", "sql_console_read_only", "--open-api-key", ""], - clap::error::ErrorKind::InvalidValue, - ), - ( - vec!["--role", "sql_console_read_only", "--allowed-origins", ""], - clap::error::ErrorKind::InvalidValue, - ), - ] { - let error = Cli::try_parse_from(prefix.into_iter().chain(flags)) - .err() - .unwrap(); - assert_eq!(error.kind(), expected); - } - } - - #[test] - fn rejects_unknown_query_endpoint_role() { - let result = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - "create", - "svc-1", - "--role", - "admin", - ]); - - assert!(result.is_err()); - } - - #[test] - fn parses_private_endpoint_create_values() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "create", - "svc-1", - "--endpoint-id", - "vpce-0123456789abcdef0", - "--description", - "production", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = command else { - panic!("expected private-endpoint command"); - }; - let crate::cloud::cli::PrivateEndpointCommands::Create { - service_id, - endpoint_id, - description, - } = command - else { - panic!("expected private-endpoint create"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(endpoint_id, "vpce-0123456789abcdef0"); - assert_eq!(description.as_deref(), Some("production")); - } - - /// Non-AWS endpoint IDs have no marker that separates a typo from a valid - /// value, so they must pass through untouched (issue #611). - #[test] - fn parses_non_aws_private_endpoint_ids_unchanged() { - for id in [ - // GCP Private Service Connect connection ID. - "102600141743718403", - // Azure private endpoint Resource ID. - "/subscriptions/11111111-2222-3333-4444-555555555555/resourceGroups/rg/providers/\ - Microsoft.Network/privateEndpoints/pe-demo", - // Azure resourceGuid (legacy form). - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - // Legacy 8-character AWS VPC endpoint ID. - "vpce-0123abcd", - ] { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "create", - "svc-1", - "--endpoint-id", - id, - ]); - let crate::cloud::cli::ServiceCommands::PrivateEndpoint { command } = command else { - panic!("expected private-endpoint command"); - }; - let crate::cloud::cli::PrivateEndpointCommands::Create { endpoint_id, .. } = command - else { - panic!("expected private-endpoint create"); - }; - assert_eq!(endpoint_id, id, "{id} must be accepted verbatim"); - } - } - - #[test] - fn rejects_malformed_private_endpoint_create_endpoint_id() { - for id in ["vpce-1", "vpce-bogus", "VPCE-0123456789ABCDEF0", "", " "] { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "create", - "svc-1", - "--endpoint-id", - id, - ]) - .err() - .unwrap_or_else(|| panic!("--endpoint-id {id:?} must be rejected")); - assert_eq!( - error.kind(), - clap::error::ErrorKind::ValueValidation, - "--endpoint-id {id:?} must fail as a usage error" - ); - } - } - - #[test] - fn rejects_malformed_added_private_endpoint_id_on_update() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - "--add-private-endpoint-id", - "vpce-nope", - ]) - .err() - .expect("malformed --add-private-endpoint-id must be rejected"); - assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); - } - - /// Removing an ID must never be format-checked: a bogus ID that is already - /// registered has to stay removable (issue #611). - #[test] - fn accepts_malformed_removed_private_endpoint_id_on_update() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "update", - "svc-1", - "--remove-private-endpoint-id", - "vpce-nope", - ]); - let crate::cloud::cli::ServiceCommands::Update { - remove_private_endpoint_id, - .. - } = command - else { - panic!("expected service update"); - }; - assert_eq!(remove_private_endpoint_id, vec!["vpce-nope"]); - } - - #[test] - fn validate_private_endpoint_id_accepts_every_provider_format() { - for id in [ - "vpce-0123456789abcdef0", - "vpce-0123abcd", - "102600141743718403", - "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateEndpoints/pe", - // An Azure resource may itself be named `vpce-...`. - "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Network/privateEndpoints/\ - vpce-mine", - ] { - assert!( - validate_private_endpoint_id(id).is_ok(), - "{id} must be accepted" - ); - } - } - - #[test] - fn validate_private_endpoint_id_rejects_empty_values() { - for id in ["", " ", "\t"] { - let error = validate_private_endpoint_id(id).expect_err("empty must be rejected"); - assert!(error.contains("must not be empty"), "{error}"); - } - } - - #[test] - fn validate_private_endpoint_id_rejects_malformed_values() { - for id in [ - // Two IDs squeezed into one quoted flag value. - "vpce-0123456789abcdef0 vpce-0123abcd", - "vpce-", - "vpce-1", - "vpce-xyz", - // 16 hex characters: one short of the current AWS length. - "vpce-0123456789abcdef", - // 18 hex characters: one too many. - "vpce-0123456789abcdef01", - // Non-hex character inside an otherwise correct-length ID. - "vpce-0123456789abcdefg", - // AWS never issues uppercase IDs, so this would register a dud. - "VPCE-0123456789ABCDEF0", - // Endpoint *service* name pasted instead of the endpoint ID. - "com.amazonaws.vpce.us-east-1.vpce-svc-0123456789abcdef0", - // Full ARN pasted instead of the endpoint ID. - "arn:aws:ec2:us-east-1:123456789012:vpc-endpoint/vpce-0123456789abcdef0", - ] { - let error = validate_private_endpoint_id(id) - .expect_err(&format!("{id} must be rejected, not accepted")); - // The message names the offending value so the fix is obvious. - assert!( - error.contains(id), - "error for {id} should quote the value: {error}" - ); - } - } - - #[test] - fn parses_service_identity_lifecycle_and_prometheus_options() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "get", - "svc-1", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::Get { service_id } = command else { - panic!("expected service get"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - - for action in ["start", "wake", "stop"] { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - action, - "svc-1", - "--org-id", - "org-1", - ]); - match command { - crate::cloud::cli::ServiceCommands::Start { service_id } - | crate::cloud::cli::ServiceCommands::Wake { service_id } - | crate::cloud::cli::ServiceCommands::Stop { service_id } => { - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - } - _ => panic!("expected service {action}"), - } - } - - let command = parse_service(&["clickhousectl", "cloud", "service", "prometheus", "svc-1"]); - let crate::cloud::cli::ServiceCommands::Prometheus { - filtered_metrics, .. - } = command - else { - panic!("expected service prometheus"); - }; - - assert!(filtered_metrics.is_none()); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "prometheus", - "svc-1", - "--filtered-metrics", - "true", - "--org-id", - "org-1", - ]); - let crate::cloud::cli::ServiceCommands::Prometheus { - service_id, - filtered_metrics, - } = command - else { - panic!("expected service prometheus"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - - assert_eq!(filtered_metrics, Some(true)); - } - - #[test] - fn top_level_write_classification_covers_every_service_command() { - assert_write(&["clickhousectl", "cloud", "service", "list"], false); - assert_write( - &["clickhousectl", "cloud", "service", "get", "svc-1"], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "profile", - "list", - "--region", - "us-east-1", - ], - false, - ); - for action in ["list", "schema"] { - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "settings", - action, - "svc-1", - ], - false, - ); - } - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "settings", - "get", - "svc-1", - "compatibility", - ], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "settings", - "set", - "svc-1", - "--setting", - "compatibility=\"24.8\"", - ], - true, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "settings", - "unset", - "svc-1", - "compatibility", - ], - true, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "repair-query-key", - "svc-1", - ], - true, - ); - assert_write( - &["clickhousectl", "cloud", "service", "prometheus", "svc-1"], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "query", - "--id", - "svc-1", - "--query", - "SELECT 1", - ], - false, - ); - assert_write( - &["clickhousectl", "cloud", "service", "create", "--name", "s"], - true, - ); - for action in [ - "delete", - "start", - "wake", - "stop", - "update", - "scale", - "reset-password", - ] { - assert_write( - &["clickhousectl", "cloud", "service", action, "svc-1"], - true, - ); - } - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - "get", - "svc-1", - ], - false, - ); - for action in ["create", "delete"] { - let mut args = vec![ - "clickhousectl", - "cloud", - "service", - "query-endpoint", - action, - "svc-1", - ]; - if action == "create" { - args.extend(["--role", "sql_console_read_only"]); - } - assert_write(&args, true); - } - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "get-config", - "svc-1", - ], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "private-endpoint", - "create", - "svc-1", - "--endpoint-id", - "ep-1", - ], - true, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "backup-config", - "get", - "svc-1", - ], - false, - ); - assert_write( - &[ - "clickhousectl", - "cloud", - "service", - "backup-config", - "update", - "svc-1", - "--backup-period-hours", - "24", - ], - true, - ); - } - - #[test] - fn first_endpoint_keeps_the_present_half_of_a_partial_endpoint() { - let endpoint = |host: Option<&str>, port: Option| ServiceEndpoint { - host: host.map(str::to_string), - port, - ..Default::default() - }; - assert_eq!(first_endpoint(None), ABSENT); - assert_eq!(first_endpoint(Some(&[])), ABSENT); - assert_eq!( - first_endpoint(Some(&[endpoint(Some("host"), Some(9440))])), - "host:9440" - ); - assert_eq!( - first_endpoint(Some(&[endpoint(Some("host"), None)])), - "host" - ); - assert_eq!( - first_endpoint(Some(&[endpoint(None, Some(9440))])), - format!("{ABSENT}:9440") - ); - assert_eq!(first_endpoint(Some(&[endpoint(None, None)])), ABSENT); - } - - #[test] - fn service_query_hint_is_dropped_when_the_id_is_absent() { - assert_eq!(service_query_hint(None), None); - let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); - let hint = service_query_hint(Some(id)).unwrap(); - assert!(hint.contains("query a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6")); - assert!(hint.contains("provisioned automatically on first use")); - } - - #[test] - fn service_credentials_block_shows_the_password_the_api_sent() { - let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); - assert_eq!( - service_credentials_block(Some("s3cret"), Some(id)), - "Credentials (save these, password shown only once):\n Username: default\n \ - Password: s3cret" - ); - } - - #[test] - fn service_credentials_block_treats_an_empty_password_as_sent() { - assert_eq!( - service_credentials_block(Some(""), None), - "Credentials (save these, password shown only once):\n Username: default\n \ - Password: " - ); - } - - #[test] - fn service_credentials_block_warns_with_the_reset_command_when_the_password_is_absent() { - let id = uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap(); - let block = service_credentials_block(None, Some(id)); - assert!(!block.contains(&format!("Password: {ABSENT}"))); - assert!(block.contains( - "clickhousectl cloud service reset-password a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6" - )); - } - - #[test] - fn service_credentials_block_warns_generically_when_the_service_id_is_absent() { - assert!( - service_credentials_block(None, None) - .contains("clickhousectl cloud service reset-password ") - ); - } - - #[test] - fn classify_stop_poll_state_fails_on_an_absent_state() { - let error = classify_stop_poll_state(None).unwrap_err(); - assert!(error.to_string().contains("omitted the service state")); - } - - #[test] - fn classify_stop_poll_state_separates_stopped_waiting_and_failed() { - assert!(classify_stop_poll_state(Some(&ServiceState::Stopped)).unwrap()); - assert!(classify_stop_poll_state(Some(&ServiceState::Idle)).unwrap()); - assert!(!classify_stop_poll_state(Some(&ServiceState::Stopping)).unwrap()); - assert!(!classify_stop_poll_state(Some(&ServiceState::Running)).unwrap()); - assert!( - !classify_stop_poll_state(Some(&ServiceState::Unknown("hibernating".into()))).unwrap() - ); - assert!(classify_stop_poll_state(Some(&ServiceState::Failed)).is_err()); - assert!(classify_stop_poll_state(Some(&ServiceState::Unknown("deleted".into()))).is_err()); - } - - #[test] - fn query_output_completion_acknowledges_empty_responses() { - assert_eq!( - query_output_completion("TabSeparated", 0, None), - QueryOutputCompletion::Acknowledge - ); - assert_eq!( - query_output_completion("RowBinary", 0, None), - QueryOutputCompletion::Acknowledge - ); - } - - #[test] - fn query_output_completion_adds_only_a_missing_text_newline() { - for format in ["TabSeparated", "PrettyCompact", "JSONEachRow"] { - assert_eq!( - query_output_completion(format, 2, Some(b'K')), - QueryOutputCompletion::Newline - ); - } - assert_eq!( - query_output_completion("JSONEachRow", 2, Some(b'\n')), - QueryOutputCompletion::None - ); - } - - #[test] - fn query_output_completion_preserves_exact_and_binary_bodies() { - for format in [ - "Template", - "Buffers", - "BSONEachRow", - "RowBinary", - "Native", - "Parquet", - "ArrowStream", - "MsgPack", - ] { - assert_eq!( - query_output_completion(format, 3, Some(0)), - QueryOutputCompletion::None - ); - } - } - - #[test] - fn query_readiness_is_only_auth_or_generic_not_found() { - for status in [401, 403, 404] { - assert!(query_endpoint_readiness_error( - &clickhouse_cloud_api::Error::Api { - status, - message: "rejected".into(), - } - )); - } - for status in [400, 408, 429, 500] { - assert!(!query_endpoint_readiness_error( - &clickhouse_cloud_api::Error::Api { - status, - message: "query failed".into(), - } - )); - } - // A SQL-level rejection proves the endpoint *is* usable, and it is - // recognized by its variant rather than by its message text. - assert!(!query_endpoint_readiness_error( - &clickhouse_cloud_api::Error::Sql { - status: 404, - code: "60".into(), - details: "Unknown table".into(), - } - )); - assert!(!query_endpoint_readiness_error( - &clickhouse_cloud_api::Error::ServiceStopped - )); - } - - #[tokio::test] - async fn query_does_not_provision_or_retry_transport_errors() { - let transport_error = reqwest::Client::new() - .get("not a url") - .send() - .await - .unwrap_err(); - assert!(!query_endpoint_readiness_error( - &clickhouse_cloud_api::Error::Http(transport_error) - )); - } - - #[tokio::test] - async fn query_readiness_deadline_bounds_a_stalled_attempt() { - let readiness = QueryEndpointReadiness { - timeout: Duration::from_millis(10), - initial_backoff: Duration::ZERO, - max_backoff: Duration::ZERO, - }; - let result = tokio::time::timeout( - Duration::from_secs(1), - wait_for_query_endpoint_readiness(readiness, || { - std::future::pending::>() - }), - ) - .await - .expect("stalled attempt exceeded the test guard"); - - assert_query_readiness_timeout(result, readiness.timeout); - } - - // ── post-repair verification (issue #658) ─────────────────────────── - - /// The service under repair; a UUID because the service model's `id` is one. - const VERIFIED_SERVICE_ID: &str = "11111111-2222-3333-4444-555555555555"; - - const FAST_READINESS: QueryEndpointReadiness = QueryEndpointReadiness { - timeout: Duration::from_millis(60), - initial_backoff: Duration::from_millis(1), - max_backoff: Duration::from_millis(5), - }; - - async fn mount_running_service(control: &wiremock::MockServer) { - wiremock::Mock::given(wiremock::matchers::method("GET")) - .and(wiremock::matchers::path(format!( - "/v1/organizations/org-1/services/{VERIFIED_SERVICE_ID}" - ))) - .respond_with( - wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "result": { "id": VERIFIED_SERVICE_ID, "name": "demo", "state": "running" }, - "status": 200, - "requestId": "stub-service-get" - })), - ) - .mount(control) - .await; - } - - fn probes_received(requests: &[wiremock::Request]) -> usize { - requests - .iter() - .filter(|request| request.url.path() == format!("/service/{VERIFIED_SERVICE_ID}/run")) - .count() - } - - #[tokio::test] - async fn a_repair_verification_timeout_is_a_generic_error_with_its_own_code() { - let control = wiremock::MockServer::start().await; - mount_running_service(&control).await; - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path(format!( - "/service/{VERIFIED_SERVICE_ID}/run" - ))) - .respond_with( - wiremock::ResponseTemplate::new(401).set_body_string("API key is not authorized"), - ) - .mount(&control) - .await; - let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); - - let error = probe_repaired_query_key( - &client, - "org-1", - VERIFIED_SERVICE_ID, - "new-key-uuid", - "kid", - "sec", - FAST_READINESS, - ) - .await - .expect_err("a key rejected for the whole window is an error"); - - // Not an auth failure of the command: the management credentials - // worked, and exit code 4 would send the user to `cloud auth`. - assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); - assert!( - error.message.starts_with(&format!( - "the query key for service {VERIFIED_SERVICE_ID} was replaced (new API key \ - new-key-uuid) and stored in .clickhouse/credentials.json, but the Query API \ - kept rejecting it" - )), - "{}", - error.message - ); - assert!( - error.message.contains("Do not rerun repair-query-key"), - "{}", - error.message - ); - assert_eq!( - error.failure.map(|failure| failure.kind), - Some(FailureKind::Timeout) - ); - let details = error.details.expect("the timeout has a structured form"); - assert_eq!(details.code, CloudErrorCode::QueryKeyRepairUnverified); - assert_eq!(details.api_key_id.as_deref(), Some("new-key-uuid")); - assert_eq!( - details.command.as_deref(), - Some( - format!( - "clickhousectl cloud service query {VERIFIED_SERVICE_ID} --org-id org-1 \ - --query \"SELECT 1\"" - ) - .as_str() - ) - ); - assert_eq!(details.message, error.message); - assert!(probes_received(&control.received_requests().await.unwrap()) >= 2); - } - - #[tokio::test] - async fn a_repair_verification_succeeds_once_the_probe_is_accepted() { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - let control = wiremock::MockServer::start().await; - mount_running_service(&control).await; - let attempts = Arc::new(AtomicUsize::new(0)); - wiremock::Mock::given(wiremock::matchers::method("POST")) - .and(wiremock::matchers::path(format!( - "/service/{VERIFIED_SERVICE_ID}/run" - ))) - .respond_with(move |_: &wiremock::Request| { - if attempts.fetch_add(1, Ordering::SeqCst) == 0 { - wiremock::ResponseTemplate::new(401).set_body_string("not yet") - } else { - wiremock::ResponseTemplate::new(200).set_body_string("1\n") - } - }) - .mount(&control) - .await; - let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); - - let verification = probe_repaired_query_key( - &client, - "org-1", - VERIFIED_SERVICE_ID, - "new-key-uuid", - "kid", - "sec", - FAST_READINESS, - ) - .await - .unwrap(); - assert_eq!(verification, RepairVerification::Verified); - assert_eq!( - probes_received(&control.received_requests().await.unwrap()), - 2 - ); - } - - #[tokio::test] - async fn a_repair_verification_without_a_configured_query_host_skips_without_probing() { - let control = wiremock::MockServer::start().await; - mount_running_service(&control).await; - // A loopback base URL derives no query host, and no override is given. - let client = CloudClient::for_tests(&control.uri(), None); - let verification = probe_repaired_query_key( - &client, - "org-1", - VERIFIED_SERVICE_ID, - "new-key-uuid", - "kid", - "sec", - FAST_READINESS, - ) - .await - .unwrap(); - assert_eq!(verification, RepairVerification::Skipped); - assert!(control.received_requests().await.unwrap().is_empty()); - } - - #[tokio::test] - async fn a_repair_verification_skips_without_probing_when_the_record_is_gone() { - // No credentials file names this service, so there is nothing to - // probe with; the mock sees no request at all. - let control = wiremock::MockServer::start().await; - mount_running_service(&control).await; - let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); - let verification = verify_repaired_query_key( - &client, - "org-1", - "svc-1-with-no-stored-record", - "new-key-uuid", - FAST_READINESS, - ) - .await - .unwrap(); - assert_eq!(verification, RepairVerification::Skipped); - assert!(control.received_requests().await.unwrap().is_empty()); - } - - #[test] - fn the_repaired_credential_is_probed_only_while_the_record_names_the_new_key() { - let stored = |api_key_id: &str| credentials::ServiceQueryKey { - organization_id: Some("org-1".into()), - api_key_id: Some(api_key_id.into()), - key_id: "kid".into(), - key_secret: "sec".into(), - endpoint_id: Some("ep-1".into()), - pending_cleanup_api_key_ids: vec![], - service_name: "demo".into(), - created_at: chrono::Utc::now(), - }; - assert_eq!( - repaired_query_credential(Ok(Some(stored("new"))), "svc-1", "new"), - Ok(("kid".to_string(), "sec".to_string())) - ); - // A concurrent repair replaced the key, or the record vanished. - let changed = "the stored query key for service svc-1 changed before the replacement \ - could be probed"; - assert_eq!( - repaired_query_credential(Ok(Some(stored("newer"))), "svc-1", "new"), - Err(changed.to_string()) - ); - assert_eq!( - repaired_query_credential(Ok(None), "svc-1", "new"), - Err(changed.to_string()) - ); - } - - #[test] - fn a_credentials_read_error_after_a_committed_repair_skips_rather_than_fails() { - // The repair is committed by the time the record is re-read, so an - // unreadable or unparsable credentials file skips the probe like every - // other "not this run's to verify" case. Only a full-window Query API - // rejection may exit 1. - let reason = repaired_query_credential( - Err(CloudError::new("credentials.json is not valid JSON")), - "svc-1", - "new", - ) - .unwrap_err(); - assert_eq!( - reason, - "the stored query key for service svc-1 could not be read (credentials.json is not \ - valid JSON)" - ); - } - - #[tokio::test] - async fn query_readiness_exhaustion_is_not_an_auth_error() { - use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }; - - let readiness = QueryEndpointReadiness { - timeout: Duration::from_millis(20), - initial_backoff: Duration::from_millis(20), - max_backoff: Duration::from_millis(20), - }; - let attempts = Arc::new(AtomicUsize::new(0)); - let probe_attempts = Arc::clone(&attempts); - let result = tokio::time::timeout( - Duration::from_secs(1), - wait_for_query_endpoint_readiness(readiness, move || { - probe_attempts.fetch_add(1, Ordering::SeqCst); - std::future::ready(Err::<(), _>(clickhouse_cloud_api::Error::Api { - status: 401, - message: "query endpoint is not ready".into(), - })) - }), - ) - .await - .expect("readiness retries exceeded the test guard"); - - assert_eq!(attempts.load(Ordering::SeqCst), 1); - assert_query_readiness_timeout(result, readiness.timeout); - } - - fn assert_query_readiness_timeout( - result: Result, - timeout: Duration, - ) { - let elapsed = match result { - Err(QueryReadinessError::TimedOut(elapsed)) => elapsed, - Err(QueryReadinessError::Api(error)) => panic!("expected the deadline, got {error}"), - Ok(_) => panic!("readiness should time out"), - }; - assert_eq!(elapsed, timeout); - let converted = query_readiness_timeout_error(elapsed); - - assert_eq!( - converted.kind, - crate::cloud::client::CloudErrorKind::Generic - ); - assert_eq!( - converted.message, - format!("Query API endpoint did not become ready within {timeout:?}") - ); - // The readiness deadline is reported as a timeout (#450), not as a - // generic 4xx: the CLI, not the API, gave up — so there is no HTTP - // status to report either. - assert_eq!( - converted.failure, - Some(ApiFailure::new(FailureKind::Timeout)) - ); - } - - /// A query target for the error-rendering tests. `native` decides whether - /// the timeout hint has a real endpoint to name. - fn test_query_target(native: Option<&NativeEndpoint>) -> QueryTarget<'_> { - QueryTarget { - service_name: "demo", - service_id: "svc-1", - org_id: "org-1", - native, - may_be_waking: false, - } - } - - #[test] - fn native_secure_endpoint_picks_the_native_protocol_endpoint() { - let endpoints = vec![ - ServiceEndpoint { - host: Some("demo.gcp.clickhouse.cloud".into()), - port: Some(8443), - protocol: Some(ServiceEndpointProtocol::Https), - username: None, - }, - ServiceEndpoint { - host: Some("demo.gcp.clickhouse.cloud".into()), - port: Some(9440), - protocol: Some(ServiceEndpointProtocol::Nativesecure), - username: Some("default".into()), - }, - ]; - assert_eq!( - native_secure_endpoint(Some(&endpoints)), - Some(NativeEndpoint { - host: "demo.gcp.clickhouse.cloud".into(), - port: 9440, - }) - ); - } - - #[test] - fn native_secure_endpoint_is_absent_when_the_response_is_incomplete() { - // No endpoints at all, and an empty list. - assert_eq!(native_secure_endpoint(None), None); - assert_eq!(native_secure_endpoint(Some(&[])), None); - // Only the HTTPS endpoint. - assert_eq!( - native_secure_endpoint(Some(&[ServiceEndpoint { - host: Some("demo.clickhouse.cloud".into()), - port: Some(8443), - protocol: Some(ServiceEndpointProtocol::Https), - username: None, - }])), - None - ); - // Every response field is optional, so a half-returned endpoint is - // absent rather than half-rendered into a command. - for partial in [ - ServiceEndpoint { - host: Some("demo.clickhouse.cloud".into()), - port: None, - protocol: Some(ServiceEndpointProtocol::Nativesecure), - username: None, - }, - ServiceEndpoint { - host: None, - port: Some(9440), - protocol: Some(ServiceEndpointProtocol::Nativesecure), - username: None, - }, - ServiceEndpoint { - host: Some("demo.clickhouse.cloud".into()), - port: Some(9440), - protocol: None, - username: None, - }, - ] { - assert_eq!( - native_secure_endpoint(Some(std::slice::from_ref(&partial))), - None - ); - } - } - - /// The gateway timeout names the limit, warns before a rerun, and hands - /// over a native-protocol command built from the service's own endpoint - /// (#644). - #[test] - fn query_timeout_error_hints_the_native_endpoint() { - let native = NativeEndpoint { - host: "demo.gcp.clickhouse.cloud".into(), - port: 9440, - }; - let error = query_timeout_error(test_query_target(Some(&native))); - - assert!( - error.message.contains("about 30 seconds"), - "{}", - error.message - ); - assert!( - error - .message - .contains("SELECT query_id, elapsed FROM system.processes"), - "{}", - error.message - ); - assert!( - error.message.contains("clickhousectl local use latest"), - "{}", - error.message - ); - assert!( - error.message.contains( - "clickhouse client --host demo.gcp.clickhouse.cloud --secure --port 9440 \ - --user default --password '' --query ''" - ), - "{}", - error.message - ); - assert!( - error - .message - .contains("clickhousectl cloud service reset-password svc-1"), - "{}", - error.message - ); - // With a real endpoint there is nothing to look up. - assert!( - !error.message.contains("cloud service get"), - "{}", - error.message - ); - assert!(!error.message.contains(""), "{}", error.message); - - // A timeout is exit code 1 (Generic), never an auth failure, and the - // classification is the variant's (#450). - assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); - assert_eq!( - error.failure, - Some(ApiFailure::with_status(FailureKind::Timeout, 500)) - ); - - let details = error.details.as_deref().expect("json details"); - assert_eq!(details.code, CloudErrorCode::QueryTimeout); - assert_eq!(details.message, error.message); - assert_eq!(details.host.as_deref(), Some("demo.gcp.clickhouse.cloud")); - assert_eq!(details.port, Some(9440)); - assert_eq!( - details.command.as_deref(), - Some( - "clickhouse client --host demo.gcp.clickhouse.cloud --secure --port 9440 --user \ - default --password '' --query ''" - ) - ); - } - - #[test] - fn query_timeout_after_wake_retains_structural_classification() { - let client = CloudClient::new( - Some("test-key"), - Some("test-secret"), - Some("https://api.example.com/v1"), - ) - .unwrap(); - let error = QueryRequestError { - error: clickhouse_cloud_api::Error::QueryTimeout, - wake_requested: true, - } - .into_cloud_error(&client, test_query_target(None)); - assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); - assert_eq!( - error.failure, - Some(ApiFailure::with_status(FailureKind::Timeout, 500)) - ); - let details = error.details.as_deref().unwrap(); - assert_eq!(details.code, CloudErrorCode::QueryTimeout); - assert_eq!(details.message, error.message); - assert_eq!( - details.command.as_deref(), - Some("clickhousectl cloud service get svc-1 --org-id org-1") - ); - assert!(details.host.is_none()); - assert!(details.port.is_none()); - - for status in [401, 403, 500] { - let error = QueryRequestError { - error: clickhouse_cloud_api::Error::Api { - status, - message: "unrelated failure".into(), - }, - wake_requested: true, - } - .into_cloud_error(&client, test_query_target(None)); - assert!(!error.message.contains("waking")); - assert_eq!(error.failure.unwrap().http_status, Some(status)); - assert_eq!( - error.kind == crate::cloud::client::CloudErrorKind::Auth, - status == 401 || status == 403 - ); - } - } - - #[test] - fn query_timeout_error_falls_back_to_a_host_placeholder() { - let error = query_timeout_error(test_query_target(None)); - - assert!( - error.message.contains( - "clickhouse client --host --secure --port 9440 --user default \ - --password '' --query ''" - ), - "{}", - error.message - ); - // No endpoint in the response, so the message says where to get one. - assert!( - error - .message - .contains("clickhousectl cloud service get svc-1 --org-id org-1"), - "{}", - error.message - ); - - let details = error.details.as_deref().expect("json details"); - // Absent means omitted: no fabricated host or port. - assert_eq!(details.host, None); - assert_eq!(details.port, None); - assert!( - details - .command - .as_deref() - .is_some_and(|command| command.contains("--host ")), - "{details:?}" - ); - let json = serde_json::to_value(details).expect("details serialize"); - assert_eq!(json["code"], "query_timeout"); - assert!(json.get("host").is_none(), "{json}"); - assert!(json.get("port").is_none(), "{json}"); - } - - /// The message is rendered from the target and fixed placeholders only, - /// so neither the statement nor any credential can reach it. - #[test] - fn query_timeout_error_never_echoes_sql_or_credentials() { - let native = NativeEndpoint { - host: "demo.gcp.clickhouse.cloud".into(), - port: 9440, - }; - for target in [test_query_target(Some(&native)), test_query_target(None)] { - let error = query_timeout_error(target); - assert!(error.message.contains("''"), "{}", error.message); - assert!( - error.message.contains("--password ''"), - "{}", - error.message - ); - } - } - - /// Every query-path error a boundary rewrites keeps a classification - /// derived from what actually failed, not from its message (#450). - #[test] - fn query_errors_carry_their_structural_classification() { - let client = CloudClient::new( - Some("test-key"), - Some("test-secret"), - Some("https://api.example.com/v1"), - ) - .unwrap(); - - // A stopped service gets a rewritten, actionable message; the - // classification survives the rewrite. - let stopped = convert_query_error( - &client, - clickhouse_cloud_api::Error::ServiceStopped, - test_query_target(None), - ); - assert!(stopped.message.contains("is stopped")); - assert_eq!( - stopped.failure, - Some(ApiFailure::new(FailureKind::ServiceStopped)) - ); - - let sql = convert_query_error( - &client, - clickhouse_cloud_api::Error::Sql { - status: 400, - code: "62".into(), - details: "Syntax error".into(), - }, - test_query_target(None), - ); - assert_eq!( - sql.failure, - Some(ApiFailure::with_status(FailureKind::SqlError, 400)) - ); - - // `--no-auto-enable` rewrites the message but the failure is still - // the Query API's own rejection: its class and status survive. - for status in [401, 403, 404] { - let refused = refused_query_provisioning_error( - "svc-1", - &clickhouse_cloud_api::Error::Api { - status, - message: "rejected".into(), - }, - ); - assert!(refused.message.contains("--no-auto-enable"), "{refused}"); - assert_eq!(refused.kind, crate::cloud::client::CloudErrorKind::Generic); - assert_eq!( - refused.failure, - Some(ApiFailure::with_status(FailureKind::Http4xx, status)), - "status {status}" - ); - } - } - - #[test] - fn service_delete_error_uses_the_api_status_and_preserves_classification() { - let client = CloudClient::new( - Some("test-key"), - Some("test-secret"), - Some("https://api.example.com/v1"), - ) - .unwrap(); - let error = clickhouse_cloud_api::Error::Api { - status: 409, - message: "opaque conflict response".into(), - }; - let rewritten = service_delete_error(&client, error, false, "svc-1", "org-1"); - - assert_eq!( - rewritten.message, - "service could not be deleted because of a conflict. If it is running, use --force \ - to stop it first, or `clickhousectl cloud service stop svc-1`. API response: opaque \ - conflict response" - ); - assert_eq!( - rewritten.kind, - crate::cloud::client::CloudErrorKind::Generic - ); - assert_eq!( - rewritten.failure, - Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) - ); - } - - #[test] - fn service_delete_error_does_not_classify_from_message_text() { - let client = CloudClient::new( - Some("test-key"), - Some("test-secret"), - Some("https://api.example.com/v1"), - ) - .unwrap(); - let running_prose = || clickhouse_cloud_api::Error::Api { - status: 400, - message: "CONFLICT: Current state: 'running'".into(), - }; - - let ordinary = service_delete_error(&client, running_prose(), false, "svc-1", "org-1"); - assert_eq!(ordinary.message, "CONFLICT: Current state: 'running'"); - assert_eq!( - ordinary.failure, - Some(ApiFailure::with_status(FailureKind::Http4xx, 400)) - ); - - let forced = service_delete_error( - &client, - clickhouse_cloud_api::Error::Api { - status: 409, - message: "opaque conflict response".into(), - }, - true, - "svc-1", - "org-1", - ); - assert_eq!(forced.message, "opaque conflict response"); - assert_eq!( - forced.failure, - Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) - ); - } - - #[test] - fn service_delete_conflict_preserves_converted_error_metadata() { - let detail = CloudErrorDetail { - code: CloudErrorCode::ResourceNotFound, - message: "structured error".into(), - host: None, - port: None, - command: Some("cloud service list".into()), - api_key_id: None, - ip_access_list: None, - }; - let error = CloudError::auth("opaque conflict response") - .with_failure(ApiFailure::with_status(FailureKind::Http4xx, 409)) - .with_details(detail); - - let rewritten = service_delete_conflict(error, "svc-1"); - - assert_eq!(rewritten.kind, crate::cloud::client::CloudErrorKind::Auth); - assert_eq!( - rewritten.failure, - Some(ApiFailure::with_status(FailureKind::Http4xx, 409)) - ); - let detail = rewritten.details.as_deref().expect("structured details"); - assert_eq!(detail.code, CloudErrorCode::ResourceNotFound); - assert_eq!(detail.message, "structured error"); - assert_eq!(detail.command.as_deref(), Some("cloud service list")); - } - - #[test] - fn generation_is_requested_unless_a_password_hash_is_sent() { - let request = |new_password_hash: Option<&str>, new_double_sha1_hash: Option<&str>| { - ServicePasswordPatchRequest { - new_password_hash: new_password_hash.map(str::to_string), - new_double_sha1_hash: new_double_sha1_hash.map(str::to_string), - } - }; - assert!(generation_requested(&request(None, None))); - assert!(generation_requested(&request(None, Some("sha1")))); - assert!(!generation_requested(&request(Some("sha256"), None))); - assert!(!generation_requested(&request( - Some("sha256"), - Some("sha1") - ))); - } - - #[test] - fn resolve_reset_password_outcome_returns_the_generated_password() { - assert!(matches!( - resolve_reset_password_outcome(true, Some("s3cret")).unwrap(), - ResetPasswordOutcome::Generated("s3cret") - )); - assert!(matches!( - resolve_reset_password_outcome(true, Some("")).unwrap(), - ResetPasswordOutcome::Generated("") - )); - } - - #[test] - fn resolve_reset_password_outcome_reports_hash_updated_regardless_of_response() { - assert!(matches!( - resolve_reset_password_outcome(false, None).unwrap(), - ResetPasswordOutcome::HashUpdated - )); - assert!(matches!( - resolve_reset_password_outcome(false, Some("s3cret")).unwrap(), - ResetPasswordOutcome::HashUpdated - )); - } - - #[test] - fn resolve_reset_password_outcome_fails_when_the_generated_password_is_absent() { - let error = resolve_reset_password_outcome(true, None).unwrap_err(); - assert!(error.to_string().contains("omitted the generated password")); - } - - fn minimal_create_options() -> CreateServiceOptions { - CreateServiceOptions { - name: "svc".to_string(), - provider: "aws".to_string(), - region: "us-east-1".to_string(), - ..Default::default() - } - } - - #[test] - fn build_create_service_request_supports_minimal_fields() { - let request = build_create_service_request(&minimal_create_options()).unwrap(); - - assert_eq!(request.name, "svc"); - assert_eq!(request.provider, ServicePostRequestProvider::Aws); - assert_eq!(request.region, ServicePostRequestRegion::Us_east_1); - assert_eq!(request.ip_access_list.len(), 1); - assert_eq!(request.ip_access_list[0].source, "0.0.0.0/0"); - assert_eq!( - request.ip_access_list[0].description.as_deref(), - Some("Allow all (created by clickhousectl)") - ); - assert!(request.autoscaling_mode.is_none()); - assert!(request.min_replica_memory_gb.is_none()); - assert!(request.max_replica_memory_gb.is_none()); - assert!(request.num_replicas.is_none()); - assert!(request.min_replicas.is_none()); - assert!(request.max_replicas.is_none()); - assert!(request.idle_scaling.is_none()); - assert!(request.idle_timeout_minutes.is_none()); - assert!(request.backup_id.is_none()); - assert!(request.backup_encryption_config.is_none()); - assert!(request.release_channel.is_none()); - assert!(request.tags.is_none()); - assert!(request.data_warehouse_id.is_none()); - assert!(request.is_readonly.is_none()); - assert!(request.has_transparent_data_encryption.is_none()); - assert!(request.compliance_type.is_none()); - assert!(request.profile.is_none()); - assert!(request.private_preview_terms_checked.is_none()); - assert!(request.endpoints.is_none()); - assert!(request.enable_core_dumps.is_none()); - assert!(request.byoc_id.is_none()); - } - - #[test] - fn build_create_service_request_supports_maximal_fields() { - let options = CreateServiceOptions { - min_replica_memory_gb: Some(24), - max_replica_memory_gb: Some(48), - num_replicas: Some(3), - autoscaling_mode: Some("vertical".to_string()), - idle_scaling: Some(true), - idle_timeout_minutes: Some(10), - ip_allow: vec!["2001:db8::/32=office".to_string()], - backup_id: Some("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6".to_string()), - backup_encryption_config: Some(BackupEncryptionConfig::from([ - ("schema_version".to_string(), serde_json::json!(1)), - ( - "restore_key_pairs".to_string(), - serde_json::json!([{ "encrypted_dek": "wrapped" }]), - ), - ])), - release_channel: Some("fast".to_string()), - data_warehouse_id: Some("dw-1".to_string()), - is_readonly: true, - enable_tde: true, - compliance_type: Some("hipaa".to_string()), - profile: Some("v1-default".to_string()), - tags: vec!["env=prod".to_string()], - enable_endpoints: vec!["mysql".to_string()], - disable_endpoints: vec!["mysql".to_string()], - private_preview_terms_checked: true, - enable_core_dumps: Some(true), - ..minimal_create_options() - }; - let request = build_create_service_request(&options).unwrap(); - - assert_eq!(request.name, "svc"); - assert_eq!(request.provider, ServicePostRequestProvider::Aws); - assert_eq!(request.region, ServicePostRequestRegion::Us_east_1); - assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Vertical)); - assert_eq!(request.min_replica_memory_gb, Some(24.0)); - assert_eq!(request.max_replica_memory_gb, Some(48.0)); - assert_eq!(request.num_replicas, Some(3)); - assert!(request.min_replicas.is_none()); - assert!(request.max_replicas.is_none()); - assert_eq!(request.idle_scaling, Some(true)); - assert_eq!(request.idle_timeout_minutes, Some(10.0)); - assert_eq!(request.ip_access_list.len(), 1); - assert_eq!(request.ip_access_list[0].source, "2001:db8::/32"); - assert_eq!( - request.ip_access_list[0].description.as_deref(), - Some("office") - ); - assert_eq!( - request.backup_id, - Some(uuid::Uuid::parse_str("a1a2a3a4-b1b2-c1c2-d1d2-e1e2e3e4e5e6").unwrap()) - ); - assert_eq!( - serde_json::to_value(request.backup_encryption_config.as_ref().unwrap()).unwrap(), - serde_json::json!({ - "schema_version": 1, - "restore_key_pairs": [{ "encrypted_dek": "wrapped" }] - }) - ); - assert_eq!( - request.release_channel, - Some(ServicePostRequestReleasechannel::Fast) - ); - assert_eq!(request.data_warehouse_id.as_deref(), Some("dw-1")); - assert_eq!(request.is_readonly, Some(true)); - assert_eq!(request.has_transparent_data_encryption, Some(true)); - assert_eq!( - request.compliance_type, - Some(ServicePostRequestCompliancetype::Hipaa) - ); - assert_eq!(request.profile, Some(ServicePostRequestProfile::V1_default)); - let tags = request.tags.as_ref().unwrap(); - assert_eq!(tags.len(), 1); - assert_eq!(tags[0].key, "env"); - assert_eq!(tags[0].value.as_deref(), Some("prod")); - let endpoints = request.endpoints.as_ref().unwrap(); - assert_eq!(endpoints.len(), 2); - assert_eq!(endpoints[0].protocol, ServiceEndpointChangeProtocol::Mysql); - assert!(endpoints[0].enabled); - assert_eq!(endpoints[1].protocol, ServiceEndpointChangeProtocol::Mysql); - assert!(!endpoints[1].enabled); - assert_eq!(request.private_preview_terms_checked, Some(true)); - assert_eq!(request.enable_core_dumps, Some(true)); - assert!(request.byoc_id.is_none()); - } - - #[test] - fn backup_encryption_config_requires_backup_id() { - let error = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "create", - "--name", - "svc", - "--backup-encryption-config", - "config.json", - ]) - .err() - .expect("--backup-encryption-config without --backup-id should fail"); - assert_eq!( - error.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - } - - #[test] - fn read_backup_encryption_config_passes_a_json_object_through_unchanged() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("encryption_config.json"); - let document = serde_json::json!({ - "schema_version": 1, - "restore_key_pairs": [{ - "customer_managed_encryption_key": { "aws_kms_key_arn": "arn:aws:kms:k" }, - "encrypted_dek": "d2VsbA==", - "future_field": null - }] - }); - std::fs::write(&path, document.to_string()).unwrap(); - - let config = read_backup_encryption_config(path.to_str().unwrap()).unwrap(); - assert_eq!(serde_json::to_value(&config).unwrap(), document); - } - - #[test] - fn read_backup_encryption_config_rejects_bad_input_as_usage_errors() { - let directory = tempfile::tempdir().unwrap(); - let missing = directory.path().join("missing.json"); - let array = directory.path().join("array.json"); - let invalid = directory.path().join("invalid.json"); - std::fs::write(&array, "[1, 2]").unwrap(); - std::fs::write(&invalid, "{not json").unwrap(); - - for path in [&missing, &array, &invalid] { - let error = read_backup_encryption_config(path.to_str().unwrap()).unwrap_err(); - assert_eq!( - error.kind, - crate::cloud::client::CloudErrorKind::Usage, - "{path:?}: {error:?}" - ); - } - } - - #[test] - fn build_create_service_request_accepts_a_dynamic_byoc_profile() { - let request = build_create_service_request(&CreateServiceOptions { - min_replica_memory_gb: Some(48), - max_replica_memory_gb: Some(48), - profile: Some("v1-standard-byoc-4".to_string()), - byoc_id: Some("byoc-1".to_string()), - ..minimal_create_options() - }) - .unwrap(); - - assert_eq!(request.byoc_id.as_deref(), Some("byoc-1")); - assert_eq!( - request.profile, - Some(ServicePostRequestProfile::Unknown( - "v1-standard-byoc-4".to_string() - )) - ); - assert_eq!(request.min_replica_memory_gb, Some(48.0)); - assert_eq!(request.max_replica_memory_gb, Some(48.0)); - } - - #[test] - fn build_create_service_request_preserves_standard_profile_validation() { - let standard = build_create_service_request(&CreateServiceOptions { - profile: Some("v1-highmem-xs".to_string()), - ..minimal_create_options() - }) - .unwrap(); - assert_eq!( - standard.profile, - Some(ServicePostRequestProfile::V1_highmem_xs) - ); - - let error = build_create_service_request(&CreateServiceOptions { - profile: Some("future-standard-profile".to_string()), - ..minimal_create_options() - }) - .unwrap_err(); - assert!( - error - .to_string() - .contains("dynamic profiles require --byoc-id") - ); - } - - #[test] - fn build_create_service_request_enforces_byoc_memory_constraints() { - for options in [ - CreateServiceOptions { - byoc_id: Some("byoc-1".to_string()), - ..minimal_create_options() - }, - CreateServiceOptions { - byoc_id: Some("byoc-1".to_string()), - min_replica_memory_gb: Some(48), - ..minimal_create_options() - }, - CreateServiceOptions { - byoc_id: Some("byoc-1".to_string()), - min_replica_memory_gb: Some(48), - max_replica_memory_gb: Some(116), - profile: Some("v1-standard-byoc-4".to_string()), - ..minimal_create_options() - }, - ] { - assert!(build_create_service_request(&options).is_err()); - } - } - - #[test] - fn build_create_service_request_trims_tag_keys() { - let request = build_create_service_request(&CreateServiceOptions { - tags: vec![" env =prod".to_string()], - ..minimal_create_options() - }) - .unwrap(); - let json = serde_json::to_value(&request).unwrap(); - assert_eq!(json["tags"][0]["key"], "env"); - assert_eq!(json["tags"][0]["value"], "prod"); - } - - #[test] - fn build_create_service_request_rejects_empty_tag_keys() { - let error = build_create_service_request(&CreateServiceOptions { - tags: vec!["=prod".to_string()], - ..minimal_create_options() - }) - .unwrap_err(); - assert_eq!( - error.to_string(), - "invalid tag '=prod': tag key cannot be empty" - ); - } - - #[test] - fn build_update_service_request_supports_minimal_fields() { - let request = build_update_service_request(&ServiceUpdateOptions::default()).unwrap(); - - assert!(request.name.is_none()); - assert!(request.ip_access_list.is_none()); - assert!(request.private_endpoint_ids.is_none()); - assert!(request.release_channel.is_none()); - assert!(request.endpoints.is_none()); - assert!(request.transparent_data_encryption_key_id.is_none()); - assert!(request.tags.is_none()); - assert!(request.enable_core_dumps.is_none()); - } - - #[test] - fn build_update_service_request_supports_maximal_fields() { - let options = ServiceUpdateOptions { - name: Some("updated".to_string()), - add_ip_allow: vec!["10.0.0.0/8=office".to_string()], - remove_ip_allow: vec!["0.0.0.0/0".to_string()], - add_private_endpoint_ids: vec!["pe-1".to_string()], - remove_private_endpoint_ids: vec!["pe-2".to_string()], - release_channel: Some("default".to_string()), - enable_endpoints: vec!["mysql".to_string()], - disable_endpoints: vec!["mysql".to_string()], - transparent_data_encryption_key_id: Some("tde-1".to_string()), - add_tags: vec!["env=staging".to_string()], - remove_tags: vec!["old=tag".to_string()], - enable_core_dumps: Some(false), - }; - let request = build_update_service_request(&options).unwrap(); - - assert_eq!(request.name.as_deref(), Some("updated")); - let ip_access_list = request.ip_access_list.as_ref().unwrap(); - assert_eq!(ip_access_list.add.len(), 1); - assert_eq!(ip_access_list.add[0].source, "10.0.0.0/8"); - assert_eq!(ip_access_list.add[0].description.as_deref(), Some("office")); - assert_eq!(ip_access_list.remove.len(), 1); - assert_eq!(ip_access_list.remove[0].source, "0.0.0.0/0"); - assert!(ip_access_list.remove[0].description.is_none()); - let private_endpoint_ids = request.private_endpoint_ids.as_ref().unwrap(); - assert_eq!(private_endpoint_ids.add, vec!["pe-1"]); - assert_eq!(private_endpoint_ids.remove, vec!["pe-2"]); - assert_eq!( - request.release_channel, - Some(ServicePatchRequestReleasechannel::Default) - ); - let endpoints = request.endpoints.as_ref().unwrap(); - assert_eq!(endpoints.len(), 2); - assert_eq!(endpoints[0].protocol, ServiceEndpointChangeProtocol::Mysql); - assert!(endpoints[0].enabled); - assert_eq!(endpoints[1].protocol, ServiceEndpointChangeProtocol::Mysql); - assert!(!endpoints[1].enabled); - assert_eq!( - request.transparent_data_encryption_key_id.as_deref(), - Some("tde-1") - ); - let tags = request.tags.as_ref().unwrap(); - assert_eq!(tags.add.len(), 1); - assert_eq!(tags.add[0].key, "env"); - assert_eq!(tags.add[0].value.as_deref(), Some("staging")); - assert_eq!(tags.remove.len(), 1); - assert_eq!(tags.remove[0].key, "old"); - assert_eq!(tags.remove[0].value.as_deref(), Some("tag")); - assert_eq!(request.enable_core_dumps, Some(false)); - } - - #[test] - fn build_update_service_request_rejects_empty_tag_keys() { - let error = build_update_service_request(&ServiceUpdateOptions { - add_tags: vec![" =prod".to_string()], - ..Default::default() - }) - .unwrap_err(); - assert_eq!( - error.to_string(), - "invalid tag ' =prod': tag key cannot be empty" - ); - } - - #[test] - fn build_update_service_request_rejects_invalid_release_channel() { - let error = build_update_service_request(&ServiceUpdateOptions { - release_channel: Some("turbo".to_string()), - ..Default::default() - }) - .unwrap_err(); - assert!(error.to_string().contains("turbo")); - } - - #[test] - fn has_removals_is_false_when_no_remove_flags_set() { - assert!(!has_removals(&ServiceUpdateOptions::default())); - assert!(!has_removals(&ServiceUpdateOptions { - add_ip_allow: vec!["10.0.0.0/8".to_string()], - ..Default::default() - })); - } - - #[test] - fn has_removals_is_true_when_any_remove_flag_set() { - assert!(has_removals(&ServiceUpdateOptions { - remove_ip_allow: vec!["10.0.0.0/8".to_string()], - ..Default::default() - })); - assert!(has_removals(&ServiceUpdateOptions { - remove_private_endpoint_ids: vec!["pe-1".to_string()], - ..Default::default() - })); - assert!(has_removals(&ServiceUpdateOptions { - remove_tags: vec!["env".to_string()], - ..Default::default() - })); - } - - #[test] - fn unmatched_returns_only_values_absent_from_current() { - let current: HashSet<&str> = ["a", "b"].into_iter().collect(); - assert_eq!( - unmatched(&["a".to_string(), "c".to_string()], ¤t), - vec!["c"] - ); - assert!(unmatched(&["a".to_string(), "b".to_string()], ¤t).is_empty()); - } - - #[test] - fn tag_key_strips_value_and_trims() { - assert_eq!(tag_key("env"), "env"); - assert_eq!(tag_key("env=prod"), "env"); - assert_eq!(tag_key(" env = prod"), "env"); - } - - fn service_with_ip_allow_endpoints_and_tags() -> Service { - Service { - ip_access_list: Some(vec![IpAccessListEntryResponse { - source: Some("10.0.0.0/8".to_string()), - description: None, - }]), - private_endpoint_ids: Some(vec!["pe-1".to_string()]), - tags: Some(vec![ResourceTagsV1Response { - key: Some("env".to_string()), - value: Some("prod".to_string()), - }]), - ..Default::default() - } - } - - #[test] - fn unmatched_removal_warnings_is_empty_when_everything_matches() { - let current = service_with_ip_allow_endpoints_and_tags(); - let options = ServiceUpdateOptions { - remove_ip_allow: vec![" 10.0.0.0/8 =retired office".to_string()], - remove_private_endpoint_ids: vec!["pe-1".to_string()], - remove_tags: vec!["env".to_string()], - ..Default::default() - }; - let request = build_update_service_request(&options).unwrap(); - assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); - } - - #[test] - fn unmatched_removal_warnings_match_parsed_ipv4_and_ipv6_sources() { - let current = Service { - ip_access_list: Some(vec![ - IpAccessListEntryResponse { - source: Some("192.0.2.0/24".to_string()), - description: None, - }, - IpAccessListEntryResponse { - source: Some("2001:db8::/32".to_string()), - description: None, - }, - ]), - ..Default::default() - }; - let options = ServiceUpdateOptions { - remove_ip_allow: vec![ - " 192.0.2.0/24 =old office".to_string(), - " 2001:db8::/32 =old ipv6 range".to_string(), - ], - ..Default::default() - }; - let request = build_update_service_request(&options).unwrap(); - - assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); - } - - #[test] - fn unmatched_removal_warnings_names_every_unmatched_entry() { - let current = service_with_ip_allow_endpoints_and_tags(); - let options = ServiceUpdateOptions { - remove_ip_allow: vec![" 10.99.99.99/32 =old office".to_string()], - remove_private_endpoint_ids: vec!["pe-missing".to_string()], - remove_tags: vec!["missing=value".to_string()], - ..Default::default() - }; - let request = build_update_service_request(&options).unwrap(); - let warnings = unmatched_removal_warnings(&options, &request, ¤t); - assert_eq!(warnings.len(), 3); - assert!( - warnings[0].contains("--remove-ip-allow 10.99.99.99/32") - && warnings[0].contains("did not match"), - "{warnings:?}" - ); - assert!( - warnings[1].contains("--remove-private-endpoint-id pe-missing"), - "{warnings:?}" - ); - assert!(warnings[2].contains("--remove-tag missing"), "{warnings:?}"); - } - - #[test] - fn unmatched_removal_warnings_matches_tag_removal_by_key_only() { - // Removing "env=staging" should still match a current "env=prod" tag: - // the key is what the server matches on for removal. - let current = service_with_ip_allow_endpoints_and_tags(); - let options = ServiceUpdateOptions { - remove_tags: vec!["env=staging".to_string()], - ..Default::default() - }; - let request = build_update_service_request(&options).unwrap(); - assert!(unmatched_removal_warnings(&options, &request, ¤t).is_empty()); - } - - #[test] - fn unmatched_removal_warnings_handles_empty_current_lists() { - let options = ServiceUpdateOptions { - remove_ip_allow: vec!["10.0.0.0/8".to_string()], - ..Default::default() - }; - let request = build_update_service_request(&options).unwrap(); - let warnings = unmatched_removal_warnings(&options, &request, &Service::default()); - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains("--remove-ip-allow 10.0.0.0/8")); - } - - #[test] - fn build_create_service_request_rejects_invalid_provider() { - let error = build_create_service_request(&CreateServiceOptions { - provider: "awss".to_string(), - ..minimal_create_options() - }) - .unwrap_err(); - assert!(error.to_string().contains("awss")); - } - - #[test] - fn build_create_service_request_rejects_invalid_region() { - let error = build_create_service_request(&CreateServiceOptions { - region: "us-east-99".to_string(), - ..minimal_create_options() - }) - .unwrap_err(); - assert!(error.to_string().contains("us-east-99")); - } - - #[test] - fn build_create_service_request_rejects_invalid_release_channel() { - let error = build_create_service_request(&CreateServiceOptions { - release_channel: Some("turbo".to_string()), - ..minimal_create_options() - }) - .unwrap_err(); - assert!(error.to_string().contains("turbo")); - } - - #[test] - fn build_create_service_request_rejects_invalid_autoscaling_mode() { - let error = build_create_service_request(&CreateServiceOptions { - min_replicas: Some(2), - max_replicas: Some(8), - autoscaling_mode: Some("turbo".to_string()), - ..minimal_create_options() - }) - .unwrap_err(); - assert!(error.to_string().contains("turbo")); - } - - #[test] - fn build_create_service_request_supports_horizontal_fields() { - let request = build_create_service_request(&CreateServiceOptions { - min_replicas: Some(2), - max_replicas: Some(8), - autoscaling_mode: Some("horizontal".to_string()), - ..minimal_create_options() - }) - .unwrap(); - assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); - assert_eq!(request.min_replicas, Some(2)); - assert_eq!(request.max_replicas, Some(8)); - assert!(request.num_replicas.is_none()); - assert!(request.min_replica_memory_gb.is_none()); - assert!(request.max_replica_memory_gb.is_none()); - } - - #[test] - fn build_create_service_request_replica_pair_without_mode_omits_mode() { - let request = build_create_service_request(&CreateServiceOptions { - min_replicas: Some(1), - max_replicas: Some(4), - ..minimal_create_options() - }) - .unwrap(); - assert!(request.autoscaling_mode.is_none()); - assert_eq!(request.min_replicas, Some(1)); - assert_eq!(request.max_replicas, Some(4)); - } - - #[test] - fn build_create_service_request_vertical_omits_horizontal_fields() { - let request = build_create_service_request(&CreateServiceOptions { - num_replicas: Some(3), - min_replica_memory_gb: Some(24), - max_replica_memory_gb: Some(48), - ..minimal_create_options() - }) - .unwrap(); - assert!(request.autoscaling_mode.is_none()); - assert!(request.min_replicas.is_none()); - assert!(request.max_replicas.is_none()); - assert_eq!(request.num_replicas, Some(3)); - assert_eq!(request.min_replica_memory_gb, Some(24.0)); - assert_eq!(request.max_replica_memory_gb, Some(48.0)); - } - - #[test] - fn build_create_service_request_rejects_min_without_max_replicas() { - for options in [ - CreateServiceOptions { - min_replicas: Some(2), - ..minimal_create_options() - }, - CreateServiceOptions { - max_replicas: Some(8), - ..minimal_create_options() - }, - ] { - let error = build_create_service_request(&options).unwrap_err(); - assert!(error.to_string().contains("--min-replicas")); - } - } - - #[test] - fn resolve_horizontal_autoscaling_explicit_vertical_with_no_replicas() { - let resolved = resolve_horizontal_autoscaling(Some("vertical"), None, None).unwrap(); - assert_eq!(resolved.autoscaling_mode, Some(AutoscalingMode::Vertical)); - assert!(resolved.min_replicas.is_none()); - assert!(resolved.max_replicas.is_none()); - } - - #[test] - fn build_scaling_schedule_request_supports_minimal_and_empty_schedules() { - let request = build_scaling_schedule_request( - serde_json::json!({ - "entries": [{ - "name": "Business hours", - "weekdays": [1], - "startHourUtc": 9, - "endHourUtc": 17 - }] - }), - "test.json", - ) - .unwrap(); - assert_eq!(request.entries.len(), 1); - let entry = &request.entries[0]; - assert_eq!(entry.name, "Business hours"); - assert_eq!(entry.weekdays, vec![1]); - assert_eq!(entry.start_hour_utc, 9); - assert_eq!(entry.end_hour_utc, 17); - assert!(entry.autoscaling_mode.is_none()); - assert!(entry.idle_scaling.is_none()); - assert!(entry.idle_timeout_minutes.is_none()); - assert!(entry.min_replica_memory_gb.is_none()); - assert!(entry.max_replica_memory_gb.is_none()); - assert!(entry.num_replicas.is_none()); - assert!(entry.min_replicas.is_none()); - assert!(entry.max_replicas.is_none()); - - let clear = build_scaling_schedule_request(serde_json::json!({"entries": []}), "test.json") - .unwrap(); - assert!(clear.entries.is_empty()); - } - - #[test] - fn build_scaling_schedule_request_preserves_all_entry_fields() { - let request = build_scaling_schedule_request( - serde_json::json!({ - "entries": [ - { - "name": "Vertical nights", - "weekdays": [0, 6], - "startHourUtc": 22, - "endHourUtc": 6, - "autoscalingMode": "vertical", - "idleScaling": true, - "idleTimeoutMinutes": 5, - "minReplicaMemoryGb": 8, - "maxReplicaMemoryGb": 32, - "numReplicas": 2 - }, - { - "name": "Horizontal weekdays", - "weekdays": [1, 2, 3, 4, 5], - "startHourUtc": 8, - "endHourUtc": 24, - "autoscalingMode": "horizontal", - "idleScaling": false, - "idleTimeoutMinutes": 0, - "minReplicaMemoryGb": 16, - "maxReplicaMemoryGb": 16, - "minReplicas": 2, - "maxReplicas": 7 - } - ] - }), - "schedule.json", - ) - .unwrap(); - - assert_eq!(request.entries.len(), 2); - let vertical = &request.entries[0]; - assert_eq!(vertical.autoscaling_mode, Some(AutoscalingMode::Vertical)); - assert_eq!(vertical.idle_scaling, Some(true)); - assert_eq!(vertical.idle_timeout_minutes, Some(5)); - assert_eq!(vertical.min_replica_memory_gb, Some(8.0)); - assert_eq!(vertical.max_replica_memory_gb, Some(32.0)); - assert_eq!(vertical.num_replicas, Some(2)); - assert!(vertical.min_replicas.is_none()); - assert!(vertical.max_replicas.is_none()); - - let horizontal = &request.entries[1]; - assert_eq!( - horizontal.autoscaling_mode, - Some(AutoscalingMode::Horizontal) - ); - assert_eq!(horizontal.weekdays, vec![1, 2, 3, 4, 5]); - assert_eq!(horizontal.end_hour_utc, 24); - assert_eq!(horizontal.idle_scaling, Some(false)); - assert_eq!(horizontal.idle_timeout_minutes, Some(0)); - assert_eq!(horizontal.min_replica_memory_gb, Some(16.0)); - assert_eq!(horizontal.max_replica_memory_gb, Some(16.0)); - assert_eq!(horizontal.min_replicas, Some(2)); - assert_eq!(horizontal.max_replicas, Some(7)); - assert!(horizontal.num_replicas.is_none()); - } - - #[test] - fn build_scaling_schedule_request_rejects_unknown_fields_and_modes() { - for value in [ - serde_json::json!({ - "entries": [{ - "name": "typo", - "weekdays": [1], - "startHourUtc": 9, - "endHourUtc": 17, - "minReplicaMemoryGB": 16 - }] - }), - serde_json::json!({ - "entries": [{ - "name": "future mode", - "weekdays": [1], - "startHourUtc": 9, - "endHourUtc": 17, - "autoscalingMode": "diagonal" - }] - }), - serde_json::json!({"entries": [], "baseConfig": {}}), - ] { - assert!(build_scaling_schedule_request(value, "bad.json").is_err()); - } - } - - #[test] - fn build_scaling_schedule_request_validates_documented_bounds_and_relationships() { - let invalid_entries = [ - serde_json::json!({"name":"x","weekdays":[],"startHourUtc":0,"endHourUtc":24}), - serde_json::json!({"name":"x","weekdays":[7],"startHourUtc":0,"endHourUtc":24}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":-1,"endHourUtc":24}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":0,"endHourUtc":25}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":8}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":8}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":12,"maxReplicaMemoryGb":8}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":10,"maxReplicaMemoryGb":12}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"numReplicas":0}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicas":1}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicas":3,"maxReplicas":2}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"numReplicas":2,"minReplicas":2,"maxReplicas":2}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"vertical","minReplicas":1,"maxReplicas":2}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"horizontal","minReplicaMemoryGb":16,"maxReplicaMemoryGb":16}), - serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"horizontal","minReplicaMemoryGb":16,"maxReplicaMemoryGb":20,"minReplicas":1,"maxReplicas":2}), - ]; - for entry in invalid_entries { - let error = - build_scaling_schedule_request(serde_json::json!({"entries": [entry]}), "bad.json") - .unwrap_err(); - assert!(error.message.contains("entries[0]"), "{error}"); - } - } - - #[test] - fn build_service_scale_request_supports_minimal_fields() { - let request = build_service_scale_request(&ServiceScaleOptions::default()).unwrap(); - - assert!(request.autoscaling_mode.is_none()); - assert!(request.min_replica_memory_gb.is_none()); - assert!(request.max_replica_memory_gb.is_none()); - assert!(request.num_replicas.is_none()); - assert!(request.min_replicas.is_none()); - assert!(request.max_replicas.is_none()); - assert!(request.idle_scaling.is_none()); - assert!(request.idle_timeout_minutes.is_none()); - } - - #[test] - fn build_service_scale_request_supports_horizontal_fields() { - let request = build_service_scale_request(&ServiceScaleOptions { - min_replicas: Some(2), - max_replicas: Some(8), - autoscaling_mode: Some("horizontal".to_string()), - ..Default::default() - }) - .unwrap(); - - assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); - assert_eq!(request.min_replicas, Some(2)); - assert_eq!(request.max_replicas, Some(8)); - assert!(request.min_replica_memory_gb.is_none()); - assert!(request.max_replica_memory_gb.is_none()); - assert!(request.num_replicas.is_none()); - } - - #[test] - fn build_service_scale_request_supports_maximal_vertical_fields() { - let request = build_service_scale_request(&ServiceScaleOptions { - autoscaling_mode: Some("vertical".to_string()), - num_replicas: Some(3), - min_replica_memory_gb: Some(8), - max_replica_memory_gb: Some(32), - idle_scaling: Some(false), - idle_timeout_minutes: Some(15), - ..Default::default() - }) - .unwrap(); - - assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Vertical)); - assert_eq!(request.num_replicas, Some(3)); - assert_eq!(request.min_replica_memory_gb, Some(8.0)); - assert_eq!(request.max_replica_memory_gb, Some(32.0)); - assert!(request.min_replicas.is_none()); - assert!(request.max_replicas.is_none()); - assert_eq!(request.idle_scaling, Some(false)); - assert_eq!(request.idle_timeout_minutes, Some(15.0)); - } - - #[test] - fn build_service_scale_request_supports_horizontal_memory_fields() { - let request = build_service_scale_request(&ServiceScaleOptions { - autoscaling_mode: Some("horizontal".to_string()), - min_replicas: Some(2), - max_replicas: Some(8), - min_replica_memory_gb: Some(16), - max_replica_memory_gb: Some(16), - ..Default::default() - }) - .unwrap(); - - assert_eq!(request.autoscaling_mode, Some(AutoscalingMode::Horizontal)); - assert_eq!(request.min_replicas, Some(2)); - assert_eq!(request.max_replicas, Some(8)); - assert_eq!(request.min_replica_memory_gb, Some(16.0)); - assert_eq!(request.max_replica_memory_gb, Some(16.0)); - assert!(request.num_replicas.is_none()); - } - - #[test] - fn build_service_scale_request_rejects_min_without_max_replicas() { - let error = build_service_scale_request(&ServiceScaleOptions { - max_replicas: Some(8), - ..Default::default() - }) - .unwrap_err(); - assert!(error.to_string().contains("--min-replicas")); - } - - #[test] - fn build_service_password_patch_request_supports_minimal_fields() { - let request = build_service_password_patch_request(&ServiceResetPasswordOptions::default()); - - assert!(request.new_password_hash.is_none()); - assert!(request.new_double_sha1_hash.is_none()); - } - - #[test] - fn build_service_password_patch_request_supports_maximal_fields() { - let request = build_service_password_patch_request(&ServiceResetPasswordOptions { - new_password_hash: Some("sha256".to_string()), - new_double_sha1_hash: Some("sha1".to_string()), - }); - - assert_eq!(request.new_password_hash.as_deref(), Some("sha256")); - assert_eq!(request.new_double_sha1_hash.as_deref(), Some("sha1")); - } - - #[test] - fn build_query_endpoint_create_request_supports_minimal_fields() { - let request = build_query_endpoint_create_request( - &QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into()], - allowed_origins: Some("https://example.com".into()), - ..Default::default() - }, - None, - ) - .unwrap(); - - assert_eq!(request.roles, [QueryEndpointRole::SqlConsoleReadOnly]); - assert!(request.open_api_keys.is_empty()); - assert_eq!( - request.allowed_origins.as_deref(), - Some("https://example.com") - ); - } - - #[test] - fn build_query_endpoint_create_request_supports_maximal_fields() { - let request = build_query_endpoint_create_request( - &QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into(), "sql_console_admin".into()], - open_api_keys: vec!["key-1".into(), "key-2".into(), "key-1".into()], - replace_open_api_keys: true, - allowed_origins: Some("*".into()), - }, - Some(&ServiceQueryAPIEndpoint { - open_api_keys: Some(vec!["old-key".into()]), - allowed_origins: Some("https://before.example.com".into()), - ..Default::default() - }), - ) - .unwrap(); - - assert_eq!( - request.roles, - [ - QueryEndpointRole::SqlConsoleReadOnly, - QueryEndpointRole::SqlConsoleAdmin - ] - ); - assert_eq!(request.open_api_keys, ["key-1", "key-2"]); - assert_eq!(request.allowed_origins.as_deref(), Some("*")); - } - - #[test] - fn build_query_endpoint_create_request_merges_keys_and_preserves_origins() { - let request = build_query_endpoint_create_request( - &QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into()], - open_api_keys: vec!["key-2".into(), "key-3".into(), "key-3".into()], - ..Default::default() - }, - Some(&ServiceQueryAPIEndpoint { - roles: Some(vec![QueryEndpointRole::SqlConsoleAdmin]), - open_api_keys: Some(vec!["key-1".into(), "key-2".into(), "key-1".into()]), - allowed_origins: Some("https://before.example.com".into()), - ..Default::default() - }), - ) - .unwrap(); - - assert_eq!(request.roles, [QueryEndpointRole::SqlConsoleReadOnly]); - assert_eq!(request.open_api_keys, ["key-1", "key-2", "key-3"]); - assert_eq!( - request.allowed_origins.as_deref(), - Some("https://before.example.com") - ); - } - - #[test] - fn build_query_endpoint_create_request_requires_roles_and_first_origins() { - assert!( - build_query_endpoint_create_request(&QueryEndpointCreateOptions::default(), None) - .is_err() - ); - assert!( - build_query_endpoint_create_request( - &QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into()], - ..Default::default() - }, - None - ) - .is_err() - ); - } - - #[test] - fn build_query_endpoint_create_request_preserves_explicitly_empty_state() { - let request = build_query_endpoint_create_request( - &QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into()], - ..Default::default() - }, - Some(&ServiceQueryAPIEndpoint { - open_api_keys: Some(vec![]), - allowed_origins: Some(String::new()), - ..Default::default() - }), - ) - .unwrap(); - assert!(request.open_api_keys.is_empty()); - assert_eq!(request.allowed_origins.as_deref(), Some("")); - } - - #[test] - fn build_query_endpoint_create_request_rejects_unknown_keys_and_passes_absent_origins() { - let existing = ServiceQueryAPIEndpoint::default(); - let mut options = QueryEndpointCreateOptions { - roles: vec!["sql_console_read_only".into()], - open_api_keys: vec!["key-1".into()], - ..Default::default() - }; - assert!(build_query_endpoint_create_request(&options, Some(&existing)).is_err()); - options.replace_open_api_keys = true; - // An existing endpoint that reports no `allowedOrigins` is written back - // without the field. - let request = build_query_endpoint_create_request(&options, Some(&existing)).unwrap(); - assert_eq!(request.allowed_origins, None); - assert!( - serde_json::to_value(&request) - .unwrap() - .get("allowedOrigins") - .is_none() - ); - options.allowed_origins = Some("https://example.com".into()); - let request = build_query_endpoint_create_request(&options, Some(&existing)).unwrap(); - assert_eq!(request.open_api_keys, ["key-1"]); - assert_eq!( - request.allowed_origins.as_deref(), - Some("https://example.com") - ); - } - - #[test] - fn build_private_endpoint_create_request_supports_minimal_fields() { - let request = build_private_endpoint_create_request("vpce-0123456789abcdef0", None); - - assert_eq!(request.id, "vpce-0123456789abcdef0"); - assert_eq!(request.description.as_deref(), Some("")); - // The spec makes `description` optional; the wire request still sends `""`. - assert_eq!(serde_json::to_value(&request).unwrap()["description"], ""); - } - - #[test] - fn build_private_endpoint_create_request_supports_maximal_fields() { - let request = - build_private_endpoint_create_request("vpce-0123456789abcdef0", Some("production")); - - assert_eq!(request.id, "vpce-0123456789abcdef0"); - assert_eq!(request.description.as_deref(), Some("production")); - } - - #[test] - fn build_service_state_patch_request_preserves_every_command() { - let start = build_service_state_patch_request(ServiceStatePatchRequestCommand::Start); - let stop = build_service_state_patch_request(ServiceStatePatchRequestCommand::Stop); - let awake = build_service_state_patch_request(ServiceStatePatchRequestCommand::Awake); - - assert_eq!(start.command, Some(ServiceStatePatchRequestCommand::Start)); - assert_eq!(stop.command, Some(ServiceStatePatchRequestCommand::Stop)); - assert_eq!(awake.command, Some(ServiceStatePatchRequestCommand::Awake)); - } - - #[test] - fn inline_query_only_conflicts_with_bytes_waiting_on_stdin() { - // The whole matrix of stdin states crossed with --query being present - // (#641). Only "not a terminal and data is already waiting" conflicts: - // a terminal must never be read, and a non-terminal empty stdin is the - // normal shape for CI and for coding agents. - assert!(!inline_query_conflicts_with_stdin( - Some("SELECT 1"), - StdinInput::Terminal - )); - assert!(!inline_query_conflicts_with_stdin( - Some("SELECT 1"), - StdinInput::Empty - )); - assert!(inline_query_conflicts_with_stdin( - Some("SELECT 1"), - StdinInput::Pending - )); - - assert!(!inline_query_conflicts_with_stdin( - None, - StdinInput::Terminal - )); - assert!(!inline_query_conflicts_with_stdin(None, StdinInput::Empty)); - // Without --query, piped bytes are the SQL itself, not a conflict. - assert!(!inline_query_conflicts_with_stdin( - None, - StdinInput::Pending - )); - } - - /// A real unnamed pipe as `(read_fd, write_fd)`. - fn test_pipe() -> (libc::c_int, libc::c_int) { - let mut fds = [0 as libc::c_int; 2]; - // SAFETY: `fds` is a two-element array, which is what `pipe` writes. - let created = unsafe { libc::pipe(fds.as_mut_ptr()) }; - assert_eq!(created, 0, "pipe() failed"); - (fds[0], fds[1]) - } - - fn write_byte(fd: libc::c_int) { - // SAFETY: one byte is written from a live local buffer. - let written = unsafe { libc::write(fd, b"x".as_ptr().cast(), 1) }; - assert_eq!(written, 1, "write() failed"); - } - - fn close_fd(fd: libc::c_int) { - // SAFETY: each fd is closed exactly once by its owning test. - unsafe { libc::close(fd) }; - } - - #[test] - fn readiness_waits_for_a_producer_that_has_not_written_its_first_byte() { - // The #641 race: `cat data.csv | clickhousectl ... --query ...` starts - // both processes at once, so the CLI can look at stdin before the - // producer has written. A zero timeout calls that "no input" and sends - // the empty INSERT, which is the whole bug; the bounded wait sees the - // byte. This is asserted on a real pipe rather than through a - // subprocess so the interleaving is not left to process startup. - let (read_fd, write_fd) = test_pipe(); - assert!( - !fd_becomes_readable(read_fd, 0), - "nothing has been written yet" - ); - - let producer = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(100)); - write_byte(write_fd); - close_fd(write_fd); - }); - - let started = std::time::Instant::now(); - assert!(fd_becomes_readable(read_fd, STDIN_FIRST_BYTE_TIMEOUT_MS)); - assert!( - started.elapsed() < Duration::from_millis(STDIN_FIRST_BYTE_TIMEOUT_MS as u64 + 500), - "the wait must stay bounded" - ); - - producer.join().unwrap(); - close_fd(read_fd); - } - - #[test] - fn readiness_gives_up_on_a_pipe_nobody_writes_to() { - // A harness that wired up stdin and produced nothing must not hang the - // command: the wait expires and the run continues. - let (read_fd, write_fd) = test_pipe(); - - let started = std::time::Instant::now(); - assert!(!fd_becomes_readable(read_fd, STDIN_FIRST_BYTE_TIMEOUT_MS)); - let waited = started.elapsed(); - assert!( - waited < Duration::from_millis(STDIN_FIRST_BYTE_TIMEOUT_MS as u64 + 500), - "waited {waited:?}" - ); - - close_fd(write_fd); - close_fd(read_fd); - } - - #[test] - fn readiness_is_immediate_for_data_that_is_already_waiting() { - let (read_fd, write_fd) = test_pipe(); - write_byte(write_fd); - - assert!(fd_becomes_readable(read_fd, 0)); - - close_fd(write_fd); - close_fd(read_fd); - } - - #[test] - fn the_stdin_conflict_message_says_what_does_work() { - assert!( - INLINE_QUERY_WITH_STDIN_ERROR - .starts_with("--query cannot be combined with SQL or data on stdin.") - ); - assert!(INLINE_QUERY_WITH_STDIN_ERROR.contains("cat - data.csv")); - assert!(INLINE_QUERY_WITH_STDIN_ERROR.contains("--queries-file -")); - } - - #[test] - fn parses_service_settings_set_inputs() { - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "settings", - "set", - "svc-1", - "--setting", - "compatibility=\"24.8\"", - "--setting", - "enable_analyzer=1", - "--org-id", - "org-1", - ]); - let ServiceCommands::Settings { - command: - ServiceSettingsCommands::Set { - service_id, - setting, - settings_file, - }, - } = command - else { - panic!("expected settings set"); - }; - assert_eq!(service_id.id.as_deref(), Some("svc-1")); - assert_eq!(setting, ["compatibility=\"24.8\"", "enable_analyzer=1"]); - assert!(settings_file.is_none()); - - let command = parse_service(&[ - "clickhousectl", - "cloud", - "service", - "settings", - "set", - "svc-1", - "--file", - "-", - ]); - let ServiceCommands::Settings { - command: ServiceSettingsCommands::Set { settings_file, .. }, - } = command - else { - panic!("expected settings set"); - }; - assert_eq!(settings_file.as_deref(), Some("-")); - } - - #[test] - fn service_settings_set_requires_exactly_one_input_kind() { - let missing = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "settings", - "set", - "svc-1", - ]) - .err() - .expect("missing settings input should fail"); - assert_eq!( - missing.kind(), - clap::error::ErrorKind::MissingRequiredArgument - ); - - let conflict = Cli::try_parse_from([ - "clickhousectl", - "cloud", - "service", - "settings", - "set", - "svc-1", - "--setting", - "compatibility=\"24.8\"", - "--file", - "settings.json", - ]) - .err() - .expect("conflicting settings inputs should fail"); - assert_eq!(conflict.kind(), clap::error::ErrorKind::ArgumentConflict); - } - - #[test] - fn builds_settings_patch_with_object_and_preserved_types() { - let minimal = BTreeMap::from([("compatibility".to_string(), serde_json::json!("24.8"))]); - let request = build_service_settings_patch_request(&minimal).unwrap(); - assert_eq!(request.settings, Some(minimal)); - - let maximal = BTreeMap::from([ - ("bool_setting".to_string(), serde_json::json!(false)), - ("null_setting".to_string(), serde_json::Value::Null), - ("number_setting".to_string(), serde_json::json!(42)), - ("string_setting".to_string(), serde_json::json!("literal")), - ( - "unknown_future_setting".to_string(), - serde_json::json!([1, 2]), - ), - ]); - let request = build_service_settings_patch_request(&maximal).unwrap(); - assert_eq!(request.settings, Some(maximal.clone())); - assert_eq!( - serde_json::to_value(&request).unwrap()["settings"], - serde_json::json!({ - "bool_setting": false, - "null_setting": null, - "number_setting": 42, - "string_setting": "literal", - "unknown_future_setting": [1, 2] - }) - ); - } - - #[test] - fn parses_dynamic_setting_assignments_without_a_name_allowlist() { - let settings = parse_setting_assignments(&[ - "future_setting={\"nested\":true}".to_string(), - "compatibility=\"24.8\"".to_string(), - ]) - .unwrap(); - assert_eq!( - settings["future_setting"], - serde_json::json!({"nested": true}) - ); - assert_eq!(settings["compatibility"], serde_json::json!("24.8")); - } - - #[test] - fn settings_file_rejects_api_wrapper_shape() { - let error = parse_settings_map_document( - r#"{"settings":"{\"compatibility\":\"24.8\"}"}"#, - "settings file", - ) - .unwrap_err(); - assert!(error.to_string().contains("API request wrapper")); - } - - #[test] - fn settings_file_reads_a_plain_dynamic_map() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("settings.json"); - std::fs::write(&path, r#"{"compatibility":"24.8","future_setting":false}"#).unwrap(); - let settings = read_service_settings(&[], Some(path.to_str().unwrap())).unwrap(); - assert_eq!(settings["compatibility"], serde_json::json!("24.8")); - assert_eq!(settings["future_setting"], serde_json::json!(false)); - } -} diff --git a/crates/clickhousectl/src/cloud/services/query.rs b/crates/clickhousectl/src/cloud/services/query.rs new file mode 100644 index 00000000..bf578510 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/query.rs @@ -0,0 +1,2077 @@ +use crate::cloud::client::{CloudClient, CloudError, Result as CloudResult}; +use crate::cloud::credentials; +use crate::cloud::output::{CloudErrorCode, CloudErrorDetail, eprint_line, or_absent}; +use crate::cloud::services::query_key::{CallerKeyBinding, RepairVerification}; +use crate::cloud::shared::resolve_org_id; +use crate::failure::{self, ApiFailure, FailureKind, FailureStage, ProvisioningState}; +use clickhouse_cloud_api::models::{ + Service, ServiceEndpoint, ServiceEndpointProtocol, ServiceState, +}; +use std::{io::IsTerminal, time::Duration}; + +#[derive(Clone, Copy)] +struct QueryEndpointReadiness { + timeout: Duration, + initial_backoff: Duration, + max_backoff: Duration, +} + +const QUERY_ENDPOINT_READINESS: QueryEndpointReadiness = QueryEndpointReadiness { + timeout: Duration::from_secs(120), + initial_backoff: Duration::from_millis(200), + max_backoff: Duration::from_secs(5), +}; + +/// Resolve a service by name or ID within the given org. +/// Exactly one of `name` or `id` must be provided. +async fn resolve_service( + client: &CloudClient, + org_id: &str, + name: Option<&str>, + id: Option<&str>, +) -> CloudResult { + match (name, id) { + (Some(name), None) => { + let services = client.list_services(org_id).await?; + let id = crate::cloud::shared::select_named_id( + "service", + name, + services + .iter() + .map(|service| (service.name.as_deref(), service.id.as_ref())), + )?; + services + .into_iter() + .find(|service| { + service + .id + .as_ref() + .is_some_and(|candidate| candidate.to_string() == id) + }) + .ok_or_else(|| CloudError::new("resolved service is missing from the list")) + } + (None, Some(id)) => Ok(client.get_service(org_id, id).await?), + (Some(_), Some(_)) => Err(CloudError::new( + "supply either a positional service ID or --name", + )), + // Clap rejects this state; retain the check for any future non-CLI caller. + (None, None) => Err(CloudError::new( + "supply a positional service ID or --name to identify the service", + )), + } +} + +pub(super) async fn service_query_key_repair( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let result = + crate::cloud::services::query_key::repair_service_query_key(client, &org_id, service_id) + .await?; + // A retired key that could not be deleted is a warning, not a failure: the + // replacement is active and its ID is stored for the retry (#527). The + // warning goes to stderr in both modes; the JSON result carries the IDs. + let mut result = result; + if let Some(warning) = &result.cleanup_warning { + eprint_line(warning); + } + // The repair is committed before the probe runs, so its result is printed + // whatever the probe says (#658): a `--json` consumer must never lose the + // IDs of a repair that happened. Only a probe that kept being rejected + // for the whole readiness window is an error, and it is reported after + // the result. + let mut verification_error = None; + if result.status == "repaired" { + match verify_repaired_query_key( + client, + &org_id, + service_id, + &result.api_key_id, + QUERY_ENDPOINT_READINESS, + ) + .await + { + Ok(verification) => result.verification = Some(verification), + Err(error) => { + result.verification = Some(RepairVerification::Failed); + verification_error = Some(error); + } + } + } + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + match result.status { + "already_repaired" => println!( + "Query key for service {} was already repaired by another process (active API key: {})", + result.service_id, result.api_key_id + ), + _ => { + println!("Query key repaired for service {}", result.service_id); + println!( + " Replaced API key: {}", + or_absent(result.replaced_api_key_id.as_deref()) + ); + println!(" New API key: {}", result.api_key_id); + println!(" Query endpoint: {}", result.endpoint_id); + if !result.deleted_api_key_ids.is_empty() { + println!( + " Deleted API keys: {}", + result.deleted_api_key_ids.join(", ") + ); + } + if !result.pending_cleanup_api_key_ids.is_empty() { + println!( + " API keys awaiting deletion: {}", + result.pending_cleanup_api_key_ids.join(", ") + ); + } + if let Some(verification) = result.verification { + println!(" Query API check: {}", verification.as_str()); + } + } + } + } + match verification_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +/// Confirm that the Query API accepts the key a repair just committed (#658). +/// +/// The new binding reaches the Query API host a moment after the upsert; a +/// query issued in between is rejected, and the stored-key classifier (#528) +/// would then read an enabled, bound key as "IP allowlist or secret +/// mismatch", a verdict that is wrong and points nowhere useful. So repair +/// probes the endpoint with the new key the way first-use provisioning does. +/// +/// The repair is committed before this runs and nothing here undoes it. The +/// outcome is reported, not acted on: `Verified` when a probe succeeded; +/// `Skipped` when the key was not probed (no Query API host configured, the +/// service not `running` or its state unreadable, the record unreadable or +/// replaced by a concurrent repair, or the probe answered idle/stopped); +/// `Failed` when the probe failed for a reason unrelated to readiness. Only a +/// key the Query API kept rejecting for the whole readiness window is an +/// error, and even then the message says the repair stands and points at +/// `cloud service query`, which classifies the rejection — never at rerunning +/// the repair, which would rotate a key that may simply not have propagated +/// yet. +async fn verify_repaired_query_key( + client: &CloudClient, + org_id: &str, + service_id: &str, + api_key_id: &str, + readiness: QueryEndpointReadiness, +) -> CloudResult { + let stored = credentials::try_get_service_query_key(service_id); + let (key_id, key_secret) = match repaired_query_credential(stored, service_id, api_key_id) { + Ok(credential) => credential, + Err(reason) => { + return Ok(skip_repair_verification( + org_id, service_id, api_key_id, &reason, + )); + } + }; + probe_repaired_query_key( + client, + org_id, + service_id, + api_key_id, + &key_id, + &key_secret, + readiness, + ) + .await +} + +/// The credential pair to probe with, if the record still names the key the +/// repair just stored. A record that vanished, that a concurrent repair +/// replaced, or that could not be read back at all is not this run's to +/// verify; the `Err` carries the reason the probe is skipped. The repair is +/// already committed, so re-reading it is never what turns the run into a +/// failure. +fn repaired_query_credential( + stored: CloudResult>, + service_id: &str, + api_key_id: &str, +) -> Result<(String, String), String> { + let stored = stored.map_err(|error| { + format!("the stored query key for service {service_id} could not be read ({error})") + })?; + let changed = || { + format!( + "the stored query key for service {service_id} changed before the replacement could \ + be probed" + ) + }; + let stored = stored.ok_or_else(changed)?; + if stored.api_key_id.as_deref() != Some(api_key_id) { + return Err(changed()); + } + Ok((stored.key_id, stored.key_secret)) +} + +fn next_query_command(service_id: &str, org_id: &str) -> String { + format!("clickhousectl cloud service query {service_id} --org-id {org_id} --query \"SELECT 1\"") +} + +/// Print the one `Note:` line every skipped verification gets, and return +/// `Skipped`. A closed stderr must not panic: the repair is already done. +fn skip_repair_verification( + org_id: &str, + service_id: &str, + api_key_id: &str, + reason: &str, +) -> RepairVerification { + eprint_line(format!( + "Note: {reason}, so the repaired query key was not probed; the replacement (API key \ + {api_key_id}) is stored in .clickhouse/credentials.json and is verified by the next `{}`", + next_query_command(service_id, org_id) + )); + RepairVerification::Skipped +} + +#[allow(clippy::too_many_arguments)] +async fn probe_repaired_query_key( + client: &CloudClient, + org_id: &str, + service_id: &str, + api_key_id: &str, + key_id: &str, + key_secret: &str, + readiness: QueryEndpointReadiness, +) -> CloudResult { + // Never probe production by accident: a control plane the query host + // cannot be derived from (a local mock, an unusual base URL) means the + // library would fall back to the production host. + if client.configured_query_host().is_none() { + return Ok(skip_repair_verification( + org_id, + service_id, + api_key_id, + &format!( + "no Query API host is configured for {} (set CLICKHOUSE_CLOUD_QUERY_HOST)", + client.base_url() + ), + )); + } + let state = match client.get_service(org_id, service_id).await { + Ok(service) => service.state, + Err(error) => { + return Ok(skip_repair_verification( + org_id, + service_id, + api_key_id, + &format!("could not read service {service_id}: {error}"), + )); + } + }; + if state != Some(ServiceState::Running) { + // A probe would wake an idle service, and a stopped one cannot answer. + let state = state + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "in an unknown state".to_string()); + return Ok(skip_repair_verification( + org_id, + service_id, + api_key_id, + &format!("service {service_id} is {state}"), + )); + } + + let outcome = wait_for_query_endpoint_readiness(readiness, || { + client.run_service_query( + service_id, + key_id, + key_secret, + "SELECT 1", + None, + "TabSeparated", + false, + ) + }) + .await; + let repaired = format!( + "the query key for service {service_id} was replaced (new API key {api_key_id}) and \ + stored in .clickhouse/credentials.json" + ); + match outcome { + Ok(false) => Ok(RepairVerification::Verified), + Ok(true) => Ok(skip_repair_verification( + org_id, + service_id, + api_key_id, + &format!("service {service_id} is idle"), + )), + Err(QueryReadinessError::Api(clickhouse_cloud_api::Error::ServiceStopped)) => { + Ok(skip_repair_verification( + org_id, + service_id, + api_key_id, + &format!("service {service_id} is stopped"), + )) + } + Err(QueryReadinessError::Api(error)) => { + let converted = client.convert_error(error); + eprint_line(format!( + "Warning: {repaired}, but probing it failed: {converted}; it is verified by the \ + next `{}`", + next_query_command(service_id, org_id) + )); + Ok(RepairVerification::Failed) + } + Err(QueryReadinessError::TimedOut(timeout)) => { + let command = next_query_command(service_id, org_id); + let message = format!( + "{repaired}, but the Query API kept rejecting it for {timeout:?}. Do not rerun \ + repair-query-key: that would rotate a key that may only be slow to propagate. \ + Run `{command}`, which classifies the rejection" + ); + Err(CloudError::new(message.clone()) + .with_failure(ApiFailure::new(FailureKind::Timeout)) + .with_details(CloudErrorDetail { + code: CloudErrorCode::QueryKeyRepairUnverified, + message, + host: None, + port: None, + command: Some(command), + api_key_id: Some(api_key_id.to_string()), + ip_access_list: None, + }) + .at_stage(FailureStage::QueryRequest)) + } + } +} + +pub(super) struct ServiceQueryOptions { + pub(super) name: Option, + pub(super) id: Option, + pub(super) query: Option, + pub(super) queries_file: Option, + pub(super) database: Option, + pub(super) format: Option, + pub(super) json: bool, + + pub(super) no_auto_enable: bool, +} + +#[allow(clippy::too_many_arguments)] +async fn run_basic_service_query( + client: &CloudClient, + service_id: &str, + key_id: &str, + key_secret: &str, + sql: &str, + database: Option<&str>, + format: &str, + service_name: &str, + confirmed_idle: bool, +) -> Result { + run_service_query_with_wake(service_name, confirmed_idle, |wake| { + client.run_service_query(service_id, key_id, key_secret, sql, database, format, wake) + }) + .await +} + +/// Preserve an explicit pre-execution idle response when the following wake +/// attempt fails. A timeout alone never permits another SQL submission. +struct QueryRequestError { + error: clickhouse_cloud_api::Error, + wake_requested: bool, +} + +impl QueryRequestError { + fn into_cloud_error(self, client: &CloudClient, target: QueryTarget<'_>) -> CloudError { + convert_query_error( + client, + self.error, + QueryTarget { + may_be_waking: target.may_be_waking || self.wake_requested, + ..target + }, + ) + } +} + +async fn run_service_query_with_wake( + service_name: &str, + confirmed_idle: bool, + run: F, +) -> Result +where + F: Fn(bool) -> Fut, + Fut: std::future::Future>, +{ + let waking_error = |error| QueryRequestError { + error, + wake_requested: true, + }; + if confirmed_idle { + eprint_waking_service(service_name); + failure::note_retry(); + return run(true).await.map_err(waking_error); + } + + match run(false).await { + Err(clickhouse_cloud_api::Error::ServiceIdle) => { + eprint_waking_service(service_name); + failure::note_retry(); + run(true).await.map_err(waking_error) + } + other => other.map_err(|error| QueryRequestError { + error, + wake_requested: false, + }), + } +} + +/// Why the readiness wait ended without the endpoint accepting a probe. +/// +/// The deadline is the CLI's own: no HTTP response stands behind it, so it is +/// a variant of its own rather than a synthesized `Error::Api { status: 408 }` +/// — telemetry reports `http_status` as the exact status the server sent, and +/// a made-up one would be a lie there (#450). +#[derive(Debug)] +enum QueryReadinessError { + /// The endpoint was still rejecting probes when the deadline elapsed. + TimedOut(Duration), + /// A probe failed for a reason other than "not ready yet". + Api(clickhouse_cloud_api::Error), +} + +fn query_readiness_timeout_error(timeout: Duration) -> CloudError { + CloudError::new(format!( + "Query API endpoint did not become ready within {timeout:?}" + )) + .with_failure(ApiFailure::new(FailureKind::Timeout)) +} + +async fn wait_for_query_endpoint_readiness( + readiness: QueryEndpointReadiness, + mut probe: F, +) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let deadline = tokio::time::Instant::now() + readiness.timeout; + let mut backoff = readiness.initial_backoff; + let mut waiting = false; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(QueryReadinessError::TimedOut(readiness.timeout)); + } + + match tokio::time::timeout(remaining, probe()).await { + Ok(Ok(_)) => return Ok(false), + Ok(Err(clickhouse_cloud_api::Error::ServiceIdle)) => return Ok(true), + Ok(Err(error)) if query_endpoint_readiness_error(&error) => {} + Ok(Err(error)) => return Err(QueryReadinessError::Api(error)), + Err(_) => return Err(QueryReadinessError::TimedOut(readiness.timeout)), + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(QueryReadinessError::TimedOut(readiness.timeout)); + } + if !waiting { + eprint_line("Waiting for the Query API endpoint to become ready..."); + waiting = true; + } + // One more probe is about to be sent: the retry bucket is how a + // dashboard sees "this run waited on propagation" (#450). + failure::note_retry(); + tokio::time::sleep(backoff.min(remaining)).await; + backoff = backoff.saturating_mul(2).min(readiness.max_backoff); + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_just_provisioned_service_query( + client: &CloudClient, + key_id: &str, + key_secret: &str, + sql: &str, + database: Option<&str>, + format: &str, + target: QueryTarget<'_>, + readiness: QueryEndpointReadiness, +) -> CloudResult { + run_just_provisioned_service_query_or( + client, + key_id, + key_secret, + sql, + database, + format, + target, + readiness, + query_readiness_timeout_error, + ) + .await +} + +/// [`run_just_provisioned_service_query`], reporting an exhausted readiness +/// window with `on_timeout` (given the window) instead of the generic +/// timeout error. Every other failure converts as usual. +#[allow(clippy::too_many_arguments)] +async fn run_just_provisioned_service_query_or( + client: &CloudClient, + key_id: &str, + key_secret: &str, + sql: &str, + database: Option<&str>, + format: &str, + target: QueryTarget<'_>, + readiness: QueryEndpointReadiness, + on_timeout: impl FnOnce(Duration) -> CloudError, +) -> CloudResult { + let confirmed_idle = wait_for_query_endpoint_readiness(readiness, || { + client.run_service_query( + target.service_id, + key_id, + key_secret, + "SELECT 1", + None, + "TabSeparated", + false, + ) + }) + .await + .map_err(|error| match error { + QueryReadinessError::TimedOut(timeout) => on_timeout(timeout), + QueryReadinessError::Api(error) => convert_query_error(client, error, target), + })?; + + run_basic_service_query( + client, + target.service_id, + key_id, + key_secret, + sql, + database, + format, + target.service_name, + confirmed_idle, + ) + .await + .map_err(|error| error.into_cloud_error(client, target)) +} + +/// Whether an error means "the Query API endpoint is not (yet) usable by this +/// credential" rather than "the query itself failed". +/// +/// The test is structural: a SQL-level rejection arrives as +/// `Error::Sql`, which proves the service ran the request, so the endpoint is +/// demonstrably usable. It used to be told apart by sniffing the formatted +/// message for a `SQL error ` prefix (#450). +fn query_endpoint_readiness_error(error: &clickhouse_cloud_api::Error) -> bool { + matches!( + error, + clickhouse_cloud_api::Error::Api { + status: 401 | 403 | 404, + .. + } + ) +} + +fn stored_query_key_rejection_status(error: &clickhouse_cloud_api::Error) -> Option { + match error { + clickhouse_cloud_api::Error::Api { + status: status @ (401 | 403), + .. + } => Some(*status), + _ => None, + } +} + +/// The `--no-auto-enable` refusal: the Query API rejected the management key +/// (401/403/404) and binding that key to the endpoint would have fixed that, +/// but the caller forbade it. The message is the CLI's; the classification stays the API's actual +/// rejection, carried across from the variant rather than reset to `other` +/// (#450). +fn refused_query_provisioning_error( + service_id: &str, + rejection: &clickhouse_cloud_api::Error, +) -> CloudError { + CloudError::new(format!( + "the authenticated API key cannot use the Query API endpoint for service {service_id}, \ + and --no-auto-enable prevents binding it to the endpoint" + )) + .with_failure(failure::classify_api_error(rejection)) +} + +pub(super) async fn service_query( + client: &CloudClient, + options: ServiceQueryOptions, +) -> CloudResult<()> { + // Anchor the duration bucket of any failure classification (#450) at the + // start of the run rather than at process start, so it measures the query + // and not the CLI's own startup. + failure::start_span(); + + let sql = read_query_sql(options.query.as_deref(), options.queries_file.as_deref()) + .map_err(|error| error.at_stage(FailureStage::SqlInput))?; + let org_id = resolve_org_id(client) + .await + .map_err(|error| error.at_stage(FailureStage::OrgResolution))?; + let service = resolve_service( + client, + &org_id, + options.name.as_deref(), + options.id.as_deref(), + ) + .await + .map_err(|error| error.at_stage(FailureStage::ServiceResolution))?; + let service_id = match service.id { + Some(id) => id.to_string(), + None => options.id.clone().ok_or_else(|| { + CloudError::new("the API response is missing the service id") + .at_stage(FailureStage::ServiceResolution) + })?, + }; + let service_name = or_absent(service.name.as_deref()); + // Resolved once, from the `GET service` response the query already needed: + // the gateway-timeout hint spells out a `clickhouse client` command, and + // it must use the service's real native endpoint when the API returned + // one (#644). + let native = native_secure_endpoint(service.endpoints.as_deref()); + let target = QueryTarget { + service_name: &service_name, + service_id: &service_id, + org_id: &org_id, + native: native.as_ref(), + // Use the service lookup already required for this query. This is + // evidence of a possible wake delay, not proof the SQL never ran. + may_be_waking: matches!( + service.state, + Some(ServiceState::Idle | ServiceState::Awaking) + ), + }; + + let format = options.format.unwrap_or_else(|| { + if options.json { + "JSONEachRow".to_string() + } else { + default_query_format() + } + }); + + // The `query_request` classifications below are a *fallback*: recording + // is first-write-wins, so an inner boundary that knows the exact stage + // (`whoami`, `endpoint_get`, ...) keeps its record and only failures + // no boundary claimed -- an unusable local credential store, say -- land + // on the coarse `query_request` stage rather than going unclassified + // (#450). + let response = if client.is_bearer_auth() { + // OAuth carries no write access, so no key or endpoint provisioning is + // possible on this path at all. + failure::set_provisioning_state(ProvisioningState::Bearer); + let run = |wake: bool| { + client.run_service_query_bearer( + &service_id, + &sql, + options.database.as_deref(), + &format, + wake, + ) + }; + run_service_query_with_wake(&service_name, false, run) + .await + .map_err(|error| { + error + .into_cloud_error(client, target) + .at_stage(FailureStage::QueryRequest) + })? + } else { + let convert = |error: QueryRequestError| error.into_cloud_error(client, target); + let result = if let Some(key) = credentials::try_get_service_query_key(&service_id) + .map_err(|error| error.at_stage(FailureStage::QueryRequest))? + { + failure::set_provisioning_state(ProvisioningState::StoredKey); + // A repair that could not delete a superseded key left its ID on + // the record; finish that here, quietly, before the query (#527). + // The list is empty on every ordinary run, so this costs nothing + // unless a retirement is actually outstanding. + crate::cloud::services::query_key::retry_pending_query_key_cleanup( + client, + &key, + &service_id, + &org_id, + ) + .await; + match run_basic_service_query( + client, + &service_id, + &key.key_id, + &key.key_secret, + &sql, + options.database.as_deref(), + &format, + &service_name, + false, + ) + .await + { + Err(error) => match stored_query_key_rejection_status(&error.error) { + // The 401/403 alone does not say whether the key is stale + // or was deliberately revoked; the management record + // decides, and nothing is replaced automatically (#528). + Some(status) => { + return Err( + crate::cloud::services::query_key::rejected_stored_query_key_error( + client, + &key, + &service_id, + &org_id, + status, + ) + .await + .at_stage(FailureStage::QueryRequest), + ); + } + None => Err(convert(error)), + }, + Ok(response) => Ok(response), + } + } else { + failure::set_provisioning_state(ProvisioningState::ManagementKey); + let (key_id, key_secret) = client.basic_auth_credentials().ok_or_else(|| { + CloudError::new("API key credentials are unavailable") + .at_stage(FailureStage::QueryRequest) + })?; + match run_basic_service_query( + client, + &service_id, + key_id, + key_secret, + &sql, + options.database.as_deref(), + &format, + &service_name, + false, + ) + .await + { + Err(error) if query_endpoint_readiness_error(&error.error) => { + if options.no_auto_enable { + failure::set_provisioning_state(ProvisioningState::Refused); + return Err(refused_query_provisioning_error(&service_id, &error.error) + .at_stage(FailureStage::QueryRequest)); + } + eprint_line(format!( + "Binding the authenticated API key to the Query API endpoint for \ + service '{}'...", + service_name + )); + // No key is created: the caller's own key is bound to the + // endpoint and the query re-runs with it (#1043). The bind + // records `BoundCallerKey` itself once it succeeds. + failure::set_provisioning_state(ProvisioningState::Provisioning); + let binding = crate::cloud::services::query_key::bind_caller_query_key( + client, + &org_id, + &service_id, + ) + .await + .map_err(|error| error.at_stage(FailureStage::QueryRequest))?; + match binding { + CallerKeyBinding::Bound => { + run_just_provisioned_service_query( + client, + key_id, + key_secret, + &sql, + options.database.as_deref(), + &format, + target, + QUERY_ENDPOINT_READINESS, + ) + .await + } + // Nothing was written. A concurrent run may have bound + // the key while this one waited for the lock, so wait + // for the endpoint as the binder does; only a key + // still rejected for the whole window means rebinding + // cannot help. + CallerKeyBinding::AlreadyBound { caller_uuid } => { + failure::set_provisioning_state(ProvisioningState::ManagementKey); + run_just_provisioned_service_query_or( + client, + key_id, + key_secret, + &sql, + options.database.as_deref(), + &format, + target, + QUERY_ENDPOINT_READINESS, + |waited| { + crate::cloud::services::query_key::bound_caller_key_rejected_error( + &service_id, + &org_id, + &caller_uuid, + waited, + ) + .at_stage(FailureStage::EndpointGet) + }, + ) + .await + } + } + } + other => other.map_err(convert), + } + }; + result.map_err(|error| error.at_stage(FailureStage::QueryRequest))? + }; + + use futures_util::StreamExt; + use std::io::Write as _; + let mut stream = response.bytes_stream(); + let mut handle = crate::stdout::stdout(); + let mut byte_count = 0; + let mut last_byte = None; + // Both failure modes of the stream are classified here, at the boundary + // that knows which one happened (#450): a truncated body is a transport + // failure, a refused write is local I/O. + let stream_failure = |error: CloudError| error.at_stage(FailureStage::ResponseStream); + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|error| { + stream_failure( + CloudError::new(format!("Failed to read query response: {error}")) + .with_failure(ApiFailure::new(FailureKind::Transport)), + ) + })?; + handle + .write_all(&bytes) + .map_err(|error| stream_failure(CloudError::from(error)))?; + byte_count += bytes.len(); + if let Some(last) = bytes.last() { + last_byte = Some(*last); + } + } + match query_output_completion(&format, byte_count, last_byte) { + QueryOutputCompletion::None => {} + QueryOutputCompletion::Newline => handle + .write_all(b"\n") + .map_err(|error| stream_failure(CloudError::from(error)))?, + QueryOutputCompletion::Acknowledge => eprint_line("OK"), + } + handle + .flush() + .map_err(|error| stream_failure(CloudError::from(error)))?; + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +enum QueryOutputCompletion { + None, + Newline, + Acknowledge, +} + +fn query_output_completion( + format: &str, + byte_count: usize, + last_byte: Option, +) -> QueryOutputCompletion { + if byte_count == 0 { + return QueryOutputCompletion::Acknowledge; + } + if query_format_uses_text_lines(format) && last_byte != Some(b'\n') { + QueryOutputCompletion::Newline + } else { + QueryOutputCompletion::None + } +} + +fn query_format_uses_text_lines(format: &str) -> bool { + matches!( + format.to_ascii_lowercase().as_str(), + "jsoneachrow" | "prettycompact" | "tabseparated" + ) +} + +fn eprint_waking_service(service_name: &str) { + eprint_line(format!( + "Service '{service_name}' is idle; waking it (this may take a minute)..." + )); +} + +/// A service's native-protocol (`nativesecure`) endpoint. +/// +/// Both halves are required. A hint that named the real host but guessed the +/// port would hand the user a command that does not connect, which is worse +/// than a command that visibly needs filling in. +#[derive(Debug, Clone, PartialEq, Eq)] +struct NativeEndpoint { + host: String, + port: i64, +} + +/// What the query path knows about its target service when it has to render +/// an error: enough to name the service, to point at `service start`, and to +/// spell out the native-protocol command that routes around the Query API +/// gateway timeout (#644). +#[derive(Debug, Clone, Copy)] +struct QueryTarget<'a> { + service_name: &'a str, + service_id: &'a str, + org_id: &'a str, + /// The service's `nativesecure` endpoint, when the `GET service` + /// response carried a complete one. + native: Option<&'a NativeEndpoint>, + /// An idle/awaking service lookup, or an explicit pre-execution idle + /// response followed by a wake request. Neither proves the SQL did not run. + may_be_waking: bool, +} + +/// The service's `nativesecure` endpoint, when the API returned a complete +/// one. +/// +/// Every field of a response model is optional, so a partial endpoint (a host +/// with no port, or the reverse) counts as absent here rather than being +/// half-rendered into a command. +fn native_secure_endpoint(endpoints: Option<&[ServiceEndpoint]>) -> Option { + endpoints?.iter().find_map(|endpoint| { + if endpoint.protocol != Some(ServiceEndpointProtocol::Nativesecure) { + return None; + } + Some(NativeEndpoint { + host: endpoint.host.clone()?, + port: endpoint.port?, + }) + }) +} + +/// Stand-in for a host the API did not return. A placeholder, never a guess. +const NATIVE_HOST_PLACEHOLDER: &str = ""; + +/// The standard native-protocol TLS port. Only used in the placeholder +/// command, where there is no endpoint to read a real port from. +const DEFAULT_NATIVE_SECURE_PORT: i64 = 9440; + +/// The `clickhouse client` command that reruns the statement over the native +/// protocol. +/// +/// The SQL and the password are always literal placeholders: the user's +/// statement is never echoed back into an error message, and no credential is +/// ever rendered. +fn native_client_command(native: Option<&NativeEndpoint>) -> String { + let (host, port) = match native { + Some(endpoint) => (endpoint.host.as_str(), endpoint.port), + None => (NATIVE_HOST_PLACEHOLDER, DEFAULT_NATIVE_SECURE_PORT), + }; + format!( + "clickhouse client --host {host} --secure --port {port} --user default \ + --password '' --query ''" + ) +} + +/// Diagnose a typed gateway timeout using the state observed before the +/// request (#826). A possible wake delay does not prove the statement was +/// never executed, so neither diagnosis permits an automatic retry. +fn query_timeout_error(target: QueryTarget<'_>) -> CloudError { + let details = if target.may_be_waking { + let command = format!( + "clickhousectl cloud service get {} --org-id {}", + target.service_id, target.org_id + ); + let message = format!( + "the query timed out and service '{}' may still be waking; it was idle or awaking \ + before the query attempt.\nThe statement may already have executed or may still be \ + running; it was not retried after the timeout.\nCheck the service state with \ + `{command}`. Once it is running, verify the statement's outcome before deciding \ + whether it is safe to run again.", + target.service_name + ); + CloudErrorDetail { + command: Some(command), + ..CloudErrorDetail::new(CloudErrorCode::QueryTimeout, message) + } + } else { + let command = native_client_command(target.native); + let endpoint_guidance = if target.native.is_some() { + String::new() + } else { + format!( + "\nThe API response carried no native endpoint for this service; read the host and \ + port from `clickhousectl cloud service get {} --org-id {}`.", + target.service_id, target.org_id + ) + }; + let message = format!( + "the query timed out at the Query API gateway, which stops waiting after about 30 \ + seconds.\nThe statement may still be running on the service or may already have \ + completed; check `SELECT query_id, elapsed FROM system.processes` and verify its \ + outcome before running it again.\n\nHint: run long statements over the native \ + protocol instead. Install the client with\n clickhousectl local use latest\nthen \ + rerun the query with\n {command}{endpoint_guidance}\nThe password is the one shown \ + when the service was created; `clickhousectl cloud service reset-password {}` \ + issues a new one.", + target.service_id + ); + CloudErrorDetail { + code: CloudErrorCode::QueryTimeout, + message, + host: target.native.map(|native| native.host.clone()), + port: target.native.map(|native| native.port), + command: Some(command), + api_key_id: None, + ip_access_list: None, + } + }; + + CloudError::new(details.message.clone()) + // Both diagnoses retain the typed timeout classification (#450). + .with_failure(failure::classify_api_error( + &clickhouse_cloud_api::Error::QueryTimeout, + )) + .with_details(details) +} + +fn convert_query_error( + client: &CloudClient, + error: clickhouse_cloud_api::Error, + target: QueryTarget<'_>, +) -> CloudError { + match error { + clickhouse_cloud_api::Error::ServiceStopped => CloudError::new(format!( + "service '{}' is stopped; start it with `clickhousectl cloud service start {} --org-id {}` and retry", + target.service_name, target.service_id, target.org_id + )) + // Rewriting the message must not lose the classification the variant + // already established (#450). + .with_failure(ApiFailure::new(FailureKind::ServiceStopped)), + clickhouse_cloud_api::Error::QueryTimeout => query_timeout_error(target), + other => client.convert_error(other), + } +} + +/// What stdin holds when `--query` is validated (#641). Derived from the file +/// descriptor only, never from anything the user typed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StdinInput { + /// An interactive terminal: nothing was piped or redirected in, and stdin + /// must not be read at all — a read would block on the human. + Terminal, + /// Not a terminal, and no byte arrived within + /// [`STDIN_FIRST_BYTE_TIMEOUT_MS`]: `< /dev/null`, a closed pipe, or a CI + /// runner or coding agent that has no tty and piped nothing. + Empty, + /// Not a terminal and at least one byte is available to read. + Pending, +} + +/// The message for `--query` plus data on stdin. The Query API takes a single +/// request body, so a statement and a separate data stream can never both be +/// sent; the message therefore leads with what does work. +const INLINE_QUERY_WITH_STDIN_ERROR: &str = "--query cannot be combined with SQL or data on stdin. \ + The Query API sends one request body, so redirected data is never read. Pipe the statement \ + and its data together on stdin instead: \ + printf 'INSERT INTO t FORMAT CSV\\n' | cat - data.csv | clickhousectl cloud service query \ + --id . Or read a whole statement from stdin with --queries-file -."; + +/// Whether `--query` conflicts with what is on stdin. Pure, so the decision is +/// unit-tested without a real file descriptor. Only bytes that are actually +/// waiting count: a non-terminal but empty stdin is the normal shape for CI +/// and for coding agents, so it must stay a valid way to run `--query`. +fn inline_query_conflicts_with_stdin(inline: Option<&str>, stdin: StdinInput) -> bool { + inline.is_some() && matches!(stdin, StdinInput::Pending) +} + +/// How long to wait for the first byte before deciding stdin carries nothing. +/// +/// A zero timeout would reintroduce #641 as a race: in `cat data.csv | +/// clickhousectl ... --query ...` the shell starts both processes at once, so +/// the CLI can reach `poll` before `cat` has written anything and would go on +/// to send the empty INSERT. A bounded wait closes that window. Nothing that +/// already knows its answer pays it: a regular file, `/dev/null`, a closed +/// pipe (`POLLHUP`) and a pipe that already holds data all make `poll` return +/// at once. The only invocation that waits is `--query` whose stdin is an open +/// pipe with nothing written yet — the harness case that must never hang — and +/// it waits this long rather than forever. +const STDIN_FIRST_BYTE_TIMEOUT_MS: libc::c_int = 250; + +/// Classify stdin without consuming it and without blocking indefinitely. +/// +/// `poll` answers "would a read block?" without reading; only once it says a +/// read is ready do we `fill_buf`, which performs one syscall and leaves the +/// bytes in the shared stdin buffer, so the other SQL sources are unaffected. +/// A hangup without readable data (`POLLHUP` alone) is an empty stdin, not +/// input. The deliberate residual is now narrow: a producer that has not +/// written its first byte within [`STDIN_FIRST_BYTE_TIMEOUT_MS`] still reads +/// as no input, which is the price of never hanging on a pipe nobody is +/// writing to. +fn stdin_input() -> StdinInput { + use std::io::BufRead as _; + + if std::io::stdin().is_terminal() { + return StdinInput::Terminal; + } + + if !fd_becomes_readable(libc::STDIN_FILENO, STDIN_FIRST_BYTE_TIMEOUT_MS) { + return StdinInput::Empty; + } + + match std::io::stdin().lock().fill_buf() { + Ok(buffered) if !buffered.is_empty() => StdinInput::Pending, + _ => StdinInput::Empty, + } +} + +/// Whether a read on `fd` would find something within `timeout_ms`. A hangup +/// with nothing readable (`POLLHUP` alone, a closed pipe) reports `false`, and +/// so does a `poll` that is interrupted or errors: the CLI fails open to "no +/// input" rather than refusing a run it cannot justify refusing. Split out +/// from [`stdin_input`] so the timeout is testable against a real pipe without +/// touching the process's own stdin. +fn fd_becomes_readable(fd: libc::c_int, timeout_ms: libc::c_int) -> bool { + let mut poll_fd = libc::pollfd { + fd, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: a single initialized `pollfd` is passed with a matching length, + // and the bounded timeout keeps the call from borrowing anything beyond + // its own return. + let ready = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) }; + ready > 0 && poll_fd.revents & libc::POLLIN != 0 +} + +/// Decode SQL at the Query API boundary without replacing invalid bytes. +/// +/// This happens before service lookup, so the native fallback deliberately +/// uses placeholders instead of guessing an endpoint or handling credentials. +fn decode_query_sql(bytes: Vec, source: &str) -> CloudResult { + String::from_utf8(bytes).map_err(|_| { + CloudError::new(format!( + concat!( + "SQL from {} is not valid UTF-8. The Query API accepts only UTF-8 SQL ", + "and text-format data; use the native protocol for binary formats such as ", + "RowBinary or Native, and for bulk input. Install the client with ", + "`clickhousectl local use latest`, then stream the statement and data with:\n ", + "printf 'INSERT INTO
FORMAT RowBinary\\n' | cat - | ", + "clickhousectl local client --host --port 9440 -- --secure ", + "--user default --password ''\nFind the native host and port with ", + "`clickhousectl cloud service get `." + ), + source + )) + .with_failure(ApiFailure::new(FailureKind::Io)) + }) +} + +fn read_query_sql(inline: Option<&str>, queries_file: Option<&str>) -> CloudResult { + use std::io::Read as _; + + if let Some(query) = inline { + if query.trim().is_empty() { + return Err(CloudError::new("--query was empty")); + } + if inline_query_conflicts_with_stdin(inline, stdin_input()) { + return Err(CloudError::new(INLINE_QUERY_WITH_STDIN_ERROR)); + } + return Ok(query.to_string()); + } + + if let Some(path) = queries_file { + let (bytes, source) = if path == "-" { + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes)?; + (bytes, "stdin".to_string()) + } else { + (std::fs::read(path)?, format!("queries file '{}'", path)) + }; + let content = decode_query_sql(bytes, &source)?; + if content.trim().is_empty() { + return Err(CloudError::new("queries file was empty")); + } + return Ok(content); + } + + if std::io::stdin().is_terminal() { + return Err(CloudError::new( + "no SQL provided. Pass --query, --queries-file, or pipe SQL on stdin.", + )); + } + + let mut bytes = Vec::new(); + std::io::stdin().read_to_end(&mut bytes)?; + let content = decode_query_sql(bytes, "stdin")?; + if content.trim().is_empty() { + return Err(CloudError::new("no SQL received on stdin")); + } + Ok(content) +} + +fn default_query_format() -> String { + if std::io::stdout().is_terminal() { + "PrettyCompact".to_string() + } else { + "TabSeparated".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_401_or_403_counts_as_a_stored_query_key_rejection() { + for status in [401, 403] { + let error = clickhouse_cloud_api::Error::Api { + status, + message: "rejected".into(), + }; + assert_eq!(stored_query_key_rejection_status(&error), Some(status)); + } + assert_eq!( + stored_query_key_rejection_status(&clickhouse_cloud_api::Error::Api { + status: 404, + message: "missing".into(), + }), + None + ); + } + + #[test] + fn query_output_completion_acknowledges_empty_responses() { + assert_eq!( + query_output_completion("TabSeparated", 0, None), + QueryOutputCompletion::Acknowledge + ); + assert_eq!( + query_output_completion("RowBinary", 0, None), + QueryOutputCompletion::Acknowledge + ); + } + + #[test] + fn query_output_completion_adds_only_a_missing_text_newline() { + for format in ["TabSeparated", "PrettyCompact", "JSONEachRow"] { + assert_eq!( + query_output_completion(format, 2, Some(b'K')), + QueryOutputCompletion::Newline + ); + } + assert_eq!( + query_output_completion("JSONEachRow", 2, Some(b'\n')), + QueryOutputCompletion::None + ); + } + + #[test] + fn query_output_completion_preserves_exact_and_binary_bodies() { + for format in [ + "Template", + "Buffers", + "BSONEachRow", + "RowBinary", + "Native", + "Parquet", + "ArrowStream", + "MsgPack", + ] { + assert_eq!( + query_output_completion(format, 3, Some(0)), + QueryOutputCompletion::None + ); + } + } + + #[test] + fn query_readiness_is_only_auth_or_generic_not_found() { + for status in [401, 403, 404] { + assert!(query_endpoint_readiness_error( + &clickhouse_cloud_api::Error::Api { + status, + message: "rejected".into(), + } + )); + } + for status in [400, 408, 429, 500] { + assert!(!query_endpoint_readiness_error( + &clickhouse_cloud_api::Error::Api { + status, + message: "query failed".into(), + } + )); + } + // A SQL-level rejection proves the endpoint *is* usable, and it is + // recognized by its variant rather than by its message text. + assert!(!query_endpoint_readiness_error( + &clickhouse_cloud_api::Error::Sql { + status: 404, + code: "60".into(), + details: "Unknown table".into(), + } + )); + assert!(!query_endpoint_readiness_error( + &clickhouse_cloud_api::Error::ServiceStopped + )); + } + + #[tokio::test] + async fn query_does_not_provision_or_retry_transport_errors() { + let transport_error = reqwest::Client::new() + .get("not a url") + .send() + .await + .unwrap_err(); + assert!(!query_endpoint_readiness_error( + &clickhouse_cloud_api::Error::Http(transport_error) + )); + } + + #[tokio::test] + async fn query_readiness_deadline_bounds_a_stalled_attempt() { + let readiness = QueryEndpointReadiness { + timeout: Duration::from_millis(10), + initial_backoff: Duration::ZERO, + max_backoff: Duration::ZERO, + }; + let result = tokio::time::timeout( + Duration::from_secs(1), + wait_for_query_endpoint_readiness(readiness, || { + std::future::pending::>() + }), + ) + .await + .expect("stalled attempt exceeded the test guard"); + + assert_query_readiness_timeout(result, readiness.timeout); + } + + // ── post-repair verification (issue #658) ─────────────────────────── + + /// The service under repair; a UUID because the service model's `id` is one. + const VERIFIED_SERVICE_ID: &str = "11111111-2222-3333-4444-555555555555"; + + const FAST_READINESS: QueryEndpointReadiness = QueryEndpointReadiness { + timeout: Duration::from_millis(60), + initial_backoff: Duration::from_millis(1), + max_backoff: Duration::from_millis(5), + }; + + async fn mount_running_service(control: &wiremock::MockServer) { + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path(format!( + "/v1/organizations/org-1/services/{VERIFIED_SERVICE_ID}" + ))) + .respond_with( + wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "result": { "id": VERIFIED_SERVICE_ID, "name": "demo", "state": "running" }, + "status": 200, + "requestId": "stub-service-get" + })), + ) + .mount(control) + .await; + } + + fn probes_received(requests: &[wiremock::Request]) -> usize { + requests + .iter() + .filter(|request| request.url.path() == format!("/service/{VERIFIED_SERVICE_ID}/run")) + .count() + } + + #[tokio::test] + async fn a_repair_verification_timeout_is_a_generic_error_with_its_own_code() { + let control = wiremock::MockServer::start().await; + mount_running_service(&control).await; + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path(format!( + "/service/{VERIFIED_SERVICE_ID}/run" + ))) + .respond_with( + wiremock::ResponseTemplate::new(401).set_body_string("API key is not authorized"), + ) + .mount(&control) + .await; + let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); + + let error = probe_repaired_query_key( + &client, + "org-1", + VERIFIED_SERVICE_ID, + "new-key-uuid", + "kid", + "sec", + FAST_READINESS, + ) + .await + .expect_err("a key rejected for the whole window is an error"); + + // Not an auth failure of the command: the management credentials + // worked, and exit code 4 would send the user to `cloud auth`. + assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); + assert!( + error.message.starts_with(&format!( + "the query key for service {VERIFIED_SERVICE_ID} was replaced (new API key \ + new-key-uuid) and stored in .clickhouse/credentials.json, but the Query API \ + kept rejecting it" + )), + "{}", + error.message + ); + assert!( + error.message.contains("Do not rerun repair-query-key"), + "{}", + error.message + ); + assert_eq!( + error.failure.map(|failure| failure.kind), + Some(FailureKind::Timeout) + ); + let details = error.details.expect("the timeout has a structured form"); + assert_eq!(details.code, CloudErrorCode::QueryKeyRepairUnverified); + assert_eq!(details.api_key_id.as_deref(), Some("new-key-uuid")); + assert_eq!( + details.command.as_deref(), + Some( + format!( + "clickhousectl cloud service query {VERIFIED_SERVICE_ID} --org-id org-1 \ + --query \"SELECT 1\"" + ) + .as_str() + ) + ); + assert_eq!(details.message, error.message); + assert!(probes_received(&control.received_requests().await.unwrap()) >= 2); + } + + #[tokio::test] + async fn a_repair_verification_succeeds_once_the_probe_is_accepted() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + let control = wiremock::MockServer::start().await; + mount_running_service(&control).await; + let attempts = Arc::new(AtomicUsize::new(0)); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path(format!( + "/service/{VERIFIED_SERVICE_ID}/run" + ))) + .respond_with(move |_: &wiremock::Request| { + if attempts.fetch_add(1, Ordering::SeqCst) == 0 { + wiremock::ResponseTemplate::new(401).set_body_string("not yet") + } else { + wiremock::ResponseTemplate::new(200).set_body_string("1\n") + } + }) + .mount(&control) + .await; + let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); + + let verification = probe_repaired_query_key( + &client, + "org-1", + VERIFIED_SERVICE_ID, + "new-key-uuid", + "kid", + "sec", + FAST_READINESS, + ) + .await + .unwrap(); + assert_eq!(verification, RepairVerification::Verified); + assert_eq!( + probes_received(&control.received_requests().await.unwrap()), + 2 + ); + } + + #[tokio::test] + async fn a_repair_verification_without_a_configured_query_host_skips_without_probing() { + let control = wiremock::MockServer::start().await; + mount_running_service(&control).await; + // A loopback base URL derives no query host, and no override is given. + let client = CloudClient::for_tests(&control.uri(), None); + let verification = probe_repaired_query_key( + &client, + "org-1", + VERIFIED_SERVICE_ID, + "new-key-uuid", + "kid", + "sec", + FAST_READINESS, + ) + .await + .unwrap(); + assert_eq!(verification, RepairVerification::Skipped); + assert!(control.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn a_repair_verification_skips_without_probing_when_the_record_is_gone() { + // No credentials file names this service, so there is nothing to + // probe with; the mock sees no request at all. + let control = wiremock::MockServer::start().await; + mount_running_service(&control).await; + let client = CloudClient::for_tests(&control.uri(), Some(&control.uri())); + let verification = verify_repaired_query_key( + &client, + "org-1", + "svc-1-with-no-stored-record", + "new-key-uuid", + FAST_READINESS, + ) + .await + .unwrap(); + assert_eq!(verification, RepairVerification::Skipped); + assert!(control.received_requests().await.unwrap().is_empty()); + } + + #[test] + fn the_repaired_credential_is_probed_only_while_the_record_names_the_new_key() { + let stored = |api_key_id: &str| credentials::ServiceQueryKey { + organization_id: Some("org-1".into()), + api_key_id: Some(api_key_id.into()), + key_id: "kid".into(), + key_secret: "sec".into(), + endpoint_id: Some("ep-1".into()), + pending_cleanup_api_key_ids: vec![], + service_name: "demo".into(), + created_at: chrono::Utc::now(), + }; + assert_eq!( + repaired_query_credential(Ok(Some(stored("new"))), "svc-1", "new"), + Ok(("kid".to_string(), "sec".to_string())) + ); + // A concurrent repair replaced the key, or the record vanished. + let changed = "the stored query key for service svc-1 changed before the replacement \ + could be probed"; + assert_eq!( + repaired_query_credential(Ok(Some(stored("newer"))), "svc-1", "new"), + Err(changed.to_string()) + ); + assert_eq!( + repaired_query_credential(Ok(None), "svc-1", "new"), + Err(changed.to_string()) + ); + } + + #[test] + fn a_credentials_read_error_after_a_committed_repair_skips_rather_than_fails() { + // The repair is committed by the time the record is re-read, so an + // unreadable or unparsable credentials file skips the probe like every + // other "not this run's to verify" case. Only a full-window Query API + // rejection may exit 1. + let reason = repaired_query_credential( + Err(CloudError::new("credentials.json is not valid JSON")), + "svc-1", + "new", + ) + .unwrap_err(); + assert_eq!( + reason, + "the stored query key for service svc-1 could not be read (credentials.json is not \ + valid JSON)" + ); + } + + #[tokio::test] + async fn query_readiness_exhaustion_is_not_an_auth_error() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + let readiness = QueryEndpointReadiness { + timeout: Duration::from_millis(20), + initial_backoff: Duration::from_millis(20), + max_backoff: Duration::from_millis(20), + }; + let attempts = Arc::new(AtomicUsize::new(0)); + let probe_attempts = Arc::clone(&attempts); + let result = tokio::time::timeout( + Duration::from_secs(1), + wait_for_query_endpoint_readiness(readiness, move || { + probe_attempts.fetch_add(1, Ordering::SeqCst); + std::future::ready(Err::<(), _>(clickhouse_cloud_api::Error::Api { + status: 401, + message: "query endpoint is not ready".into(), + })) + }), + ) + .await + .expect("readiness retries exceeded the test guard"); + + assert_eq!(attempts.load(Ordering::SeqCst), 1); + assert_query_readiness_timeout(result, readiness.timeout); + } + + fn assert_query_readiness_timeout( + result: Result, + timeout: Duration, + ) { + let elapsed = match result { + Err(QueryReadinessError::TimedOut(elapsed)) => elapsed, + Err(QueryReadinessError::Api(error)) => panic!("expected the deadline, got {error}"), + Ok(_) => panic!("readiness should time out"), + }; + assert_eq!(elapsed, timeout); + let converted = query_readiness_timeout_error(elapsed); + + assert_eq!( + converted.kind, + crate::cloud::client::CloudErrorKind::Generic + ); + assert_eq!( + converted.message, + format!("Query API endpoint did not become ready within {timeout:?}") + ); + // The readiness deadline is reported as a timeout (#450), not as a + // generic 4xx: the CLI, not the API, gave up — so there is no HTTP + // status to report either. + assert_eq!( + converted.failure, + Some(ApiFailure::new(FailureKind::Timeout)) + ); + } + + /// A query target for the error-rendering tests. `native` decides whether + /// the timeout hint has a real endpoint to name. + fn test_query_target(native: Option<&NativeEndpoint>) -> QueryTarget<'_> { + QueryTarget { + service_name: "demo", + service_id: "svc-1", + org_id: "org-1", + native, + may_be_waking: false, + } + } + + #[test] + fn native_secure_endpoint_picks_the_native_protocol_endpoint() { + let endpoints = vec![ + ServiceEndpoint { + host: Some("demo.gcp.clickhouse.cloud".into()), + port: Some(8443), + protocol: Some(ServiceEndpointProtocol::Https), + username: None, + }, + ServiceEndpoint { + host: Some("demo.gcp.clickhouse.cloud".into()), + port: Some(9440), + protocol: Some(ServiceEndpointProtocol::Nativesecure), + username: Some("default".into()), + }, + ]; + assert_eq!( + native_secure_endpoint(Some(&endpoints)), + Some(NativeEndpoint { + host: "demo.gcp.clickhouse.cloud".into(), + port: 9440, + }) + ); + } + + #[test] + fn native_secure_endpoint_is_absent_when_the_response_is_incomplete() { + // No endpoints at all, and an empty list. + assert_eq!(native_secure_endpoint(None), None); + assert_eq!(native_secure_endpoint(Some(&[])), None); + // Only the HTTPS endpoint. + assert_eq!( + native_secure_endpoint(Some(&[ServiceEndpoint { + host: Some("demo.clickhouse.cloud".into()), + port: Some(8443), + protocol: Some(ServiceEndpointProtocol::Https), + username: None, + }])), + None + ); + // Every response field is optional, so a half-returned endpoint is + // absent rather than half-rendered into a command. + for partial in [ + ServiceEndpoint { + host: Some("demo.clickhouse.cloud".into()), + port: None, + protocol: Some(ServiceEndpointProtocol::Nativesecure), + username: None, + }, + ServiceEndpoint { + host: None, + port: Some(9440), + protocol: Some(ServiceEndpointProtocol::Nativesecure), + username: None, + }, + ServiceEndpoint { + host: Some("demo.clickhouse.cloud".into()), + port: Some(9440), + protocol: None, + username: None, + }, + ] { + assert_eq!( + native_secure_endpoint(Some(std::slice::from_ref(&partial))), + None + ); + } + } + + /// The gateway timeout names the limit, warns before a rerun, and hands + /// over a native-protocol command built from the service's own endpoint + /// (#644). + #[test] + fn query_timeout_error_hints_the_native_endpoint() { + let native = NativeEndpoint { + host: "demo.gcp.clickhouse.cloud".into(), + port: 9440, + }; + let error = query_timeout_error(test_query_target(Some(&native))); + + assert!( + error.message.contains("about 30 seconds"), + "{}", + error.message + ); + assert!( + error + .message + .contains("SELECT query_id, elapsed FROM system.processes"), + "{}", + error.message + ); + assert!( + error.message.contains("clickhousectl local use latest"), + "{}", + error.message + ); + assert!( + error.message.contains( + "clickhouse client --host demo.gcp.clickhouse.cloud --secure --port 9440 \ + --user default --password '' --query ''" + ), + "{}", + error.message + ); + assert!( + error + .message + .contains("clickhousectl cloud service reset-password svc-1"), + "{}", + error.message + ); + // With a real endpoint there is nothing to look up. + assert!( + !error.message.contains("cloud service get"), + "{}", + error.message + ); + assert!(!error.message.contains(""), "{}", error.message); + + // A timeout is exit code 1 (Generic), never an auth failure, and the + // classification is the variant's (#450). + assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); + assert_eq!( + error.failure, + Some(ApiFailure::with_status(FailureKind::Timeout, 500)) + ); + + let details = error.details.as_deref().expect("json details"); + assert_eq!(details.code, CloudErrorCode::QueryTimeout); + assert_eq!(details.message, error.message); + assert_eq!(details.host.as_deref(), Some("demo.gcp.clickhouse.cloud")); + assert_eq!(details.port, Some(9440)); + assert_eq!( + details.command.as_deref(), + Some( + "clickhouse client --host demo.gcp.clickhouse.cloud --secure --port 9440 --user \ + default --password '' --query ''" + ) + ); + } + + #[test] + fn query_timeout_after_wake_retains_structural_classification() { + let client = CloudClient::new( + Some("test-key"), + Some("test-secret"), + Some("https://api.example.com/v1"), + ) + .unwrap(); + let error = QueryRequestError { + error: clickhouse_cloud_api::Error::QueryTimeout, + wake_requested: true, + } + .into_cloud_error(&client, test_query_target(None)); + assert_eq!(error.kind, crate::cloud::client::CloudErrorKind::Generic); + assert_eq!( + error.failure, + Some(ApiFailure::with_status(FailureKind::Timeout, 500)) + ); + let details = error.details.as_deref().unwrap(); + assert_eq!(details.code, CloudErrorCode::QueryTimeout); + assert_eq!(details.message, error.message); + assert_eq!( + details.command.as_deref(), + Some("clickhousectl cloud service get svc-1 --org-id org-1") + ); + assert!(details.host.is_none()); + assert!(details.port.is_none()); + + for status in [401, 403, 500] { + let error = QueryRequestError { + error: clickhouse_cloud_api::Error::Api { + status, + message: "unrelated failure".into(), + }, + wake_requested: true, + } + .into_cloud_error(&client, test_query_target(None)); + assert!(!error.message.contains("waking")); + assert_eq!(error.failure.unwrap().http_status, Some(status)); + assert_eq!( + error.kind == crate::cloud::client::CloudErrorKind::Auth, + status == 401 || status == 403 + ); + } + } + + #[test] + fn query_timeout_error_falls_back_to_a_host_placeholder() { + let error = query_timeout_error(test_query_target(None)); + + assert!( + error.message.contains( + "clickhouse client --host --secure --port 9440 --user default \ + --password '' --query ''" + ), + "{}", + error.message + ); + // No endpoint in the response, so the message says where to get one. + assert!( + error + .message + .contains("clickhousectl cloud service get svc-1 --org-id org-1"), + "{}", + error.message + ); + + let details = error.details.as_deref().expect("json details"); + // Absent means omitted: no fabricated host or port. + assert_eq!(details.host, None); + assert_eq!(details.port, None); + assert!( + details + .command + .as_deref() + .is_some_and(|command| command.contains("--host ")), + "{details:?}" + ); + let json = serde_json::to_value(details).expect("details serialize"); + assert_eq!(json["code"], "query_timeout"); + assert!(json.get("host").is_none(), "{json}"); + assert!(json.get("port").is_none(), "{json}"); + } + + /// The message is rendered from the target and fixed placeholders only, + /// so neither the statement nor any credential can reach it. + #[test] + fn query_timeout_error_never_echoes_sql_or_credentials() { + let native = NativeEndpoint { + host: "demo.gcp.clickhouse.cloud".into(), + port: 9440, + }; + for target in [test_query_target(Some(&native)), test_query_target(None)] { + let error = query_timeout_error(target); + assert!(error.message.contains("''"), "{}", error.message); + assert!( + error.message.contains("--password ''"), + "{}", + error.message + ); + } + } + + /// Every query-path error a boundary rewrites keeps a classification + /// derived from what actually failed, not from its message (#450). + #[test] + fn query_errors_carry_their_structural_classification() { + let client = CloudClient::new( + Some("test-key"), + Some("test-secret"), + Some("https://api.example.com/v1"), + ) + .unwrap(); + + // A stopped service gets a rewritten, actionable message; the + // classification survives the rewrite. + let stopped = convert_query_error( + &client, + clickhouse_cloud_api::Error::ServiceStopped, + test_query_target(None), + ); + assert!(stopped.message.contains("is stopped")); + assert_eq!( + stopped.failure, + Some(ApiFailure::new(FailureKind::ServiceStopped)) + ); + + let sql = convert_query_error( + &client, + clickhouse_cloud_api::Error::Sql { + status: 400, + code: "62".into(), + details: "Syntax error".into(), + }, + test_query_target(None), + ); + assert_eq!( + sql.failure, + Some(ApiFailure::with_status(FailureKind::SqlError, 400)) + ); + + // `--no-auto-enable` rewrites the message but the failure is still + // the Query API's own rejection: its class and status survive. + for status in [401, 403, 404] { + let refused = refused_query_provisioning_error( + "svc-1", + &clickhouse_cloud_api::Error::Api { + status, + message: "rejected".into(), + }, + ); + assert!(refused.message.contains("--no-auto-enable"), "{refused}"); + assert_eq!(refused.kind, crate::cloud::client::CloudErrorKind::Generic); + assert_eq!( + refused.failure, + Some(ApiFailure::with_status(FailureKind::Http4xx, status)), + "status {status}" + ); + } + } + + #[test] + fn inline_query_only_conflicts_with_bytes_waiting_on_stdin() { + // The whole matrix of stdin states crossed with --query being present + // (#641). Only "not a terminal and data is already waiting" conflicts: + // a terminal must never be read, and a non-terminal empty stdin is the + // normal shape for CI and for coding agents. + assert!(!inline_query_conflicts_with_stdin( + Some("SELECT 1"), + StdinInput::Terminal + )); + assert!(!inline_query_conflicts_with_stdin( + Some("SELECT 1"), + StdinInput::Empty + )); + assert!(inline_query_conflicts_with_stdin( + Some("SELECT 1"), + StdinInput::Pending + )); + + assert!(!inline_query_conflicts_with_stdin( + None, + StdinInput::Terminal + )); + assert!(!inline_query_conflicts_with_stdin(None, StdinInput::Empty)); + // Without --query, piped bytes are the SQL itself, not a conflict. + assert!(!inline_query_conflicts_with_stdin( + None, + StdinInput::Pending + )); + } + + /// A real unnamed pipe as `(read_fd, write_fd)`. + fn test_pipe() -> (libc::c_int, libc::c_int) { + let mut fds = [0 as libc::c_int; 2]; + // SAFETY: `fds` is a two-element array, which is what `pipe` writes. + let created = unsafe { libc::pipe(fds.as_mut_ptr()) }; + assert_eq!(created, 0, "pipe() failed"); + (fds[0], fds[1]) + } + + fn write_byte(fd: libc::c_int) { + // SAFETY: one byte is written from a live local buffer. + let written = unsafe { libc::write(fd, b"x".as_ptr().cast(), 1) }; + assert_eq!(written, 1, "write() failed"); + } + + fn close_fd(fd: libc::c_int) { + // SAFETY: each fd is closed exactly once by its owning test. + unsafe { libc::close(fd) }; + } + + #[test] + fn readiness_waits_for_a_producer_that_has_not_written_its_first_byte() { + // The #641 race: `cat data.csv | clickhousectl ... --query ...` starts + // both processes at once, so the CLI can look at stdin before the + // producer has written. A zero timeout calls that "no input" and sends + // the empty INSERT, which is the whole bug; the bounded wait sees the + // byte. This is asserted on a real pipe rather than through a + // subprocess so the interleaving is not left to process startup. + let (read_fd, write_fd) = test_pipe(); + assert!( + !fd_becomes_readable(read_fd, 0), + "nothing has been written yet" + ); + + let producer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + write_byte(write_fd); + close_fd(write_fd); + }); + + let started = std::time::Instant::now(); + assert!(fd_becomes_readable(read_fd, STDIN_FIRST_BYTE_TIMEOUT_MS)); + assert!( + started.elapsed() < Duration::from_millis(STDIN_FIRST_BYTE_TIMEOUT_MS as u64 + 500), + "the wait must stay bounded" + ); + + producer.join().unwrap(); + close_fd(read_fd); + } + + #[test] + fn readiness_gives_up_on_a_pipe_nobody_writes_to() { + // A harness that wired up stdin and produced nothing must not hang the + // command: the wait expires and the run continues. + let (read_fd, write_fd) = test_pipe(); + + let started = std::time::Instant::now(); + assert!(!fd_becomes_readable(read_fd, STDIN_FIRST_BYTE_TIMEOUT_MS)); + let waited = started.elapsed(); + assert!( + waited < Duration::from_millis(STDIN_FIRST_BYTE_TIMEOUT_MS as u64 + 500), + "waited {waited:?}" + ); + + close_fd(write_fd); + close_fd(read_fd); + } + + #[test] + fn readiness_is_immediate_for_data_that_is_already_waiting() { + let (read_fd, write_fd) = test_pipe(); + write_byte(write_fd); + + assert!(fd_becomes_readable(read_fd, 0)); + + close_fd(write_fd); + close_fd(read_fd); + } + + #[test] + fn the_stdin_conflict_message_says_what_does_work() { + assert!( + INLINE_QUERY_WITH_STDIN_ERROR + .starts_with("--query cannot be combined with SQL or data on stdin.") + ); + assert!(INLINE_QUERY_WITH_STDIN_ERROR.contains("cat - data.csv")); + assert!(INLINE_QUERY_WITH_STDIN_ERROR.contains("--queries-file -")); + } +} diff --git a/crates/clickhousectl/src/cloud/services/scaling_schedule.rs b/crates/clickhousectl/src/cloud/services/scaling_schedule.rs new file mode 100644 index 00000000..a24060f1 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/scaling_schedule.rs @@ -0,0 +1,371 @@ +use crate::cloud::client::{CloudClient, CloudError, Result as CloudResult}; +use crate::cloud::config::{deserialize_strict_config, read_config_value}; +use crate::cloud::output::{print_human, print_line}; +use crate::cloud::shared::resolve_org_id; +use clickhouse_cloud_api::models::{ + AutoscalingMode, ScalingScheduleEntryRequest, ScalingSchedulePostRequest, +}; + +fn build_scaling_schedule_request( + value: serde_json::Value, + source: &str, +) -> CloudResult { + let request: ScalingSchedulePostRequest = deserialize_strict_config(value, source)?; + for (index, entry) in request.entries.iter().enumerate() { + validate_scaling_schedule_entry(entry, index, source)?; + } + Ok(request) +} + +fn validate_scaling_schedule_entry( + entry: &ScalingScheduleEntryRequest, + index: usize, + source: &str, +) -> CloudResult<()> { + let invalid = |message: String| { + CloudError::new(format!( + "invalid request body in config {source}: entries[{index}] {message}" + )) + }; + + if entry.weekdays.is_empty() { + return Err(invalid("weekdays must contain at least one day".into())); + } + if let Some(day) = entry.weekdays.iter().find(|&&day| !(0..=6).contains(&day)) { + return Err(invalid(format!( + "weekdays contains {day}; each day must be between 0 (Sunday) and 6 (Saturday)" + ))); + } + if !(0..=23).contains(&entry.start_hour_utc) { + return Err(invalid(format!( + "startHourUtc must be between 0 and 23, got {}", + entry.start_hour_utc + ))); + } + if !(1..=24).contains(&entry.end_hour_utc) { + return Err(invalid(format!( + "endHourUtc must be between 1 and 24, got {}", + entry.end_hour_utc + ))); + } + if entry.start_hour_utc == entry.end_hour_utc { + return Err(invalid( + "startHourUtc and endHourUtc must be different".into(), + )); + } + + let memory = match (entry.min_replica_memory_gb, entry.max_replica_memory_gb) { + (None, None) => None, + (Some(min), Some(max)) => { + for (field, value) in [("minReplicaMemoryGb", min), ("maxReplicaMemoryGb", max)] { + if !(8.0..=356.0).contains(&value) || value % 4.0 != 0.0 { + return Err(invalid(format!( + "{field} must be a multiple of 4 between 8 and 356, got {value}" + ))); + } + } + if min > max { + return Err(invalid(format!( + "minReplicaMemoryGb ({min}) must not exceed maxReplicaMemoryGb ({max})" + ))); + } + Some((min, max)) + } + _ => { + return Err(invalid( + "minReplicaMemoryGb and maxReplicaMemoryGb must be provided together or both omitted" + .into(), + )); + } + }; + + let replicas = match (entry.min_replicas, entry.max_replicas) { + (None, None) => None, + (Some(min), Some(max)) => { + if min < 1 || max < 1 { + return Err(invalid(format!( + "minReplicas and maxReplicas must each be at least 1, got {min} and {max}" + ))); + } + if min > max { + return Err(invalid(format!( + "minReplicas ({min}) must not exceed maxReplicas ({max})" + ))); + } + Some((min, max)) + } + _ => { + return Err(invalid( + "minReplicas and maxReplicas must be provided together or both omitted".into(), + )); + } + }; + + if entry.num_replicas.is_some() && replicas.is_some() { + return Err(invalid( + "numReplicas is mutually exclusive with minReplicas and maxReplicas".into(), + )); + } + if let Some(count) = entry.num_replicas + && count < 1 + { + return Err(invalid(format!( + "numReplicas must be at least 1, got {count}" + ))); + } + + match entry.autoscaling_mode.as_ref() { + Some(AutoscalingMode::Unknown(value)) => { + return Err(invalid(format!( + "autoscalingMode must be one of {}, got {value}", + AutoscalingMode::VALUES.join(", ") + ))); + } + Some(AutoscalingMode::Horizontal) => { + let Some((min_memory, max_memory)) = memory else { + return Err(invalid( + "horizontal autoscaling requires minReplicaMemoryGb and maxReplicaMemoryGb" + .into(), + )); + }; + if min_memory != max_memory { + return Err(invalid( + "horizontal autoscaling requires equal minReplicaMemoryGb and maxReplicaMemoryGb" + .into(), + )); + } + if replicas.is_none() { + return Err(invalid( + "horizontal autoscaling requires minReplicas and maxReplicas".into(), + )); + } + if entry.num_replicas.is_some() { + return Err(invalid( + "horizontal autoscaling does not accept numReplicas".into(), + )); + } + } + Some(AutoscalingMode::Vertical) | None => { + if let Some((min, max)) = replicas + && min != max + { + return Err(invalid( + "vertical autoscaling accepts minReplicas and maxReplicas only as an equal fixed count" + .into(), + )); + } + } + } + + Ok(()) +} + +pub(super) async fn scaling_schedule_get( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let schedule = client.get_scaling_schedule(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&schedule)?); + } else { + print_human(&schedule)?; + } + Ok(()) +} + +pub(super) async fn scaling_schedule_set( + client: &CloudClient, + service_id: &str, + file: &str, + json: bool, +) -> CloudResult<()> { + let request = build_scaling_schedule_request(read_config_value(file)?, file)?; + let org_id = resolve_org_id(client).await?; + let schedule = client + .set_scaling_schedule(&org_id, service_id, &request) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&schedule)?); + } else { + print_human(&schedule)?; + } + Ok(()) +} + +pub(super) async fn scaling_schedule_delete( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let response = client.delete_scaling_schedule(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + print_line(format!( + "Scaling schedule deleted from service {service_id}" + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_scaling_schedule_request_supports_minimal_and_empty_schedules() { + let request = build_scaling_schedule_request( + serde_json::json!({ + "entries": [{ + "name": "Business hours", + "weekdays": [1], + "startHourUtc": 9, + "endHourUtc": 17 + }] + }), + "test.json", + ) + .unwrap(); + assert_eq!(request.entries.len(), 1); + let entry = &request.entries[0]; + assert_eq!(entry.name, "Business hours"); + assert_eq!(entry.weekdays, vec![1]); + assert_eq!(entry.start_hour_utc, 9); + assert_eq!(entry.end_hour_utc, 17); + assert!(entry.autoscaling_mode.is_none()); + assert!(entry.idle_scaling.is_none()); + assert!(entry.idle_timeout_minutes.is_none()); + assert!(entry.min_replica_memory_gb.is_none()); + assert!(entry.max_replica_memory_gb.is_none()); + assert!(entry.num_replicas.is_none()); + assert!(entry.min_replicas.is_none()); + assert!(entry.max_replicas.is_none()); + + let clear = build_scaling_schedule_request(serde_json::json!({"entries": []}), "test.json") + .unwrap(); + assert!(clear.entries.is_empty()); + } + + #[test] + fn build_scaling_schedule_request_preserves_all_entry_fields() { + let request = build_scaling_schedule_request( + serde_json::json!({ + "entries": [ + { + "name": "Vertical nights", + "weekdays": [0, 6], + "startHourUtc": 22, + "endHourUtc": 6, + "autoscalingMode": "vertical", + "idleScaling": true, + "idleTimeoutMinutes": 5, + "minReplicaMemoryGb": 8, + "maxReplicaMemoryGb": 32, + "numReplicas": 2 + }, + { + "name": "Horizontal weekdays", + "weekdays": [1, 2, 3, 4, 5], + "startHourUtc": 8, + "endHourUtc": 24, + "autoscalingMode": "horizontal", + "idleScaling": false, + "idleTimeoutMinutes": 0, + "minReplicaMemoryGb": 16, + "maxReplicaMemoryGb": 16, + "minReplicas": 2, + "maxReplicas": 7 + } + ] + }), + "schedule.json", + ) + .unwrap(); + + assert_eq!(request.entries.len(), 2); + let vertical = &request.entries[0]; + assert_eq!(vertical.autoscaling_mode, Some(AutoscalingMode::Vertical)); + assert_eq!(vertical.idle_scaling, Some(true)); + assert_eq!(vertical.idle_timeout_minutes, Some(5)); + assert_eq!(vertical.min_replica_memory_gb, Some(8.0)); + assert_eq!(vertical.max_replica_memory_gb, Some(32.0)); + assert_eq!(vertical.num_replicas, Some(2)); + assert!(vertical.min_replicas.is_none()); + assert!(vertical.max_replicas.is_none()); + + let horizontal = &request.entries[1]; + assert_eq!( + horizontal.autoscaling_mode, + Some(AutoscalingMode::Horizontal) + ); + assert_eq!(horizontal.weekdays, vec![1, 2, 3, 4, 5]); + assert_eq!(horizontal.end_hour_utc, 24); + assert_eq!(horizontal.idle_scaling, Some(false)); + assert_eq!(horizontal.idle_timeout_minutes, Some(0)); + assert_eq!(horizontal.min_replica_memory_gb, Some(16.0)); + assert_eq!(horizontal.max_replica_memory_gb, Some(16.0)); + assert_eq!(horizontal.min_replicas, Some(2)); + assert_eq!(horizontal.max_replicas, Some(7)); + assert!(horizontal.num_replicas.is_none()); + } + + #[test] + fn build_scaling_schedule_request_rejects_unknown_fields_and_modes() { + for value in [ + serde_json::json!({ + "entries": [{ + "name": "typo", + "weekdays": [1], + "startHourUtc": 9, + "endHourUtc": 17, + "minReplicaMemoryGB": 16 + }] + }), + serde_json::json!({ + "entries": [{ + "name": "future mode", + "weekdays": [1], + "startHourUtc": 9, + "endHourUtc": 17, + "autoscalingMode": "diagonal" + }] + }), + serde_json::json!({"entries": [], "baseConfig": {}}), + ] { + assert!(build_scaling_schedule_request(value, "bad.json").is_err()); + } + } + + #[test] + fn build_scaling_schedule_request_validates_documented_bounds_and_relationships() { + let invalid_entries = [ + serde_json::json!({"name":"x","weekdays":[],"startHourUtc":0,"endHourUtc":24}), + serde_json::json!({"name":"x","weekdays":[7],"startHourUtc":0,"endHourUtc":24}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":-1,"endHourUtc":24}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":0,"endHourUtc":25}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":8}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":8}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":12,"maxReplicaMemoryGb":8}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicaMemoryGb":10,"maxReplicaMemoryGb":12}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"numReplicas":0}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicas":1}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"minReplicas":3,"maxReplicas":2}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"numReplicas":2,"minReplicas":2,"maxReplicas":2}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"vertical","minReplicas":1,"maxReplicas":2}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"horizontal","minReplicaMemoryGb":16,"maxReplicaMemoryGb":16}), + serde_json::json!({"name":"x","weekdays":[1],"startHourUtc":8,"endHourUtc":9,"autoscalingMode":"horizontal","minReplicaMemoryGb":16,"maxReplicaMemoryGb":20,"minReplicas":1,"maxReplicas":2}), + ]; + for entry in invalid_entries { + let error = + build_scaling_schedule_request(serde_json::json!({"entries": [entry]}), "bad.json") + .unwrap_err(); + assert!(error.message.contains("entries[0]"), "{error}"); + } + } +} diff --git a/crates/clickhousectl/src/cloud/services/settings.rs b/crates/clickhousectl/src/cloud/services/settings.rs new file mode 100644 index 00000000..2de66be5 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/settings.rs @@ -0,0 +1,317 @@ +use crate::cloud::client::{CloudClient, CloudError, Result as CloudResult}; +use crate::cloud::output::{or_absent, print_human}; +use crate::cloud::shared::resolve_org_id; +use clickhouse_cloud_api::models::ServiceClickhouseSettingsPatchRequest; +use std::collections::BTreeMap; +use tabled::{Table, Tabled, settings::Style}; + +// serde_json preserves i64/u64 integers, but falls back to f64 for overflow +// and exponent/decimal literals. Reject that representation before it can be +// rounded and sent, including numbers nested in future setting value shapes. +fn validate_setting_numbers(value: &serde_json::Value, source: &str) -> CloudResult<()> { + match value { + serde_json::Value::Number(number) if number.is_f64() => Err(CloudError::new(format!( + "{source}: numeric settings must be integers from {} to {}; decimal and exponent values must be JSON strings", + i64::MIN, + u64::MAX + ))), + serde_json::Value::Array(values) => { + for value in values { + validate_setting_numbers(value, source)?; + } + Ok(()) + } + serde_json::Value::Object(values) => { + for value in values.values() { + validate_setting_numbers(value, source)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn parse_settings_map_document( + raw: &str, + source: &str, +) -> CloudResult> { + let value: serde_json::Value = serde_json::from_str(raw) + .map_err(|error| CloudError::new(format!("invalid JSON in {source}: {error}")))?; + validate_setting_numbers(&value, source)?; + let object = value.as_object().ok_or_else(|| { + CloudError::new(format!( + "{source} must contain a JSON object mapping setting names to values" + )) + })?; + if object.is_empty() { + return Err(CloudError::new(format!( + "{source} must contain at least one setting" + ))); + } + if object.len() == 1 + && object + .get("settings") + .is_some_and(|value| value.is_string() || value.is_object()) + { + return Err(CloudError::new(format!( + "{source} looks like the API request wrapper; provide the decoded settings map, for example {{\"compatibility\":\"24.8\"}}" + ))); + } + Ok(object + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect()) +} + +fn parse_setting_assignments( + assignments: &[String], +) -> CloudResult> { + let mut settings = BTreeMap::new(); + for (index, assignment) in assignments.iter().enumerate() { + let position = index + 1; + let (name, raw_value) = assignment.split_once('=').ok_or_else(|| { + CloudError::new(format!("--setting #{position} must use NAME=JSON_VALUE")) + })?; + if name.trim().is_empty() { + return Err(CloudError::new(format!( + "--setting #{position} has an empty setting name" + ))); + } + let value = serde_json::from_str(raw_value).map_err(|error| { + CloudError::new(format!( + "--setting #{position} value is not valid JSON: {error}; quote string values, for example compatibility=\"24.8\"" + )) + })?; + validate_setting_numbers(&value, &format!("--setting #{position} ('{name}')"))?; + if settings.insert(name.to_string(), value).is_some() { + return Err(CloudError::new(format!( + "setting '{name}' was provided more than once" + ))); + } + } + Ok(settings) +} + +fn read_service_settings( + assignments: &[String], + settings_file: Option<&str>, +) -> CloudResult> { + if let Some(path) = settings_file { + use std::io::Read as _; + + let (raw, source) = if path == "-" { + let mut raw = String::new(); + std::io::stdin().read_to_string(&mut raw).map_err(|error| { + CloudError::new(format!("failed to read settings from stdin: {error}")) + })?; + (raw, "stdin".to_string()) + } else { + ( + std::fs::read_to_string(path).map_err(|error| { + CloudError::new(format!("failed to read settings file '{path}': {error}")) + })?, + format!("settings file '{path}'"), + ) + }; + return parse_settings_map_document(&raw, &source); + } + + parse_setting_assignments(assignments) +} + +fn build_service_settings_patch_request( + settings: &BTreeMap, +) -> CloudResult { + if settings.is_empty() { + return Err(CloudError::new("provide at least one ClickHouse setting")); + } + Ok(ServiceClickhouseSettingsPatchRequest { + settings: Some(settings.clone()), + }) +} + +pub(super) async fn service_settings_list( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let response = client.list_clickhouse_settings(&org_id, service_id).await?; + + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + let Some(settings) = response.settings else { + println!("No ClickHouse settings returned"); + return Ok(()); + }; + if settings.is_empty() { + println!("No ClickHouse settings configured"); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Name")] + name: String, + #[tabled(rename = "Value")] + value: String, + } + let rows = settings + .into_iter() + .map(|setting| Row { + name: or_absent(setting.name), + value: or_absent(setting.value.map(|value| match value { + serde_json::Value::String(value) => value, + value => value.to_string(), + })), + }) + .collect::>(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +pub(super) async fn service_setting_get( + client: &CloudClient, + service_id: &str, + setting_name: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let setting = client + .get_clickhouse_setting(&org_id, service_id, setting_name) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&setting)?); + } else { + print_human(&setting)?; + } + Ok(()) +} + +pub(super) async fn service_settings_schema( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let schema = client + .get_clickhouse_settings_schema(&org_id, service_id) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&schema)?); + } else { + print_human(&schema)?; + } + Ok(()) +} + +pub(super) async fn service_settings_set( + client: &CloudClient, + service_id: &str, + assignments: &[String], + settings_file: Option<&str>, + json: bool, +) -> CloudResult<()> { + // Parse before organization discovery so malformed local input never + // causes an HTTP request, even when --org-id was omitted. + let settings = read_service_settings(assignments, settings_file)?; + let request = build_service_settings_patch_request(&settings)?; + let org_id = resolve_org_id(client).await?; + let response = client + .update_clickhouse_settings(&org_id, service_id, &request) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + print_human(&response)?; + } + Ok(()) +} + +pub(super) async fn service_setting_unset( + client: &CloudClient, + service_id: &str, + setting_name: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let response = client + .reset_clickhouse_setting(&org_id, service_id, setting_name) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Reset ClickHouse setting '{setting_name}' to its platform default"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_settings_patch_with_object_and_preserved_types() { + let minimal = BTreeMap::from([("compatibility".to_string(), serde_json::json!("24.8"))]); + let request = build_service_settings_patch_request(&minimal).unwrap(); + assert_eq!(request.settings, Some(minimal)); + + let maximal = BTreeMap::from([ + ("bool_setting".to_string(), serde_json::json!(false)), + ("null_setting".to_string(), serde_json::Value::Null), + ("number_setting".to_string(), serde_json::json!(42)), + ("string_setting".to_string(), serde_json::json!("literal")), + ( + "unknown_future_setting".to_string(), + serde_json::json!([1, 2]), + ), + ]); + let request = build_service_settings_patch_request(&maximal).unwrap(); + assert_eq!(request.settings, Some(maximal.clone())); + assert_eq!( + serde_json::to_value(&request).unwrap()["settings"], + serde_json::json!({ + "bool_setting": false, + "null_setting": null, + "number_setting": 42, + "string_setting": "literal", + "unknown_future_setting": [1, 2] + }) + ); + } + + #[test] + fn parses_dynamic_setting_assignments_without_a_name_allowlist() { + let settings = parse_setting_assignments(&[ + "future_setting={\"nested\":true}".to_string(), + "compatibility=\"24.8\"".to_string(), + ]) + .unwrap(); + assert_eq!( + settings["future_setting"], + serde_json::json!({"nested": true}) + ); + assert_eq!(settings["compatibility"], serde_json::json!("24.8")); + } + + #[test] + fn settings_file_rejects_api_wrapper_shape() { + let error = parse_settings_map_document( + r#"{"settings":"{\"compatibility\":\"24.8\"}"}"#, + "settings file", + ) + .unwrap_err(); + assert!(error.to_string().contains("API request wrapper")); + } + + #[test] + fn settings_file_reads_a_plain_dynamic_map() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("settings.json"); + std::fs::write(&path, r#"{"compatibility":"24.8","future_setting":false}"#).unwrap(); + let settings = read_service_settings(&[], Some(path.to_str().unwrap())).unwrap(); + assert_eq!(settings["compatibility"], serde_json::json!("24.8")); + assert_eq!(settings["future_setting"], serde_json::json!(false)); + } +} diff --git a/crates/clickhousectl/src/cloud/services/upgrade_window.rs b/crates/clickhousectl/src/cloud/services/upgrade_window.rs new file mode 100644 index 00000000..85902845 --- /dev/null +++ b/crates/clickhousectl/src/cloud/services/upgrade_window.rs @@ -0,0 +1,291 @@ +use crate::cloud::client::{CloudClient, CloudError, Result as CloudResult}; +use crate::cloud::output::{ABSENT, or_absent, print_human}; +use crate::cloud::shared::resolve_org_id; +use clickhouse_cloud_api::models::{ + ReplicaAwareRouting, ReplicaAwareRoutingPatchRequest, UpgradeWindow, UpgradeWindowPutRequest, + UpgradeWindowStartHourUtc, +}; +use tabled::{Table, Tabled, settings::Style}; + +fn build_upgrade_window_request( + weekday: i64, + start_hour: &str, +) -> CloudResult { + let start_hour_utc = match start_hour { + "0" => UpgradeWindowStartHourUtc::Hour0, + "6" => UpgradeWindowStartHourUtc::Hour6, + "12" => UpgradeWindowStartHourUtc::Hour12, + "18" => UpgradeWindowStartHourUtc::Hour18, + value => { + return Err(CloudError::new(format!( + "invalid start_hour '{value}': expected one of 0, 6, 12, 18" + ))); + } + }; + if !(0..=6).contains(&weekday) { + return Err(CloudError::new(format!( + "invalid weekday '{weekday}': expected a number from 0 through 6" + ))); + } + Ok(UpgradeWindowPutRequest { + start_hour_utc, + weekday, + }) +} + +pub(super) fn build_replica_aware_routing_request( + http_enabled: Option, + native_enabled: Option, +) -> ReplicaAwareRoutingPatchRequest { + ReplicaAwareRoutingPatchRequest { + http_enabled, + native_enabled, + } +} + +#[derive(serde::Serialize)] +struct UpgradeWindowHumanView { + duration: String, + #[serde(rename = "startHourUtc")] + start_hour_utc: String, + weekday: String, +} + +fn upgrade_window_human_view(window: &UpgradeWindow) -> UpgradeWindowHumanView { + let duration = window + .duration + .map(|duration| { + let hours = i64::from(duration); + format!("{hours} hour{}", if hours == 1 { "" } else { "s" }) + }) + .unwrap_or_else(|| ABSENT.to_string()); + let weekday = window + .weekday + .map(|weekday| match weekday { + 0 => "Sunday (0)".to_string(), + 1 => "Monday (1)".to_string(), + 2 => "Tuesday (2)".to_string(), + 3 => "Wednesday (3)".to_string(), + 4 => "Thursday (4)".to_string(), + 5 => "Friday (5)".to_string(), + 6 => "Saturday (6)".to_string(), + unknown => unknown.to_string(), + }) + .unwrap_or_else(|| ABSENT.to_string()); + + UpgradeWindowHumanView { + duration, + start_hour_utc: or_absent(window.start_hour_utc.as_ref()), + weekday, + } +} + +pub(super) async fn upgrade_window_get( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let window = client.get_upgrade_window(&org_id, service_id).await?; + if json { + println!("{}", serde_json::to_string_pretty(&window)?); + } else { + print_human(&upgrade_window_human_view(&window))?; + } + Ok(()) +} + +pub(super) async fn upgrade_window_set( + client: &CloudClient, + service_id: &str, + weekday: i64, + start_hour: &str, + json: bool, +) -> CloudResult<()> { + let request = build_upgrade_window_request(weekday, start_hour)?; + let org_id = resolve_org_id(client).await?; + let window = client + .set_upgrade_window(&org_id, service_id, &request) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&window)?); + } else { + print_human(&upgrade_window_human_view(&window))?; + } + Ok(()) +} + +pub(super) async fn replica_aware_routing_get( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let routing = client + .get_replica_aware_routing(&org_id, service_id) + .await?; + print_replica_aware_routing(&routing, json) +} + +pub(super) async fn replica_aware_routing_update( + client: &CloudClient, + service_id: &str, + request: ReplicaAwareRoutingPatchRequest, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let routing = client + .update_replica_aware_routing(&org_id, service_id, &request) + .await?; + print_replica_aware_routing(&routing, json) +} + +fn print_replica_aware_routing(routing: &ReplicaAwareRouting, json: bool) -> CloudResult<()> { + if json { + println!("{}", serde_json::to_string_pretty(routing)?); + } else { + print_human(routing)?; + } + Ok(()) +} + +pub(super) async fn upgrade_window_delete( + client: &CloudClient, + service_id: &str, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let response = client.delete_upgrade_window(&org_id, service_id).await?; + if json { + println!("{}", serde_json::to_string_pretty(&response)?); + } else { + println!("Upgrade window deleted for service {service_id}"); + } + Ok(()) +} + +pub(super) async fn service_profile_list( + client: &CloudClient, + region_id: Option<&str>, + byoc_id: Option<&str>, + json: bool, +) -> CloudResult<()> { + let org_id = resolve_org_id(client).await?; + let profiles = client + .list_service_profiles(&org_id, region_id, byoc_id) + .await?; + + if json { + println!("{}", serde_json::to_string_pretty(&profiles)?); + } else { + if profiles.is_empty() { + println!("No service profiles found"); + return Ok(()); + } + #[derive(Tabled)] + struct Row { + #[tabled(rename = "Profile")] + profile: String, + #[tabled(rename = "CPU cores")] + cpu_cores: String, + #[tabled(rename = "Memory GiB")] + memory_gi: String, + } + let rows: Vec = profiles + .into_iter() + .map(|profile| Row { + profile: or_absent(profile.profile), + cpu_cores: or_absent(profile.cpu_cores), + memory_gi: or_absent(profile.memory_gi), + }) + .collect(); + println!("{}", Table::new(rows).with(Style::markdown())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_replica_aware_routing_request_supports_minimal_and_maximal_values() { + let minimal = build_replica_aware_routing_request(None, Some(false)); + assert_eq!(minimal.http_enabled, None); + assert_eq!(minimal.native_enabled, Some(false)); + assert_eq!( + serde_json::to_value(&minimal).unwrap(), + serde_json::json!({"nativeEnabled": false}) + ); + + let maximal = build_replica_aware_routing_request(Some(true), Some(true)); + assert_eq!(maximal.http_enabled, Some(true)); + assert_eq!(maximal.native_enabled, Some(true)); + } + + #[test] + fn build_upgrade_window_request_supports_minimal_and_maximal_values() { + let earliest = build_upgrade_window_request(0, "0").unwrap(); + assert_eq!(earliest.weekday, 0); + assert_eq!(earliest.start_hour_utc, UpgradeWindowStartHourUtc::Hour0); + + let latest = build_upgrade_window_request(6, "18").unwrap(); + assert_eq!(latest.weekday, 6); + assert_eq!(latest.start_hour_utc, UpgradeWindowStartHourUtc::Hour18); + } + + #[test] + fn upgrade_window_human_view_names_every_documented_weekday() { + let expected = [ + "Sunday (0)", + "Monday (1)", + "Tuesday (2)", + "Wednesday (3)", + "Thursday (4)", + "Friday (5)", + "Saturday (6)", + ]; + + for (weekday, expected) in expected.into_iter().enumerate() { + let view = upgrade_window_human_view(&UpgradeWindow { + duration: None, + start_hour_utc: None, + weekday: Some(weekday as i64), + }); + assert_eq!(view.weekday, expected); + } + } + + #[test] + fn upgrade_window_human_view_labels_duration_and_preserves_sparse_unknowns() { + use clickhouse_cloud_api::models::UpgradeWindowDuration; + + let known = upgrade_window_human_view(&UpgradeWindow { + duration: Some(UpgradeWindowDuration::SixHours), + start_hour_utc: Some(UpgradeWindowStartHourUtc::Hour12), + weekday: Some(3), + }); + assert_eq!( + serde_json::to_value(known).unwrap(), + serde_json::json!({ + "duration": "6 hours", + "startHourUtc": "12", + "weekday": "Wednesday (3)" + }) + ); + + let sparse_unknown = upgrade_window_human_view(&UpgradeWindow { + duration: Some(UpgradeWindowDuration::Unknown(1)), + start_hour_utc: None, + weekday: Some(9), + }); + assert_eq!( + serde_json::to_value(sparse_unknown).unwrap(), + serde_json::json!({ + "duration": "1 hour", + "startHourUtc": "-", + "weekday": "9" + }) + ); + } +} From 90d05ad87ff53775e306b8cc1a5dae0f377a5241 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 15:26:01 +0100 Subject: [PATCH 3/4] chore: ignore the services split in git blame (#1157) Co-Authored-By: Claude Opus 5.5 --- .git-blame-ignore-revs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 95958cc9..7b5566a8 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -4,3 +4,6 @@ # Format entire workspace with cargo fmt d78a6d2aa40a70d9e1093f09ae987ca1b26e3e41 + +# Split cloud/services into per-command-family files (#1157) +30bd640d06f010ddbcbcf59c0a403329748e72d2 From f55058cc82feecb02f5558b48bc043dcb910b834 Mon Sep 17 00:00:00 2001 From: sdairs Date: Fri, 30 Oct 2026 15:29:00 +0100 Subject: [PATCH 4/4] docs(agents): describe directory-based Cloud domain modules (#1157) A large Cloud domain can now be a directory: `PERMISSIONS` and dispatch in `mod.rs`, clap definitions in `cli.rs`, `CloudClient` wrappers in `client.rs`, and one file per command family with its tests. Point the `run_service_query` exception at `services/client.rs`. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/add-cli-command/references/cloud.md | 14 +++++++++----- .agents/skills/cli-help-text/SKILL.md | 3 ++- AGENTS.md | 9 ++++++--- docs/development.md | 1 + 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/skills/add-cli-command/references/cloud.md b/.agents/skills/add-cli-command/references/cloud.md index b8d726bd..ec5ad1dd 100644 --- a/.agents/skills/add-cli-command/references/cloud.md +++ b/.agents/skills/add-cli-command/references/cloud.md @@ -4,14 +4,18 @@ its own PR (see `crates/clickhouse-cloud-api/AGENTS.md`). Everything for a command lives in its domain module, `src/cloud/.rs`: the clap definition, permissions, -dispatch, the `CloudClient` wrapper, the request builder, the handler and the tests. Copy the pattern from -`src/cloud/services.rs` or `src/cloud/postgres.rs`. Handlers never call `client.api()`. The one wrapper that -returns the raw library error is `run_service_query{,_bearer}` in `services.rs`: query paths stream the -response and classify the error themselves. Don't copy that for other endpoints. +dispatch, the `CloudClient` wrapper, the request builder, the handler and the tests. A large domain is a +directory, `src/cloud//`: `PERMISSIONS`, `run()` and re-exports in `mod.rs`, clap definitions in +`cli.rs`, `CloudClient` wrappers in `client.rs`, and one file per command family, with tests beside the code +they test. Copy the pattern from `src/cloud/services/` or `src/cloud/postgres.rs`. Handlers never call +`client.api()`. The one wrapper that returns the raw library error is `run_service_query{,_bearer}` in +`services/client.rs`: query paths stream the response and classify the error themselves. Don't copy that for +other endpoints. 1. **Definition.** Add the variant and its argument struct to the domain's command enum. For a new command group: - - Create `src/cloud/.rs` and re-export its enum privately from `src/cloud/cli.rs`. + - Create `src/cloud/.rs` (or `src/cloud//mod.rs`) and re-export its enum privately from + `src/cloud/cli.rs`. - Add an arm to `CloudCommands::is_write_command()` in `src/cloud/cli.rs`. - Add an arm to `dispatch()` in `src/cloud/mod.rs`. - Add the module's `PERMISSIONS` to the list in `src/cloud/permissions.rs`. diff --git a/.agents/skills/cli-help-text/SKILL.md b/.agents/skills/cli-help-text/SKILL.md index 6e0c5f5a..ec239598 100644 --- a/.agents/skills/cli-help-text/SKILL.md +++ b/.agents/skills/cli-help-text/SKILL.md @@ -12,7 +12,8 @@ is noise that costs the reader time and tokens. ## Where help lives - `#[command(about = ..., after_help = ...)]` and arg doc comments in `src/cli.rs`, `src/local/cli.rs`, - `src/cloud/cli.rs` and `src/cloud/.rs` (all under `crates/clickhousectl/`). + `src/cloud/cli.rs` and `src/cloud/.rs`, or `src/cloud//cli.rs` for a domain split into a + directory (all under `crates/clickhousectl/`). - `const INSTALL_AFTER_HELP` in `src/local/cli.rs`. - `src/cloud/permissions.rs` appends API-key permission lines to every executable Cloud command, built from each domain's declarations. Don't write those lines by hand. diff --git a/AGENTS.md b/AGENTS.md index aac8312b..70edafab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ work on a branch, with an associated issue and a PR. - New Cloud handlers go through `CloudClient` wrapper methods co-located in each domain module, not `clickhouse_cloud_api::Client` directly. `src/cloud/client.rs` owns the core client, credential precedence, error conversion, and response unwrapping. -- One deliberate exception to wrapper error conversion: `run_service_query{,_bearer}` in `src/cloud/services.rs` +- One deliberate exception to wrapper error conversion: `run_service_query{,_bearer}` in `src/cloud/services/client.rs` return the raw library error and an unread `reqwest::Response`, because the query paths stream the body and classify the error themselves before `convert_query_error`. Do not copy that shape for other endpoints. - Cloud handlers support `--json` unless there is good reason not to. JSON is emitted automatically when `--json` @@ -66,7 +66,9 @@ work on a branch, with an associated issue and a PR. To add or change a local or Cloud command or flag, use the `add-cli-command` skill (`.agents/skills/add-cli-command/SKILL.md`). Local clap definitions live in `src/local/cli.rs`. Each Cloud -domain keeps its definitions, handlers and tests together in `src/cloud/.rs`. +domain keeps its definitions, handlers and tests together in `src/cloud/.rs`, or `src/cloud//` for +large domains (clap definitions in `cli.rs`, `CloudClient` wrappers in `client.rs`, one file per command family, +tests beside the code they test). ## Writing help text @@ -78,7 +80,8 @@ flag, or any help text. It holds the help standard and its test rule. Test coverage is non-negotiable. - **Clap parsing** — `Cli::try_parse_from` tests next to each command definition (`src/cli.rs`, the owning - `src/cloud/.rs`, `src/cloud/cli.rs`, `src/local/cli.rs`). Assert flag names, types, defaults, repeatability. + `src/cloud/.rs` or `src/cloud//cli.rs`, `src/cloud/cli.rs`, `src/local/cli.rs`). Assert flag + names, types, defaults, repeatability. - **Request builders** — unit tests for `build_*_request` helpers next to the owning cloud domain code, asserting on library request-struct fields with minimal + maximal inputs. - **Cloud subprocess + wiremock** — `tests/cli_request_shape_test.rs`. Spawn the real binary against a local mock diff --git a/docs/development.md b/docs/development.md index 33910242..07684595 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,6 +9,7 @@ | `crates/clickhousectl/src/main.rs`, `cli.rs` | Entrypoint, common CLI options, output mode | | `crates/clickhousectl/src/local/` | Local command definitions, dispatch, servers, Docker/Postgres, output | | `crates/clickhousectl/src/cloud/.rs` | Cloud domain definitions, handlers, builders, wrappers, tests | +| `crates/clickhousectl/src/cloud//` | A large Cloud domain: `PERMISSIONS` and dispatch in `mod.rs`, clap definitions in `cli.rs`, `CloudClient` wrappers in `client.rs`, one file per command family, tests beside the code they test | | `crates/clickhousectl/src/cloud/client.rs` | Credential resolution and shared Cloud client/error handling | | `crates/clickhousectl/src/stdout.rs` | CLI-owned stdout and typed broken-pipe handling | | `crates/clickhouse-cloud-api/` | Published typed API client |