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 serde::{Deserialize, Serialize};
10
11/// Pod label carrying the id of the run that created the pod.
12///
13/// Stamped by the engine on every agent step. Together with [`LABEL_STEP`] it
14/// lets the provider find and delete the pods of a previous attempt of the
15/// same step before starting a retry.
16pub const LABEL_RUN_ID: &str = "ironflow.io/run-id";
17
18/// Pod label carrying the sanitized name of the step that created the pod.
19///
20/// The value goes through [`sanitize_label_value`].
21pub const LABEL_STEP: &str = "ironflow.io/step";
22
23/// Pod label carrying the id of the top-level run of the pod's run.
24///
25/// Equal to [`LABEL_RUN_ID`] for a run started on its own; a sub-workflow's
26/// child run carries its parent's root. Stamped by the engine on every agent
27/// step, so a retry of the top-level run finds the pods its children left
28/// behind.
29pub const LABEL_ROOT_RUN_ID: &str = "ironflow.io/root-run-id";
30
31/// Pod label selecting the network egress profile of the pod.
32///
33/// Network policies select agent pods on this label to open egress to the
34/// hosts of a profile (for instance `gitlab`).
35pub const LABEL_EGRESS_PROFILE: &str = "ironflow.io/egress-profile";
36
37/// Label naming the tool that manages an object, set to
38/// [`MANAGED_BY_IRONFLOW`] on every pod, Job and ConfigMap ironflow creates.
39///
40/// Reserved: see [`is_reserved_pod_label`].
41pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by";
42
43/// Value of [`LABEL_MANAGED_BY`] on every object ironflow creates. The orphan
44/// reaper and the run cleanup select on it.
45pub const MANAGED_BY_IRONFLOW: &str = "ironflow";
46
47/// Label naming what created the object inside ironflow: `claude-runner`,
48/// `prompt-data`, `pod-run` or `job-run`.
49///
50/// Reserved: see [`is_reserved_pod_label`].
51pub const LABEL_COMPONENT: &str = "app.kubernetes.io/component";
52
53/// Annotation holding the unix time (seconds) after which an ironflow pod,
54/// Job or prompt ConfigMap is considered orphaned and may be reaped.
55pub const LABEL_EXPIRES_AT: &str = "ironflow.io/expires-at";
56
57/// Return `true` for a label ironflow sets itself on every object it
58/// creates ([`LABEL_MANAGED_BY`], [`LABEL_COMPONENT`]): a caller cannot set
59/// it, since the orphan reaper and the run cleanup select on it.
60///
61/// # Examples
62///
63/// ```
64/// use ironflow_core::provider::{LABEL_COMPONENT, LABEL_RUN_ID, is_reserved_pod_label};
65///
66/// assert!(is_reserved_pod_label(LABEL_COMPONENT));
67/// assert!(!is_reserved_pod_label(LABEL_RUN_ID));
68/// ```
69pub fn is_reserved_pod_label(key: &str) -> bool {
70    key == LABEL_MANAGED_BY || key == LABEL_COMPONENT
71}
72
73/// Refuse a reserved label passed to a pod builder.
74///
75/// Called by every builder that takes a label from its caller: the K8s
76/// providers, `PodRun` and `JobRun` of `ironflow-ops-k8s`.
77///
78/// # Panics
79///
80/// Panics when [`is_reserved_pod_label`] returns `true` for `key`.
81///
82/// # Examples
83///
84/// ```should_panic
85/// use ironflow_core::provider::{LABEL_COMPONENT, assert_pod_label_allowed};
86///
87/// assert_pod_label_allowed(LABEL_COMPONENT);
88/// ```
89pub fn assert_pod_label_allowed(key: &str) {
90    assert!(
91        !is_reserved_pod_label(key),
92        "pod label '{key}' is reserved: ironflow sets it on every object it creates"
93    );
94}
95
96/// Maximum length of a Kubernetes label value, in bytes.
97const LABEL_VALUE_MAX: usize = 63;
98
99/// Length of the `-xxxxxxxx` hash suffix appended to altered values.
100const HASH_SUFFIX_LEN: usize = 9;
101
102/// Environment variable read from a Kubernetes Secret.
103///
104/// Rendered as `valueFrom.secretKeyRef`: the value never enters the pod spec.
105///
106/// # Examples
107///
108/// ```
109/// use ironflow_core::provider::SecretEnvVar;
110///
111/// let var = SecretEnvVar {
112///     name: "CLAUDE_CODE_OAUTH_TOKEN".to_string(),
113///     secret: "claude-oauth".to_string(),
114///     key: "token".to_string(),
115/// };
116/// assert_eq!(var.secret, "claude-oauth");
117/// ```
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
119pub struct SecretEnvVar {
120    /// Name of the environment variable inside the container.
121    pub name: String,
122    /// Name of the Secret in the pod's namespace.
123    pub secret: String,
124    /// Key inside the Secret.
125    pub key: String,
126}
127
128/// Source of a [`ReadOnlyVolume`].
129///
130/// # Examples
131///
132/// ```
133/// use ironflow_core::provider::PodVolumeSource;
134///
135/// let source = PodVolumeSource::PersistentVolumeClaim {
136///     claim_name: "repos".to_string(),
137/// };
138/// assert!(matches!(source, PodVolumeSource::PersistentVolumeClaim { .. }));
139/// ```
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case")]
142pub enum PodVolumeSource {
143    /// An existing PersistentVolumeClaim in the pod's namespace.
144    PersistentVolumeClaim {
145        /// Name of the claim.
146        claim_name: String,
147    },
148    /// A directory on the node.
149    HostPath {
150        /// Absolute path of the directory on the node.
151        path: String,
152    },
153    /// An existing ConfigMap in the pod's namespace.
154    ConfigMap {
155        /// Name of the ConfigMap.
156        name: String,
157    },
158}
159
160/// A volume mounted read-only into the agent container.
161///
162/// # Examples
163///
164/// ```
165/// use ironflow_core::provider::{PodVolumeSource, ReadOnlyVolume};
166///
167/// let volume = ReadOnlyVolume {
168///     source: PodVolumeSource::ConfigMap { name: "prompts".to_string() },
169///     mount_path: "/data/prompts".to_string(),
170///     sub_path: None,
171/// };
172/// assert_eq!(volume.mount_path, "/data/prompts");
173/// ```
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct ReadOnlyVolume {
176    /// Where the data comes from.
177    pub source: PodVolumeSource,
178    /// Absolute mount path inside the container.
179    pub mount_path: String,
180    /// Optional sub-path of the volume to mount instead of its root.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub sub_path: Option<String>,
183}
184
185/// Pod-level settings a step asks for (K8s ephemeral provider only).
186///
187/// Merged with the provider's own settings when the pod is built: the step
188/// wins on conflicts (same env var name, service account, managed settings).
189///
190/// # Examples
191///
192/// ```
193/// use ironflow_core::provider::PodSettings;
194///
195/// let settings = PodSettings::default();
196/// assert!(settings.is_empty());
197/// ```
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
199pub struct PodSettings {
200    /// Environment variables read from Kubernetes Secrets.
201    #[serde(default, skip_serializing_if = "Vec::is_empty")]
202    pub secret_env: Vec<SecretEnvVar>,
203    /// Service account of the pod. Overrides the provider's.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub service_account: Option<String>,
206    /// Volumes mounted read-only, after the provider's.
207    #[serde(default, skip_serializing_if = "Vec::is_empty")]
208    pub read_only_volumes: Vec<ReadOnlyVolume>,
209    /// Name of a managed-settings preset registered on the provider.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub managed_settings: Option<String>,
212}
213
214impl PodSettings {
215    /// Return `true` when no setting is set.
216    ///
217    /// # Examples
218    ///
219    /// ```
220    /// use ironflow_core::provider::PodSettings;
221    ///
222    /// let mut settings = PodSettings::default();
223    /// assert!(settings.is_empty());
224    /// settings.service_account = Some("agent".to_string());
225    /// assert!(!settings.is_empty());
226    /// ```
227    pub fn is_empty(&self) -> bool {
228        self.secret_env.is_empty()
229            && self.service_account.is_none()
230            && self.read_only_volumes.is_empty()
231            && self.managed_settings.is_none()
232    }
233}
234
235/// Add `entry` to `list`, replacing in place an entry with the same name.
236pub(crate) fn upsert_secret_env(list: &mut Vec<SecretEnvVar>, entry: SecretEnvVar) {
237    match list.iter_mut().find(|e| e.name == entry.name) {
238        Some(existing) => *existing = entry,
239        None => list.push(entry),
240    }
241}
242
243/// 32-bit FNV-1a hash: deterministic across processes and platforms.
244fn fnv1a(data: &[u8]) -> u32 {
245    let mut hash: u32 = 0x811c_9dc5;
246    for byte in data {
247        hash ^= u32::from(*byte);
248        hash = hash.wrapping_mul(0x0100_0193);
249    }
250    hash
251}
252
253fn is_label_char(c: char) -> bool {
254    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')
255}
256
257fn is_valid_label_value(raw: &str) -> bool {
258    !raw.is_empty()
259        && raw.len() <= LABEL_VALUE_MAX
260        && raw.chars().all(is_label_char)
261        && raw.starts_with(|c: char| c.is_ascii_alphanumeric())
262        && raw.ends_with(|c: char| c.is_ascii_alphanumeric())
263}
264
265/// Turn any string into a valid Kubernetes label value.
266///
267/// A value that is already valid is returned unchanged. Otherwise every char
268/// outside `[A-Za-z0-9._-]` becomes `-`, the result is truncated, leading and
269/// trailing non-alphanumerics are trimmed, an empty result becomes `unnamed`,
270/// and `-` plus 8 hex chars of a stable hash of the raw input is appended, so
271/// that `"a b"` and `"a/b"` do not collide. The output is at most 63 bytes and
272/// deterministic.
273///
274/// # Examples
275///
276/// ```
277/// use ironflow_core::provider::sanitize_label_value;
278///
279/// assert_eq!(sanitize_label_value("investigate"), "investigate");
280/// assert!(sanitize_label_value("fix bug/42").starts_with("fix-bug-42-"));
281/// assert_ne!(sanitize_label_value("a b"), sanitize_label_value("a/b"));
282/// ```
283pub fn sanitize_label_value(raw: &str) -> String {
284    if is_valid_label_value(raw) {
285        return raw.to_string();
286    }
287
288    let replaced: String = raw
289        .chars()
290        .map(|c| if is_label_char(c) { c } else { '-' })
291        .collect();
292    // Only ASCII remains, so byte truncation cannot split a char.
293    let truncated = &replaced[..replaced.len().min(LABEL_VALUE_MAX - HASH_SUFFIX_LEN)];
294    let trimmed = truncated.trim_matches(|c: char| !c.is_ascii_alphanumeric());
295    let base = match trimmed {
296        "" => "unnamed",
297        valid => valid,
298    };
299    format!("{base}-{:08x}", fnv1a(raw.as_bytes()))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn k8s_sanitize_label_value_keeps_valid_value() {
308        assert_eq!(sanitize_label_value("investigate"), "investigate");
309        assert_eq!(sanitize_label_value("step-1.a_b"), "step-1.a_b");
310    }
311
312    #[test]
313    fn k8s_sanitize_label_value_replaces_invalid_chars_and_adds_hash() {
314        let value = sanitize_label_value("fix bug/42");
315        assert!(value.starts_with("fix-bug-42-"), "got {value}");
316        assert_eq!(value.len(), "fix-bug-42".len() + HASH_SUFFIX_LEN);
317        let suffix = &value["fix-bug-42-".len()..];
318        assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
319    }
320
321    #[test]
322    fn k8s_sanitize_label_value_distinguishes_similar_inputs() {
323        assert_ne!(sanitize_label_value("a b"), sanitize_label_value("a/b"));
324    }
325
326    #[test]
327    fn k8s_sanitize_label_value_truncates_long_input() {
328        let raw = "x".repeat(200);
329        let value = sanitize_label_value(&raw);
330        assert!(value.len() <= LABEL_VALUE_MAX, "len {}", value.len());
331        assert!(value.ends_with(|c: char| c.is_ascii_alphanumeric()));
332        assert!(value.starts_with(|c: char| c.is_ascii_alphanumeric()));
333    }
334
335    #[test]
336    fn k8s_sanitize_label_value_trims_non_alphanumeric_edges() {
337        let value = sanitize_label_value("--step--");
338        assert!(value.starts_with("step-"), "got {value}");
339    }
340
341    #[test]
342    fn k8s_sanitize_label_value_empty_and_unicode_only() {
343        assert!(sanitize_label_value("").starts_with("unnamed-"));
344        assert!(sanitize_label_value("日本語").starts_with("unnamed-"));
345        assert_ne!(sanitize_label_value(""), sanitize_label_value("日本語"));
346    }
347
348    #[test]
349    fn k8s_sanitize_label_value_is_deterministic() {
350        let raw = "Résumé / step #3";
351        assert_eq!(sanitize_label_value(raw), sanitize_label_value(raw));
352        assert!(is_valid_label_value(&sanitize_label_value(raw)));
353    }
354
355    #[test]
356    fn k8s_pod_settings_is_empty() {
357        assert!(PodSettings::default().is_empty());
358        let with_secret = PodSettings {
359            secret_env: vec![SecretEnvVar::default()],
360            ..PodSettings::default()
361        };
362        assert!(!with_secret.is_empty());
363        let with_preset = PodSettings {
364            managed_settings: Some("locked".to_string()),
365            ..PodSettings::default()
366        };
367        assert!(!with_preset.is_empty());
368        let with_volume = PodSettings {
369            read_only_volumes: vec![ReadOnlyVolume {
370                source: PodVolumeSource::HostPath {
371                    path: "/srv".to_string(),
372                },
373                mount_path: "/data".to_string(),
374                sub_path: None,
375            }],
376            ..PodSettings::default()
377        };
378        assert!(!with_volume.is_empty());
379    }
380
381    #[test]
382    fn k8s_pod_volume_source_serde_is_tagged() {
383        let source = PodVolumeSource::PersistentVolumeClaim {
384            claim_name: "repos".to_string(),
385        };
386        let json = serde_json::to_value(&source).unwrap();
387        assert_eq!(json["kind"], "persistent_volume_claim");
388        assert_eq!(json["claim_name"], "repos");
389        let back: PodVolumeSource = serde_json::from_value(json).unwrap();
390        assert_eq!(back, source);
391    }
392}