From 01d81320d270147694764d7058f6d8f71fa9e4f7 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Wed, 2 Sep 2026 13:37:01 +0200 Subject: [PATCH 1/6] Add S3 storage options to cluster and database CLI --- README.md | 29 ++++++++ src/cli.rs | 150 ++++++++++++++++++++++++++++++++++++++- src/commands/cluster.rs | 95 +++++++++++++++++++++++-- src/commands/database.rs | 129 ++++++++++++++++++++++++++++----- src/main.rs | 5 +- 5 files changed, 383 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index d102d98..228361f 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,17 @@ 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-data-bucket acme-rawtree-data \ + --s3-data-path rawtree/data \ + --s3-backups-bucket acme-rawtree-backups \ + --s3-backups-path rawtree/backups \ + --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ + --s3-external-id rawtree-example rtree cluster use production rtree cluster status production rtree cluster update production --idle-timeout-minutes 60 @@ -201,6 +212,24 @@ 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 and database creation accept the same customer-owned S3 options. Pass +all four required values (`--s3-data-bucket`, `--s3-backups-bucket`, +`--s3-role-arn`, and `--s3-external-id`) to send the platform's `s3_storage` +configuration; the data and backup paths are optional. For a database: + +```sh +rtree database create analytics \ + --s3-data-bucket acme-rawtree-data \ + --s3-backups-bucket acme-rawtree-backups \ + --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ + --s3-external-id rawtree-example +``` + +If no S3 options are passed, the database inherits the cluster's storage. Use +`rtree cluster list --json` or `rtree database list --json` to inspect the +safe bucket/path metadata returned as `s3_storage`; credentials are never +returned. + 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 890e804..b3f63b7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,9 @@ use std::{fmt, str::FromStr}; -use clap::{Parser, Subcommand, ValueEnum}; +use anyhow::Result; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ClusterSizeArg { @@ -51,6 +54,84 @@ impl fmt::Display for ClusterSizeArg { } } +#[derive(Args, Clone, Debug, Default)] +pub(crate) struct S3StorageArgs { + /// Customer-owned S3 bucket for database data + #[arg(long, value_name = "BUCKET")] + pub(crate) s3_data_bucket: Option, + /// Optional path prefix within the data bucket + #[arg(long, value_name = "PATH")] + pub(crate) s3_data_path: Option, + /// Customer-owned S3 bucket for backups + #[arg(long, value_name = "BUCKET")] + pub(crate) s3_backups_bucket: Option, + /// Optional path prefix within the backups bucket + #[arg(long, value_name = "PATH")] + pub(crate) s3_backups_path: Option, + /// IAM role ARN RawTree should assume + #[arg(long, value_name = "ARN")] + pub(crate) s3_role_arn: Option, + /// External ID configured in the customer IAM role trust policy + #[arg(long, value_name = "ID")] + pub(crate) s3_external_id: Option, +} + +#[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, +} + +impl S3StorageArgs { + pub(crate) fn to_json(&self) -> Result> { + let configured = [ + &self.s3_data_bucket, + &self.s3_data_path, + &self.s3_backups_bucket, + &self.s3_backups_path, + &self.s3_role_arn, + &self.s3_external_id, + ] + .iter() + .any(|value| value.is_some()); + if !configured { + return Ok(None); + } + + let data_bucket = required_s3_value(&self.s3_data_bucket, "--s3-data-bucket")?; + let backups_bucket = required_s3_value(&self.s3_backups_bucket, "--s3-backups-bucket")?; + let role_arn = required_s3_value(&self.s3_role_arn, "--s3-role-arn")?; + let external_id = required_s3_value(&self.s3_external_id, "--s3-external-id")?; + + Ok(Some(json!({ + "data": { + "bucket": data_bucket, + "path": self.s3_data_path.as_deref().unwrap_or(""), + }, + "backups": { + "bucket": backups_bucket, + "path": self.s3_backups_path.as_deref().unwrap_or(""), + }, + "role_arn": role_arn, + "external_id": external_id, + }))) + } +} + +fn required_s3_value<'a>(value: &'a Option, flag: &str) -> Result<&'a str> { + value.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "S3 storage is incomplete. Provide --s3-data-bucket, --s3-backups-bucket, --s3-role-arn, and --s3-external-id. Missing {flag}." + ) + }) +} + #[derive(Parser)] #[command( name = "rtree", @@ -263,6 +344,8 @@ pub enum DatabaseCommand { Create { /// Database name name: String, + #[command(flatten)] + s3_storage: S3StorageArgs, }, /// Set the default database Use { @@ -330,6 +413,8 @@ pub enum ClusterCommand { /// Minutes without activity before automatically pausing; 0 disables idling #[arg(long)] idle_timeout_minutes: Option, + #[command(flatten)] + s3_storage: S3StorageArgs, }, /// Set the default cluster Use { @@ -414,7 +499,7 @@ pub enum TableCommand { #[cfg(test)] mod tests { - use super::{Cli, ClusterCommand, ClusterSizeArg, Command, KeyCommand}; + use super::{Cli, ClusterCommand, ClusterSizeArg, Command, KeyCommand, S3StorageArgs}; use clap::{error::ErrorKind, CommandFactory, Parser}; #[test] @@ -517,6 +602,7 @@ mod tests { idle_timeout_minutes: Some(30), min_size, max_size: Some(max_size), + .. } } if name == "production" && replicas == 2 @@ -525,6 +611,66 @@ mod tests { )); } + #[test] + fn cluster_create_parses_s3_storage_options() { + let cli = Cli::try_parse_from([ + "rtree", + "cluster", + "create", + "--name", + "production", + "--replicas", + "1", + "--min-size", + "2:8", + "--s3-data-bucket", + "customer-data", + "--s3-data-path", + "rawtree/data", + "--s3-backups-bucket", + "customer-backups", + "--s3-backups-path", + "rawtree/backups", + "--s3-role-arn", + "arn:aws:iam::123456789012:role/RawTreeS3Access", + "--s3-external-id", + "rawtree-example", + ]) + .expect("cluster create S3 options should parse"); + + let Command::Cluster { + action: ClusterCommand::Create { s3_storage, .. }, + } = cli.command + else { + panic!("expected cluster create command"); + }; + + assert_eq!( + s3_storage + .to_json() + .expect("complete S3 options should be valid"), + Some(serde_json::json!({ + "data": {"bucket": "customer-data", "path": "rawtree/data"}, + "backups": {"bucket": "customer-backups", "path": "rawtree/backups"}, + "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", + "external_id": "rawtree-example" + })) + ); + } + + #[test] + fn partial_s3_storage_options_are_rejected_before_request() { + let args = S3StorageArgs { + s3_data_bucket: Some("customer-data".to_string()), + ..S3StorageArgs::default() + }; + + let error = args + .to_json() + .expect_err("partial S3 options should be rejected"); + assert!(error.to_string().contains("--s3-backups-bucket")); + } + #[test] fn cluster_create_rejects_invalid_size_format() { let error = match Cli::try_parse_from([ diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index b608dc2..eb7d539 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use super::table_output::new_cli_table; -use crate::cli::ClusterSizeArg; +use crate::cli::{ClusterSizeArg, S3StorageArgs, S3StorageMetadata}; use crate::client::ApiClient; use crate::config; use crate::output; @@ -41,6 +41,8 @@ struct ClusterItem { resources: Option, can_pause: bool, can_resume: bool, + #[serde(default, alias = "custom_s3")] + s3_storage: Option, idle_timeout_minutes: u64, } @@ -116,6 +118,7 @@ pub fn create( options: ClusterCreateOptions<'_>, json_mode: bool, ) -> Result<()> { + let s3_storage = options.s3_storage.to_json()?; let (_, sizes) = load_cluster_sizes(client)?; let (min_index, min_size) = resolve_cluster_size(&sizes.sizes, options.min_size)?; let (max_index, max_size) = options @@ -133,6 +136,7 @@ pub fn create( min_size, max_size, options.idle_timeout_minutes, + s3_storage, ); let value: Value = client.post(&clusters_collection_path(options.organization), &body)?; @@ -160,6 +164,7 @@ pub struct ClusterCreateOptions<'a> { pub min_size: ClusterSizeArg, pub max_size: Option, pub idle_timeout_minutes: Option, + pub s3_storage: S3StorageArgs, } pub fn update( @@ -235,6 +240,7 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> "replicas", "size / replica", "idle timeout", + "storage", "created", "id", ]); @@ -250,6 +256,7 @@ 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())), Cell::new(format_created_at(&cluster.created_at)), Cell::new(&cluster.id), ]); @@ -321,6 +328,7 @@ pub fn status( "Idle timeout: {}", format_idle_timeout(cluster.idle_timeout_minutes) ); + println!("Storage: {}", format_storage(cluster.s3_storage.as_ref())); if let Some(message) = cluster.status.message.as_deref() { println!("Message: {message}"); } @@ -462,6 +470,7 @@ fn create_request_body( min_size: ClusterSizeArg, max_size: ClusterSizeArg, idle_timeout_minutes: Option, + s3_storage: Option, ) -> Value { let mut body = json!({ "name": name, @@ -475,6 +484,9 @@ fn create_request_body( 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 } @@ -552,6 +564,14 @@ fn format_idle_timeout(minutes: u64) -> String { } } +fn format_storage(storage: Option<&S3StorageMetadata>) -> &'static str { + if storage.is_some() { + "customer-owned S3" + } else { + "cluster default" + } +} + fn format_created_at(created_at: &str) -> String { DateTime::parse_from_str(created_at, "%Y-%m-%d %H:%M:%S%.f%#z") .map(|timestamp| { @@ -596,10 +616,11 @@ mod tests { use super::{ cluster_path, cluster_size_json, clusters_collection_path, create_request_body, default_cluster_after_delete, delete_output, format_created_at, format_idle_timeout, - format_phase, format_size_per_replica, renamed_default_cluster, resolve_cluster, - resolve_cluster_size, ClusterItem, ClusterResources, ClusterSizeItem, ClusterStatus, + format_phase, format_size_per_replica, format_storage, renamed_default_cluster, + resolve_cluster, resolve_cluster_size, ClusterItem, ClusterResources, ClusterSizeItem, + ClusterStatus, }; - use crate::cli::ClusterSizeArg; + use crate::cli::{ClusterSizeArg, S3StorageArgs}; #[test] fn collection_path_encodes_organization() { @@ -692,6 +713,7 @@ mod tests { memory_gib: 256, }, Some(30), + None, ), json!({ "name": "production", @@ -719,6 +741,7 @@ mod tests { resources: None, can_pause: true, can_resume: false, + s3_storage: None, idle_timeout_minutes: 15, } } @@ -830,6 +853,70 @@ mod tests { assert_eq!(cluster.idle_timeout_minutes, 15); } + #[test] + fn cluster_storage_response_deserializes_with_the_new_field_name() { + let cluster: ClusterItem = serde_json::from_value(json!({ + "id": "cluster-id", + "name": "production", + "created_at": "2026-07-14 20:38:33.004347+00", + "status": {"phase": "ready", "ready": true, "message": null}, + "resources": null, + "can_pause": true, + "can_resume": false, + "s3_storage": { + "data": {"bucket": "customer-data", "path": "rawtree/data"}, + "backups": {"bucket": "customer-backups", "path": "rawtree/backups"} + }, + "idle_timeout_minutes": 15 + })) + .expect("cluster storage response should deserialize"); + + assert_eq!( + format_storage(cluster.s3_storage.as_ref()), + "customer-owned S3" + ); + assert_eq!( + cluster.s3_storage.expect("storage metadata").data.bucket, + "customer-data" + ); + } + + #[test] + fn cluster_create_body_uses_s3_storage() { + let storage = S3StorageArgs { + s3_data_bucket: Some("customer-data".to_string()), + s3_data_path: Some("rawtree/data".to_string()), + s3_backups_bucket: Some("customer-backups".to_string()), + s3_backups_path: Some("rawtree/backups".to_string()), + s3_role_arn: Some("arn:aws:iam::123456789012:role/RawTreeS3Access".to_string()), + s3_external_id: Some("rawtree-example".to_string()), + }; + let body = create_request_body( + "production", + 1, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + None, + storage.to_json().expect("valid storage"), + ); + + assert_eq!( + body["s3_storage"], + json!({ + "data": {"bucket": "customer-data", "path": "rawtree/data"}, + "backups": {"bucket": "customer-backups", "path": "rawtree/backups"}, + "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", + "external_id": "rawtree-example" + }) + ); + } + #[test] fn timestamp_is_rendered_to_seconds_in_utc() { assert_eq!( diff --git a/src/commands/database.rs b/src/commands/database.rs index 64f206f..1d07e0e 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -2,6 +2,7 @@ use anyhow::Result; use serde::Deserialize; use serde_json::json; +use crate::cli::{S3StorageArgs, S3StorageMetadata}; use crate::client::ApiClient; use crate::config; use crate::org; @@ -12,6 +13,8 @@ struct DatabaseItem { name: String, #[serde(default)] organization: Option, + #[serde(default, alias = "custom_s3")] + s3_storage: Option, } #[derive(Deserialize)] @@ -28,16 +31,13 @@ struct OrganizationRef { #[derive(Deserialize)] struct CreateDatabaseResponse { - name: String, - #[serde(default)] - organization: Option, + organization: OrganizationRef, + database: DatabaseItem, } impl CreateDatabaseResponse { fn resolved_organization_name(&self) -> Option<&str> { - self.organization - .as_ref() - .map(|organization| organization.name.as_str()) + Some(self.organization.name.as_str()) } } @@ -46,7 +46,7 @@ fn apply_database_create_config( resp: &CreateDatabaseResponse, cluster: Option<&str>, ) { - cfg.default_database = Some(resp.name.clone()); + cfg.default_database = Some(resp.database.name.clone()); cfg.default_organization = resp.resolved_organization_name().map(ToString::to_string); if let Some(cluster) = cluster { cfg.default_cluster = Some(cluster.to_string()); @@ -62,9 +62,22 @@ fn create_database_response( name: &str, organization: Option<&str>, cluster: Option<&str>, + s3_storage: &S3StorageArgs, ) -> Result { let path = database_create_collection_path(organization, cluster); - client.post(&path, &json!({ "name": name })) + let body = create_database_request_body(name, s3_storage)?; + client.post(&path, &body) +} + +fn create_database_request_body( + name: &str, + s3_storage: &S3StorageArgs, +) -> Result { + let mut body = json!({ "name": name }); + if let Some(s3_storage) = s3_storage.to_json()? { + body["s3_storage"] = s3_storage; + } + Ok(body) } fn create_and_persist( @@ -72,8 +85,9 @@ fn create_and_persist( name: &str, organization: Option<&str>, cluster: Option<&str>, + s3_storage: &S3StorageArgs, ) -> Result { - let resp = create_database_response(client, name, organization, cluster)?; + let resp = create_database_response(client, name, organization, cluster, s3_storage)?; let mut cfg = config::load()?; apply_database_create_config(&mut cfg, &resp, cluster); config::save(&cfg)?; @@ -97,6 +111,7 @@ pub fn list( .as_ref() .or(resp.organization.as_ref()) .map(|org| json!({"name": org.name})), + "s3_storage": p.s3_storage, })).collect::>() }), json_mode, @@ -111,7 +126,12 @@ pub fn list( .or(resp.organization.as_ref()) .map(|org| org.name.as_str()) .unwrap_or("unknown"); - println!("{:<20} org={}", p.name, organization); + println!( + "{:<20} org={} storage={}", + p.name, + organization, + format_storage(p.s3_storage.as_ref()) + ); } } }, @@ -119,29 +139,43 @@ pub fn list( Ok(()) } +fn format_storage(storage: Option<&S3StorageMetadata>) -> &'static str { + if storage.is_some() { + "customer-owned S3" + } else { + "cluster default" + } +} + pub fn create( client: &ApiClient, 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 storage_configured = s3_storage.to_json()?.is_some(); + let resp = create_and_persist(client, name, organization, cluster, &s3_storage)?; output::print_result( &json!({ - "name": resp.name, + "name": resp.database.name, "organization": resp .resolved_organization_name() .map(|name| json!({"name": name})), + "storage_configured": storage_configured, }), json_mode, |_| { let organization_name = resp.resolved_organization_name().unwrap_or("unknown"); println!( "Database '{}' created in organization '{}'.", - resp.name, organization_name + resp.database.name, organization_name ); + if storage_configured { + println!("Using customer-owned S3 storage."); + } }, ); Ok(()) @@ -191,9 +225,10 @@ pub fn delete( #[cfg(test)] mod tests { use super::{ - apply_database_create_config, database_create_collection_path, CreateDatabaseResponse, - DatabaseItem, OrganizationRef, + apply_database_create_config, create_database_request_body, + database_create_collection_path, CreateDatabaseResponse, DatabaseItem, OrganizationRef, }; + use crate::cli::S3StorageArgs; use crate::config::Config; use serde_json::json; @@ -206,10 +241,14 @@ mod tests { ..Config::default() }; let resp = CreateDatabaseResponse { - name: "analytics".to_string(), - organization: Some(OrganizationRef { + organization: OrganizationRef { name: "new_team".to_string(), - }), + }, + database: DatabaseItem { + name: "analytics".to_string(), + organization: None, + s3_storage: None, + }, }; apply_database_create_config(&mut cfg, &resp, Some("production")); @@ -233,6 +272,60 @@ mod tests { assert_eq!(item.organization.expect("organization").name, "team_alpha"); } + #[test] + fn database_item_deserializes_s3_storage_metadata() { + let item: DatabaseItem = serde_json::from_value(json!({ + "name": "analytics", + "s3_storage": { + "data": {"bucket": "customer-data", "path": "rawtree/data"}, + "backups": {"bucket": "customer-backups", "path": "rawtree/backups"} + } + })) + .expect("database storage metadata should deserialize"); + + assert_eq!( + item.s3_storage.expect("storage metadata").data.bucket, + "customer-data" + ); + } + + #[test] + fn database_create_response_deserializes_nested_platform_shape() { + let response: CreateDatabaseResponse = serde_json::from_value(json!({ + "organization": {"name": "team_alpha"}, + "database": {"name": "analytics"} + })) + .expect("nested database create response should deserialize"); + + assert_eq!(response.database.name, "analytics"); + assert_eq!(response.resolved_organization_name(), Some("team_alpha")); + } + + #[test] + fn database_create_body_uses_s3_storage() { + let storage = S3StorageArgs { + s3_data_bucket: Some("customer-data".to_string()), + s3_data_path: None, + s3_backups_bucket: Some("customer-backups".to_string()), + s3_backups_path: None, + s3_role_arn: Some("arn:aws:iam::123456789012:role/RawTreeS3Access".to_string()), + s3_external_id: Some("rawtree-example".to_string()), + }; + + assert_eq!( + create_database_request_body("analytics", &storage).expect("valid storage"), + json!({ + "name": "analytics", + "s3_storage": { + "data": {"bucket": "customer-data", "path": ""}, + "backups": {"bucket": "customer-backups", "path": ""}, + "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", + "external_id": "rawtree-example" + } + }) + ); + } + #[test] fn database_create_collection_path_uses_databases_endpoint() { assert_eq!(database_create_collection_path(None, None), "/v1/databases"); diff --git a/src/main.rs b/src/main.rs index f143e45..d65857d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,13 +286,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, ) } @@ -340,6 +341,7 @@ fn run(cli: Cli) -> Result<()> { min_size, max_size, idle_timeout_minutes, + s3_storage, } => commands::cluster::create( &client, commands::cluster::ClusterCreateOptions { @@ -349,6 +351,7 @@ fn run(cli: Cli) -> Result<()> { min_size, max_size, idle_timeout_minutes, + s3_storage, }, json, ), From 601a7bf9ff961e3870ae07fc94112aa5b47140a7 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Mon, 7 Sep 2026 15:30:14 +0200 Subject: [PATCH 2/6] Add independent database S3 access to cluster CLI --- README.md | 17 ++++ src/cli.rs | 165 +++++++++++++++++++++++++++++++++++- src/commands/cluster.rs | 181 ++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 + 4 files changed, 359 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 228361f..f179359 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,14 @@ rtree cluster create \ --s3-backups-path rawtree/backups \ --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ --s3-external-id rawtree-example +# Or keep the cluster on RawTree-managed storage while enabling +# independent customer-owned S3 storage for databases created later +rtree cluster create \ + --name database-storage \ + --replicas 1 \ + --min-size 2:8 \ + --database-s3-external-id rawtree-database-access \ + --database-bucket-tag rawtree-customer-database rtree cluster use production rtree cluster status production rtree cluster update production --idle-timeout-minutes 60 @@ -230,6 +238,15 @@ If no S3 options are passed, the database inherits the cluster's storage. Use safe bucket/path metadata returned as `s3_storage`; credentials are never returned. +Clusters can also enable independent per-database S3 access with +`--database-s3-external-id` and `--database-bucket-tag`. This capability is +independent from cluster-level `s3_storage`, so it can be configured even when +the cluster uses RawTree-managed defaults. Tag every customer-owned database +bucket and IAM role with `rawtree.com/cluster=`. If both +storage configurations are supplied, the two External IDs must match. Cluster +list/status JSON exposes the safe `database_s3_access` metadata; human-readable +status includes the External ID and bucket tag. + 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 b3f63b7..2e0beb8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -76,6 +76,16 @@ pub(crate) struct S3StorageArgs { pub(crate) s3_external_id: Option, } +#[derive(Args, Clone, Debug, Default)] +pub(crate) struct DatabaseS3AccessArgs { + /// External ID configured in the customer IAM role trust policy for database buckets + #[arg(long, value_name = "ID")] + pub(crate) database_s3_external_id: Option, + /// Immutable tag required on customer-owned database buckets and IAM roles + #[arg(long, value_name = "TAG")] + pub(crate) database_bucket_tag: Option, +} + #[derive(Clone, Deserialize, Serialize)] pub(crate) struct S3StorageMetadata { pub(crate) data: S3StorageDestination, @@ -88,6 +98,12 @@ pub(crate) struct S3StorageDestination { pub(crate) path: String, } +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct DatabaseS3AccessMetadata { + pub(crate) external_id: String, + pub(crate) database_bucket_tag: String, +} + impl S3StorageArgs { pub(crate) fn to_json(&self) -> Result> { let configured = [ @@ -124,6 +140,74 @@ impl S3StorageArgs { } } +impl DatabaseS3AccessArgs { + pub(crate) fn to_json(&self) -> Result> { + let configured = + self.database_s3_external_id.is_some() || self.database_bucket_tag.is_some(); + if !configured { + return Ok(None); + } + + let external_id = self + .database_s3_external_id + .as_deref() + .ok_or_else(|| { + anyhow::anyhow!( + "Database S3 access is incomplete. Provide --database-s3-external-id and --database-bucket-tag. Missing --database-s3-external-id." + ) + })?; + let database_bucket_tag = self.database_bucket_tag.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Database S3 access is incomplete. Provide --database-s3-external-id and --database-bucket-tag. Missing --database-bucket-tag." + ) + })?; + validate_database_s3_external_id(external_id)?; + validate_database_bucket_tag(database_bucket_tag)?; + + Ok(Some(json!({ + "external_id": external_id, + "database_bucket_tag": database_bucket_tag, + }))) + } +} + +fn validate_database_s3_external_id(value: &str) -> Result<()> { + let value = value.trim(); + if !(2..=1224).contains(&value.len()) + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'_' | b'+' | b'=' | b',' | b'.' | b'@' | b':' | b'/' | b'-' + ) + }) + { + anyhow::bail!( + "Invalid database S3 access External ID. Use 2-1224 letters, numbers, or the characters _ + = , . @ : / -." + ); + } + Ok(()) +} + +fn validate_database_bucket_tag(value: &str) -> Result<()> { + let value = value.trim(); + if value.is_empty() + || value.len() > 256 + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + || !value + .as_bytes() + .first() + .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) + { + anyhow::bail!( + "Invalid database S3 bucket tag. Use 1-256 lowercase letters, numbers, or hyphens, starting with a letter or number." + ); + } + Ok(()) +} + fn required_s3_value<'a>(value: &'a Option, flag: &str) -> Result<&'a str> { value.as_deref().ok_or_else(|| { anyhow::anyhow!( @@ -415,6 +499,8 @@ pub enum ClusterCommand { idle_timeout_minutes: Option, #[command(flatten)] s3_storage: S3StorageArgs, + #[command(flatten)] + database_s3_access: DatabaseS3AccessArgs, }, /// Set the default cluster Use { @@ -499,7 +585,10 @@ pub enum TableCommand { #[cfg(test)] mod tests { - use super::{Cli, ClusterCommand, ClusterSizeArg, Command, KeyCommand, S3StorageArgs}; + use super::{ + Cli, ClusterCommand, ClusterSizeArg, Command, DatabaseS3AccessArgs, KeyCommand, + S3StorageArgs, + }; use clap::{error::ErrorKind, CommandFactory, Parser}; #[test] @@ -671,6 +760,80 @@ mod tests { assert!(error.to_string().contains("--s3-backups-bucket")); } + #[test] + fn cluster_create_parses_independent_database_s3_access_options() { + let cli = Cli::try_parse_from([ + "rtree", + "cluster", + "create", + "--name", + "production", + "--replicas", + "1", + "--min-size", + "2:8", + "--database-s3-external-id", + "rawtree-database-access", + "--database-bucket-tag", + "rawtree-customer-database", + ]) + .expect("cluster create database S3 options should parse"); + + let Command::Cluster { + action: + ClusterCommand::Create { + database_s3_access, + s3_storage, + .. + }, + } = cli.command + else { + panic!("expected cluster create command"); + }; + + assert_eq!( + s3_storage.to_json().expect("storage should be omitted"), + None + ); + assert_eq!( + database_s3_access + .to_json() + .expect("complete database S3 access should be valid"), + Some(serde_json::json!({ + "external_id": "rawtree-database-access", + "database_bucket_tag": "rawtree-customer-database" + })) + ); + } + + #[test] + fn partial_database_s3_access_options_are_rejected_before_request() { + let args = DatabaseS3AccessArgs { + database_s3_external_id: Some("rawtree-database-access".to_string()), + ..DatabaseS3AccessArgs::default() + }; + + let error = args + .to_json() + .expect_err("partial database S3 access should be rejected"); + assert!(error.to_string().contains("--database-bucket-tag")); + } + + #[test] + fn invalid_database_s3_access_values_are_rejected_before_request() { + let invalid_external_id = DatabaseS3AccessArgs { + database_s3_external_id: Some("?".to_string()), + database_bucket_tag: Some("rawtree-customer-database".to_string()), + }; + assert!(invalid_external_id.to_json().is_err()); + + let invalid_bucket_tag = DatabaseS3AccessArgs { + database_s3_external_id: Some("rawtree-database-access".to_string()), + database_bucket_tag: Some("RawTree_Invalid".to_string()), + }; + assert!(invalid_bucket_tag.to_json().is_err()); + } + #[test] fn cluster_create_rejects_invalid_size_format() { let error = match Cli::try_parse_from([ diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index eb7d539..c54978f 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -5,7 +5,10 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use super::table_output::new_cli_table; -use crate::cli::{ClusterSizeArg, S3StorageArgs, S3StorageMetadata}; +use crate::cli::{ + ClusterSizeArg, DatabaseS3AccessArgs, DatabaseS3AccessMetadata, S3StorageArgs, + S3StorageMetadata, +}; use crate::client::ApiClient; use crate::config; use crate::output; @@ -43,6 +46,8 @@ struct ClusterItem { can_resume: bool, #[serde(default, alias = "custom_s3")] s3_storage: Option, + #[serde(default)] + database_s3_access: Option, idle_timeout_minutes: u64, } @@ -119,6 +124,8 @@ pub fn create( json_mode: bool, ) -> Result<()> { let s3_storage = options.s3_storage.to_json()?; + let database_s3_access = options.database_s3_access.to_json()?; + validate_matching_s3_external_ids(&options.s3_storage, &options.database_s3_access)?; let (_, sizes) = load_cluster_sizes(client)?; let (min_index, min_size) = resolve_cluster_size(&sizes.sizes, options.min_size)?; let (max_index, max_size) = options @@ -137,6 +144,7 @@ pub fn create( max_size, options.idle_timeout_minutes, s3_storage, + database_s3_access, ); let value: Value = client.post(&clusters_collection_path(options.organization), &body)?; @@ -165,6 +173,7 @@ pub struct ClusterCreateOptions<'a> { pub max_size: Option, pub idle_timeout_minutes: Option, pub s3_storage: S3StorageArgs, + pub database_s3_access: DatabaseS3AccessArgs, } pub fn update( @@ -241,6 +250,7 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> "size / replica", "idle timeout", "storage", + "database S3", "created", "id", ]); @@ -257,6 +267,9 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> 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())), + Cell::new(format_database_s3_access( + cluster.database_s3_access.as_ref(), + )), Cell::new(format_created_at(&cluster.created_at)), Cell::new(&cluster.id), ]); @@ -329,6 +342,19 @@ pub fn status( format_idle_timeout(cluster.idle_timeout_minutes) ); println!("Storage: {}", format_storage(cluster.s3_storage.as_ref())); + if let Some(database_s3_access) = cluster.database_s3_access.as_ref() { + println!("Database S3 access: configured"); + println!( + "Database S3 external ID: {}", + database_s3_access.external_id + ); + println!( + "Database bucket tag: {}", + database_s3_access.database_bucket_tag + ); + } else { + println!("Database S3 access: not configured"); + } if let Some(message) = cluster.status.message.as_deref() { println!("Message: {message}"); } @@ -471,6 +497,7 @@ fn create_request_body( max_size: ClusterSizeArg, idle_timeout_minutes: Option, s3_storage: Option, + database_s3_access: Option, ) -> Value { let mut body = json!({ "name": name, @@ -487,9 +514,29 @@ fn create_request_body( if let Some(s3_storage) = s3_storage { body["s3_storage"] = s3_storage; } + if let Some(database_s3_access) = database_s3_access { + body["database_s3_access"] = database_s3_access; + } body } +fn validate_matching_s3_external_ids( + s3_storage: &S3StorageArgs, + database_s3_access: &DatabaseS3AccessArgs, +) -> Result<()> { + if let (Some(storage_external_id), Some(database_external_id)) = ( + s3_storage.s3_external_id.as_deref(), + database_s3_access.database_s3_external_id.as_deref(), + ) { + if storage_external_id.trim() != database_external_id.trim() { + anyhow::bail!( + "S3 External IDs do not match. Use the same value for --s3-external-id and --database-s3-external-id." + ); + } + } + Ok(()) +} + fn resolve_cluster<'a>(clusters: &'a [ClusterItem], name_or_id: &str) -> Result<&'a ClusterItem> { clusters .iter() @@ -572,6 +619,12 @@ fn format_storage(storage: Option<&S3StorageMetadata>) -> &'static str { } } +fn format_database_s3_access(access: Option<&DatabaseS3AccessMetadata>) -> String { + access + .map(|access| format!("configured ({})", access.database_bucket_tag)) + .unwrap_or_else(|| "not configured".to_string()) +} + fn format_created_at(created_at: &str) -> String { DateTime::parse_from_str(created_at, "%Y-%m-%d %H:%M:%S%.f%#z") .map(|timestamp| { @@ -615,12 +668,13 @@ mod tests { use super::{ cluster_path, cluster_size_json, clusters_collection_path, create_request_body, - default_cluster_after_delete, delete_output, format_created_at, format_idle_timeout, - format_phase, format_size_per_replica, format_storage, renamed_default_cluster, - resolve_cluster, resolve_cluster_size, ClusterItem, ClusterResources, ClusterSizeItem, + default_cluster_after_delete, delete_output, format_created_at, format_database_s3_access, + format_idle_timeout, format_phase, format_size_per_replica, format_storage, + renamed_default_cluster, resolve_cluster, resolve_cluster_size, + validate_matching_s3_external_ids, ClusterItem, ClusterResources, ClusterSizeItem, ClusterStatus, }; - use crate::cli::{ClusterSizeArg, S3StorageArgs}; + use crate::cli::{ClusterSizeArg, DatabaseS3AccessArgs, S3StorageArgs}; #[test] fn collection_path_encodes_organization() { @@ -714,6 +768,7 @@ mod tests { }, Some(30), None, + None, ), json!({ "name": "production", @@ -742,6 +797,7 @@ mod tests { can_pause: true, can_resume: false, s3_storage: None, + database_s3_access: None, idle_timeout_minutes: 15, } } @@ -881,6 +937,37 @@ mod tests { ); } + #[test] + fn cluster_database_s3_access_response_deserializes_metadata() { + let cluster: ClusterItem = serde_json::from_value(json!({ + "id": "cluster-id", + "name": "production", + "created_at": "2026-07-14 20:38:33.004347+00", + "status": {"phase": "ready", "ready": true, "message": null}, + "resources": null, + "can_pause": true, + "can_resume": false, + "s3_storage": null, + "database_s3_access": { + "external_id": "rawtree-database-access", + "database_bucket_tag": "rawtree-customer-database" + }, + "idle_timeout_minutes": 15 + })) + .expect("cluster database S3 access metadata should deserialize"); + + let access = cluster + .database_s3_access + .expect("database S3 access metadata"); + assert_eq!(access.external_id, "rawtree-database-access"); + assert_eq!(access.database_bucket_tag, "rawtree-customer-database"); + assert_eq!( + format_database_s3_access(Some(&access)), + "configured (rawtree-customer-database)" + ); + assert_eq!(format_database_s3_access(None), "not configured"); + } + #[test] fn cluster_create_body_uses_s3_storage() { let storage = S3StorageArgs { @@ -904,6 +991,7 @@ mod tests { }, None, storage.to_json().expect("valid storage"), + None, ); assert_eq!( @@ -917,6 +1005,89 @@ mod tests { ); } + #[test] + fn cluster_create_body_supports_database_s3_access_without_cluster_storage() { + let body = create_request_body( + "production", + 1, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + None, + None, + Some(json!({ + "external_id": "rawtree-database-access", + "database_bucket_tag": "rawtree-customer-database" + })), + ); + + assert!(body.get("s3_storage").is_none()); + assert_eq!( + body["database_s3_access"], + json!({ + "external_id": "rawtree-database-access", + "database_bucket_tag": "rawtree-customer-database" + }) + ); + } + + #[test] + fn cluster_create_body_supports_matching_storage_and_database_access() { + let storage = S3StorageArgs { + s3_data_bucket: Some("customer-data".to_string()), + s3_backups_bucket: Some("customer-backups".to_string()), + s3_role_arn: Some("arn:aws:iam::123456789012:role/RawTreeS3Access".to_string()), + s3_external_id: Some("rawtree-example".to_string()), + ..S3StorageArgs::default() + }; + let access = DatabaseS3AccessArgs { + database_s3_external_id: Some("rawtree-example".to_string()), + database_bucket_tag: Some("rawtree-customer-database".to_string()), + }; + + validate_matching_s3_external_ids(&storage, &access) + .expect("matching external IDs should be accepted"); + let body = create_request_body( + "production", + 1, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + ClusterSizeArg { + cpu_cores: 2, + memory_gib: 8, + }, + None, + storage.to_json().expect("valid storage"), + access.to_json().expect("valid database access"), + ); + + assert!(body["s3_storage"].is_object()); + assert!(body["database_s3_access"].is_object()); + } + + #[test] + fn cluster_create_rejects_mismatched_storage_and_database_external_ids() { + let storage = S3StorageArgs { + s3_external_id: Some("rawtree-cluster".to_string()), + ..S3StorageArgs::default() + }; + let access = DatabaseS3AccessArgs { + database_s3_external_id: Some("rawtree-database".to_string()), + ..DatabaseS3AccessArgs::default() + }; + + let error = validate_matching_s3_external_ids(&storage, &access) + .expect_err("mismatched external IDs should be rejected"); + assert!(error.to_string().contains("S3 External IDs do not match")); + } + #[test] fn timestamp_is_rendered_to_seconds_in_utc() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index d65857d..7272089 100644 --- a/src/main.rs +++ b/src/main.rs @@ -342,6 +342,7 @@ fn run(cli: Cli) -> Result<()> { max_size, idle_timeout_minutes, s3_storage, + database_s3_access, } => commands::cluster::create( &client, commands::cluster::ClusterCreateOptions { @@ -352,6 +353,7 @@ fn run(cli: Cli) -> Result<()> { max_size, idle_timeout_minutes, s3_storage, + database_s3_access, }, json, ), From fd110df24e64a5ae04dcb082b4af19e06fc10503 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Sat, 17 Oct 2026 14:57:05 +0200 Subject: [PATCH 3/6] Use cluster identity fields and flat S3 storage API --- Cargo.lock | 12 + Cargo.toml | 1 + README.md | 71 +++--- src/cli.rs | 5 +- src/cli/tests.rs | 4 +- src/cluster_identity.rs | 11 + src/commands/cluster.rs | 83 ++++--- src/commands/cluster/storage_tests.rs | 188 +--------------- src/commands/database.rs | 2 +- src/main.rs | 5 +- src/s3_storage.rs | 311 +++----------------------- tests/api_contracts.rs | 2 +- tests/live_platform.rs | 2 + tests/live_platform/storage.rs | 40 ++++ tests/storage_contracts.rs | 210 +++++++++++------ 15 files changed, 342 insertions(+), 605 deletions(-) create mode 100644 src/cluster_identity.rs create mode 100644 tests/live_platform/storage.rs diff --git a/Cargo.lock b/Cargo.lock index fb37b9d..bf40fcd 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 db300d4..5e2d771 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 5814dda..2d0a7e8 100644 --- a/README.md +++ b/README.md @@ -372,18 +372,17 @@ rtree cluster create \ --min-size 2:8 \ --s3-data-bucket acme-rawtree-data \ --s3-data-path rawtree/data \ - --s3-backups-bucket acme-rawtree-backups \ - --s3-backups-path rawtree/backups \ + --s3-backup-bucket acme-rawtree-backups \ + --s3-backup-path rawtree/backups \ --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ - --s3-external-id rawtree-example -# Or keep the cluster on RawTree-managed storage while enabling -# independent customer-owned S3 storage for databases created later + --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 \ - --database-s3-external-id rawtree-database-access \ - --database-bucket-tag rawtree-customer-database + --external-id rawtree-database-access \ + --resource-tag rawtree-customer-database rtree cluster use production rtree cluster status production rtree cluster update production --idle-timeout-minutes 60 @@ -401,36 +400,44 @@ 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 and database creation accept the same customer-owned S3 options. Pass -all four required values (`--s3-data-bucket`, `--s3-backups-bucket`, -`--s3-role-arn`, and `--s3-external-id`) to send the platform's `s3_storage` -configuration; the data and backup paths are optional. For a database: +Cluster creation forwards `--external-id` and `--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 `--external-id` to match the customer IAM role's +trust policy. `--resource-tag` may be supplied or generated. + +Cluster and database creation share `--s3-data-bucket`, `--s3-backup-bucket`, and +`--s3-role-arn`; all three are required if any S3 option is supplied. The optional +`--s3-data-path` and `--s3-backup-path` specify object-key prefixes. ```sh -rtree database create analytics \ +rtree --org acme --cluster production database create analytics \ --s3-data-bucket acme-rawtree-data \ - --s3-backups-bucket acme-rawtree-backups \ - --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ - --s3-external-id rawtree-example + --s3-backup-bucket acme-rawtree-backups \ + --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access ``` -These options configure new clusters and databases. They do not create S3 -buckets or IAM roles, or migrate existing data. The API verifies access to -the supplied S3 storage before accepting creation. - -If no S3 options are passed, the database inherits the cluster's storage. Use -`rtree cluster list --json` or `rtree database list --json` to inspect the -safe bucket/path metadata returned as `s3_storage`; credentials are never -returned. - -Clusters can also enable independent per-database S3 access with -`--database-s3-external-id` and `--database-bucket-tag`. This capability is -independent from cluster-level `s3_storage`, so it can be configured even when -the cluster uses RawTree-managed defaults. Tag every customer-owned database -bucket and IAM role with `rawtree.com/cluster=`. If both -storage configurations are supplied, the two External IDs must match. Cluster -list/status JSON exposes the safe `database_s3_access` metadata; human-readable -status includes the External ID and bucket tag. +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 a flat `s3_storage` object containing `data_bucket`, +`data_path`, `backup_bucket`, `backup_path`, and `role_arn`. List/status JSON +preserves the flat bucket/path metadata and top-level cluster `external_id` and +`resource_tag`; role ARNs and credentials are not returned. Older clusters without +saved identity show `not configured` in status output. + +The old `--s3-external-id`, `--database-s3-external-id`, `--database-bucket-tag`, +and plural `--s3-backups-*` flags are removed. 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 diff --git a/src/cli.rs b/src/cli.rs index 8803b69..0fec27f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -2,7 +2,8 @@ use std::{fmt, str::FromStr}; use clap::{Parser, Subcommand, ValueEnum}; -use crate::s3_storage::{DatabaseS3AccessArgs, S3StorageArgs}; +use crate::cluster_identity::ClusterIdentityArgs; +use crate::s3_storage::S3StorageArgs; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ClusterSizeArg { @@ -341,7 +342,7 @@ pub enum ClusterCommand { #[command(flatten)] s3_storage: S3StorageArgs, #[command(flatten)] - database_s3_access: DatabaseS3AccessArgs, + identity: ClusterIdentityArgs, }, /// Set the default cluster Use { diff --git a/src/cli/tests.rs b/src/cli/tests.rs index 91e73e2..b365d7c 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -97,8 +97,7 @@ fn global_api_key_parses_before_subcommand() { #[test] fn cluster_list_parses() { - let cli = - Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); + let cli = Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); assert!(matches!( cli.command, Command::Cluster { @@ -146,6 +145,7 @@ fn cluster_create_parses_explicit_name_and_idle_timeout() { idle_timeout_minutes: Some(30), min_size, max_size: Some(max_size), + .. } } if name == "production" && replicas == 2 diff --git a/src/cluster_identity.rs b/src/cluster_identity.rs new file mode 100644 index 0000000..9bdb18e --- /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, value_name = "ID")] + pub(crate) external_id: Option, + /// Immutable rawtree.com/cluster AWS resource tag value; API generates it when omitted + #[arg(long, value_name = "TAG")] + pub(crate) resource_tag: Option, +} diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index 804f3bb..97e4761 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -7,12 +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_database_s3_access, format_storage, validate_matching_s3_external_ids, - DatabaseS3AccessArgs, DatabaseS3AccessMetadata, S3StorageArgs, S3StorageMetadata, -}; +use crate::s3_storage::{format_storage, S3StorageArgs, S3StorageMetadata}; #[derive(Deserialize)] struct ListClustersResponse { @@ -45,10 +43,12 @@ struct ClusterItem { resources: Option, can_pause: bool, can_resume: bool, - #[serde(default, alias = "custom_s3")] + #[serde(default)] s3_storage: Option, #[serde(default)] - database_s3_access: Option, + external_id: Option, + #[serde(default)] + resource_tag: Option, idle_timeout_minutes: u64, } @@ -125,8 +125,6 @@ pub fn create( json_mode: bool, ) -> Result<()> { let s3_storage = options.s3_storage.to_json()?; - let database_s3_access = options.database_s3_access.to_json()?; - validate_matching_s3_external_ids(&options.s3_storage, &options.database_s3_access)?; let (_, sizes) = load_cluster_sizes(client)?; let (min_index, min_size) = resolve_cluster_size(&sizes.sizes, options.min_size)?; let (max_index, max_size) = options @@ -145,7 +143,7 @@ pub fn create( max_size, options.idle_timeout_minutes, s3_storage, - database_s3_access, + options.identity, ); let value: Value = client.post(&clusters_collection_path(options.organization), &body)?; @@ -158,6 +156,14 @@ pub fn create( created.name, format_phase(&created.status.phase), ); + println!( + "External ID: {}", + created.external_id.as_deref().unwrap_or("not configured") + ); + println!( + "Resource tag: {}", + created.resource_tag.as_deref().unwrap_or("not configured") + ); println!( "Run `rtree cluster status {}` to check provisioning progress.", created.name @@ -174,7 +180,7 @@ pub struct ClusterCreateOptions<'a> { pub max_size: Option, pub idle_timeout_minutes: Option, pub s3_storage: S3StorageArgs, - pub database_s3_access: DatabaseS3AccessArgs, + pub identity: ClusterIdentityArgs, } pub fn update( @@ -248,7 +254,7 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> "size / replica", "idle timeout", "storage", - "database S3", + "resource tag", "created", "id", ]); @@ -264,10 +270,11 @@ 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())), - Cell::new(format_database_s3_access( - cluster.database_s3_access.as_ref(), + Cell::new(format_storage( + cluster.s3_storage.as_ref(), + "RawTree-managed", )), + Cell::new(cluster.resource_tag.as_deref().unwrap_or("—")), Cell::new(format_created_at(&cluster.created_at)), Cell::new(&cluster.id), ]); @@ -339,20 +346,18 @@ pub fn status( "Idle timeout: {}", format_idle_timeout(cluster.idle_timeout_minutes) ); - println!("Storage: {}", format_storage(cluster.s3_storage.as_ref())); - if let Some(database_s3_access) = cluster.database_s3_access.as_ref() { - println!("Database S3 access: configured"); - println!( - "Database S3 external ID: {}", - database_s3_access.external_id - ); - println!( - "Database bucket tag: {}", - database_s3_access.database_bucket_tag - ); - } else { - println!("Database S3 access: not configured"); - } + println!( + "Storage: {}", + format_storage(cluster.s3_storage.as_ref(), "RawTree-managed") + ); + println!( + "External ID: {}", + cluster.external_id.as_deref().unwrap_or("not configured") + ); + println!( + "Resource tag: {}", + cluster.resource_tag.as_deref().unwrap_or("not configured") + ); if let Some(message) = cluster.status.message.as_deref() { println!("Message: {message}"); } @@ -507,7 +512,7 @@ fn create_request_body( max_size: ClusterSizeArg, idle_timeout_minutes: Option, s3_storage: Option, - database_s3_access: Option, + identity: ClusterIdentityArgs, ) -> Value { let mut body = json!({ "name": name, @@ -518,15 +523,18 @@ fn create_request_body( "max_size": cluster_size_json(max_size), }, }); + if let Some(external_id) = identity.external_id { + body["external_id"] = json!(external_id); + } + if let Some(resource_tag) = identity.resource_tag { + body["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; } - if let Some(database_s3_access) = database_s3_access { - body["database_s3_access"] = database_s3_access; - } body } @@ -746,7 +754,10 @@ mod tests { }, Some(30), None, - None, + crate::cluster_identity::ClusterIdentityArgs { + external_id: Some("rawtree-test".into()), + resource_tag: Some("test-tag".into()) + }, ), json!({ "name": "production", @@ -756,7 +767,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, + "external_id": "rawtree-test", "resource_tag": "test-tag" }) ); } @@ -775,7 +787,8 @@ mod tests { can_pause: true, can_resume: false, s3_storage: None, - database_s3_access: None, + external_id: None, + resource_tag: None, idle_timeout_minutes: 15, } } diff --git a/src/commands/cluster/storage_tests.rs b/src/commands/cluster/storage_tests.rs index ee8dd04..8bec33f 100644 --- a/src/commands/cluster/storage_tests.rs +++ b/src/commands/cluster/storage_tests.rs @@ -1,183 +1,19 @@ -use super::{create_request_body, format_database_s3_access, format_storage, ClusterItem}; -use crate::cli::ClusterSizeArg; -use crate::s3_storage::{validate_matching_s3_external_ids, DatabaseS3AccessArgs, S3StorageArgs}; +use super::ClusterItem; use serde_json::json; #[test] -fn cluster_storage_response_deserializes_with_the_new_field_name() { +fn cluster_metadata_exposes_identity_and_flat_storage() { let cluster: ClusterItem = serde_json::from_value(json!({ - "id": "cluster-id", - "name": "production", - "created_at": "2026-07-14 20:38:33.004347+00", + "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": { - "data": {"bucket": "customer-data", "path": "rawtree/data"}, - "backups": {"bucket": "customer-backups", "path": "rawtree/backups"} - }, + "resources": null, "can_pause": true, "can_resume": false, + "external_id": "rawtree-example", "resource_tag": "customer-buckets", + "s3_storage": {"data_bucket": "customer-data", "data_path": "events", "backup_bucket": "customer-backups", "backup_path": "snapshots"}, "idle_timeout_minutes": 15 - })) - .expect("cluster storage response should deserialize"); - - assert_eq!( - format_storage(cluster.s3_storage.as_ref()), - "customer-owned S3" - ); - assert_eq!( - cluster.s3_storage.expect("storage metadata").data.bucket, - "customer-data" - ); -} - -#[test] -fn cluster_database_s3_access_response_deserializes_metadata() { - let cluster: ClusterItem = serde_json::from_value(json!({ - "id": "cluster-id", - "name": "production", - "created_at": "2026-07-14 20:38:33.004347+00", - "status": {"phase": "ready", "ready": true, "message": null}, - "resources": null, - "can_pause": true, - "can_resume": false, - "s3_storage": null, - "database_s3_access": { - "external_id": "rawtree-database-access", - "database_bucket_tag": "rawtree-customer-database" - }, - "idle_timeout_minutes": 15 - })) - .expect("cluster database S3 access metadata should deserialize"); - - let access = cluster - .database_s3_access - .expect("database S3 access metadata"); - assert_eq!(access.external_id, "rawtree-database-access"); - assert_eq!(access.database_bucket_tag, "rawtree-customer-database"); - assert_eq!( - format_database_s3_access(Some(&access)), - "configured (rawtree-customer-database)" - ); - assert_eq!(format_database_s3_access(None), "not configured"); -} - -#[test] -fn cluster_create_body_uses_s3_storage() { - let storage = S3StorageArgs { - s3_data_bucket: Some("customer-data".to_string()), - s3_data_path: Some("rawtree/data".to_string()), - s3_backups_bucket: Some("customer-backups".to_string()), - s3_backups_path: Some("rawtree/backups".to_string()), - s3_role_arn: Some("arn:aws:iam::123456789012:role/RawTreeS3Access".to_string()), - s3_external_id: Some("rawtree-example".to_string()), - }; - let body = create_request_body( - "production", - 1, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - None, - storage.to_json().expect("valid storage"), - None, - ); - - assert_eq!( - body["s3_storage"], - json!({ - "data": {"bucket": "customer-data", "path": "rawtree/data"}, - "backups": {"bucket": "customer-backups", "path": "rawtree/backups"}, - "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", - "external_id": "rawtree-example" - }) - ); -} - -#[test] -fn cluster_create_body_supports_database_s3_access_without_cluster_storage() { - let body = create_request_body( - "production", - 1, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - None, - None, - Some(json!({ - "external_id": "rawtree-database-access", - "database_bucket_tag": "rawtree-customer-database" - })), - ); - - assert!(body.get("s3_storage").is_none()); - assert_eq!( - body["database_s3_access"], - json!({ - "external_id": "rawtree-database-access", - "database_bucket_tag": "rawtree-customer-database" - }) - ); -} - -#[test] -fn cluster_create_body_supports_matching_storage_and_database_access() { - let storage = S3StorageArgs { - s3_data_bucket: Some("customer-data".to_string()), - s3_backups_bucket: Some("customer-backups".to_string()), - s3_role_arn: Some("arn:aws:iam::123456789012:role/RawTreeS3Access".to_string()), - s3_external_id: Some("rawtree-example".to_string()), - ..S3StorageArgs::default() - }; - let access = DatabaseS3AccessArgs { - database_s3_external_id: Some("rawtree-example".to_string()), - database_bucket_tag: Some("rawtree-customer-database".to_string()), - }; - - validate_matching_s3_external_ids(&storage, &access) - .expect("matching external IDs should be accepted"); - let body = create_request_body( - "production", - 1, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - ClusterSizeArg { - cpu_cores: 2, - memory_gib: 8, - }, - None, - storage.to_json().expect("valid storage"), - access.to_json().expect("valid database access"), - ); - - assert!(body["s3_storage"].is_object()); - assert!(body["database_s3_access"].is_object()); -} - -#[test] -fn cluster_create_rejects_mismatched_storage_and_database_external_ids() { - let storage = S3StorageArgs { - s3_external_id: Some("rawtree-cluster".to_string()), - ..S3StorageArgs::default() - }; - let access = DatabaseS3AccessArgs { - database_s3_external_id: Some("rawtree-database".to_string()), - ..DatabaseS3AccessArgs::default() - }; - - let error = validate_matching_s3_external_ids(&storage, &access) - .expect_err("mismatched external IDs should be rejected"); - assert!(error.to_string().contains("S3 External IDs do not match")); + })).expect("cluster metadata"); + assert_eq!(cluster.external_id.as_deref(), Some("rawtree-example")); + assert_eq!(cluster.resource_tag.as_deref(), Some("customer-buckets")); + let storage = cluster.s3_storage.expect("storage metadata"); + assert_eq!(storage.data_bucket, "customer-data"); + assert_eq!(storage.backup_path, "snapshots"); } diff --git a/src/commands/database.rs b/src/commands/database.rs index c745f29..fd34802 100644 --- a/src/commands/database.rs +++ b/src/commands/database.rs @@ -62,7 +62,7 @@ pub fn list( println!( "{:<20} storage={}", database.name, - format_storage(database.s3_storage.as_ref()) + format_storage(database.s3_storage.as_ref(), "cluster default") ); } } diff --git a/src/main.rs b/src/main.rs index c928f52..fd5c217 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod client; +mod cluster_identity; mod commands; mod config; mod constants; @@ -378,7 +379,7 @@ fn run(cli: Cli) -> Result { max_size, idle_timeout_minutes, s3_storage, - database_s3_access, + identity, } => commands::cluster::create( &client, commands::cluster::ClusterCreateOptions { @@ -389,7 +390,7 @@ fn run(cli: Cli) -> Result { max_size, idle_timeout_minutes, s3_storage, - database_s3_access, + identity, }, json, ), diff --git a/src/s3_storage.rs b/src/s3_storage.rs index 31ca093..d7b39a2 100644 --- a/src/s3_storage.rs +++ b/src/s3_storage.rs @@ -5,7 +5,7 @@ use serde_json::{json, Value}; #[derive(Args, Clone, Debug, Default)] pub(crate) struct S3StorageArgs { - /// Customer-owned S3 bucket for database data + /// Customer-owned S3 data bucket; requires --s3-backup-bucket and --s3-role-arn #[arg(long, value_name = "BUCKET")] pub(crate) s3_data_bucket: Option, /// Optional path prefix within the data bucket @@ -13,44 +13,13 @@ pub(crate) struct S3StorageArgs { pub(crate) s3_data_path: Option, /// Customer-owned S3 bucket for backups #[arg(long, value_name = "BUCKET")] - pub(crate) s3_backups_bucket: Option, + pub(crate) s3_backup_bucket: Option, /// Optional path prefix within the backups bucket #[arg(long, value_name = "PATH")] - pub(crate) s3_backups_path: Option, + pub(crate) s3_backup_path: Option, /// IAM role ARN RawTree should assume #[arg(long, value_name = "ARN")] pub(crate) s3_role_arn: Option, - /// External ID configured in the customer IAM role trust policy - #[arg(long, value_name = "ID")] - pub(crate) s3_external_id: Option, -} - -#[derive(Args, Clone, Debug, Default)] -pub(crate) struct DatabaseS3AccessArgs { - /// External ID configured in the customer IAM role trust policy for database buckets - #[arg(long, value_name = "ID")] - pub(crate) database_s3_external_id: Option, - /// Immutable tag required on customer-owned database buckets and IAM roles - #[arg(long, value_name = "TAG")] - pub(crate) database_bucket_tag: Option, -} - -#[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, -} - -#[derive(Clone, Deserialize, Serialize)] -pub(crate) struct DatabaseS3AccessMetadata { - pub(crate) external_id: String, - pub(crate) database_bucket_tag: String, } impl S3StorageArgs { @@ -58,10 +27,9 @@ impl S3StorageArgs { let configured = [ &self.s3_data_bucket, &self.s3_data_path, - &self.s3_backups_bucket, - &self.s3_backups_path, + &self.s3_backup_bucket, + &self.s3_backup_path, &self.s3_role_arn, - &self.s3_external_id, ] .iter() .any(|value| value.is_some()); @@ -70,269 +38,40 @@ impl S3StorageArgs { } let data_bucket = required_s3_value(&self.s3_data_bucket, "--s3-data-bucket")?; - let backups_bucket = required_s3_value(&self.s3_backups_bucket, "--s3-backups-bucket")?; + let backups_bucket = required_s3_value(&self.s3_backup_bucket, "--s3-backup-bucket")?; let role_arn = required_s3_value(&self.s3_role_arn, "--s3-role-arn")?; - let external_id = required_s3_value(&self.s3_external_id, "--s3-external-id")?; Ok(Some(json!({ - "data": { - "bucket": data_bucket, - "path": self.s3_data_path.as_deref().unwrap_or(""), - }, - "backups": { - "bucket": backups_bucket, - "path": self.s3_backups_path.as_deref().unwrap_or(""), - }, + "data_bucket": data_bucket, + "data_path": self.s3_data_path.as_deref().unwrap_or(""), + "backup_bucket": backups_bucket, + "backup_path": self.s3_backup_path.as_deref().unwrap_or(""), "role_arn": role_arn, - "external_id": external_id, - }))) - } -} - -impl DatabaseS3AccessArgs { - pub(crate) fn to_json(&self) -> Result> { - let configured = - self.database_s3_external_id.is_some() || self.database_bucket_tag.is_some(); - if !configured { - return Ok(None); - } - - let external_id = self - .database_s3_external_id - .as_deref() - .ok_or_else(|| { - anyhow::anyhow!( - "Database S3 access is incomplete. Provide --database-s3-external-id and --database-bucket-tag. Missing --database-s3-external-id." - ) - })?; - let database_bucket_tag = self.database_bucket_tag.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "Database S3 access is incomplete. Provide --database-s3-external-id and --database-bucket-tag. Missing --database-bucket-tag." - ) - })?; - validate_database_s3_external_id(external_id)?; - validate_database_bucket_tag(database_bucket_tag)?; - - Ok(Some(json!({ - "external_id": external_id, - "database_bucket_tag": database_bucket_tag, }))) } } -fn validate_database_s3_external_id(value: &str) -> Result<()> { - let value = value.trim(); - if !(2..=1224).contains(&value.len()) - || !value.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'_' | b'+' | b'=' | b',' | b'.' | b'@' | b':' | b'/' | b'-' - ) - }) - { - anyhow::bail!( - "Invalid database S3 access External ID. Use 2-1224 letters, numbers, or the characters _ + = , . @ : / -." - ); - } - Ok(()) -} - -fn validate_database_bucket_tag(value: &str) -> Result<()> { - let value = value.trim(); - if value.is_empty() - || value.len() > 256 - || !value - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - || !value - .as_bytes() - .first() - .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) - { - anyhow::bail!( - "Invalid database S3 bucket tag. Use 1-256 lowercase letters, numbers, or hyphens, starting with a letter or number." - ); - } - Ok(()) -} - -fn required_s3_value<'a>(value: &'a Option, flag: &str) -> Result<&'a str> { - value.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "S3 storage is incomplete. Provide --s3-data-bucket, --s3-backups-bucket, --s3-role-arn, and --s3-external-id. Missing {flag}." - ) - }) -} - -pub(crate) fn validate_matching_s3_external_ids( - s3_storage: &S3StorageArgs, - database_s3_access: &DatabaseS3AccessArgs, -) -> Result<()> { - if let (Some(storage_external_id), Some(database_external_id)) = ( - s3_storage.s3_external_id.as_deref(), - database_s3_access.database_s3_external_id.as_deref(), - ) { - if storage_external_id.trim() != database_external_id.trim() { - anyhow::bail!( - "S3 External IDs do not match. Use the same value for --s3-external-id and --database-s3-external-id." - ); - } - } - Ok(()) -} - -pub(crate) fn format_storage(storage: Option<&S3StorageMetadata>) -> &'static str { +pub(crate) fn format_storage( + storage: Option<&S3StorageMetadata>, + default: &'static str, +) -> &'static str { if storage.is_some() { "customer-owned S3" } else { - "cluster default" + default } } -pub(crate) fn format_database_s3_access(access: Option<&DatabaseS3AccessMetadata>) -> String { - access - .map(|access| format!("configured ({})", access.database_bucket_tag)) - .unwrap_or_else(|| "not configured".to_string()) +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-data-bucket, --s3-backup-bucket, and --s3-role-arn. Missing {flag}.") + }) } -#[cfg(test)] -mod tests { - use super::{DatabaseS3AccessArgs, S3StorageArgs}; - use crate::cli::{Cli, ClusterCommand, Command}; - use clap::Parser; - - #[test] - fn cluster_create_parses_s3_storage_options() { - let cli = Cli::try_parse_from([ - "rtree", - "cluster", - "create", - "--name", - "production", - "--replicas", - "1", - "--min-size", - "2:8", - "--s3-data-bucket", - "customer-data", - "--s3-data-path", - "rawtree/data", - "--s3-backups-bucket", - "customer-backups", - "--s3-backups-path", - "rawtree/backups", - "--s3-role-arn", - "arn:aws:iam::123456789012:role/RawTreeS3Access", - "--s3-external-id", - "rawtree-example", - ]) - .expect("cluster create S3 options should parse"); - - let Command::Cluster { - action: ClusterCommand::Create { s3_storage, .. }, - } = cli.command - else { - panic!("expected cluster create command"); - }; - - assert_eq!( - s3_storage - .to_json() - .expect("complete S3 options should be valid"), - Some(serde_json::json!({ - "data": {"bucket": "customer-data", "path": "rawtree/data"}, - "backups": {"bucket": "customer-backups", "path": "rawtree/backups"}, - "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", - "external_id": "rawtree-example" - })) - ); - } - - #[test] - fn partial_s3_storage_options_are_rejected_before_request() { - let args = S3StorageArgs { - s3_data_bucket: Some("customer-data".to_string()), - ..S3StorageArgs::default() - }; - - let error = args - .to_json() - .expect_err("partial S3 options should be rejected"); - assert!(error.to_string().contains("--s3-backups-bucket")); - } - - #[test] - fn cluster_create_parses_independent_database_s3_access_options() { - let cli = Cli::try_parse_from([ - "rtree", - "cluster", - "create", - "--name", - "production", - "--replicas", - "1", - "--min-size", - "2:8", - "--database-s3-external-id", - "rawtree-database-access", - "--database-bucket-tag", - "rawtree-customer-database", - ]) - .expect("cluster create database S3 options should parse"); - - let Command::Cluster { - action: - ClusterCommand::Create { - database_s3_access, - s3_storage, - .. - }, - } = cli.command - else { - panic!("expected cluster create command"); - }; - - assert_eq!( - s3_storage.to_json().expect("storage should be omitted"), - None - ); - assert_eq!( - database_s3_access - .to_json() - .expect("complete database S3 access should be valid"), - Some(serde_json::json!({ - "external_id": "rawtree-database-access", - "database_bucket_tag": "rawtree-customer-database" - })) - ); - } - - #[test] - fn partial_database_s3_access_options_are_rejected_before_request() { - let args = DatabaseS3AccessArgs { - database_s3_external_id: Some("rawtree-database-access".to_string()), - ..DatabaseS3AccessArgs::default() - }; - - let error = args - .to_json() - .expect_err("partial database S3 access should be rejected"); - assert!(error.to_string().contains("--database-bucket-tag")); - } - - #[test] - fn invalid_database_s3_access_values_are_rejected_before_request() { - let invalid_external_id = DatabaseS3AccessArgs { - database_s3_external_id: Some("?".to_string()), - database_bucket_tag: Some("rawtree-customer-database".to_string()), - }; - assert!(invalid_external_id.to_json().is_err()); - - let invalid_bucket_tag = DatabaseS3AccessArgs { - database_s3_external_id: Some("rawtree-database-access".to_string()), - database_bucket_tag: Some("RawTree_Invalid".to_string()), - }; - assert!(invalid_bucket_tag.to_json().is_err()); - } +#[derive(Clone, Deserialize, Serialize)] +pub(crate) struct S3StorageMetadata { + pub(crate) data_bucket: String, + pub(crate) data_path: String, + pub(crate) backup_bucket: String, + pub(crate) backup_path: String, } diff --git a/tests/api_contracts.rs b/tests/api_contracts.rs index 078039e..a58a888 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", "data_path": "prefix/", "backup_bucket": "backups", "backup_path": ""}} ]}); let (result, _, _) = run( &["database", "list"], 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..40ace19 --- /dev/null +++ b/tests/live_platform/storage.rs @@ -0,0 +1,40 @@ +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[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[field], created[field]); + } + assert_ne!(created["external_id"], created["resource_tag"]); + assert!(created["s3_storage"].is_null()); +} diff --git a/tests/storage_contracts.rs b/tests/storage_contracts.rs index d1479bc..1ff1719 100644 --- a/tests/storage_contracts.rs +++ b/tests/storage_contracts.rs @@ -5,20 +5,17 @@ use serde_json::{json, Value}; const S3_ARGS: &[&str] = &[ "--s3-data-bucket", "customer-data", - "--s3-backups-bucket", + "--s3-backup-bucket", "customer-backups", "--s3-role-arn", "arn:aws:iam::123456789012:role/RawTreeS3Access", - "--s3-external-id", - "rawtree-example", ]; fn storage() -> Value { json!({ - "data": {"bucket": "customer-data", "path": ""}, - "backups": {"bucket": "customer-backups", "path": ""}, - "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access", - "external_id": "rawtree-example" + "data_bucket": "customer-data", "data_path": "", + "backup_bucket": "customer-backups", "backup_path": "", + "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access" }) } @@ -29,10 +26,10 @@ fn cluster() -> Value { "resources": null, "can_pause": false, "can_resume": false, "idle_timeout_minutes": 15, "s3_storage": { - "data": {"bucket": "customer-data", "path": ""}, - "backups": {"bucket": "customer-backups", "path": ""} + "data_bucket": "customer-data", "data_path": "", + "backup_bucket": "customer-backups", "backup_path": "" }, - "database_s3_access": {"external_id": "rawtree-example", "database_bucket_tag": "customer-buckets"} + "external_id": "rawtree-example", "resource_tag": "customer-buckets" }) } @@ -59,7 +56,7 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() args.extend_from_slice(&[ "--s3-data-path", "events/", - "--s3-backups-path", + "--s3-backup-path", "snapshots/", ]); let (output, saved, bodies) = common::run_cli( @@ -78,8 +75,8 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() String::from_utf8_lossy(&output.stderr) ); let mut expected = storage(); - expected["data"]["path"] = json!("events/"); - expected["backups"]["path"] = json!("snapshots/"); + expected["data_path"] = json!("events/"); + expected["backup_path"] = json!("snapshots/"); assert_eq!( bodies, vec![json!({"name": "analytics", "s3_storage": expected})] @@ -100,8 +97,9 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() } #[test] -fn cluster_creation_supports_default_storage_s3_and_independent_database_access() { - for (with_storage, with_access) in [(false, false), (true, false), (false, true), (true, true)] +fn cluster_creation_forwards_optional_identity_and_flat_storage() { + for (with_storage, with_identity) in + [(false, false), (true, false), (false, true), (true, true)] { let mut args = vec![ "--org", @@ -119,21 +117,21 @@ fn cluster_creation_supports_default_storage_s3_and_independent_database_access( if with_storage { args.extend_from_slice(S3_ARGS); } - if with_access { + if with_identity { args.extend_from_slice(&[ - "--database-s3-external-id", + "--external-id", "rawtree-example", - "--database-bucket-tag", + "--resource-tag", "customer-buckets", ]); } + if with_storage && !with_identity { + args.extend_from_slice(&["--external-id", "rawtree-example"]); + } let mut response = cluster(); if !with_storage { response["s3_storage"] = Value::Null; } - if !with_access { - response["database_s3_access"] = Value::Null; - } let (output, _, bodies) = common::run_cli( &[ ( @@ -163,8 +161,11 @@ fn cluster_creation_supports_default_storage_s3_and_independent_database_access( if with_storage { expected["s3_storage"] = storage(); } - if with_access { - expected["database_s3_access"] = cluster()["database_s3_access"].clone(); + if with_identity || with_storage { + expected["external_id"] = json!("rawtree-example"); + } + if with_identity { + expected["resource_tag"] = json!("customer-buckets"); } assert_eq!(bodies[1], expected); } @@ -172,50 +173,11 @@ fn cluster_creation_supports_default_storage_s3_and_independent_database_access( #[test] fn invalid_storage_flags_fail_before_any_api_request() { - for (command, flags, error) in [ - ( - vec!["database", "create", "analytics"], - vec!["--s3-data-bucket", "customer-data"], - "--s3-backups-bucket", - ), - ( - vec![ - "cluster", - "create", - "--name", - "production", - "--replicas", - "1", - "--min-size", - "2:8", - ], - vec!["--database-s3-external-id", "rawtree-example"], - "--database-bucket-tag", - ), - ( - vec![ - "cluster", - "create", - "--name", - "production", - "--replicas", - "1", - "--min-size", - "2:8", - ], - [ - S3_ARGS, - &[ - "--database-s3-external-id", - "different-id", - "--database-bucket-tag", - "customer-buckets", - ], - ] - .concat(), - "External IDs do not match", - ), - ] { + for (command, flags, error) in [( + vec!["database", "create", "analytics"], + vec!["--s3-data-bucket", "customer-data"], + "--s3-backup-bucket", + )] { let mut args = vec!["--org", "team", "--cluster", "prod"]; args.extend(command); args.extend(flags); @@ -266,7 +228,8 @@ fn cluster_list_and_status_show_storage_without_an_organization_envelope() { &response }; assert_eq!(item["s3_storage"], cluster()["s3_storage"]); - assert_eq!(item["database_s3_access"], cluster()["database_s3_access"]); + assert_eq!(item["external_id"], "rawtree-example"); + assert_eq!(item["resource_tag"], "customer-buckets"); assert!(item["s3_storage"].get("role_arn").is_none()); } else { assert!(stdout.contains("customer-owned S3"), "{stdout}"); @@ -275,3 +238,114 @@ fn cluster_list_and_status_show_storage_without_an_organization_envelope() { } } } + +#[test] +fn obsolete_identity_and_plural_backup_flags_are_rejected() { + for flag in [ + "--s3-external-id", + "--database-s3-external-id", + "--database-bucket-tag", + "--s3-backups-bucket", + "--external-id", + "--resource-tag", + ] { + let (output, _, bodies) = common::run_cli( + &[], + &["database", "create", "analytics", flag, "obsolete"], + &json!({}), + ); + assert!(!output.status.success(), "{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["external_id"] = Value::Null; + managed["resource_tag"] = 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 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 external_id")); + assert!(bodies[1].get("external_id").is_none()); + assert!(bodies[1].get("resource_tag").is_none()); +} From 65f1c08acbf2e32705238a96562ef1997b3fbbbc Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Mon, 19 Oct 2026 17:41:59 +0200 Subject: [PATCH 4/6] Use nested S3 storage access and scoped identity flags --- README.md | 16 ++++---- src/cli/tests.rs | 3 +- src/cluster_identity.rs | 4 +- src/commands/cluster.rs | 51 +++++++++++++++++------- src/commands/cluster/storage_tests.rs | 12 ++++-- tests/live_platform/storage.rs | 14 +++++-- tests/storage_contracts.rs | 56 +++++++++++++++++---------- 7 files changed, 105 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 2b3a53a..f1d250a 100644 --- a/README.md +++ b/README.md @@ -387,14 +387,14 @@ rtree cluster create \ --s3-backup-bucket acme-rawtree-backups \ --s3-backup-path rawtree/backups \ --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ - --external-id rawtree-example + --s3-storage-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 \ - --external-id rawtree-database-access \ - --resource-tag rawtree-customer-database + --s3-storage-external-id rawtree-database-access \ + --s3-storage-resource-tag rawtree-customer-database rtree cluster use production rtree cluster status production rtree cluster update production --idle-timeout-minutes 60 @@ -412,12 +412,12 @@ 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 `--external-id` and `--resource-tag` when supplied. +Cluster creation forwards `--s3-storage-external-id` and `--s3-storage-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 `--external-id` to match the customer IAM role's -trust policy. `--resource-tag` may be supplied or generated. +Custom cluster storage requires `--s3-storage-external-id` to match the customer IAM role's +trust policy. `--s3-storage-resource-tag` may be supplied or generated. Cluster and database creation share `--s3-data-bucket`, `--s3-backup-bucket`, and `--s3-role-arn`; all three are required if any S3 option is supplied. The optional @@ -442,8 +442,8 @@ inherits its cluster's storage. The API payload has a flat `s3_storage` object containing `data_bucket`, `data_path`, `backup_bucket`, `backup_path`, and `role_arn`. List/status JSON -preserves the flat bucket/path metadata and top-level cluster `external_id` and -`resource_tag`; role ARNs and credentials are not returned. Older clusters without +preserves the flat bucket/path metadata and 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 old `--s3-external-id`, `--database-s3-external-id`, `--database-bucket-tag`, diff --git a/src/cli/tests.rs b/src/cli/tests.rs index d614f96..ce99527 100644 --- a/src/cli/tests.rs +++ b/src/cli/tests.rs @@ -97,8 +97,7 @@ fn global_api_key_parses_before_subcommand() { #[test] fn cluster_list_parses() { - let cli = - Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); + let cli = Cli::try_parse_from(["rtree", "cluster", "list"]).expect("cluster list should parse"); assert!(matches!( cli.command, Command::Cluster { diff --git a/src/cluster_identity.rs b/src/cluster_identity.rs index 9bdb18e..d226a8e 100644 --- a/src/cluster_identity.rs +++ b/src/cluster_identity.rs @@ -3,9 +3,9 @@ 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, value_name = "ID")] + #[arg(long = "s3-storage-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, value_name = "TAG")] + #[arg(long = "s3-storage-resource-tag", value_name = "TAG")] pub(crate) resource_tag: Option, } diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index 148b76c..e0ba7da 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -46,12 +46,16 @@ struct ClusterItem { #[serde(default)] s3_storage: Option, #[serde(default)] - external_id: Option, - #[serde(default)] - resource_tag: Option, + 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, @@ -162,11 +166,19 @@ pub fn create( ); println!( "External ID: {}", - created.external_id.as_deref().unwrap_or("not configured") + created + .s3_storage_access + .as_ref() + .map(|access| access.external_id.as_str()) + .unwrap_or("not configured") ); println!( "Resource tag: {}", - created.resource_tag.as_deref().unwrap_or("not configured") + 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.", @@ -279,7 +291,13 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> cluster.s3_storage.as_ref(), "RawTree-managed", )), - Cell::new(cluster.resource_tag.as_deref().unwrap_or("—")), + 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), ]); @@ -376,11 +394,19 @@ pub fn status( ); println!( "External ID: {}", - cluster.external_id.as_deref().unwrap_or("not configured") + cluster + .s3_storage_access + .as_ref() + .map(|access| access.external_id.as_str()) + .unwrap_or("not configured") ); println!( "Resource tag: {}", - cluster.resource_tag.as_deref().unwrap_or("not configured") + 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}"); @@ -548,10 +574,10 @@ fn create_request_body( }, }); if let Some(external_id) = identity.external_id { - body["external_id"] = json!(external_id); + body["s3_storage_access"]["external_id"] = json!(external_id); } if let Some(resource_tag) = identity.resource_tag { - body["resource_tag"] = json!(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); @@ -792,7 +818,7 @@ mod tests { "max_size": {"cpu_cores": 64, "memory_gib": 256} }, "idle_timeout_minutes": 30, - "external_id": "rawtree-test", "resource_tag": "test-tag" + "s3_storage_access": {"external_id": "rawtree-test", "resource_tag": "test-tag"} }) ); } @@ -811,8 +837,7 @@ mod tests { can_pause: true, can_resume: false, s3_storage: None, - external_id: None, - resource_tag: None, + s3_storage_access: None, idle_timeout_minutes: 15, } } diff --git a/src/commands/cluster/storage_tests.rs b/src/commands/cluster/storage_tests.rs index 8bec33f..94f5db5 100644 --- a/src/commands/cluster/storage_tests.rs +++ b/src/commands/cluster/storage_tests.rs @@ -7,12 +7,18 @@ fn cluster_metadata_exposes_identity_and_flat_storage() { "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, - "external_id": "rawtree-example", "resource_tag": "customer-buckets", + "s3_storage_access": {"external_id": "rawtree-example", "resource_tag": "customer-buckets"}, "s3_storage": {"data_bucket": "customer-data", "data_path": "events", "backup_bucket": "customer-backups", "backup_path": "snapshots"}, "idle_timeout_minutes": 15 })).expect("cluster metadata"); - assert_eq!(cluster.external_id.as_deref(), Some("rawtree-example")); - assert_eq!(cluster.resource_tag.as_deref(), Some("customer-buckets")); + 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.backup_path, "snapshots"); diff --git a/tests/live_platform/storage.rs b/tests/live_platform/storage.rs index 40ace19..0785ed6 100644 --- a/tests/live_platform/storage.rs +++ b/tests/live_platform/storage.rs @@ -29,12 +29,20 @@ fn cluster_identity_is_generated_persisted_and_read_back() { 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[field].as_str().expect("saved identity"); + 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[field], created[field]); + assert_eq!( + readback["s3_storage_access"][field], + created["s3_storage_access"][field] + ); } - assert_ne!(created["external_id"], created["resource_tag"]); + 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 index 1ff1719..9606af1 100644 --- a/tests/storage_contracts.rs +++ b/tests/storage_contracts.rs @@ -29,7 +29,7 @@ fn cluster() -> Value { "data_bucket": "customer-data", "data_path": "", "backup_bucket": "customer-backups", "backup_path": "" }, - "external_id": "rawtree-example", "resource_tag": "customer-buckets" + "s3_storage_access": {"external_id": "rawtree-example", "resource_tag": "customer-buckets"} }) } @@ -60,11 +60,18 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() "snapshots/", ]); let (output, saved, bodies) = common::run_cli( - &[( - "POST /v1/databases?organization=team%20alpha&cluster=prod%2Feu".into(), - if success { "200 OK" } else { "400 Bad Request" }, - response.clone(), - )], + &[ + ( + "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, ); @@ -79,7 +86,10 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() expected["backup_path"] = json!("snapshots/"); assert_eq!( bodies, - vec![json!({"name": "analytics", "s3_storage": expected})] + vec![ + Value::Null, + json!({"name": "analytics", "s3_storage": expected}) + ] ); if success { assert_eq!( @@ -119,14 +129,14 @@ fn cluster_creation_forwards_optional_identity_and_flat_storage() { } if with_identity { args.extend_from_slice(&[ - "--external-id", + "--s3-storage-external-id", "rawtree-example", - "--resource-tag", + "--s3-storage-resource-tag", "customer-buckets", ]); } if with_storage && !with_identity { - args.extend_from_slice(&["--external-id", "rawtree-example"]); + args.extend_from_slice(&["--s3-storage-external-id", "rawtree-example"]); } let mut response = cluster(); if !with_storage { @@ -162,10 +172,10 @@ fn cluster_creation_forwards_optional_identity_and_flat_storage() { expected["s3_storage"] = storage(); } if with_identity || with_storage { - expected["external_id"] = json!("rawtree-example"); + expected["s3_storage_access"]["external_id"] = json!("rawtree-example"); } if with_identity { - expected["resource_tag"] = json!("customer-buckets"); + expected["s3_storage_access"]["resource_tag"] = json!("customer-buckets"); } assert_eq!(bodies[1], expected); } @@ -228,8 +238,11 @@ fn cluster_list_and_status_show_storage_without_an_organization_envelope() { &response }; assert_eq!(item["s3_storage"], cluster()["s3_storage"]); - assert_eq!(item["external_id"], "rawtree-example"); - assert_eq!(item["resource_tag"], "customer-buckets"); + 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}"); @@ -242,12 +255,14 @@ fn cluster_list_and_status_show_storage_without_an_organization_envelope() { #[test] fn obsolete_identity_and_plural_backup_flags_are_rejected() { for flag in [ + "--external-id", + "--resource-tag", "--s3-external-id", "--database-s3-external-id", "--database-bucket-tag", "--s3-backups-bucket", - "--external-id", - "--resource-tag", + "--s3-storage-external-id", + "--s3-storage-resource-tag", ] { let (output, _, bodies) = common::run_cli( &[], @@ -263,8 +278,7 @@ fn obsolete_identity_and_plural_backup_flags_are_rejected() { fn managed_cluster_storage_and_inherited_database_storage_have_distinct_labels() { let mut managed = cluster(); managed["s3_storage"] = Value::Null; - managed["external_id"] = Value::Null; - managed["resource_tag"] = Value::Null; + managed["s3_storage_access"] = Value::Null; for action in [ vec!["cluster", "list"], vec!["cluster", "status", "production"], @@ -323,7 +337,7 @@ fn cluster_identity_validation_errors_come_from_the_api() { ( "POST /v1/clusters?organization=team".into(), "400 Bad Request", - json!({"message": "Custom S3 storage requires external_id."}), + json!({"message": "Custom S3 storage requires s3_storage_access.external_id."}), ), ], &[ @@ -345,7 +359,9 @@ fn cluster_identity_validation_errors_come_from_the_api() { &json!({}), ); assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("requires external_id")); + 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()); } From b1b577ff7865881319328f1b2ce0fbc7cc131dc6 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Mon, 19 Oct 2026 18:00:02 +0200 Subject: [PATCH 5/6] Restore nested S3 destination payloads and metadata --- README.md | 7 ++++--- src/commands/cluster/storage_tests.rs | 8 ++++---- src/s3_storage.rs | 18 ++++++++++-------- tests/api_contracts.rs | 2 +- tests/storage_contracts.rs | 14 +++++++------- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f1d250a..e1aeb41 100644 --- a/README.md +++ b/README.md @@ -440,9 +440,10 @@ 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 a flat `s3_storage` object containing `data_bucket`, -`data_path`, `backup_bucket`, `backup_path`, and `role_arn`. List/status JSON -preserves the flat bucket/path metadata and cluster `s3_storage_access.external_id` and +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. diff --git a/src/commands/cluster/storage_tests.rs b/src/commands/cluster/storage_tests.rs index 94f5db5..09d28c3 100644 --- a/src/commands/cluster/storage_tests.rs +++ b/src/commands/cluster/storage_tests.rs @@ -2,13 +2,13 @@ use super::ClusterItem; use serde_json::json; #[test] -fn cluster_metadata_exposes_identity_and_flat_storage() { +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", "data_path": "events", "backup_bucket": "customer-backups", "backup_path": "snapshots"}, + "s3_storage": {"data": { "bucket": "customer-data", "path": "events" }, "backups": { "bucket": "customer-backups", "path": "snapshots" }}, "idle_timeout_minutes": 15 })).expect("cluster metadata"); assert_eq!( @@ -20,6 +20,6 @@ fn cluster_metadata_exposes_identity_and_flat_storage() { "customer-buckets" ); let storage = cluster.s3_storage.expect("storage metadata"); - assert_eq!(storage.data_bucket, "customer-data"); - assert_eq!(storage.backup_path, "snapshots"); + assert_eq!(storage.data.bucket, "customer-data"); + assert_eq!(storage.backups.path, "snapshots"); } diff --git a/src/s3_storage.rs b/src/s3_storage.rs index d7b39a2..5695507 100644 --- a/src/s3_storage.rs +++ b/src/s3_storage.rs @@ -42,10 +42,8 @@ impl S3StorageArgs { let role_arn = required_s3_value(&self.s3_role_arn, "--s3-role-arn")?; Ok(Some(json!({ - "data_bucket": data_bucket, - "data_path": self.s3_data_path.as_deref().unwrap_or(""), - "backup_bucket": backups_bucket, - "backup_path": self.s3_backup_path.as_deref().unwrap_or(""), + "data": { "bucket": data_bucket, "path": self.s3_data_path.as_deref().unwrap_or("") }, + "backups": { "bucket": backups_bucket, "path": self.s3_backup_path.as_deref().unwrap_or("") }, "role_arn": role_arn, }))) } @@ -70,8 +68,12 @@ fn required_s3_value<'a>(value: &'a Option, flag: &str) -> Result<&'a st #[derive(Clone, Deserialize, Serialize)] pub(crate) struct S3StorageMetadata { - pub(crate) data_bucket: String, - pub(crate) data_path: String, - pub(crate) backup_bucket: String, - pub(crate) backup_path: String, + 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 85bb2dc..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", "data_path": "prefix/", "backup_bucket": "backups", "backup_path": ""}} + {"name": "analytics", "s3_storage": {"data": { "bucket": "data-bucket", "path": "prefix/" }, "backups": { "bucket": "backups", "path": "" }}} ]}); let (result, _, _) = run( &["database", "list"], diff --git a/tests/storage_contracts.rs b/tests/storage_contracts.rs index 9606af1..8be1092 100644 --- a/tests/storage_contracts.rs +++ b/tests/storage_contracts.rs @@ -13,8 +13,8 @@ const S3_ARGS: &[&str] = &[ fn storage() -> Value { json!({ - "data_bucket": "customer-data", "data_path": "", - "backup_bucket": "customer-backups", "backup_path": "", + "data": { "bucket": "customer-data", "path": "" }, + "backups": { "bucket": "customer-backups", "path": "" }, "role_arn": "arn:aws:iam::123456789012:role/RawTreeS3Access" }) } @@ -26,8 +26,8 @@ fn cluster() -> Value { "resources": null, "can_pause": false, "can_resume": false, "idle_timeout_minutes": 15, "s3_storage": { - "data_bucket": "customer-data", "data_path": "", - "backup_bucket": "customer-backups", "backup_path": "" + "data": { "bucket": "customer-data", "path": "" }, + "backups": { "bucket": "customer-backups", "path": "" } }, "s3_storage_access": {"external_id": "rawtree-example", "resource_tag": "customer-buckets"} }) @@ -82,8 +82,8 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() String::from_utf8_lossy(&output.stderr) ); let mut expected = storage(); - expected["data_path"] = json!("events/"); - expected["backup_path"] = json!("snapshots/"); + expected["data"]["path"] = json!("events/"); + expected["backups"]["path"] = json!("snapshots/"); assert_eq!( bodies, vec![ @@ -107,7 +107,7 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() } #[test] -fn cluster_creation_forwards_optional_identity_and_flat_storage() { +fn cluster_creation_forwards_optional_identity_and_nested_storage() { for (with_storage, with_identity) in [(false, false), (true, false), (false, true), (true, true)] { From 29099a2e367a16357e2021281c33dee20dd46d43 Mon Sep 17 00:00:00 2001 From: Rafa Moreno Date: Mon, 19 Oct 2026 18:28:28 +0200 Subject: [PATCH 6/6] Align S3 storage CLI flags with API field paths --- README.md | 51 +++++++++++++++++----------- src/cluster_identity.rs | 4 +-- src/s3_storage.rs | 38 +++++++++++---------- tests/storage_contracts.rs | 68 +++++++++++++++++++++++++++----------- 4 files changed, 104 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index e1aeb41..d7b94ff 100644 --- a/README.md +++ b/README.md @@ -382,19 +382,19 @@ rtree cluster create \ --name customer-data \ --replicas 1 \ --min-size 2:8 \ - --s3-data-bucket acme-rawtree-data \ - --s3-data-path rawtree/data \ - --s3-backup-bucket acme-rawtree-backups \ - --s3-backup-path rawtree/backups \ - --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access \ - --s3-storage-external-id rawtree-example + --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-external-id rawtree-database-access \ - --s3-storage-resource-tag rawtree-customer-database + --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 @@ -412,22 +412,34 @@ 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-external-id` and `--s3-storage-resource-tag` when supplied. +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-external-id` to match the customer IAM role's -trust policy. `--s3-storage-resource-tag` may be supplied or generated. +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. -Cluster and database creation share `--s3-data-bucket`, `--s3-backup-bucket`, and -`--s3-role-arn`; all three are required if any S3 option is supplied. The optional -`--s3-data-path` and `--s3-backup-path` specify object-key prefixes. +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-data-bucket acme-rawtree-data \ - --s3-backup-bucket acme-rawtree-backups \ - --s3-role-arn arn:aws:iam::123456789012:role/RawTreeS3Access + --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 @@ -447,8 +459,9 @@ 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 old `--s3-external-id`, `--database-s3-external-id`, `--database-bucket-tag`, -and plural `--s3-backups-*` flags are removed. This CLI targets +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. diff --git a/src/cluster_identity.rs b/src/cluster_identity.rs index d226a8e..5314746 100644 --- a/src/cluster_identity.rs +++ b/src/cluster_identity.rs @@ -3,9 +3,9 @@ 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-external-id", value_name = "ID")] + #[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-resource-tag", value_name = "TAG")] + #[arg(long = "s3-storage-access-resource-tag", value_name = "TAG")] pub(crate) resource_tag: Option, } diff --git a/src/s3_storage.rs b/src/s3_storage.rs index 5695507..ec546f4 100644 --- a/src/s3_storage.rs +++ b/src/s3_storage.rs @@ -5,31 +5,31 @@ use serde_json::{json, Value}; #[derive(Args, Clone, Debug, Default)] pub(crate) struct S3StorageArgs { - /// Customer-owned S3 data bucket; requires --s3-backup-bucket and --s3-role-arn + /// Customer-owned S3 data bucket; requires --s3-storage-backups-bucket and --s3-storage-role-arn #[arg(long, value_name = "BUCKET")] - pub(crate) s3_data_bucket: Option, + pub(crate) s3_storage_data_bucket: Option, /// Optional path prefix within the data bucket #[arg(long, value_name = "PATH")] - pub(crate) s3_data_path: Option, + pub(crate) s3_storage_data_path: Option, /// Customer-owned S3 bucket for backups #[arg(long, value_name = "BUCKET")] - pub(crate) s3_backup_bucket: Option, + pub(crate) s3_storage_backups_bucket: Option, /// Optional path prefix within the backups bucket #[arg(long, value_name = "PATH")] - pub(crate) s3_backup_path: Option, + pub(crate) s3_storage_backups_path: Option, /// IAM role ARN RawTree should assume #[arg(long, value_name = "ARN")] - pub(crate) s3_role_arn: Option, + pub(crate) s3_storage_role_arn: Option, } impl S3StorageArgs { pub(crate) fn to_json(&self) -> Result> { let configured = [ - &self.s3_data_bucket, - &self.s3_data_path, - &self.s3_backup_bucket, - &self.s3_backup_path, - &self.s3_role_arn, + &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()); @@ -37,13 +37,17 @@ impl S3StorageArgs { return Ok(None); } - let data_bucket = required_s3_value(&self.s3_data_bucket, "--s3-data-bucket")?; - let backups_bucket = required_s3_value(&self.s3_backup_bucket, "--s3-backup-bucket")?; - let role_arn = required_s3_value(&self.s3_role_arn, "--s3-role-arn")?; + 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_data_path.as_deref().unwrap_or("") }, - "backups": { "bucket": backups_bucket, "path": self.s3_backup_path.as_deref().unwrap_or("") }, + "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, }))) } @@ -62,7 +66,7 @@ pub(crate) fn format_storage( 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-data-bucket, --s3-backup-bucket, and --s3-role-arn. Missing {flag}.") + anyhow::anyhow!("S3 storage is incomplete. Provide --s3-storage-data-bucket, --s3-storage-backups-bucket, and --s3-storage-role-arn. Missing {flag}.") }) } diff --git a/tests/storage_contracts.rs b/tests/storage_contracts.rs index 8be1092..295e374 100644 --- a/tests/storage_contracts.rs +++ b/tests/storage_contracts.rs @@ -3,11 +3,11 @@ mod common; use serde_json::{json, Value}; const S3_ARGS: &[&str] = &[ - "--s3-data-bucket", + "--s3-storage-data-bucket", "customer-data", - "--s3-backup-bucket", + "--s3-storage-backups-bucket", "customer-backups", - "--s3-role-arn", + "--s3-storage-role-arn", "arn:aws:iam::123456789012:role/RawTreeS3Access", ]; @@ -54,9 +54,9 @@ fn database_storage_create_uses_current_response_and_persists_only_on_success() ]; args.extend_from_slice(S3_ARGS); args.extend_from_slice(&[ - "--s3-data-path", + "--s3-storage-data-path", "events/", - "--s3-backup-path", + "--s3-storage-backups-path", "snapshots/", ]); let (output, saved, bodies) = common::run_cli( @@ -129,14 +129,14 @@ fn cluster_creation_forwards_optional_identity_and_nested_storage() { } if with_identity { args.extend_from_slice(&[ - "--s3-storage-external-id", + "--s3-storage-access-external-id", "rawtree-example", - "--s3-storage-resource-tag", + "--s3-storage-access-resource-tag", "customer-buckets", ]); } if with_storage && !with_identity { - args.extend_from_slice(&["--s3-storage-external-id", "rawtree-example"]); + args.extend_from_slice(&["--s3-storage-access-external-id", "rawtree-example"]); } let mut response = cluster(); if !with_storage { @@ -185,8 +185,8 @@ fn cluster_creation_forwards_optional_identity_and_nested_storage() { fn invalid_storage_flags_fail_before_any_api_request() { for (command, flags, error) in [( vec!["database", "create", "analytics"], - vec!["--s3-data-bucket", "customer-data"], - "--s3-backup-bucket", + vec!["--s3-storage-data-bucket", "customer-data"], + "--s3-storage-backups-bucket", )] { let mut args = vec!["--org", "team", "--cluster", "prod"]; args.extend(command); @@ -253,24 +253,54 @@ fn cluster_list_and_status_show_storage_without_an_organization_envelope() { } #[test] -fn obsolete_identity_and_plural_backup_flags_are_rejected() { - for flag in [ +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 (output, _, bodies) = common::run_cli( - &[], - &["database", "create", "analytics", flag, "obsolete"], - &json!({}), - ); - assert!(!output.status.success(), "{flag}"); - assert!(bodies.is_empty()); + 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()); + } } }