diff --git a/Cargo.lock b/Cargo.lock index e25e84c..a5f2aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1519,6 +1519,7 @@ dependencies = [ "serde_json", "tempfile", "urlencoding", + "uuid", "webbrowser", ] @@ -2273,6 +2274,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index c411f5b..8249a07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ chrono = "0.4" axoupdater = { version = "0.10.2", default-features = false, features = ["blocking", "github_releases"] } [dev-dependencies] +uuid = { version = "1", features = ["v4"] } tempfile = "3" [profile.release] diff --git a/README.md b/README.md index a6de5ec..d7b94ff 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,24 @@ rtree cluster create \ --min-size 2:8 \ --max-size 64:256 \ --idle-timeout-minutes 30 +# Or create a cluster with customer-owned S3 storage +rtree cluster create \ + --name customer-data \ + --replicas 1 \ + --min-size 2:8 \ + --s3-storage-data-bucket acme-rawtree-data \ + --s3-storage-data-path rawtree/data \ + --s3-storage-backups-bucket acme-rawtree-backups \ + --s3-storage-backups-path rawtree/backups \ + --s3-storage-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ + --s3-storage-access-external-id rawtree-example +# Or choose explicit cluster identity with RawTree-managed default storage +rtree cluster create \ + --name database-storage \ + --replicas 1 \ + --min-size 2:8 \ + --s3-storage-access-external-id rawtree-database-access \ + --s3-storage-access-resource-tag rawtree-customer-database rtree cluster use production rtree cluster status production rtree cluster update production --idle-timeout-minutes 60 @@ -394,6 +412,59 @@ catalog. Run `rtree cluster sizes` to see the currently available sizes; add `--idle-timeout-minutes` is optional on create and update; omit it to use the server default on create, and pass `0` to disable automatic idling. +Cluster creation forwards `--s3-storage-access-external-id` and `--s3-storage-access-resource-tag` when supplied. +The API generates omitted identity values independently as `rawtree-` +using secure randomness and returns the saved values. The CLI does not generate +identity values. Both are immutable after creation. +Custom cluster storage requires `--s3-storage-access-external-id` to match the customer IAM role's +trust policy. `--s3-storage-access-resource-tag` may be supplied or generated. + +Flag names follow the API field paths: + +| Flag | API field | Available on | +| --- | --- | --- | +| `--s3-storage-data-bucket` | `s3_storage.data.bucket` | Cluster and database creation | +| `--s3-storage-data-path` | `s3_storage.data.path` | Cluster and database creation | +| `--s3-storage-backups-bucket` | `s3_storage.backups.bucket` | Cluster and database creation | +| `--s3-storage-backups-path` | `s3_storage.backups.path` | Cluster and database creation | +| `--s3-storage-role-arn` | `s3_storage.role_arn` | Cluster and database creation | +| `--s3-storage-access-external-id` | `s3_storage_access.external_id` | Cluster creation only | +| `--s3-storage-access-resource-tag` | `s3_storage_access.resource_tag` | Cluster creation only | + +Cluster and database creation share `--s3-storage-data-bucket`, `--s3-storage-backups-bucket`, and +`--s3-storage-role-arn`; all three are required if any destination or role option is supplied. The optional +`--s3-storage-data-path` and `--s3-storage-backups-path` specify object-key prefixes. + +```sh +rtree --org acme --cluster production database create analytics \ + --s3-storage-data-bucket acme-rawtree-data \ + --s3-storage-backups-bucket acme-rawtree-backups \ + --s3-storage-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access +``` + +Database creation reuses the cluster's saved identity: no external ID or tag is +accepted on database commands. Use `rtree cluster status production` to read the +External ID for the IAM trust policy and the resource tag value. Tag eligible +customer buckets and roles with `rawtree.com/cluster=`. + +These flags configure new resources; the API verifies access and provisions +storage. The CLI does not create AWS buckets or IAM roles or migrate existing +data. Omitting S3 options uses RawTree-managed cluster storage or, for a database, +inherits its cluster's storage. + +The API payload has an `s3_storage` object containing `data` and `backups` +destinations (each with `bucket` and optional `path`), plus `role_arn`. List/status +JSON always includes both destinations with explicit paths for custom storage +and preserves cluster `s3_storage_access.external_id` and +`s3_storage_access.resource_tag`; role ARNs and credentials are not returned. Older clusters without +saved identity show `not configured` in status output. + +The former `--s3-data-*`, `--s3-backup-*`, `--s3-backups-*`, `--s3-role-arn`, +`--s3-storage-external-id`, `--s3-storage-resource-tag`, and legacy database +identity flags are rejected. This CLI targets +[Platform PR #1482](https://github.com/rawtreedb/rawtree-platform/pull/1482) and +must be released with that server contract. + Cluster lifecycle and provisioning changes are asynchronous. The `create`, `stop`, `resume`, and `delete` commands return as soon as the API accepts the request; they do not wait for the infrastructure operation to finish. After diff --git a/src/cli.rs b/src/cli.rs index 5f617c2..2c659cc 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,6 +2,9 @@ use std::{fmt, str::FromStr}; use clap::{Parser, Subcommand, ValueEnum}; +use crate::cluster_identity::ClusterIdentityArgs; +use crate::s3_storage::S3StorageArgs; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ClusterSizeArg { pub(crate) cpu_cores: u32, @@ -267,6 +270,8 @@ pub enum DatabaseCommand { Create { /// Database name name: String, + #[command(flatten)] + s3_storage: S3StorageArgs, }, /// Set the default database Use { @@ -343,6 +348,10 @@ pub enum ClusterCommand { /// Minutes without activity before automatically pausing; 0 disables idling #[arg(long)] idle_timeout_minutes: Option, + #[command(flatten)] + s3_storage: S3StorageArgs, + #[command(flatten)] + identity: ClusterIdentityArgs, }, /// Set the default cluster Use { @@ -609,642 +618,4 @@ pub struct WorkflowTimeRange { } #[cfg(test)] -mod tests { - use super::{ - Cli, ClusterCommand, ClusterSizeArg, Command, KeyCommand, TableCommand, WorkflowCommand, - }; - use clap::{error::ErrorKind, CommandFactory, Parser}; - - #[test] - fn root_command_exposes_version_flag() { - let command = Cli::command(); - assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); - } - - #[test] - fn table_sorting_key_accepts_one_sql_expression_string() { - let expression = "region, ifNull(cityHash64(host, instanceId), 0)"; - let cli = Cli::try_parse_from([ - "rtree", - "table", - "create", - "events", - "--database", - "analytics", - "--sorting-key", - expression, - ]) - .expect("table create should parse"); - assert!(matches!( - cli.command, - Command::Table { - action: TableCommand::Create { sorting_key: Some(key), .. } - } if key == expression - )); - - let cli = Cli::try_parse_from([ - "rtree", - "table", - "update", - "events", - "--sorting-key", - expression, - ]) - .expect("table update should parse"); - assert!(matches!( - cli.command, - Command::Table { - action: TableCommand::Update { sorting_key, .. } - } if sorting_key == expression - )); - - let err = Cli::try_parse_from(["rtree", "table", "update", "events"]) - .err() - .expect("update requires a sorting key"); - assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument); - } - - #[test] - fn lowercase_v_triggers_version_output() { - let err = match Cli::try_parse_from(["rtree", "-v"]) { - Ok(_) => panic!("-v should print version"), - Err(err) => err, - }; - assert_eq!(err.kind(), ErrorKind::DisplayVersion); - assert!(err.to_string().contains(env!("CARGO_PKG_VERSION"))); - } - - #[test] - fn login_without_api_key_is_allowed_for_interactive_flow() { - let cli = Cli::try_parse_from(["rtree", "login"]).expect("login should parse"); - assert!(cli.api_key.is_none()); - } - - #[test] - fn login_with_api_key_parses() { - let cli = Cli::try_parse_from(["rtree", "login", "--api-key", "rt_abc123"]) - .expect("login with --api-key should parse"); - assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); - } - - #[test] - fn login_help_hides_internal_email_and_password_flags() { - let err = match Cli::try_parse_from(["rtree", "login", "--help"]) { - Ok(_) => panic!("--help should return display output"), - Err(err) => err, - }; - assert_eq!(err.kind(), ErrorKind::DisplayHelp); - let help = err.to_string(); - assert!(!help.contains("--email")); - assert!(!help.contains("--password")); - assert!(!help.to_ascii_lowercase().contains("email")); - } - - #[test] - fn global_api_key_parses_before_subcommand() { - let cli = Cli::try_parse_from(["rtree", "--api-key", "rt_abc123", "database", "list"]) - .expect("global --api-key should parse before subcommand"); - assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); - } - - #[test] - fn cluster_list_parses() { - let cli = - Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); - assert!(matches!( - cli.command, - Command::Cluster { - action: ClusterCommand::List - } - )); - } - - #[test] - fn cluster_sizes_parses() { - let cli = - Cli::try_parse_from(["rtree", "cluster", "sizes"]).expect("cluster sizes should parse"); - assert!(matches!( - cli.command, - Command::Cluster { - action: ClusterCommand::Sizes { region_id: None } - } - )); - } - - #[test] - fn cluster_create_parses_explicit_name_and_idle_timeout() { - let cli = Cli::try_parse_from([ - "rtree", - "cluster", - "create", - "--name", - "production", - "--replicas", - "2", - "--min-size", - "2:8", - "--max-size", - "64:256", - "--idle-timeout-minutes", - "30", - ]) - .expect("cluster create should parse"); - assert!(matches!( - cli.command, - Command::Cluster { - action: ClusterCommand::Create { - name, - replicas, - idle_timeout_minutes: Some(30), - region_id: None, - min_size, - max_size: Some(max_size), - } - } if name == "production" - && replicas == 2 - && min_size == ClusterSizeArg { cpu_cores: 2, memory_gib: 8 } - && max_size == ClusterSizeArg { cpu_cores: 64, memory_gib: 256 } - )); - } - - #[test] - fn cluster_create_rejects_invalid_size_format() { - let error = match Cli::try_parse_from([ - "rtree", - "cluster", - "create", - "--name", - "production", - "--replicas", - "2", - "--min-size", - "2x8", - ]) { - Ok(_) => panic!("cluster create should reject an invalid size"), - Err(error) => error, - }; - - let message = error.to_string(); - assert!(message.contains("CPU_CORES:MEMORY_GIB")); - assert!(message.contains("2:8")); - } - - #[test] - fn cluster_update_parses_partial_settings() { - let cli = Cli::try_parse_from([ - "rtree", - "cluster", - "update", - "production", - "--idle-timeout-minutes", - "0", - ]) - .expect("cluster update should parse"); - assert!(matches!( - cli.command, - Command::Cluster { - action: ClusterCommand::Update { - name_or_id, - name: None, - idle_timeout_minutes: Some(0), - } - } if name_or_id == "production" - )); - } - - #[test] - fn cluster_use_parses() { - let cli = Cli::try_parse_from(["rtree", "cluster", "use", "production"]) - .expect("cluster use should parse"); - - match cli.command { - Command::Cluster { - action: ClusterCommand::Use { name }, - } => assert_eq!(name, "production"), - _ => panic!("expected cluster use command"), - } - } - - #[test] - fn cluster_lifecycle_commands_parse() { - let status = Cli::try_parse_from(["rtree", "cluster", "status", "production"]) - .expect("cluster status should parse"); - assert!(matches!( - status.command, - Command::Cluster { - action: ClusterCommand::Status { name_or_id } - } if name_or_id == "production" - )); - - let stop = Cli::try_parse_from(["rtree", "cluster", "stop", "production"]) - .expect("cluster stop should parse"); - assert!(matches!( - stop.command, - Command::Cluster { - action: ClusterCommand::Stop { name_or_id } - } if name_or_id == "production" - )); - - let resume = Cli::try_parse_from(["rtree", "cluster", "resume", "production"]) - .expect("cluster resume should parse"); - assert!(matches!( - resume.command, - Command::Cluster { - action: ClusterCommand::Resume { name_or_id } - } if name_or_id == "production" - )); - - let delete = Cli::try_parse_from(["rtree", "cluster", "delete", "production"]) - .expect("cluster delete should parse"); - assert!(matches!( - delete.command, - Command::Cluster { - action: ClusterCommand::Delete { name_or_id } - } if name_or_id == "production" - )); - } - - #[test] - fn login_with_token_flag_is_rejected() { - let result = Cli::try_parse_from(["rtree", "login", "--token", "rt_abc123"]); - assert!(result.is_err(), "--token should not be accepted"); - } - - #[test] - fn login_with_password_requires_email() { - let result = Cli::try_parse_from(["rtree", "login", "--password", "secret123"]); - assert!(result.is_err(), "password without email should fail"); - } - - #[test] - fn login_with_api_key_conflicts_with_email() { - let cli = Cli::try_parse_from([ - "rtree", - "login", - "--api-key", - "rt_abc123", - "--email", - "user@example.com", - ]) - .expect("global --api-key is parsed before runtime login validation"); - assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); - } - - #[test] - fn login_with_database_without_email_is_allowed_for_interactive_flow() { - let cli = Cli::try_parse_from(["rtree", "login", "--database", "analytics"]) - .expect("login with --database should parse"); - match cli.command { - Command::Login { - email, database, .. - } => { - assert!(email.is_none()); - assert_eq!(database.as_deref(), Some("analytics")); - } - _ => panic!("expected login command"), - } - } - - #[test] - fn register_command_is_rejected() { - let result = Cli::try_parse_from([ - "rtree", - "register", - "--email", - "user@example.com", - "--password", - "secret123", - ]); - assert!(result.is_err(), "register command should not be accepted"); - } - - #[test] - fn insert_with_url_is_allowed() { - let cli = Cli::try_parse_from([ - "rtree", - "insert", - "--database", - "analytics", - "--table", - "events", - "--url", - "https://example.com/events.jsonl", - ]) - .expect("insert --url should parse"); - - match cli.command { - Command::Insert { url, .. } => { - assert_eq!(url.as_deref(), Some("https://example.com/events.jsonl")) - } - _ => panic!("expected insert command"), - } - } - - #[test] - fn insert_url_conflicts_with_data() { - let result = Cli::try_parse_from([ - "rtree", - "insert", - "--database", - "analytics", - "--table", - "events", - "--url", - "https://example.com/events.jsonl", - "--data", - r#"{"id":1}"#, - ]); - assert!(result.is_err(), "insert --url should conflict with --data"); - } - - #[test] - fn api_url_and_insert_url_can_both_be_provided() { - let cli = Cli::try_parse_from([ - "rtree", - "--api-url", - "https://api.rawtree.com", - "insert", - "--database", - "analytics", - "--table", - "events", - "--url", - "https://example.com/events.jsonl", - ]) - .expect("--api-url and insert --url should parse"); - - assert_eq!(cli.api_url.as_deref(), Some("https://api.rawtree.com")); - match cli.command { - Command::Insert { url, .. } => { - assert_eq!(url.as_deref(), Some("https://example.com/events.jsonl")) - } - _ => panic!("expected insert command"), - } - } - - #[test] - fn api_url_can_be_passed_before_subcommand() { - let cli = Cli::try_parse_from([ - "rtree", - "--api-url", - "https://api.rawtree.com", - "query", - "--database", - "analytics", - "--sql", - "SELECT 1", - ]) - .expect("--api-url should parse before subcommand"); - - assert_eq!(cli.api_url.as_deref(), Some("https://api.rawtree.com")); - } - - #[test] - fn cluster_can_be_passed_after_subcommand() { - let cli = Cli::try_parse_from([ - "rtree", - "query", - "--cluster", - "production", - "--database", - "analytics", - "--sql", - "SELECT 1", - ]) - .expect("global --cluster should parse after the subcommand"); - - assert_eq!(cli.cluster.as_deref(), Some("production")); - } - - #[test] - fn query_format_flag_is_rejected() { - let result = Cli::try_parse_from([ - "rtree", - "query", - "--database", - "analytics", - "--sql", - "SELECT 1", - "--format", - "csv", - ]); - assert!(result.is_err(), "query --format should not be supported"); - } - - #[test] - fn key_command_is_singular() { - let cli = Cli::try_parse_from(["rtree", "key", "list"]).unwrap(); - assert!(matches!( - cli.command, - Command::Key { - action: KeyCommand::List - } - )); - } - - #[test] - fn cluster_scoped_commands_reject_obsolete_database_flags() { - for args in [ - vec!["rtree", "key", "list", "--database", "analytics"], - vec![ - "rtree", - "key", - "delete", - "key-id", - "--database", - "analytics", - ], - vec!["rtree", "logs", "--database", "analytics"], - vec!["rtree", "logs", "--log-databases", "analytics"], - ] { - assert_eq!( - Cli::try_parse_from(args) - .err() - .expect("obsolete flag must fail") - .kind(), - ErrorKind::UnknownArgument - ); - } - } - - #[test] - fn keys_command_is_rejected() { - let result = Cli::try_parse_from(["rtree", "keys", "list", "--database", "analytics"]); - assert!(result.is_err(), "keys should not be accepted as a command"); - } - - #[test] - fn key_create_uses_name_flag() { - let cli = Cli::try_parse_from([ - "rtree", - "key", - "create", - "--database", - "analytics", - "--name", - "ci", - "--permission", - "read_write", - ]) - .expect("key create with --name should parse"); - - match cli.command { - Command::Key { action } => match action { - KeyCommand::Create { name, .. } => { - assert_eq!(name, "ci"); - } - _ => panic!("expected key create command"), - }, - _ => panic!("expected key command"), - } - } - - #[test] - fn key_create_label_flag_is_rejected() { - let result = Cli::try_parse_from([ - "rtree", - "key", - "create", - "--database", - "analytics", - "--label", - "ci", - "--permission", - "read_write", - ]); - assert!(result.is_err(), "key create should use --name, not --label"); - } - - #[test] - fn logs_request_filters_parse() { - let cli = Cli::try_parse_from([ - "rtree", - "logs", - "--search", - "request-123", - "--methods", - "GET,POST", - "--status-codes", - "200,500", - "--levels", - "success,error", - "--sources", - "cli", - "--paths", - "/v1/query,/v1/logs", - ]) - .expect("request log filters should parse"); - - match cli.command { - Command::Logs { - search, - methods, - status_codes, - levels, - sources, - paths, - .. - } => { - assert_eq!(search.as_deref(), Some("request-123")); - assert_eq!(methods, vec!["GET", "POST"]); - assert_eq!(status_codes, vec![200, 500]); - assert_eq!(levels, vec!["success", "error"]); - assert_eq!(sources, vec!["cli"]); - assert_eq!(paths, vec!["/v1/query", "/v1/logs"]); - } - _ => panic!("expected logs command"), - } - } - - #[test] - fn logs_status_codes_must_be_http_statuses() { - let result = Cli::try_parse_from(["rtree", "logs", "--status-codes", "99"]); - assert!(result.is_err(), "status codes below 100 should be rejected"); - } - - #[test] - fn workflow_create_collects_repeated_sinks() { - let cli = Cli::try_parse_from([ - "rtree", - "workflow", - "create", - "--name", - "alerts", - "--sql", - "SELECT 1", - "--interval-seconds", - "60", - "--disabled", - "--sink", - r#"{"type":"table","settings":{"database":"a","table":"b"}}"#, - "--sink", - r#"{"type":"http","settings":{"url":"https://example.com"}}"#, - ]) - .expect("workflow create should parse"); - match cli.command { - Command::Workflow { - action: - WorkflowCommand::Create { - interval_seconds, - disabled, - sinks, - database, - .. - }, - } => { - assert_eq!(interval_seconds.map(|interval| interval.0), Some(Some(60))); - assert!(disabled); - assert_eq!(sinks.len(), 2); - assert!(database.is_none()); - } - _ => panic!("expected workflow create command"), - } - } - - #[test] - fn workflow_update_rejects_conflicting_flags() { - for args in [ - vec!["--enable", "--disable"], - vec!["--clear-sinks", "--sink", "{}"], - ] { - let mut command = vec!["rtree", "workflow", "update", "wf-1"]; - command.extend(args); - assert!(Cli::try_parse_from(command).is_err()); - } - } - - #[test] - fn workflow_interval_and_time_ranges_are_validated() { - assert!(Cli::try_parse_from([ - "rtree", - "workflow", - "create", - "--name", - "a", - "--sql", - "SELECT 1", - "--interval-seconds", - "86401", - ]) - .is_err()); - assert!(Cli::try_parse_from([ - "rtree", - "workflow", - "logs", - "wf-1", - "--since", - "1h", - "--start-time", - "2026-01-01T00:00:00Z", - ]) - .is_err()); - let cli = Cli::try_parse_from(["rtree", "workflow", "metrics", "wf-1", "--since", "2h"]) - .expect("workflow metrics should parse"); - assert!(matches!( - cli.command, - Command::Workflow { - action: WorkflowCommand::Metrics { range, .. } - } if range.since.as_deref() == Some("2h") - )); - } -} +mod tests; diff --git a/src/cli/tests.rs b/src/cli/tests.rs new file mode 100644 index 0000000..ce99527 --- /dev/null +++ b/src/cli/tests.rs @@ -0,0 +1,637 @@ +use super::{ + Cli, ClusterCommand, ClusterSizeArg, Command, KeyCommand, TableCommand, WorkflowCommand, +}; +use clap::{error::ErrorKind, CommandFactory, Parser}; + +#[test] +fn root_command_exposes_version_flag() { + let command = Cli::command(); + assert_eq!(command.get_version(), Some(env!("CARGO_PKG_VERSION"))); +} + +#[test] +fn table_sorting_key_accepts_one_sql_expression_string() { + let expression = "region, ifNull(cityHash64(host, instanceId), 0)"; + let cli = Cli::try_parse_from([ + "rtree", + "table", + "create", + "events", + "--database", + "analytics", + "--sorting-key", + expression, + ]) + .expect("table create should parse"); + assert!(matches!( + cli.command, + Command::Table { + action: TableCommand::Create { sorting_key: Some(key), .. } + } if key == expression + )); + + let cli = Cli::try_parse_from([ + "rtree", + "table", + "update", + "events", + "--sorting-key", + expression, + ]) + .expect("table update should parse"); + assert!(matches!( + cli.command, + Command::Table { + action: TableCommand::Update { sorting_key, .. } + } if sorting_key == expression + )); + + let err = Cli::try_parse_from(["rtree", "table", "update", "events"]) + .err() + .expect("update requires a sorting key"); + assert_eq!(err.kind(), ErrorKind::MissingRequiredArgument); +} + +#[test] +fn lowercase_v_triggers_version_output() { + let err = match Cli::try_parse_from(["rtree", "-v"]) { + Ok(_) => panic!("-v should print version"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::DisplayVersion); + assert!(err.to_string().contains(env!("CARGO_PKG_VERSION"))); +} + +#[test] +fn login_without_api_key_is_allowed_for_interactive_flow() { + let cli = Cli::try_parse_from(["rtree", "login"]).expect("login should parse"); + assert!(cli.api_key.is_none()); +} + +#[test] +fn login_with_api_key_parses() { + let cli = Cli::try_parse_from(["rtree", "login", "--api-key", "rt_abc123"]) + .expect("login with --api-key should parse"); + assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); +} + +#[test] +fn login_help_hides_internal_email_and_password_flags() { + let err = match Cli::try_parse_from(["rtree", "login", "--help"]) { + Ok(_) => panic!("--help should return display output"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::DisplayHelp); + let help = err.to_string(); + assert!(!help.contains("--email")); + assert!(!help.contains("--password")); + assert!(!help.to_ascii_lowercase().contains("email")); +} + +#[test] +fn global_api_key_parses_before_subcommand() { + let cli = Cli::try_parse_from(["rtree", "--api-key", "rt_abc123", "database", "list"]) + .expect("global --api-key should parse before subcommand"); + assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); +} + +#[test] +fn cluster_list_parses() { + let cli = Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); + assert!(matches!( + cli.command, + Command::Cluster { + action: ClusterCommand::List + } + )); +} + +#[test] +fn cluster_sizes_parses() { + let cli = + Cli::try_parse_from(["rtree", "cluster", "sizes"]).expect("cluster sizes should parse"); + assert!(matches!( + cli.command, + Command::Cluster { + action: ClusterCommand::Sizes { region_id: None } + } + )); +} + +#[test] +fn cluster_create_parses_explicit_name_and_idle_timeout() { + let cli = Cli::try_parse_from([ + "rtree", + "cluster", + "create", + "--name", + "production", + "--replicas", + "2", + "--min-size", + "2:8", + "--max-size", + "64:256", + "--idle-timeout-minutes", + "30", + ]) + .expect("cluster create should parse"); + assert!(matches!( + cli.command, + Command::Cluster { + action: ClusterCommand::Create { + name, + replicas, + idle_timeout_minutes: Some(30), + region_id: None, + min_size, + max_size: Some(max_size), + .. + } + } if name == "production" + && replicas == 2 + && min_size == ClusterSizeArg { cpu_cores: 2, memory_gib: 8 } + && max_size == ClusterSizeArg { cpu_cores: 64, memory_gib: 256 } + )); +} + +#[test] +fn cluster_create_rejects_invalid_size_format() { + let error = match Cli::try_parse_from([ + "rtree", + "cluster", + "create", + "--name", + "production", + "--replicas", + "2", + "--min-size", + "2x8", + ]) { + Ok(_) => panic!("cluster create should reject an invalid size"), + Err(error) => error, + }; + + let message = error.to_string(); + assert!(message.contains("CPU_CORES:MEMORY_GIB")); + assert!(message.contains("2:8")); +} + +#[test] +fn cluster_update_parses_partial_settings() { + let cli = Cli::try_parse_from([ + "rtree", + "cluster", + "update", + "production", + "--idle-timeout-minutes", + "0", + ]) + .expect("cluster update should parse"); + assert!(matches!( + cli.command, + Command::Cluster { + action: ClusterCommand::Update { + name_or_id, + name: None, + idle_timeout_minutes: Some(0), + } + } if name_or_id == "production" + )); +} + +#[test] +fn cluster_use_parses() { + let cli = Cli::try_parse_from(["rtree", "cluster", "use", "production"]) + .expect("cluster use should parse"); + + match cli.command { + Command::Cluster { + action: ClusterCommand::Use { name }, + } => assert_eq!(name, "production"), + _ => panic!("expected cluster use command"), + } +} + +#[test] +fn cluster_lifecycle_commands_parse() { + let status = Cli::try_parse_from(["rtree", "cluster", "status", "production"]) + .expect("cluster status should parse"); + assert!(matches!( + status.command, + Command::Cluster { + action: ClusterCommand::Status { name_or_id } + } if name_or_id == "production" + )); + + let stop = Cli::try_parse_from(["rtree", "cluster", "stop", "production"]) + .expect("cluster stop should parse"); + assert!(matches!( + stop.command, + Command::Cluster { + action: ClusterCommand::Stop { name_or_id } + } if name_or_id == "production" + )); + + let resume = Cli::try_parse_from(["rtree", "cluster", "resume", "production"]) + .expect("cluster resume should parse"); + assert!(matches!( + resume.command, + Command::Cluster { + action: ClusterCommand::Resume { name_or_id } + } if name_or_id == "production" + )); + + let delete = Cli::try_parse_from(["rtree", "cluster", "delete", "production"]) + .expect("cluster delete should parse"); + assert!(matches!( + delete.command, + Command::Cluster { + action: ClusterCommand::Delete { name_or_id } + } if name_or_id == "production" + )); +} + +#[test] +fn login_with_token_flag_is_rejected() { + let result = Cli::try_parse_from(["rtree", "login", "--token", "rt_abc123"]); + assert!(result.is_err(), "--token should not be accepted"); +} + +#[test] +fn login_with_password_requires_email() { + let result = Cli::try_parse_from(["rtree", "login", "--password", "secret123"]); + assert!(result.is_err(), "password without email should fail"); +} + +#[test] +fn login_with_api_key_conflicts_with_email() { + let cli = Cli::try_parse_from([ + "rtree", + "login", + "--api-key", + "rt_abc123", + "--email", + "user@example.com", + ]) + .expect("global --api-key is parsed before runtime login validation"); + assert_eq!(cli.api_key.as_deref(), Some("rt_abc123")); +} + +#[test] +fn login_with_database_without_email_is_allowed_for_interactive_flow() { + let cli = Cli::try_parse_from(["rtree", "login", "--database", "analytics"]) + .expect("login with --database should parse"); + match cli.command { + Command::Login { + email, database, .. + } => { + assert!(email.is_none()); + assert_eq!(database.as_deref(), Some("analytics")); + } + _ => panic!("expected login command"), + } +} + +#[test] +fn register_command_is_rejected() { + let result = Cli::try_parse_from([ + "rtree", + "register", + "--email", + "user@example.com", + "--password", + "secret123", + ]); + assert!(result.is_err(), "register command should not be accepted"); +} + +#[test] +fn insert_with_url_is_allowed() { + let cli = Cli::try_parse_from([ + "rtree", + "insert", + "--database", + "analytics", + "--table", + "events", + "--url", + "https://example.com/events.jsonl", + ]) + .expect("insert --url should parse"); + + match cli.command { + Command::Insert { url, .. } => { + assert_eq!(url.as_deref(), Some("https://example.com/events.jsonl")) + } + _ => panic!("expected insert command"), + } +} + +#[test] +fn insert_url_conflicts_with_data() { + let result = Cli::try_parse_from([ + "rtree", + "insert", + "--database", + "analytics", + "--table", + "events", + "--url", + "https://example.com/events.jsonl", + "--data", + r#"{"id":1}"#, + ]); + assert!(result.is_err(), "insert --url should conflict with --data"); +} + +#[test] +fn api_url_and_insert_url_can_both_be_provided() { + let cli = Cli::try_parse_from([ + "rtree", + "--api-url", + "https://api.rawtree.com", + "insert", + "--database", + "analytics", + "--table", + "events", + "--url", + "https://example.com/events.jsonl", + ]) + .expect("--api-url and insert --url should parse"); + + assert_eq!(cli.api_url.as_deref(), Some("https://api.rawtree.com")); + match cli.command { + Command::Insert { url, .. } => { + assert_eq!(url.as_deref(), Some("https://example.com/events.jsonl")) + } + _ => panic!("expected insert command"), + } +} + +#[test] +fn api_url_can_be_passed_before_subcommand() { + let cli = Cli::try_parse_from([ + "rtree", + "--api-url", + "https://api.rawtree.com", + "query", + "--database", + "analytics", + "--sql", + "SELECT 1", + ]) + .expect("--api-url should parse before subcommand"); + + assert_eq!(cli.api_url.as_deref(), Some("https://api.rawtree.com")); +} + +#[test] +fn cluster_can_be_passed_after_subcommand() { + let cli = Cli::try_parse_from([ + "rtree", + "query", + "--cluster", + "production", + "--database", + "analytics", + "--sql", + "SELECT 1", + ]) + .expect("global --cluster should parse after the subcommand"); + + assert_eq!(cli.cluster.as_deref(), Some("production")); +} + +#[test] +fn query_format_flag_is_rejected() { + let result = Cli::try_parse_from([ + "rtree", + "query", + "--database", + "analytics", + "--sql", + "SELECT 1", + "--format", + "csv", + ]); + assert!(result.is_err(), "query --format should not be supported"); +} + +#[test] +fn key_command_is_singular() { + let cli = Cli::try_parse_from(["rtree", "key", "list"]).unwrap(); + assert!(matches!( + cli.command, + Command::Key { + action: KeyCommand::List + } + )); +} + +#[test] +fn cluster_scoped_commands_reject_obsolete_database_flags() { + for args in [ + vec!["rtree", "key", "list", "--database", "analytics"], + vec![ + "rtree", + "key", + "delete", + "key-id", + "--database", + "analytics", + ], + vec!["rtree", "logs", "--database", "analytics"], + vec!["rtree", "logs", "--log-databases", "analytics"], + ] { + assert_eq!( + Cli::try_parse_from(args) + .err() + .expect("obsolete flag must fail") + .kind(), + ErrorKind::UnknownArgument + ); + } +} + +#[test] +fn keys_command_is_rejected() { + let result = Cli::try_parse_from(["rtree", "keys", "list", "--database", "analytics"]); + assert!(result.is_err(), "keys should not be accepted as a command"); +} + +#[test] +fn key_create_uses_name_flag() { + let cli = Cli::try_parse_from([ + "rtree", + "key", + "create", + "--database", + "analytics", + "--name", + "ci", + "--permission", + "read_write", + ]) + .expect("key create with --name should parse"); + + match cli.command { + Command::Key { action } => match action { + KeyCommand::Create { name, .. } => { + assert_eq!(name, "ci"); + } + _ => panic!("expected key create command"), + }, + _ => panic!("expected key command"), + } +} + +#[test] +fn key_create_label_flag_is_rejected() { + let result = Cli::try_parse_from([ + "rtree", + "key", + "create", + "--database", + "analytics", + "--label", + "ci", + "--permission", + "read_write", + ]); + assert!(result.is_err(), "key create should use --name, not --label"); +} + +#[test] +fn logs_request_filters_parse() { + let cli = Cli::try_parse_from([ + "rtree", + "logs", + "--search", + "request-123", + "--methods", + "GET,POST", + "--status-codes", + "200,500", + "--levels", + "success,error", + "--sources", + "cli", + "--paths", + "/v1/query,/v1/logs", + ]) + .expect("request log filters should parse"); + + match cli.command { + Command::Logs { + search, + methods, + status_codes, + levels, + sources, + paths, + .. + } => { + assert_eq!(search.as_deref(), Some("request-123")); + assert_eq!(methods, vec!["GET", "POST"]); + assert_eq!(status_codes, vec![200, 500]); + assert_eq!(levels, vec!["success", "error"]); + assert_eq!(sources, vec!["cli"]); + assert_eq!(paths, vec!["/v1/query", "/v1/logs"]); + } + _ => panic!("expected logs command"), + } +} + +#[test] +fn logs_status_codes_must_be_http_statuses() { + let result = Cli::try_parse_from(["rtree", "logs", "--status-codes", "99"]); + assert!(result.is_err(), "status codes below 100 should be rejected"); +} + +#[test] +fn workflow_create_collects_repeated_sinks() { + let cli = Cli::try_parse_from([ + "rtree", + "workflow", + "create", + "--name", + "alerts", + "--sql", + "SELECT 1", + "--interval-seconds", + "60", + "--disabled", + "--sink", + r#"{"type":"table","settings":{"database":"a","table":"b"}}"#, + "--sink", + r#"{"type":"http","settings":{"url":"https://example.com"}}"#, + ]) + .expect("workflow create should parse"); + match cli.command { + Command::Workflow { + action: + WorkflowCommand::Create { + interval_seconds, + disabled, + sinks, + database, + .. + }, + } => { + assert_eq!(interval_seconds.map(|interval| interval.0), Some(Some(60))); + assert!(disabled); + assert_eq!(sinks.len(), 2); + assert!(database.is_none()); + } + _ => panic!("expected workflow create command"), + } +} + +#[test] +fn workflow_update_rejects_conflicting_flags() { + for args in [ + vec!["--enable", "--disable"], + vec!["--clear-sinks", "--sink", "{}"], + ] { + let mut command = vec!["rtree", "workflow", "update", "wf-1"]; + command.extend(args); + assert!(Cli::try_parse_from(command).is_err()); + } +} + +#[test] +fn workflow_interval_and_time_ranges_are_validated() { + assert!(Cli::try_parse_from([ + "rtree", + "workflow", + "create", + "--name", + "a", + "--sql", + "SELECT 1", + "--interval-seconds", + "86401", + ]) + .is_err()); + assert!(Cli::try_parse_from([ + "rtree", + "workflow", + "logs", + "wf-1", + "--since", + "1h", + "--start-time", + "2026-01-01T00:00:00Z", + ]) + .is_err()); + let cli = Cli::try_parse_from(["rtree", "workflow", "metrics", "wf-1", "--since", "2h"]) + .expect("workflow metrics should parse"); + assert!(matches!( + cli.command, + Command::Workflow { + action: WorkflowCommand::Metrics { range, .. } + } if range.since.as_deref() == Some("2h") + )); +} diff --git a/src/cluster_identity.rs b/src/cluster_identity.rs new file mode 100644 index 0000000..5314746 --- /dev/null +++ b/src/cluster_identity.rs @@ -0,0 +1,11 @@ +use clap::Args; + +#[derive(Args, Clone, Debug, Default)] +pub(crate) struct ClusterIdentityArgs { + /// Immutable External ID for IAM trust policies; API generates it for managed storage when omitted + #[arg(long = "s3-storage-access-external-id", value_name = "ID")] + pub(crate) external_id: Option, + /// Immutable rawtree.com/cluster AWS resource tag value; API generates it when omitted + #[arg(long = "s3-storage-access-resource-tag", value_name = "TAG")] + pub(crate) resource_tag: Option, +} diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index 3cfaf4b..e0ba7da 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -7,8 +7,10 @@ use serde_json::{json, Value}; use super::table_output::new_cli_table; use crate::cli::ClusterSizeArg; use crate::client::ApiClient; +use crate::cluster_identity::ClusterIdentityArgs; use crate::config; use crate::output; +use crate::s3_storage::{format_storage, S3StorageArgs, S3StorageMetadata}; #[derive(Deserialize)] struct ListClustersResponse { @@ -41,9 +43,19 @@ struct ClusterItem { resources: Option, can_pause: bool, can_resume: bool, + #[serde(default)] + s3_storage: Option, + #[serde(default)] + s3_storage_access: Option, idle_timeout_minutes: u64, } +#[derive(Clone, Deserialize, Serialize)] +struct S3StorageAccess { + external_id: String, + resource_tag: String, +} + #[derive(Clone, Deserialize, Serialize)] struct ClusterStatus { phase: String, @@ -116,6 +128,7 @@ pub fn create( options: ClusterCreateOptions<'_>, json_mode: bool, ) -> Result<()> { + let s3_storage = options.s3_storage.to_json()?; let client = client.for_region(options.organization, options.region_id)?; let (_, sizes) = load_cluster_sizes(&client)?; let (min_index, min_size) = resolve_cluster_size(&sizes.sizes, options.min_size)?; @@ -134,6 +147,8 @@ pub fn create( min_size, max_size, options.idle_timeout_minutes, + s3_storage, + options.identity, ); if let Some(region_id) = options.region_id { @@ -149,6 +164,22 @@ pub fn create( created.name, format_phase(&created.status.phase), ); + println!( + "External ID: {}", + created + .s3_storage_access + .as_ref() + .map(|access| access.external_id.as_str()) + .unwrap_or("not configured") + ); + println!( + "Resource tag: {}", + created + .s3_storage_access + .as_ref() + .map(|access| access.resource_tag.as_str()) + .unwrap_or("not configured") + ); println!( "Run `rtree cluster status {}` to check provisioning progress.", created.name @@ -165,6 +196,8 @@ pub struct ClusterCreateOptions<'a> { pub min_size: ClusterSizeArg, pub max_size: Option, pub idle_timeout_minutes: Option, + pub s3_storage: S3StorageArgs, + pub identity: ClusterIdentityArgs, } pub fn update( @@ -237,6 +270,8 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> "replicas", "size / replica", "idle timeout", + "storage", + "resource tag", "created", "id", ]); @@ -252,6 +287,17 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> Cell::new(replicas).set_alignment(CellAlignment::Right), Cell::new(format_size_per_replica(cluster.resources.as_ref())), Cell::new(format_idle_timeout(cluster.idle_timeout_minutes)), + Cell::new(format_storage( + cluster.s3_storage.as_ref(), + "RawTree-managed", + )), + Cell::new( + cluster + .s3_storage_access + .as_ref() + .map(|access| access.resource_tag.as_str()) + .unwrap_or("—"), + ), Cell::new(format_created_at(&cluster.created_at)), Cell::new(&cluster.id), ]); @@ -342,6 +388,26 @@ pub fn status( "Idle timeout: {}", format_idle_timeout(cluster.idle_timeout_minutes) ); + println!( + "Storage: {}", + format_storage(cluster.s3_storage.as_ref(), "RawTree-managed") + ); + println!( + "External ID: {}", + cluster + .s3_storage_access + .as_ref() + .map(|access| access.external_id.as_str()) + .unwrap_or("not configured") + ); + println!( + "Resource tag: {}", + cluster + .s3_storage_access + .as_ref() + .map(|access| access.resource_tag.as_str()) + .unwrap_or("not configured") + ); if let Some(message) = cluster.status.message.as_deref() { println!("Message: {message}"); } @@ -495,6 +561,8 @@ fn create_request_body( min_size: ClusterSizeArg, max_size: ClusterSizeArg, idle_timeout_minutes: Option, + s3_storage: Option, + identity: ClusterIdentityArgs, ) -> Value { let mut body = json!({ "name": name, @@ -505,9 +573,18 @@ fn create_request_body( "max_size": cluster_size_json(max_size), }, }); + if let Some(external_id) = identity.external_id { + body["s3_storage_access"]["external_id"] = json!(external_id); + } + if let Some(resource_tag) = identity.resource_tag { + body["s3_storage_access"]["resource_tag"] = json!(resource_tag); + } if let Some(idle_timeout_minutes) = idle_timeout_minutes { body["idle_timeout_minutes"] = json!(idle_timeout_minutes); } + if let Some(s3_storage) = s3_storage { + body["s3_storage"] = s3_storage; + } body } @@ -726,6 +803,11 @@ mod tests { memory_gib: 256, }, Some(30), + None, + crate::cluster_identity::ClusterIdentityArgs { + external_id: Some("rawtree-test".into()), + resource_tag: Some("test-tag".into()) + }, ), json!({ "name": "production", @@ -735,7 +817,8 @@ mod tests { "min_size": {"cpu_cores": 2, "memory_gib": 8}, "max_size": {"cpu_cores": 64, "memory_gib": 256} }, - "idle_timeout_minutes": 30 + "idle_timeout_minutes": 30, + "s3_storage_access": {"external_id": "rawtree-test", "resource_tag": "test-tag"} }) ); } @@ -753,6 +836,8 @@ mod tests { resources: None, can_pause: true, can_resume: false, + s3_storage: None, + s3_storage_access: None, idle_timeout_minutes: 15, } } @@ -967,3 +1052,6 @@ mod tests { assert_eq!(format_phase("rolling_rawtree"), "rolling rawtree"); } } + +#[cfg(test)] +mod storage_tests; diff --git a/src/commands/cluster/storage_tests.rs b/src/commands/cluster/storage_tests.rs new file mode 100644 index 0000000..09d28c3 --- /dev/null +++ b/src/commands/cluster/storage_tests.rs @@ -0,0 +1,25 @@ +use super::ClusterItem; +use serde_json::json; + +#[test] +fn cluster_metadata_exposes_identity_and_nested_storage() { + let cluster: ClusterItem = serde_json::from_value(json!({ + "id": "cluster-id", "name": "production", "created_at": "2026-10-17T00:00:00Z", + "status": {"phase": "ready", "ready": true, "message": null}, + "resources": null, "can_pause": true, "can_resume": false, + "s3_storage_access": {"external_id": "rawtree-example", "resource_tag": "customer-buckets"}, + "s3_storage": {"data": { "bucket": "customer-data", "path": "events" }, "backups": { "bucket": "customer-backups", "path": "snapshots" }}, + "idle_timeout_minutes": 15 + })).expect("cluster metadata"); + assert_eq!( + cluster.s3_storage_access.as_ref().unwrap().external_id, + "rawtree-example" + ); + assert_eq!( + cluster.s3_storage_access.as_ref().unwrap().resource_tag, + "customer-buckets" + ); + let storage = cluster.s3_storage.expect("storage metadata"); + assert_eq!(storage.data.bucket, "customer-data"); + assert_eq!(storage.backups.path, "snapshots"); +} diff --git a/src/commands/database.rs b/src/commands/database.rs index 728b945..fd34802 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -6,6 +6,7 @@ use crate::client::ApiClient; use crate::config; use crate::org; use crate::output; +use crate::s3_storage::{format_storage, S3StorageArgs, S3StorageMetadata}; #[derive(Deserialize, Serialize)] struct DatabaseRef { @@ -15,7 +16,7 @@ struct DatabaseRef { #[derive(Deserialize, Serialize)] struct DatabaseItem { name: String, - s3_storage: Option, + s3_storage: Option, } #[derive(Deserialize, Serialize)] @@ -45,29 +46,6 @@ fn database_create_collection_path(organization: Option<&str>, cluster: Option<& org::databases_collection_path(organization, cluster) } -fn create_database_response( - client: &ApiClient, - name: &str, - organization: Option<&str>, - cluster: Option<&str>, -) -> Result { - let path = database_create_collection_path(organization, cluster); - client.post(&path, &json!({ "name": name })) -} - -fn create_and_persist( - client: &ApiClient, - name: &str, - organization: Option<&str>, - cluster: Option<&str>, -) -> Result { - let resp = create_database_response(client, name, organization, cluster)?; - let mut cfg = config::load()?; - apply_database_create_config(&mut cfg, &resp, organization, cluster); - config::save(&cfg)?; - Ok(resp) -} - pub fn list( client: &ApiClient, organization: Option<&str>, @@ -81,7 +59,11 @@ pub fn list( println!("No databases yet. Create one with `rtree database create `."); } else { for database in &resp.databases { - println!("{}", database.name); + println!( + "{:<20} storage={}", + database.name, + format_storage(database.s3_storage.as_ref(), "cluster default") + ); } } }); @@ -93,11 +75,25 @@ pub fn create( name: &str, organization: Option<&str>, cluster: Option<&str>, + s3_storage: S3StorageArgs, json_mode: bool, ) -> Result<()> { - let resp = create_and_persist(client, name, organization, cluster)?; + let s3_storage = s3_storage.to_json()?; + let storage_configured = s3_storage.is_some(); + let mut body = json!({ "name": name }); + if let Some(storage) = s3_storage { + body["s3_storage"] = storage; + } + let path = database_create_collection_path(organization, cluster); + let resp: CreateDatabaseResponse = client.post(&path, &body)?; + let mut cfg = config::load()?; + apply_database_create_config(&mut cfg, &resp, organization, cluster); + config::save(&cfg)?; output::print_result(&resp, json_mode, |resp| { println!("Database '{}' created.", resp.database.name); + if storage_configured { + println!("Using customer-owned S3 storage."); + } }); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 688941e..254d6c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,12 @@ mod cli; mod client; +mod cluster_identity; mod commands; mod config; mod constants; mod org; mod output; +mod s3_storage; use std::io::{self, IsTerminal, Read}; use std::process::ExitCode; @@ -321,13 +323,14 @@ fn run(cli: Cli) -> Result { json, ) } - DatabaseCommand::Create { name } => { + DatabaseCommand::Create { name, s3_storage } => { let effective_org = resolve_effective_org(&client, cli_org.clone()); commands::database::create( &client, &name, effective_org.as_deref(), effective_cluster.as_deref(), + s3_storage, json, ) } @@ -383,6 +386,8 @@ fn run(cli: Cli) -> Result { min_size, max_size, idle_timeout_minutes, + s3_storage, + identity, } => commands::cluster::create( &client, commands::cluster::ClusterCreateOptions { @@ -393,6 +398,8 @@ fn run(cli: Cli) -> Result { min_size, max_size, idle_timeout_minutes, + s3_storage, + identity, }, json, ), diff --git a/src/s3_storage.rs b/src/s3_storage.rs new file mode 100644 index 0000000..ec546f4 --- /dev/null +++ b/src/s3_storage.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use clap::Args; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Args, Clone, Debug, Default)] +pub(crate) struct S3StorageArgs { + /// Customer-owned S3 data bucket; requires --s3-storage-backups-bucket and --s3-storage-role-arn + #[arg(long, value_name = "BUCKET")] + pub(crate) s3_storage_data_bucket: Option, + /// Optional path prefix within the data bucket + #[arg(long, value_name = "PATH")] + pub(crate) s3_storage_data_path: Option, + /// Customer-owned S3 bucket for backups + #[arg(long, value_name = "BUCKET")] + pub(crate) s3_storage_backups_bucket: Option, + /// Optional path prefix within the backups bucket + #[arg(long, value_name = "PATH")] + pub(crate) s3_storage_backups_path: Option, + /// IAM role ARN RawTree should assume + #[arg(long, value_name = "ARN")] + pub(crate) s3_storage_role_arn: Option, +} + +impl S3StorageArgs { + pub(crate) fn to_json(&self) -> Result> { + let configured = [ + &self.s3_storage_data_bucket, + &self.s3_storage_data_path, + &self.s3_storage_backups_bucket, + &self.s3_storage_backups_path, + &self.s3_storage_role_arn, + ] + .iter() + .any(|value| value.is_some()); + if !configured { + return Ok(None); + } + + let data_bucket = + required_s3_value(&self.s3_storage_data_bucket, "--s3-storage-data-bucket")?; + let backups_bucket = required_s3_value( + &self.s3_storage_backups_bucket, + "--s3-storage-backups-bucket", + )?; + let role_arn = required_s3_value(&self.s3_storage_role_arn, "--s3-storage-role-arn")?; + + Ok(Some(json!({ + "data": { "bucket": data_bucket, "path": self.s3_storage_data_path.as_deref().unwrap_or("") }, + "backups": { "bucket": backups_bucket, "path": self.s3_storage_backups_path.as_deref().unwrap_or("") }, + "role_arn": role_arn, + }))) + } +} + +pub(crate) fn format_storage( + storage: Option<&S3StorageMetadata>, + default: &'static str, +) -> &'static str { + if storage.is_some() { + "customer-owned S3" + } else { + default + } +} + +fn required_s3_value<'a>(value: &'a Option, flag: &str) -> Result<&'a str> { + value.as_deref().map(str::trim).filter(|value| !value.is_empty()).ok_or_else(|| { + anyhow::anyhow!("S3 storage is incomplete. Provide --s3-storage-data-bucket, --s3-storage-backups-bucket, and --s3-storage-role-arn. Missing {flag}.") + }) +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct S3StorageMetadata { + pub(crate) data: S3StorageDestination, + pub(crate) backups: S3StorageDestination, +} + +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct S3StorageDestination { + pub(crate) bucket: String, + pub(crate) path: String, +} diff --git a/tests/api_contracts.rs b/tests/api_contracts.rs index 9ffc1f3..4a132e4 100644 --- a/tests/api_contracts.rs +++ b/tests/api_contracts.rs @@ -66,7 +66,7 @@ fn database_create_failure_does_not_overwrite_config() { fn database_list_preserves_current_metadata_without_obsolete_organization() { let response = json!({"databases": [ {"name": "default", "s3_storage": null}, - {"name": "analytics", "s3_storage": {"data": {"bucket": "data-bucket", "path": "prefix/"}, "backups": {"bucket": "backups", "path": ""}}} + {"name": "analytics", "s3_storage": {"data": { "bucket": "data-bucket", "path": "prefix/" }, "backups": { "bucket": "backups", "path": "" }}} ]}); let (result, _, _) = run( &["database", "list"], @@ -89,7 +89,7 @@ fn database_list_preserves_current_metadata_without_obsolete_organization() { assert!(output.status.success()); assert_eq!( String::from_utf8(output.stdout).unwrap(), - "default\nanalytics\n" + "default storage=cluster default\nanalytics storage=customer-owned S3\n" ); } diff --git a/tests/live_platform.rs b/tests/live_platform.rs index 763bd5b..e6124c7 100644 --- a/tests/live_platform.rs +++ b/tests/live_platform.rs @@ -1,3 +1,5 @@ +#[path = "live_platform/storage.rs"] +mod storage; #[path = "live_platform/workflows.rs"] mod workflows; diff --git a/tests/live_platform/storage.rs b/tests/live_platform/storage.rs new file mode 100644 index 0000000..0785ed6 --- /dev/null +++ b/tests/live_platform/storage.rs @@ -0,0 +1,48 @@ +use super::LiveCli; +use serde_json::Value; + +#[test] +#[ignore = "requires a bootstrapped Platform stack with the new cluster identity API"] +fn cluster_identity_is_generated_persisted_and_read_back() { + let cli = LiveCli::from_environment(); + let catalog = cli.json(&["cluster", "sizes"]); + let size = &catalog["sizes"][0]; + let size = format!("{}:{}", size["cpu_cores"], size["memory_gib"]); + let replicas = catalog["min_number_of_replicas"] + .as_u64() + .expect("replica limit") + .to_string(); + let name = format!("cli-identity-{}", uuid::Uuid::new_v4().simple()); + let created = cli.json(&[ + "cluster", + "create", + "--name", + &name, + "--replicas", + &replicas, + "--min-size", + &size, + ]); + // Capture readback before cleanup, but assert afterward so failed assertions do not leak a cluster. + let readback = cli.execute(&["cluster", "status", &name], Some(&cli.token)); + cli.json(&["cluster", "delete", &name]); + assert!(readback.status.success(), "cluster status failed"); + let readback: Value = serde_json::from_slice(&readback.stdout).expect("status JSON"); + for field in ["external_id", "resource_tag"] { + let value = created["s3_storage_access"][field] + .as_str() + .expect("saved identity"); + let id = + uuid::Uuid::parse_str(value.strip_prefix("rawtree-").expect("prefix")).expect("UUID"); + assert_eq!(id.get_version_num(), 4); + assert_eq!( + readback["s3_storage_access"][field], + created["s3_storage_access"][field] + ); + } + assert_ne!( + created["s3_storage_access"]["external_id"], + created["s3_storage_access"]["resource_tag"] + ); + assert!(created["s3_storage"].is_null()); +} diff --git a/tests/storage_contracts.rs b/tests/storage_contracts.rs new file mode 100644 index 0000000..295e374 --- /dev/null +++ b/tests/storage_contracts.rs @@ -0,0 +1,397 @@ +mod common; + +use serde_json::{json, Value}; + +const S3_ARGS: &[&str] = &[ + "--s3-storage-data-bucket", + "customer-data", + "--s3-storage-backups-bucket", + "customer-backups", + "--s3-storage-role-arn", + "arn:aws:iam::123456789012:role/RawTreeS3Access", +]; + +fn storage() -> Value { + json!({ + "data": { "bucket": "customer-data", "path": "" }, + "backups": { "bucket": "customer-backups", "path": "" }, + "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access" + }) +} + +fn cluster() -> Value { + json!({ + "id": "cluster-id", "name": "production", "created_at": "2026-09-24T00:00:00Z", + "status": {"phase": "creating", "ready": false, "message": null}, + "resources": null, "can_pause": false, "can_resume": false, + "idle_timeout_minutes": 15, + "s3_storage": { + "data": { "bucket": "customer-data", "path": "" }, + "backups": { "bucket": "customer-backups", "path": "" } + }, + "s3_storage_access": {"external_id": "rawtree-example", "resource_tag": "customer-buckets"} + }) +} + +#[test] +fn database_storage_create_uses_current_response_and_persists_only_on_success() { + let original = json!({"token": "fixture-session", "default_organization": "old-org", "database": "old-db"}); + for success in [true, false] { + let response = if success { + json!({"database": {"name": "analytics"}}) + } else { + json!({"message": "S3 access verification failed"}) + }; + let mut args = vec![ + "--org", + "team alpha", + "--cluster", + "prod/eu", + "--json", + "database", + "create", + "analytics", + ]; + args.extend_from_slice(S3_ARGS); + args.extend_from_slice(&[ + "--s3-storage-data-path", + "events/", + "--s3-storage-backups-path", + "snapshots/", + ]); + let (output, saved, bodies) = common::run_cli( + &[ + ( + "GET /v1/clusters?organization=team%20alpha".into(), + "200 OK", + json!({"clusters": []}), + ), + ( + "POST /v1/databases?organization=team%20alpha&cluster=prod%2Feu".into(), + if success { "200 OK" } else { "400 Bad Request" }, + response.clone(), + ), + ], + &args, + &original, + ); + assert_eq!( + output.status.success(), + success, + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let mut expected = storage(); + expected["data"]["path"] = json!("events/"); + expected["backups"]["path"] = json!("snapshots/"); + assert_eq!( + bodies, + vec![ + Value::Null, + json!({"name": "analytics", "s3_storage": expected}) + ] + ); + if success { + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + response + ); + assert_eq!(saved["database"], "analytics"); + assert_eq!(saved["default_organization"], "team alpha"); + assert_eq!(saved["cluster"], "prod/eu"); + assert_eq!(saved["token"], original["token"]); + } else { + assert_eq!(saved, original); + } + } +} + +#[test] +fn cluster_creation_forwards_optional_identity_and_nested_storage() { + for (with_storage, with_identity) in + [(false, false), (true, false), (false, true), (true, true)] + { + let mut args = vec![ + "--org", + "team", + "--json", + "cluster", + "create", + "--name", + "production", + "--replicas", + "1", + "--min-size", + "2:8", + ]; + if with_storage { + args.extend_from_slice(S3_ARGS); + } + if with_identity { + args.extend_from_slice(&[ + "--s3-storage-access-external-id", + "rawtree-example", + "--s3-storage-access-resource-tag", + "customer-buckets", + ]); + } + if with_storage && !with_identity { + args.extend_from_slice(&["--s3-storage-access-external-id", "rawtree-example"]); + } + let mut response = cluster(); + if !with_storage { + response["s3_storage"] = Value::Null; + } + let (output, _, bodies) = common::run_cli( + &[ + ( + "GET /v1/clusters/sizes".into(), + "200 OK", + json!({"sizes": [{"cpu_cores": 2, "memory_gib": 8}]}), + ), + ( + "POST /v1/clusters?organization=team".into(), + "200 OK", + response.clone(), + ), + ], + &args, + &json!({}), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + response + ); + let mut expected = json!({"name": "production", "replicas": 1, "size": {"cpu_cores": 2, "memory_gib": 8}, "autoscaling": {"min_size": {"cpu_cores": 2, "memory_gib": 8}, "max_size": {"cpu_cores": 2, "memory_gib": 8}}}); + if with_storage { + expected["s3_storage"] = storage(); + } + if with_identity || with_storage { + expected["s3_storage_access"]["external_id"] = json!("rawtree-example"); + } + if with_identity { + expected["s3_storage_access"]["resource_tag"] = json!("customer-buckets"); + } + assert_eq!(bodies[1], expected); + } +} + +#[test] +fn invalid_storage_flags_fail_before_any_api_request() { + for (command, flags, error) in [( + vec!["database", "create", "analytics"], + vec!["--s3-storage-data-bucket", "customer-data"], + "--s3-storage-backups-bucket", + )] { + let mut args = vec!["--org", "team", "--cluster", "prod"]; + args.extend(command); + args.extend(flags); + let original = json!({"database": "existing"}); + let (output, saved, _) = common::run_cli(&[], &args, &original); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains(error), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(saved, original); + } +} + +#[test] +fn cluster_list_and_status_show_storage_without_an_organization_envelope() { + for action in [ + vec!["cluster", "list"], + vec!["cluster", "status", "production"], + ] { + for json_mode in [true, false] { + let mut args = vec!["--org", "team"]; + if json_mode { + args.push("--json"); + } + args.extend_from_slice(&action); + let (output, _, _) = common::run_cli( + &[( + "GET /v1/clusters?organization=team".into(), + "200 OK", + json!({"clusters": [cluster()]}), + )], + &args, + &json!({}), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + if json_mode { + let response: Value = serde_json::from_str(&stdout).unwrap(); + let item = if action[1] == "list" { + &response["clusters"][0] + } else { + &response + }; + assert_eq!(item["s3_storage"], cluster()["s3_storage"]); + assert_eq!(item["s3_storage_access"]["external_id"], "rawtree-example"); + assert_eq!( + item["s3_storage_access"]["resource_tag"], + "customer-buckets" + ); + assert!(item["s3_storage"].get("role_arn").is_none()); + } else { + assert!(stdout.contains("customer-owned S3"), "{stdout}"); + assert!(stdout.contains("customer-buckets"), "{stdout}"); + } + } + } +} + +#[test] +fn obsolete_and_database_identity_flags_are_rejected() { + let obsolete = [ + "--external-id", + "--resource-tag", + "--s3-external-id", + "--database-s3-external-id", + "--database-bucket-tag", + "--s3-data-bucket", + "--s3-data-path", + "--s3-backup-bucket", + "--s3-backup-path", + "--s3-backups-bucket", + "--s3-backups-path", + "--s3-role-arn", + "--s3-storage-external-id", + "--s3-storage-resource-tag", + ]; + for command in [ + vec!["database", "create", "analytics"], + vec![ + "cluster", + "create", + "--name", + "production", + "--replicas", + "1", + "--min-size", + "2:8", + ], + ] { + let mut rejected = obsolete.to_vec(); + if command[0] == "database" { + rejected.extend([ + "--s3-storage-access-external-id", + "--s3-storage-access-resource-tag", + ]); + } + for flag in rejected { + let mut args = command.clone(); + args.extend([flag, "obsolete"]); + let (output, _, bodies) = common::run_cli(&[], &args, &json!({})); + assert!(!output.status.success(), "{flag}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unexpected argument"), + "{flag}" + ); + assert!(bodies.is_empty()); + } + } +} + +#[test] +fn managed_cluster_storage_and_inherited_database_storage_have_distinct_labels() { + let mut managed = cluster(); + managed["s3_storage"] = Value::Null; + managed["s3_storage_access"] = Value::Null; + for action in [ + vec!["cluster", "list"], + vec!["cluster", "status", "production"], + ] { + let mut args = vec!["--org", "team"]; + args.extend_from_slice(&action); + let (output, _, _) = common::run_cli( + &[( + "GET /v1/clusters?organization=team".into(), + "200 OK", + json!({"clusters": [managed]}), + )], + &args, + &json!({}), + ); + assert!(output.status.success()); + let text = String::from_utf8(output.stdout).unwrap(); + assert!(text.contains("RawTree-managed"), "{text}"); + assert!(!text.contains("cluster default"), "{text}"); + if action[1] == "status" { + assert!(text.contains("External ID: not configured")); + assert!(text.contains("Resource tag: not configured")); + } + } + let (output, _, _) = common::run_cli( + &[( + "GET /v1/databases?organization=team&cluster=production".into(), + "200 OK", + json!({"databases": [{"name": "analytics", "s3_storage": null}]}), + )], + &[ + "--org", + "team", + "--cluster", + "production", + "database", + "list", + ], + &json!({}), + ); + assert!(output.status.success()); + assert!(String::from_utf8(output.stdout) + .unwrap() + .contains("storage=cluster default")); +} + +#[test] +fn cluster_identity_validation_errors_come_from_the_api() { + let (output, _, bodies) = common::run_cli( + &[ + ( + "GET /v1/clusters/sizes".into(), + "200 OK", + json!({"sizes": [{"cpu_cores": 2, "memory_gib": 8}]}), + ), + ( + "POST /v1/clusters?organization=team".into(), + "400 Bad Request", + json!({"message": "Custom S3 storage requires s3_storage_access.external_id."}), + ), + ], + &[ + &[ + "--org", + "team", + "cluster", + "create", + "--name", + "production", + "--replicas", + "1", + "--min-size", + "2:8", + ][..], + S3_ARGS, + ] + .concat(), + &json!({}), + ); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires s3_storage_access.external_id") + ); + assert!(bodies[1].get("external_id").is_none()); + assert!(bodies[1].get("resource_tag").is_none()); +}