use minijinja::{Environment, Value};
use serde_json::Value as JsonValue;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
pub trait ClusterReader: Send + Sync {
fn lookup_one(
&self,
api_version: &str,
kind: &str,
namespace: &str,
name: &str,
) -> Option<JsonValue>;
fn lookup_list(&self, api_version: &str, kind: &str, namespace: &str) -> Vec<JsonValue>;
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct LookupKey {
api_version: String,
kind: String,
namespace: String,
name: String,
}
#[derive(Clone)]
pub struct LookupState {
reader: Arc<dyn ClusterReader>,
cache: Arc<Mutex<HashMap<LookupKey, JsonValue>>>,
warnings: Arc<Mutex<Vec<String>>>,
warned_keys: Arc<Mutex<HashSet<(String, String)>>>,
}
impl LookupState {
pub fn new(reader: Arc<dyn ClusterReader>) -> Self {
Self {
reader,
cache: Arc::new(Mutex::new(HashMap::new())),
warnings: Arc::new(Mutex::new(Vec::new())),
warned_keys: Arc::new(Mutex::new(HashSet::new())),
}
}
pub fn register(&self, env: &mut Environment<'static>) {
let state = self.clone();
env.add_function(
"lookup",
move |api_version: String,
kind: String,
namespace: String,
name: String|
-> Result<Value, minijinja::Error> {
Ok(state.do_lookup(&api_version, &kind, &namespace, &name))
},
);
}
pub fn take_warnings(&self) -> Vec<String> {
let mut w = self.warnings.lock().unwrap();
std::mem::take(&mut *w)
}
fn do_lookup(&self, api_version: &str, kind: &str, namespace: &str, name: &str) -> Value {
let key = LookupKey {
api_version: api_version.to_string(),
kind: kind.to_string(),
namespace: namespace.to_string(),
name: name.to_string(),
};
if let Some(cached) = self.cache.lock().unwrap().get(&key) {
return Value::from_serialize(cached);
}
let result: JsonValue = if name.is_empty() {
let items = self.reader.lookup_list(api_version, kind, namespace);
JsonValue::Object(
serde_json::Map::from_iter([("items".to_string(), JsonValue::Array(items))])
.into_iter()
.collect(),
)
} else {
self.reader
.lookup_one(api_version, kind, namespace, name)
.unwrap_or_else(|| JsonValue::Object(serde_json::Map::new()))
};
if !is_empty_lookup_result(&result, name.is_empty())
&& self
.warned_keys
.lock()
.unwrap()
.insert((kind.to_string(), name.to_string()))
{
self.warnings.lock().unwrap().push(format!(
"lookup() returned cluster state for {}/{}{} — render is non-deterministic",
kind,
if namespace.is_empty() {
"<all-ns>"
} else {
namespace
},
if name.is_empty() {
String::new()
} else {
format!("/{}", name)
}
));
}
self.cache.lock().unwrap().insert(key, result.clone());
Value::from_serialize(result)
}
}
fn is_empty_lookup_result(v: &JsonValue, list_mode: bool) -> bool {
match v {
JsonValue::Object(m) if list_mode => m
.get("items")
.and_then(|i| i.as_array())
.is_none_or(|a| a.is_empty()),
JsonValue::Object(m) => m.is_empty(),
_ => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockReader {
objects: HashMap<(String, String, String, String), JsonValue>,
call_count: Arc<Mutex<usize>>,
}
impl MockReader {
fn new() -> Self {
Self {
objects: HashMap::new(),
call_count: Arc::new(Mutex::new(0)),
}
}
fn with(mut self, av: &str, kind: &str, ns: &str, name: &str, val: JsonValue) -> Self {
self.objects
.insert((av.into(), kind.into(), ns.into(), name.into()), val);
self
}
}
impl ClusterReader for MockReader {
fn lookup_one(&self, av: &str, k: &str, ns: &str, n: &str) -> Option<JsonValue> {
*self.call_count.lock().unwrap() += 1;
self.objects
.get(&(av.into(), k.into(), ns.into(), n.into()))
.cloned()
}
fn lookup_list(&self, _av: &str, _k: &str, _ns: &str) -> Vec<JsonValue> {
*self.call_count.lock().unwrap() += 1;
Vec::new()
}
}
#[test]
fn test_lookup_returns_empty_when_not_found() {
let reader = Arc::new(MockReader::new());
let state = LookupState::new(reader);
let v = state.do_lookup("v1", "Secret", "default", "missing");
assert_eq!(v.len().unwrap_or(0), 0);
}
#[test]
fn test_lookup_returns_existing_resource() {
let reader = Arc::new(MockReader::new().with(
"v1",
"Secret",
"default",
"tls-cert",
serde_json::json!({"data": {"tls.crt": "abc"}}),
));
let state = LookupState::new(reader);
let v = state.do_lookup("v1", "Secret", "default", "tls-cert");
let data = v.get_attr("data").unwrap();
let crt = data.get_attr("tls.crt").unwrap();
assert_eq!(crt.to_string(), "abc");
}
#[test]
fn test_cache_dedups_repeated_calls() {
let reader = MockReader::new().with(
"v1",
"Secret",
"default",
"x",
serde_json::json!({"data": {}}),
);
let counter = reader.call_count.clone();
let state = LookupState::new(Arc::new(reader));
for _ in 0..5 {
let _ = state.do_lookup("v1", "Secret", "default", "x");
}
assert_eq!(*counter.lock().unwrap(), 1, "should only hit reader once");
}
#[test]
fn test_cache_distinguishes_keys() {
let reader = MockReader::new()
.with("v1", "Secret", "default", "a", serde_json::json!({}))
.with("v1", "Secret", "default", "b", serde_json::json!({}));
let counter = reader.call_count.clone();
let state = LookupState::new(Arc::new(reader));
state.do_lookup("v1", "Secret", "default", "a");
state.do_lookup("v1", "Secret", "default", "b");
state.do_lookup("v1", "Secret", "default", "a");
assert_eq!(*counter.lock().unwrap(), 2);
}
#[test]
fn test_warning_emitted_only_for_nonempty_results() {
let reader = Arc::new(MockReader::new().with(
"v1",
"Secret",
"default",
"real",
serde_json::json!({"data": {"x": "y"}}),
));
let state = LookupState::new(reader);
state.do_lookup("v1", "Secret", "default", "missing"); state.do_lookup("v1", "Secret", "default", "real");
let w = state.take_warnings();
assert_eq!(w.len(), 1);
assert!(w[0].contains("Secret"));
assert!(w[0].contains("real"));
}
#[test]
fn test_warning_deduped_by_kind_and_name() {
let reader = Arc::new(MockReader::new().with(
"v1",
"Secret",
"default",
"real",
serde_json::json!({"data": {"x": "y"}}),
));
let state = LookupState::new(reader);
for _ in 0..10 {
state.do_lookup("v1", "Secret", "default", "real");
}
assert_eq!(state.take_warnings().len(), 1);
}
#[test]
fn test_take_warnings_clears() {
let reader = Arc::new(MockReader::new().with(
"v1",
"ConfigMap",
"default",
"x",
serde_json::json!({"data": {"k": "v"}}),
));
let state = LookupState::new(reader);
state.do_lookup("v1", "ConfigMap", "default", "x");
assert_eq!(state.take_warnings().len(), 1);
assert_eq!(state.take_warnings().len(), 0);
}
#[test]
fn test_list_mode_returns_items_wrapper() {
let reader = Arc::new(MockReader::new());
let state = LookupState::new(reader);
let v = state.do_lookup("v1", "Secret", "default", "");
let items = v.get_attr("items").expect("list mode returns {items: []}");
assert!(items.try_iter().is_ok());
}
}