Skip to main content

agent_orchestrator/resource/
project.rs

1use 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)]
8/// Builtin manifest adapter for global `Project` resources.
9pub struct ProjectResource {
10    /// Resource metadata from the manifest.
11    pub metadata: ResourceMetadata,
12    /// Manifest spec payload for the project.
13    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        // Project is a global resource, not scoped to a project.
66        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        // Project is a global resource, not scoped to a project.
80        super::delete_from_store(config, "Project", name)
81    }
82}
83
84/// Builds a typed `ProjectResource` from a generic manifest wrapper.
85pub(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}