use chrono::{DateTime, FixedOffset, NaiveDate, NaiveTime};
use clap::builder::PossibleValuesParser;
use clap::{Args, Subcommand};
const OBJECT_STORAGE_FORMATS: &[&str] = &[
"JSONEachRow",
"JSONAsObject",
"CSV",
"CSVWithNames",
"TabSeparated",
"TabSeparatedWithNames",
"Parquet",
"Avro",
];
const OBJECT_STORAGE_COMPRESSIONS: &[&str] = &[
"none", "gzip", "gz", "brotli", "br", "xz", "LZMA", "zstd", "auto",
];
const OBJECT_STORAGE_TYPES: &[&str] = &[
"s3",
"gcs",
"dospaces",
"azureblobstorage",
"cloudflarer2",
"ovhobjectstorage",
];
const KAFKA_FORMATS: &[&str] = &["JSONEachRow", "Avro", "AvroConfluent", "Protobuf"];
const KAFKA_TYPES: &[&str] = &[
"kafka",
"redpanda",
"msk",
"gcmk",
"confluent",
"warpstream",
"azureeventhub",
"dokafka",
];
const KAFKA_AUTHS: &[&str] = &[
"PLAIN",
"SCRAM-SHA-256",
"SCRAM-SHA-512",
"IAM_ROLE",
"IAM_USER",
"MUTUAL_TLS",
];
const KAFKA_OFFSET_STRATEGIES: &[&str] = &["from_beginning", "from_latest", "from_timestamp"];
const KINESIS_FORMATS: &[&str] = &["JSONEachRow", "Avro", "AvroConfluent"];
const KINESIS_AUTHS: &[&str] = &["IAM_ROLE", "IAM_USER"];
const KINESIS_ITERATOR_TYPES: &[&str] = &["TRIM_HORIZON", "LATEST", "AT_TIMESTAMP"];
const POSTGRES_TYPES: &[&str] = &[
"postgres",
"supabase",
"neon",
"alloydb",
"planetscale",
"rdspostgres",
"aurorapostgres",
"cloudsqlpostgres",
"azurepostgres",
"crunchybridge",
"tigerdata",
];
const DB_AUTHS: &[&str] = &["basic", "IAM_ROLE"];
const REPLICATION_MODES: &[&str] = &["cdc", "snapshot", "cdc_only"];
const MYSQL_TYPES: &[&str] = &["mysql", "rdsmysql", "auroramysql", "mariadb", "rdsmariadb"];
const MYSQL_REPLICATION_MECHANISMS: &[&str] = &["GTID", "FILE_POS"];
const MONGODB_READ_PREFERENCES: &[&str] = &[
"primary",
"primaryPreferred",
"secondary",
"secondaryPreferred",
"nearest",
];
fn parse_date_only(value: &str) -> Result<String, String> {
if NaiveDate::parse_from_str(value, "%Y-%m-%d").is_err() {
return Err(format!("invalid date '{}': expected YYYY-MM-DD", value));
}
Ok(value.to_string())
}
pub(super) fn parse_datetime(value: &str) -> Result<String, String> {
if DateTime::<FixedOffset>::parse_from_rfc3339(value).is_err() {
return Err(format!(
"invalid datetime '{}': expected ISO 8601 / RFC 3339",
value
));
}
Ok(value.to_string())
}
fn parse_time_only(value: &str) -> Result<String, String> {
if NaiveTime::parse_from_str(value, "%H:%M").is_err() {
return Err(format!("invalid time '{}': expected HH:MM", value));
}
Ok(value.to_string())
}
#[derive(Subcommand)]
pub enum AuthCommands {
#[command(after_help = "\
CONTEXT FOR AGENTS:
Defaults to OAuth device flow (opens browser). OAuth tokens are READ-ONLY.
For write operations, use API keys via: --api-key/--api-secret flags, or
CLICKHOUSE_CLOUD_API_KEY / CLICKHOUSE_CLOUD_API_SECRET env vars (exported or in .env).
Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl
Related: use `clickhousectl cloud auth status` to verify.")]
Login {
#[arg(long)]
interactive: bool,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
api_secret: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
With no flags, clears everything. Use --oauth to keep API keys, or --api-keys to keep OAuth tokens.")]
Logout {
#[arg(long, conflicts_with = "api_keys")]
oauth: bool,
#[arg(long, conflicts_with = "oauth")]
api_keys: bool,
},
Status,
#[command(after_help = "\
CONTEXT FOR AGENTS:
Opens the ClickHouse Cloud sign-up page in the user's browser. This is an interactive flow —
it requires a human to complete sign-up in the browser. Do not use in fully autonomous or CI environments.")]
Signup,
}
#[derive(Args)]
pub struct CloudArgs {
#[arg(long, global = true)]
pub api_key: Option<String>,
#[arg(long, global = true)]
pub api_secret: Option<String>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub debug: bool,
#[cfg_attr(debug_assertions, arg(long, global = true))]
#[cfg_attr(not(debug_assertions), arg(long, global = true, hide = true))]
pub url: Option<String>,
#[command(subcommand)]
pub command: CloudCommands,
}
#[derive(Subcommand)]
pub enum CloudCommands {
#[command(after_help = "\
CONTEXT FOR AGENTS:
Default `login` opens a browser for OAuth (read-only).
Use `login --api-key X --api-secret Y` for full read/write access, or set
CLICKHOUSE_CLOUD_API_KEY / CLICKHOUSE_CLOUD_API_SECRET env vars (exported or in .env).
Create API keys: https://clickhouse.com/docs/cloud/manage/openapi?referrer=clickhousectl
`logout` clears all saved credentials (OAuth tokens and API keys).
Related: `clickhousectl cloud org list` to verify credentials work.")]
Auth {
#[command(subcommand)]
command: AuthCommands,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Manage ClickHouse Cloud organizations. Subcommands: list, get, update, prometheus, usage.
Org IDs are needed for most service and backup operations.
Start with `clickhousectl cloud org list` to discover available org IDs.
Related: `clickhousectl cloud service list` (uses org ID).")]
Org {
#[command(subcommand)]
command: OrgCommands,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Most commands need a service ID — get it from `clickhousectl cloud service list`.
Org ID is auto-detected if you have only one org; otherwise pass --org-id.
Write commands (create, delete, start, stop, update, scale) require API key auth — OAuth is read-only.
Use `query` to run SQL against a service over HTTP.
Related: `clickhousectl cloud org list` for org IDs.")]
Service {
#[command(subcommand)]
command: ServiceCommands,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Manage ClickHouse Cloud backups. Subcommands: list, get.
Requires a service ID — get it from `clickhousectl cloud service list`.
Backup IDs from `backup list` can be used with `service create --backup-id` to restore.
Related: `clickhousectl cloud service list` for service IDs.")]
Backup {
#[command(subcommand)]
command: BackupCommands,
},
#[command(
name = "clickpipe",
after_help = "\
CONTEXT FOR AGENTS:
Manage ClickPipes for ingesting data into ClickHouse Cloud.
Subcommands: list, get, delete, start, stop, resync, scale, settings, create.
Requires a service ID — get it from `clickhousectl cloud service list`."
)]
ClickPipe {
#[command(subcommand)]
command: Box<ClickPipeCommands>,
},
Member {
#[command(subcommand)]
command: MemberCommands,
},
Invitation {
#[command(subcommand)]
command: InvitationCommands,
},
Key {
#[command(subcommand)]
command: KeyCommands,
},
Activity {
#[command(subcommand)]
command: ActivityCommands,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Manage ClickHouse Cloud managed Postgres services. Subcommands cover CRUD, lifecycle
(restart/promote/switchover), CA certs, runtime config, password reset, read replicas,
and point-in-time restore. Service IDs come from `postgres list`.
Write commands require API key auth — OAuth is read-only.")]
Postgres {
#[command(subcommand)]
command: crate::cloud::postgres::PostgresCommands,
},
}
impl CloudCommands {
pub fn is_write_command(&self) -> bool {
match self {
CloudCommands::Auth { .. } => false,
CloudCommands::Org { command } => match command {
OrgCommands::List => false,
OrgCommands::Get { .. } => false,
OrgCommands::Prometheus { .. } => false,
OrgCommands::Usage { .. } => false,
OrgCommands::Update { .. } => true,
},
CloudCommands::Service { command } => match command {
ServiceCommands::List { .. } => false,
ServiceCommands::Get { .. } => false,
ServiceCommands::Prometheus { .. } => false,
ServiceCommands::Query { .. } => false,
ServiceCommands::Create { .. } => true,
ServiceCommands::Delete { .. } => true,
ServiceCommands::Start { .. } => 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 } => match command {
BackupConfigCommands::Get { .. } => false,
BackupConfigCommands::Update { .. } => true,
},
},
CloudCommands::Backup { command } => match command {
BackupCommands::List { .. } => false,
BackupCommands::Get { .. } => false,
},
CloudCommands::Member { command } => match command {
MemberCommands::List { .. } => false,
MemberCommands::Get { .. } => false,
MemberCommands::Update { .. } => true,
MemberCommands::Remove { .. } => true,
},
CloudCommands::Invitation { command } => match command {
InvitationCommands::List { .. } => false,
InvitationCommands::Get { .. } => false,
InvitationCommands::Create { .. } => true,
InvitationCommands::Delete { .. } => true,
},
CloudCommands::Key { command } => match command {
KeyCommands::List { .. } => false,
KeyCommands::Get { .. } => false,
KeyCommands::Create { .. } => true,
KeyCommands::Update { .. } => true,
KeyCommands::Delete { .. } => true,
},
CloudCommands::Activity { command } => match command {
ActivityCommands::List { .. } => false,
ActivityCommands::Get { .. } => false,
},
CloudCommands::Postgres { command } => command.is_write(),
CloudCommands::ClickPipe { command } => match command.as_ref() {
ClickPipeCommands::List { .. } => false,
ClickPipeCommands::Get { .. } => false,
ClickPipeCommands::Delete { .. } => true,
ClickPipeCommands::Start { .. } => true,
ClickPipeCommands::Stop { .. } => true,
ClickPipeCommands::Resync { .. } => true,
ClickPipeCommands::Scale { .. } => true,
ClickPipeCommands::SchemaDiscover { .. } => true,
ClickPipeCommands::Create { .. } => true,
ClickPipeCommands::Settings { command } => match command {
ClickPipeSettingsCommands::Get { .. } => false,
ClickPipeSettingsCommands::Update { .. } => true,
},
},
}
}
}
#[derive(Subcommand)]
pub enum OrgCommands {
#[command(after_help = "\
CONTEXT FOR AGENTS:
Returns all organizations accessible with the current API credentials.
Use this to find org IDs needed by service and backup commands.
Add --json for machine-readable output.
Related: `clickhousectl cloud service list` next.")]
List,
#[command(after_help = "\
CONTEXT FOR AGENTS:
Returns details for a single organization by ID.
Get org IDs from `clickhousectl cloud org list`.
Add --json for machine-readable output.
Related: `clickhousectl cloud org list` to find org IDs.")]
Get {
org_id: String,
},
Update {
org_id: String,
#[arg(long)]
name: Option<String>,
#[arg(long = "remove-private-endpoint")]
remove_private_endpoint: Vec<String>,
#[arg(long)]
enable_core_dumps: Option<bool>,
},
Prometheus {
org_id: String,
#[arg(long)]
filtered_metrics: Option<bool>,
},
Usage {
org_id: String,
#[arg(long, value_parser = parse_date_only)]
from_date: String,
#[arg(long, value_parser = parse_date_only)]
to_date: String,
#[arg(long)]
filter: Vec<String>,
},
}
#[derive(Subcommand)]
pub enum ServiceCommands {
#[command(after_help = "\
CONTEXT FOR AGENTS:
Lists all services in the organization. Org ID is auto-detected if only one org exists.
Returns service IDs needed by get, delete, start, stop, and backup commands.
Add --json for machine-readable output.
Related: `clickhousectl cloud service get <id>` for full details.")]
List {
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
filter: Vec<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Returns full service details: status, endpoints, scaling config, IP access list.
Get the service ID from `clickhousectl cloud service list`.
Add --json for machine-readable output.
Related: `clickhousectl cloud service start/stop <id>` to change state.")]
Get {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Creates a new ClickHouse Cloud service. Only --name is required; other fields have defaults.
Returns the new service ID and initial password — save these.
Typical: `clickhousectl cloud service create --name my-svc`.
Defaults: provider=aws, region=us-east-1. Add --json for machine-readable output.
Related: `clickhousectl cloud service get <id>` to check status after creation.")]
Create {
#[arg(long)]
name: String,
#[arg(long, default_value = "aws")]
provider: String,
#[arg(long, default_value = "us-east-1")]
region: String,
#[arg(long)]
min_replica_memory_gb: Option<u32>,
#[arg(long)]
max_replica_memory_gb: Option<u32>,
#[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])]
num_replicas: Option<u32>,
#[arg(long, conflicts_with = "num_replicas")]
min_replicas: Option<u32>,
#[arg(long, conflicts_with = "num_replicas")]
max_replicas: Option<u32>,
#[arg(
long,
value_parser = PossibleValuesParser::new(
clickhouse_cloud_api::models::AutoscalingMode::VALUES
)
)]
autoscaling_mode: Option<String>,
#[arg(long)]
idle_scaling: Option<bool>,
#[arg(long)]
idle_timeout_minutes: Option<u32>,
#[arg(long = "ip-allow")]
ip_allow: Vec<String>,
#[arg(long)]
backup_id: Option<String>,
#[arg(long)]
release_channel: Option<String>,
#[arg(long)]
data_warehouse_id: Option<String>,
#[arg(long)]
readonly: bool,
#[arg(long)]
encryption_key: Option<String>,
#[arg(long)]
encryption_role: Option<String>,
#[arg(long)]
enable_tde: bool,
#[arg(long)]
compliance_type: Option<String>,
#[arg(long)]
profile: Option<String>,
#[arg(long = "tag", value_name = "KEY[=VALUE]")]
tag: Vec<String>,
#[arg(long = "enable-endpoint")]
enable_endpoint: Vec<String>,
#[arg(long = "disable-endpoint")]
disable_endpoint: Vec<String>,
#[arg(long)]
private_preview_terms_checked: bool,
#[arg(long)]
enable_core_dumps: Option<bool>,
#[arg(long)]
org_id: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Permanently deletes a ClickHouse Cloud service. This action is irreversible.
Use --force to stop a running service before deleting it in one step.
Related: `clickhousectl cloud service stop <id>` to idle instead of delete.")]
Delete {
service_id: String,
#[arg(long)]
force: bool,
#[arg(long)]
org_id: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Resumes a stopped/idled ClickHouse Cloud service.
Takes a service ID — get it from `clickhousectl cloud service list`.
Add --json for machine-readable output.
Related: `clickhousectl cloud service get <id>` to check status, `clickhousectl cloud service stop <id>` to idle.")]
Start {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Idles a ClickHouse Cloud service, stopping billing for compute.
Data is preserved. Takes a service ID — get it from `clickhousectl cloud service list`.
Add --json for machine-readable output.
Related: `clickhousectl cloud service start <id>` to resume, `clickhousectl cloud service delete <id>` to remove.")]
Stop {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
Update {
service_id: String,
#[arg(long)]
name: Option<String>,
#[arg(long = "add-ip-allow")]
add_ip_allow: Vec<String>,
#[arg(long = "remove-ip-allow")]
remove_ip_allow: Vec<String>,
#[arg(long = "add-private-endpoint-id")]
add_private_endpoint_id: Vec<String>,
#[arg(long = "remove-private-endpoint-id")]
remove_private_endpoint_id: Vec<String>,
#[arg(long)]
release_channel: Option<String>,
#[arg(long = "enable-endpoint")]
enable_endpoint: Vec<String>,
#[arg(long = "disable-endpoint")]
disable_endpoint: Vec<String>,
#[arg(long)]
transparent_data_encryption_key_id: Option<String>,
#[arg(long = "add-tag", value_name = "KEY[=VALUE]")]
add_tag: Vec<String>,
#[arg(long = "remove-tag", value_name = "KEY[=VALUE]")]
remove_tag: Vec<String>,
#[arg(long)]
enable_core_dumps: Option<bool>,
#[arg(long)]
org_id: Option<String>,
},
Scale {
service_id: String,
#[arg(long)]
min_replica_memory_gb: Option<u32>,
#[arg(long)]
max_replica_memory_gb: Option<u32>,
#[arg(long, conflicts_with_all = ["min_replicas", "max_replicas"])]
num_replicas: Option<u32>,
#[arg(long, conflicts_with = "num_replicas")]
min_replicas: Option<u32>,
#[arg(long, conflicts_with = "num_replicas")]
max_replicas: Option<u32>,
#[arg(
long,
value_parser = PossibleValuesParser::new(
clickhouse_cloud_api::models::AutoscalingMode::VALUES
)
)]
autoscaling_mode: Option<String>,
#[arg(long)]
idle_scaling: Option<bool>,
#[arg(long)]
idle_timeout_minutes: Option<u32>,
#[arg(long)]
org_id: Option<String>,
},
ResetPassword {
service_id: String,
#[arg(long)]
new_password_hash: Option<String>,
#[arg(long)]
new_double_sha1_hash: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
#[command(name = "query-endpoint")]
QueryEndpoint {
#[command(subcommand)]
command: QueryEndpointCommands,
},
#[command(name = "private-endpoint")]
PrivateEndpoint {
#[command(subcommand)]
command: PrivateEndpointCommands,
},
#[command(name = "backup-config")]
BackupConfig {
#[command(subcommand)]
command: BackupConfigCommands,
},
Prometheus {
service_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
filtered_metrics: Option<bool>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Runs SQL over HTTP — no local clickhouse binary or service password required.
With API key auth: uses a per-service API key (read+write, scoped to this
service via the query endpoint binding) auto-provisioned on first use and
stored in .clickhouse/credentials.json.
With OAuth (cloud auth login): sends your own bearer token — SQL runs as
your cloud user with read-only access (SELECT only, no writes); no key
provisioning and no query endpoint required on the service.
SQL precedence: --query > --queries-file > stdin. Default format: PrettyCompact
on a TTY, TabSeparated when piped.")]
Query {
#[arg(long, conflicts_with = "id")]
name: Option<String>,
#[arg(long, conflicts_with = "name")]
id: Option<String>,
#[arg(long, short)]
query: Option<String>,
#[arg(long)]
queries_file: Option<String>,
#[arg(long)]
database: Option<String>,
#[arg(long)]
format: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
no_auto_enable: bool,
},
}
#[derive(Subcommand)]
pub enum QueryEndpointCommands {
Get {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
Create {
service_id: String,
#[arg(long)]
role: Vec<String>,
#[arg(long = "open-api-key")]
open_api_key: Vec<String>,
#[arg(long)]
allowed_origins: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Delete {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum PrivateEndpointCommands {
Create {
service_id: String,
#[arg(long)]
endpoint_id: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
GetConfig {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum BackupCommands {
#[command(after_help = "\
CONTEXT FOR AGENTS:
Lists all backups for a given service. Requires a service ID from `clickhousectl cloud service list`.
Returns backup IDs that can be used with `clickhousectl cloud service create --backup-id` to restore.
Add --json for machine-readable output.
Related: `clickhousectl cloud backup get` for details on a specific backup.")]
List {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
#[command(after_help = "\
CONTEXT FOR AGENTS:
Returns details for a specific backup. Requires service ID and backup ID.
Get service IDs from `clickhousectl cloud service list`, backup IDs from `clickhousectl cloud backup list`.
Add --json for machine-readable output.
Related: `clickhousectl cloud service create --backup-id <id>` to restore from this backup.")]
Get {
service_id: String,
backup_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum ClickPipeCommands {
List {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
Get {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Delete {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Start {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Stop {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Resync {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Scale {
service_id: String,
clickpipe_id: String,
#[arg(long)]
replicas: Option<u32>,
#[arg(long)]
cpu_millicores: Option<u32>,
#[arg(long)]
memory_gb: Option<f64>,
#[arg(long)]
org_id: Option<String>,
},
Settings {
#[command(subcommand)]
command: ClickPipeSettingsCommands,
},
#[command(after_help = "\\
CONTEXT FOR AGENTS:
Infers the schema (column name + ClickHouse type) for a Kafka or Kinesis source
without creating a ClickPipe. Useful for filling in --column on `clickpipe create`.
Related: `clickhousectl cloud clickpipe create kafka|kinesis` to create a pipe with the discovered columns.")]
SchemaDiscover {
service_id: String,
#[command(subcommand)]
command: ClickPipeSchemaDiscoverCommands,
#[arg(long)]
org_id: Option<String>,
},
Create {
#[command(subcommand)]
command: ClickPipeCreateCommands,
},
}
#[derive(Subcommand)]
pub enum ClickPipeSchemaDiscoverCommands {
Kafka(Box<KafkaSourceFields>),
Kinesis(Box<KinesisSourceFields>),
}
#[derive(Subcommand)]
pub enum ClickPipeSettingsCommands {
Get {
service_id: String,
clickpipe_id: String,
#[arg(long)]
org_id: Option<String>,
},
Update {
service_id: String,
clickpipe_id: String,
#[arg(long)]
streaming_max_insert_wait_ms: Option<u32>,
#[arg(long)]
object_storage_concurrency: Option<u32>,
#[arg(long)]
object_storage_polling_interval_ms: Option<u32>,
#[arg(long)]
object_storage_max_insert_bytes: Option<u64>,
#[arg(long)]
object_storage_max_file_count: Option<u32>,
#[arg(long)]
clickhouse_max_threads: Option<u32>,
#[arg(long)]
clickhouse_max_insert_threads: Option<u32>,
#[arg(long)]
object_storage_use_cluster_function: Option<bool>,
#[arg(long)]
clickhouse_parallel_view_processing: Option<bool>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ClickPipeCreateCommands {
#[command(name = "object-storage")]
ObjectStorage(ObjectStorageCreateArgs),
Kafka(KafkaCreateArgs),
Kinesis(KinesisCreateArgs),
Postgres(PostgresCreateArgs),
#[command(name = "mysql")]
MySQL(MySqlCreateArgs),
#[command(name = "mongodb")]
MongoDB(MongoDbCreateArgs),
#[command(name = "bigquery")]
BigQuery(BigQueryCreateArgs),
}
#[derive(Args, Debug)]
pub struct ObjectStorageCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub source_url: String,
#[arg(long, value_parser = PossibleValuesParser::new(OBJECT_STORAGE_FORMATS))]
pub format: String,
#[arg(long)]
pub database: String,
#[arg(long)]
pub table: String,
#[arg(long = "column")]
pub columns: Vec<String>,
#[arg(
long,
default_value = "s3",
value_parser = PossibleValuesParser::new(OBJECT_STORAGE_TYPES),
)]
pub storage_type: String,
#[arg(
long,
default_value = "auto",
value_parser = PossibleValuesParser::new(OBJECT_STORAGE_COMPRESSIONS),
)]
pub compression: String,
#[arg(long)]
pub continuous: bool,
#[arg(long)]
pub queue_url: Option<String>,
#[arg(long, requires = "queue_url")]
pub skip_initial_load: bool,
#[arg(long, conflicts_with = "skip_initial_load")]
pub start_after: Option<String>,
#[arg(long)]
pub delimiter: Option<String>,
#[arg(long)]
pub iam_role: Option<String>,
#[arg(long, requires = "secret_key")]
pub access_key_id: Option<String>,
#[arg(long, requires = "access_key_id")]
pub secret_key: Option<String>,
#[arg(long)]
pub connection_string: Option<String>,
#[arg(long)]
pub azure_container_name: Option<String>,
#[arg(long)]
pub path: Option<String>,
#[arg(long)]
pub service_account_file: Option<String>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct KafkaSourceFields {
#[arg(long)]
pub brokers: String,
#[arg(long)]
pub topics: String,
#[arg(long, value_parser = PossibleValuesParser::new(KAFKA_FORMATS))]
pub format: String,
#[arg(
long,
default_value = "kafka",
value_parser = PossibleValuesParser::new(KAFKA_TYPES),
)]
pub kafka_type: String,
#[arg(long)]
pub consumer_group: Option<String>,
#[arg(long, value_parser = PossibleValuesParser::new(KAFKA_AUTHS))]
pub auth: Option<String>,
#[arg(long, requires = "password")]
pub username: Option<String>,
#[arg(long, requires = "username")]
pub password: Option<String>,
#[arg(long)]
pub iam_role: Option<String>,
#[arg(long, requires = "secret_key")]
pub access_key_id: Option<String>,
#[arg(long, requires = "access_key_id")]
pub secret_key: Option<String>,
#[arg(
long,
default_value = "from_beginning",
value_parser = PossibleValuesParser::new(KAFKA_OFFSET_STRATEGIES),
)]
pub offset: String,
#[arg(long)]
pub offset_timestamp: Option<String>,
#[arg(long)]
pub schema_registry_url: Option<String>,
#[arg(long)]
pub schema_registry_username: Option<String>,
#[arg(long)]
pub schema_registry_password: Option<String>,
#[arg(long)]
pub ca_certificate: Option<String>,
#[arg(long)]
pub client_certificate: Option<String>,
#[arg(long)]
pub client_key: Option<String>,
#[arg(long)]
pub schema_registry_ca_certificate: Option<String>,
#[arg(long = "reverse-private-endpoint-id")]
pub reverse_private_endpoint_ids: Vec<String>,
}
#[derive(Args, Debug)]
pub struct KafkaCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[command(flatten)]
pub source: KafkaSourceFields,
#[arg(long)]
pub database: String,
#[arg(long)]
pub table: String,
#[arg(long = "column")]
pub columns: Vec<String>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct KinesisSourceFields {
#[arg(long)]
pub stream_name: String,
#[arg(long)]
pub region: String,
#[arg(long, value_parser = PossibleValuesParser::new(KINESIS_FORMATS))]
pub format: String,
#[arg(
long,
default_value = "IAM_ROLE",
value_parser = PossibleValuesParser::new(KINESIS_AUTHS),
)]
pub auth: String,
#[arg(long)]
pub iam_role: Option<String>,
#[arg(long, requires = "secret_key")]
pub access_key_id: Option<String>,
#[arg(long, requires = "access_key_id")]
pub secret_key: Option<String>,
#[arg(
long,
default_value = "TRIM_HORIZON",
value_parser = PossibleValuesParser::new(KINESIS_ITERATOR_TYPES),
)]
pub iterator_type: String,
#[arg(long)]
pub iterator_timestamp: Option<u64>,
#[arg(long)]
pub enhanced_fan_out: bool,
}
#[derive(Args, Debug)]
pub struct KinesisCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[command(flatten)]
pub source: KinesisSourceFields,
#[arg(long)]
pub database: String,
#[arg(long)]
pub table: String,
#[arg(long = "column")]
pub columns: Vec<String>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct PostgresCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub host: String,
#[arg(long, default_value = "5432")]
pub port: u16,
#[arg(long)]
pub pg_database: String,
#[arg(long)]
pub username: String,
#[arg(long)]
pub password: String,
#[arg(long = "table-mapping")]
pub table_mappings: Vec<String>,
#[arg(
long,
default_value = "postgres",
value_parser = PossibleValuesParser::new(POSTGRES_TYPES),
)]
pub postgres_type: String,
#[arg(
long,
default_value = "cdc",
value_parser = PossibleValuesParser::new(REPLICATION_MODES),
)]
pub replication_mode: String,
#[arg(
long,
default_value = "basic",
value_parser = PossibleValuesParser::new(DB_AUTHS),
)]
pub auth: String,
#[arg(long)]
pub iam_role: Option<String>,
#[arg(long)]
pub tls_host: Option<String>,
#[arg(long)]
pub ca_certificate: Option<String>,
#[arg(long)]
pub publication_name: Option<String>,
#[arg(long)]
pub replication_slot_name: Option<String>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct MySqlCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub host: String,
#[arg(long, default_value = "3306")]
pub port: u16,
#[arg(long)]
pub username: String,
#[arg(long)]
pub password: String,
#[arg(long = "table-mapping")]
pub table_mappings: Vec<String>,
#[arg(
long,
default_value = "mysql",
value_parser = PossibleValuesParser::new(MYSQL_TYPES),
)]
pub mysql_type: String,
#[arg(
long,
default_value = "cdc",
value_parser = PossibleValuesParser::new(REPLICATION_MODES),
)]
pub replication_mode: String,
#[arg(
long,
default_value = "GTID",
value_parser = PossibleValuesParser::new(MYSQL_REPLICATION_MECHANISMS),
)]
pub replication_mechanism: String,
#[arg(
long,
default_value = "basic",
value_parser = PossibleValuesParser::new(DB_AUTHS),
)]
pub auth: String,
#[arg(long)]
pub iam_role: Option<String>,
#[arg(long)]
pub tls_host: Option<String>,
#[arg(long)]
pub ca_certificate: Option<String>,
#[arg(long)]
pub disable_tls: bool,
#[arg(long)]
pub skip_cert_verification: bool,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..=4294967295))]
pub server_id: Option<u64>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct MongoDbCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub uri: String,
#[arg(long)]
pub username: String,
#[arg(long)]
pub password: String,
#[arg(long = "table-mapping")]
pub table_mappings: Vec<String>,
#[arg(
long,
default_value = "cdc",
value_parser = PossibleValuesParser::new(REPLICATION_MODES),
)]
pub replication_mode: String,
#[arg(
long,
default_value = "secondaryPreferred",
value_parser = PossibleValuesParser::new(MONGODB_READ_PREFERENCES),
)]
pub read_preference: String,
#[arg(long)]
pub tls_host: Option<String>,
#[arg(long)]
pub ca_certificate: Option<String>,
#[arg(long)]
pub disable_tls: bool,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Args, Debug)]
pub struct BigQueryCreateArgs {
pub service_id: String,
#[arg(long)]
pub name: String,
#[arg(long)]
pub service_account_file: String,
#[arg(long)]
pub staging_path: String,
#[arg(long = "table-mapping")]
pub table_mappings: Vec<String>,
#[arg(long)]
pub org_id: Option<String>,
}
#[derive(Subcommand)]
pub enum MemberCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Get {
user_id: String,
#[arg(long)]
org_id: Option<String>,
},
Update {
user_id: String,
#[arg(long)]
role_id: Vec<String>,
#[arg(long)]
org_id: Option<String>,
},
Remove {
user_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum InvitationCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Create {
#[arg(long)]
email: String,
#[arg(long)]
role_id: Vec<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
invitation_id: String,
#[arg(long)]
org_id: Option<String>,
},
Delete {
invitation_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum KeyCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Create {
#[arg(long)]
name: String,
#[arg(long)]
role_id: Vec<String>,
#[arg(long, value_parser = parse_datetime)]
expires_at: Option<String>,
#[arg(long)]
state: Option<String>,
#[arg(long = "ip-allow")]
ip_allow: Vec<String>,
#[arg(long)]
hash_key_id: Option<String>,
#[arg(long)]
hash_key_id_suffix: Option<String>,
#[arg(long)]
hash_key_secret: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
key_id: String,
#[arg(long)]
org_id: Option<String>,
},
Update {
key_id: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
role_id: Vec<String>,
#[arg(long, value_parser = parse_datetime)]
expires_at: Option<String>,
#[arg(long)]
state: Option<String>,
#[arg(long = "ip-allow")]
ip_allow: Vec<String>,
#[arg(long)]
org_id: Option<String>,
},
Delete {
key_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum ActivityCommands {
List {
#[arg(long)]
org_id: Option<String>,
#[arg(long, value_parser = parse_date_only)]
from_date: Option<String>,
#[arg(long, value_parser = parse_date_only)]
to_date: Option<String>,
},
Get {
activity_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand)]
pub enum BackupConfigCommands {
Get {
service_id: String,
#[arg(long)]
org_id: Option<String>,
},
Update {
service_id: String,
#[arg(long)]
backup_period_hours: Option<u32>,
#[arg(long)]
backup_retention_period_hours: Option<u32>,
#[arg(long, value_parser = parse_time_only)]
backup_start_time: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{Cli, Commands};
use clap::Parser;
#[test]
fn parses_service_update_ga_patch_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"update",
"svc-1",
"--add-ip-allow",
"10.0.0.0/8",
"--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",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
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, "svc-1");
assert_eq!(add_ip_allow, vec!["10.0.0.0/8"]);
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_create_horizontal_autoscaling_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"create",
"--name",
"s",
"--min-replicas",
"2",
"--max-replicas",
"8",
"--autoscaling-mode",
"horizontal",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
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() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"create",
"--name",
"s",
"--min-replicas",
"2",
"--max-replicas",
"8",
"--num-replicas",
"3",
]);
assert!(result.is_err());
}
#[test]
fn parses_service_create_horizontal_mode_with_memory_bounds() {
let cli = Cli::try_parse_from([
"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",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
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() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"create",
"--name",
"s",
"--min-replicas",
"2",
"--max-replicas",
"8",
"--autoscaling-mode",
"turbo",
]);
assert!(result.is_err());
}
#[test]
fn parses_service_scale_horizontal_autoscaling_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"scale",
"svc-1",
"--min-replicas",
"2",
"--max-replicas",
"8",
"--autoscaling-mode",
"horizontal",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
let ServiceCommands::Scale {
service_id,
min_replicas,
max_replicas,
autoscaling_mode,
num_replicas,
..
} = command
else {
panic!("expected service scale");
};
assert_eq!(service_id, "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 cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"scale",
"svc-1",
"--autoscaling-mode",
"vertical",
"--num-replicas",
"3",
"--min-replica-memory-gb",
"8",
"--max-replica-memory-gb",
"32",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
let ServiceCommands::Scale {
autoscaling_mode,
num_replicas,
min_replica_memory_gb,
max_replica_memory_gb,
min_replicas,
max_replicas,
..
} = 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());
}
#[test]
fn rejects_service_scale_num_replicas_with_replica_band() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"scale",
"svc-1",
"--num-replicas",
"3",
"--min-replicas",
"2",
"--max-replicas",
"8",
]);
assert!(result.is_err());
}
#[test]
fn parses_clickpipe_object_storage_ingestion_control_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"object-storage",
"svc-id",
"--name",
"t",
"--source-url",
"https://b.s3.us-east-1.amazonaws.com/d/*.json",
"--format",
"JSONEachRow",
"--database",
"d",
"--table",
"t",
"--column",
"id:Int64",
"--queue-url",
"https://sqs.us-east-1.amazonaws.com/123/q",
"--start-after",
"key1",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::ClickPipe { command } = args.command else {
panic!("expected clickpipe command");
};
let ClickPipeCommands::Create { command } = *command else {
panic!("expected create");
};
let ClickPipeCreateCommands::ObjectStorage(args) = command else {
panic!("expected object-storage");
};
assert!(!args.skip_initial_load);
assert_eq!(args.start_after.as_deref(), Some("key1"));
assert_eq!(
args.queue_url.as_deref(),
Some("https://sqs.us-east-1.amazonaws.com/123/q")
);
}
#[test]
fn rejects_skip_initial_load_without_queue_url() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"object-storage",
"svc-id",
"--name",
"t",
"--source-url",
"https://b.s3.us-east-1.amazonaws.com/d/*.json",
"--format",
"JSONEachRow",
"--database",
"d",
"--table",
"t",
"--column",
"id:Int64",
"--skip-initial-load",
]);
assert!(result.is_err());
}
#[test]
fn rejects_skip_initial_load_with_start_after() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"object-storage",
"svc-id",
"--name",
"t",
"--source-url",
"https://b.s3.us-east-1.amazonaws.com/d/*.json",
"--format",
"JSONEachRow",
"--database",
"d",
"--table",
"t",
"--column",
"id:Int64",
"--queue-url",
"https://sqs.us-east-1.amazonaws.com/123/q",
"--skip-initial-load",
"--start-after",
"key1",
]);
assert!(result.is_err());
}
#[test]
fn parses_clickpipe_mysql_server_id() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"mysql",
"svc-id",
"--name",
"t",
"--host",
"h",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"db.t:t",
"--server-id",
"4242",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::ClickPipe { command } = args.command else {
panic!("expected clickpipe command");
};
let ClickPipeCommands::Create { command } = *command else {
panic!("expected create");
};
let ClickPipeCreateCommands::MySQL(args) = command else {
panic!("expected mysql");
};
assert_eq!(args.server_id, Some(4242));
}
#[test]
fn rejects_clickpipe_mysql_server_id_out_of_range() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"mysql",
"svc-id",
"--name",
"t",
"--host",
"h",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"db.t:t",
"--server-id",
"0",
]);
assert!(result.is_err());
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"create",
"mysql",
"svc-id",
"--name",
"t",
"--host",
"h",
"--username",
"u",
"--password",
"p",
"--table-mapping",
"db.t:t",
"--server-id",
"4294967296",
]);
assert!(result.is_err());
}
#[test]
fn parses_clickpipe_schema_discover_kafka() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"clickpipe",
"schema-discover",
"svc-1",
"kafka",
"--brokers",
"b:9092",
"--topics",
"t",
"--format",
"JSONEachRow",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::ClickPipe { command } = args.command else {
panic!("expected clickpipe command");
};
let ClickPipeCommands::SchemaDiscover {
service_id,
command,
..
} = *command
else {
panic!("expected schema-discover");
};
assert_eq!(service_id, "svc-1");
assert!(matches!(command, ClickPipeSchemaDiscoverCommands::Kafka(_)));
}
#[test]
fn parses_private_endpoint_config_and_password_hash_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"reset-password",
"svc-1",
"--new-password-hash",
"sha256",
"--new-double-sha1-hash",
"sha1",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
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 cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"private-endpoint",
"get-config",
"svc-1",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
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, "svc-1");
}
#[test]
fn parses_key_create_and_backup_config_update_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"key",
"create",
"--name",
"ci-key",
"--ip-allow",
"10.0.0.0/8",
"--hash-key-id",
"id-hash",
"--hash-key-id-suffix",
"abcd",
"--hash-key-secret",
"secret-hash",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Key { command } = args.command else {
panic!("expected key command");
};
let KeyCommands::Create {
ip_allow,
hash_key_id,
hash_key_id_suffix,
hash_key_secret,
..
} = command
else {
panic!("expected key create");
};
assert_eq!(ip_allow, vec!["10.0.0.0/8"]);
assert_eq!(hash_key_id.as_deref(), Some("id-hash"));
assert_eq!(hash_key_id_suffix.as_deref(), Some("abcd"));
assert_eq!(hash_key_secret.as_deref(), Some("secret-hash"));
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"backup-config",
"update",
"svc-1",
"--backup-period-hours",
"12",
"--backup-retention-period-hours",
"336",
"--backup-start-time",
"03:00",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
let ServiceCommands::BackupConfig { command } = command else {
panic!("expected backup-config");
};
let BackupConfigCommands::Update {
backup_period_hours,
backup_retention_period_hours,
backup_start_time,
..
} = command
else {
panic!("expected backup-config update");
};
assert_eq!(backup_period_hours, Some(12));
assert_eq!(backup_retention_period_hours, Some(336));
assert_eq!(backup_start_time.as_deref(), Some("03:00"));
}
#[test]
fn parses_key_expires_at_rfc3339_timestamps() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"key",
"create",
"--name",
"ci-key",
"--expires-at",
"2025-12-31T23:59:59Z",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Key { command } = args.command else {
panic!("expected key command");
};
let KeyCommands::Create { expires_at, .. } = command else {
panic!("expected key create");
};
assert_eq!(expires_at.as_deref(), Some("2025-12-31T23:59:59Z"));
}
#[test]
fn rejects_invalid_key_expires_at_timestamps() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"key",
"update",
"key-1",
"--expires-at",
"2025-12-31",
]);
match result {
Ok(_) => panic!("expected invalid expires-at input to be rejected"),
Err(err) => assert!(err.to_string().contains("expected ISO 8601 / RFC 3339")),
}
}
#[test]
fn parses_backup_start_time_hhmm() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"backup-config",
"update",
"svc-1",
"--backup-start-time",
"03:00",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Service { command } = args.command else {
panic!("expected service command");
};
let ServiceCommands::BackupConfig { command } = command else {
panic!("expected backup-config");
};
let BackupConfigCommands::Update {
backup_start_time, ..
} = command
else {
panic!("expected backup-config update");
};
assert_eq!(backup_start_time.as_deref(), Some("03:00"));
}
#[test]
fn rejects_invalid_backup_start_time() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"service",
"backup-config",
"update",
"svc-1",
"--backup-start-time",
"25:00",
]);
match result {
Ok(_) => panic!("expected invalid backup start time to be rejected"),
Err(err) => assert!(err.to_string().contains("expected HH:MM")),
}
}
#[test]
fn parses_org_usage_date_only_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"org",
"usage",
"org-1",
"--from-date",
"2025-01-01",
"--to-date",
"2025-01-31",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Org { command } = args.command else {
panic!("expected org command");
};
let OrgCommands::Usage {
from_date, to_date, ..
} = command
else {
panic!("expected org usage");
};
assert_eq!(from_date, "2025-01-01");
assert_eq!(to_date, "2025-01-31");
}
#[test]
fn rejects_org_usage_timestamps() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"org",
"usage",
"org-1",
"--from-date",
"2025-01-01T00:00:00Z",
"--to-date",
"2025-01-31",
]);
match result {
Ok(_) => panic!("expected timestamp input to be rejected"),
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
}
}
#[test]
fn rejects_invalid_org_usage_calendar_dates() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"org",
"usage",
"org-1",
"--from-date",
"2025-02-31",
"--to-date",
"2025-03-01",
]);
match result {
Ok(_) => panic!("expected invalid calendar date to be rejected"),
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
}
}
#[test]
fn parses_activity_list_date_only_flags() {
let cli = Cli::try_parse_from([
"clickhousectl",
"cloud",
"activity",
"list",
"--from-date",
"2025-01-01",
"--to-date",
"2025-01-31",
])
.unwrap();
let Commands::Cloud(args) = cli.command else {
panic!("expected cloud command");
};
let CloudCommands::Activity { command } = args.command else {
panic!("expected activity command");
};
let ActivityCommands::List {
from_date, to_date, ..
} = command
else {
panic!("expected activity list");
};
assert_eq!(from_date.as_deref(), Some("2025-01-01"));
assert_eq!(to_date.as_deref(), Some("2025-01-31"));
}
#[test]
fn rejects_activity_list_timestamps() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"activity",
"list",
"--from-date",
"2025-01-01T00:00:00Z",
"--to-date",
"2025-01-31",
]);
match result {
Ok(_) => panic!("expected timestamp input to be rejected"),
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
}
}
#[test]
fn rejects_invalid_activity_list_calendar_dates() {
let result = Cli::try_parse_from([
"clickhousectl",
"cloud",
"activity",
"list",
"--from-date",
"2025-02-31",
"--to-date",
"2025-03-01",
]);
match result {
Ok(_) => panic!("expected invalid calendar date to be rejected"),
Err(err) => assert!(err.to_string().contains("expected YYYY-MM-DD")),
}
}
fn assert_write(args: &[&str], expected: bool) {
let cli = Cli::try_parse_from(args).unwrap();
let Commands::Cloud(cloud_args) = cli.command else {
panic!("expected cloud command");
};
assert_eq!(
cloud_args.command.is_write_command(),
expected,
"wrong classification for: {}",
args.join(" ")
);
}
#[test]
fn is_write_command_read_only_commands() {
assert_write(&["clickhousectl", "cloud", "org", "list"], false);
assert_write(&["clickhousectl", "cloud", "org", "get", "org-1"], false);
assert_write(
&["clickhousectl", "cloud", "org", "prometheus", "org-1"],
false,
);
assert_write(
&[
"clickhousectl",
"cloud",
"org",
"usage",
"org-1",
"--from-date",
"2025-01-01",
"--to-date",
"2025-01-31",
],
false,
);
assert_write(&["clickhousectl", "cloud", "service", "list"], false);
assert_write(
&["clickhousectl", "cloud", "service", "get", "svc-1"],
false,
);
assert_write(
&["clickhousectl", "cloud", "service", "prometheus", "svc-1"],
false,
);
assert_write(
&["clickhousectl", "cloud", "backup", "list", "svc-1"],
false,
);
assert_write(
&["clickhousectl", "cloud", "backup", "get", "svc-1", "bk-1"],
false,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"backup-config",
"get",
"svc-1",
],
false,
);
assert_write(&["clickhousectl", "cloud", "member", "list"], false);
assert_write(&["clickhousectl", "cloud", "member", "get", "usr-1"], false);
assert_write(&["clickhousectl", "cloud", "invitation", "list"], false);
assert_write(
&["clickhousectl", "cloud", "invitation", "get", "inv-1"],
false,
);
assert_write(&["clickhousectl", "cloud", "key", "list"], false);
assert_write(&["clickhousectl", "cloud", "key", "get", "key-1"], false);
assert_write(&["clickhousectl", "cloud", "activity", "list"], false);
assert_write(
&["clickhousectl", "cloud", "activity", "get", "act-1"],
false,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"query-endpoint",
"get",
"svc-1",
],
false,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"private-endpoint",
"get-config",
"svc-1",
],
false,
);
assert_write(&["clickhousectl", "cloud", "postgres", "list"], false);
assert_write(
&["clickhousectl", "cloud", "postgres", "get", "pg-1"],
false,
);
assert_write(
&["clickhousectl", "cloud", "postgres", "certs", "get", "pg-1"],
false,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"config",
"get",
"pg-1",
],
false,
);
}
#[test]
fn is_write_command_destructive_commands() {
assert_write(
&[
"clickhousectl",
"cloud",
"clickpipe",
"schema-discover",
"svc-1",
"kafka",
"--brokers",
"b:9092",
"--topics",
"t",
"--format",
"JSONEachRow",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"org",
"update",
"org-1",
"--name",
"new",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"create",
"--name",
"s",
"--provider",
"aws",
"--region",
"us-east-1",
],
true,
);
assert_write(
&["clickhousectl", "cloud", "service", "delete", "svc-1"],
true,
);
assert_write(
&["clickhousectl", "cloud", "service", "start", "svc-1"],
true,
);
assert_write(
&["clickhousectl", "cloud", "service", "stop", "svc-1"],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"update",
"svc-1",
"--name",
"new",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"scale",
"svc-1",
"--num-replicas",
"2",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"reset-password",
"svc-1",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"backup-config",
"update",
"svc-1",
"--backup-period-hours",
"12",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"member",
"update",
"usr-1",
"--role-id",
"r1",
],
true,
);
assert_write(
&["clickhousectl", "cloud", "member", "remove", "usr-1"],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"invitation",
"create",
"--email",
"a@b.com",
"--role-id",
"r1",
],
true,
);
assert_write(
&["clickhousectl", "cloud", "invitation", "delete", "inv-1"],
true,
);
assert_write(
&["clickhousectl", "cloud", "key", "create", "--name", "k"],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"key",
"update",
"key-1",
"--name",
"new",
],
true,
);
assert_write(&["clickhousectl", "cloud", "key", "delete", "key-1"], true);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"query-endpoint",
"create",
"svc-1",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"query-endpoint",
"delete",
"svc-1",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"service",
"private-endpoint",
"create",
"svc-1",
"--endpoint-id",
"ep-1",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"create",
"--name",
"pg",
"--region",
"us-east-1",
"--size",
"m7i.2xlarge",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"update",
"pg-1",
"--size",
"c6gd.large",
],
true,
);
assert_write(
&["clickhousectl", "cloud", "postgres", "delete", "pg-1"],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"config",
"replace",
"pg-1",
"--file",
"/tmp/c.json",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"config",
"patch",
"pg-1",
"--set",
"max_connections=500",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"reset-password",
"pg-1",
"--generate",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"read-replica",
"create",
"pg-1",
"--name",
"r1",
],
true,
);
assert_write(
&[
"clickhousectl",
"cloud",
"postgres",
"restore",
"pg-1",
"--name",
"r",
"--restore-target",
"2026-04-16T12:00:00Z",
],
true,
);
assert_write(
&["clickhousectl", "cloud", "postgres", "restart", "pg-1"],
true,
);
assert_write(
&["clickhousectl", "cloud", "postgres", "promote", "pg-1"],
true,
);
assert_write(
&["clickhousectl", "cloud", "postgres", "switchover", "pg-1"],
true,
);
}
}