use serde::{Deserialize, Serialize};
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, 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>,
}
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()
}
}
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 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_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());
}
#[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);
}
}