Skip to main content

harmont_cli/orchestrator/
cache.rs

1//! Host-side cache decision.
2//!
3//! Resolves a wire-typed [`CommandStep`] against the local Docker
4//! daemon and returns the wire-typed [`CacheDecision`] consumed by
5//! step-executor plugins (design spec §5.5).
6//!
7//! Cache keys are computed by `harmont.keygen` at plan time and ride
8//! along the JSON in `cache.key`. We turn them into Docker image tags
9//! and consult the local image store.
10
11use anyhow::Result;
12use hm_plugin_protocol::{CacheDecision, CommandStep, SnapshotRef};
13
14use crate::orchestrator::docker_client::DockerClient;
15
16/// `harmont-local/<step_key>:<cache_key_first_16_hex>`. Step key is
17/// sanitised to `[a-zA-Z0-9_-]` (Docker tag rules). Returns `None`
18/// when the step has no cache or a policy of `"none"`.
19///
20/// The cache key is the SHA-256 hex resolved at plan time by
21/// `harmont.keygen`. We truncate to the first 16 hex chars (8 bytes)
22/// for the image tag — collision odds across a developer's local
23/// cache are negligible. The cloud path uses the full key elsewhere;
24/// that divergence is acceptable for local-only tags since they're
25/// never resolved across machines.
26fn cache_image_tag(step: &CommandStep) -> Option<String> {
27    let cache = step.cache.as_ref()?;
28    if cache.policy == "none" {
29        return None;
30    }
31    let key = cache.key.as_deref()?;
32    let safe = sanitize_for_tag(&step.key);
33    let short = &key[..key.len().min(16)];
34    Some(format!("harmont-local/{safe}:{short}"))
35}
36
37fn sanitize_for_tag(s: &str) -> String {
38    s.chars()
39        .map(|c| {
40            if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
41                c
42            } else {
43                '-'
44            }
45        })
46        .collect()
47}
48
49/// Decide cache outcome for a step against the local Docker daemon.
50///
51/// Returns hit (snapshot already present), miss-with-tag (run and commit
52/// afterwards), or miss-no-commit (`cache.policy == "none"` or no cache
53/// key).
54///
55/// # Errors
56/// Returns an error if the Docker daemon `image_exists` call fails.
57pub async fn decide(docker: &DockerClient, step: &CommandStep) -> Result<CacheDecision> {
58    let Some(tag) = cache_image_tag(step) else {
59        return Ok(CacheDecision::MissNoCommit);
60    };
61    if docker.image_exists(&tag).await? {
62        Ok(CacheDecision::Hit {
63            tag: SnapshotRef(tag),
64        })
65    } else {
66        Ok(CacheDecision::MissBuildAs {
67            tag: SnapshotRef(tag),
68        })
69    }
70}
71
72#[cfg(test)]
73#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
74mod tests {
75    use super::*;
76    use hm_plugin_protocol::Cache;
77
78    fn step(cache: Option<Cache>) -> CommandStep {
79        CommandStep {
80            key: "build".into(),
81            label: None,
82            cmd: "true".into(),
83            builds_in: None,
84            image: None,
85            env: None,
86            timeout_seconds: None,
87            cache,
88            runner: None,
89            runner_args: None,
90        }
91    }
92
93    #[test]
94    fn no_cache_yields_none() {
95        assert!(cache_image_tag(&step(None)).is_none());
96    }
97
98    #[test]
99    fn policy_none_yields_none() {
100        let s = step(Some(Cache {
101            policy: "none".into(),
102            key: Some("abcdef".into()),
103        }));
104        assert!(cache_image_tag(&s).is_none());
105    }
106
107    #[test]
108    fn ttl_with_key_yields_tag() {
109        let s = step(Some(Cache {
110            policy: "ttl".into(),
111            key: Some("0123456789abcdefffff".into()),
112        }));
113        let tag = cache_image_tag(&s).unwrap();
114        assert!(tag.starts_with("harmont-local/build:"));
115    }
116}