use std::{
cmp::Ordering,
collections::{BTreeMap, HashMap},
fmt::Display,
};
use anyhow::{Context as _, Result, bail};
use colored::Colorize;
use country_emoji::flag;
use json_dotpath::DotPaths as _;
use serde_json::{Map, Value, json};
use crate::{
client::post_graphql,
config::Configs,
controllers::config::{DeployConfig, EnvironmentConfig, RegionConfig, ServiceInstance},
gql::queries,
util::prompt::{
prompt_select_with_cancel, prompt_u64_with_placeholder_and_validation_and_cancel,
},
};
pub const MAX_TOTAL_REPLICAS: u64 = 50;
pub struct PromptRegion(pub queries::regions::RegionsRegions, pub String);
impl Display for PromptRegion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.1)
}
}
pub async fn fetch_regions_for_project(
client: &reqwest::Client,
configs: &Configs,
project_id: Option<&str>,
) -> Result<queries::regions::ResponseData> {
let regions = post_graphql::<queries::Regions, _>(
client,
configs.get_backboard(),
queries::regions::Variables {
project_id: project_id.map(ToString::to_string),
},
)
.await?;
Ok(regions)
}
pub async fn fetch_region_locations(
client: &reqwest::Client,
configs: &Configs,
) -> HashMap<String, String> {
fetch_region_locations_for_project(client, configs, None).await
}
pub async fn fetch_region_locations_for_project(
client: &reqwest::Client,
configs: &Configs,
project_id: Option<&str>,
) -> HashMap<String, String> {
match fetch_regions_for_project(client, configs, project_id).await {
Ok(regions) => region_locations_from_regions(®ions.regions),
Err(_) => HashMap::new(),
}
}
pub fn region_locations_from_regions(
regions: &[queries::regions::RegionsRegions],
) -> HashMap<String, String> {
regions
.iter()
.filter(|r| !r.location.is_empty())
.flat_map(|r| {
let mut entries = vec![(r.name.clone(), r.location.clone())];
if let Some(region) = &r.region {
entries.push((region.clone(), r.location.clone()));
}
entries
})
.collect()
}
pub fn region_is_available(region: &queries::regions::RegionsRegions) -> bool {
!region
.deployment_constraints
.as_ref()
.and_then(|constraints| constraints.deprecation_info.as_ref())
.is_some_and(|deprecation_info| deprecation_info.is_deprecated)
}
pub fn region_friendly_name(region: &queries::regions::RegionsRegions) -> &str {
if region.location.is_empty() {
®ion.name
} else {
®ion.location
}
}
pub fn region_full_label(region: &queries::regions::RegionsRegions) -> String {
if let Some(provider_region) = ®ion.region {
format!(
"{} ({}, {})",
region_friendly_name(region),
provider_region,
region.country
)
} else {
format!("{} ({})", region_friendly_name(region), region.country)
}
}
pub fn region_flag_name(region: &queries::regions::RegionsRegions) -> String {
let mut slug = String::new();
let mut last_was_separator = false;
for ch in region_friendly_name(region).chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch.to_ascii_lowercase());
last_was_separator = false;
} else if !last_was_separator && !slug.is_empty() {
slug.push('-');
last_was_separator = true;
}
}
slug.trim_end_matches('-').to_string()
}
pub fn region_matches_input(region: &queries::regions::RegionsRegions, input: &str) -> bool {
region.name.eq_ignore_ascii_case(input)
|| region_flag_name(region) == input.to_ascii_lowercase()
|| region_friendly_name(region).eq_ignore_ascii_case(input)
}
pub fn resolve_deploy_region_id(
regions: &queries::regions::ResponseData,
input: &str,
) -> Result<String> {
let matches = regions
.regions
.iter()
.filter(|region| region_is_available(region) && region_matches_input(region, input))
.collect::<Vec<_>>();
match matches.as_slice() {
[region] => Ok(region.name.clone()),
[] => bail!(
"Unknown region `{}`. Available regions:\n{}",
input,
available_deploy_regions_help(®ions.regions)
),
regions => bail!(
"Region `{}` is ambiguous. Matching regions:\n{}",
input,
regions
.iter()
.map(|region| format!(
" {:<16} {}",
region_flag_name(region),
region_full_label(region)
))
.collect::<Vec<_>>()
.join("\n")
),
}
}
pub fn resolve_deploy_region_id_for_scale(
regions: &queries::regions::ResponseData,
input: &str,
replicas: u64,
existing: &Value,
) -> Result<String> {
match resolve_deploy_region_id(regions, input) {
Ok(region_id) => Ok(region_id),
Err(error) => {
if let Some((region_id, current_replicas)) = existing_region_replicas(existing, input)
&& replicas <= current_replicas
{
return Ok(region_id);
}
Err(error)
}
}
}
pub fn available_deploy_regions_help(regions: &[queries::regions::RegionsRegions]) -> String {
regions
.iter()
.filter(|region| region_is_available(region))
.map(|region| {
format!(
" {:<16} {}",
region_flag_name(region),
region_full_label(region)
)
})
.collect::<Vec<_>>()
.join("\n")
}
pub fn configured_replicas(region_config: &Value) -> u64 {
region_config
.get("numReplicas")
.and_then(Value::as_u64)
.unwrap_or(0)
}
pub fn total_replicas(region_data: &Value) -> u64 {
region_data
.as_object()
.map(|config| {
config.values().fold(0u64, |total, value| {
total.saturating_add(configured_replicas(value))
})
})
.unwrap_or(0)
}
pub fn validate_total_replicas(region_data: &Value) -> Result<()> {
let total = total_replicas(region_data);
if total > MAX_TOTAL_REPLICAS {
bail!(
"Cannot scale to {total} replicas. The maximum is {MAX_TOTAL_REPLICAS} total replicas across regions. Reduce the requested total by at least {} replicas.",
total.saturating_sub(MAX_TOTAL_REPLICAS)
);
}
Ok(())
}
fn existing_region_replicas(existing: &Value, input: &str) -> Option<(String, u64)> {
existing
.as_object()?
.iter()
.find(|(region, _)| region.eq_ignore_ascii_case(input))
.map(|(region, value)| (region.clone(), configured_replicas(value)))
}
pub fn region_display_name(region: &str, region_locations: &HashMap<String, String>) -> String {
region_locations
.get(region)
.cloned()
.or_else(|| friendly_region_fallback(region))
.unwrap_or_else(|| region.to_string())
}
pub fn format_region_replicas(
region_data: &Value,
region_locations: &HashMap<String, String>,
) -> String {
let mut regions = region_data
.as_object()
.map(|config| {
config
.iter()
.map(|(name, value)| {
let replicas = value
.get("numReplicas")
.and_then(Value::as_i64)
.unwrap_or(0);
(name.clone(), replicas)
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
regions.sort_by(|a, b| a.0.cmp(&b.0));
if regions.len() == 1 {
return format!(
"{} ({})",
region_display_name(®ions[0].0, region_locations),
regions[0].1
);
}
let sep = " · ".dimmed();
regions
.iter()
.map(|(name, replicas)| {
format!(
"{} ({})",
region_display_name(name, region_locations),
replicas.to_string().dimmed()
)
})
.collect::<Vec<_>>()
.join(&sep.to_string())
}
fn friendly_region_fallback(region: &str) -> Option<String> {
let normalized = region.to_ascii_lowercase();
let label = if normalized.starts_with("europe-west") {
"EU West"
} else if normalized.starts_with("europe-north") {
"EU North"
} else if normalized.starts_with("europe-south") {
"EU South"
} else if normalized.starts_with("europe-central") {
"EU Central"
} else if normalized.starts_with("us-west") || normalized.starts_with("northamerica-west") {
"US West"
} else if normalized.starts_with("us-east") || normalized.starts_with("northamerica-east") {
"US East"
} else if normalized.starts_with("us-central") || normalized.starts_with("northamerica-central")
{
"US Central"
} else if normalized.starts_with("asia-east") {
"Asia East"
} else if normalized.starts_with("asia-southeast") {
"Asia Southeast"
} else if normalized.starts_with("asia-south") {
"Asia South"
} else if normalized.starts_with("australia") {
"Australia"
} else if normalized.starts_with("southamerica") {
"South America"
} else {
return None;
};
Some(label.to_string())
}
pub fn prompt_for_regions_with_data(
mut regions: queries::regions::ResponseData,
existing: &Value,
) -> Result<HashMap<String, u64>> {
let mut updated: HashMap<String, u64> = HashMap::new();
loop {
let get_replicas_amount = |name: String| {
let before = if let Some(num) = existing.get(name.clone()) {
num.get("numReplicas").unwrap().as_u64().unwrap()
} else {
0
};
let after = if let Some(new_value) = updated.get(&name) {
*new_value
} else {
before
};
(before, after)
};
regions.regions.sort_by(|a, b| {
get_replicas_amount(b.name.clone())
.1
.cmp(&get_replicas_amount(a.name.clone()).1)
});
let region_options = regions
.regions
.iter()
.filter(|r| region_is_available(r))
.map(|f| {
PromptRegion(
f.clone(),
format!(
"{} {}{}",
flag(&f.country).unwrap_or_default(),
f.location,
{
let (before, after) = get_replicas_amount(f.name.clone());
let amount = format!(
" ({} replica{})",
after,
if after == 1 { "" } else { "s" }
);
match after.cmp(&before) {
Ordering::Equal if after == 0 => String::new().normal(),
Ordering::Equal => amount.yellow(),
Ordering::Greater => amount.green(),
Ordering::Less => amount.red(),
}
.to_string()
}
),
)
})
.collect::<Vec<PromptRegion>>();
let selected =
prompt_select_with_cancel("Select a region <esc to finish>", region_options)?;
if let Some(region) = selected {
let amount_before = if let Some(updated) = updated.get(®ion.0.name) {
*updated
} else if let Some(previous) = existing.as_object().unwrap().get(®ion.0.name) {
previous.get("numReplicas").unwrap().as_u64().unwrap()
} else {
0
};
let prompted = prompt_u64_with_placeholder_and_validation_and_cancel(
format!(
"Enter the amount of replicas for {} <esc to go back>",
region_friendly_name(®ion.0)
)
.as_str(),
amount_before.to_string().as_str(),
)?;
if let Some(prompted) = prompted {
let parse: u64 = prompted.parse()?;
updated.insert(region.0.name.clone(), parse);
}
} else {
break;
}
}
Ok(updated)
}
pub fn convert_hashmap_to_map(map: HashMap<String, u64>) -> Map<String, Value> {
map.iter().fold(Map::new(), |mut m, (key, val)| {
m.insert(
key.clone(),
if *val == 0 {
Value::Null } else {
json!({ "numReplicas": val })
},
);
m
})
}
pub fn merge_config(existing: Value, new_config: Map<String, Value>) -> Value {
let mut map = match existing {
Value::Object(object) => object,
_ => Map::new(),
};
map.extend(new_config);
Value::Object(map)
}
pub fn region_data_from_deploy(deploy: &Value) -> Result<Option<Value>> {
if let Some(c) = deploy.dot_get::<Value>("multiRegionConfig")? {
return Ok(Some(c));
}
if let Some(region) = deploy.dot_get::<String>("region")? {
let mut map = Map::new();
let replicas = deploy.dot_get::<Value>("numReplicas")?.unwrap_or(json!(1));
map.insert(region, json!({ "numReplicas": replicas }));
return Ok(Some(Value::Object(map)));
}
Ok(None)
}
pub fn region_data_from_deployment_meta(meta: &Value) -> Result<Option<Value>> {
let Some(deploy) = meta.dot_get::<Value>("serviceManifest.deploy")? else {
return Ok(None);
};
region_data_from_deploy(&deploy)
}
pub fn build_multi_region_patch(
service_id: &str,
region_data: &Value,
) -> Result<EnvironmentConfig> {
let multi_region_config: BTreeMap<String, Option<RegionConfig>> =
serde_json::from_value(region_data.clone())
.context("Failed to build environment patch for region config")?;
let mut services = BTreeMap::new();
services.insert(
service_id.to_string(),
ServiceInstance {
deploy: Some(DeployConfig {
multi_region_config: Some(multi_region_config),
..DeployConfig::default()
}),
..ServiceInstance::default()
},
);
Ok(EnvironmentConfig {
services,
..EnvironmentConfig::default()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BucketRegion {
Sjc,
Iad,
Ams,
Sin,
}
impl BucketRegion {
pub fn code(self) -> &'static str {
match self {
Self::Sjc => "sjc",
Self::Iad => "iad",
Self::Ams => "ams",
Self::Sin => "sin",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Sjc => "US West, California",
Self::Iad => "US East, Virginia",
Self::Ams => "EU West, Amsterdam",
Self::Sin => "Asia Pacific, Singapore",
}
}
pub fn country(self) -> &'static str {
match self {
Self::Sjc | Self::Iad => "US",
Self::Ams => "NL",
Self::Sin => "SG",
}
}
pub fn parse(input: &str) -> Result<Self> {
match input.trim().to_ascii_lowercase().as_str() {
"sjc" => Ok(Self::Sjc),
"iad" => Ok(Self::Iad),
"ams" => Ok(Self::Ams),
"sin" => Ok(Self::Sin),
_ => bail!("Invalid bucket region \"{input}\". Valid regions: sjc, iad, ams, sin."),
}
}
pub fn all() -> Vec<Self> {
vec![Self::Sjc, Self::Iad, Self::Ams, Self::Sin]
}
pub fn display_for_code(code: &str) -> String {
Self::parse(code)
.map(|region| region.to_string())
.unwrap_or_else(|_| code.to_string())
}
}
impl Display for BucketRegion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.code(), self.label())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn region(is_deprecated: Option<bool>) -> queries::regions::RegionsRegions {
queries::regions::RegionsRegions {
name: "us-west2".to_string(),
region: Some("us-west2".to_string()),
country: "US".to_string(),
location: "US West".to_string(),
workspace_id: None,
deployment_constraints: is_deprecated.map(|is_deprecated| {
queries::regions::RegionsRegionsDeploymentConstraints {
deprecation_info: Some(
queries::regions::RegionsRegionsDeploymentConstraintsDeprecationInfo {
is_deprecated,
replacement_region: "us-west2".to_string(),
},
),
}
}),
}
}
#[test]
fn deprecated_regions_are_not_available() {
assert!(!region_is_available(®ion(Some(true))));
}
#[test]
fn deprecated_existing_regions_can_be_scaled_down_by_id() {
let regions = queries::regions::ResponseData {
regions: vec![region(Some(true))],
};
let existing = json!({
"us-west2": { "numReplicas": 3 }
});
assert_eq!(
resolve_deploy_region_id_for_scale(®ions, "us-west2", 0, &existing).unwrap(),
"us-west2"
);
assert!(resolve_deploy_region_id_for_scale(®ions, "us-west2", 4, &existing).is_err());
}
#[test]
fn region_matching_accepts_friendly_slug_and_region_id() {
let region = region(None);
assert!(region_matches_input(®ion, "us-west"));
assert!(region_matches_input(®ion, "us-west2"));
assert!(region_matches_input(®ion, "US West"));
}
#[test]
fn legacy_deploy_region_config_returns_region_map() {
let deploy = json!({
"region": "us-west2",
"numReplicas": 3
});
assert_eq!(
region_data_from_deploy(&deploy).unwrap(),
Some(json!({
"us-west2": { "numReplicas": 3 }
}))
);
}
#[test]
fn deployment_meta_region_config_returns_multi_region_map() {
let meta = json!({
"serviceManifest": {
"deploy": {
"multiRegionConfig": {
"europe-west4-drams3a": { "numReplicas": 2 },
"us-east4-eqdc4a": { "numReplicas": 1 }
}
}
}
});
assert_eq!(
region_data_from_deployment_meta(&meta).unwrap(),
Some(json!({
"europe-west4-drams3a": { "numReplicas": 2 },
"us-east4-eqdc4a": { "numReplicas": 1 }
}))
);
}
#[test]
fn total_replicas_are_capped_at_fifty() {
let valid = json!({
"us-west2": { "numReplicas": 25 },
"europe-west4-drams3a": { "numReplicas": 25 },
"us-east4-eqdc4a": null
});
let invalid = json!({
"us-west2": { "numReplicas": 25 },
"europe-west4-drams3a": { "numReplicas": 26 }
});
assert_eq!(total_replicas(&valid), MAX_TOTAL_REPLICAS);
assert!(validate_total_replicas(&valid).is_ok());
assert!(validate_total_replicas(&invalid).is_err());
}
}