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..e020260 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -23,6 +23,7 @@ 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)?; @@ -199,6 +200,37 @@ fn check_cluster_nodes(config: &ForgeConfig) -> Result<(), ForgeError> { Ok(()) } +/// Cluster port mappings need non-zero host/container ports and unique +/// host ports within the same cluster. +fn check_cluster_ports(config: &ForgeConfig) -> Result<(), ForgeError> { + for cluster in &config.spec.clusters { + let mut seen = BTreeSet::new(); + for pm in &cluster.ports { + if pm.host == 0 || pm.container == 0 { + return Err(ForgeError::Validation(format!( + "cluster {:?}: port mapping host and container ports must not be zero", + cluster.name, + ))); + } + if let Some(addr) = pm.bind_address.as_ref() { + if addr.parse::().is_err() { + return Err(ForgeError::Validation(format!( + "cluster {:?}: bind address {addr:?} is not a valid IP", + cluster.name, + ))); + } + } + if !seen.insert(pm.host) { + return Err(ForgeError::Validation(format!( + "cluster {:?}: duplicate host port {}", + cluster.name, pm.host, + ))); + } + } + } + Ok(()) +} + /// Service names must be unique and DNS-label-valid. fn check_service_names(config: &ForgeConfig) -> Result<(), ForgeError> { let mut seen = BTreeSet::new(); @@ -1295,6 +1327,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 +1342,7 @@ mod tests { let cluster = ClusterSpec { name: "dupe".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }; @@ -1329,6 +1363,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 +1390,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 +1410,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 +1439,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 +1515,7 @@ mod tests { control_planes: 0, workers: 1, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1499,6 +1538,7 @@ mod tests { control_planes: 10, workers: 0, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1521,6 +1561,7 @@ mod tests { control_planes: 1, workers: u32::MAX, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1543,6 +1584,7 @@ mod tests { control_planes: 9, workers: 100, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1551,6 +1593,58 @@ 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}" + ); + } + #[test] fn invalid_service_protocol_rejected() { let mut config = base_config(); 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"); +}