diff --git a/.gitignore b/.gitignore index ad67955..fce6711 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ debug target +# Forge runtime state +.forge/ + # These are backup files generated by rustfmt **/*.rs.bk diff --git a/src/cluster/kind.rs b/src/cluster/kind.rs index 2ab8bb4..a449920 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, }; @@ -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 @@ -40,10 +52,7 @@ pub fn cluster_exists(runner: &dyn CommandRunner, kind_name: &str) -> Result 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 } @@ -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"); @@ -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!( @@ -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}"); @@ -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![ + 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" + ); + } } diff --git a/src/cluster/mod.rs b/src/cluster/mod.rs index a0db0c8..9120d9f 100644 --- a/src/cluster/mod.rs +++ b/src/cluster/mod.rs @@ -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) } @@ -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(()) } diff --git a/src/command/up.rs b/src/command/up.rs index 490762a..6ef32bb 100644 --- a/src/command/up.rs +++ b/src/command/up.rs @@ -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) } diff --git a/src/config/mod.rs b/src/config/mod.rs index dba3752..344a8a3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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, /// 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 e5a45a6..4bfa1f4 100644 --- a/src/config/validate.rs +++ b/src/config/validate.rs @@ -7,8 +7,8 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use crate::{ config::{ - API_VERSION, ForgeConfig, HealthCheck, KIND, NetworkMode, RuntimeProvider, ServiceSpec, - StepSpec, + API_VERSION, ForgeConfig, HealthCheck, KIND, NetworkMode, PortMapping, RuntimeProvider, + ServiceSpec, StepSpec, }, error::ForgeError, }; @@ -25,12 +25,14 @@ pub fn validate(config: &ForgeConfig) -> Result<(), ForgeError> { check_network_name(&config.metadata.name, &config.spec)?; check_cluster_names(config)?; check_cluster_nodes(config)?; + check_cluster_ports(config)?; check_service_names(config)?; check_services(config)?; check_service_deps(config)?; check_service_auto_start_deps(config)?; check_service_dep_cycles(config)?; check_service_port_conflicts(config)?; + check_cross_scope_port_conflicts(config)?; check_stack_names(config)?; check_cluster_stack_refs(config)?; check_stack_steps(config)?; @@ -171,6 +173,47 @@ fn check_cluster_nodes(config: &ForgeConfig) -> Result<(), ForgeError> { Ok(()) } +/// Validate cluster-level port mappings. +fn check_cluster_ports(config: &ForgeConfig) -> Result<(), ForgeError> { + for cluster in &config.spec.clusters { + let mut seen = BTreeSet::new(); + for (i, pm) in cluster.ports.iter().enumerate() { + let ctx = format!("cluster '{}' port mapping {}", cluster.name, i); + if pm.host == 0 { + return Err(ForgeError::Validation(format!( + "{ctx}: host port must be non-zero" + ))); + } + if pm.container == 0 { + return Err(ForgeError::Validation(format!( + "{ctx}: container port must be non-zero" + ))); + } + check_port_bind_address(&pm.bind_address, &ctx)?; + check_cluster_port_protocol(&pm.protocol, &ctx)?; + let key = (pm.bind_address.clone(), pm.host, pm.protocol.to_lowercase()); + if !seen.insert(key) { + return Err(ForgeError::Validation(format!( + "cluster '{}': duplicate host port {} ({})", + cluster.name, pm.host, pm.protocol, + ))); + } + } + } + Ok(()) +} + +/// Cluster ports only allow `tcp` or `udp` (case-insensitive). +fn check_cluster_port_protocol(protocol: &str, context: &str) -> Result<(), ForgeError> { + let lower = protocol.to_lowercase(); + if lower != "tcp" && lower != "udp" { + return Err(ForgeError::Validation(format!( + "{context}: unsupported port protocol {protocol:?} (expected tcp or udp)" + ))); + } + Ok(()) +} + /// Service names must be unique and DNS-label-valid. fn check_service_names(config: &ForgeConfig) -> Result<(), ForgeError> { let mut seen = BTreeSet::new(); @@ -216,7 +259,7 @@ fn check_service_ports(svc: &ServiceSpec) -> Result<(), ForgeError> { for port in &svc.ports { check_port_nonzero(port.host, &svc.name, "host")?; check_port_nonzero(port.container, &svc.name, "container")?; - check_port_bind_address(&port.bind_address, &svc.name)?; + check_port_bind_address(&port.bind_address, &format!("service {:?}", svc.name))?; check_port_protocol_tcp(&port.protocol, &svc.name)?; } Ok(()) @@ -233,13 +276,13 @@ fn check_port_nonzero(port: u16, svc_name: &str, field: &str) -> Result<(), Forg } /// Validate an optional bind address as a valid IP. -fn check_port_bind_address(addr: &Option, svc_name: &str) -> Result<(), ForgeError> { +fn check_port_bind_address(addr: &Option, context: &str) -> Result<(), ForgeError> { if let Some(addr) = addr .as_ref() .filter(|a| a.parse::().is_err()) { return Err(ForgeError::Validation(format!( - "service {svc_name:?}: bind address {addr:?} is not a valid IP" + "{context}: bind address {addr:?} is not a valid IP" ))); } Ok(()) @@ -558,6 +601,43 @@ fn check_service_port_conflicts(config: &ForgeConfig) -> Result<(), ForgeError> Ok(()) } +/// Reject host-port collisions across cluster and service port mappings. +/// +/// `(host_port, protocol, bind_address)` must be unique across every port +/// mapping in the config, whether it belongs to a cluster or a service. +fn check_cross_scope_port_conflicts(config: &ForgeConfig) -> Result<(), ForgeError> { + let mut seen: BTreeSet<(String, u16, String)> = BTreeSet::new(); + for cluster in &config.spec.clusters { + for pm in &cluster.ports { + check_port_collision(&mut seen, pm, &format!("cluster {:?}", cluster.name))?; + } + } + for svc in &config.spec.services { + for pm in &svc.ports { + check_port_collision(&mut seen, pm, &format!("service {:?}", svc.name))?; + } + } + Ok(()) +} + +/// Record a port mapping's binding key, rejecting collisions. +fn check_port_collision( + seen: &mut BTreeSet<(String, u16, String)>, + pm: &PortMapping, + context: &str, +) -> Result<(), ForgeError> { + let bind = pm.bind_address.as_deref().unwrap_or("").to_owned(); + let key = (bind.clone(), pm.host, pm.protocol.to_lowercase()); + if !seen.insert(key) { + return Err(ForgeError::Validation(format!( + "{context}: duplicate host port binding {}:{}/{} conflicts with another \ + cluster or service port mapping", + bind, pm.host, pm.protocol, + ))); + } + Ok(()) +} + /// Stack names must be DNS-label-valid. fn check_stack_names(config: &ForgeConfig) -> Result<(), ForgeError> { for name in config.spec.stacks.keys() { @@ -1193,6 +1273,7 @@ mod tests { let cluster = ClusterSpec { name: "dupe".to_owned(), nodes: NodeConfig::default(), + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }; @@ -1213,6 +1294,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(), }]; @@ -1232,6 +1314,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(), @@ -1263,6 +1346,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(), }]; @@ -1299,6 +1383,7 @@ mod tests { control_planes: 0, workers: 1, }, + ports: Vec::new(), stacks: Vec::new(), properties: BTreeMap::new(), }]; @@ -1312,6 +1397,264 @@ 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![], + 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![], + properties: BTreeMap::new(), + }); + let result = validate(&config); + assert!(result.is_err(), "duplicate host port should be rejected"); + } + + #[test] + fn cluster_same_host_port_different_bind_address_passes() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![ + PortMapping { + bind_address: Some("127.0.0.1".to_owned()), + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }, + PortMapping { + bind_address: Some("127.0.0.2".to_owned()), + host: 8080, + container: 30081, + protocol: "tcp".to_owned(), + }, + ], + stacks: vec![], + properties: BTreeMap::new(), + }); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + #[test] + fn cluster_port_bind_address_invalid_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: Some("not-an-ip".to_owned()), + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("bind address"), + "expected bind address error, got: {msg}" + ); + } + + #[test] + fn cluster_port_bind_address_valid_passes() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "test".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: Some("127.0.0.1".to_owned()), + host: 8080, + container: 30080, + protocol: "udp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + #[test] + fn cluster_port_invalid_protocol_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: "sctp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("protocol"), + "expected protocol error, got: {msg}" + ); + } + + #[test] + fn cluster_port_udp_protocol_accepted() { + 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: "UDP".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + + #[test] + fn cross_cluster_host_port_conflict_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "a".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + config.spec.clusters.push(ClusterSpec { + name: "b".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30081, + protocol: "tcp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("duplicate host port binding"), + "expected cross-cluster conflict error, got: {msg}" + ); + } + + #[test] + fn cluster_vs_service_host_port_conflict_rejected() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "a".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: None, + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + config.spec.services = vec![test_service_with_port(PortMapping { + bind_address: None, + host: 8080, + container: 80, + protocol: "tcp".to_owned(), + })]; + let Err(err) = validate(&config) else { + std::process::abort(); + }; + let msg = err.to_string(); + assert!( + msg.contains("duplicate host port binding"), + "expected cluster-vs-service conflict error, got: {msg}" + ); + } + + #[test] + fn different_bind_addresses_do_not_conflict() { + let mut config = base_config(); + config.spec.clusters.push(ClusterSpec { + name: "a".to_owned(), + nodes: NodeConfig::default(), + ports: vec![PortMapping { + bind_address: Some("127.0.0.1".to_owned()), + host: 8080, + container: 30080, + protocol: "tcp".to_owned(), + }], + stacks: vec![], + properties: BTreeMap::new(), + }); + config.spec.services = vec![test_service_with_port(PortMapping { + bind_address: Some("127.0.0.2".to_owned()), + host: 8080, + container: 80, + protocol: "tcp".to_owned(), + })]; + validate(&config).unwrap_or_else(|_e| { + std::process::abort(); + }); + } + #[test] fn invalid_service_protocol_rejected() { let mut config = base_config(); diff --git a/src/stack/engine.rs b/src/stack/engine.rs index b06dae0..021dce4 100644 --- a/src/stack/engine.rs +++ b/src/stack/engine.rs @@ -1161,6 +1161,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/src/stack/mod.rs b/src/stack/mod.rs index b8ee565..91a5ed8 100644 --- a/src/stack/mod.rs +++ b/src/stack/mod.rs @@ -703,6 +703,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/tests/fixtures/port-mappings.yaml b/tests/fixtures/port-mappings.yaml new file mode 100644 index 0000000..4cff3d1 --- /dev/null +++ b/tests/fixtures/port-mappings.yaml @@ -0,0 +1,25 @@ +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 0cb0579..0fee831 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -313,3 +313,32 @@ fn cli_accepts_stack_status() { let result = Cli::try_parse_from(["praxis-forge", "stack", "status"]); assert!(result.is_ok(), "stack status should parse: {result:?}"); } + +#[test] +fn config_with_port_mappings_parses_and_validates() { + let yaml = std::fs::read_to_string("tests/fixtures/port-mappings.yaml") + .unwrap_or_else(|_| std::process::abort()); + let cfg: config::ForgeConfig = + serde_yaml::from_str(&yaml).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); + let first = cluster + .ports + .first() + .unwrap_or_else(|| std::process::abort()); + assert_eq!(first.host, 8080); + assert_eq!(first.container, 30080); + let third = cluster + .ports + .get(2) + .unwrap_or_else(|| std::process::abort()); + assert_eq!(third.host, 9090); + assert_eq!(third.container, 30090); + assert_eq!(third.bind_address.as_deref(), Some("127.0.0.1")); + assert_eq!(third.protocol, "udp"); +}