1use minijinja::{Environment, Value};
34use serde_json::Value as JsonValue;
35use std::collections::{HashMap, HashSet};
36use std::sync::{Arc, Mutex};
37
38pub trait ClusterReader: Send + Sync {
47 fn lookup_one(
53 &self,
54 api_version: &str,
55 kind: &str,
56 namespace: &str,
57 name: &str,
58 ) -> Option<JsonValue>;
59
60 fn lookup_list(&self, api_version: &str, kind: &str, namespace: &str) -> Vec<JsonValue>;
64}
65
66#[derive(Clone, Debug, PartialEq, Eq, Hash)]
68struct LookupKey {
69 api_version: String,
70 kind: String,
71 namespace: String,
72 name: String,
74}
75
76#[derive(Clone)]
86pub struct LookupState {
87 reader: Arc<dyn ClusterReader>,
88 cache: Arc<Mutex<HashMap<LookupKey, JsonValue>>>,
89 warnings: Arc<Mutex<Vec<String>>>,
90 warned_keys: Arc<Mutex<HashSet<(String, String)>>>,
92}
93
94impl LookupState {
95 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 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 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 if let Some(cached) = self.cache.lock().unwrap().get(&key) {
141 return Value::from_serialize(cached);
142 }
143
144 let result: JsonValue = if name.is_empty() {
146 let items = self.reader.lookup_list(api_version, kind, namespace);
147 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 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 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 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 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"); state.do_lookup("v1", "Secret", "default", "real"); 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 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}