Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
debug
target

# Forge runtime state
.forge/

# These are backup files generated by rustfmt
**/*.rs.bk

Expand Down
114 changes: 98 additions & 16 deletions src/cluster/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use std::collections::BTreeMap;

use crate::{
command::runner::{CommandOutput, CommandRunner, CommandSpec},
config::NodeConfig,
config::{NodeConfig, PortMapping},
error::ForgeError,
};

Expand All @@ -30,6 +30,18 @@ pub fn kubectl_context(kind_name: &str) -> String {
// Lifecycle
// ---------------------------------------------------------------

/// Configuration for creating a KIND cluster.
pub struct CreateClusterConfig<'a> {
/// Node layout for the cluster.
pub nodes: &'a NodeConfig,
/// Port mappings to add to the first control-plane node.
pub ports: &'a [PortMapping],
/// Directory where config files are written.
pub config_dir: &'a std::path::Path,
/// Optional Docker network to join via `KIND_EXPERIMENTAL_DOCKER_NETWORK`.
pub docker_network: Option<&'a str>,
}

/// Check whether a KIND cluster with the given name exists.
///
/// # Errors
Expand All @@ -40,24 +52,19 @@ pub fn cluster_exists(runner: &dyn CommandRunner, kind_name: &str) -> Result<boo
Ok(clusters.iter().any(|c| c == kind_name))
}

/// Create a KIND cluster with a generated config.
///
/// When `docker_network` is `Some`, the cluster nodes join that
/// Docker network via `KIND_EXPERIMENTAL_DOCKER_NETWORK`.
/// Create a Kind cluster with the given configuration.
///
/// # Errors
///
/// Returns [`ForgeError`] if the cluster cannot be created.
pub fn create_cluster(
runner: &dyn CommandRunner,
kind_name: &str,
nodes: &NodeConfig,
config_dir: &std::path::Path,
docker_network: Option<&str>,
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
}
Expand Down Expand Up @@ -130,11 +137,31 @@ pub fn run_kubectl(
// KIND config generation
// ---------------------------------------------------------------

/// Generate a KIND cluster config YAML from a [`NodeConfig`].
pub fn generate_kind_config(nodes: &NodeConfig) -> String {
/// Generate a Kind cluster config YAML 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 {
use std::fmt::Write as _;
let mut yaml = String::from("kind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n");
for _ in 0..nodes.control_planes {
for i in 0..nodes.control_planes {
yaml.push_str(" - role: control-plane\n");
if i == 0 && !ports.is_empty() {
yaml.push_str(" extraPortMappings:\n");
for pm in ports {
let _written = write!(
&mut yaml,
" - hostPort: {}\n containerPort: {}\n protocol: {}\n",
pm.host,
pm.container,
pm.protocol.to_uppercase(),
);
if let Some(addr) = &pm.bind_address {
let _written = writeln!(&mut yaml, " listenAddress: \"{addr}\"");
}
}
}
}
for _ in 0..nodes.workers {
yaml.push_str(" - role: worker\n");
Expand Down Expand Up @@ -316,7 +343,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!(
Expand All @@ -332,7 +359,7 @@ 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}");
Expand Down Expand Up @@ -439,4 +466,59 @@ mod tests {
});
assert!(!exists, "forge-missing should not exist");
}

#[test]
fn generate_kind_config_with_port_mappings() {
let nodes = NodeConfig::default();
let ports = vec![

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The listenAddress generation branch (line 160-162) is not covered by any unit test. Both port mappings here use bind_address: None, so the if let Some(addr) = &pm.bind_address path is never exercised.

Add a port with a bind_address to this test (or a dedicated test) and assert the generated YAML contains the expected listenAddress entry:

PortMapping {
    bind_address: Some("127.0.0.1".to_owned()),
    host: 9090,
    container: 30909,
    protocol: "tcp".to_owned(),
},

then:

assert!(
    yaml.contains("listenAddress: \"127.0.0.1\""),
    "should include listenAddress for bind_address",
);

PortMapping {
bind_address: None,
host: 13000,
container: 30300,
protocol: "tcp".to_owned(),
},
PortMapping {
bind_address: None,
host: 19090,
container: 30909,
protocol: "tcp".to_owned(),
},
];
let yaml = generate_kind_config(&nodes, &ports);
assert!(
yaml.contains("extraPortMappings:"),
"should have extraPortMappings"
);
assert!(yaml.contains("hostPort: 13000"), "should map Grafana port");
assert!(
yaml.contains("containerPort: 30300"),
"should map Grafana node port"
);
assert!(
yaml.contains("hostPort: 19090"),
"should map Prometheus port"
);
assert!(yaml.contains("protocol: TCP"), "should uppercase protocol");
}

#[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);
// extraPortMappings should appear exactly once
assert_eq!(
yaml.matches("extraPortMappings:").count(),
1,
"port mappings should only appear on the first control-plane node"
);
}
}
15 changes: 10 additions & 5 deletions src/cluster/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ fn handle_create(
}
let _lock = lock::acquire(&ctx.state_dir)?;
let mut state = state::load(&ctx.state_dir)?;
create_if_missing(ctx, &kind_name, &cluster.nodes, &mut state, name)?;
create_if_missing(ctx, &kind_name, cluster, &mut state)?;
state::save(&ctx.state_dir, &state)?;
report_created(writer, name, &kind_name, &ctx.format)
}
Expand Down Expand Up @@ -168,15 +168,20 @@ 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<(), ForgeError> {
if kind_ops::cluster_exists(ctx.runner, kind_name)? {
return Ok(());
}
kind_ops::create_cluster(ctx.runner, kind_name, nodes, &ctx.state_dir, None)?;
upsert_cluster_state(st, name, kind_name, ClusterPhase::Running);
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, &cluster.name, kind_name, ClusterPhase::Running);
Ok(())
}

Expand Down
12 changes: 6 additions & 6 deletions src/command/up.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,13 +199,13 @@ fn create_if_missing(
ensure_state_entry(state, &cluster.name, kind_name, ClusterPhase::Running);
return Ok(false);
}
kind_ops::create_cluster(
ctx.runner,
kind_name,
&cluster.nodes,
&ctx.state_dir,
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)
}
Expand Down
7 changes: 7 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,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<PortMapping>,
/// Stacks to apply to this cluster (must exist in `spec.stacks`).
#[serde(default)]
pub stacks: Vec<String>,
Expand Down
Loading