From 326515b1167531ef69e9c8089393339816d3d35b 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 integration test with a port-mappings fixture. 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 | 94 ++++++++++++++++++++++++++ src/stack.rs | 1 + src/stack/engine.rs | 1 + tests/fixtures/port-mappings.yaml | 30 +++++++++ tests/integration.rs | 26 ++++++++ 9 files changed, 270 insertions(+), 15 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..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"); +}