From 3364b5febe81dd3d424024e107e6fa7794bbedea Mon Sep 17 00:00:00 2001 From: Ladislav Smola Date: Fri, 28 Aug 2026 10:44:10 +0200 Subject: [PATCH] feat: add extraPortMappings support to ClusterSpec Expose KIND extraPortMappings in the forge config schema via a new ports field on ClusterSpec. Enables mapping host ports to container NodePorts for accessing services (Grafana, Prometheus, MLflow) from the host machine. Includes config validation, KIND config generation, and an integration test with a port-mappings fixture. Host bindings are validated through one environment-wide registry covering both cluster mappings and service ports. They are published on the same host by the same container runtime and so compete for one set of bindings; 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, TCP and UDP on one port are distinct bindings that Docker and KIND both accept, and a wildcard bind overlaps any other binding of that port and protocol while two specific addresses conflict only when equal. Conflicts name both claimants. Signed-off-by: Ladislav Smola --- src/cluster.rs | 12 +- src/cluster/kind.rs | 106 ++++++- src/command/up.rs | 8 +- src/config.rs | 7 + src/config/validate.rs | 506 ++++++++++++++++++++++++++++-- src/stack.rs | 1 + src/stack/engine.rs | 1 + tests/fixtures/port-mappings.yaml | 30 ++ tests/integration.rs | 26 ++ 9 files changed, 654 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/port-mappings.yaml 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"); +}