agent_orchestrator/resource/
project.rs1use crate::cli_types::{OrchestratorResource, ProjectSpec, ResourceKind, ResourceSpec};
2use crate::config::{OrchestratorConfig, ProjectConfig};
3use anyhow::{Result, anyhow};
4
5use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
6
7#[derive(Debug, Clone)]
8pub struct ProjectResource {
10 pub metadata: ResourceMetadata,
12 pub spec: ProjectSpec,
14}
15
16impl Resource for ProjectResource {
17 fn kind(&self) -> ResourceKind {
18 ResourceKind::Project
19 }
20
21 fn name(&self) -> &str {
22 &self.metadata.name
23 }
24
25 fn validate(&self) -> Result<()> {
26 super::validate_resource_name(self.name())
27 }
28
29 fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
30 use crate::crd::projection::CrdProjectable;
31 let incoming = ProjectConfig {
32 description: self.spec.description.clone(),
33 workspaces: std::collections::HashMap::new(),
34 agents: std::collections::HashMap::new(),
35 workflows: std::collections::HashMap::new(),
36 step_templates: std::collections::HashMap::new(),
37 env_stores: std::collections::HashMap::new(),
38 secret_stores: std::collections::HashMap::new(),
39 execution_profiles: std::collections::HashMap::new(),
40 triggers: std::collections::HashMap::new(),
41 };
42 let spec_value = incoming.to_cr_spec();
43 Ok(super::apply_to_store(
44 config,
45 "Project",
46 self.name(),
47 &self.metadata,
48 spec_value,
49 ))
50 }
51
52 fn to_yaml(&self) -> Result<String> {
53 super::manifest_yaml(
54 ResourceKind::Project,
55 &self.metadata,
56 ResourceSpec::Project(self.spec.clone()),
57 )
58 }
59
60 fn get_from_project(
61 config: &OrchestratorConfig,
62 name: &str,
63 _project_id: Option<&str>,
64 ) -> Option<Self> {
65 config.projects.get(name).map(|project| Self {
67 metadata: super::metadata_with_name(name),
68 spec: ProjectSpec {
69 description: project.description.clone(),
70 },
71 })
72 }
73
74 fn delete_from_project(
75 config: &mut OrchestratorConfig,
76 name: &str,
77 _project_id: Option<&str>,
78 ) -> bool {
79 super::delete_from_store(config, "Project", name)
81 }
82}
83
84pub(super) fn build_project(resource: OrchestratorResource) -> Result<RegisteredResource> {
86 let OrchestratorResource {
87 kind,
88 metadata,
89 spec,
90 ..
91 } = resource;
92 if kind != ResourceKind::Project {
93 return Err(anyhow!("resource kind/spec mismatch for Project"));
94 }
95 match spec {
96 ResourceSpec::Project(spec) => Ok(RegisteredResource::Project(ProjectResource {
97 metadata,
98 spec,
99 })),
100 _ => Err(anyhow!("resource kind/spec mismatch for Project")),
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use crate::cli_types::{ResourceMetadata, ResourceSpec};
108 use crate::resource::{API_VERSION, dispatch_resource};
109
110 use super::super::test_fixtures::{make_config, project_manifest};
111
112 #[test]
113 fn project_resource_dispatch_and_kind() {
114 let resource = dispatch_resource(project_manifest("my-proj", "A test project"))
115 .expect("dispatch should succeed");
116 assert_eq!(resource.kind(), ResourceKind::Project);
117 assert_eq!(resource.name(), "my-proj");
118 }
119
120 #[test]
121 fn project_resource_validate_accepts_valid() {
122 let resource = dispatch_resource(project_manifest("valid-proj", "desc"))
123 .expect("dispatch should succeed");
124 assert!(resource.validate().is_ok());
125 }
126
127 #[test]
128 fn project_resource_validate_rejects_empty_name() {
129 let resource =
130 dispatch_resource(project_manifest("", "desc")).expect("dispatch should succeed");
131 assert!(resource.validate().is_err());
132 }
133
134 #[test]
135 fn project_resource_apply_created_then_unchanged() {
136 let mut config = make_config();
137 let resource =
138 dispatch_resource(project_manifest("proj-a", "desc")).expect("dispatch should succeed");
139 assert_eq!(
140 resource.apply(&mut config).expect("apply"),
141 ApplyResult::Created
142 );
143 assert_eq!(
144 resource.apply(&mut config).expect("apply"),
145 ApplyResult::Unchanged
146 );
147 }
148
149 #[test]
150 fn project_resource_apply_configured_on_change() {
151 let mut config = make_config();
152 let r1 =
153 dispatch_resource(project_manifest("proj-b", "v1")).expect("dispatch should succeed");
154 assert_eq!(r1.apply(&mut config).expect("apply"), ApplyResult::Created);
155
156 let r2 =
157 dispatch_resource(project_manifest("proj-b", "v2")).expect("dispatch should succeed");
158 assert_eq!(
159 r2.apply(&mut config).expect("apply"),
160 ApplyResult::Configured
161 );
162 }
163
164 #[test]
165 fn project_resource_get_from_and_delete_from() {
166 let mut config = make_config();
167 let resource = dispatch_resource(project_manifest("proj-del", "desc"))
168 .expect("dispatch should succeed");
169 resource.apply(&mut config).expect("apply");
170
171 let loaded = ProjectResource::get_from(&config, "proj-del");
172 let loaded = loaded.expect("project resource should load after apply");
173 assert_eq!(loaded.spec.description, Some("desc".to_string()));
174
175 assert!(ProjectResource::delete_from(&mut config, "proj-del"));
176 assert!(ProjectResource::get_from(&config, "proj-del").is_none());
177 }
178
179 #[test]
180 fn project_resource_delete_returns_false_when_missing() {
181 let mut config = make_config();
182 assert!(!ProjectResource::delete_from(&mut config, "nonexistent"));
183 }
184
185 #[test]
186 fn project_resource_to_yaml() {
187 let resource = dispatch_resource(project_manifest("yaml-proj", "desc"))
188 .expect("dispatch should succeed");
189 let yaml = resource.to_yaml().expect("should serialize");
190 assert!(yaml.contains("kind: Project"));
191 assert!(yaml.contains("yaml-proj"));
192 }
193
194 #[test]
195 fn build_project_rejects_wrong_kind() {
196 let resource = OrchestratorResource {
197 api_version: API_VERSION.to_string(),
198 kind: ResourceKind::Project,
199 metadata: ResourceMetadata {
200 name: "bad".to_string(),
201 project: None,
202 labels: None,
203 annotations: None,
204 },
205 spec: ResourceSpec::RuntimePolicy(crate::cli_types::RuntimePolicySpec {
206 runner: crate::cli_types::RunnerSpec {
207 shell: "/bin/bash".to_string(),
208 shell_arg: "-lc".to_string(),
209 policy: "allowlist".to_string(),
210 executor: "shell".to_string(),
211 allowed_shells: vec![],
212 allowed_shell_args: vec![],
213 env_allowlist: vec![],
214 redaction_patterns: vec![],
215 },
216 resume: crate::cli_types::ResumeSpec { auto: false },
217 observability: None,
218 }),
219 };
220 let err = dispatch_resource(resource).expect_err("operation should fail");
221 assert!(err.to_string().contains("mismatch"));
222 }
223}