1pub use orchestrator_config::resource_store::*;
2
3use crate::crd::projection::CrdProjectable;
4use std::collections::HashMap;
5
6pub trait ResourceStoreExt {
10 fn project_map<T: CrdProjectable>(&self) -> HashMap<String, T>;
12 fn project_singleton<T: CrdProjectable>(&self) -> Option<T>;
14 fn project_singleton_for_project<T: CrdProjectable>(&self, project: &str) -> Option<T>;
16}
17
18impl ResourceStoreExt for ResourceStore {
19 fn project_map<T: CrdProjectable>(&self) -> HashMap<String, T> {
20 let kind = T::crd_kind();
21 let mut result = HashMap::new();
22 for cr in self.list_by_kind(kind) {
23 if let Ok(typed) = T::from_cr_spec(&cr.spec) {
24 result.insert(cr.metadata.name.clone(), typed);
25 }
26 }
27 result
28 }
29
30 fn project_singleton<T: CrdProjectable>(&self) -> Option<T> {
31 let kind = T::crd_kind();
32 let items = self.list_by_kind(kind);
33 items
34 .into_iter()
35 .next()
36 .and_then(|cr| T::from_cr_spec(&cr.spec).ok())
37 }
38
39 fn project_singleton_for_project<T: CrdProjectable>(&self, project: &str) -> Option<T> {
40 let kind = T::crd_kind();
41 let items = self.list_by_kind_for_project(kind, project);
42 items
43 .into_iter()
44 .next()
45 .and_then(|cr| T::from_cr_spec(&cr.spec).ok())
46 }
47}
48
49#[cfg(test)]
50mod tests {
51 use super::*;
52 use crate::cli_types::ResourceMetadata;
53 use crate::config::{AgentConfig, StepTemplateConfig};
54 use crate::crd::projection::CrdProjectable;
55 use crate::crd::types::CustomResource;
56
57 fn make_cr(kind: &str, name: &str, spec: serde_json::Value) -> CustomResource {
58 CustomResource {
59 kind: kind.to_string(),
60 api_version: "orchestrator.dev/v2".to_string(),
61 metadata: ResourceMetadata {
62 name: name.to_string(),
63 project: None,
64 labels: None,
65 annotations: None,
66 },
67 spec,
68 generation: 1,
69 created_at: "2026-01-01T00:00:00Z".to_string(),
70 updated_at: "2026-01-01T00:00:00Z".to_string(),
71 }
72 }
73
74 #[test]
75 fn put_and_get() {
76 let mut store = ResourceStore::default();
77 let cr = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
78 assert_eq!(store.put(cr.clone()), ApplyResult::Created);
79 assert!(store.get("Foo", "bar").is_some());
80 assert!(store.get("Foo", "missing").is_none());
81 }
82
83 #[test]
84 fn put_unchanged() {
85 let mut store = ResourceStore::default();
86 let cr = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
87 store.put(cr.clone());
88 assert_eq!(store.put(cr), ApplyResult::Unchanged);
89 }
90
91 #[test]
92 fn put_configured() {
93 let mut store = ResourceStore::default();
94 let cr1 = make_cr("Foo", "bar", serde_json::json!({"x": 1}));
95 store.put(cr1);
96 let cr2 = make_cr("Foo", "bar", serde_json::json!({"x": 2}));
97 assert_eq!(store.put(cr2), ApplyResult::Configured);
98 }
99
100 #[test]
101 fn remove_existing() {
102 let mut store = ResourceStore::default();
103 let cr = make_cr("Foo", "bar", serde_json::json!({}));
104 store.put(cr);
105 assert!(store.remove("Foo", "bar").is_some());
106 assert!(store.get("Foo", "bar").is_none());
107 }
108
109 #[test]
110 fn remove_missing() {
111 let mut store = ResourceStore::default();
112 assert!(store.remove("Foo", "bar").is_none());
113 }
114
115 #[test]
116 fn list_by_kind() {
117 let mut store = ResourceStore::default();
118 store.put(make_cr("Foo", "a", serde_json::json!({})));
119 store.put(make_cr("Foo", "b", serde_json::json!({})));
120 store.put(make_cr("Bar", "c", serde_json::json!({})));
121 assert_eq!(store.list_by_kind("Foo").len(), 2);
122 assert_eq!(store.list_by_kind("Bar").len(), 1);
123 assert_eq!(store.list_by_kind("Baz").len(), 0);
124 }
125
126 #[test]
127 fn generation_increments() {
128 let mut store = ResourceStore::default();
129 assert_eq!(store.generation(), 0);
130 store.put(make_cr("Foo", "a", serde_json::json!({})));
131 assert_eq!(store.generation(), 1);
132 store.put(make_cr("Foo", "b", serde_json::json!({})));
133 assert_eq!(store.generation(), 2);
134 store.remove("Foo", "a");
135 assert_eq!(store.generation(), 3);
136 }
137
138 #[test]
139 fn is_empty_and_len() {
140 let mut store = ResourceStore::default();
141 assert!(store.is_empty());
142 assert_eq!(store.len(), 0);
143 store.put(make_cr("Foo", "a", serde_json::json!({})));
144 assert!(!store.is_empty());
145 assert_eq!(store.len(), 1);
146 }
147
148 #[test]
149 fn project_map_for_agents() {
150 let mut store = ResourceStore::default();
151 let agent = AgentConfig {
152 enabled: true,
153 command: "echo {prompt}".to_string(),
154 capabilities: vec!["plan".to_string()],
155 ..Default::default()
156 };
157 let spec_val = agent.to_cr_spec();
158 store.put(make_cr("Agent", "test-agent", spec_val));
159
160 let map: HashMap<String, AgentConfig> = store.project_map();
161 assert_eq!(map.len(), 1);
162 let loaded = map.get("test-agent").expect("should exist");
163 assert_eq!(loaded.command, "echo {prompt}");
164 }
165
166 #[test]
167 fn project_singleton() {
168 let mut store = ResourceStore::default();
169 let tmpl = StepTemplateConfig {
170 prompt: "do qa".to_string(),
171 description: None,
172 };
173 let spec_val = tmpl.to_cr_spec();
174 store.put(make_cr("StepTemplate", "qa", spec_val));
175
176 let loaded: Option<StepTemplateConfig> = store.project_singleton();
177 let loaded = loaded.expect("should project singleton");
178 assert_eq!(loaded.prompt, "do qa");
179 }
180
181 #[test]
182 fn cross_kind_key_isolation() {
183 let mut store = ResourceStore::default();
184 store.put(make_cr("Trigger", "alpha", serde_json::json!({"a": 1})));
187 store.put(make_cr("Project", "alpha", serde_json::json!({"w": 2})));
188 assert_eq!(store.len(), 2);
189 assert_eq!(store.get("Trigger", "alpha").unwrap().spec["a"], 1);
190 assert_eq!(store.get("Project", "alpha").unwrap().spec["w"], 2);
191 store.remove("Trigger", "alpha");
192 assert!(store.get("Trigger", "alpha").is_none());
193 assert!(store.get("Project", "alpha").is_some());
194 }
195
196 #[test]
197 fn list_by_kind_does_not_match_prefix_substring() {
198 let mut store = ResourceStore::default();
199 store.put(make_cr("Foo", "x", serde_json::json!({})));
200 store.put(make_cr("FooBar", "y", serde_json::json!({})));
201 assert_eq!(store.list_by_kind("Foo").len(), 1);
202 assert_eq!(store.list_by_kind("FooBar").len(), 1);
203 }
204
205 #[test]
206 fn generation_does_not_increment_on_failed_remove() {
207 let mut store = ResourceStore::default();
208 store.put(make_cr("X", "a", serde_json::json!({})));
209 let gen_before = store.generation();
210 store.remove("X", "nonexistent");
211 assert_eq!(store.generation(), gen_before);
212 }
213
214 #[test]
215 fn generation_increments_on_unchanged_put() {
216 let mut store = ResourceStore::default();
217 let cr = make_cr("X", "a", serde_json::json!({}));
218 store.put(cr.clone());
219 let gen_after_create = store.generation();
220 store.put(cr);
221 assert_eq!(store.generation(), gen_after_create + 1);
222 }
223
224 #[test]
225 fn get_namespaced_uses_three_segment_key() {
226 let mut store = ResourceStore::default();
227 let cr = CustomResource {
228 kind: "Agent".to_string(),
229 api_version: "orchestrator.dev/v2".to_string(),
230 metadata: ResourceMetadata {
231 name: "my-agent".to_string(),
232 project: Some("proj1".to_string()),
233 labels: None,
234 annotations: None,
235 },
236 spec: serde_json::json!({}),
237 generation: 1,
238 created_at: "t".to_string(),
239 updated_at: "t".to_string(),
240 };
241 store
242 .resources_mut()
243 .insert("Agent/proj1/my-agent".to_string(), cr);
244 assert!(store.get_namespaced("Agent", "proj1", "my-agent").is_some());
245 assert!(store.get_namespaced("Agent", "proj2", "my-agent").is_none());
246 assert!(store.get("Agent", "my-agent").is_none());
247 }
248
249 #[test]
250 fn project_singleton_runtime_policy() {
251 use crate::config::{ResumeConfig, RunnerConfig};
252 use crate::crd::projection::RuntimePolicyProjection;
253
254 let mut store = ResourceStore::default();
255 let rp = RuntimePolicyProjection {
256 runner: RunnerConfig::default(),
257 resume: ResumeConfig { auto: true },
258 observability: crate::config::ObservabilityConfig::default(),
259 };
260 store.put(make_cr("RuntimePolicy", "default", rp.to_cr_spec()));
261 let projected: Option<RuntimePolicyProjection> = store.project_singleton();
262 let p = projected.expect("should project RuntimePolicy singleton");
263 assert!(p.resume.auto);
264 assert_eq!(p.runner.shell, "/bin/bash");
265 }
266
267 #[test]
268 fn project_map_skips_corrupted_specs() {
269 let mut store = ResourceStore::default();
270 let good = AgentConfig {
271 enabled: true,
272 command: "echo ok".to_string(),
273 ..Default::default()
274 };
275 store.put(make_cr("Agent", "good", good.to_cr_spec()));
276 store.put(make_cr(
277 "Agent",
278 "bad",
279 serde_json::json!({"not_command": 42}),
280 ));
281 let map: HashMap<String, AgentConfig> = store.project_map();
282 assert_eq!(map.len(), 1);
283 assert!(map.contains_key("good"));
284 assert!(!map.contains_key("bad"));
285 }
286
287 #[test]
288 fn project_singleton_returns_none_for_empty_store() {
289 let store = ResourceStore::default();
290 let runtime: Option<crate::crd::projection::RuntimePolicyProjection> =
291 store.project_singleton();
292 assert!(runtime.is_none());
293 }
294
295 #[test]
296 fn put_auto_assigns_default_project_for_project_scoped_kinds() {
297 let mut store = ResourceStore::default();
298 let cr = make_cr(
299 "Agent",
300 "my-agent",
301 serde_json::json!({"command": "echo test"}),
302 );
303 assert!(cr.metadata.project.is_none());
304 store.put(cr);
305 assert!(
306 store
307 .get_namespaced("Agent", crate::config::DEFAULT_PROJECT_ID, "my-agent")
308 .is_some()
309 );
310 assert!(store.get("Agent", "my-agent").is_none());
311 }
312
313 #[test]
314 fn put_keeps_system_project_for_cluster_scoped_kinds() {
315 let mut store = ResourceStore::default();
316 let cr = make_cr("Project", "my-project", serde_json::json!({}));
318 store.put(cr);
319 assert!(store.get("Project", "my-project").is_some());
320 }
321
322 #[test]
323 fn put_assigns_default_project_for_runtime_policy() {
324 let mut store = ResourceStore::default();
325 let cr = make_cr("RuntimePolicy", "runtime", serde_json::json!({}));
327 store.put(cr);
328 assert!(
329 store
330 .get_namespaced(
331 "RuntimePolicy",
332 crate::config::DEFAULT_PROJECT_ID,
333 "runtime"
334 )
335 .is_some()
336 );
337 assert!(store.get("RuntimePolicy", "runtime").is_none());
339 }
340
341 #[test]
342 fn put_detects_metadata_change_as_configured() {
343 let mut store = ResourceStore::default();
344 let cr1 = make_cr("Agent", "a", serde_json::json!({"command": "echo x"}));
345 store.put(cr1);
346 let mut cr2 = make_cr("Agent", "a", serde_json::json!({"command": "echo x"}));
347 cr2.metadata.labels = Some([("env".to_string(), "prod".to_string())].into());
348 assert_eq!(store.put(cr2), ApplyResult::Configured);
349 }
350}