Skip to main content

orchestrator_config/
resource_store.rs

1//! Unified resource store and apply-result types.
2
3use crate::cli_types::ResourceMetadata;
4use crate::config::DEFAULT_PROJECT_ID;
5use crate::crd_types::CustomResource;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Result of applying a manifest resource into an `OrchestratorConfig`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ApplyResult {
12    /// Resource did not exist and was created.
13    Created,
14    /// Resource existed and its stored representation changed.
15    Configured,
16    /// Resource already matched the requested representation.
17    Unchanged,
18}
19
20/// Project namespace for singleton/cluster-scoped resources (RuntimePolicy, Project, CRDs).
21pub const SYSTEM_PROJECT: &str = "_system";
22
23/// Returns true for resource kinds that must belong to a project (not `_system`).
24pub fn is_project_scoped(kind: &str) -> bool {
25    matches!(
26        kind,
27        "Agent"
28            | "Workflow"
29            | "Workspace"
30            | "StepTemplate"
31            | "ExecutionProfile"
32            | "EnvStore"
33            | "SecretStore"
34            | "RuntimePolicy"
35    )
36}
37
38/// Unified resource store — single source of truth for all resource instances.
39///
40/// All resources use 3-segment keys: `kind/project/name`.
41/// Singleton/cluster-scoped resources use `_system` as their project namespace.
42#[derive(Debug, Clone, Serialize, Deserialize, Default)]
43pub struct ResourceStore {
44    #[serde(default)]
45    resources: HashMap<String, CustomResource>,
46    #[serde(skip)]
47    generation: u64,
48}
49
50impl ResourceStore {
51    fn storage_key(kind: &str, metadata: &ResourceMetadata) -> String {
52        let project = metadata
53            .project
54            .as_deref()
55            .filter(|p| !p.trim().is_empty())
56            .unwrap_or(SYSTEM_PROJECT);
57        format!("{}/{}/{}", kind, project, metadata.name)
58    }
59
60    /// Get a resource by kind and name (delegates to `_system` project).
61    pub fn get(&self, kind: &str, name: &str) -> Option<&CustomResource> {
62        self.get_namespaced(kind, SYSTEM_PROJECT, name)
63    }
64
65    /// Get a mutable reference to a resource by its storage key.
66    pub fn get_mut_by_key(&mut self, key: &str) -> Option<&mut CustomResource> {
67        self.resources.get_mut(key)
68    }
69
70    /// Get a namespaced resource by kind, project, and name.
71    pub fn get_namespaced(&self, kind: &str, project: &str, name: &str) -> Option<&CustomResource> {
72        let key = format!("{}/{}/{}", kind, project, name);
73        self.resources.get(&key)
74    }
75
76    /// List all resources of a given kind.
77    pub fn list_by_kind(&self, kind: &str) -> Vec<&CustomResource> {
78        let prefix = format!("{}/", kind);
79        self.resources
80            .iter()
81            .filter(|(k, _)| k.starts_with(&prefix))
82            .map(|(_, v)| v)
83            .collect()
84    }
85
86    /// List resources of a given kind within a specific project.
87    pub fn list_by_kind_for_project(&self, kind: &str, project: &str) -> Vec<&CustomResource> {
88        let prefix = format!("{}/{}/", kind, project);
89        self.resources
90            .iter()
91            .filter(|(k, _)| k.starts_with(&prefix))
92            .map(|(_, v)| v)
93            .collect()
94    }
95
96    /// Insert or update a resource. Returns the apply result.
97    /// For project-scoped kinds with no project, auto-assigns DEFAULT_PROJECT_ID.
98    pub fn put(&mut self, mut cr: CustomResource) -> ApplyResult {
99        // Auto-assign DEFAULT_PROJECT_ID for project-scoped kinds with no/empty project
100        if is_project_scoped(&cr.kind)
101            && cr
102                .metadata
103                .project
104                .as_deref()
105                .filter(|p| !p.trim().is_empty())
106                .is_none()
107        {
108            cr.metadata.project = Some(DEFAULT_PROJECT_ID.to_string());
109        }
110        let key = Self::storage_key(&cr.kind, &cr.metadata);
111        self.generation += 1;
112
113        match self.resources.get(&key) {
114            None => {
115                self.resources.insert(key, cr);
116                ApplyResult::Created
117            }
118            Some(existing) => {
119                if existing.spec == cr.spec
120                    && existing.api_version == cr.api_version
121                    && existing.metadata == cr.metadata
122                {
123                    ApplyResult::Unchanged
124                } else {
125                    self.resources.insert(key, cr);
126                    ApplyResult::Configured
127                }
128            }
129        }
130    }
131
132    /// Remove a resource by kind and name (delegates to `_system` project).
133    pub fn remove(&mut self, kind: &str, name: &str) -> Option<CustomResource> {
134        self.remove_namespaced(kind, SYSTEM_PROJECT, name)
135    }
136
137    /// Remove a resource by kind and name from any project namespace.
138    /// Scans all entries of the form `kind/*/name`.
139    pub fn remove_first_by_kind_name(&mut self, kind: &str, name: &str) -> Option<CustomResource> {
140        let suffix = format!("/{}", name);
141        let prefix = format!("{}/", kind);
142        let key = self
143            .resources
144            .keys()
145            .find(|k| k.starts_with(&prefix) && k.ends_with(&suffix) && k.matches('/').count() == 2)
146            .cloned();
147        if let Some(key) = key {
148            let removed = self.resources.remove(&key);
149            if removed.is_some() {
150                self.generation += 1;
151            }
152            return removed;
153        }
154        None
155    }
156
157    /// Removes one project-scoped resource by kind, project, and name.
158    pub fn remove_namespaced(
159        &mut self,
160        kind: &str,
161        project: &str,
162        name: &str,
163    ) -> Option<CustomResource> {
164        let key = format!("{}/{}/{}", kind, project, name);
165        let removed = self.resources.remove(&key);
166        if removed.is_some() {
167            self.generation += 1;
168        }
169        removed
170    }
171
172    /// Removes all resources belonging to a project.
173    pub fn remove_all_for_project(&mut self, project: &str) {
174        let pattern = format!("/{}/", project);
175        let before = self.resources.len();
176        self.resources.retain(|key, _| !key.contains(&pattern));
177        if self.resources.len() < before {
178            self.generation += 1;
179        }
180    }
181
182    /// Current generation counter (incremented on each mutation).
183    pub fn generation(&self) -> u64 {
184        self.generation
185    }
186
187    /// Whether the store has no resources.
188    pub fn is_empty(&self) -> bool {
189        self.resources.is_empty()
190    }
191
192    /// Number of resources in the store.
193    pub fn len(&self) -> usize {
194        self.resources.len()
195    }
196
197    /// Access the underlying resource map (for iteration/serialization).
198    pub fn resources(&self) -> &HashMap<String, CustomResource> {
199        &self.resources
200    }
201
202    /// Mutable access to the underlying resource map.
203    pub fn resources_mut(&mut self) -> &mut HashMap<String, CustomResource> {
204        &mut self.resources
205    }
206}