Skip to main content

harmont_cli/orchestrator/
cache.rs

1//! Host-side cache decision.
2//!
3//! Resolves a wire-typed [`CommandStep`] against Docker image tags
4//! to decide whether to skip execution (cache hit) or run + commit.
5//!
6//! Cache keys are computed by `harmont.keygen` at plan time and ride
7//! along the JSON in `cache.key`.
8
9use hm_plugin_protocol::CommandStep;
10
11use super::docker_client::DockerClient;
12
13fn sanitize_for_tag(s: &str) -> String {
14    s.chars()
15        .map(|c| {
16            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
17                c
18            } else {
19                '-'
20            }
21        })
22        .collect()
23}
24
25/// Derive a deterministic Docker image tag for a cacheable step.
26///
27/// Returns `None` when the step has no cache, a `"none"` policy, or no
28/// cache key.
29#[must_use]
30pub fn stable_cache_tag(step: &CommandStep) -> Option<String> {
31    let cache = step.cache.as_ref()?;
32    if cache.policy == "none" {
33        return None;
34    }
35    let key = cache.key.as_deref()?;
36    let safe = sanitize_for_tag(&step.key);
37    let short = &key[..key.len().min(16)];
38    Some(format!("harmont-cache/{safe}:{short}"))
39}
40
41/// Remove Docker images for `step_key` that don't match `current_tag`.
42pub async fn evict_stale_docker_tags(
43    docker: &DockerClient,
44    step_key: &str,
45    current_tag: Option<&str>,
46) {
47    let safe = sanitize_for_tag(step_key);
48    let reference = format!("harmont-cache/{safe}");
49    let tags = match docker.list_images_by_reference(&reference).await {
50        Ok(t) => t,
51        Err(e) => {
52            tracing::warn!(%e, "failed to list images for stale eviction");
53            return;
54        }
55    };
56    for tag in tags {
57        if Some(tag.as_str()) == current_tag {
58            continue;
59        }
60        if let Err(e) = docker.remove_image(&tag).await {
61            tracing::warn!(image = %tag, %e, "failed to remove stale cached Docker image");
62        }
63    }
64}
65
66#[cfg(test)]
67#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
68mod tests {
69    use super::*;
70    use hm_plugin_protocol::Cache;
71
72    fn step(cache: Option<Cache>) -> CommandStep {
73        CommandStep {
74            key: "build".into(),
75            label: None,
76            cmd: "true".into(),
77            image: None,
78            env: None,
79            timeout_seconds: None,
80            cache,
81            runner: None,
82            runner_args: None,
83        }
84    }
85
86    #[test]
87    fn sanitize_replaces_invalid_chars() {
88        assert_eq!(sanitize_for_tag("my/step.name:v1"), "my-step-name-v1");
89        assert_eq!(sanitize_for_tag("simple"), "simple");
90        assert_eq!(sanitize_for_tag("a_b-c"), "a_b-c");
91    }
92
93    #[test]
94    fn stable_cache_tag_for_cacheable_step() {
95        let s = step(Some(Cache {
96            policy: "ttl".into(),
97            key: Some("0123456789abcdef0000".into()),
98        }));
99        let tag = stable_cache_tag(&s);
100        assert_eq!(
101            tag,
102            Some("harmont-cache/build:0123456789abcdef".to_string())
103        );
104    }
105
106    #[test]
107    fn stable_cache_tag_none_for_uncacheable() {
108        let s = step(None);
109        assert_eq!(stable_cache_tag(&s), None);
110    }
111
112    #[test]
113    fn stable_cache_tag_none_for_policy_none() {
114        let s = step(Some(Cache {
115            policy: "none".into(),
116            key: Some("abc".into()),
117        }));
118        assert_eq!(stable_cache_tag(&s), None);
119    }
120}