feat: add support for custom CIDR ingress/egress rules (#60)
All checks were successful
Run Check Script / check (push) Successful in 1m53s

- Added `additional_allowed_cidr_ingress` and `additional_allowed_cidr_egress` fields to `TenantNetworkPolicy` to allow specifying custom CIDR blocks for network access.
- Updated K8sTenantManager to parse and apply these CIDR rules to NetworkPolicy ingress and egress rules.
- Added `cidr` dependency to `harmony_macros` and a custom proc macro `cidrv4` to easily parse CIDR strings.
- Updated TenantConfig to default inter tenant and internet egress to deny all and added default empty vectors for CIDR ingress and egress.
- Updated ResourceLimits to implement default.

Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/60
Co-authored-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
Co-committed-by: Jean-Gabriel Gill-Couture <jg@nationtech.io>
This commit is contained in:
Jean-Gabriel Gill-Couture 2025-06-12 15:24:03 +00:00 committed by johnride
parent ef5ec4a131
commit b94dd1e595
9 changed files with 281 additions and 28 deletions

4
Cargo.lock generated
View File

@ -394,6 +394,9 @@ name = "cidr"
version = "0.2.3" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621" checksum = "6bdf600c45bd958cf2945c445264471cca8b6c8e67bc87b71affd6d7e5682621"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "cipher" name = "cipher"
@ -1476,6 +1479,7 @@ dependencies = [
name = "harmony_macros" name = "harmony_macros"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"cidr",
"harmony_types", "harmony_types",
"quote", "quote",
"serde", "serde",

View File

@ -24,7 +24,7 @@ env_logger = "0.11.5"
derive-new = "0.7.0" derive-new = "0.7.0"
async-trait = "0.1.82" async-trait = "0.1.82"
tokio = { version = "1.40.0", features = ["io-std", "fs", "macros", "rt-multi-thread"] } tokio = { version = "1.40.0", features = ["io-std", "fs", "macros", "rt-multi-thread"] }
cidr = "0.2.3" cidr = { features = ["serde"], version = "0.2" }
russh = "0.45.0" russh = "0.45.0"
russh-keys = "0.45.0" russh-keys = "0.45.0"
rand = "0.8.5" rand = "0.8.5"

View File

@ -6,7 +6,6 @@ use log::{debug, info, warn};
use tokio::sync::OnceCell; use tokio::sync::OnceCell;
use crate::{ use crate::{
data::Id,
executors::ExecutorError, executors::ExecutorError,
interpret::{InterpretError, Outcome}, interpret::{InterpretError, Outcome},
inventory::Inventory, inventory::Inventory,
@ -18,9 +17,7 @@ use crate::{
use super::{ use super::{
HelmCommand, K8sclient, Topology, HelmCommand, K8sclient, Topology,
k8s::K8sClient, k8s::K8sClient,
tenant::{ tenant::{TenantConfig, TenantManager, k8s::K8sTenantManager},
ResourceLimits, TenantConfig, TenantManager, TenantNetworkPolicy, k8s::K8sTenantManager,
},
}; };
struct K8sState { struct K8sState {

View File

@ -6,9 +6,14 @@ use crate::{
}; };
use async_trait::async_trait; use async_trait::async_trait;
use derive_new::new; use derive_new::new;
use k8s_openapi::api::{ use k8s_openapi::{
core::v1::{Namespace, ResourceQuota}, api::{
networking::v1::NetworkPolicy, core::v1::{Namespace, ResourceQuota},
networking::v1::{
NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPort,
},
},
apimachinery::pkg::util::intstr::IntOrString,
}; };
use kube::Resource; use kube::Resource;
use log::{debug, info, warn}; use log::{debug, info, warn};
@ -191,12 +196,107 @@ impl K8sTenantManager {
} }
}); });
serde_json::from_value(network_policy).map_err(|e| { let mut network_policy: NetworkPolicy =
ExecutorError::ConfigurationError(format!( serde_json::from_value(network_policy).map_err(|e| {
"Could not build TenantManager NetworkPolicy. {}", ExecutorError::ConfigurationError(format!(
e "Could not build TenantManager NetworkPolicy. {}",
)) e
}) ))
})?;
config
.network_policy
.additional_allowed_cidr_ingress
.iter()
.try_for_each(|c| -> Result<(), ExecutorError> {
let cidr_list: Vec<serde_json::Value> =
c.0.iter()
.map(|ci| {
json!({
"ipBlock": {
"cidr": ci.to_string(),
}
})
})
.collect();
let rule = serde_json::from_value::<NetworkPolicyIngressRule>(json!({
"from": cidr_list
}))
.map_err(|e| {
ExecutorError::ConfigurationError(format!(
"Could not build TenantManager NetworkPolicyIngressRule. {}",
e
))
})?;
network_policy
.spec
.as_mut()
.unwrap()
.ingress
.as_mut()
.unwrap()
.push(rule);
Ok(())
})?;
config
.network_policy
.additional_allowed_cidr_egress
.iter()
.try_for_each(|c| -> Result<(), ExecutorError> {
let cidr_list: Vec<serde_json::Value> =
c.0.iter()
.map(|ci| {
json!({
"ipBlock": {
"cidr": ci.to_string(),
}
})
})
.collect();
let ports: Option<Vec<NetworkPolicyPort>> =
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
}],
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::<NetworkPolicyEgressRule>(json!({
"to": cidr_list,
"ports": ports,
}))
.map_err(|e| {
ExecutorError::ConfigurationError(format!(
"Could not build TenantManager NetworkPolicyEgressRule. {}",
e
))
})?;
network_policy
.spec
.as_mut()
.unwrap()
.egress
.as_mut()
.unwrap()
.push(rule);
Ok(())
})?;
Ok(network_policy)
} }
} }

View File

@ -1,5 +1,7 @@
pub mod k8s; pub mod k8s;
mod manager; mod manager;
use std::str::FromStr;
pub use manager::*; pub use manager::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -27,22 +29,18 @@ impl Default for TenantConfig {
Self { Self {
name: format!("tenant_{id}"), name: format!("tenant_{id}"),
id, id,
resource_limits: ResourceLimits { resource_limits: ResourceLimits::default(),
cpu_request_cores: 4.0,
cpu_limit_cores: 4.0,
memory_request_gb: 4.0,
memory_limit_gb: 4.0,
storage_total_gb: 20.0,
},
network_policy: TenantNetworkPolicy { network_policy: TenantNetworkPolicy {
default_inter_tenant_ingress: InterTenantIngressPolicy::DenyAll, default_inter_tenant_ingress: InterTenantIngressPolicy::DenyAll,
default_internet_egress: InternetEgressPolicy::AllowAll, default_internet_egress: InternetEgressPolicy::AllowAll,
additional_allowed_cidr_ingress: vec![],
additional_allowed_cidr_egress: vec![],
}, },
} }
} }
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ResourceLimits { pub struct ResourceLimits {
/// Requested/guaranteed CPU cores (e.g., 2.0). /// Requested/guaranteed CPU cores (e.g., 2.0).
pub cpu_request_cores: f32, pub cpu_request_cores: f32,
@ -58,6 +56,18 @@ pub struct ResourceLimits {
pub storage_total_gb: f32, pub storage_total_gb: f32,
} }
impl Default for ResourceLimits {
fn default() -> Self {
Self {
cpu_request_cores: 4.0,
cpu_limit_cores: 4.0,
memory_request_gb: 4.0,
memory_limit_gb: 4.0,
storage_total_gb: 20.0,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TenantNetworkPolicy { pub struct TenantNetworkPolicy {
/// Policy for ingress traffic originating from other tenants within the same Harmony-managed environment. /// Policy for ingress traffic originating from other tenants within the same Harmony-managed environment.
@ -65,6 +75,20 @@ pub struct TenantNetworkPolicy {
/// Policy for egress traffic destined for the public internet. /// Policy for egress traffic destined for the public internet.
pub default_internet_egress: InternetEgressPolicy, pub default_internet_egress: InternetEgressPolicy,
pub additional_allowed_cidr_ingress: Vec<(Vec<cidr::Ipv4Cidr>, Option<PortSpec>)>,
pub additional_allowed_cidr_egress: Vec<(Vec<cidr::Ipv4Cidr>, Option<PortSpec>)>,
}
impl Default for TenantNetworkPolicy {
fn default() -> Self {
TenantNetworkPolicy {
default_inter_tenant_ingress: InterTenantIngressPolicy::DenyAll,
default_internet_egress: InternetEgressPolicy::DenyAll,
additional_allowed_cidr_ingress: vec![],
additional_allowed_cidr_egress: vec![],
}
}
} }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -80,3 +104,121 @@ pub enum InternetEgressPolicy {
/// Deny all outbound traffic to the internet by default. /// Deny all outbound traffic to the internet by default.
DenyAll, 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<PortSpec, String> {
// Check for single port
if let Ok(port) = spec.parse::<u16>() {
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::<u16>(), end_str.parse::<u16>()) {
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::<u16>().is_ok()) {
let maybe_ports = ports.iter().try_fold(vec![], |mut list, &p| {
if let Ok(p) = p.parse::<u16>() {
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, Self::Err> {
Self::parse_from_str(s)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum PortSpecData {
SinglePort(u16),
PortRange(u16, u16),
ListOfPorts(Vec<u16>),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_single_port() {
let port_spec = "2144".parse::<PortSpec>().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::<PortSpec>().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::<PortSpec>().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::<PortSpec>();
assert!(result.is_err());
}
#[test]
fn test_empty_input() {
let result = "".parse::<PortSpec>();
assert!(result.is_err());
}
#[test]
fn test_only_coma() {
let result = ",".parse::<PortSpec>();
assert!(result.is_err());
}
}

View File

@ -1,8 +1,7 @@
use super::{config::KubePrometheusConfig, monitoring_alerting::AlertChannel}; use super::{config::KubePrometheusConfig, monitoring_alerting::AlertChannel};
use log::info; use log::info;
use non_blank_string_rs::NonBlankString; use non_blank_string_rs::NonBlankString;
use std::{collections::HashMap, str::FromStr}; use std::str::FromStr;
use url::Url;
use crate::modules::helm::chart::HelmChartScore; use crate::modules::helm::chart::HelmChartScore;

View File

@ -13,10 +13,7 @@ use crate::{
topology::{HelmCommand, Topology}, topology::{HelmCommand, Topology},
}; };
use super::{ use super::{config::KubePrometheusConfig, kube_prometheus::kube_prometheus_helm_chart_score};
config::KubePrometheusConfig, discord_alert_manager::discord_alert_manager_score,
kube_prometheus::kube_prometheus_helm_chart_score,
};
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub enum AlertChannel { pub enum AlertChannel {

View File

@ -14,6 +14,7 @@ quote = "1.0.37"
serde = "1.0.217" serde = "1.0.217"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
syn = "2.0.90" syn = "2.0.90"
cidr.workspace = true
[dev-dependencies] [dev-dependencies]
serde = { version = "1.0.217", features = ["derive"] } serde = { version = "1.0.217", features = ["derive"] }

View File

@ -132,3 +132,16 @@ pub fn ingress_path(input: TokenStream) -> TokenStream {
false => panic!("Invalid ingress path"), false => panic!("Invalid ingress path"),
} }
} }
#[proc_macro]
pub fn cidrv4(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as LitStr);
let cidr_str = input.value();
if let Ok(_) = cidr_str.parse::<cidr::Ipv4Cidr>() {
let expanded = quote! { #cidr_str.parse::<cidr::Ipv4Cidr>().unwrap() };
return TokenStream::from(expanded);
}
panic!("Invalid IPv4 CIDR : {}", cidr_str);
}