use std::path::Path;
use chrono::Utc;
use greentic_deploy_spec::{
CapabilitySlot, Credentials, CredentialsMode, CredentialsValidation,
CredentialsValidationResult, EnvId, EnvironmentHostConfig, SchemaVersion, SecretRef,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::env_packs::{EnvPackRegistry, RegistryError};
use crate::environment::{EnvironmentStore, LocalFsStore, StoreError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Capability {
pub id: String,
pub description: String,
}
impl Capability {
pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
Self {
id: id.into(),
description: description.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum CapabilityStatus {
Pass,
Fail { reason: String },
Skipped { reason: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityCheck {
pub capability: Capability,
#[serde(flatten)]
pub status: CapabilityStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RequirementsReport {
pub checks: Vec<CapabilityCheck>,
}
impl RequirementsReport {
pub fn new(checks: Vec<CapabilityCheck>) -> Self {
Self { checks }
}
pub fn passed(&self) -> bool {
self.checks
.iter()
.all(|c| !matches!(c.status, CapabilityStatus::Fail { .. }))
}
pub fn missing(&self) -> Vec<String> {
self.checks
.iter()
.filter(|c| !matches!(c.status, CapabilityStatus::Pass))
.map(|c| c.capability.id.clone())
.collect()
}
}
#[derive(Debug)]
pub struct ValidationContext<'a> {
pub env_id: &'a EnvId,
pub env_root: &'a Path,
pub host_config: &'a EnvironmentHostConfig,
}
#[derive(Debug, Error)]
pub enum ValidateError {
#[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
NoDeployerBound(EnvId),
#[error("env `{0}` has no credentials_ref; run `op credentials bootstrap` first or supply one")]
NoCredentialsRef(EnvId),
#[error(
"deployer env-pack `{kind}` has no native credentials handler registered (Phase D plug-in)"
)]
HandlerNotRegistered { kind: String },
#[error(transparent)]
Store(#[from] StoreError),
#[error(transparent)]
Registry(#[from] RegistryError),
}
pub fn validate_requirements(
store: &LocalFsStore,
registry: &EnvPackRegistry,
env_id: &EnvId,
) -> Result<(Credentials, RequirementsReport), ValidateError> {
let env = store.load(env_id)?;
let deployer = env
.pack_for_slot(CapabilitySlot::Deployer)
.ok_or_else(|| ValidateError::NoDeployerBound(env_id.clone()))?;
let handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
let creds =
handler
.deployer_credentials()
.ok_or_else(|| ValidateError::HandlerNotRegistered {
kind: deployer.kind.as_str().to_string(),
})?;
let creds_ref = if creds.requires_credentials_material() {
env.credentials_ref
.clone()
.ok_or_else(|| ValidateError::NoCredentialsRef(env_id.clone()))?
} else {
env.credentials_ref.clone().unwrap_or_else(|| {
SecretRef::try_new(format!(
"secret://{}/local-process/no-material-required",
env_id.as_str()
))
.expect("sentinel SecretRef is well-formed")
})
};
let env_root = store.env_dir(env_id)?;
let ctx = ValidationContext {
env_id,
env_root: &env_root,
host_config: &env.host_config,
};
let report = creds.validate(&ctx);
let result = if report.passed() {
CredentialsValidationResult::Pass
} else {
CredentialsValidationResult::Fail
};
let doc = Credentials {
schema: SchemaVersion::new(SchemaVersion::CREDENTIALS_V1),
env_id: env_id.clone(),
deployer_kind: deployer.kind.clone(),
mode: CredentialsMode::Requirements,
provided_credentials_ref: creds_ref,
validation: CredentialsValidation {
last_run_at: Utc::now(),
result,
missing_capabilities: report.missing(),
},
bootstrap: None,
expiry: None,
};
Ok((doc, report))
}
#[cfg(test)]
mod tests {
use super::*;
fn cap(id: &str) -> Capability {
Capability::new(id, format!("description for {id}"))
}
#[test]
fn passed_true_when_no_failures() {
let report = RequirementsReport::new(vec![
CapabilityCheck {
capability: cap("a"),
status: CapabilityStatus::Pass,
},
CapabilityCheck {
capability: cap("b"),
status: CapabilityStatus::Skipped {
reason: "no backend".into(),
},
},
]);
assert!(report.passed(), "Skipped does not block overall pass");
assert_eq!(report.missing(), vec!["b".to_string()]);
}
#[test]
fn passed_false_when_any_fail() {
let report = RequirementsReport::new(vec![
CapabilityCheck {
capability: cap("a"),
status: CapabilityStatus::Pass,
},
CapabilityCheck {
capability: cap("b"),
status: CapabilityStatus::Fail {
reason: "denied".into(),
},
},
]);
assert!(!report.passed());
assert_eq!(report.missing(), vec!["b".to_string()]);
}
#[test]
fn empty_report_passes() {
let report = RequirementsReport::new(vec![]);
assert!(report.passed());
assert!(report.missing().is_empty());
}
}