use std::collections::BTreeMap;
use serde_json::Value;
use super::database_engines::{image_tag_version, parse_image_ref};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AdoptionRules {
pub label: Option<String>,
pub repositories: Vec<String>,
pub require_floating_major_tag: bool,
pub require_image_entrypoint: bool,
pub supported_image_major_versions: Vec<i64>,
pub pin_to_minor_version: bool,
pub role_options: BTreeMap<String, Vec<i64>>,
}
pub fn rules_from_template(serialized_config: &Value) -> AdoptionRules {
let Some(root) = serialized_config
.get("services")
.and_then(Value::as_object)
.and_then(|services| {
services
.values()
.find(|s| s.get("clusterRole").and_then(Value::as_str) == Some("root"))
})
else {
return AdoptionRules::default();
};
let eligibility = root.get("adoptionImageEligibility");
let conversion = root.get("haConversionConfig");
let flag = |parent: Option<&Value>, key: &str| {
parent
.and_then(|v| v.get(key))
.and_then(Value::as_bool)
.unwrap_or(false)
};
AdoptionRules {
label: eligibility
.and_then(|v| v.get("label"))
.and_then(Value::as_str)
.map(str::to_string),
repositories: eligibility
.and_then(|v| v.get("repositories"))
.and_then(Value::as_array)
.map(|entries| {
entries
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default(),
require_floating_major_tag: flag(eligibility, "requireFloatingMajorTag"),
require_image_entrypoint: flag(eligibility, "requireImageEntrypoint"),
supported_image_major_versions: conversion
.and_then(|v| v.get("supportedImageMajorVersions"))
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(Value::as_i64).collect())
.unwrap_or_default(),
pin_to_minor_version: flag(conversion, "pinToMinorVersion"),
role_options: ["replica", "internal", "edge"]
.into_iter()
.filter_map(|role| {
let selector = conversion?.get(role).filter(|v| !v.is_null())?;
let options = selector
.get("options")
.and_then(Value::as_array)
.map(|entries| entries.iter().filter_map(Value::as_i64).collect())
.unwrap_or_default();
Some((role.to_string(), options))
})
.collect(),
}
}
pub fn companion_image_repositories(serialized_config: &Value) -> Vec<String> {
let Some(services) = serialized_config.get("services").and_then(Value::as_object) else {
return Vec::new();
};
let mut repositories: Vec<String> = services
.values()
.filter_map(|service| {
let image = service.get("source")?.get("image")?.as_str()?;
let parsed = parse_image_ref(image)?;
Some(match &parsed.domain {
Some(domain) => format!("{domain}/{}", parsed.path),
None => parsed.path.clone(),
})
})
.collect();
repositories.sort();
repositories.dedup();
repositories
}
pub fn image_is_from_repository(image: Option<&str>, repositories: &[String]) -> bool {
let Some(parsed) = image.and_then(parse_image_ref) else {
return false;
};
let qualified = match &parsed.domain {
Some(domain) => format!("{domain}/{}", parsed.path),
None => parsed.path.clone(),
};
repositories.contains(&qualified)
}
pub struct AdoptionTarget<'a> {
pub image: Option<&'a str>,
pub has_start_command: bool,
}
impl AdoptionRules {
pub fn blockers(&self, target: &AdoptionTarget<'_>) -> Vec<String> {
let mut blockers = Vec::new();
let feature = self.label.as_deref().unwrap_or("This feature");
if !self.repositories.is_empty() && !self.image_repository_is_eligible(target.image) {
blockers.push(format!(
"{feature} runs in Railway's own database images, and \"{}\" is not one of them. Supported images: {}.",
target.image.unwrap_or("(no image)"),
self.repositories.join(", ")
));
}
if self.require_image_entrypoint && target.has_start_command {
blockers.push(format!(
"{feature} is switched on by the image's entrypoint, which a custom start command overrides. Clear the service's start command first."
));
}
if self.repositories.is_empty() || self.image_repository_is_eligible(target.image) {
blockers.extend(self.tag_blockers(feature, target.image));
}
blockers
}
fn image_repository_is_eligible(&self, image: Option<&str>) -> bool {
let Some(parsed) = image.and_then(parse_image_ref) else {
return false;
};
let qualified = match &parsed.domain {
Some(domain) => format!("{domain}/{}", parsed.path),
None => parsed.path.clone(),
};
self.repositories.contains(&qualified)
}
fn tag_blockers(&self, feature: &str, image: Option<&str>) -> Vec<String> {
let mut blockers = Vec::new();
let Some(image) = image else {
return blockers;
};
let parsed = parse_image_ref(image);
let version = image_tag_version(Some(image));
if self.require_floating_major_tag {
let digest_pinned = parsed.as_ref().is_some_and(|p| p.digest.is_some());
if digest_pinned {
blockers.push(format!(
"{feature} ships its fixes by republishing the major tag, which a digest pin freezes out. Move \"{image}\" to a floating major tag (e.g. \":16\") first."
));
} else if let Some(version) = version
&& version.minor.is_some()
{
blockers.push(format!(
"{feature} ships its fixes by republishing the major tag, which a minor pin freezes out. Move \"{image}\" to the major tag \":{}\" first.",
version.major
));
}
}
if self.pin_to_minor_version {
match version {
Some(v) if v.minor.is_none() => blockers.push(format!(
"This cluster pins every node to the source image's exact major.minor version, but \"{image}\" declares only a major. Retag it to the minor your database is actually running (e.g. \":{}.2\") first.",
v.major
)),
None => blockers.push(format!(
"This cluster pins every node to the source image's exact major.minor version, but no version can be read from \"{image}\". Retag it to the minor your database is actually running first."
)),
_ => {}
}
}
if !self.supported_image_major_versions.is_empty() {
let supported = self
.supported_image_major_versions
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ");
match version {
Some(v) if self.supported_image_major_versions.contains(&v.major) => {}
Some(v) => blockers.push(format!(
"No high-availability image is published for major version {}. Supported majors: {supported}.",
v.major
)),
None => blockers.push(format!(
"No version can be read from \"{image}\", so the cluster's node images cannot be pinned to it. Retag the service to a versioned tag first. Supported majors: {supported}."
)),
}
}
blockers
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn target<'a>(image: &'a str, has_start_command: bool) -> AdoptionTarget<'a> {
AdoptionTarget {
image: Some(image),
has_start_command,
}
}
fn postgres_pitr_rules() -> AdoptionRules {
rules_from_template(&json!({
"services": {
"root": {
"clusterRole": "root",
"adoptionImageEligibility": {
"label": "Point-in-time recovery",
"repositories": [
"ghcr.io/railwayapp-templates/postgres-ssl",
"ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni"
],
"requireImageEntrypoint": true,
"requireFloatingMajorTag": true
}
}
}
}))
}
fn minor_tagged_lineage_rules() -> AdoptionRules {
rules_from_template(&json!({
"services": {
"root": {
"clusterRole": "root",
"adoptionImageEligibility": {
"label": "High availability conversion",
"repositories": [
"ghcr.io/railwayapp-templates/mysql-ha/mysql",
"ghcr.io/railwayapp-templates/mysql-ha/mysql-wrapper"
],
"requireImageEntrypoint": true
}
}
}
}))
}
#[test]
fn reads_the_declarations_off_the_root_slot() {
let rules = postgres_pitr_rules();
assert_eq!(rules.label.as_deref(), Some("Point-in-time recovery"));
assert_eq!(rules.repositories.len(), 2);
assert!(rules.require_floating_major_tag);
assert!(rules.require_image_entrypoint);
let rules = minor_tagged_lineage_rules();
assert!(rules.require_image_entrypoint);
assert!(!rules.require_floating_major_tag);
}
#[test]
fn a_template_declaring_nothing_blocks_nothing() {
let rules = rules_from_template(&json!({"services": {"root": {"clusterRole": "root"}}}));
assert_eq!(rules, AdoptionRules::default());
assert!(
rules
.blockers(&target("anything/at-all:latest", true))
.is_empty()
);
assert_eq!(rules_from_template(&json!({})), AdoptionRules::default());
}
#[test]
fn eligible_postgres_image_on_a_major_tag_passes() {
let rules = postgres_pitr_rules();
assert!(
rules
.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:16",
false
))
.is_empty()
);
}
#[test]
fn postgres_minor_and_digest_pins_are_refused_with_the_remedy() {
let rules = postgres_pitr_rules();
let blockers = rules.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:16.10",
false,
));
assert_eq!(blockers.len(), 1);
assert!(blockers[0].contains("minor pin"));
assert!(blockers[0].contains("\":16\""));
let blockers = rules.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl@sha256:abc123",
false,
));
assert_eq!(blockers.len(), 1);
assert!(blockers[0].contains("digest pin"));
}
#[test]
fn minor_pins_are_accepted_where_the_template_does_not_forbid_them() {
let rules = minor_tagged_lineage_rules();
assert!(
rules
.blockers(&target(
"ghcr.io/railwayapp-templates/mysql-ha/mysql:8.4",
false
))
.is_empty()
);
}
#[test]
fn a_start_command_blocks_only_where_the_entrypoint_is_required() {
let rules = postgres_pitr_rules();
let blockers = rules.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:16",
true,
));
assert_eq!(blockers.len(), 1);
assert!(blockers[0].contains("start command"));
let mut relaxed = rules.clone();
relaxed.require_image_entrypoint = false;
assert!(
relaxed
.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:16",
true
))
.is_empty()
);
}
#[test]
fn ineligible_images_are_matched_exactly_not_by_substring() {
let rules = postgres_pitr_rules();
let blockers = rules.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ha/haproxy:3",
false,
));
assert!(blockers.iter().any(|b| b.contains("not one of them")));
let blockers = rules.blockers(&target(
"evil.example.com/railwayapp-templates/postgres-ssl:16",
false,
));
assert!(blockers.iter().any(|b| b.contains("not one of them")));
let blockers = rules.blockers(&target("postgis/postgis:16.1", false));
assert_eq!(blockers.len(), 1);
}
#[test]
fn conversion_gates_on_the_templates_declared_majors() {
let rules = rules_from_template(&json!({
"services": {
"root": {
"clusterRole": "root",
"haConversionConfig": {
"supportedImageMajorVersions": [7, 8],
"pinToMinorVersion": true
}
}
}
}));
assert!(rules.repositories.is_empty());
assert!(rules.blockers(&target("redis:8.2", false)).is_empty());
let blockers = rules.blockers(&target("redis:8", false));
assert_eq!(blockers.len(), 1);
assert!(blockers[0].contains("major.minor"));
let blockers = rules.blockers(&target("redis:6.2", false));
assert!(blockers.iter().any(|b| b.contains("major version 6")));
assert!(blockers.iter().any(|b| b.contains("7, 8")));
let blockers = rules.blockers(&target("redis:latest", false));
assert_eq!(blockers.len(), 2);
}
#[test]
fn mysql_ha_conversion_declares_its_own_majors() {
let rules = rules_from_template(&json!({
"services": {
"root": {
"clusterRole": "root",
"haConversionConfig": {
"supportedImageMajorVersions": [8, 9],
"pinToMinorVersion": true
}
}
}
}));
assert!(rules.blockers(&target("mysql:8.4", false)).is_empty());
assert!(!rules.blockers(&target("mysql:5.7", false)).is_empty());
}
#[test]
fn postgres_ha_conversion_accepts_a_bare_major_because_it_does_not_pin_minors() {
let rules = rules_from_template(&json!({
"services": {
"root": {
"clusterRole": "root",
"haConversionConfig": { "supportedImageMajorVersions": [14, 15, 16, 17, 18] },
"adoptionImageEligibility": {
"label": "High availability conversion",
"repositories": ["ghcr.io/railwayapp-templates/postgres-ssl"]
}
}
}
}));
assert!(
rules
.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:16",
false
))
.is_empty()
);
assert!(
!rules
.blockers(&target(
"ghcr.io/railwayapp-templates/postgres-ssl:13",
false
))
.is_empty()
);
}
#[test]
fn companion_image_repositories_reads_every_slot_the_template_deploys() {
let repositories = companion_image_repositories(&serde_json::json!({
"services": {
"a": { "clusterRole": "root",
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:18" } },
"b": { "clusterRole": "replica",
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni:18" } },
"c": { "clusterRole": "internal",
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/etcd:3" } },
"d": { "clusterRole": "edge",
"source": { "image": "ghcr.io/railwayapp-templates/postgres-ha/haproxy:3" } },
"e": { "clusterRole": "edge" },
}
}));
assert_eq!(
repositories,
vec![
"ghcr.io/railwayapp-templates/postgres-ha/etcd".to_string(),
"ghcr.io/railwayapp-templates/postgres-ha/haproxy".to_string(),
"ghcr.io/railwayapp-templates/postgres-ha/postgres-patroni".to_string(),
]
);
assert!(companion_image_repositories(&serde_json::json!({})).is_empty());
}
#[test]
fn image_is_from_repository_matches_the_repository_exactly() {
let repositories = vec!["ghcr.io/railwayapp-templates/postgres-ha/haproxy".to_string()];
assert!(image_is_from_repository(
Some("ghcr.io/railwayapp-templates/postgres-ha/haproxy:3.2"),
&repositories
));
assert!(!image_is_from_repository(
Some("ghcr.io/railwayapp-templates/postgres-ha/haproxy-sidecar:1"),
&repositories
));
assert!(!image_is_from_repository(
Some("evil.example.com/railwayapp-templates/postgres-ha/haproxy:3.2"),
&repositories
));
assert!(!image_is_from_repository(None, &repositories));
}
}