Skip to main content

ironflow_core/provider/
pod.rs

1//! Per-step pod settings for the Kubernetes ephemeral provider.
2//!
3//! [`PodSettings`] travels inside [`AgentConfig`](super::AgentConfig) so a step
4//! can ask for secrets, a service account, read-only volumes or a managed
5//! settings preset. Only `K8sEphemeralProvider` reads it; every other provider
6//! ignores it. The types live here without a feature gate because the engine
7//! and the workflow author set them regardless of the transport.
8
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12use strum::Display;
13
14/// Pod label carrying the id of the run that created the pod.
15///
16/// Stamped by the engine on every agent step. Together with [`LABEL_STEP`] it
17/// lets the provider find and delete the pods of a previous attempt of the
18/// same step before starting a retry.
19pub const LABEL_RUN_ID: &str = "ironflow.io/run-id";
20
21/// Pod label carrying the sanitized name of the step that created the pod.
22///
23/// The value goes through [`sanitize_label_value`].
24pub const LABEL_STEP: &str = "ironflow.io/step";
25
26/// Pod label carrying the id of the top-level run of the pod's run.
27///
28/// Equal to [`LABEL_RUN_ID`] for a run started on its own; a sub-workflow's
29/// child run carries its parent's root. Stamped by the engine on every agent
30/// step, so a retry of the top-level run finds the pods its children left
31/// behind.
32pub const LABEL_ROOT_RUN_ID: &str = "ironflow.io/root-run-id";
33
34/// Pod label selecting the network egress profile of the pod.
35///
36/// Network policies select agent pods on this label to open egress to the
37/// hosts of a profile (for instance `gitlab`).
38pub const LABEL_EGRESS_PROFILE: &str = "ironflow.io/egress-profile";
39
40/// Label naming the tool that manages an object, set to
41/// [`MANAGED_BY_IRONFLOW`] on every pod, Job and ConfigMap ironflow creates.
42///
43/// Reserved: see [`is_reserved_pod_label`].
44pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
45
46/// Value of [`LABEL_MANAGED_BY`] on every object ironflow creates. The orphan
47/// reaper and the run cleanup select on it.
48pub const MANAGED_BY_IRONFLOW: &str = "ironflow";
49
50/// Label naming what created the object inside ironflow: `claude-runner`,
51/// `prompt-data`, `pod-run` or `job-run`.
52///
53/// Reserved: see [`is_reserved_pod_label`].
54pub const LABEL_COMPONENT: &str = "app.kubernetes.io/component";
55
56/// Annotation holding the unix time (seconds) after which an ironflow pod,
57/// Job or prompt ConfigMap is considered orphaned and may be reaped.
58pub const LABEL_EXPIRES_AT: &str = "ironflow.io/expires-at";
59
60/// Return `true` for a label ironflow sets itself on every object it
61/// creates ([`LABEL_MANAGED_BY`], [`LABEL_COMPONENT`]): a caller cannot set
62/// it, since the orphan reaper and the run cleanup select on it.
63///
64/// # Examples
65///
66/// ```
67/// use ironflow_core::provider::{LABEL_COMPONENT, LABEL_RUN_ID, is_reserved_pod_label};
68///
69/// assert!(is_reserved_pod_label(LABEL_COMPONENT));
70/// assert!(!is_reserved_pod_label(LABEL_RUN_ID));
71/// ```
72pub fn is_reserved_pod_label(key: &str) -> bool {
73    key == LABEL_MANAGED_BY || key == LABEL_COMPONENT
74}
75
76/// Refuse a reserved label passed to a pod builder.
77///
78/// Called by every builder that takes a label from its caller: the K8s
79/// providers, `PodRun` and `JobRun` of `ironflow-ops-k8s`.
80///
81/// # Panics
82///
83/// Panics when [`is_reserved_pod_label`] returns `true` for `key`.
84///
85/// # Examples
86///
87/// ```should_panic
88/// use ironflow_core::provider::{LABEL_COMPONENT, assert_pod_label_allowed};
89///
90/// assert_pod_label_allowed(LABEL_COMPONENT);
91/// ```
92pub fn assert_pod_label_allowed(key: &str) {
93    assert!(
94        !is_reserved_pod_label(key),
95        "pod label '{key}' is reserved: ironflow sets it on every object it creates"
96    );
97}
98
99/// Maximum length of a Kubernetes label value, in bytes.
100const LABEL_VALUE_MAX: usize = 63;
101
102/// Length of the `-xxxxxxxx` hash suffix appended to altered values.
103const HASH_SUFFIX_LEN: usize = 9;
104
105/// Environment variable read from a Kubernetes Secret.
106///
107/// Rendered as `valueFrom.secretKeyRef`: the value never enters the pod spec.
108///
109/// # Examples
110///
111/// ```
112/// use ironflow_core::provider::SecretEnvVar;
113///
114/// let var = SecretEnvVar {
115///     name: "CLAUDE_CODE_OAUTH_TOKEN".to_string(),
116///     secret: "claude-oauth".to_string(),
117///     key: "token".to_string(),
118/// };
119/// assert_eq!(var.secret, "claude-oauth");
120/// ```
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
122pub struct SecretEnvVar {
123    /// Name of the environment variable inside the container.
124    pub name: String,
125    /// Name of the Secret in the pod's namespace.
126    pub secret: String,
127    /// Key inside the Secret.
128    pub key: String,
129}
130
131/// Source of a [`ReadOnlyVolume`].
132///
133/// # Examples
134///
135/// ```
136/// use ironflow_core::provider::PodVolumeSource;
137///
138/// let source = PodVolumeSource::PersistentVolumeClaim {
139///     claim_name: "repos".to_string(),
140/// };
141/// assert!(matches!(source, PodVolumeSource::PersistentVolumeClaim { .. }));
142/// ```
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144#[serde(tag = "kind", rename_all = "snake_case")]
145pub enum PodVolumeSource {
146    /// An existing PersistentVolumeClaim in the pod's namespace.
147    PersistentVolumeClaim {
148        /// Name of the claim.
149        claim_name: String,
150    },
151    /// A directory on the node.
152    HostPath {
153        /// Absolute path of the directory on the node.
154        path: String,
155    },
156    /// An existing ConfigMap in the pod's namespace.
157    ConfigMap {
158        /// Name of the ConfigMap.
159        name: String,
160    },
161}
162
163/// A volume mounted read-only into the agent container.
164///
165/// # Examples
166///
167/// ```
168/// use ironflow_core::provider::{PodVolumeSource, ReadOnlyVolume};
169///
170/// let volume = ReadOnlyVolume {
171///     source: PodVolumeSource::ConfigMap { name: "prompts".to_string() },
172///     mount_path: "/data/prompts".to_string(),
173///     sub_path: None,
174/// };
175/// assert_eq!(volume.mount_path, "/data/prompts");
176/// ```
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ReadOnlyVolume {
179    /// Where the data comes from.
180    pub source: PodVolumeSource,
181    /// Absolute mount path inside the container.
182    pub mount_path: String,
183    /// Optional sub-path of the volume to mount instead of its root.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub sub_path: Option<String>,
186}
187
188/// A PersistentVolumeClaim mounted into the agent container, read-write by
189/// default.
190///
191/// Unlike [`ReadOnlyVolume`], the mount can be read-write and carries an
192/// optional `sub_path`. Several mounts may share one claim.
193///
194/// # Examples
195///
196/// ```
197/// use ironflow_core::provider::PvcVolume;
198///
199/// let volume = PvcVolume {
200///     claim_name: "repos".to_string(),
201///     mount_path: "/data/repos".to_string(),
202///     sub_path: Some("team-a".to_string()),
203///     read_only: true,
204/// };
205/// assert_eq!(volume.claim_name, "repos");
206/// ```
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208pub struct PvcVolume {
209    /// Name of the claim in the pod's namespace.
210    pub claim_name: String,
211    /// Absolute mount path inside the container.
212    pub mount_path: String,
213    /// Optional sub-path of the volume to mount instead of its root.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub sub_path: Option<String>,
216    /// Mount the volume read-only.
217    #[serde(default)]
218    pub read_only: bool,
219}
220
221impl PvcVolume {
222    /// Check that the claim is named and that `sub_path`, when set, passes
223    /// [`validate_pvc_sub_path`]. The mount path is checked by the provider,
224    /// which knows its reserved paths.
225    ///
226    /// # Errors
227    ///
228    /// Returns the reason as a message when the volume is refused.
229    ///
230    /// # Examples
231    ///
232    /// ```
233    /// use ironflow_core::provider::PvcVolume;
234    ///
235    /// let mut volume = PvcVolume {
236    ///     claim_name: "repos".to_string(),
237    ///     mount_path: "/data/repos".to_string(),
238    ///     sub_path: Some("team-a".to_string()),
239    ///     read_only: false,
240    /// };
241    /// assert!(volume.validate().is_ok());
242    /// volume.sub_path = Some("../x".to_string());
243    /// assert!(volume.validate().is_err());
244    /// ```
245    pub fn validate(&self) -> Result<(), String> {
246        if self.claim_name.is_empty() {
247            return Err("pvc volume needs a non-empty claim_name".to_string());
248        }
249        self.sub_path
250            .as_deref()
251            .map_or(Ok(()), validate_pvc_sub_path)
252    }
253}
254
255/// Check the structure of a PVC `subPath`.
256///
257/// Refuses an empty value, a leading `/`, an empty segment (a trailing `/` or
258/// a `//`) and any `.` or `..` segment. Characters are not restricted: a
259/// volume's directories may contain spaces.
260///
261/// # Errors
262///
263/// Returns the reason as a message when `sub_path` is refused.
264///
265/// # Examples
266///
267/// ```
268/// use ironflow_core::provider::validate_pvc_sub_path;
269///
270/// assert!(validate_pvc_sub_path("team a/repos").is_ok());
271/// assert!(validate_pvc_sub_path("../x").is_err());
272/// assert!(validate_pvc_sub_path("/abs").is_err());
273/// assert!(validate_pvc_sub_path("a//b").is_err());
274/// ```
275pub fn validate_pvc_sub_path(sub_path: &str) -> Result<(), String> {
276    // An empty value splits into one empty segment, so it is refused below.
277    let malformed = sub_path.starts_with('/')
278        || sub_path
279            .split('/')
280            .any(|segment| matches!(segment, "" | "." | ".."));
281    if malformed {
282        return Err(format!(
283            "sub_path '{sub_path}' must be relative, without empty, '.' or '..' segments"
284        ));
285    }
286    Ok(())
287}
288
289/// Value of [`LABEL_COMPONENT`] on the PersistentVolumeClaims backing a
290/// persistent agent environment (see [`EnvironmentVolume`]).
291pub const COMPONENT_ENVIRONMENT: &str = "environment";
292
293/// Maximum length of an environment ID (a DNS-1123 subdomain name).
294const ENVIRONMENT_ID_MAX: usize = 253;
295
296/// Check that `environment_id` can name a PersistentVolumeClaim: non-empty,
297/// at most 253 characters, only lowercase ASCII letters, digits and `-`.
298///
299/// # Errors
300///
301/// Returns the reason as a message when the ID is refused.
302pub(crate) fn validate_environment_id(environment_id: &str) -> Result<(), String> {
303    if environment_id.is_empty() {
304        return Err("environment_id must not be empty".to_string());
305    }
306    if environment_id.len() > ENVIRONMENT_ID_MAX {
307        return Err(format!(
308            "environment_id must not exceed {ENVIRONMENT_ID_MAX} characters"
309        ));
310    }
311    let valid = environment_id
312        .chars()
313        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
314    if !valid {
315        return Err(format!(
316            "environment_id must only contain lowercase ASCII letters, digits and '-', got: {environment_id}"
317        ));
318    }
319    Ok(())
320}
321
322/// Check that `environment_id` can name a PersistentVolumeClaim.
323///
324/// # Panics
325///
326/// Panics when [`validate_environment_id`] refuses the ID.
327pub(crate) fn assert_environment_id_valid(environment_id: &str) {
328    if let Err(reason) = validate_environment_id(environment_id) {
329        panic!("{reason}");
330    }
331}
332
333/// Binary unit of a [`VolumeSize`], mapped to the Kubernetes quantity suffix.
334///
335/// # Examples
336///
337/// ```
338/// use ironflow_core::provider::StorageUnit;
339///
340/// assert_eq!(StorageUnit::Gi.to_string(), "Gi");
341/// ```
342#[derive(Debug, Clone, Copy, PartialEq, Eq, Display)]
343pub enum StorageUnit {
344    /// Mebibytes (`Mi`).
345    Mi,
346    /// Gibibytes (`Gi`).
347    Gi,
348    /// Tebibytes (`Ti`).
349    Ti,
350}
351
352/// Storage size: a numeric amount and a [`StorageUnit`].
353///
354/// # Examples
355///
356/// ```
357/// use ironflow_core::provider::{StorageUnit, VolumeSize};
358///
359/// let size = VolumeSize::new(20, StorageUnit::Gi);
360/// assert_eq!(size.to_quantity(), "20Gi");
361/// ```
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct VolumeSize {
364    /// Numeric amount, expressed in [`unit`](Self::unit).
365    pub amount: u64,
366    /// Unit of the amount.
367    pub unit: StorageUnit,
368}
369
370impl VolumeSize {
371    /// Create a size of `amount` `unit`s.
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// use ironflow_core::provider::{StorageUnit, VolumeSize};
377    ///
378    /// let size = VolumeSize::new(512, StorageUnit::Mi);
379    /// assert_eq!(size.amount, 512);
380    /// ```
381    pub fn new(amount: u64, unit: StorageUnit) -> Self {
382        Self { amount, unit }
383    }
384
385    /// Render the size as a Kubernetes quantity, such as `20Gi`.
386    ///
387    /// # Examples
388    ///
389    /// ```
390    /// use ironflow_core::provider::{StorageUnit, VolumeSize};
391    ///
392    /// assert_eq!(VolumeSize::new(1, StorageUnit::Ti).to_quantity(), "1Ti");
393    /// ```
394    pub fn to_quantity(&self) -> String {
395        format!("{}{}", self.amount, self.unit)
396    }
397}
398
399/// Persistent working volume of the K8s ephemeral provider.
400///
401/// When a provider carries one, every agent pod gets a
402/// PersistentVolumeClaim mounted at [`mount_path`](Self::mount_path): a new
403/// claim for a fresh step, the claim named by
404/// [`AgentConfig::resume_environment`](super::AgentConfig::resume_environment)
405/// for a step resuming a previous one. The claim name is handed back as
406/// [`AgentOutput::environment_id`](super::AgentOutput::environment_id).
407///
408/// The claim is `ReadWriteOnce`: only one pod mounts it at a time. Its
409/// [`LABEL_EXPIRES_AT`] annotation is pushed `ttl` forward on every use; the
410/// orphan reaper deletes it once expired.
411///
412/// # Examples
413///
414/// ```
415/// use std::time::Duration;
416/// use ironflow_core::provider::{EnvironmentVolume, StorageUnit, VolumeSize};
417///
418/// let volume = EnvironmentVolume::new("/workspace")
419///     .size(VolumeSize::new(20, StorageUnit::Gi))
420///     .storage_class("fast-ssd")
421///     .ttl(Duration::from_secs(24 * 3600));
422/// assert!(volume.validate().is_ok());
423/// ```
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct EnvironmentVolume {
426    /// Absolute mount path inside the agent container.
427    pub mount_path: String,
428    /// Storage request of a new claim (default 10 `Gi`).
429    pub size: VolumeSize,
430    /// Storage class of a new claim; the cluster default when `None`.
431    pub storage_class: Option<String>,
432    /// Time an unused claim is kept before the reaper deletes it (default 7 days).
433    pub ttl: Duration,
434}
435
436impl EnvironmentVolume {
437    /// Create an environment volume mounted at `mount_path`, with a `10Gi`
438    /// request, the default storage class and a 7 day TTL.
439    ///
440    /// # Examples
441    ///
442    /// ```
443    /// use ironflow_core::provider::EnvironmentVolume;
444    ///
445    /// let volume = EnvironmentVolume::new("/workspace");
446    /// assert_eq!(volume.size.to_quantity(), "10Gi");
447    /// ```
448    pub fn new(mount_path: &str) -> Self {
449        Self {
450            mount_path: mount_path.to_string(),
451            size: VolumeSize::new(10, StorageUnit::Gi),
452            storage_class: None,
453            ttl: Duration::from_secs(7 * 24 * 3600),
454        }
455    }
456
457    /// Set the storage request of a new claim.
458    ///
459    /// # Examples
460    ///
461    /// ```
462    /// use ironflow_core::provider::{EnvironmentVolume, StorageUnit, VolumeSize};
463    ///
464    /// let volume = EnvironmentVolume::new("/workspace").size(VolumeSize::new(50, StorageUnit::Gi));
465    /// assert_eq!(volume.size.to_quantity(), "50Gi");
466    /// ```
467    pub fn size(mut self, size: VolumeSize) -> Self {
468        self.size = size;
469        self
470    }
471
472    /// Set the storage class of a new claim.
473    ///
474    /// # Examples
475    ///
476    /// ```
477    /// use ironflow_core::provider::EnvironmentVolume;
478    ///
479    /// let volume = EnvironmentVolume::new("/workspace").storage_class("standard");
480    /// assert_eq!(volume.storage_class.as_deref(), Some("standard"));
481    /// ```
482    pub fn storage_class(mut self, class: &str) -> Self {
483        self.storage_class = Some(class.to_string());
484        self
485    }
486
487    /// Set the time an unused claim is kept before the reaper deletes it.
488    ///
489    /// # Examples
490    ///
491    /// ```
492    /// use std::time::Duration;
493    /// use ironflow_core::provider::EnvironmentVolume;
494    ///
495    /// let volume = EnvironmentVolume::new("/workspace").ttl(Duration::from_secs(3600));
496    /// assert_eq!(volume.ttl, Duration::from_secs(3600));
497    /// ```
498    pub fn ttl(mut self, ttl: Duration) -> Self {
499        self.ttl = ttl;
500        self
501    }
502
503    /// Check the settings: an absolute mount path other than `/`, a
504    /// non-zero size, a non-empty storage class, a non-zero TTL.
505    ///
506    /// # Errors
507    ///
508    /// Returns the reason as a message when the volume is refused.
509    ///
510    /// # Examples
511    ///
512    /// ```
513    /// use ironflow_core::provider::EnvironmentVolume;
514    ///
515    /// assert!(EnvironmentVolume::new("/workspace").validate().is_ok());
516    /// assert!(EnvironmentVolume::new("workspace").validate().is_err());
517    /// assert!(EnvironmentVolume::new("/").validate().is_err());
518    /// ```
519    pub fn validate(&self) -> Result<(), String> {
520        if !self.mount_path.starts_with('/') || self.mount_path.trim_end_matches('/').is_empty() {
521            return Err(format!(
522                "environment volume mount path '{}' must be absolute and not '/'",
523                self.mount_path
524            ));
525        }
526        if self.size.amount == 0 {
527            return Err("environment volume size must be greater than zero".to_string());
528        }
529        if self
530            .storage_class
531            .as_deref()
532            .is_some_and(|c| c.trim().is_empty())
533        {
534            return Err("environment volume storage class must not be empty".to_string());
535        }
536        if self.ttl.is_zero() {
537            return Err("environment volume ttl must be greater than zero".to_string());
538        }
539        Ok(())
540    }
541}
542
543/// Pod-level settings a step asks for (K8s ephemeral provider only).
544///
545/// Merged with the provider's own settings when the pod is built: the step
546/// wins on conflicts (same env var name, service account, managed settings).
547///
548/// # Examples
549///
550/// ```
551/// use ironflow_core::provider::PodSettings;
552///
553/// let settings = PodSettings::default();
554/// assert!(settings.is_empty());
555/// ```
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
557pub struct PodSettings {
558    /// Environment variables read from Kubernetes Secrets.
559    #[serde(default, skip_serializing_if = "Vec::is_empty")]
560    pub secret_env: Vec<SecretEnvVar>,
561    /// Service account of the pod. Overrides the provider's.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub service_account: Option<String>,
564    /// Volumes mounted read-only, after the provider's.
565    #[serde(default, skip_serializing_if = "Vec::is_empty")]
566    pub read_only_volumes: Vec<ReadOnlyVolume>,
567    /// Name of a managed-settings preset registered on the provider.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub managed_settings: Option<String>,
570    /// RuntimeClass of the pod (`spec.runtimeClassName`). Overrides the provider's.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub runtime_class: Option<String>,
573    /// PVC mounts of the step, merged after the provider's volumes.
574    #[serde(default, skip_serializing_if = "Vec::is_empty")]
575    pub pvc_volumes: Vec<PvcVolume>,
576    /// Drop the provider's `volume` and `pvc_volume` mounts for this step.
577    #[serde(default)]
578    pub without_provider_volumes: bool,
579}
580
581impl PodSettings {
582    /// Return `true` when no setting is set.
583    ///
584    /// # Examples
585    ///
586    /// ```
587    /// use ironflow_core::provider::PodSettings;
588    ///
589    /// let mut settings = PodSettings::default();
590    /// assert!(settings.is_empty());
591    /// settings.service_account = Some("agent".to_string());
592    /// assert!(!settings.is_empty());
593    /// ```
594    pub fn is_empty(&self) -> bool {
595        self.secret_env.is_empty()
596            && self.service_account.is_none()
597            && self.read_only_volumes.is_empty()
598            && self.managed_settings.is_none()
599            && self.runtime_class.is_none()
600            && self.pvc_volumes.is_empty()
601            && !self.without_provider_volumes
602    }
603}
604
605/// Add `entry` to `list`, replacing in place an entry with the same name.
606pub(crate) fn upsert_secret_env(list: &mut Vec<SecretEnvVar>, entry: SecretEnvVar) {
607    match list.iter_mut().find(|e| e.name == entry.name) {
608        Some(existing) => *existing = entry,
609        None => list.push(entry),
610    }
611}
612
613/// 32-bit FNV-1a hash: deterministic across processes and platforms.
614fn fnv1a(data: &[u8]) -> u32 {
615    let mut hash: u32 = 0x811c_9dc5;
616    for byte in data {
617        hash ^= u32::from(*byte);
618        hash = hash.wrapping_mul(0x0100_0193);
619    }
620    hash
621}
622
623fn is_label_char(c: char) -> bool {
624    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
625}
626
627fn is_valid_label_value(raw: &str) -> bool {
628    !raw.is_empty()
629        && raw.len() <= LABEL_VALUE_MAX
630        && raw.chars().all(is_label_char)
631        && raw.starts_with(|c: char| c.is_ascii_alphanumeric())
632        && raw.ends_with(|c: char| c.is_ascii_alphanumeric())
633}
634
635/// Turn any string into a valid Kubernetes label value.
636///
637/// A value that is already valid is returned unchanged. Otherwise every char
638/// outside `[A-Za-z0-9._-]` becomes `-`, the result is truncated, leading and
639/// trailing non-alphanumerics are trimmed, an empty result becomes `unnamed`,
640/// and `-` plus 8 hex chars of a stable hash of the raw input is appended, so
641/// that `"a b"` and `"a/b"` do not collide. The output is at most 63 bytes and
642/// deterministic.
643///
644/// # Examples
645///
646/// ```
647/// use ironflow_core::provider::sanitize_label_value;
648///
649/// assert_eq!(sanitize_label_value("investigate"), "investigate");
650/// assert!(sanitize_label_value("fix bug/42").starts_with("fix-bug-42-"));
651/// assert_ne!(sanitize_label_value("a b"), sanitize_label_value("a/b"));
652/// ```
653pub fn sanitize_label_value(raw: &str) -> String {
654    if is_valid_label_value(raw) {
655        return raw.to_string();
656    }
657
658    let replaced: String = raw
659        .chars()
660        .map(|c| if is_label_char(c) { c } else { '-' })
661        .collect();
662    // Only ASCII remains, so byte truncation cannot split a char.
663    let truncated = &replaced[..replaced.len().min(LABEL_VALUE_MAX - HASH_SUFFIX_LEN)];
664    let trimmed = truncated.trim_matches(|c: char| !c.is_ascii_alphanumeric());
665    let base = match trimmed {
666        "" => "unnamed",
667        valid => valid,
668    };
669    format!("{base}-{:08x}", fnv1a(raw.as_bytes()))
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    #[test]
677    fn environment_volume_defaults() {
678        let volume = EnvironmentVolume::new("/workspace");
679        assert_eq!(volume.mount_path, "/workspace");
680        assert_eq!(volume.size, VolumeSize::new(10, StorageUnit::Gi));
681        assert_eq!(volume.storage_class, None);
682        assert_eq!(volume.ttl, Duration::from_secs(7 * 24 * 3600));
683        assert!(volume.validate().is_ok());
684    }
685
686    #[test]
687    fn environment_volume_validate_rejects_relative_and_root_paths() {
688        assert!(EnvironmentVolume::new("workspace").validate().is_err());
689        assert!(EnvironmentVolume::new("").validate().is_err());
690        assert!(EnvironmentVolume::new("/").validate().is_err());
691        assert!(EnvironmentVolume::new("//").validate().is_err());
692    }
693
694    #[test]
695    fn volume_size_renders_kubernetes_quantity() {
696        assert_eq!(VolumeSize::new(512, StorageUnit::Mi).to_quantity(), "512Mi");
697        assert_eq!(VolumeSize::new(20, StorageUnit::Gi).to_quantity(), "20Gi");
698        assert_eq!(VolumeSize::new(2, StorageUnit::Ti).to_quantity(), "2Ti");
699    }
700
701    #[test]
702    fn environment_volume_validate_rejects_zero_size_empty_class_and_zero_ttl() {
703        assert!(
704            EnvironmentVolume::new("/w")
705                .size(VolumeSize::new(0, StorageUnit::Gi))
706                .validate()
707                .is_err()
708        );
709        assert!(
710            EnvironmentVolume::new("/w")
711                .storage_class(" ")
712                .validate()
713                .is_err()
714        );
715        assert!(
716            EnvironmentVolume::new("/w")
717                .ttl(Duration::ZERO)
718                .validate()
719                .is_err()
720        );
721    }
722
723    #[test]
724    fn environment_id_valid_accepts_dns_names() {
725        assert_environment_id_valid("ironflow-env-0192f0c1-7d2e");
726        assert_environment_id_valid(&"a".repeat(ENVIRONMENT_ID_MAX));
727    }
728
729    #[test]
730    #[should_panic(expected = "environment_id must not be empty")]
731    fn environment_id_empty_panics() {
732        assert_environment_id_valid("");
733    }
734
735    #[test]
736    #[should_panic(expected = "environment_id must only contain")]
737    fn environment_id_uppercase_panics() {
738        assert_environment_id_valid("Env-1");
739    }
740
741    #[test]
742    #[should_panic(expected = "environment_id must not exceed")]
743    fn environment_id_too_long_panics() {
744        assert_environment_id_valid(&"a".repeat(ENVIRONMENT_ID_MAX + 1));
745    }
746
747    #[test]
748    fn k8s_sanitize_label_value_keeps_valid_value() {
749        assert_eq!(sanitize_label_value("investigate"), "investigate");
750        assert_eq!(sanitize_label_value("step-1.a_b"), "step-1.a_b");
751    }
752
753    #[test]
754    fn k8s_sanitize_label_value_replaces_invalid_chars_and_adds_hash() {
755        let value = sanitize_label_value("fix bug/42");
756        assert!(value.starts_with("fix-bug-42-"), "got {value}");
757        assert_eq!(value.len(), "fix-bug-42".len() + HASH_SUFFIX_LEN);
758        let suffix = &value["fix-bug-42-".len()..];
759        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
760    }
761
762    #[test]
763    fn k8s_sanitize_label_value_distinguishes_similar_inputs() {
764        assert_ne!(sanitize_label_value("a b"), sanitize_label_value("a/b"));
765    }
766
767    #[test]
768    fn k8s_sanitize_label_value_truncates_long_input() {
769        let raw = "x".repeat(200);
770        let value = sanitize_label_value(&raw);
771        assert!(value.len() <= LABEL_VALUE_MAX, "len {}", value.len());
772        assert!(value.ends_with(|c: char| c.is_ascii_alphanumeric()));
773        assert!(value.starts_with(|c: char| c.is_ascii_alphanumeric()));
774    }
775
776    #[test]
777    fn k8s_sanitize_label_value_trims_non_alphanumeric_edges() {
778        let value = sanitize_label_value("--step--");
779        assert!(value.starts_with("step-"), "got {value}");
780    }
781
782    #[test]
783    fn k8s_sanitize_label_value_empty_and_unicode_only() {
784        assert!(sanitize_label_value("").starts_with("unnamed-"));
785        assert!(sanitize_label_value("日本語").starts_with("unnamed-"));
786        assert_ne!(sanitize_label_value(""), sanitize_label_value("日本語"));
787    }
788
789    #[test]
790    fn k8s_sanitize_label_value_is_deterministic() {
791        let raw = "Résumé / step #3";
792        assert_eq!(sanitize_label_value(raw), sanitize_label_value(raw));
793        assert!(is_valid_label_value(&sanitize_label_value(raw)));
794    }
795
796    #[test]
797    fn k8s_pod_settings_is_empty() {
798        assert!(PodSettings::default().is_empty());
799        let with_secret = PodSettings {
800            secret_env: vec![SecretEnvVar::default()],
801            ..PodSettings::default()
802        };
803        assert!(!with_secret.is_empty());
804        let with_preset = PodSettings {
805            managed_settings: Some("locked".to_string()),
806            ..PodSettings::default()
807        };
808        assert!(!with_preset.is_empty());
809        let with_runtime_class = PodSettings {
810            runtime_class: Some("gvisor".to_string()),
811            ..PodSettings::default()
812        };
813        assert!(!with_runtime_class.is_empty());
814        let with_volume = PodSettings {
815            read_only_volumes: vec![ReadOnlyVolume {
816                source: PodVolumeSource::HostPath {
817                    path: "/srv".to_string(),
818                },
819                mount_path: "/data".to_string(),
820                sub_path: None,
821            }],
822            ..PodSettings::default()
823        };
824        assert!(!with_volume.is_empty());
825        let with_pvc = PodSettings {
826            pvc_volumes: vec![PvcVolume {
827                claim_name: "repos".to_string(),
828                mount_path: "/data".to_string(),
829                sub_path: None,
830                read_only: false,
831            }],
832            ..PodSettings::default()
833        };
834        assert!(!with_pvc.is_empty());
835        let without_provider = PodSettings {
836            without_provider_volumes: true,
837            ..PodSettings::default()
838        };
839        assert!(!without_provider.is_empty());
840    }
841
842    #[test]
843    fn k8s_validate_pvc_sub_path_accepts_nested_and_spaces() {
844        assert!(validate_pvc_sub_path("a").is_ok());
845        assert!(validate_pvc_sub_path("a/b c/d.e").is_ok());
846        assert!(validate_pvc_sub_path("..a/.b").is_ok());
847    }
848
849    #[test]
850    fn k8s_validate_pvc_sub_path_refuses_structural_errors() {
851        for bad in ["", "/abs", "a//b", "a/", ".", "..", "a/../b", "./a"] {
852            assert!(validate_pvc_sub_path(bad).is_err(), "{bad:?} accepted");
853        }
854    }
855
856    #[test]
857    fn k8s_pvc_volume_validate() {
858        let volume = PvcVolume {
859            claim_name: "c".to_string(),
860            mount_path: "/m".to_string(),
861            sub_path: None,
862            read_only: false,
863        };
864        assert!(volume.validate().is_ok());
865        let nested = PvcVolume {
866            sub_path: Some("a/b".to_string()),
867            ..volume.clone()
868        };
869        assert!(nested.validate().is_ok());
870        let unnamed = PvcVolume {
871            claim_name: String::new(),
872            ..volume.clone()
873        };
874        assert!(unnamed.validate().unwrap_err().contains("claim_name"));
875        let escaping = PvcVolume {
876            sub_path: Some("../x".to_string()),
877            ..volume
878        };
879        assert!(escaping.validate().unwrap_err().contains("sub_path"));
880    }
881
882    #[test]
883    fn k8s_pvc_volume_serde_skips_defaults() {
884        let volume = PvcVolume {
885            claim_name: "c".to_string(),
886            mount_path: "/m".to_string(),
887            sub_path: None,
888            read_only: false,
889        };
890        let json = serde_json::to_value(&volume).unwrap();
891        assert!(json.get("sub_path").is_none());
892        let back: PvcVolume =
893            serde_json::from_value(serde_json::json!({"claim_name":"c","mount_path":"/m"}))
894                .unwrap();
895        assert_eq!(back, volume);
896    }
897
898    #[test]
899    fn k8s_pod_volume_source_serde_is_tagged() {
900        let source = PodVolumeSource::PersistentVolumeClaim {
901            claim_name: "repos".to_string(),
902        };
903        let json = serde_json::to_value(&source).unwrap();
904        assert_eq!(json["kind"], "persistent_volume_claim");
905        assert_eq!(json["claim_name"], "repos");
906        let back: PodVolumeSource = serde_json::from_value(json).unwrap();
907        assert_eq!(back, source);
908    }
909}