diff --git a/src/cluster.rs b/src/cluster.rs index 34c20cf..175a4d8 100644 --- a/src/cluster.rs +++ b/src/cluster.rs @@ -55,7 +55,7 @@ fn handle_create(ctx: &ForgeContext<'_>, name: &str, writer: &mut dyn Write) -> } let _lock = lock::acquire(&ctx.state_dir)?; let mut state = state::load(&ctx.state_dir)?; - let created = create_if_missing(ctx, &kind_name, &cluster.nodes, &mut state, name)?; + let created = create_if_missing(ctx, &kind_name, cluster, &mut state, name)?; state::save(&ctx.state_dir, &state)?; if created { report_created(writer, name, &kind_name, &ctx.format) @@ -203,7 +203,7 @@ fn cluster_kind_name(ctx: &ForgeContext<'_>, name: &str) -> String { fn create_if_missing( ctx: &ForgeContext<'_>, kind_name: &str, - nodes: &crate::config::NodeConfig, + cluster: &crate::config::ClusterSpec, st: &mut state::ForgeState, name: &str, ) -> Result { @@ -213,7 +213,13 @@ fn create_if_missing( } upsert_cluster_state(st, name, kind_name, ClusterPhase::Creating); state::save(&ctx.state_dir, st)?; - kind_ops::create_cluster(ctx.runner, kind_name, nodes, &ctx.state_dir, None)?; + let config = kind_ops::CreateClusterConfig { + nodes: &cluster.nodes, + ports: &cluster.ports, + config_dir: &ctx.state_dir, + docker_network: None, + }; + kind_ops::create_cluster(ctx.runner, kind_name, &config)?; upsert_cluster_state(st, name, kind_name, ClusterPhase::Running); Ok(true) } diff --git a/src/cluster/kind.rs b/src/cluster/kind.rs index 56f9e0c..35256bb 100644 --- a/src/cluster/kind.rs +++ b/src/cluster/kind.rs @@ -8,7 +8,7 @@ use std::collections::BTreeMap; use crate::{ command::runner::{CommandOutput, CommandRunner, CommandSpec}, - config::NodeConfig, + config::{NodeConfig, PortMapping}, error::ForgeError, }; @@ -40,6 +40,18 @@ pub fn cluster_exists(runner: &dyn CommandRunner, kind_name: &str) -> Result { + /// Node layout for the cluster. + pub nodes: &'cfg NodeConfig, + /// Port mappings to add to the first control-plane node. + pub ports: &'cfg [PortMapping], + /// Directory where config files are written. + pub config_dir: &'cfg std::path::Path, + /// Optional Docker network to join via `KIND_EXPERIMENTAL_DOCKER_NETWORK`. + pub docker_network: Option<&'cfg str>, +} + /// Create a KIND cluster with a generated config. /// /// When `docker_network` is `Some`, the cluster nodes join that @@ -51,13 +63,11 @@ pub fn cluster_exists(runner: &dyn CommandRunner, kind_name: &str) -> Result, + config: &CreateClusterConfig<'_>, ) -> Result<(), ForgeError> { - let config_yaml = generate_kind_config(nodes); - let config_path = write_kind_config(config_dir, kind_name, &config_yaml)?; - let result = run_create(runner, kind_name, &config_path, docker_network); + let config_yaml = generate_kind_config(config.nodes, config.ports); + let config_path = write_kind_config(config.config_dir, kind_name, &config_yaml)?; + let result = run_create(runner, kind_name, &config_path, config.docker_network); cleanup_kind_config(&config_path); result } @@ -126,10 +136,17 @@ pub fn run_kubectl(runner: &dyn CommandRunner, kind_name: &str, args: &[String]) // --------------------------------------------------------------- /// Generate a KIND cluster config YAML from a [`NodeConfig`]. -pub fn generate_kind_config(nodes: &NodeConfig) -> String { +/// +/// When `ports` is non-empty, `extraPortMappings` entries are added +/// to the first control-plane node (KIND only supports port mappings +/// on control-plane nodes). +pub fn generate_kind_config(nodes: &NodeConfig, ports: &[PortMapping]) -> String { let mut yaml = String::from("kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n"); - for _ in 0..nodes.control_planes { + for idx in 0..nodes.control_planes { yaml.push_str(" - role: control-plane\n"); + if idx == 0 && !ports.is_empty() { + write_port_mappings(&mut yaml, ports); + } } for _ in 0..nodes.workers { yaml.push_str(" - role: worker\n"); @@ -137,6 +154,24 @@ pub fn generate_kind_config(nodes: &NodeConfig) -> String { yaml } +/// Append `extraPortMappings` entries for a control-plane node. +fn write_port_mappings(yaml: &mut String, ports: &[PortMapping]) { + use std::fmt::Write as _; + yaml.push_str(" extraPortMappings:\n"); + for port in ports { + let _written = write!( + yaml, + " - hostPort: {}\n containerPort: {}\n protocol: {}\n", + port.host, + port.container, + port.protocol.to_uppercase(), + ); + if let Some(addr) = &port.bind_address { + let _addr_written = writeln!(yaml, " listenAddress: \"{addr}\""); + } + } +} + // --------------------------------------------------------------- // Private helpers // --------------------------------------------------------------- @@ -292,7 +327,7 @@ mod tests { #[test] fn generate_kind_config_default_nodes() { let nodes = NodeConfig::default(); - let yaml = generate_kind_config(&nodes); + let yaml = generate_kind_config(&nodes, &[]); assert!(yaml.contains("control-plane"), "should have control-plane"); let cp_count = yaml.matches("control-plane").count(); assert_eq!(cp_count, 1, "default should have 1 control-plane, got {cp_count}"); @@ -305,13 +340,62 @@ mod tests { control_planes: 3, workers: 2, }; - let yaml = generate_kind_config(&nodes); + let yaml = generate_kind_config(&nodes, &[]); let cp_count = yaml.matches("control-plane").count(); let w_count = yaml.matches("worker").count(); assert_eq!(cp_count, 3, "should have 3 control-planes, got {cp_count}"); assert_eq!(w_count, 2, "should have 2 workers, got {w_count}"); } + #[test] + fn generate_kind_config_with_port_mappings() { + let nodes = NodeConfig::default(); + let ports = vec![ + PortMapping { + bind_address: None, + host: 13000, + container: 30300, + protocol: "tcp".to_owned(), + }, + PortMapping { + bind_address: Some("127.0.0.1".to_owned()), + host: 19090, + container: 30909, + protocol: "udp".to_owned(), + }, + ]; + let yaml = generate_kind_config(&nodes, &ports); + assert!(yaml.contains("extraPortMappings:"), "should have extraPortMappings"); + assert!(yaml.contains("hostPort: 13000"), "should map first host port"); + assert!(yaml.contains("containerPort: 30300"), "should map first container port"); + assert!(yaml.contains("hostPort: 19090"), "should map second host port"); + assert!(yaml.contains("protocol: UDP"), "should uppercase protocol"); + assert!( + yaml.contains("listenAddress: \"127.0.0.1\""), + "should include listen address" + ); + } + + #[test] + fn generate_kind_config_port_mappings_only_on_first_control_plane() { + let nodes = NodeConfig { + control_planes: 2, + workers: 0, + }; + let ports = vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }]; + let yaml = generate_kind_config(&nodes, &ports); + assert_eq!( + yaml.matches("extraPortMappings:").count(), + 1, + "port mappings should only appear on the first control-plane node" + ); + } + #[test] fn parse_cluster_list_handles_empty() { let output = CommandOutput { diff --git a/src/command/up.rs b/src/command/up.rs index af0bbc0..b4ac4af 100644 --- a/src/command/up.rs +++ b/src/command/up.rs @@ -217,7 +217,13 @@ fn create_if_missing( } ensure_state_entry(state, &cluster.name, kind_name, ClusterPhase::Creating); checkpoint(ctx, state)?; - kind_ops::create_cluster(ctx.runner, kind_name, &cluster.nodes, &ctx.state_dir, docker_network)?; + let cluster_config = kind_ops::CreateClusterConfig { + nodes: &cluster.nodes, + ports: &cluster.ports, + config_dir: &ctx.state_dir, + docker_network, + }; + kind_ops::create_cluster(ctx.runner, kind_name, &cluster_config)?; ensure_state_entry(state, &cluster.name, kind_name, ClusterPhase::Running); Ok(true) } diff --git a/src/config.rs b/src/config.rs index 2bffde6..50ec746 100644 --- a/src/config.rs +++ b/src/config.rs @@ -163,6 +163,13 @@ pub struct ClusterSpec { /// Node layout for this Kind cluster. #[serde(default)] pub nodes: NodeConfig, + /// Host-to-node port mappings (Kind `extraPortMappings`). + /// + /// Maps host ports to Kind node ports via `extraPortMappings` in the + /// Kind cluster config. Required on macOS where `MetalLB` `LoadBalancer` + /// IPs are unreachable from the host. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ports: Vec, /// Stacks to apply to this cluster (must exist in `spec.stacks`). #[serde(default)] pub stacks: Vec, diff --git a/src/config/validate.rs b/src/config/validate.rs index 104a8b4..72f6653 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -6,7 +6,9 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use crate::{ - config::{API_VERSION, ForgeConfig, HealthCheck, KIND, NetworkMode, RuntimeProvider, ServiceSpec, StepSpec}, + config::{ + API_VERSION, ForgeConfig, HealthCheck, KIND, NetworkMode, PortMapping, RuntimeProvider, ServiceSpec, StepSpec, + }, error::ForgeError, }; @@ -23,12 +25,13 @@ pub fn validate(config: &ForgeConfig) -> Result<(), ForgeError> { check_cluster_names(config)?; check_cluster_prefix(config)?; check_cluster_nodes(config)?; + check_cluster_ports(config)?; check_service_names(config)?; check_services(config)?; check_service_deps(config)?; check_service_auto_start_deps(config)?; check_service_dep_cycles(config)?; - check_service_port_conflicts(config)?; + check_host_port_conflicts(config)?; check_stack_names(config)?; check_cluster_stack_refs(config)?; check_stack_steps(config)?; @@ -199,6 +202,40 @@ fn check_cluster_nodes(config: &ForgeConfig) -> Result<(), ForgeError> { Ok(()) } +/// Validate every cluster's port mappings in isolation. +/// +/// Conflicts *between* mappings are not checked here: cluster and service +/// mappings compete for the same host bindings, so both go through +/// [`check_host_port_conflicts`]. +fn check_cluster_ports(config: &ForgeConfig) -> Result<(), ForgeError> { + for cluster in &config.spec.clusters { + for pm in &cluster.ports { + check_cluster_port(pm, &cluster.name)?; + } + } + Ok(()) +} + +/// Per-port checks that do not depend on any other port: non-zero values, a +/// parseable bind address, and a protocol KIND accepts. +fn check_cluster_port(pm: &PortMapping, cluster_name: &str) -> Result<(), ForgeError> { + if pm.host == 0 || pm.container == 0 { + return Err(ForgeError::Validation(format!( + "cluster {cluster_name:?}: port mapping host and container ports must not be zero" + ))); + } + if let Some(addr) = pm + .bind_address + .as_ref() + .filter(|bind| bind.parse::().is_err()) + { + return Err(ForgeError::Validation(format!( + "cluster {cluster_name:?}: bind address {addr:?} is not a valid IP" + ))); + } + check_cluster_port_protocol(&pm.protocol, cluster_name) +} + /// Service names must be unique and DNS-label-valid. fn check_service_names(config: &ForgeConfig) -> Result<(), ForgeError> { let mut seen = BTreeSet::new(); @@ -274,6 +311,21 @@ fn check_port_bind_address(addr: Option<&String>, svc_name: &str) -> Result<(), Ok(()) } +/// KIND accepts only TCP, UDP, and SCTP for `extraPortMappings`. The value is +/// upper-cased verbatim into the generated cluster config, so anything else +/// surfaces as an opaque `kind create cluster` failure instead of a config +/// error. Unlike service ports (TCP only), all three are valid here. +fn check_cluster_port_protocol(protocol: &str, cluster_name: &str) -> Result<(), ForgeError> { + const VALID_PROTOCOLS: [&str; 3] = ["tcp", "udp", "sctp"]; + if !VALID_PROTOCOLS.contains(&protocol.to_lowercase().as_str()) { + return Err(ForgeError::Validation(format!( + "cluster {cluster_name:?}: unsupported port protocol {protocol:?} \ + (expected tcp, udp, or sctp)" + ))); + } + Ok(()) +} + /// F3 only allows TCP port protocol. fn check_port_protocol_tcp(protocol: &str, svc_name: &str) -> Result<(), ForgeError> { if protocol != "tcp" { @@ -595,34 +647,84 @@ fn parse_bind_addr(addr: Option<&str>) -> BindAddr { } } -/// True when a candidate binding overlaps any already-seen binding. -fn binds_conflict(seen: &[BindAddr], candidate: &BindAddr) -> bool { +/// What claimed a host binding, so a conflict can name both sides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PortOwner<'cfg> { + /// A cluster `extraPortMappings` entry. + Cluster(&'cfg str), + /// A service port mapping. + Service(&'cfg str), +} + +impl std::fmt::Display for PortOwner<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + Self::Cluster(name) => write!(f, "cluster {name:?}"), + Self::Service(name) => write!(f, "service {name:?}"), + } + } +} + +/// Every binding claimed on one `(host port, protocol)` pair, with its claimant. +type BindingRegistry<'cfg> = BTreeMap<(u16, String), Vec<(BindAddr, PortOwner<'cfg>)>>; + +/// The first already-seen binding a candidate overlaps, if any. +fn conflicting_owner<'cfg>(seen: &[(BindAddr, PortOwner<'cfg>)], candidate: &BindAddr) -> Option> { seen.iter() - .any(|old| *old == BindAddr::Wildcard || *candidate == BindAddr::Wildcard || old == candidate) + .find(|(old, _)| *old == BindAddr::Wildcard || *candidate == BindAddr::Wildcard || old == candidate) + .map(|&(_, owner)| owner) +} + +/// Render a conflict, naming the binding and both claimants. +fn describe_port_conflict(owner: PortOwner<'_>, other: PortOwner<'_>, port: &PortMapping) -> String { + let binding = format!( + "{}:{}/{}", + port.bind_address.as_deref().unwrap_or("0.0.0.0"), + port.host, + port.protocol.to_lowercase(), + ); + if owner == other { + format!("{owner}: duplicate host port binding {binding}") + } else { + format!("{owner}: host port binding {binding} is already mapped by {other}") + } } -/// Reject overlapping host-port bindings across services. +/// Reject overlapping host-port bindings anywhere in the environment. /// -/// A wildcard bind (unset, `0.0.0.0`, or `::`) publishes on all -/// interfaces, so it conflicts with every other binding of the same -/// host port and protocol; two specific addresses conflict only when -/// they are the same IP. -fn check_service_port_conflicts(config: &ForgeConfig) -> Result<(), ForgeError> { - let mut seen: BTreeMap<(u16, String), Vec> = BTreeMap::new(); - for svc in &config.spec.services { - for port in &svc.ports { - let candidate = parse_bind_addr(port.bind_address.as_deref()); - let entry = seen.entry((port.host, port.protocol.clone())).or_default(); - if binds_conflict(entry, &candidate) { - return Err(ForgeError::Validation(format!( - "duplicate host port binding: {}:{}/{}", - port.bind_address.as_deref().unwrap_or("0.0.0.0"), - port.host, - port.protocol, - ))); - } - entry.push(candidate); +/// Cluster `extraPortMappings` and service ports are published on the same +/// host by the same container runtime, so they compete for one set of +/// bindings and are checked against one registry. Two registries would let a +/// cluster and a service both claim `8080/tcp`, pass validation, and fail +/// later during `forge up` with an opaque "port is already allocated". +/// +/// A binding is `(host port, protocol, bind address)`: +/// +/// - Protocol is compared case-insensitively, and TCP and UDP on the same port are distinct bindings that both Docker +/// and KIND accept. +/// - A wildcard bind (unset, `0.0.0.0`, or `::`) publishes on every interface, so it conflicts with any other binding +/// of that port and protocol; two specific addresses conflict only when they are equal. +fn check_host_port_conflicts(config: &ForgeConfig) -> Result<(), ForgeError> { + let clusters = config.spec.clusters.iter().flat_map(|cluster| { + cluster + .ports + .iter() + .map(|port| (port, PortOwner::Cluster(cluster.name.as_str()))) + }); + let services = config.spec.services.iter().flat_map(|svc| { + svc.ports + .iter() + .map(|port| (port, PortOwner::Service(svc.name.as_str()))) + }); + + let mut seen: BindingRegistry<'_> = BTreeMap::new(); + for (port, owner) in clusters.chain(services) { + let candidate = parse_bind_addr(port.bind_address.as_deref()); + let entry = seen.entry((port.host, port.protocol.to_lowercase())).or_default(); + if let Some(other) = conflicting_owner(entry, &candidate) { + return Err(ForgeError::Validation(describe_port_conflict(owner, other, port))); } + entry.push((candidate, owner)); } Ok(()) } @@ -1155,7 +1257,7 @@ mod tests { use super::*; use crate::config::{ CertificateConfig, ClusterSpec, EnvironmentSpec, HealthCheckType, Metadata, NetworkConfig, NodeConfig, - PortMapping, RestartPolicy, RuntimeConfig, StackSpec, VolumeMount, + RestartPolicy, RuntimeConfig, StackSpec, VolumeMount, }; /// Build a minimal valid config for test modification. @@ -1295,6 +1397,7 @@ mod tests { config.spec.clusters = vec![ClusterSpec { name: "hub".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1309,6 +1412,7 @@ mod tests { let cluster = ClusterSpec { name: "dupe".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }; @@ -1329,6 +1433,7 @@ mod tests { config.spec.clusters = vec![ClusterSpec { name: "c1".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: vec!["nonexistent".to_owned()], properties: BTreeMap::new(), }]; @@ -1355,6 +1460,7 @@ mod tests { config.spec.clusters = vec![ClusterSpec { name: "c1".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: vec!["base".to_owned(), "base".to_owned()], properties: BTreeMap::new(), }]; @@ -1374,6 +1480,7 @@ mod tests { config.spec.clusters = vec![ClusterSpec { name: "c1".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::from([( "model".to_owned(), @@ -1402,6 +1509,7 @@ mod tests { config.spec.clusters = vec![ClusterSpec { name: "hub".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: vec!["base".to_owned()], properties: BTreeMap::new(), }]; @@ -1477,6 +1585,7 @@ mod tests { control_planes: 0, workers: 1, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1499,6 +1608,7 @@ mod tests { control_planes: 10, workers: 0, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1521,6 +1631,7 @@ mod tests { control_planes: 1, workers: u32::MAX, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1543,6 +1654,7 @@ mod tests { control_planes: 9, workers: 100, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1551,6 +1663,338 @@ mod tests { }); } + #[test] + fn cluster_port_zero_host_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 0, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + let result = validate(&config); + assert!(result.is_err(), "zero host port should be rejected"); + } + + #[test] + fn cluster_duplicate_host_port_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![ + PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }, + PortMapping { + bind_address: None, + host: 8080, + container: 30081, + protocol: "tcp".to_owned(), + }, + ], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("duplicate host port"), + "expected duplicate port error, got: {msg}" + ); + } + + /// Two clusters claiming the same host port collide on the host, so this + /// must fail at validation rather than at `kind create cluster` time. + #[test] + fn host_port_claimed_by_two_clusters_rejected() { + let mut config = base_config(); + for name in ["alpha", "beta"] { + config.spec.clusters.push(ClusterSpec { + name: name.to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + } + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("already mapped by cluster"), + "expected cross-cluster port conflict, got: {msg}" + ); + } + + /// Distinct host ports across clusters are the normal case and must pass. + #[test] + fn distinct_host_ports_across_clusters_pass() { + let mut config = base_config(); + for (name, host) in [("alpha", 8080_u16), ("beta", 8081_u16)] { + config.spec.clusters.push(ClusterSpec { + name: name.to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + } + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + /// Build a cluster with the given port mappings. + fn test_cluster_with_ports(name: &str, ports: Vec) -> ClusterSpec { + ClusterSpec { + name: name.to_owned(), + nodes: NodeConfig::default(), + ports, + stacks: Vec::new(), + properties: BTreeMap::new(), + } + } + + /// Build a cluster port mapping, defaulting container port and bind address. + fn cluster_port(host: u16, protocol: &str, bind_address: Option<&str>) -> PortMapping { + PortMapping { + bind_address: bind_address.map(str::to_owned), + host, + container: 30080, + protocol: protocol.to_owned(), + } + } + + /// A cluster mapping and a service mapping are published on the same host + /// by the same runtime, so one binding cannot serve both. Checked against + /// one registry, or this passes validation and fails during `forge up`. + #[test] + fn cluster_and_service_claiming_one_binding_rejected() { + let mut config = base_config(); + config + .spec + .clusters + .push(test_cluster_with_ports("alpha", vec![cluster_port(8080, "tcp", None)])); + config + .spec + .services + .push(test_service_with_port(cluster_port(8080, "tcp", None))); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("already mapped by cluster") && msg.contains("service"), + "expected a cluster/service binding conflict, got: {msg}" + ); + } + + /// TCP and UDP on one port are distinct bindings that Docker and KIND both + /// accept, so keying conflicts on the port number alone rejects valid + /// configurations. + #[test] + fn cluster_tcp_and_udp_on_one_port_pass() { + let mut config = base_config(); + config.spec.clusters.push(test_cluster_with_ports( + "alpha", + vec![cluster_port(8080, "tcp", None), cluster_port(8080, "udp", None)], + )); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + /// The same split holds across a cluster and a service. + #[test] + fn cluster_udp_and_service_tcp_on_one_port_pass() { + let mut config = base_config(); + config + .spec + .clusters + .push(test_cluster_with_ports("alpha", vec![cluster_port(8080, "udp", None)])); + config + .spec + .services + .push(test_service_with_port(cluster_port(8080, "tcp", None))); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + /// Protocol is compared case-insensitively: KIND upper-cases the value into + /// the generated cluster config, so `TCP` and `tcp` are one binding. + #[test] + fn cluster_port_protocol_case_insensitive_conflict() { + let mut config = base_config(); + config.spec.clusters.push(test_cluster_with_ports( + "alpha", + vec![cluster_port(8080, "TCP", None), cluster_port(8080, "tcp", None)], + )); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("duplicate host port binding"), + "expected a duplicate binding error, got: {msg}" + ); + } + + /// Two specific addresses on one port do not overlap. + #[test] + fn cluster_and_service_on_distinct_bind_addresses_pass() { + let mut config = base_config(); + config.spec.clusters.push(test_cluster_with_ports( + "alpha", + vec![cluster_port(8080, "tcp", Some("127.0.0.1"))], + )); + config + .spec + .services + .push(test_service_with_port(cluster_port(8080, "tcp", Some("127.0.0.2")))); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + /// A wildcard bind publishes on every interface, so it overlaps a specific + /// address on the same port and protocol even across a cluster and a + /// service. + #[test] + fn wildcard_service_conflicts_with_specific_cluster_binding() { + let mut config = base_config(); + config.spec.clusters.push(test_cluster_with_ports( + "alpha", + vec![cluster_port(8080, "tcp", Some("127.0.0.1"))], + )); + config + .spec + .services + .push(test_service_with_port(cluster_port(8080, "tcp", None))); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("already mapped by cluster"), + "expected a wildcard/specific overlap, got: {msg}" + ); + } + + #[test] + fn cluster_port_invalid_protocol_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "http".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("unsupported port protocol"), + "expected protocol error, got: {msg}" + ); + } + + /// KIND accepts UDP and SCTP for extraPortMappings even though service + /// ports are TCP-only, so neither may be rejected here. + #[test] + fn cluster_port_udp_and_sctp_accepted() { + for protocol in ["udp", "SCTP"] { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: protocol.to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + } + + #[test] + fn cluster_port_invalid_bind_address_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: Some("not-an-ip".to_owned()), + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!(msg.contains("bind address"), "expected bind address error, got: {msg}"); + } + + #[test] + fn cluster_port_valid_bind_address_passes() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: Some("127.0.0.1".to_owned()), + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: Vec::new(), + properties: BTreeMap::new(), + }); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + #[test] fn invalid_service_protocol_rejected() { let mut config = base_config(); @@ -2003,7 +2447,10 @@ spec: std::process::abort(); }; let msg = err.to_string(); - assert!(msg.contains("duplicate"), "expected duplicate port error, got: {msg}"); + assert!( + msg.contains("already mapped by service") && msg.contains("8080/tcp"), + "expected a service binding conflict naming both sides, got: {msg}" + ); } /// Build two single-port services with the given bind addresses on port 8080. @@ -2033,7 +2480,10 @@ spec: std::process::abort(); }; let msg = err.to_string(); - assert!(msg.contains("duplicate"), "expected duplicate port error, got: {msg}"); + assert!( + msg.contains("already mapped by service") && msg.contains("8080/tcp"), + "expected a service binding conflict naming both sides, got: {msg}" + ); } #[test] diff --git a/src/stack.rs b/src/stack.rs index a9dde8a..f4e0902 100644 --- a/src/stack.rs +++ b/src/stack.rs @@ -751,6 +751,7 @@ mod tests { clusters: vec![ClusterSpec { name: "hub".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: vec!["base".to_owned()], properties: BTreeMap::new(), }], diff --git a/src/stack/engine.rs b/src/stack/engine.rs index 66da8ed..ff760ad 100644 --- a/src/stack/engine.rs +++ b/src/stack/engine.rs @@ -1243,6 +1243,7 @@ mod tests { let cluster = ClusterSpec { name: "provider-east".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }; diff --git a/tests/fixtures/port-mappings.yaml b/tests/fixtures/port-mappings.yaml new file mode 100644 index 0000000..df24294 --- /dev/null +++ b/tests/fixtures/port-mappings.yaml @@ -0,0 +1,30 @@ +apiVersion: forge.praxis.dev/v1alpha1 +kind: Environment + +metadata: + name: port-mapping-test + +spec: + runtime: + provider: docker + clusterPrefix: pm-test + + network: + crossCluster: false + dnsZone: pm.test + + clusters: + - name: local + ports: + - host: 8080 + container: 30080 + - host: 3000 + container: 30300 + protocol: tcp + - host: 9090 + container: 30090 + bindAddress: "127.0.0.1" + protocol: udp + stacks: [] + + stacks: {} diff --git a/tests/integration.rs b/tests/integration.rs index 80ed55a..df69488 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -295,3 +295,29 @@ fn cli_accepts_stack_status() { let result = Cli::try_parse_from(["praxis-forge", "stack", "status"]); assert!(result.is_ok(), "stack status should parse: {result:?}"); } + +// --------------------------------------------------------------- +// Cluster port mappings +// --------------------------------------------------------------- + +#[test] +fn config_with_port_mappings_parses_and_validates() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/port-mappings.yaml"); + let cfg = config::load(&path).unwrap_or_else(|_| std::process::abort()); + validate::validate(&cfg).unwrap_or_else(|_| std::process::abort()); + let cluster = cfg.spec.clusters.first().unwrap_or_else(|| std::process::abort()); + assert_eq!(cluster.ports.len(), 3, "should have 3 port mappings"); + assert_port_mapping(cluster.ports.first(), (8080, 30080, None, "tcp")); + assert_port_mapping(cluster.ports.get(2), (9090, 30090, Some("127.0.0.1"), "udp")); +} + +/// Assert a single port mapping's fields against `(host, container, +/// bind_address, protocol)`. +fn assert_port_mapping(port: Option<&config::PortMapping>, expected: (u16, u16, Option<&str>, &str)) { + let port = port.unwrap_or_else(|| std::process::abort()); + let (host, container, bind_address, protocol) = expected; + assert_eq!(port.host, host, "host port mismatch"); + assert_eq!(port.container, container, "container port mismatch"); + assert_eq!(port.bind_address.as_deref(), bind_address, "bind address mismatch"); + assert_eq!(port.protocol, protocol, "protocol mismatch"); +}