sherpack_kube/
annotations.rs

1//! Annotation parsing with Helm compatibility
2//!
3//! Sherpack supports both `sherpack.io/*` and `helm.sh/*` annotations
4//! to facilitate migration from Helm charts.
5
6use std::collections::BTreeMap;
7use std::time::Duration;
8
9/// Sherpack-native annotations
10pub mod sherpack {
11    /// Hook phase annotation
12    pub const HOOK: &str = "sherpack.io/hook";
13    /// Hook weight for ordering
14    pub const HOOK_WEIGHT: &str = "sherpack.io/hook-weight";
15    /// Hook timeout
16    pub const HOOK_TIMEOUT: &str = "sherpack.io/hook-timeout";
17    /// Hook delete policy
18    pub const HOOK_DELETE_POLICY: &str = "sherpack.io/hook-delete-policy";
19    /// Hook failure policy
20    pub const HOOK_FAILURE_POLICY: &str = "sherpack.io/hook-failure-policy";
21    /// Hook retry count
22    pub const HOOK_RETRIES: &str = "sherpack.io/hook-retries";
23    /// Sync wave for ordering resources
24    pub const SYNC_WAVE: &str = "sherpack.io/sync-wave";
25    /// Wait for another resource before applying
26    pub const WAIT_FOR: &str = "sherpack.io/wait-for";
27    /// Custom health check configuration
28    pub const HEALTH_CHECK: &str = "sherpack.io/health-check";
29    /// Skip waiting for this resource
30    pub const SKIP_WAIT: &str = "sherpack.io/skip-wait";
31}
32
33/// Helm-compatible annotations (for migration)
34pub mod helm {
35    /// Hook phase annotation
36    pub const HOOK: &str = "helm.sh/hook";
37    /// Hook weight for ordering
38    pub const HOOK_WEIGHT: &str = "helm.sh/hook-weight";
39    /// Hook delete policy
40    pub const HOOK_DELETE_POLICY: &str = "helm.sh/hook-delete-policy";
41    /// Resource policy (keep on uninstall)
42    pub const RESOURCE_POLICY: &str = "helm.sh/resource-policy";
43}
44
45/// Get annotation value, preferring Sherpack over Helm
46pub fn get_annotation<'a>(
47    annotations: &'a BTreeMap<String, String>,
48    sherpack_key: &str,
49    helm_key: &str,
50) -> Option<&'a str> {
51    annotations
52        .get(sherpack_key)
53        .or_else(|| annotations.get(helm_key))
54        .map(|s| s.as_str())
55}
56
57/// Get annotation with only Sherpack key
58pub fn get_sherpack_annotation<'a>(
59    annotations: &'a BTreeMap<String, String>,
60    key: &str,
61) -> Option<&'a str> {
62    annotations.get(key).map(|s| s.as_str())
63}
64
65/// Parse hook phases from annotation value
66pub fn parse_hook_phases(value: &str) -> Vec<String> {
67    value
68        .split(',')
69        .map(|s| s.trim().to_string())
70        .filter(|s| !s.is_empty())
71        .collect()
72}
73
74/// Parse hook weight (default: 0)
75pub fn parse_hook_weight(annotations: &BTreeMap<String, String>) -> i32 {
76    get_annotation(annotations, sherpack::HOOK_WEIGHT, helm::HOOK_WEIGHT)
77        .and_then(|s| s.parse().ok())
78        .unwrap_or(0)
79}
80
81/// Parse sync wave (default: 0)
82pub fn parse_sync_wave(annotations: &BTreeMap<String, String>) -> i32 {
83    get_sherpack_annotation(annotations, sherpack::SYNC_WAVE)
84        .and_then(|s| s.parse().ok())
85        .unwrap_or(0)
86}
87
88/// Parse wait-for dependencies
89/// Format: "kind/name" or "kind/name,kind/name"
90pub fn parse_wait_for(annotations: &BTreeMap<String, String>) -> Vec<ResourceRef> {
91    get_sherpack_annotation(annotations, sherpack::WAIT_FOR)
92        .map(|s| {
93            s.split(',')
94                .filter_map(|dep| {
95                    let dep = dep.trim();
96                    let parts: Vec<&str> = dep.split('/').collect();
97                    if parts.len() == 2 {
98                        Some(ResourceRef {
99                            kind: parts[0].to_string(),
100                            name: parts[1].to_string(),
101                        })
102                    } else {
103                        None
104                    }
105                })
106                .collect()
107        })
108        .unwrap_or_default()
109}
110
111/// Parse timeout duration from string (e.g., "5m", "300s", "1h")
112pub fn parse_duration(value: &str) -> Option<Duration> {
113    let value = value.trim();
114    if value.is_empty() {
115        return None;
116    }
117
118    let (num_str, unit) = if let Some(stripped) = value.strip_suffix("ms") {
119        (stripped, "ms")
120    } else if let Some(stripped) = value.strip_suffix('s') {
121        (stripped, "s")
122    } else if let Some(stripped) = value.strip_suffix('m') {
123        (stripped, "m")
124    } else if let Some(stripped) = value.strip_suffix('h') {
125        (stripped, "h")
126    } else {
127        // Assume seconds if no unit
128        (value, "s")
129    };
130
131    let num: u64 = num_str.parse().ok()?;
132
133    Some(match unit {
134        "ms" => Duration::from_millis(num),
135        "s" => Duration::from_secs(num),
136        "m" => Duration::from_secs(num * 60),
137        "h" => Duration::from_secs(num * 3600),
138        _ => return None,
139    })
140}
141
142/// Parse hook timeout (default: 5 minutes)
143pub fn parse_hook_timeout(annotations: &BTreeMap<String, String>) -> Duration {
144    get_sherpack_annotation(annotations, sherpack::HOOK_TIMEOUT)
145        .and_then(parse_duration)
146        .unwrap_or(Duration::from_secs(300))
147}
148
149/// Parse hook delete policy
150pub fn parse_delete_policy(annotations: &BTreeMap<String, String>) -> DeletePolicy {
151    let value = get_annotation(
152        annotations,
153        sherpack::HOOK_DELETE_POLICY,
154        helm::HOOK_DELETE_POLICY,
155    );
156
157    match value {
158        Some(s) => {
159            let policies: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
160
161            if policies.contains(&"before-hook-creation") {
162                DeletePolicy::BeforeHookCreation
163            } else if policies.contains(&"hook-succeeded") && policies.contains(&"hook-failed") {
164                DeletePolicy::Always
165            } else if policies.contains(&"hook-succeeded") {
166                DeletePolicy::OnSuccess
167            } else if policies.contains(&"hook-failed") {
168                DeletePolicy::OnFailure
169            } else {
170                DeletePolicy::BeforeHookCreation // Default
171            }
172        }
173        None => DeletePolicy::BeforeHookCreation,
174    }
175}
176
177/// Parse failure policy
178pub fn parse_failure_policy(annotations: &BTreeMap<String, String>) -> FailurePolicy {
179    get_sherpack_annotation(annotations, sherpack::HOOK_FAILURE_POLICY)
180        .map(|s| match s.to_lowercase().as_str() {
181            "continue" => FailurePolicy::Continue,
182            "rollback" => FailurePolicy::Rollback,
183            "fail" | "abort" => FailurePolicy::Fail,
184            s if s.starts_with("retry") => {
185                // Parse "retry(3)" or "retry:3"
186                let count = s
187                    .trim_start_matches("retry")
188                    .trim_start_matches('(')
189                    .trim_start_matches(':')
190                    .trim_end_matches(')')
191                    .parse()
192                    .unwrap_or(3);
193                FailurePolicy::Retry(count)
194            }
195            _ => FailurePolicy::Fail,
196        })
197        .unwrap_or(FailurePolicy::Fail)
198}
199
200/// Check if resource should skip wait
201pub fn should_skip_wait(annotations: &BTreeMap<String, String>) -> bool {
202    get_sherpack_annotation(annotations, sherpack::SKIP_WAIT)
203        .map(|s| s.to_lowercase() == "true" || s == "1")
204        .unwrap_or(false)
205}
206
207/// Reference to a Kubernetes resource
208#[derive(Debug, Clone, PartialEq, Eq, Hash)]
209pub struct ResourceRef {
210    pub kind: String,
211    pub name: String,
212}
213
214impl ResourceRef {
215    pub fn new(kind: impl Into<String>, name: impl Into<String>) -> Self {
216        Self {
217            kind: kind.into(),
218            name: name.into(),
219        }
220    }
221}
222
223impl std::fmt::Display for ResourceRef {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        write!(f, "{}/{}", self.kind, self.name)
226    }
227}
228
229/// Hook delete policy
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
231pub enum DeletePolicy {
232    /// Delete before creating a new hook (default)
233    #[default]
234    BeforeHookCreation,
235    /// Delete after successful completion
236    OnSuccess,
237    /// Delete after failure
238    OnFailure,
239    /// Always delete (success or failure)
240    Always,
241    /// Never delete
242    Never,
243}
244
245/// Hook failure policy
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub enum FailurePolicy {
248    /// Fail the entire operation (default)
249    #[default]
250    Fail,
251    /// Log and continue
252    Continue,
253    /// Trigger rollback
254    Rollback,
255    /// Retry N times
256    Retry(u32),
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    fn make_annotations(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
264        pairs
265            .iter()
266            .map(|(k, v)| (k.to_string(), v.to_string()))
267            .collect()
268    }
269
270    #[test]
271    fn test_get_annotation_prefers_sherpack() {
272        let annotations = make_annotations(&[
273            ("sherpack.io/hook", "pre-install"),
274            ("helm.sh/hook", "post-install"),
275        ]);
276
277        let result = get_annotation(&annotations, sherpack::HOOK, helm::HOOK);
278        assert_eq!(result, Some("pre-install"));
279    }
280
281    #[test]
282    fn test_get_annotation_falls_back_to_helm() {
283        let annotations = make_annotations(&[("helm.sh/hook", "post-install")]);
284
285        let result = get_annotation(&annotations, sherpack::HOOK, helm::HOOK);
286        assert_eq!(result, Some("post-install"));
287    }
288
289    #[test]
290    fn test_parse_hook_phases() {
291        assert_eq!(
292            parse_hook_phases("pre-install,post-upgrade"),
293            vec!["pre-install", "post-upgrade"]
294        );
295        assert_eq!(parse_hook_phases("pre-install"), vec!["pre-install"]);
296        assert_eq!(
297            parse_hook_phases(" pre-install , post-install "),
298            vec!["pre-install", "post-install"]
299        );
300    }
301
302    #[test]
303    fn test_parse_sync_wave() {
304        let annotations = make_annotations(&[("sherpack.io/sync-wave", "2")]);
305        assert_eq!(parse_sync_wave(&annotations), 2);
306
307        let annotations = make_annotations(&[("sherpack.io/sync-wave", "-1")]);
308        assert_eq!(parse_sync_wave(&annotations), -1);
309
310        let empty: BTreeMap<String, String> = BTreeMap::new();
311        assert_eq!(parse_sync_wave(&empty), 0);
312    }
313
314    #[test]
315    fn test_parse_wait_for() {
316        let annotations = make_annotations(&[("sherpack.io/wait-for", "Deployment/postgres")]);
317        let deps = parse_wait_for(&annotations);
318        assert_eq!(deps.len(), 1);
319        assert_eq!(deps[0].kind, "Deployment");
320        assert_eq!(deps[0].name, "postgres");
321
322        let annotations =
323            make_annotations(&[("sherpack.io/wait-for", "Deployment/db, Service/cache")]);
324        let deps = parse_wait_for(&annotations);
325        assert_eq!(deps.len(), 2);
326    }
327
328    #[test]
329    fn test_parse_duration() {
330        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
331        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
332        assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
333        assert_eq!(parse_duration("100ms"), Some(Duration::from_millis(100)));
334        assert_eq!(parse_duration("60"), Some(Duration::from_secs(60)));
335        assert_eq!(parse_duration(""), None);
336    }
337
338    #[test]
339    fn test_parse_delete_policy() {
340        let annotations = make_annotations(&[("helm.sh/hook-delete-policy", "hook-succeeded")]);
341        assert_eq!(parse_delete_policy(&annotations), DeletePolicy::OnSuccess);
342
343        let annotations =
344            make_annotations(&[("helm.sh/hook-delete-policy", "hook-succeeded,hook-failed")]);
345        assert_eq!(parse_delete_policy(&annotations), DeletePolicy::Always);
346
347        let annotations =
348            make_annotations(&[("helm.sh/hook-delete-policy", "before-hook-creation")]);
349        assert_eq!(
350            parse_delete_policy(&annotations),
351            DeletePolicy::BeforeHookCreation
352        );
353    }
354
355    #[test]
356    fn test_parse_failure_policy() {
357        let annotations = make_annotations(&[("sherpack.io/hook-failure-policy", "continue")]);
358        assert_eq!(parse_failure_policy(&annotations), FailurePolicy::Continue);
359
360        let annotations = make_annotations(&[("sherpack.io/hook-failure-policy", "retry(5)")]);
361        assert_eq!(parse_failure_policy(&annotations), FailurePolicy::Retry(5));
362
363        let annotations = make_annotations(&[("sherpack.io/hook-failure-policy", "retry:3")]);
364        assert_eq!(parse_failure_policy(&annotations), FailurePolicy::Retry(3));
365    }
366}