From b5ed445d5fb577ab41f140361b12e5f4196b958e Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 11 Jun 2025 17:12:59 -0400 Subject: [PATCH] feat: Support specifying ports in Tenant network config --- harmony/src/domain/topology/tenant/k8s.rs | 70 ++++++++---- harmony/src/domain/topology/tenant/mod.rs | 124 +++++++++++++++++++++- 2 files changed, 173 insertions(+), 21 deletions(-) diff --git a/harmony/src/domain/topology/tenant/k8s.rs b/harmony/src/domain/topology/tenant/k8s.rs index a68f84d..a03e8d7 100644 --- a/harmony/src/domain/topology/tenant/k8s.rs +++ b/harmony/src/domain/topology/tenant/k8s.rs @@ -6,9 +6,14 @@ use crate::{ }; use async_trait::async_trait; use derive_new::new; -use k8s_openapi::api::{ - core::v1::{Namespace, ResourceQuota}, - networking::v1::{NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule}, +use k8s_openapi::{ + api::{ + core::v1::{Namespace, ResourceQuota}, + networking::v1::{ + NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPort, + }, + }, + apimachinery::pkg::util::intstr::IntOrString, }; use kube::Resource; use log::{debug, info, warn}; @@ -204,15 +209,18 @@ impl K8sTenantManager { .additional_allowed_cidr_ingress .iter() .try_for_each(|c| -> Result<(), ExecutorError> { + let cidr_list: Vec = + c.0.iter() + .map(|ci| { + json!({ + "ipBlock": { + "cidr": ci.to_string(), + } + }) + }) + .collect(); let rule = serde_json::from_value::(json!({ - "from": [ - { - "ipBlock": { - - "cidr": c.to_string(), - } - } - ] + "from": cidr_list })) .map_err(|e| { ExecutorError::ConfigurationError(format!( @@ -237,15 +245,39 @@ impl K8sTenantManager { .additional_allowed_cidr_egress .iter() .try_for_each(|c| -> Result<(), ExecutorError> { - let rule = serde_json::from_value::(json!({ - "to": [ - { - "ipBlock": { + let cidr_list: Vec = + c.0.iter() + .map(|ci| { + json!({ + "ipBlock": { + "cidr": ci.to_string(), + } + }) + }) + .collect(); + let ports: Option> = + c.1.as_ref().map(|spec| match &spec.data { + super::PortSpecData::SinglePort(port) => vec![NetworkPolicyPort { + port: Some(IntOrString::Int(port.clone().into())), + ..Default::default() + }], + super::PortSpecData::PortRange(start, end) => vec![NetworkPolicyPort { + port: Some(IntOrString::Int(start.clone().into())), + end_port: Some(end.clone().into()), + protocol: None, // Not currently supported by Harmony + }], - "cidr": c.to_string(), - } - } - ] + super::PortSpecData::ListOfPorts(items) => items + .iter() + .map(|i| NetworkPolicyPort { + port: Some(IntOrString::Int(i.clone().into())), + ..Default::default() + }) + .collect(), + }); + let rule = serde_json::from_value::(json!({ + "to": cidr_list, + "ports": ports, })) .map_err(|e| { ExecutorError::ConfigurationError(format!( diff --git a/harmony/src/domain/topology/tenant/mod.rs b/harmony/src/domain/topology/tenant/mod.rs index 8fd5fea..e8d41dc 100644 --- a/harmony/src/domain/topology/tenant/mod.rs +++ b/harmony/src/domain/topology/tenant/mod.rs @@ -1,5 +1,7 @@ pub mod k8s; mod manager; +use std::str::FromStr; + pub use manager::*; use serde::{Deserialize, Serialize}; @@ -74,8 +76,8 @@ pub struct TenantNetworkPolicy { /// Policy for egress traffic destined for the public internet. pub default_internet_egress: InternetEgressPolicy, - pub additional_allowed_cidr_ingress: Vec, - pub additional_allowed_cidr_egress: Vec, + pub additional_allowed_cidr_ingress: Vec<(Vec, Option)>, + pub additional_allowed_cidr_egress: Vec<(Vec, Option)>, } impl Default for TenantNetworkPolicy { @@ -102,3 +104,121 @@ pub enum InternetEgressPolicy { /// Deny all outbound traffic to the internet by default. DenyAll, } + +/// Represents a port specification that can be either a single port, a comma-separated list of ports, +/// or a range separated by a dash. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PortSpec { + /// The actual representation of the ports as strings for serialization/deserialization purposes. + pub data: PortSpecData, +} + +impl PortSpec { + /// TODO write short rust doc that shows what types of input are supported + fn parse_from_str(spec: &str) -> Result { + // Check for single port + if let Ok(port) = spec.parse::() { + let spec = PortSpecData::SinglePort(port); + return Ok(Self { data: spec }); + } + + if let Some(range) = spec.find('-') { + let start_str = &spec[..range]; + let end_str = &spec[(range + 1)..]; + + if let (Ok(start), Ok(end)) = (start_str.parse::(), end_str.parse::()) { + let spec = PortSpecData::PortRange(start, end); + return Ok(Self { data: spec }); + } + } + + let ports: Vec<&str> = spec.split(',').collect(); + if !ports.is_empty() && ports.iter().all(|p| p.parse::().is_ok()) { + let maybe_ports = ports.iter().try_fold(vec![], |mut list, &p| { + if let Ok(p) = p.parse::() { + list.push(p); + return Ok(list); + } + Err(()) + }); + + if let Ok(ports) = maybe_ports { + let spec = PortSpecData::ListOfPorts(ports); + return Ok(Self { data: spec }); + } + } + + Err(format!("Invalid port spec format {spec}")) + } +} + +impl FromStr for PortSpec { + type Err = String; + + fn from_str(s: &str) -> Result { + Self::parse_from_str(s) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum PortSpecData { + SinglePort(u16), + PortRange(u16, u16), + ListOfPorts(Vec), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_port() { + let port_spec = "2144".parse::().unwrap(); + match port_spec.data { + PortSpecData::SinglePort(port) => assert_eq!(port, 2144), + _ => panic!("Expected SinglePort"), + } + } + + #[test] + fn test_port_range() { + let port_spec = "80-90".parse::().unwrap(); + match port_spec.data { + PortSpecData::PortRange(start, end) => { + assert_eq!(start, 80); + assert_eq!(end, 90); + } + _ => panic!("Expected PortRange"), + } + } + + #[test] + fn test_list_of_ports() { + let port_spec = "2144,3424".parse::().unwrap(); + match port_spec.data { + PortSpecData::ListOfPorts(ports) => { + assert_eq!(ports[0], 2144); + assert_eq!(ports[1], 3424); + } + _ => panic!("Expected ListOfPorts"), + } + } + + #[test] + fn test_invalid_port_spec() { + let result = "invalid".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_empty_input() { + let result = "".parse::(); + assert!(result.is_err()); + } + + #[test] + fn test_only_coma() { + let result = ",".parse::(); + assert!(result.is_err()); + } +}