use std::time::Duration;
use serde::{Deserialize, Serialize};
use strum::Display;
pub const LABEL_RUN_ID: &str = "ironflow.io/run-id";
pub const LABEL_STEP: &str = "ironflow.io/step";
pub const LABEL_ROOT_RUN_ID: &str = "ironflow.io/root-run-id";
pub const LABEL_EGRESS_PROFILE: &str = "ironflow.io/egress-profile";
pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
pub const MANAGED_BY_IRONFLOW: &str = "ironflow";
pub const LABEL_COMPONENT: &str = "app.kubernetes.io/component";
pub const LABEL_EXPIRES_AT: &str = "ironflow.io/expires-at";
pub fn is_reserved_pod_label(key: &str) -> bool {
key == LABEL_MANAGED_BY || key == LABEL_COMPONENT
}
pub fn assert_pod_label_allowed(key: &str) {
assert!(
!is_reserved_pod_label(key),
"pod label '{key}' is reserved: ironflow sets it on every object it creates"
);
}
const LABEL_VALUE_MAX: usize = 63;
const HASH_SUFFIX_LEN: usize = 9;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SecretEnvVar {
pub name: String,
pub secret: String,
pub key: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PodVolumeSource {
PersistentVolumeClaim {
claim_name: String,
},
HostPath {
path: String,
},
ConfigMap {
name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReadOnlyVolume {
pub source: PodVolumeSource,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sub_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PvcVolume {
pub claim_name: String,
pub mount_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sub_path: Option<String>,
#[serde(default)]
pub read_only: bool,
}
impl PvcVolume {
pub fn validate(&self) -> Result<(), String> {
if self.claim_name.is_empty() {
return Err("pvc volume needs a non-empty claim_name".to_string());
}
self.sub_path
.as_deref()
.map_or(Ok(()), validate_pvc_sub_path)
}
}
pub fn validate_pvc_sub_path(sub_path: &str) -> Result<(), String> {
let malformed = sub_path.starts_with('/')
|| sub_path
.split('/')
.any(|segment| matches!(segment, "" | "." | ".."));
if malformed {
return Err(format!(
"sub_path '{sub_path}' must be relative, without empty, '.' or '..' segments"
));
}
Ok(())
}
pub const COMPONENT_ENVIRONMENT: &str = "environment";
const ENVIRONMENT_ID_MAX: usize = 253;
pub(crate) fn validate_environment_id(environment_id: &str) -> Result<(), String> {
if environment_id.is_empty() {
return Err("environment_id must not be empty".to_string());
}
if environment_id.len() > ENVIRONMENT_ID_MAX {
return Err(format!(
"environment_id must not exceed {ENVIRONMENT_ID_MAX} characters"
));
}
let valid = environment_id
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if !valid {
return Err(format!(
"environment_id must only contain lowercase ASCII letters, digits and '-', got: {environment_id}"
));
}
Ok(())
}
pub(crate) fn assert_environment_id_valid(environment_id: &str) {
if let Err(reason) = validate_environment_id(environment_id) {
panic!("{reason}");
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
pub enum StorageUnit {
Mi,
Gi,
Ti,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VolumeSize {
pub amount: u64,
pub unit: StorageUnit,
}
impl VolumeSize {
pub fn new(amount: u64, unit: StorageUnit) -> Self {
Self { amount, unit }
}
pub fn to_quantity(&self) -> String {
format!("{}{}", self.amount, self.unit)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentVolume {
pub mount_path: String,
pub size: VolumeSize,
pub storage_class: Option<String>,
pub ttl: Duration,
}
impl EnvironmentVolume {
pub fn new(mount_path: &str) -> Self {
Self {
mount_path: mount_path.to_string(),
size: VolumeSize::new(10, StorageUnit::Gi),
storage_class: None,
ttl: Duration::from_secs(7 * 24 * 3600),
}
}
pub fn size(mut self, size: VolumeSize) -> Self {
self.size = size;
self
}
pub fn storage_class(mut self, class: &str) -> Self {
self.storage_class = Some(class.to_string());
self
}
pub fn ttl(mut self, ttl: Duration) -> Self {
self.ttl = ttl;
self
}
pub fn validate(&self) -> Result<(), String> {
if !self.mount_path.starts_with('/') || self.mount_path.trim_end_matches('/').is_empty() {
return Err(format!(
"environment volume mount path '{}' must be absolute and not '/'",
self.mount_path
));
}
if self.size.amount == 0 {
return Err("environment volume size must be greater than zero".to_string());
}
if self
.storage_class
.as_deref()
.is_some_and(|c| c.trim().is_empty())
{
return Err("environment volume storage class must not be empty".to_string());
}
if self.ttl.is_zero() {
return Err("environment volume ttl must be greater than zero".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PodSettings {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret_env: Vec<SecretEnvVar>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_account: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub read_only_volumes: Vec<ReadOnlyVolume>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub managed_settings: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_class: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pvc_volumes: Vec<PvcVolume>,
#[serde(default)]
pub without_provider_volumes: bool,
}
impl PodSettings {
pub fn is_empty(&self) -> bool {
self.secret_env.is_empty()
&& self.service_account.is_none()
&& self.read_only_volumes.is_empty()
&& self.managed_settings.is_none()
&& self.runtime_class.is_none()
&& self.pvc_volumes.is_empty()
&& !self.without_provider_volumes
}
}
pub(crate) fn upsert_secret_env(list: &mut Vec<SecretEnvVar>, entry: SecretEnvVar) {
match list.iter_mut().find(|e| e.name == entry.name) {
Some(existing) => *existing = entry,
None => list.push(entry),
}
}
fn fnv1a(data: &[u8]) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in data {
hash ^= u32::from(*byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
fn is_label_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
}
fn is_valid_label_value(raw: &str) -> bool {
!raw.is_empty()
&& raw.len() <= LABEL_VALUE_MAX
&& raw.chars().all(is_label_char)
&& raw.starts_with(|c: char| c.is_ascii_alphanumeric())
&& raw.ends_with(|c: char| c.is_ascii_alphanumeric())
}
pub fn sanitize_label_value(raw: &str) -> String {
if is_valid_label_value(raw) {
return raw.to_string();
}
let replaced: String = raw
.chars()
.map(|c| if is_label_char(c) { c } else { '-' })
.collect();
let truncated = &replaced[..replaced.len().min(LABEL_VALUE_MAX - HASH_SUFFIX_LEN)];
let trimmed = truncated.trim_matches(|c: char| !c.is_ascii_alphanumeric());
let base = match trimmed {
"" => "unnamed",
valid => valid,
};
format!("{base}-{:08x}", fnv1a(raw.as_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_volume_defaults() {
let volume = EnvironmentVolume::new("/workspace");
assert_eq!(volume.mount_path, "/workspace");
assert_eq!(volume.size, VolumeSize::new(10, StorageUnit::Gi));
assert_eq!(volume.storage_class, None);
assert_eq!(volume.ttl, Duration::from_secs(7 * 24 * 3600));
assert!(volume.validate().is_ok());
}
#[test]
fn environment_volume_validate_rejects_relative_and_root_paths() {
assert!(EnvironmentVolume::new("workspace").validate().is_err());
assert!(EnvironmentVolume::new("").validate().is_err());
assert!(EnvironmentVolume::new("/").validate().is_err());
assert!(EnvironmentVolume::new("//").validate().is_err());
}
#[test]
fn volume_size_renders_kubernetes_quantity() {
assert_eq!(VolumeSize::new(512, StorageUnit::Mi).to_quantity(), "512Mi");
assert_eq!(VolumeSize::new(20, StorageUnit::Gi).to_quantity(), "20Gi");
assert_eq!(VolumeSize::new(2, StorageUnit::Ti).to_quantity(), "2Ti");
}
#[test]
fn environment_volume_validate_rejects_zero_size_empty_class_and_zero_ttl() {
assert!(
EnvironmentVolume::new("/w")
.size(VolumeSize::new(0, StorageUnit::Gi))
.validate()
.is_err()
);
assert!(
EnvironmentVolume::new("/w")
.storage_class(" ")
.validate()
.is_err()
);
assert!(
EnvironmentVolume::new("/w")
.ttl(Duration::ZERO)
.validate()
.is_err()
);
}
#[test]
fn environment_id_valid_accepts_dns_names() {
assert_environment_id_valid("ironflow-env-0192f0c1-7d2e");
assert_environment_id_valid(&"a".repeat(ENVIRONMENT_ID_MAX));
}
#[test]
#[should_panic(expected = "environment_id must not be empty")]
fn environment_id_empty_panics() {
assert_environment_id_valid("");
}
#[test]
#[should_panic(expected = "environment_id must only contain")]
fn environment_id_uppercase_panics() {
assert_environment_id_valid("Env-1");
}
#[test]
#[should_panic(expected = "environment_id must not exceed")]
fn environment_id_too_long_panics() {
assert_environment_id_valid(&"a".repeat(ENVIRONMENT_ID_MAX + 1));
}
#[test]
fn k8s_sanitize_label_value_keeps_valid_value() {
assert_eq!(sanitize_label_value("investigate"), "investigate");
assert_eq!(sanitize_label_value("step-1.a_b"), "step-1.a_b");
}
#[test]
fn k8s_sanitize_label_value_replaces_invalid_chars_and_adds_hash() {
let value = sanitize_label_value("fix bug/42");
assert!(value.starts_with("fix-bug-42-"), "got {value}");
assert_eq!(value.len(), "fix-bug-42".len() + HASH_SUFFIX_LEN);
let suffix = &value["fix-bug-42-".len()..];
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn k8s_sanitize_label_value_distinguishes_similar_inputs() {
assert_ne!(sanitize_label_value("a b"), sanitize_label_value("a/b"));
}
#[test]
fn k8s_sanitize_label_value_truncates_long_input() {
let raw = "x".repeat(200);
let value = sanitize_label_value(&raw);
assert!(value.len() <= LABEL_VALUE_MAX, "len {}", value.len());
assert!(value.ends_with(|c: char| c.is_ascii_alphanumeric()));
assert!(value.starts_with(|c: char| c.is_ascii_alphanumeric()));
}
#[test]
fn k8s_sanitize_label_value_trims_non_alphanumeric_edges() {
let value = sanitize_label_value("--step--");
assert!(value.starts_with("step-"), "got {value}");
}
#[test]
fn k8s_sanitize_label_value_empty_and_unicode_only() {
assert!(sanitize_label_value("").starts_with("unnamed-"));
assert!(sanitize_label_value("日本語").starts_with("unnamed-"));
assert_ne!(sanitize_label_value(""), sanitize_label_value("日本語"));
}
#[test]
fn k8s_sanitize_label_value_is_deterministic() {
let raw = "Résumé / step #3";
assert_eq!(sanitize_label_value(raw), sanitize_label_value(raw));
assert!(is_valid_label_value(&sanitize_label_value(raw)));
}
#[test]
fn k8s_pod_settings_is_empty() {
assert!(PodSettings::default().is_empty());
let with_secret = PodSettings {
secret_env: vec![SecretEnvVar::default()],
..PodSettings::default()
};
assert!(!with_secret.is_empty());
let with_preset = PodSettings {
managed_settings: Some("locked".to_string()),
..PodSettings::default()
};
assert!(!with_preset.is_empty());
let with_runtime_class = PodSettings {
runtime_class: Some("gvisor".to_string()),
..PodSettings::default()
};
assert!(!with_runtime_class.is_empty());
let with_volume = PodSettings {
read_only_volumes: vec![ReadOnlyVolume {
source: PodVolumeSource::HostPath {
path: "/srv".to_string(),
},
mount_path: "/data".to_string(),
sub_path: None,
}],
..PodSettings::default()
};
assert!(!with_volume.is_empty());
let with_pvc = PodSettings {
pvc_volumes: vec![PvcVolume {
claim_name: "repos".to_string(),
mount_path: "/data".to_string(),
sub_path: None,
read_only: false,
}],
..PodSettings::default()
};
assert!(!with_pvc.is_empty());
let without_provider = PodSettings {
without_provider_volumes: true,
..PodSettings::default()
};
assert!(!without_provider.is_empty());
}
#[test]
fn k8s_validate_pvc_sub_path_accepts_nested_and_spaces() {
assert!(validate_pvc_sub_path("a").is_ok());
assert!(validate_pvc_sub_path("a/b c/d.e").is_ok());
assert!(validate_pvc_sub_path("..a/.b").is_ok());
}
#[test]
fn k8s_validate_pvc_sub_path_refuses_structural_errors() {
for bad in ["", "/abs", "a//b", "a/", ".", "..", "a/../b", "./a"] {
assert!(validate_pvc_sub_path(bad).is_err(), "{bad:?} accepted");
}
}
#[test]
fn k8s_pvc_volume_validate() {
let volume = PvcVolume {
claim_name: "c".to_string(),
mount_path: "/m".to_string(),
sub_path: None,
read_only: false,
};
assert!(volume.validate().is_ok());
let nested = PvcVolume {
sub_path: Some("a/b".to_string()),
..volume.clone()
};
assert!(nested.validate().is_ok());
let unnamed = PvcVolume {
claim_name: String::new(),
..volume.clone()
};
assert!(unnamed.validate().unwrap_err().contains("claim_name"));
let escaping = PvcVolume {
sub_path: Some("../x".to_string()),
..volume
};
assert!(escaping.validate().unwrap_err().contains("sub_path"));
}
#[test]
fn k8s_pvc_volume_serde_skips_defaults() {
let volume = PvcVolume {
claim_name: "c".to_string(),
mount_path: "/m".to_string(),
sub_path: None,
read_only: false,
};
let json = serde_json::to_value(&volume).unwrap();
assert!(json.get("sub_path").is_none());
let back: PvcVolume =
serde_json::from_value(serde_json::json!({"claim_name":"c","mount_path":"/m"}))
.unwrap();
assert_eq!(back, volume);
}
#[test]
fn k8s_pod_volume_source_serde_is_tagged() {
let source = PodVolumeSource::PersistentVolumeClaim {
claim_name: "repos".to_string(),
};
let json = serde_json::to_value(&source).unwrap();
assert_eq!(json["kind"], "persistent_volume_claim");
assert_eq!(json["claim_name"], "repos");
let back: PodVolumeSource = serde_json::from_value(json).unwrap();
assert_eq!(back, source);
}
}