Skip to content

Commit ee1bf4b

Browse files
committed
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 <lsmola@redhat.com>
1 parent 98f071c commit ee1bf4b

9 files changed

Lines changed: 654 additions & 43 deletions

File tree

src/cluster.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ fn handle_create(ctx: &ForgeContext<'_>, name: &str, writer: &mut dyn Write) ->
5555
}
5656
let _lock = lock::acquire(&ctx.state_dir)?;
5757
let mut state = state::load(&ctx.state_dir)?;
58-
let created = create_if_missing(ctx, &kind_name, &cluster.nodes, &mut state, name)?;
58+
let created = create_if_missing(ctx, &kind_name, cluster, &mut state, name)?;
5959
state::save(&ctx.state_dir, &state)?;
6060
if created {
6161
report_created(writer, name, &kind_name, &ctx.format)
@@ -203,7 +203,7 @@ fn cluster_kind_name(ctx: &ForgeContext<'_>, name: &str) -> String {
203203
fn create_if_missing(
204204
ctx: &ForgeContext<'_>,
205205
kind_name: &str,
206-
nodes: &crate::config::NodeConfig,
206+
cluster: &crate::config::ClusterSpec,
207207
st: &mut state::ForgeState,
208208
name: &str,
209209
) -> Result<bool, ForgeError> {
@@ -213,7 +213,13 @@ fn create_if_missing(
213213
}
214214
upsert_cluster_state(st, name, kind_name, ClusterPhase::Creating);
215215
state::save(&ctx.state_dir, st)?;
216-
kind_ops::create_cluster(ctx.runner, kind_name, nodes, &ctx.state_dir, None)?;
216+
let config = kind_ops::CreateClusterConfig {
217+
nodes: &cluster.nodes,
218+
ports: &cluster.ports,
219+
config_dir: &ctx.state_dir,
220+
docker_network: None,
221+
};
222+
kind_ops::create_cluster(ctx.runner, kind_name, &config)?;
217223
upsert_cluster_state(st, name, kind_name, ClusterPhase::Running);
218224
Ok(true)
219225
}

src/cluster/kind.rs

Lines changed: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::collections::BTreeMap;
88

99
use 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
5163
pub 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\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\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 {

src/command/up.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,13 @@ fn create_if_missing(
217217
}
218218
ensure_state_entry(state, &cluster.name, kind_name, ClusterPhase::Creating);
219219
checkpoint(ctx, state)?;
220-
kind_ops::create_cluster(ctx.runner, kind_name, &cluster.nodes, &ctx.state_dir, docker_network)?;
220+
let cluster_config = kind_ops::CreateClusterConfig {
221+
nodes: &cluster.nodes,
222+
ports: &cluster.ports,
223+
config_dir: &ctx.state_dir,
224+
docker_network,
225+
};
226+
kind_ops::create_cluster(ctx.runner, kind_name, &cluster_config)?;
221227
ensure_state_entry(state, &cluster.name, kind_name, ClusterPhase::Running);
222228
Ok(true)
223229
}

src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ pub struct ClusterSpec {
163163
/// Node layout for this Kind cluster.
164164
#[serde(default)]
165165
pub nodes: NodeConfig,
166+
/// Host-to-node port mappings (Kind `extraPortMappings`).
167+
///
168+
/// Maps host ports to Kind node ports via `extraPortMappings` in the
169+
/// Kind cluster config. Required on macOS where `MetalLB` `LoadBalancer`
170+
/// IPs are unreachable from the host.
171+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
172+
pub ports: Vec<PortMapping>,
166173
/// Stacks to apply to this cluster (must exist in `spec.stacks`).
167174
#[serde(default)]
168175
pub stacks: Vec<String>,

0 commit comments

Comments
 (0)