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}