Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <REGION_ID>
rtree --org acme cluster create --region-id <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:
Expand Down
14 changes: 12 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
/// 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<String>,
/// Cluster name
#[arg(long)]
name: String,
Expand Down Expand Up @@ -717,7 +726,7 @@ mod tests {
assert!(matches!(
cli.command,
Command::Cluster {
action: ClusterCommand::Sizes
action: ClusterCommand::Sizes { region_id: None }
}
));
}
Expand Down Expand Up @@ -747,6 +756,7 @@ mod tests {
name,
replicas,
idle_timeout_minutes: Some(30),
region_id: None,
min_size,
max_size: Some(max_size),
}
Expand Down
9 changes: 9 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod regions;
mod routing;
#[cfg(test)]
mod routing_tests;
Expand All @@ -23,6 +24,7 @@ pub struct ApiClient {
pub token: Option<String>,
client: Client,
routing: routing::RoutingCache,
deployment: Option<(String, String)>,
}

impl ApiClient {
Expand All @@ -32,6 +34,7 @@ impl ApiClient {
token,
client: Client::new(),
routing: routing::RoutingCache::default(),
deployment: None,
}
}

Expand All @@ -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,
Expand Down
65 changes: 65 additions & 0 deletions src/client/regions.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub deployment_id: Option<String>,
pub enabled: bool,
}

#[derive(Deserialize, Serialize)]
pub struct Regions {
pub regions: Vec<Region>,
}

impl ApiClient {
pub fn list_regions(&self, organization: &str) -> Result<Regions> {
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<Self> {
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, &region.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,
})
}
}
12 changes: 11 additions & 1 deletion src/client/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ impl ApiClient {
}
}

pub(super) fn organization_id(&self, name: &str) -> Result<String> {
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<Instant>) -> Result<String> {
if let Some((time, organizations)) = self.routing.organizations.borrow().as_ref() {
if time.elapsed() < Duration::from_secs(30) {
Expand Down Expand Up @@ -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();
Expand Down
93 changes: 93 additions & 0 deletions src/client/routing_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
28 changes: 26 additions & 2 deletions src/commands/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -127,14 +128,17 @@ 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,
max_size,
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")?;
Expand All @@ -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,
Expand Down Expand Up @@ -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)?;

Expand Down
13 changes: 11 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -360,16 +360,24 @@ fn run(cli: Cli) -> Result<ExitCode> {
},
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 {
ClusterCommand::Use { name } => commands::cluster::use_cluster(&name, json),
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,
Expand All @@ -378,6 +386,7 @@ fn run(cli: Cli) -> Result<ExitCode> {
} => commands::cluster::create(
&client,
commands::cluster::ClusterCreateOptions {
region_id: region_id.as_deref(),
organization: effective_org.as_deref(),
name: &name,
replicas,
Expand Down
Loading