greentic_deployer/credentials/
validate.rs1use std::path::Path;
15
16use chrono::Utc;
17use greentic_deploy_spec::{
18 CapabilitySlot, Credentials, CredentialsMode, CredentialsValidation,
19 CredentialsValidationResult, EnvId, EnvironmentHostConfig, SchemaVersion, SecretRef,
20};
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::env_packs::{EnvPackRegistry, RegistryError};
25use crate::environment::{EnvironmentStore, LocalFsStore, StoreError};
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct Capability {
34 pub id: String,
35 pub description: String,
36}
37
38impl Capability {
39 pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
40 Self {
41 id: id.into(),
42 description: description.into(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "status", rename_all = "snake_case")]
50pub enum CapabilityStatus {
51 Pass,
53 Fail { reason: String },
55 Skipped { reason: String },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct CapabilityCheck {
64 pub capability: Capability,
65 #[serde(flatten)]
66 pub status: CapabilityStatus,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct RequirementsReport {
73 pub checks: Vec<CapabilityCheck>,
74}
75
76impl RequirementsReport {
77 pub fn new(checks: Vec<CapabilityCheck>) -> Self {
78 Self { checks }
79 }
80
81 pub fn passed(&self) -> bool {
86 self.checks
87 .iter()
88 .all(|c| !matches!(c.status, CapabilityStatus::Fail { .. }))
89 }
90
91 pub fn missing(&self) -> Vec<String> {
95 self.checks
96 .iter()
97 .filter(|c| !matches!(c.status, CapabilityStatus::Pass))
98 .map(|c| c.capability.id.clone())
99 .collect()
100 }
101}
102
103#[derive(Debug)]
111pub struct ValidationContext<'a> {
112 pub env_id: &'a EnvId,
113 pub env_root: &'a Path,
114 pub host_config: &'a EnvironmentHostConfig,
115}
116
117#[derive(Debug, Error)]
118pub enum ValidateError {
119 #[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
120 NoDeployerBound(EnvId),
121 #[error("env `{0}` has no credentials_ref; run `op credentials bootstrap` first or supply one")]
122 NoCredentialsRef(EnvId),
123 #[error(
124 "deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
125 )]
126 HandlerNotRegistered { kind: String },
127 #[error(transparent)]
128 Store(#[from] StoreError),
129 #[error(transparent)]
130 Registry(#[from] RegistryError),
131}
132
133pub fn validate_requirements(
155 store: &LocalFsStore,
156 registry: &EnvPackRegistry,
157 env_id: &EnvId,
158 creds_override: Option<&dyn super::DeployerCredentials>,
159) -> Result<(Credentials, RequirementsReport), ValidateError> {
160 let env = store.load(env_id)?;
161 let deployer = env
162 .pack_for_slot(CapabilitySlot::Deployer)
163 .ok_or_else(|| ValidateError::NoDeployerBound(env_id.clone()))?;
164
165 let handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
168 let creds: &dyn super::DeployerCredentials =
169 match creds_override {
170 Some(c) => c,
171 None => handler.deployer_credentials().ok_or_else(|| {
172 ValidateError::HandlerNotRegistered {
173 kind: deployer.kind.as_str().to_string(),
174 }
175 })?,
176 };
177
178 let creds_ref = if creds.requires_credentials_material() {
183 env.credentials_ref
184 .clone()
185 .ok_or_else(|| ValidateError::NoCredentialsRef(env_id.clone()))?
186 } else {
187 env.credentials_ref.clone().unwrap_or_else(|| {
192 SecretRef::try_new(format!(
193 "secret://{}/local-process/no-material-required",
194 env_id.as_str()
195 ))
196 .expect("sentinel SecretRef is well-formed")
197 })
198 };
199
200 let env_root = store.env_dir(env_id)?;
201 let ctx = ValidationContext {
202 env_id,
203 env_root: &env_root,
204 host_config: &env.host_config,
205 };
206 let report = creds.validate(&ctx);
207
208 let result = if report.passed() {
209 CredentialsValidationResult::Pass
210 } else {
211 CredentialsValidationResult::Fail
212 };
213 let doc = Credentials {
214 schema: SchemaVersion::new(SchemaVersion::CREDENTIALS_V1),
215 env_id: env_id.clone(),
216 deployer_kind: deployer.kind.clone(),
217 mode: CredentialsMode::Requirements,
218 provided_credentials_ref: creds_ref,
219 validation: CredentialsValidation {
220 last_run_at: Utc::now(),
221 result,
222 missing_capabilities: report.missing(),
223 },
224 bootstrap: None,
225 expiry: None,
226 };
227 Ok((doc, report))
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn cap(id: &str) -> Capability {
235 Capability::new(id, format!("description for {id}"))
236 }
237
238 #[test]
239 fn passed_true_when_no_failures() {
240 let report = RequirementsReport::new(vec![
241 CapabilityCheck {
242 capability: cap("a"),
243 status: CapabilityStatus::Pass,
244 },
245 CapabilityCheck {
246 capability: cap("b"),
247 status: CapabilityStatus::Skipped {
248 reason: "no backend".into(),
249 },
250 },
251 ]);
252 assert!(report.passed(), "Skipped does not block overall pass");
253 assert_eq!(report.missing(), vec!["b".to_string()]);
254 }
255
256 #[test]
257 fn passed_false_when_any_fail() {
258 let report = RequirementsReport::new(vec![
259 CapabilityCheck {
260 capability: cap("a"),
261 status: CapabilityStatus::Pass,
262 },
263 CapabilityCheck {
264 capability: cap("b"),
265 status: CapabilityStatus::Fail {
266 reason: "denied".into(),
267 },
268 },
269 ]);
270 assert!(!report.passed());
271 assert_eq!(report.missing(), vec!["b".to_string()]);
272 }
273
274 #[test]
275 fn empty_report_passes() {
276 let report = RequirementsReport::new(vec![]);
277 assert!(report.passed());
278 assert!(report.missing().is_empty());
279 }
280
281 #[derive(Debug)]
284 struct FakeCreds;
285 impl crate::credentials::DeployerCredentials for FakeCreds {
286 fn requires_credentials_material(&self) -> bool {
287 false
288 }
289 fn required_capabilities(&self) -> Vec<Capability> {
290 vec![cap("fake.only")]
291 }
292 fn validate(&self, _ctx: &ValidationContext<'_>) -> RequirementsReport {
293 RequirementsReport::new(vec![CapabilityCheck {
294 capability: cap("fake.only"),
295 status: CapabilityStatus::Pass,
296 }])
297 }
298 fn bootstrap(
299 &self,
300 _input: &crate::credentials::BootstrapInput<'_>,
301 ) -> Result<crate::credentials::BootstrapOutcome, crate::credentials::BootstrapError>
302 {
303 unreachable!("the override test never bootstraps")
304 }
305 }
306
307 #[test]
311 fn validate_requirements_honors_creds_override() {
312 use crate::cli::tests_common::{make_binding, make_env};
313 use crate::environment::EnvironmentStore;
314 use greentic_deploy_spec::CapabilitySlot;
315 use tempfile::tempdir;
316
317 let dir = tempdir().unwrap();
318 let store = LocalFsStore::new(dir.path());
319 let mut env = make_env("local");
320 env.packs.push(make_binding(
321 CapabilitySlot::Deployer,
322 "greentic.deployer.local-process@0.1.0",
325 ));
326 store.save(&env).unwrap();
327
328 let registry = EnvPackRegistry::with_builtins();
329 let fake = FakeCreds;
330 let (_doc, report) = validate_requirements(
331 &store,
332 ®istry,
333 &EnvId::try_from("local").unwrap(),
334 Some(&fake),
335 )
336 .expect("override path validates");
337
338 assert_eq!(report.checks.len(), 1, "override report, not handler's");
339 assert_eq!(report.checks[0].capability.id, "fake.only");
340 }
341}