Skip to main content

greentic_deployer/credentials/
validate.rs

1//! Requirements-flow driver for [`super::DeployerCredentials`].
2//!
3//! `validate_requirements` reads the env, resolves the bound deployer
4//! handler through the registry, runs the handler's probes, and returns
5//! both a [`Credentials`] doc (ready for the caller to persist) and the
6//! [`RequirementsReport`] for the CLI envelope.
7//!
8//! The runner does NOT write the doc — that's the caller's choice (the
9//! `rotate` verb persists; `requirements` returns the report and lets the
10//! caller decide). This keeps the runner pure-ish (one store read, no
11//! store write) and lets tests assert the probe output without observing a
12//! filesystem mutation.
13
14use 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/// A single capability the deployer's credentials must satisfy.
28///
29/// `id` is a stable, machine-readable identifier (e.g.
30/// `local-process.fs.writable`); `description` is the operator-facing
31/// label rendered in the CLI report.
32#[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/// Outcome for one capability probe.
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(tag = "status", rename_all = "snake_case")]
50pub enum CapabilityStatus {
51    /// Probe ran and the credentials satisfy this capability.
52    Pass,
53    /// Probe ran and the credentials do NOT satisfy this capability.
54    Fail { reason: String },
55    /// Probe could not run today (e.g. needs Phase D infrastructure that
56    /// is not wired in Phase A). The capability is reported as missing in
57    /// the persisted [`Credentials`] doc so an operator never sees a
58    /// false-pass for unsupported probes.
59    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/// Result of a full validate run — one entry per capability in the order
70/// the handler declared them.
71#[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    /// True when no capability is in [`CapabilityStatus::Fail`]. `Skipped`
82    /// entries do NOT block the overall pass (they record "we couldn't
83    /// check"), but they do surface in [`missing`](Self::missing) so the
84    /// persisted `Credentials` doc reflects that the check was incomplete.
85    pub fn passed(&self) -> bool {
86        self.checks
87            .iter()
88            .all(|c| !matches!(c.status, CapabilityStatus::Fail { .. }))
89    }
90
91    /// Capability IDs that did NOT pass (Fail OR Skipped). Fed into
92    /// [`CredentialsValidation::missing_capabilities`] on the persisted
93    /// doc.
94    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/// Per-call context passed to [`super::DeployerCredentials::validate`].
104///
105/// Probes use `env_root` to test filesystem permissions on the env's own
106/// state dir without depending on `$HOME` being readable. `env_id` is
107/// borrowed for diagnostics only. `host_config` carries the env's
108/// network bind address so port probes can target the configured
109/// `listen_addr` rather than hardcoding `127.0.0.1`.
110#[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
133/// Drive a `requirements` flow against the env's bound deployer env-pack.
134///
135/// Steps:
136/// 1. Load the env; require a deployer slot and a `credentials_ref`.
137/// 2. Resolve the deployer's handler via the registry (also rejects slot
138///    or version mismatches per A9).
139/// 3. Use `creds_override` when the caller supplies one, else read the
140///    handler's [`super::DeployerCredentials`] (None ⇒ Phase D handler
141///    that hasn't registered a credentials contract yet).
142/// 4. Run the probes against `ValidationContext { env_root }`.
143/// 5. Build a [`Credentials`] doc stamped with `last_run_at` + result.
144///
145/// `creds_override`, when `Some`, replaces the handler's own probe (step 3)
146/// — the K8s `requirements` CLI path injects a live-cluster-connected
147/// [`K8sValidatorClient`](crate::env_packs::k8s::K8sValidatorClient) this
148/// way (the handler's default credentials hold no client and fail closed).
149/// The handler is still resolved unconditionally so the A9 slot/version
150/// check runs regardless of the override.
151///
152/// Returns both the doc and the report so the CLI can render the per-check
153/// detail while the persisted doc carries only the structured summary.
154pub 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    // Resolve the handler even when an override is supplied: this is where
166    // the A9 slot/version match is enforced.
167    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    // No-material deployers (e.g. local-process) can pass validation
179    // without a credentials_ref. For deployers that require material,
180    // the env must already have one (the user ran `op credentials
181    // bootstrap` or supplied one out-of-band).
182    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        // Use the env's ref if present; otherwise a sentinel that
188        // signals "no material required". The deploy-spec's
189        // `provided_credentials_ref` field is required, so we use a
190        // well-known sentinel rather than making it optional.
191        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    /// A sentinel credentials probe used to prove the runner honors
282    /// `creds_override` instead of the handler's own probe.
283    #[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    /// When the caller supplies `creds_override`, the runner validates with
308    /// it and ignores the handler's own probe — the seam the K8s
309    /// `requirements` CLI uses to inject a live-cluster validator.
310    #[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            // A registered deployer so the A9 resolve still succeeds; its
323            // own probe declares 2 caps, distinct from the fake's 1.
324            "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            &registry,
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}