railwayapp 4.51.2

Interact with Railway via CLI
use std::collections::{BTreeMap, HashMap};

use serde::{Deserialize, Serialize};

use super::ports::generate_port;
use crate::controllers::config::ServiceInstance;

#[derive(Debug, Clone)]
pub enum PortType {
    Http,
    Tcp,
}

#[derive(Debug, Clone)]
pub struct PortInfo {
    pub internal: i64,
    pub external: u16,
    pub public_port: u16,
    pub port_type: PortType,
}

#[derive(Debug, Serialize)]
pub struct DockerComposeFile {
    pub services: BTreeMap<String, DockerComposeService>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub networks: Option<DockerComposeNetworks>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub volumes: BTreeMap<String, DockerComposeVolume>,
}

#[derive(Debug, Serialize)]
pub struct DockerComposeVolume {}

#[derive(Debug, Serialize)]
pub struct DockerComposeNetworks {
    pub railway: DockerComposeNetwork,
}

#[derive(Debug, Serialize)]
pub struct DockerComposeNetwork {
    pub driver: String,
}

#[derive(Debug, Serialize)]
pub struct DockerComposeService {
    pub image: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub command: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub restart: Option<String>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub environment: BTreeMap<String, String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub ports: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub volumes: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub networks: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub extra_hosts: Vec<String>,
}

#[derive(Debug, Deserialize)]
pub struct ComposeServiceStatus {
    #[serde(rename = "Service")]
    pub service: String,
    #[serde(rename = "State")]
    pub state: String,
    #[serde(rename = "Health")]
    pub health: String,
    #[serde(rename = "ExitCode")]
    pub exit_code: i32,
}

pub fn volume_name(environment_id: &str, volume_id: &str) -> String {
    format!("railway_{}_{}", &environment_id[..8], &volume_id[..8])
}

pub fn build_port_infos(service_id: &str, svc: &ServiceInstance) -> Vec<PortInfo> {
    let mut port_infos = Vec::new();
    if let Some(networking) = &svc.networking {
        for config in networking.service_domains.values().flatten() {
            if let Some(port) = config.port {
                if !port_infos.iter().any(|p: &PortInfo| p.internal == port) {
                    let private_port = generate_port(service_id, port);
                    let public_port = generate_port(service_id, port + 10000);
                    port_infos.push(PortInfo {
                        internal: port,
                        external: private_port,
                        public_port,
                        port_type: PortType::Http,
                    });
                }
            }
        }
        for port_str in networking.tcp_proxies.keys() {
            if let Ok(port) = port_str.parse::<i64>() {
                if !port_infos.iter().any(|p| p.internal == port) {
                    let ext_port = generate_port(service_id, port);
                    port_infos.push(PortInfo {
                        internal: port,
                        external: ext_port,
                        public_port: ext_port,
                        port_type: PortType::Tcp,
                    });
                }
            }
        }
    }
    port_infos
}

pub fn build_slug_port_mapping(service_id: &str, svc: &ServiceInstance) -> HashMap<i64, u16> {
    let mut mapping = HashMap::new();
    if let Some(networking) = &svc.networking {
        for config in networking.service_domains.values().flatten() {
            if let Some(port) = config.port {
                mapping
                    .entry(port)
                    .or_insert_with(|| generate_port(service_id, port));
            }
        }
        for port_str in networking.tcp_proxies.keys() {
            if let Ok(port) = port_str.parse::<i64>() {
                mapping
                    .entry(port)
                    .or_insert_with(|| generate_port(service_id, port));
            }
        }
    }
    mapping
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::controllers::config::{DomainConfig, ServiceInstance, ServiceNetworking};

    #[test]
    fn test_volume_name() {
        assert_eq!(
            volume_name("env-12345678-xxxx", "vol-abcdefgh-yyyy"),
            "railway_env-1234_vol-abcd"
        );
    }

    #[test]
    fn test_build_port_infos_with_http_domain() {
        let svc = ServiceInstance {
            networking: Some(ServiceNetworking {
                service_domains: BTreeMap::from([(
                    "example.up.railway.app".to_string(),
                    Some(DomainConfig { port: Some(8080) }),
                )]),
                ..Default::default()
            }),
            ..Default::default()
        };
        let ports = build_port_infos("svc-123", &svc);
        assert_eq!(ports.len(), 1);
        assert_eq!(ports[0].internal, 8080);
        assert!(matches!(ports[0].port_type, PortType::Http));
    }

    #[test]
    fn test_build_port_infos_with_tcp_proxy() {
        let svc = ServiceInstance {
            networking: Some(ServiceNetworking {
                tcp_proxies: BTreeMap::from([("6379".to_string(), None)]),
                ..Default::default()
            }),
            ..Default::default()
        };
        let ports = build_port_infos("redis-svc", &svc);
        assert_eq!(ports.len(), 1);
        assert_eq!(ports[0].internal, 6379);
        assert!(matches!(ports[0].port_type, PortType::Tcp));
    }

    #[test]
    fn test_build_port_infos_deduplicates() {
        let svc = ServiceInstance {
            networking: Some(ServiceNetworking {
                service_domains: BTreeMap::from([
                    (
                        "a.railway.app".to_string(),
                        Some(DomainConfig { port: Some(3000) }),
                    ),
                    (
                        "b.railway.app".to_string(),
                        Some(DomainConfig { port: Some(3000) }),
                    ),
                ]),
                ..Default::default()
            }),
            ..Default::default()
        };
        let ports = build_port_infos("svc", &svc);
        assert_eq!(ports.len(), 1);
    }

    #[test]
    fn test_build_slug_port_mapping() {
        let svc = ServiceInstance {
            networking: Some(ServiceNetworking {
                service_domains: BTreeMap::from([(
                    "example.railway.app".to_string(),
                    Some(DomainConfig { port: Some(8080) }),
                )]),
                tcp_proxies: BTreeMap::from([("5432".to_string(), None)]),
                ..Default::default()
            }),
            ..Default::default()
        };
        let mapping = build_slug_port_mapping("svc-123", &svc);
        assert!(mapping.contains_key(&8080));
        assert!(mapping.contains_key(&5432));
    }
}