#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SwitchoverMechanism {
Patroni,
DeclaredHttp,
}
#[derive(Debug, Clone, Copy)]
pub struct HaCompanion {
pub template_code: &'static str,
pub legacy_active_variable: Option<&'static str>,
pub switchover: SwitchoverMechanism,
}
#[derive(Debug, Clone, Copy)]
pub struct PitrSpec {
pub template_code: &'static str,
pub archive_var_prefix: &'static str,
}
impl PitrSpec {
pub fn archive_gate_variable(&self) -> String {
format!("{}BUCKET", self.archive_var_prefix)
}
}
#[derive(Debug, Clone, Copy)]
pub struct PoolingSpec {
pub template_code: &'static str,
pub image_identifier: &'static str,
}
#[derive(Debug, Clone, Copy)]
pub struct DatabaseEngine {
pub key: &'static str,
pub display_name: &'static str,
pub ha: Option<HaCompanion>,
pub pitr: Option<PitrSpec>,
pub pooling: Option<PoolingSpec>,
}
pub const POSTGRES: DatabaseEngine = DatabaseEngine {
key: "postgres",
display_name: "Postgres",
ha: Some(HaCompanion {
template_code: "postgres-ha",
legacy_active_variable: Some("PATRONI_ENABLED"),
switchover: SwitchoverMechanism::Patroni,
}),
pitr: Some(PitrSpec {
template_code: "postgres-pitr",
archive_var_prefix: "WAL_ARCHIVE_",
}),
pooling: Some(PoolingSpec {
template_code: "postgres-with-pgbouncer",
image_identifier: "pgbouncer",
}),
};
pub const MYSQL: DatabaseEngine = DatabaseEngine {
key: "mysql",
display_name: "MySQL",
ha: Some(HaCompanion {
template_code: "mysql-ha",
legacy_active_variable: None,
switchover: SwitchoverMechanism::DeclaredHttp,
}),
pitr: None,
pooling: None,
};
pub const REDIS: DatabaseEngine = DatabaseEngine {
key: "redis",
display_name: "Redis",
ha: Some(HaCompanion {
template_code: "redis-ha",
legacy_active_variable: None,
switchover: SwitchoverMechanism::DeclaredHttp,
}),
pitr: None,
pooling: None,
};
impl DatabaseEngine {
pub fn ha_template_code_for(&self, declared: Option<&str>) -> Option<String> {
declared
.map(str::to_string)
.or_else(|| self.ha.map(|ha| ha.template_code.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub domain: Option<String>,
pub path: String,
pub tag: Option<String>,
pub digest: Option<String>,
}
pub fn parse_image_ref(image: &str) -> Option<ImageRef> {
let image = image.trim();
if image.is_empty() {
return None;
}
let (remainder, digest) = match image.split_once('@') {
Some((remainder, digest)) if !digest.is_empty() => (remainder, Some(digest.to_string())),
Some(_) => return None,
None => (image, None),
};
let (mut domain, path_and_tag) = match remainder.split_once('/') {
Some((first, rest)) if is_registry_host(first) => (Some(first.to_string()), rest),
_ => (None, remainder),
};
let last_slash = path_and_tag.rfind('/').map(|i| i + 1).unwrap_or(0);
let (path, tag) = match path_and_tag[last_slash..].split_once(':') {
Some((_, tag)) => {
let cut = last_slash + path_and_tag[last_slash..].find(':').unwrap_or(0);
(
path_and_tag[..cut].to_string(),
(!tag.is_empty()).then(|| tag.to_string()),
)
}
None => (path_and_tag.to_string(), None),
};
if path.is_empty() {
return None;
}
if domain.as_deref() == Some("docker.io") {
domain = None;
}
Some(ImageRef {
domain,
path,
tag,
digest,
})
}
fn is_registry_host(segment: &str) -> bool {
segment == "localhost" || segment.contains('.') || segment.contains(':')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageTagVersion {
pub major: i64,
pub minor: Option<i64>,
}
fn leading_number(component: &str) -> Option<i64> {
let digits: String = component.chars().take_while(char::is_ascii_digit).collect();
digits.parse().ok()
}
pub fn image_tag_version(image: Option<&str>) -> Option<ImageTagVersion> {
let tag = parse_image_ref(image?)?.tag?;
let mut parts = tag.split('.');
let major = leading_number(parts.next()?)?;
let minor = parts.next().and_then(leading_number);
Some(ImageTagVersion { major, minor })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_registry_qualified_references() {
let parsed = parse_image_ref("ghcr.io/railwayapp-templates/postgres-ssl:16").unwrap();
assert_eq!(parsed.domain.as_deref(), Some("ghcr.io"));
assert_eq!(parsed.path, "railwayapp-templates/postgres-ssl");
assert_eq!(parsed.tag.as_deref(), Some("16"));
assert!(parsed.digest.is_none());
}
#[test]
fn parses_docker_library_references_without_a_host() {
let parsed = parse_image_ref("mysql:8.4").unwrap();
assert!(parsed.domain.is_none());
assert_eq!(parsed.path, "mysql");
assert_eq!(parsed.tag.as_deref(), Some("8.4"));
assert_eq!(
parse_image_ref("docker.io/library/redis:8").unwrap().domain,
None
);
}
#[test]
fn parses_ports_and_digests_without_mistaking_them_for_tags() {
let parsed = parse_image_ref("localhost:5000/team/db:16").unwrap();
assert_eq!(parsed.domain.as_deref(), Some("localhost:5000"));
assert_eq!(parsed.path, "team/db");
assert_eq!(parsed.tag.as_deref(), Some("16"));
let parsed = parse_image_ref("ghcr.io/owner/img@sha256:abc123").unwrap();
assert_eq!(parsed.path, "owner/img");
assert!(parsed.tag.is_none());
assert_eq!(parsed.digest.as_deref(), Some("sha256:abc123"));
}
#[test]
fn image_tag_version_reads_major_and_optional_minor() {
let v = image_tag_version(Some("ghcr.io/railwayapp-templates/mysql-ha/mysql:8.4")).unwrap();
assert_eq!(v.major, 8);
assert_eq!(v.minor, Some(4));
let v = image_tag_version(Some("ghcr.io/railwayapp-templates/postgres-ssl:16")).unwrap();
assert_eq!(v.major, 16);
assert_eq!(v.minor, None);
let v = image_tag_version(Some("redis:8.2-alpine")).unwrap();
assert_eq!(v.major, 8);
assert_eq!(v.minor, Some(2));
let v = image_tag_version(Some("redis:7-bookworm")).unwrap();
assert_eq!(v.major, 7);
assert_eq!(v.minor, None);
let v = image_tag_version(Some("postgres:16.4.2")).unwrap();
assert_eq!(v.major, 16);
assert_eq!(v.minor, Some(4));
assert!(image_tag_version(Some("ghcr.io/x/y:v8.2")).is_none());
assert!(image_tag_version(Some("ghcr.io/x/y:latest")).is_none());
assert!(image_tag_version(Some("ghcr.io/x/y")).is_none());
assert!(image_tag_version(Some("ghcr.io/x/y@sha256:23a8ff")).is_none());
assert!(image_tag_version(None).is_none());
}
#[test]
fn ha_template_code_prefers_the_services_own_declaration() {
assert_eq!(
POSTGRES.ha_template_code_for(Some("postgres-ha-custom")),
Some("postgres-ha-custom".to_string())
);
assert_eq!(
POSTGRES.ha_template_code_for(None),
Some("postgres-ha".to_string())
);
assert_eq!(
MYSQL.ha_template_code_for(None),
Some("mysql-ha".to_string())
);
assert_eq!(
REDIS.ha_template_code_for(None),
Some("redis-ha".to_string())
);
}
#[test]
fn pitr_gate_variable_follows_the_declared_prefix() {
assert_eq!(
POSTGRES.pitr.unwrap().archive_gate_variable(),
"WAL_ARCHIVE_BUCKET"
);
}
#[test]
fn capability_declarations_match_what_ships() {
assert!(POSTGRES.pitr.is_some());
assert!(POSTGRES.pooling.is_some());
assert!(MYSQL.pitr.is_none());
assert!(MYSQL.pooling.is_none());
assert!(REDIS.pitr.is_none());
assert!(REDIS.pooling.is_none());
assert!(POSTGRES.ha.is_some());
assert!(MYSQL.ha.is_some());
assert!(REDIS.ha.is_some());
}
}