use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
use rand::Rng;
use regex::Regex;
use crate::{
client::post_graphql,
controllers::{
config::{
ClusterWiring, DeployConfig, EnvironmentConfig, RegionConfig, ServiceInstance,
Variable, VolumeInstance, VolumeMount, fetch_environment_config,
},
database_engines::DatabaseEngine,
database_plugins,
project::ServiceContext,
template_apply::{self, format_data_node_entry, private_domain_ref},
},
errors::RailwayError,
gql::mutations,
};
const PATRONI_ENABLED_VAR: &str = "PATRONI_ENABLED";
pub struct ScaleClusterParams {
pub replicas: Option<i64>,
pub coordinators: Option<i64>,
pub edge: Option<i64>,
pub auto_deploy: bool,
pub live_primary_id: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ScaleDimensionSummary {
pub added: Vec<String>,
pub removed: Vec<String>,
}
impl ScaleDimensionSummary {
pub fn is_noop(&self) -> bool {
self.added.is_empty() && self.removed.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EdgeScaleSummary {
pub region: String,
pub previous_replicas: i64,
pub target_replicas: i64,
}
#[derive(Debug)]
pub struct ScaleClusterResult {
pub deployed: bool,
pub replicas: Option<ScaleDimensionSummary>,
pub coordinators: Option<ScaleDimensionSummary>,
pub edge: Option<EdgeScaleSummary>,
}
pub fn validate_odd_coordinator_count(target: i64) -> Result<()> {
if target % 2 == 0 {
bail!("--coordinators must be an odd number for consensus quorum (got {target})");
}
Ok(())
}
pub fn validate_data_node_quorum(wiring: &ClusterWiring, replicas_target: i64) -> Result<()> {
let votes_with_data_nodes =
wiring.quorum_variable.is_some() || wiring.data_nodes_are_quorum_voters.unwrap_or(false);
if !votes_with_data_nodes {
return Ok(());
}
let data_nodes = replicas_target + 1;
if data_nodes < 3 {
bail!(
"This cluster's data nodes carry the failover vote, so it needs at least 3 of them: \
use --replicas 2 or more (got {replicas_target}, for {data_nodes} data node(s))."
);
}
if data_nodes % 2 == 0 {
bail!(
"This cluster's data nodes carry the failover vote, so their total must be odd -- \
an even cluster cannot elect a primary after losing a node. --replicas counts nodes \
beside the primary, so pass an even number (got {replicas_target}, for {data_nodes} \
data nodes)."
);
}
Ok(())
}
pub async fn scale_cluster(
ctx: &ServiceContext,
engine: &DatabaseEngine,
root_id: &str,
root_name: &str,
names: &BTreeMap<String, String>,
params: ScaleClusterParams,
) -> Result<ScaleClusterResult> {
if let Some(target) = params.coordinators {
validate_odd_coordinator_count(target)?;
}
let config = fetch_environment_config(&ctx.client, &ctx.configs, &ctx.environment_id, true)
.await?
.config;
if let Some(target) = params.replicas
&& let Some(wiring) = config
.services
.get(root_id)
.and_then(resolve_cluster_wiring)
{
validate_data_node_quorum(&wiring, target)?;
}
let mut patch = ScalePatch::default();
let mut replicas_summary = None;
let mut coordinators_summary = None;
let mut edge_summary = None;
let mut fresh_replica_roster: Option<Vec<(String, String)>> = None;
if let Some(target) = params.replicas {
let (summary, roster) = scale_replicas(
ctx,
&config,
engine,
root_id,
root_name,
target,
names,
params.live_primary_id.as_deref(),
&mut patch,
)
.await?;
replicas_summary = Some(summary);
fresh_replica_roster = Some(roster);
}
if let Some(target) = params.coordinators {
coordinators_summary = Some(
scale_internal(
ctx,
&config,
root_id,
root_name,
target,
names,
fresh_replica_roster.as_deref(),
&mut patch,
)
.await?,
);
}
if let Some(target) = params.edge {
edge_summary = scale_edge(&config, engine, root_id, target, &mut patch.services)?;
}
let deployed = if patch.is_empty() {
false
} else {
let env_patch = EnvironmentConfig {
services: patch.services,
volumes: patch.volumes,
..EnvironmentConfig::default()
};
stage_and_commit(ctx, env_patch, params.auto_deploy).await?
};
Ok(ScaleClusterResult {
deployed,
replicas: replicas_summary,
coordinators: coordinators_summary,
edge: edge_summary,
})
}
#[derive(Default)]
struct ScalePatch {
services: BTreeMap<String, ServiceInstance>,
volumes: BTreeMap<String, VolumeInstance>,
}
impl ScalePatch {
fn is_empty(&self) -> bool {
self.services.is_empty() && self.volumes.is_empty()
}
}
async fn stage_and_commit(
ctx: &ServiceContext,
patch: EnvironmentConfig,
auto_deploy: bool,
) -> Result<bool> {
template_apply::warn_if_preexisting_staged_changes(ctx).await;
post_graphql::<mutations::EnvironmentStageChanges, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::environment_stage_changes::Variables {
environment_id: ctx.environment_id.clone(),
input: patch,
merge: Some(true),
},
)
.await
.context("Failed to stage cluster scale changes")?;
template_apply::commit_staged_patch(ctx, auto_deploy).await
}
#[allow(clippy::too_many_arguments)]
async fn scale_replicas(
ctx: &ServiceContext,
config: &EnvironmentConfig,
engine: &DatabaseEngine,
root_id: &str,
root_name: &str,
target_count: i64,
names: &BTreeMap<String, String>,
live_primary_id: Option<&str>,
patch: &mut ScalePatch,
) -> Result<(ScaleDimensionSummary, Vec<(String, String)>)> {
let root = config
.services
.get(root_id)
.with_context(|| format!("Service \"{root_name}\" not found in environment config"))?;
let mut existing = members_of_role(config, root_id, "replica", names);
let current_count = existing.len() as i64;
if target_count == current_count {
return Ok((ScaleDimensionSummary::default(), existing));
}
let wiring = resolve_cluster_wiring(root).with_context(|| {
"Could not resolve this cluster's scale wiring -- scaling would leave the connection \
routing list stale. The root service declares no `clusterWiring`."
.to_string()
})?;
let routing_edge_id = find_routing_edge_id(config, engine, root_id);
let summary = if target_count > current_count {
let Some((source_id, source_name)) = existing.first().cloned() else {
bail!(
"Cannot scale up replicas on {root_name}: there is no existing replica to clone \
from. Re-run `ha convert --replicas {target_count}` to add the first replica."
);
};
let source = config
.services
.get(&source_id)
.context("Replica disappeared from environment config mid-scale")?;
source
.source
.as_ref()
.and_then(|s| s.image.as_ref())
.context("Replica has no source image to clone")?;
let mount_path = source
.volume_mounts
.values()
.find_map(|m| m.mount_path.clone())
.context("Replica has no volume mount path to clone")?;
let base_name = derive_node_base_name(&source_name, "Replica");
let existing_names: Vec<String> = existing.iter().map(|(_, name)| name.clone()).collect();
let start_number = next_node_number(&existing_names, &base_name);
let to_add = target_count - current_count;
let mut added = Vec::with_capacity(to_add as usize);
let mut added_ids = Vec::with_capacity(to_add as usize);
for next_number in start_number..start_number + to_add {
let node_name = format!("{base_name}-{next_number}");
let node = create_clone_service(ctx, &node_name).await?;
let volume = create_clone_volume(ctx, &mount_path, &node.name).await?;
stage_new_member(
patch,
root_id,
root,
"replica",
&node,
source,
&volume,
&mount_path,
);
added_ids.push(node.id.clone());
existing.push((node.id.clone(), node.name.clone()));
added.push(node.name.clone());
}
(
ScaleDimensionSummary {
added,
removed: Vec::new(),
},
added_ids,
)
} else {
let to_remove = current_count - target_count;
let base_name = existing
.first()
.map(|(_, name)| derive_node_base_name(name, "Replica"))
.unwrap_or_else(|| "Replica".to_string());
let mut sorted = existing.clone();
sorted
.sort_by_key(|(_, name)| std::cmp::Reverse(node_number(name, &base_name).unwrap_or(0)));
let removable: Vec<(String, String)> = sorted
.into_iter()
.filter(|(id, _)| Some(id.as_str()) != live_primary_id)
.collect();
if (removable.len() as i64) < to_remove {
let primary_name = existing
.iter()
.find(|(id, _)| Some(id.as_str()) == live_primary_id)
.map(|(_, name)| name.as_str())
.unwrap_or("a replica");
bail!(
"Cannot scale down to {target_count} replica(s): {primary_name} is currently \
acting as the cluster's primary. Run `ha switchover --to {root_name}` first, \
then scale down."
);
}
let to_delete: Vec<(String, String)> =
removable.into_iter().take(to_remove as usize).collect();
for (id, _) in &to_delete {
stage_member_deletion(patch, config, id);
}
let removed: Vec<String> = to_delete.iter().map(|(_, name)| name.clone()).collect();
existing.retain(|(id, _)| !to_delete.iter().any(|(rid, _)| rid == id));
(
ScaleDimensionSummary {
added: Vec::new(),
removed,
},
Vec::new(),
)
};
let (summary, added_ids) = summary;
restamp_replica_wiring(
&mut patch.services,
&wiring,
root_name,
routing_edge_id.as_deref(),
&existing,
&added_ids,
);
Ok((summary, existing))
}
#[allow(clippy::too_many_arguments)]
fn stage_new_member(
patch: &mut ScalePatch,
root_id: &str,
root: &ServiceInstance,
role: &str,
node: &CreatedNode,
source: &ServiceInstance,
volume: &CreatedVolume,
mount_path: &str,
) {
patch.volumes.insert(
volume.id.clone(),
VolumeInstance {
is_created: Some(true),
..VolumeInstance::default()
},
);
patch.services.insert(
node.id.clone(),
ServiceInstance {
is_created: Some(true),
parent_service_id: Some(root_id.to_string()),
cluster_role: Some(role.to_string()),
group_id: root.group_id.clone(),
variables: source.variables.clone(),
source: source.source.clone(),
volume_mounts: BTreeMap::from([(
volume.id.clone(),
VolumeMount {
mount_path: Some(mount_path.to_string()),
..VolumeMount::default()
},
)]),
deploy: Some(DeployConfig {
required_mount_path: Some(mount_path.to_string()),
..source.deploy.clone().unwrap_or_default()
}),
..ServiceInstance::default()
},
);
}
fn stage_member_deletion(patch: &mut ScalePatch, config: &EnvironmentConfig, id: &str) {
if let Some(service) = config.services.get(id) {
for volume_id in service.volume_mounts.keys() {
patch.volumes.insert(
volume_id.clone(),
VolumeInstance {
is_deleted: Some(true),
..VolumeInstance::default()
},
);
}
}
patch.services.insert(
id.to_string(),
ServiceInstance {
is_deleted: Some(true),
..ServiceInstance::default()
},
);
}
#[allow(clippy::too_many_arguments)]
async fn scale_internal(
ctx: &ServiceContext,
config: &EnvironmentConfig,
root_id: &str,
root_name: &str,
target_count: i64,
names: &BTreeMap<String, String>,
fresh_replica_roster: Option<&[(String, String)]>,
patch: &mut ScalePatch,
) -> Result<ScaleDimensionSummary> {
let root = config
.services
.get(root_id)
.with_context(|| format!("Service \"{root_name}\" not found in environment config"))?;
let mut existing = members_of_role(config, root_id, "internal", names);
let current_count = existing.len() as i64;
if target_count == current_count {
return Ok(ScaleDimensionSummary::default());
}
let wiring = resolve_cluster_wiring(root).with_context(|| {
"Could not resolve this cluster's scale wiring -- scaling would leave the coordinator \
host list stale. The root service is missing both `clusterWiring` and the legacy \
`PATRONI_ENABLED` variable."
.to_string()
})?;
let replica_ids: Vec<String> = match fresh_replica_roster {
Some(roster) => roster.iter().map(|(id, _)| id.clone()).collect(),
None => members_of_role(config, root_id, "replica", names)
.into_iter()
.map(|(id, _)| id)
.collect(),
};
let data_node_ids: Vec<String> = std::iter::once(root_id.to_string())
.chain(replica_ids)
.collect();
let mut added_ids: Vec<String> = Vec::new();
let summary = if target_count > current_count {
let Some((source_id, source_name)) = existing.first().cloned() else {
bail!(
"Cannot scale up coordinators on {root_name}: there is no existing coordinator \
node to clone from. Re-run `ha convert --coordinators \
{target_count}` to add the first one."
);
};
let source = config
.services
.get(&source_id)
.context("Coordinator node disappeared from environment config mid-scale")?;
source
.source
.as_ref()
.and_then(|s| s.image.as_ref())
.context("Coordinator node has no source image to clone")?;
let mount_path = source
.volume_mounts
.values()
.find_map(|m| m.mount_path.clone())
.context("Coordinator node has no volume mount path to clone")?;
let base_name = derive_node_base_name(&source_name, "internal");
let existing_names: Vec<String> = existing.iter().map(|(_, name)| name.clone()).collect();
let start_number = next_node_number(&existing_names, &base_name);
let to_add = target_count - current_count;
let mut added = Vec::with_capacity(to_add as usize);
for next_number in start_number..start_number + to_add {
let node_name = format!("{base_name}-{next_number}");
let node = create_clone_service(ctx, &node_name).await?;
let volume = create_clone_volume(ctx, &mount_path, &node.name).await?;
stage_new_member(
patch,
root_id,
root,
"internal",
&node,
source,
&volume,
&mount_path,
);
added_ids.push(node.id.clone());
existing.push((node.id.clone(), node.name.clone()));
added.push(node.name.clone());
}
ScaleDimensionSummary {
added,
removed: Vec::new(),
}
} else {
let to_remove = current_count - target_count;
let base_name = existing
.first()
.map(|(_, name)| derive_node_base_name(name, "internal"))
.unwrap_or_else(|| "internal".to_string());
let primary_id = find_primary_internal(&existing, &base_name).map(|(id, _)| id.clone());
let mut sorted = existing.clone();
sorted
.sort_by_key(|(_, name)| std::cmp::Reverse(node_number(name, &base_name).unwrap_or(0)));
let removable: Vec<(String, String)> = sorted
.into_iter()
.filter(|(id, _)| Some(id) != primary_id.as_ref())
.collect();
if (removable.len() as i64) < to_remove {
bail!("Cannot remove the primary coordinator node on {root_name}.");
}
let to_delete: Vec<(String, String)> =
removable.into_iter().take(to_remove as usize).collect();
for (id, _) in &to_delete {
stage_member_deletion(patch, config, id);
}
let removed: Vec<String> = to_delete.iter().map(|(_, name)| name.clone()).collect();
existing.retain(|(id, _)| !to_delete.iter().any(|(rid, _)| rid == id));
ScaleDimensionSummary {
added: Vec::new(),
removed,
}
};
restamp_internal_wiring(
&mut patch.services,
&wiring,
&existing,
&added_ids,
&data_node_ids,
);
Ok(summary)
}
fn scale_edge(
config: &EnvironmentConfig,
engine: &DatabaseEngine,
root_id: &str,
target_count: i64,
patch: &mut BTreeMap<String, ServiceInstance>,
) -> Result<Option<EdgeScaleSummary>> {
let edge_id = find_routing_edge_id(config, engine, root_id)
.context("Routing edge service (e.g. HAProxy) not found in this cluster")?;
let edge = config
.services
.get(&edge_id)
.context("Edge service disappeared from environment config")?;
let mrc = edge
.deploy
.as_ref()
.and_then(|d| d.multi_region_config.as_ref())
.context("Edge service has no multi-region config to scale")?;
let (region, current) = mrc
.iter()
.find(|(_, v)| v.is_some())
.map(|(region, v)| {
(
region.clone(),
v.as_ref().and_then(|r| r.num_replicas).unwrap_or(1),
)
})
.context("Edge service region config not found")?;
if current == target_count {
return Ok(None);
}
let mut updated_mrc = mrc.clone();
updated_mrc.insert(
region.clone(),
Some(RegionConfig {
num_replicas: Some(target_count),
}),
);
let entry = patch.entry(edge_id).or_default();
let mut deploy = entry.deploy.clone().unwrap_or_default();
deploy.multi_region_config = Some(updated_mrc);
entry.deploy = Some(deploy);
Ok(Some(EdgeScaleSummary {
region,
previous_replicas: current,
target_replicas: target_count,
}))
}
fn set_patch_var(
patch: &mut BTreeMap<String, ServiceInstance>,
service_id: &str,
var_name: &str,
value: String,
) {
let entry = patch.entry(service_id.to_string()).or_default();
entry.variables.insert(
var_name.to_string(),
Some(Variable {
value: Some(value),
..Variable::default()
}),
);
}
fn restamp_replica_wiring(
patch: &mut BTreeMap<String, ServiceInstance>,
wiring: &ClusterWiring,
root_name: &str,
routing_edge_id: Option<&str>,
replicas_after: &[(String, String)],
newly_added_ids: &[String],
) {
if let Some(var_name) = &wiring.replica_node_name_variable {
for (id, name) in replicas_after {
if newly_added_ids.iter().any(|added| added == id) {
set_patch_var(patch, id, var_name, name.to_ascii_lowercase());
}
}
}
if let (Some(peer_var), Some(entry_format)) =
(&wiring.peer_hosts_variable, &wiring.peer_hosts_entry_format)
&& !newly_added_ids.is_empty()
{
let mut peer_names: Vec<&str> = std::iter::once(root_name)
.chain(replicas_after.iter().map(|(_, name)| name.as_str()))
.collect();
peer_names.sort_unstable();
let peer_list = peer_names
.iter()
.map(|name| format_data_node_entry(entry_format, name, root_name))
.collect::<Vec<_>>()
.join(",");
for id in newly_added_ids {
set_patch_var(patch, id, peer_var, peer_list.clone());
}
}
if let (Some(edge_id), Some(data_var), Some(entry_format)) = (
routing_edge_id,
&wiring.data_nodes_variable,
&wiring.data_nodes_entry_format,
) {
let mut data_node_names: Vec<&str> = std::iter::once(root_name)
.chain(replicas_after.iter().map(|(_, name)| name.as_str()))
.collect();
data_node_names.sort_unstable();
let list = data_node_names
.iter()
.map(|name| format_data_node_entry(entry_format, name, root_name))
.collect::<Vec<_>>()
.join(",");
set_patch_var(patch, edge_id, data_var, list);
}
if let Some(quorum_var) = &wiring.quorum_variable
&& !newly_added_ids.is_empty()
{
let data_node_count = replicas_after.len() + 1; let quorum = (data_node_count / 2 + 1).to_string();
for id in newly_added_ids {
set_patch_var(patch, id, quorum_var, quorum.clone());
}
}
}
fn restamp_internal_wiring(
patch: &mut BTreeMap<String, ServiceInstance>,
wiring: &ClusterWiring,
internal_after: &[(String, String)],
newly_added_ids: &[String],
data_node_ids: &[String],
) {
if let Some(var_name) = &wiring.internal_node_name_variable {
for (id, name) in internal_after {
if newly_added_ids.iter().any(|added| added == id) {
set_patch_var(patch, id, var_name, name.to_ascii_lowercase());
}
}
}
if let (Some(coordinator_var), Some(port)) =
(&wiring.coordinator_hosts_variable, wiring.coordinator_port)
{
let mut sorted_names: Vec<&(String, String)> = internal_after.iter().collect();
sorted_names.sort_by(|a, b| a.1.cmp(&b.1));
let hosts = sorted_names
.iter()
.map(|(_, name)| format!("{}:{port}", private_domain_ref(name)))
.collect::<Vec<_>>()
.join(",");
for id in data_node_ids {
set_patch_var(patch, id, coordinator_var, hosts.clone());
}
}
}
fn resolve_cluster_wiring(root: &ServiceInstance) -> Option<ClusterWiring> {
if let Some(wiring) = &root.cluster_wiring {
return Some(wiring.clone());
}
if !root.variables.contains_key(PATRONI_ENABLED_VAR) {
return None;
}
Some(ClusterWiring {
internal_node_name_variable: Some("ETCD_NAME".to_string()),
coordinator_hosts_variable: Some("PATRONI_ETCD3_HOSTS".to_string()),
coordinator_port: Some(2379),
replica_node_name_variable: Some("PATRONI_NAME".to_string()),
data_nodes_variable: Some("POSTGRES_NODES".to_string()),
data_nodes_entry_format: Some("{host}:${{{rootName}.PGPORT}}:8008".to_string()),
..ClusterWiring::default()
})
}
fn members_of_role(
config: &EnvironmentConfig,
root_id: &str,
role: &str,
names: &BTreeMap<String, String>,
) -> Vec<(String, String)> {
config
.services
.iter()
.filter(|(_, s)| {
s.parent_service_id.as_deref() == Some(root_id)
&& s.cluster_role.as_deref() == Some(role)
})
.map(|(id, _)| {
(
id.clone(),
names.get(id).cloned().unwrap_or_else(|| id.clone()),
)
})
.collect()
}
fn find_routing_edge_id(
config: &EnvironmentConfig,
engine: &DatabaseEngine,
root_id: &str,
) -> Option<String> {
config
.services
.iter()
.find(|(_, s)| {
s.parent_service_id.as_deref() == Some(root_id)
&& s.cluster_role.as_deref() == Some("edge")
&& !engine
.pooling
.is_some_and(|pooling| database_plugins::is_pooler_service(s, &pooling))
})
.map(|(id, _)| id.clone())
}
fn derive_node_base_name(any_node_name: &str, fallback: &str) -> String {
let re = Regex::new(r"^(.+?)-\d+").expect("valid regex");
if let Some(caps) = re.captures(any_node_name) {
return caps[1].to_string();
}
if any_node_name.is_empty() || any_node_name.chars().all(|c| c.is_ascii_digit()) {
fallback.to_string()
} else {
any_node_name.to_string()
}
}
fn node_number(name: &str, base_name: &str) -> Option<i64> {
let pattern = format!("(?i){}-(\\d+)", regex::escape(base_name));
let re = Regex::new(&pattern).expect("valid regex");
re.captures(name)?.get(1)?.as_str().parse().ok()
}
fn next_node_number(existing_names: &[String], base_name: &str) -> i64 {
let existing_numbers = existing_names
.iter()
.filter_map(|name| node_number(name, base_name))
.filter(|n| *n > 0);
existing_numbers
.chain(std::iter::once(existing_names.len() as i64))
.max()
.unwrap_or(0)
+ 1
}
fn find_primary_internal<'a>(
nodes: &'a [(String, String)],
base_name: &str,
) -> Option<&'a (String, String)> {
nodes
.iter()
.filter(|(_, name)| node_number(name, base_name).is_some())
.min_by_key(|(_, name)| node_number(name, base_name).unwrap_or(i64::MAX))
}
fn generate_suffix() -> String {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut rng = rand::thread_rng();
(0..4)
.map(|_| CHARSET[rng.gen_range(0..CHARSET.len())] as char)
.collect()
}
fn is_duplicate_name_error(err: &RailwayError) -> bool {
let message = err.to_string().to_ascii_lowercase();
message.contains("unique") || message.contains("already exists")
}
struct CreatedNode {
id: String,
name: String,
}
async fn create_clone_service(ctx: &ServiceContext, base_name: &str) -> Result<CreatedNode> {
let build_vars = |name: String| mutations::service_create::Variables {
name: Some(name),
project_id: ctx.project_id.clone(),
environment_id: None,
source: None,
variables: None,
branch: None,
};
let result = post_graphql::<mutations::ServiceCreate, _>(
&ctx.client,
ctx.configs.get_backboard(),
build_vars(base_name.to_string()),
)
.await;
let created = match result {
Ok(r) => r,
Err(err) if is_duplicate_name_error(&err) => {
let retried_name = format!("{base_name}-{}", generate_suffix());
post_graphql::<mutations::ServiceCreate, _>(
&ctx.client,
ctx.configs.get_backboard(),
build_vars(retried_name),
)
.await
.context("Failed to create cluster node service (after retrying a duplicate name)")?
}
Err(err) => {
return Err(err).context("Failed to create cluster node service");
}
};
Ok(CreatedNode {
id: created.service_create.id,
name: created.service_create.name,
})
}
struct CreatedVolume {
id: String,
}
async fn create_clone_volume(
ctx: &ServiceContext,
mount_path: &str,
node_name: &str,
) -> Result<CreatedVolume> {
let created = post_graphql::<mutations::VolumeCreate, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::volume_create::Variables {
project_id: ctx.project_id.clone(),
environment_id: None,
service_id: None,
mount_path: mount_path.to_string(),
},
)
.await
.context("Failed to create volume for new cluster node")?;
let volume_id = created.volume_create.id.clone();
let name_result = post_graphql::<mutations::VolumeNameUpdate, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::volume_name_update::Variables {
volume_id: volume_id.clone(),
name: format!("{node_name}-volume"),
},
)
.await;
if let Err(err) = name_result
&& is_duplicate_name_error(&err)
{
let unique_suffix = &volume_id[..8.min(volume_id.len())];
let _ = post_graphql::<mutations::VolumeNameUpdate, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::volume_name_update::Variables {
volume_id: volume_id.clone(),
name: format!("{node_name}-volume-{unique_suffix}"),
},
)
.await;
}
Ok(CreatedVolume { id: volume_id })
}
pub(crate) async fn delete_member(
ctx: &ServiceContext,
config: &EnvironmentConfig,
service_id: &str,
) -> Result<()> {
if let Some(service) = config.services.get(service_id) {
for volume_id in service.volume_mounts.keys() {
post_graphql::<mutations::VolumeDelete, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::volume_delete::Variables {
id: volume_id.clone(),
},
)
.await
.with_context(|| format!("Failed to delete volume {volume_id}"))?;
}
}
post_graphql::<mutations::ServiceDelete, _>(
&ctx.client,
ctx.configs.get_backboard(),
mutations::service_delete::Variables {
service_id: service_id.to_string(),
environment_id: ctx.environment_id.clone(),
},
)
.await
.with_context(|| format!("Failed to delete service {service_id}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn service(role: &str, parent: &str) -> ServiceInstance {
ServiceInstance {
cluster_role: Some(role.to_string()),
parent_service_id: Some(parent.to_string()),
..ServiceInstance::default()
}
}
fn config_with(services: Vec<(&str, ServiceInstance)>) -> EnvironmentConfig {
let mut config = EnvironmentConfig::default();
for (id, s) in services {
config.services.insert(id.to_string(), s);
}
config
}
fn names(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(id, name)| (id.to_string(), name.to_string()))
.collect()
}
#[test]
fn validate_odd_coordinator_count_rejects_even() {
assert!(validate_odd_coordinator_count(4).is_err());
assert!(validate_odd_coordinator_count(3).is_ok());
assert!(validate_odd_coordinator_count(1).is_ok());
}
#[test]
fn derive_node_base_name_strips_first_number_group() {
assert_eq!(
derive_node_base_name("postgres-replica-2", "Replica"),
"postgres-replica"
);
assert_eq!(derive_node_base_name("etcd-1-xK2p", "internal"), "etcd");
assert_eq!(derive_node_base_name("db-prod", "Replica"), "db-prod");
assert_eq!(derive_node_base_name("42", "Replica"), "Replica");
}
#[test]
fn node_number_matches_first_group_case_insensitively() {
assert_eq!(
node_number("postgres-replica-2", "postgres-replica"),
Some(2)
);
assert_eq!(node_number("ETCD-3-xyz", "etcd"), Some(3));
assert_eq!(node_number("db-prod", "postgres-replica"), None);
}
#[test]
fn next_node_number_continues_after_highest_existing() {
let names = vec![
"postgres-replica-1".to_string(),
"postgres-replica-3".to_string(),
];
assert_eq!(next_node_number(&names, "postgres-replica"), 4);
}
#[test]
fn next_node_number_starts_at_one_from_empty() {
let names: Vec<String> = vec![];
assert_eq!(next_node_number(&names, "postgres-replica"), 1);
}
#[test]
fn find_primary_internal_picks_lowest_number() {
let nodes = vec![
("id-3".to_string(), "etcd-3".to_string()),
("id-1".to_string(), "etcd-1".to_string()),
("id-2".to_string(), "etcd-2".to_string()),
];
let primary = find_primary_internal(&nodes, "etcd").unwrap();
assert_eq!(primary.0, "id-1");
}
#[test]
fn resolve_cluster_wiring_prefers_declared_over_legacy() {
let declared = ClusterWiring {
data_nodes_variable: Some("REDIS_NODES".to_string()),
..ClusterWiring::default()
};
let mut root = ServiceInstance {
cluster_wiring: Some(declared),
..ServiceInstance::default()
};
root.variables.insert(
PATRONI_ENABLED_VAR.to_string(),
Some(Variable {
value: Some("true".to_string()),
..Variable::default()
}),
);
let wiring = resolve_cluster_wiring(&root).unwrap();
assert_eq!(wiring.data_nodes_variable.as_deref(), Some("REDIS_NODES"));
}
#[test]
fn resolve_cluster_wiring_falls_back_to_legacy_patroni() {
let mut root = ServiceInstance::default();
root.variables.insert(
PATRONI_ENABLED_VAR.to_string(),
Some(Variable {
value: Some("true".to_string()),
..Variable::default()
}),
);
let wiring = resolve_cluster_wiring(&root).unwrap();
assert_eq!(
wiring.data_nodes_variable.as_deref(),
Some("POSTGRES_NODES")
);
assert_eq!(wiring.coordinator_port, Some(2379));
}
#[test]
fn resolve_cluster_wiring_none_without_wiring_or_patroni() {
let root = ServiceInstance::default();
assert!(resolve_cluster_wiring(&root).is_none());
}
#[test]
fn find_routing_edge_id_skips_pgbouncer_edge() {
use crate::controllers::config::ServiceSource;
let pgbouncer = ServiceInstance {
source: Some(ServiceSource {
image: Some("ghcr.io/railwayapp-templates/pgbouncer:latest".to_string()),
..ServiceSource::default()
}),
..service("edge", "root")
};
let haproxy = ServiceInstance {
source: Some(ServiceSource {
image: Some("ghcr.io/railwayapp-templates/haproxy:latest".to_string()),
..ServiceSource::default()
}),
..service("edge", "root")
};
let config = config_with(vec![("pgbouncer", pgbouncer), ("haproxy", haproxy)]);
use crate::controllers::database_engines::POSTGRES;
assert_eq!(
find_routing_edge_id(&config, &POSTGRES, "root"),
Some("haproxy".to_string())
);
}
#[test]
fn members_of_role_filters_by_parent_and_role() {
let config = config_with(vec![
("replica-1", service("replica", "root")),
("etcd-1", service("internal", "root")),
("other-replica", service("replica", "some-other-root")),
]);
let names = names(&[("replica-1", "postgres-replica-1")]);
let members = members_of_role(&config, "root", "replica", &names);
assert_eq!(
members,
vec![("replica-1".to_string(), "postgres-replica-1".to_string())]
);
}
fn replica_scale_wiring() -> ClusterWiring {
ClusterWiring {
replica_node_name_variable: Some("PATRONI_NAME".to_string()),
data_nodes_variable: Some("POSTGRES_NODES".to_string()),
data_nodes_entry_format: Some("{host}:${{{rootName}.PGPORT}}:8008".to_string()),
quorum_variable: Some("QUORUM".to_string()),
..ClusterWiring::default()
}
}
#[test]
fn restamp_replica_wiring_stamps_identity_and_quorum_on_joining_nodes_only() {
let wiring = replica_scale_wiring();
let replicas = vec![
("r1".to_string(), "postgres-replica-1".to_string()),
("r2".to_string(), "postgres-replica-2".to_string()),
];
let mut patch = BTreeMap::new();
restamp_replica_wiring(
&mut patch,
&wiring,
"db-prod",
Some("edge"),
&replicas,
&["r2".to_string()],
);
assert_eq!(
patch["r2"].variables["PATRONI_NAME"]
.as_ref()
.unwrap()
.value
.as_deref(),
Some("postgres-replica-2")
);
assert_eq!(
patch["r2"].variables["QUORUM"]
.as_ref()
.unwrap()
.value
.as_deref(),
Some("2")
);
assert!(!patch.contains_key("r1"));
assert!(!patch.contains_key("root"));
let edge_list = patch["edge"].variables["POSTGRES_NODES"]
.as_ref()
.unwrap()
.value
.clone()
.unwrap();
assert!(edge_list.contains("db-prod"));
assert_eq!(edge_list.split(',').count(), 3);
}
#[test]
fn restamp_replica_wiring_touches_no_node_on_a_scale_down() {
let wiring = replica_scale_wiring();
let replicas = vec![
("r1".to_string(), "postgres-replica-1".to_string()),
("r2".to_string(), "postgres-replica-2".to_string()),
];
let mut patch = BTreeMap::new();
restamp_replica_wiring(&mut patch, &wiring, "db-prod", Some("edge"), &replicas, &[]);
assert_eq!(patch.keys().collect::<Vec<_>>(), vec!["edge"]);
assert!(patch["edge"].variables.contains_key("POSTGRES_NODES"));
}
#[test]
fn restamp_internal_wiring_stamps_identity_and_coordinator_hosts() {
let wiring = ClusterWiring {
internal_node_name_variable: Some("ETCD_NAME".to_string()),
coordinator_hosts_variable: Some("PATRONI_ETCD3_HOSTS".to_string()),
coordinator_port: Some(2379),
..ClusterWiring::default()
};
let internal = vec![
("e1".to_string(), "etcd-1".to_string()),
("e2".to_string(), "etcd-2".to_string()),
("e3".to_string(), "etcd-3".to_string()),
];
let mut patch = BTreeMap::new();
restamp_internal_wiring(
&mut patch,
&wiring,
&internal,
&["e3".to_string()],
&["root".to_string()],
);
assert_eq!(
patch["e3"].variables["ETCD_NAME"]
.as_ref()
.unwrap()
.value
.as_deref(),
Some("etcd-3")
);
assert!(!patch.contains_key("e1"));
assert!(!patch.contains_key("e2"));
let hosts = patch["root"].variables["PATRONI_ETCD3_HOSTS"]
.as_ref()
.unwrap()
.value
.clone()
.unwrap();
assert_eq!(hosts.split(',').count(), 3);
assert!(hosts.contains(":2379"));
}
#[test]
fn scale_edge_merges_into_existing_patch_entry_for_same_service() {
let mut mrc = BTreeMap::new();
mrc.insert(
"us-west".to_string(),
Some(RegionConfig {
num_replicas: Some(1),
}),
);
let edge = ServiceInstance {
deploy: Some(DeployConfig {
multi_region_config: Some(mrc),
..DeployConfig::default()
}),
..service("edge", "root")
};
let config = config_with(vec![("edge-id", edge)]);
let mut patch = BTreeMap::new();
set_patch_var(
&mut patch,
"edge-id",
"POSTGRES_NODES",
"existing-list".to_string(),
);
let summary = scale_edge(
&config,
&crate::controllers::database_engines::POSTGRES,
"root",
3,
&mut patch,
)
.unwrap()
.unwrap();
assert_eq!(summary.previous_replicas, 1);
assert_eq!(summary.target_replicas, 3);
assert_eq!(
patch["edge-id"].variables["POSTGRES_NODES"]
.as_ref()
.unwrap()
.value
.as_deref(),
Some("existing-list")
);
let mrc = patch["edge-id"]
.deploy
.as_ref()
.unwrap()
.multi_region_config
.as_ref()
.unwrap();
assert_eq!(mrc["us-west"].as_ref().unwrap().num_replicas, Some(3));
}
#[test]
fn scale_edge_noop_when_already_at_target() {
let mut mrc = BTreeMap::new();
mrc.insert(
"us-west".to_string(),
Some(RegionConfig {
num_replicas: Some(2),
}),
);
let edge = ServiceInstance {
deploy: Some(DeployConfig {
multi_region_config: Some(mrc),
..DeployConfig::default()
}),
..service("edge", "root")
};
let config = config_with(vec![("edge-id", edge)]);
let mut patch = BTreeMap::new();
let summary = scale_edge(
&config,
&crate::controllers::database_engines::POSTGRES,
"root",
2,
&mut patch,
)
.unwrap();
assert!(summary.is_none());
assert!(patch.is_empty());
}
#[test]
fn scale_dimension_summary_is_noop_when_empty() {
assert!(ScaleDimensionSummary::default().is_noop());
assert!(
!ScaleDimensionSummary {
added: vec!["x".to_string()],
removed: vec![],
}
.is_noop()
);
}
#[test]
fn scale_edge_errors_without_multi_region_config() {
let edge = service("edge", "root");
let config = config_with(vec![("edge-id", edge)]);
let mut patch = BTreeMap::new();
let err = scale_edge(
&config,
&crate::controllers::database_engines::POSTGRES,
"root",
2,
&mut patch,
)
.unwrap_err();
assert!(err.to_string().contains("no multi-region config"));
}
#[test]
fn scale_edge_errors_without_an_edge_service() {
let config = config_with(vec![("replica-1", service("replica", "root"))]);
let mut patch = BTreeMap::new();
let err = scale_edge(
&config,
&crate::controllers::database_engines::POSTGRES,
"root",
2,
&mut patch,
)
.unwrap_err();
assert!(err.to_string().contains("edge service"));
}
#[test]
fn next_node_number_falls_back_to_count_when_names_are_unnumbered() {
let names = vec!["etcd".to_string()];
assert_eq!(next_node_number(&names, "etcd"), 2);
}
#[test]
fn find_primary_internal_none_when_no_numbered_nodes() {
let nodes = vec![("id-a".to_string(), "etcd".to_string())];
assert!(find_primary_internal(&nodes, "etcd").is_none());
}
#[test]
fn duplicate_name_error_detection() {
assert!(is_duplicate_name_error(&RailwayError::GraphQLError(
"Service name must be unique within a project".to_string()
)));
assert!(is_duplicate_name_error(&RailwayError::GraphQLError(
"A service with that name already exists".to_string()
)));
assert!(!is_duplicate_name_error(&RailwayError::GraphQLError(
"Internal server error".to_string()
)));
}
#[test]
fn generate_suffix_is_four_lowercase_alphanumerics() {
let suffix = generate_suffix();
assert_eq!(suffix.len(), 4);
assert!(
suffix
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
);
}
#[test]
fn members_of_role_falls_back_to_id_when_name_unknown() {
let config = config_with(vec![("replica-1", service("replica", "root"))]);
let members = members_of_role(&config, "root", "replica", &BTreeMap::new());
assert_eq!(
members,
vec![("replica-1".to_string(), "replica-1".to_string())]
);
}
#[test]
fn restamp_replica_wiring_skips_undeclared_fields() {
let wiring = ClusterWiring::default();
let mut patch = BTreeMap::new();
restamp_replica_wiring(
&mut patch,
&wiring,
"db",
Some("edge"),
&[("r1".to_string(), "replica-1".to_string())],
&["r1".to_string()],
);
assert!(patch.is_empty());
}
#[test]
fn peer_list_is_stamped_on_joining_nodes_only() {
let wiring = ClusterWiring {
peer_hosts_variable: Some("SENTINEL_HOSTS".to_string()),
peer_hosts_entry_format: Some("{host}:26379".to_string()),
..ClusterWiring::default()
};
let replicas = vec![
("r1".to_string(), "Redis-2".to_string()),
("r2".to_string(), "Redis-3".to_string()),
];
let mut patch = BTreeMap::new();
restamp_replica_wiring(
&mut patch,
&wiring,
"Redis-1",
None,
&replicas,
&["r2".to_string()],
);
let peers = patch["r2"].variables["SENTINEL_HOSTS"]
.as_ref()
.unwrap()
.value
.as_ref()
.unwrap();
assert_eq!(peers.split(',').count(), 3);
assert!(peers.contains("${{Redis-1.RAILWAY_PRIVATE_DOMAIN}}:26379"));
assert!(
patch
.get("r1")
.is_none_or(|p| !p.variables.contains_key("SENTINEL_HOSTS"))
);
assert!(
patch
.get("root")
.is_none_or(|p| !p.variables.contains_key("SENTINEL_HOSTS"))
);
}
#[test]
fn scale_down_stamps_no_peer_list_at_all() {
let wiring = ClusterWiring {
peer_hosts_variable: Some("GR_SEEDS".to_string()),
peer_hosts_entry_format: Some("{host}:3306".to_string()),
..ClusterWiring::default()
};
let mut patch = BTreeMap::new();
restamp_replica_wiring(
&mut patch,
&wiring,
"MySQL-1",
None,
&[("r1".to_string(), "MySQL-2".to_string())],
&[],
);
assert!(patch.is_empty());
}
#[test]
fn data_node_quorum_fence_applies_only_where_the_data_nodes_vote() {
let sentinel = ClusterWiring {
quorum_variable: Some("SENTINEL_QUORUM".to_string()),
..ClusterWiring::default()
};
let group_replication = ClusterWiring {
data_nodes_are_quorum_voters: Some(true),
..ClusterWiring::default()
};
for wiring in [&sentinel, &group_replication] {
assert!(validate_data_node_quorum(wiring, 2).is_ok());
assert!(validate_data_node_quorum(wiring, 4).is_ok());
assert!(validate_data_node_quorum(wiring, 3).is_err());
assert!(validate_data_node_quorum(wiring, 1).is_err());
assert!(validate_data_node_quorum(wiring, 0).is_err());
}
let external_coordinator = ClusterWiring {
coordinator_hosts_variable: Some("PATRONI_ETCD3_HOSTS".to_string()),
..ClusterWiring::default()
};
for replicas in [0, 1, 2, 3] {
assert!(validate_data_node_quorum(&external_coordinator, replicas).is_ok());
}
}
fn mock_context(
server: &crate::testkit::MockBackboard,
dir: &tempfile::TempDir,
) -> ServiceContext {
ServiceContext {
client: reqwest::Client::new(),
configs: server.configs(dir),
project: serde_json::from_value(serde_json::json!({
"id": "proj-1",
"name": "db",
"workspaceId": null,
"deletedAt": null,
"workspace": null,
"buckets": { "edges": [] },
"environments": { "edges": [] },
"services": { "edges": [] },
}))
.unwrap(),
project_id: "proj-1".to_string(),
environment_id: "env-1".to_string(),
environment_name: "production".to_string(),
service_id: "root".to_string(),
service_name: "Redis-1".to_string(),
}
}
#[tokio::test]
async fn scaling_up_stages_the_new_replica_volume_in_the_same_patch_as_its_role_and_parent() {
let dir = tempfile::tempdir().unwrap();
let server = crate::testkit::MockBackboard::spawn();
let environment_config = serde_json::json!({
"services": {
"root": {
"source": { "image": "ghcr.io/railwayapp-templates/redis-ha/redis-sentinel:8.4" },
"clusterRole": "root",
"clusterWiring": {
"quorumVariable": "SENTINEL_QUORUM",
"peerHostsVariable": "SENTINEL_HOSTS",
"peerHostsEntryFormat": "{host}:26379",
},
},
"replica-1": {
"source": { "image": "ghcr.io/railwayapp-templates/redis-ha/redis-sentinel:8.4" },
"clusterRole": "replica",
"parentServiceId": "root",
"volumeMounts": { "vol-1": { "mountPath": "/data" } },
},
"replica-2": {
"source": { "image": "ghcr.io/railwayapp-templates/redis-ha/redis-sentinel:8.4" },
"clusterRole": "replica",
"parentServiceId": "root",
"volumeMounts": { "vol-2": { "mountPath": "/data" } },
},
}
});
let environment_payload = serde_json::json!({
"environment": { "id": "env-1", "name": "production", "config": environment_config }
});
server.stub("GetEnvironmentConfig", environment_payload.clone());
server.stub(
"ServiceCreate",
serde_json::json!({
"serviceCreate": { "id": "replica-3", "name": "Redis-4" }
}),
);
server.stub(
"ServiceCreate",
serde_json::json!({
"serviceCreate": { "id": "replica-4", "name": "Redis-5" }
}),
);
server.stub(
"VolumeCreate",
serde_json::json!({
"volumeCreate": { "id": "vol-3", "name": "Redis-4-volume" }
}),
);
server.stub(
"VolumeCreate",
serde_json::json!({
"volumeCreate": { "id": "vol-4", "name": "Redis-5-volume" }
}),
);
server.stub(
"VolumeNameUpdate",
serde_json::json!({ "volumeUpdate": { "name": "Redis-4-volume" } }),
);
server.stub(
"EnvironmentStagedChanges",
serde_json::json!({
"environmentStagedChanges": { "id": "patch-0", "status": "STAGED", "patch": null }
}),
);
server.stub(
"EnvironmentStageChanges",
serde_json::json!({
"environmentStageChanges": { "id": "patch-1", "status": "STAGED" }
}),
);
server.stub(
"EnvironmentPatchCommitStaged",
serde_json::json!({
"environmentPatchCommitStaged": "wf-1"
}),
);
server.stub(
"WorkflowStatus",
serde_json::json!({
"workflowStatus": { "status": "Complete", "error": null }
}),
);
let ctx = mock_context(&server, &dir);
let names = names(&[
("root", "Redis-1"),
("replica-1", "Redis-2"),
("replica-2", "Redis-3"),
]);
scale_cluster(
&ctx,
&crate::controllers::database_engines::REDIS,
"root",
"Redis-1",
&names,
ScaleClusterParams {
replicas: Some(4),
coordinators: None,
edge: None,
auto_deploy: false,
live_primary_id: None,
},
)
.await
.unwrap();
let volume_create = server.variables_for("VolumeCreate");
assert_eq!(volume_create.len(), 2);
for variables in &volume_create {
assert_eq!(
variables.get("environmentId"),
Some(&serde_json::Value::Null),
"the volume instance must not be provisioned outside the patch"
);
assert_eq!(
variables.get("serviceId"),
Some(&serde_json::Value::Null),
"the mount is declared by the staged patch, not the record"
);
}
let service_create = server.variables_for("ServiceCreate");
assert_eq!(service_create.len(), 2);
for variables in &service_create {
assert_eq!(
variables.get("environmentId"),
Some(&serde_json::Value::Null),
"the service instance must be created by the patch, not here"
);
assert_eq!(variables.get("source"), Some(&serde_json::Value::Null));
}
let staged = server.variables_for("EnvironmentStageChanges");
assert_eq!(staged.len(), 1);
let input = staged[0].get("input").unwrap();
for volume_id in ["vol-3", "vol-4"] {
assert_eq!(
input.pointer(&format!("/volumes/{volume_id}/isCreated")),
Some(&serde_json::Value::Bool(true)),
"{volume_id} was not staged for creation by the patch"
);
}
for (service_id, volume_id) in [("replica-3", "vol-3"), ("replica-4", "vol-4")] {
assert_eq!(
input.pointer(&format!("/services/{service_id}/isCreated")),
Some(&serde_json::Value::Bool(true)),
"{service_id}'s instance was not staged for creation by the patch"
);
assert_eq!(
input.pointer(&format!("/services/{service_id}/source/image")),
Some(&serde_json::Value::String(
"ghcr.io/railwayapp-templates/redis-ha/redis-sentinel:8.4".to_string()
)),
"the image rides the patch now that the record is created bare"
);
assert_eq!(
input.pointer(&format!("/services/{service_id}/clusterRole")),
Some(&serde_json::Value::String("replica".to_string()))
);
assert_eq!(
input.pointer(&format!("/services/{service_id}/parentServiceId")),
Some(&serde_json::Value::String("root".to_string()))
);
assert_eq!(
input.pointer(&format!(
"/services/{service_id}/volumeMounts/{volume_id}/mountPath"
)),
Some(&serde_json::Value::String("/data".to_string()))
);
}
assert_eq!(
input.pointer("/services/replica-3/variables/SENTINEL_QUORUM/value"),
Some(&serde_json::Value::String("3".to_string()))
);
for survivor in ["replica-1", "replica-2", "root"] {
assert!(
input
.pointer(&format!("/services/{survivor}/variables/SENTINEL_QUORUM"))
.is_none(),
"{survivor} was restamped and will restart with the rest of the fleet"
);
}
}
#[tokio::test]
async fn scaling_down_stages_deletions_and_never_removes_the_acting_primary() {
let dir = tempfile::tempdir().unwrap();
let server = crate::testkit::MockBackboard::spawn();
let environment_config = serde_json::json!({
"services": {
"root": {
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:16" },
"clusterRole": "root",
"clusterWiring": {
"replicaNodeNameVariable": "PATRONI_NAME",
},
},
"replica-1": {
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:16" },
"clusterRole": "replica",
"parentServiceId": "root",
"volumeMounts": { "vol-1": { "mountPath": "/var/lib/postgresql/data" } },
},
"replica-2": {
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:16" },
"clusterRole": "replica",
"parentServiceId": "root",
"volumeMounts": { "vol-2": { "mountPath": "/var/lib/postgresql/data" } },
},
}
});
server.stub(
"GetEnvironmentConfig",
serde_json::json!({
"environment": { "id": "env-1", "name": "production", "config": environment_config }
}),
);
server.stub(
"EnvironmentStagedChanges",
serde_json::json!({
"environmentStagedChanges": { "id": "patch-0", "status": "STAGED", "patch": null }
}),
);
server.stub(
"EnvironmentStageChanges",
serde_json::json!({
"environmentStageChanges": { "id": "patch-1", "status": "STAGED" }
}),
);
server.stub(
"EnvironmentPatchCommitStaged",
serde_json::json!({ "environmentPatchCommitStaged": "wf-1" }),
);
server.stub(
"WorkflowStatus",
serde_json::json!({
"workflowStatus": { "status": "Complete", "error": null }
}),
);
let ctx = mock_context(&server, &dir);
let names = names(&[
("root", "Postgres"),
("replica-1", "postgres-replica-1"),
("replica-2", "postgres-replica-2"),
]);
let result = scale_cluster(
&ctx,
&crate::controllers::database_engines::POSTGRES,
"root",
"Postgres",
&names,
ScaleClusterParams {
replicas: Some(1),
coordinators: None,
edge: None,
auto_deploy: false,
live_primary_id: Some("replica-2".to_string()),
},
)
.await
.unwrap();
assert_eq!(
result.replicas.unwrap().removed,
vec!["postgres-replica-1".to_string()]
);
let staged = server.variables_for("EnvironmentStageChanges");
assert_eq!(staged.len(), 1);
let input = staged[0].get("input").unwrap();
assert_eq!(
input.pointer("/services/replica-1/isDeleted"),
Some(&serde_json::Value::Bool(true))
);
assert_eq!(
input.pointer("/volumes/vol-1/isDeleted"),
Some(&serde_json::Value::Bool(true))
);
assert!(input.pointer("/services/replica-2/isDeleted").is_none());
assert!(input.pointer("/volumes/vol-2").is_none());
}
#[tokio::test]
async fn scaling_down_past_the_acting_primary_is_refused_with_the_switchover_remedy() {
let dir = tempfile::tempdir().unwrap();
let server = crate::testkit::MockBackboard::spawn();
let environment_config = serde_json::json!({
"services": {
"root": {
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:16" },
"clusterRole": "root",
"clusterWiring": { "replicaNodeNameVariable": "PATRONI_NAME" },
},
"replica-1": {
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:16" },
"clusterRole": "replica",
"parentServiceId": "root",
"volumeMounts": { "vol-1": { "mountPath": "/var/lib/postgresql/data" } },
},
}
});
server.stub(
"GetEnvironmentConfig",
serde_json::json!({
"environment": { "id": "env-1", "name": "production", "config": environment_config }
}),
);
let ctx = mock_context(&server, &dir);
let names = names(&[("root", "Postgres"), ("replica-1", "postgres-replica-1")]);
let err = scale_cluster(
&ctx,
&crate::controllers::database_engines::POSTGRES,
"root",
"Postgres",
&names,
ScaleClusterParams {
replicas: Some(0),
coordinators: None,
edge: None,
auto_deploy: false,
live_primary_id: Some("replica-1".to_string()),
},
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("acting as the cluster's primary"));
assert!(err.contains("ha switchover --to Postgres"));
}
}