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}