Skip to main content

sherpack_engine/
cluster_reader.rs

1//! Cluster reader trait for the `lookup()` template function
2//!
3//! This module decouples the engine from a specific Kubernetes client.
4//! `sherpack-engine` defines the trait; concrete implementations live in
5//! crates that have a cluster client (e.g. `sherpack-kube::lookup`).
6//!
7//! # Helm-compatible semantics
8//!
9//! `lookup` is *non-fatal*: errors (404, 403, network, unknown kind) all
10//! resolve to an empty result rather than failing the render. Errors are
11//! surfaced through `LookupState::take_warnings()` so the caller can log
12//! them after the render.
13//!
14//! # Determinism caveat
15//!
16//! Templates that use `lookup` are non-deterministic by construction:
17//! the same Pack rendered against different clusters produces different
18//! manifests. This is the same trade-off Helm makes; document it loudly.
19//!
20//! # Wiring
21//!
22//! ```ignore
23//! use sherpack_engine::{Engine, cluster_reader::ClusterReader};
24//! use std::sync::Arc;
25//!
26//! let reader: Arc<dyn ClusterReader> = Arc::new(MyReader::new());
27//! let engine = Engine::builder()
28//!     .strict(true)
29//!     .with_cluster_reader(reader)
30//!     .build();
31//! ```
32
33use minijinja::{Environment, Value};
34use serde_json::Value as JsonValue;
35use std::collections::{HashMap, HashSet};
36use std::sync::{Arc, Mutex};
37
38/// Reads existing cluster resources at template-render time.
39///
40/// Implementations MUST be non-fatal: any error (network, RBAC, missing
41/// resource, unknown kind) MUST resolve to `None` / empty list.
42///
43/// Implementations are responsible for any sync/async bridging (e.g.
44/// `tokio::task::block_in_place`) — the trait is intentionally sync to
45/// match MiniJinja's function signature requirements.
46pub trait ClusterReader: Send + Sync {
47    /// Look up a single resource by name. Returns `None` if not found
48    /// or any error occurred.
49    ///
50    /// `namespace == ""` means cluster-scoped or any namespace, depending
51    /// on the kind's scope.
52    fn lookup_one(
53        &self,
54        api_version: &str,
55        kind: &str,
56        namespace: &str,
57        name: &str,
58    ) -> Option<JsonValue>;
59
60    /// List all resources of a kind in a namespace.
61    /// `namespace == ""` lists across all namespaces (or cluster-wide
62    /// for cluster-scoped resources). Returns an empty Vec on error.
63    fn lookup_list(&self, api_version: &str, kind: &str, namespace: &str) -> Vec<JsonValue>;
64}
65
66/// Cache key for `lookup` calls (one per (apiVersion, kind, namespace, name))
67#[derive(Clone, Debug, PartialEq, Eq, Hash)]
68struct LookupKey {
69    api_version: String,
70    kind: String,
71    namespace: String,
72    /// Empty string means "list" mode
73    name: String,
74}
75
76/// Per-render state for the `lookup()` function.
77///
78/// Holds:
79/// - the cluster reader (Arc-shared, used by the closure registered on the env)
80/// - a per-render cache (so duplicate lookups in the same render hit the cluster once)
81/// - aggregated warnings (deduped by kind+name) for the caller to surface
82///
83/// Cloning is cheap (all internals are `Arc`), and clones share the same
84/// cache + warnings — exactly what we want when the closure captures it.
85#[derive(Clone)]
86pub struct LookupState {
87    reader: Arc<dyn ClusterReader>,
88    cache: Arc<Mutex<HashMap<LookupKey, JsonValue>>>,
89    warnings: Arc<Mutex<Vec<String>>>,
90    /// Deduplication set — we only record one warning per (kind, name) per render
91    warned_keys: Arc<Mutex<HashSet<(String, String)>>>,
92}
93
94impl LookupState {
95    /// Build a new state from a reader.
96    pub fn new(reader: Arc<dyn ClusterReader>) -> Self {
97        Self {
98            reader,
99            cache: Arc::new(Mutex::new(HashMap::new())),
100            warnings: Arc::new(Mutex::new(Vec::new())),
101            warned_keys: Arc::new(Mutex::new(HashSet::new())),
102        }
103    }
104
105    /// Register the `lookup` function on the given environment.
106    ///
107    /// This *replaces* any pre-existing `lookup` registration (the no-op
108    /// stub from `functions::lookup`).
109    pub fn register(&self, env: &mut Environment<'static>) {
110        let state = self.clone();
111        env.add_function(
112            "lookup",
113            move |api_version: String,
114                  kind: String,
115                  namespace: String,
116                  name: String|
117                  -> Result<Value, minijinja::Error> {
118                Ok(state.do_lookup(&api_version, &kind, &namespace, &name))
119            },
120        );
121    }
122
123    /// Take the accumulated warnings, leaving the state empty.
124    /// Caller is responsible for surfacing these (e.g. via tracing or
125    /// the render report).
126    pub fn take_warnings(&self) -> Vec<String> {
127        let mut w = self.warnings.lock().unwrap();
128        std::mem::take(&mut *w)
129    }
130
131    fn do_lookup(&self, api_version: &str, kind: &str, namespace: &str, name: &str) -> Value {
132        let key = LookupKey {
133            api_version: api_version.to_string(),
134            kind: kind.to_string(),
135            namespace: namespace.to_string(),
136            name: name.to_string(),
137        };
138
139        // Cache hit: serve from previous fetch in this render
140        if let Some(cached) = self.cache.lock().unwrap().get(&key) {
141            return Value::from_serialize(cached);
142        }
143
144        // Miss: ask the reader
145        let result: JsonValue = if name.is_empty() {
146            let items = self.reader.lookup_list(api_version, kind, namespace);
147            // Match Helm's list shape: {items: [...]}
148            JsonValue::Object(
149                serde_json::Map::from_iter([("items".to_string(), JsonValue::Array(items))])
150                    .into_iter()
151                    .collect(),
152            )
153        } else {
154            self.reader
155                .lookup_one(api_version, kind, namespace, name)
156                .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()))
157        };
158
159        // Warn once per (kind, name) when a non-empty lookup result is used.
160        // Helps users spot non-deterministic templates without spamming.
161        if !is_empty_lookup_result(&result, name.is_empty())
162            && self
163                .warned_keys
164                .lock()
165                .unwrap()
166                .insert((kind.to_string(), name.to_string()))
167        {
168            self.warnings.lock().unwrap().push(format!(
169                "lookup() returned cluster state for {}/{}{} — render is non-deterministic",
170                kind,
171                if namespace.is_empty() {
172                    "<all-ns>"
173                } else {
174                    namespace
175                },
176                if name.is_empty() {
177                    String::new()
178                } else {
179                    format!("/{}", name)
180                }
181            ));
182        }
183
184        // Cache for the rest of this render
185        self.cache.lock().unwrap().insert(key, result.clone());
186        Value::from_serialize(result)
187    }
188}
189
190fn is_empty_lookup_result(v: &JsonValue, list_mode: bool) -> bool {
191    match v {
192        JsonValue::Object(m) if list_mode => m
193            .get("items")
194            .and_then(|i| i.as_array())
195            .is_none_or(|a| a.is_empty()),
196        JsonValue::Object(m) => m.is_empty(),
197        _ => true,
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    /// Test reader that returns a fixed map of resources.
206    struct MockReader {
207        objects: HashMap<(String, String, String, String), JsonValue>,
208        call_count: Arc<Mutex<usize>>,
209    }
210
211    impl MockReader {
212        fn new() -> Self {
213            Self {
214                objects: HashMap::new(),
215                call_count: Arc::new(Mutex::new(0)),
216            }
217        }
218
219        fn with(mut self, av: &str, kind: &str, ns: &str, name: &str, val: JsonValue) -> Self {
220            self.objects
221                .insert((av.into(), kind.into(), ns.into(), name.into()), val);
222            self
223        }
224    }
225
226    impl ClusterReader for MockReader {
227        fn lookup_one(&self, av: &str, k: &str, ns: &str, n: &str) -> Option<JsonValue> {
228            *self.call_count.lock().unwrap() += 1;
229            self.objects
230                .get(&(av.into(), k.into(), ns.into(), n.into()))
231                .cloned()
232        }
233
234        fn lookup_list(&self, _av: &str, _k: &str, _ns: &str) -> Vec<JsonValue> {
235            *self.call_count.lock().unwrap() += 1;
236            Vec::new()
237        }
238    }
239
240    #[test]
241    fn test_lookup_returns_empty_when_not_found() {
242        let reader = Arc::new(MockReader::new());
243        let state = LookupState::new(reader);
244        let v = state.do_lookup("v1", "Secret", "default", "missing");
245        // Empty result should serialize to {}
246        assert_eq!(v.len().unwrap_or(0), 0);
247    }
248
249    #[test]
250    fn test_lookup_returns_existing_resource() {
251        let reader = Arc::new(MockReader::new().with(
252            "v1",
253            "Secret",
254            "default",
255            "tls-cert",
256            serde_json::json!({"data": {"tls.crt": "abc"}}),
257        ));
258        let state = LookupState::new(reader);
259        let v = state.do_lookup("v1", "Secret", "default", "tls-cert");
260        let data = v.get_attr("data").unwrap();
261        let crt = data.get_attr("tls.crt").unwrap();
262        assert_eq!(crt.to_string(), "abc");
263    }
264
265    #[test]
266    fn test_cache_dedups_repeated_calls() {
267        let reader = MockReader::new().with(
268            "v1",
269            "Secret",
270            "default",
271            "x",
272            serde_json::json!({"data": {}}),
273        );
274        let counter = reader.call_count.clone();
275        let state = LookupState::new(Arc::new(reader));
276
277        for _ in 0..5 {
278            let _ = state.do_lookup("v1", "Secret", "default", "x");
279        }
280
281        assert_eq!(*counter.lock().unwrap(), 1, "should only hit reader once");
282    }
283
284    #[test]
285    fn test_cache_distinguishes_keys() {
286        let reader = MockReader::new()
287            .with("v1", "Secret", "default", "a", serde_json::json!({}))
288            .with("v1", "Secret", "default", "b", serde_json::json!({}));
289        let counter = reader.call_count.clone();
290        let state = LookupState::new(Arc::new(reader));
291
292        state.do_lookup("v1", "Secret", "default", "a");
293        state.do_lookup("v1", "Secret", "default", "b");
294        state.do_lookup("v1", "Secret", "default", "a");
295
296        assert_eq!(*counter.lock().unwrap(), 2);
297    }
298
299    #[test]
300    fn test_warning_emitted_only_for_nonempty_results() {
301        let reader = Arc::new(MockReader::new().with(
302            "v1",
303            "Secret",
304            "default",
305            "real",
306            serde_json::json!({"data": {"x": "y"}}),
307        ));
308        let state = LookupState::new(reader);
309
310        state.do_lookup("v1", "Secret", "default", "missing"); // empty → no warn
311        state.do_lookup("v1", "Secret", "default", "real"); // non-empty → warn
312
313        let w = state.take_warnings();
314        assert_eq!(w.len(), 1);
315        assert!(w[0].contains("Secret"));
316        assert!(w[0].contains("real"));
317    }
318
319    #[test]
320    fn test_warning_deduped_by_kind_and_name() {
321        let reader = Arc::new(MockReader::new().with(
322            "v1",
323            "Secret",
324            "default",
325            "real",
326            serde_json::json!({"data": {"x": "y"}}),
327        ));
328        let state = LookupState::new(reader);
329
330        // 10 calls — only 1 warning
331        for _ in 0..10 {
332            state.do_lookup("v1", "Secret", "default", "real");
333        }
334
335        assert_eq!(state.take_warnings().len(), 1);
336    }
337
338    #[test]
339    fn test_take_warnings_clears() {
340        let reader = Arc::new(MockReader::new().with(
341            "v1",
342            "ConfigMap",
343            "default",
344            "x",
345            serde_json::json!({"data": {"k": "v"}}),
346        ));
347        let state = LookupState::new(reader);
348        state.do_lookup("v1", "ConfigMap", "default", "x");
349
350        assert_eq!(state.take_warnings().len(), 1);
351        assert_eq!(state.take_warnings().len(), 0);
352    }
353
354    #[test]
355    fn test_list_mode_returns_items_wrapper() {
356        let reader = Arc::new(MockReader::new());
357        let state = LookupState::new(reader);
358        let v = state.do_lookup("v1", "Secret", "default", "");
359        let items = v.get_attr("items").expect("list mode returns {items: []}");
360        assert!(items.try_iter().is_ok());
361    }
362}