Skip to main content

agent_orchestrator/resource/
secret_store.rs

1use crate::cli_types::{OrchestratorResource, ResourceKind, ResourceSpec, SecretStoreSpec};
2use crate::config::{OrchestratorConfig, SecretStoreConfig};
3use anyhow::{Result, anyhow};
4
5use super::{ApplyResult, RegisteredResource, Resource, ResourceMetadata};
6
7#[derive(Debug, Clone)]
8/// Builtin manifest adapter for sensitive `SecretStore` resources.
9pub struct SecretStoreResource {
10    /// Resource metadata from the manifest.
11    pub metadata: ResourceMetadata,
12    /// Manifest spec payload for the secret store.
13    pub spec: SecretStoreSpec,
14}
15
16impl Resource for SecretStoreResource {
17    fn kind(&self) -> ResourceKind {
18        ResourceKind::SecretStore
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        Ok(())
28    }
29
30    fn apply(&self, config: &mut OrchestratorConfig) -> Result<ApplyResult> {
31        let incoming = SecretStoreConfig {
32            data: self.spec.data.clone(),
33        };
34        let project = config.ensure_project(self.metadata.project.as_deref());
35        Ok(super::helpers::apply_to_map(
36            &mut project.secret_stores,
37            self.name(),
38            incoming,
39        ))
40    }
41
42    fn to_yaml(&self) -> Result<String> {
43        super::manifest_yaml(
44            ResourceKind::SecretStore,
45            &self.metadata,
46            ResourceSpec::SecretStore(self.spec.clone()),
47        )
48    }
49
50    fn get_from_project(
51        config: &OrchestratorConfig,
52        name: &str,
53        project_id: Option<&str>,
54    ) -> Option<Self> {
55        config
56            .project(project_id)?
57            .secret_stores
58            .get(name)
59            .map(|store| Self {
60                metadata: super::metadata_with_name(name),
61                spec: SecretStoreSpec {
62                    data: store.data.clone(),
63                },
64            })
65    }
66
67    fn delete_from_project(
68        config: &mut OrchestratorConfig,
69        name: &str,
70        project_id: Option<&str>,
71    ) -> bool {
72        config
73            .project_mut(project_id)
74            .map(|project| project.secret_stores.remove(name).is_some())
75            .unwrap_or(false)
76    }
77}
78
79/// Builds a typed `SecretStoreResource` from a generic manifest wrapper.
80pub(super) fn build_secret_store(resource: OrchestratorResource) -> Result<RegisteredResource> {
81    let OrchestratorResource {
82        kind,
83        metadata,
84        spec,
85        ..
86    } = resource;
87    if kind != ResourceKind::SecretStore {
88        return Err(anyhow!("resource kind/spec mismatch for SecretStore"));
89    }
90    match spec {
91        ResourceSpec::SecretStore(spec) => {
92            Ok(RegisteredResource::SecretStore(SecretStoreResource {
93                metadata,
94                spec,
95            }))
96        }
97        _ => Err(anyhow!("resource kind/spec mismatch for SecretStore")),
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::resource::test_fixtures::make_config;
105
106    fn make_secret_store(name: &str) -> SecretStoreResource {
107        SecretStoreResource {
108            metadata: super::super::metadata_with_name(name),
109            spec: SecretStoreSpec {
110                data: [("API_KEY".to_string(), "sk-secret".to_string())].into(),
111            },
112        }
113    }
114
115    #[test]
116    fn secret_store_apply_and_get() {
117        let mut config = make_config();
118        let store = make_secret_store("my-secrets");
119        assert_eq!(
120            store.apply(&mut config).expect("apply"),
121            ApplyResult::Created
122        );
123
124        let loaded = SecretStoreResource::get_from(&config, "my-secrets")
125            .expect("secret store should be present");
126        assert_eq!(loaded.spec.data.get("API_KEY").unwrap(), "sk-secret");
127        assert_eq!(loaded.kind(), ResourceKind::SecretStore);
128
129        // Underlying config should be in secret_stores
130        assert!(
131            config
132                .default_project()
133                .unwrap()
134                .secret_stores
135                .contains_key("my-secrets")
136        );
137    }
138
139    #[test]
140    fn secret_store_apply_unchanged() {
141        let mut config = make_config();
142        let store = make_secret_store("ss-unchanged");
143        assert_eq!(
144            store.apply(&mut config).expect("apply"),
145            ApplyResult::Created
146        );
147        assert_eq!(
148            store.apply(&mut config).expect("apply"),
149            ApplyResult::Unchanged
150        );
151    }
152
153    #[test]
154    fn secret_store_delete() {
155        let mut config = make_config();
156        let store = make_secret_store("ss-del");
157        store.apply(&mut config).expect("apply");
158        assert!(SecretStoreResource::delete_from(&mut config, "ss-del"));
159        assert!(SecretStoreResource::get_from(&config, "ss-del").is_none());
160    }
161
162    #[test]
163    fn secret_store_validate_rejects_empty_name() {
164        let store = make_secret_store("");
165        assert!(store.validate().is_err());
166    }
167
168    #[test]
169    fn secret_store_to_yaml() {
170        let store = make_secret_store("yaml-ss");
171        let yaml = store.to_yaml().expect("should serialize");
172        assert!(yaml.contains("SecretStore"));
173        assert!(yaml.contains("yaml-ss"));
174    }
175
176    #[test]
177    fn secret_store_get_from_returns_none_for_missing() {
178        let config = make_config();
179        assert!(SecretStoreResource::get_from(&config, "no-such").is_none());
180    }
181
182    #[test]
183    fn secret_store_and_env_store_same_name_coexist() {
184        use crate::cli_types::EnvStoreSpec;
185        use crate::resource::env_store::EnvStoreResource;
186
187        let mut config = make_config();
188
189        // Apply EnvStore with name "shared"
190        let env = EnvStoreResource {
191            metadata: super::super::metadata_with_name("shared"),
192            spec: EnvStoreSpec {
193                data: [("ENV_KEY".to_string(), "env_val".to_string())].into(),
194            },
195        };
196        env.apply(&mut config).expect("apply env");
197
198        // Apply SecretStore with same name "shared"
199        let secret = make_secret_store("shared");
200        secret.apply(&mut config).expect("apply secret");
201
202        // Both should coexist
203        assert!(EnvStoreResource::get_from(&config, "shared").is_some());
204        assert!(SecretStoreResource::get_from(&config, "shared").is_some());
205    }
206}