@@ -8,7 +8,7 @@ use std::collections::BTreeMap;
88
99use crate :: {
1010 command:: runner:: { CommandOutput , CommandRunner , CommandSpec } ,
11- config:: NodeConfig ,
11+ config:: { NodeConfig , PortMapping } ,
1212 error:: ForgeError ,
1313} ;
1414
@@ -40,6 +40,18 @@ pub fn cluster_exists(runner: &dyn CommandRunner, kind_name: &str) -> Result<boo
4040 Ok ( clusters. iter ( ) . any ( |cl| cl == kind_name) )
4141}
4242
43+ /// Configuration for creating a KIND cluster.
44+ pub struct CreateClusterConfig < ' cfg > {
45+ /// Node layout for the cluster.
46+ pub nodes : & ' cfg NodeConfig ,
47+ /// Port mappings to add to the first control-plane node.
48+ pub ports : & ' cfg [ PortMapping ] ,
49+ /// Directory where config files are written.
50+ pub config_dir : & ' cfg std:: path:: Path ,
51+ /// Optional Docker network to join via `KIND_EXPERIMENTAL_DOCKER_NETWORK`.
52+ pub docker_network : Option < & ' cfg str > ,
53+ }
54+
4355/// Create a KIND cluster with a generated config.
4456///
4557/// 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<boo
5163pub fn create_cluster (
5264 runner : & dyn CommandRunner ,
5365 kind_name : & str ,
54- nodes : & NodeConfig ,
55- config_dir : & std:: path:: Path ,
56- docker_network : Option < & str > ,
66+ config : & CreateClusterConfig < ' _ > ,
5767) -> Result < ( ) , ForgeError > {
58- let config_yaml = generate_kind_config ( nodes) ;
59- let config_path = write_kind_config ( config_dir, kind_name, & config_yaml) ?;
60- let result = run_create ( runner, kind_name, & config_path, docker_network) ;
68+ let config_yaml = generate_kind_config ( config . nodes , config . ports ) ;
69+ let config_path = write_kind_config ( config . config_dir , kind_name, & config_yaml) ?;
70+ let result = run_create ( runner, kind_name, & config_path, config . docker_network ) ;
6171 cleanup_kind_config ( & config_path) ;
6272 result
6373}
@@ -126,17 +136,42 @@ pub fn run_kubectl(runner: &dyn CommandRunner, kind_name: &str, args: &[String])
126136// ---------------------------------------------------------------
127137
128138/// Generate a KIND cluster config YAML from a [`NodeConfig`].
129- pub fn generate_kind_config ( nodes : & NodeConfig ) -> String {
139+ ///
140+ /// When `ports` is non-empty, `extraPortMappings` entries are added
141+ /// to the first control-plane node (KIND only supports port mappings
142+ /// on control-plane nodes).
143+ pub fn generate_kind_config ( nodes : & NodeConfig , ports : & [ PortMapping ] ) -> String {
130144 let mut yaml = String :: from ( "kind: Cluster\n apiVersion: kind.x-k8s.io/v1alpha4\n nodes:\n " ) ;
131- for _ in 0 ..nodes. control_planes {
145+ for idx in 0 ..nodes. control_planes {
132146 yaml. push_str ( " - role: control-plane\n " ) ;
147+ if idx == 0 && !ports. is_empty ( ) {
148+ write_port_mappings ( & mut yaml, ports) ;
149+ }
133150 }
134151 for _ in 0 ..nodes. workers {
135152 yaml. push_str ( " - role: worker\n " ) ;
136153 }
137154 yaml
138155}
139156
157+ /// Append `extraPortMappings` entries for a control-plane node.
158+ fn write_port_mappings ( yaml : & mut String , ports : & [ PortMapping ] ) {
159+ use std:: fmt:: Write as _;
160+ yaml. push_str ( " extraPortMappings:\n " ) ;
161+ for port in ports {
162+ let _written = write ! (
163+ yaml,
164+ " - hostPort: {}\n containerPort: {}\n protocol: {}\n " ,
165+ port. host,
166+ port. container,
167+ port. protocol. to_uppercase( ) ,
168+ ) ;
169+ if let Some ( addr) = & port. bind_address {
170+ let _addr_written = writeln ! ( yaml, " listenAddress: \" {addr}\" " ) ;
171+ }
172+ }
173+ }
174+
140175// ---------------------------------------------------------------
141176// Private helpers
142177// ---------------------------------------------------------------
@@ -292,7 +327,7 @@ mod tests {
292327 #[ test]
293328 fn generate_kind_config_default_nodes ( ) {
294329 let nodes = NodeConfig :: default ( ) ;
295- let yaml = generate_kind_config ( & nodes) ;
330+ let yaml = generate_kind_config ( & nodes, & [ ] ) ;
296331 assert ! ( yaml. contains( "control-plane" ) , "should have control-plane" ) ;
297332 let cp_count = yaml. matches ( "control-plane" ) . count ( ) ;
298333 assert_eq ! ( cp_count, 1 , "default should have 1 control-plane, got {cp_count}" ) ;
@@ -305,13 +340,62 @@ mod tests {
305340 control_planes : 3 ,
306341 workers : 2 ,
307342 } ;
308- let yaml = generate_kind_config ( & nodes) ;
343+ let yaml = generate_kind_config ( & nodes, & [ ] ) ;
309344 let cp_count = yaml. matches ( "control-plane" ) . count ( ) ;
310345 let w_count = yaml. matches ( "worker" ) . count ( ) ;
311346 assert_eq ! ( cp_count, 3 , "should have 3 control-planes, got {cp_count}" ) ;
312347 assert_eq ! ( w_count, 2 , "should have 2 workers, got {w_count}" ) ;
313348 }
314349
350+ #[ test]
351+ fn generate_kind_config_with_port_mappings ( ) {
352+ let nodes = NodeConfig :: default ( ) ;
353+ let ports = vec ! [
354+ PortMapping {
355+ bind_address: None ,
356+ host: 13000 ,
357+ container: 30300 ,
358+ protocol: "tcp" . to_owned( ) ,
359+ } ,
360+ PortMapping {
361+ bind_address: Some ( "127.0.0.1" . to_owned( ) ) ,
362+ host: 19090 ,
363+ container: 30909 ,
364+ protocol: "udp" . to_owned( ) ,
365+ } ,
366+ ] ;
367+ let yaml = generate_kind_config ( & nodes, & ports) ;
368+ assert ! ( yaml. contains( "extraPortMappings:" ) , "should have extraPortMappings" ) ;
369+ assert ! ( yaml. contains( "hostPort: 13000" ) , "should map first host port" ) ;
370+ assert ! ( yaml. contains( "containerPort: 30300" ) , "should map first container port" ) ;
371+ assert ! ( yaml. contains( "hostPort: 19090" ) , "should map second host port" ) ;
372+ assert ! ( yaml. contains( "protocol: UDP" ) , "should uppercase protocol" ) ;
373+ assert ! (
374+ yaml. contains( "listenAddress: \" 127.0.0.1\" " ) ,
375+ "should include listen address"
376+ ) ;
377+ }
378+
379+ #[ test]
380+ fn generate_kind_config_port_mappings_only_on_first_control_plane ( ) {
381+ let nodes = NodeConfig {
382+ control_planes : 2 ,
383+ workers : 0 ,
384+ } ;
385+ let ports = vec ! [ PortMapping {
386+ bind_address: None ,
387+ host: 8080 ,
388+ container: 30080 ,
389+ protocol: "tcp" . to_owned( ) ,
390+ } ] ;
391+ let yaml = generate_kind_config ( & nodes, & ports) ;
392+ assert_eq ! (
393+ yaml. matches( "extraPortMappings:" ) . count( ) ,
394+ 1 ,
395+ "port mappings should only appear on the first control-plane node"
396+ ) ;
397+ }
398+
315399 #[ test]
316400 fn parse_cluster_list_handles_empty ( ) {
317401 let output = CommandOutput {
0 commit comments