diff --git a/Cargo.lock b/Cargo.lock index ef825c3..e25e84c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,7 +1502,7 @@ dependencies = [ [[package]] name = "rawtree-cli" -version = "0.7.0" +version = "0.7.1" dependencies = [ "anyhow", "axoupdater", diff --git a/Cargo.toml b/Cargo.toml index f56d1b5..c411f5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rawtree-cli" -version = "0.7.0" +version = "0.7.1" edition = "2021" repository = "https://github.com/rawtreedb/rawtree-cli" diff --git a/README.md b/README.md index 5a83e10..a6de5ec 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,18 @@ cargo build --release ./target/release/rtree --help ``` +## Create a cluster in a region + +Use a user login and select a region from the organization's catalog: + +```sh +rtree --org acme cluster regions +rtree --org acme cluster sizes --region-id +rtree --org acme cluster create --region-id --name production --replicas 2 --min-size 2:8 +``` + +Size discovery and creation use the selected region's deployment. Omitting `--region-id` preserves the default backend. A selected region without a valid deployment is rejected, except for the legacy public US East region. API keys keep their embedded routing and cannot override it with `--region-id`. + ## Update If you installed with the GitHub Releases installer, update in place: diff --git a/src/cli.rs b/src/cli.rs index 14ea328..5f617c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -313,12 +313,21 @@ pub enum ClusterCommand { /// List dedicated clusters List, /// List the CPU and memory pairs available for dedicated clusters - Sizes, + Sizes { + /// Region ID from `rtree cluster regions`; omit for the default backend + #[arg(long)] + region_id: Option, + }, + /// List regions available to the selected organization + Regions, /// Create a dedicated cluster #[command( after_help = "Sizes use CPU_CORES:MEMORY_GIB (for example, 2:8). Run `rtree cluster sizes` to list the available sizes." )] Create { + /// Region ID from `rtree cluster regions`; omit for the default backend + #[arg(long)] + region_id: Option, /// Cluster name #[arg(long)] name: String, @@ -717,7 +726,7 @@ mod tests { assert!(matches!( cli.command, Command::Cluster { - action: ClusterCommand::Sizes + action: ClusterCommand::Sizes { region_id: None } } )); } @@ -747,6 +756,7 @@ mod tests { name, replicas, idle_timeout_minutes: Some(30), + region_id: None, min_size, max_size: Some(max_size), } diff --git a/src/client.rs b/src/client.rs index f141a2a..9e29b91 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,3 +1,4 @@ +mod regions; mod routing; #[cfg(test)] mod routing_tests; @@ -23,6 +24,7 @@ pub struct ApiClient { pub token: Option, client: Client, routing: routing::RoutingCache, + deployment: Option<(String, String)>, } impl ApiClient { @@ -32,6 +34,7 @@ impl ApiClient { token, client: Client::new(), routing: routing::RoutingCache::default(), + deployment: None, } } @@ -40,6 +43,12 @@ impl ApiClient { self.client .request(method, format!("{}{}", self.base_url, path)), ); + let request = match &self.deployment { + Some((deployment, region)) => request + .header("X-RawTree-Deployment-Id", deployment) + .header("X-RawTree-Region", region), + None => request, + }; match &self.token { Some(token) => request.bearer_auth(token), None => request, diff --git a/src/client/regions.rs b/src/client/regions.rs new file mode 100644 index 0000000..d2c8980 --- /dev/null +++ b/src/client/regions.rs @@ -0,0 +1,65 @@ +use super::{routing, ApiClient}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize)] +pub struct Region { + pub id: String, + pub name: String, + pub cloud_region: String, + pub organization_id: Option, + pub deployment_id: Option, + pub enabled: bool, +} + +#[derive(Deserialize, Serialize)] +pub struct Regions { + pub regions: Vec, +} + +impl ApiClient { + pub fn list_regions(&self, organization: &str) -> Result { + let id = self.organization_id(organization)?; + self.get(&format!( + "/v1/organizations/{}/regions", + urlencoding::encode(&id) + )) + } + + /// Scope creation and size discovery to the same validated region, without mutating this client. + pub fn for_region(&self, organization: Option<&str>, region_id: Option<&str>) -> Result { + let deployment = match region_id { + None => None, + Some(id) => { + anyhow::ensure!( + !self + .token + .as_deref() + .is_some_and(|token| token.starts_with("rt_")), + "Region selection requires a user credential. Run `rtree login`." + ); + let organization = organization + .context("Choose an organization with --org when selecting a region.")?; + let region = self + .list_regions(organization)? + .regions + .into_iter() + .find(|region| region.id == id) + .context("Region not found in this organization.")?; + anyhow::ensure!(region.enabled, "The selected region is disabled."); + match region.deployment_id { + Some(deployment) if routing::valid_routing(&deployment, ®ion.cloud_region) => Some((deployment, region.cloud_region)), + None if region.id == "0c0f0a56-7f03-4d39-8e5d-000000000001" && region.organization_id.is_none() && region.cloud_region == "us-east-1" => None, + _ => anyhow::bail!("The selected region is not ready for cluster creation. Choose another region."), + } + } + }; + Ok(Self { + base_url: self.base_url.clone(), + token: self.token.clone(), + client: self.client.clone(), + routing: routing::RoutingCache::default(), + deployment, + }) + } +} diff --git a/src/client/routing.rs b/src/client/routing.rs index bef8ed5..067b8e2 100644 --- a/src/client/routing.rs +++ b/src/client/routing.rs @@ -61,6 +61,16 @@ impl ApiClient { } } + pub(super) fn organization_id(&self, name: &str) -> Result { + let response: Organizations = self.get("/v1/organizations")?; + response + .organizations + .into_iter() + .find(|organization| organization.name == name) + .map(|organization| organization.organization_id) + .context("Organization not found.") + } + fn organization_name(&self, id: &str, deadline: Option) -> Result { if let Some((time, organizations)) = self.routing.organizations.borrow().as_ref() { if time.elapsed() < Duration::from_secs(30) { @@ -237,7 +247,7 @@ fn scope(url: &Url) -> Option<(OrganizationSelector, ClusterSelector)> { } // Match the backend/worker syntax; do not maintain a catalog of AWS regions. -fn valid_routing(deployment: &str, region: &str) -> bool { +pub(super) fn valid_routing(deployment: &str, region: &str) -> bool { let mut parts = region.rsplit('-'); let number = parts.next().unwrap_or_default(); let names: Vec<_> = parts.collect(); diff --git a/src/client/routing_tests.rs b/src/client/routing_tests.rs index 33d4b61..27dba86 100644 --- a/src/client/routing_tests.rs +++ b/src/client/routing_tests.rs @@ -268,3 +268,96 @@ fn explicit_id_and_name_selectors_cannot_collide() { assert_eq!(requests[3].headers["x-rawtree-region"], "eu-west-1"); assert_eq!(requests[4].headers["x-rawtree-region"], "ap-south-1"); } + +fn region_catalog(overrides: Value) -> Vec<(&'static str, Value)> { + let mut region = json!({"id":"region-id", "name":"Europe", "cloud_region":"eu-central-1", "organization_id":null, "deployment_id":"abcd1234", "enabled":true}); + region + .as_object_mut() + .unwrap() + .extend(overrides.as_object().unwrap().clone()); + vec![ + ( + "200 OK", + json!({"organizations":[{"organization_id":ORG,"name":"acme"}]}), + ), + ("200 OK", json!({"regions":[region]})), + ] +} + +#[test] +fn creation_and_sizes_use_selected_region_without_mutating_default_client() { + let mut responses = region_catalog(json!({})); + responses.extend([ + ("200 OK", json!({"sizes":[]})), + ("200 OK", json!({"id":"new"})), + ("200 OK", json!({"clusters":[]})), + ]); + let (base, server) = server(responses); + let client = ApiClient::new(base, Some("session.fixture".into())); + let regional = client.for_region(Some("acme"), Some("region-id")).unwrap(); + let _: Value = regional.get("/v1/clusters/sizes").unwrap(); + let _: Value = regional + .post( + "/v1/clusters?organization=acme", + &json!({"region_id":"region-id"}), + ) + .unwrap(); + let _: Value = client.get("/v1/clusters?organization=acme").unwrap(); + let requests = server.join().unwrap(); + assert_eq!( + requests[1].path, + format!("GET /v1/organizations/{ORG}/regions HTTP/1.1") + ); + for request in &requests[2..4] { + assert_eq!(request.headers["x-rawtree-deployment-id"], "abcd1234"); + assert_eq!(request.headers["x-rawtree-region"], "eu-central-1"); + assert_eq!(request.headers["authorization"], "Bearer session.fixture"); + } + for index in [0, 1, 4] { + assert!(!requests[index].headers.contains_key("x-rawtree-region")); + } +} + +#[test] +fn explicit_unusable_regions_never_reach_creation() { + for overrides in [ + json!({"enabled":false}), + json!({"deployment_id":null}), + json!({"deployment_id":"bad"}), + json!({"cloud_region":"bad"}), + json!({"id":"other"}), + ] { + let (base, server) = server(region_catalog(overrides)); + let client = ApiClient::new(base, Some("session.fixture".into())); + assert!(client.for_region(Some("acme"), Some("region-id")).is_err()); + assert_eq!(server.join().unwrap().len(), 2); + } +} + +#[test] +fn legacy_region_and_omitted_selection_preserve_default_routing() { + let id = "0c0f0a56-7f03-4d39-8e5d-000000000001"; + let mut responses = + region_catalog(json!({"id":id,"deployment_id":null,"cloud_region":"us-east-1"})); + responses.extend([("200 OK", json!({})), ("200 OK", json!({}))]); + let (base, server) = server(responses); + let client = ApiClient::new(base, Some("session.fixture".into())); + for selected in [Some(id), None] { + let regional = client.for_region(Some("acme"), selected).unwrap(); + let _: Value = regional.get("/v1/clusters/sizes").unwrap(); + } + for request in server.join().unwrap() { + assert!(!request.headers.contains_key("x-rawtree-region")); + } +} + +#[test] +fn api_keys_cannot_override_their_embedded_deployment() { + let client = ApiClient::new("http://127.0.0.1:1".into(), Some("rt_fixture".into())); + assert!(client + .for_region(Some("acme"), Some("region-id")) + .err() + .unwrap() + .to_string() + .contains("user credential")); +} diff --git a/src/commands/cluster.rs b/src/commands/cluster.rs index 32798ab..3cfaf4b 100644 --- a/src/commands/cluster.rs +++ b/src/commands/cluster.rs @@ -116,7 +116,8 @@ pub fn create( options: ClusterCreateOptions<'_>, json_mode: bool, ) -> Result<()> { - let (_, sizes) = load_cluster_sizes(client)?; + let client = client.for_region(options.organization, options.region_id)?; + let (_, sizes) = load_cluster_sizes(&client)?; let (min_index, min_size) = resolve_cluster_size(&sizes.sizes, options.min_size)?; let (max_index, max_size) = options .max_size @@ -127,7 +128,7 @@ pub fn create( anyhow::bail!("Maximum size must be at least as large as the minimum size"); } - let body = create_request_body( + let mut body = create_request_body( options.name, options.replicas, min_size, @@ -135,6 +136,9 @@ pub fn create( options.idle_timeout_minutes, ); + if let Some(region_id) = options.region_id { + body["region_id"] = json!(region_id); + } let value: Value = client.post(&clusters_collection_path(options.organization), &body)?; let created: ClusterItem = serde_json::from_value(value.clone()).context("invalid cluster response from server")?; @@ -154,6 +158,7 @@ pub fn create( } pub struct ClusterCreateOptions<'a> { + pub region_id: Option<&'a str>, pub organization: Option<&'a str>, pub name: &'a str, pub replicas: u32, @@ -257,6 +262,25 @@ pub fn list(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> Ok(()) } +pub fn regions(client: &ApiClient, organization: Option<&str>, json_mode: bool) -> Result<()> { + let response = + client.list_regions(organization.context("Choose an organization with --org.")?)?; + output::print_result(&response, json_mode, |response| { + let mut table = new_cli_table(); + table.set_header(vec!["ID", "Name", "Cloud region", "Enabled"]); + for region in &response.regions { + table.add_row(vec![ + region.id.clone(), + region.name.clone(), + region.cloud_region.clone(), + region.enabled.to_string(), + ]); + } + println!("{table}"); + }); + Ok(()) +} + pub fn sizes(client: &ApiClient, json_mode: bool) -> Result<()> { let (value, resp) = load_cluster_sizes(client)?; diff --git a/src/main.rs b/src/main.rs index 6dd6e55..688941e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -360,7 +360,7 @@ fn run(cli: Cli) -> Result { }, Command::Cluster { action } => { let effective_org = match &action { - ClusterCommand::Use { .. } | ClusterCommand::Sizes => None, + ClusterCommand::Use { .. } | ClusterCommand::Sizes { region_id: None } => None, _ => resolve_effective_org(&client, cli_org.clone()), }; match action { @@ -368,8 +368,16 @@ fn run(cli: Cli) -> Result { ClusterCommand::List => { commands::cluster::list(&client, effective_org.as_deref(), json) } - ClusterCommand::Sizes => commands::cluster::sizes(&client, json), + ClusterCommand::Regions => { + commands::cluster::regions(&client, effective_org.as_deref(), json) + } + ClusterCommand::Sizes { region_id } => { + let client = + client.for_region(effective_org.as_deref(), region_id.as_deref())?; + commands::cluster::sizes(&client, json) + } ClusterCommand::Create { + region_id, name, replicas, min_size, @@ -378,6 +386,7 @@ fn run(cli: Cli) -> Result { } => commands::cluster::create( &client, commands::cluster::ClusterCreateOptions { + region_id: region_id.as_deref(), organization: effective_org.as_deref(), name: &name, replicas, diff --git a/tests/region_creation.rs b/tests/region_creation.rs new file mode 100644 index 0000000..82a3e5f --- /dev/null +++ b/tests/region_creation.rs @@ -0,0 +1,84 @@ +mod common; +use serde_json::{json, Value}; + +fn discovery() -> Vec<(String, &'static str, Value)> { + vec![ + ( + "GET /v1/organizations".into(), + "200 OK", + json!({"organizations":[{"organization_id":"org-id","name":"acme"}]}), + ), + ( + "GET /v1/organizations/org-id/regions".into(), + "200 OK", + json!({"regions":[{"id":"region-id","name":"Europe","cloud_region":"eu-central-1","deployment_id":"abcd1234","organization_id":null,"enabled":true}]}), + ), + ] +} + +#[test] +fn create_flag_routes_size_discovery_and_preserves_region_in_body() { + let mut responses = discovery(); + responses.extend([ + ("GET /v1/clusters/sizes".into(), "200 OK", json!({"sizes":[{"cpu_cores":2,"memory_gib":8}]})), + ("POST /v1/clusters?organization=acme".into(), "200 OK", json!({"id":"new","name":"production","created_at":"2026-10-18T00:00:00Z","status":{"phase":"provisioning","ready":false,"message":null},"resources":null,"can_pause":false,"can_resume":false,"idle_timeout_minutes":30})), + ]); + let (output, _, bodies) = common::run_cli( + &responses, + &[ + "--org", + "acme", + "--json", + "cluster", + "create", + "--region-id", + "region-id", + "--name", + "production", + "--replicas", + "2", + "--min-size", + "2:8", + ], + &json!({"token":"session.fixture"}), + ); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(bodies[3]["region_id"], "region-id"); + assert_eq!(bodies[3]["size"], json!({"cpu_cores":2,"memory_gib":8})); + assert!(bodies[3].get("deployment_id").is_none()); +} + +#[test] +fn region_discovery_and_region_size_commands_are_available() { + for action in ["regions", "sizes"] { + let mut responses = discovery(); + let mut args = vec!["--org", "acme", "--json", "cluster", action]; + if action == "sizes" { + args.extend(["--region-id", "region-id"]); + responses.push(( + "GET /v1/clusters/sizes".into(), + "200 OK", + json!({"sizes":[]}), + )); + } + let (output, _, _) = + common::run_cli(&responses, &args, &json!({"token":"session.fixture"})); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let body: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert!(body + .get(if action == "regions" { + "regions" + } else { + "sizes" + }) + .is_some()); + } +}