use std::path::Path;
use chrono::Utc;
use greentic_deploy_spec::{
CapabilitySlot, Credentials, CredentialsBootstrap, CredentialsMode, CredentialsValidation,
CredentialsValidationResult, EnvId, SchemaVersion, SecretRef,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zeroize::Zeroizing;
use crate::env_packs::{EnvPackRegistry, RegistryError};
use crate::environment::{LocalFsStore, StoreError};
use super::rules_export::{RulesExportError, RulesPack, write_rules_pack};
pub struct ZeroizedAdmin {
inner: Zeroizing<String>,
profile: String,
}
impl std::fmt::Debug for ZeroizedAdmin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ZeroizedAdmin")
.field("profile", &self.profile)
.field("inner", &"<redacted>")
.finish()
}
}
impl ZeroizedAdmin {
pub fn new(profile: impl Into<String>, material: String) -> Self {
Self {
inner: Zeroizing::new(material),
profile: profile.into(),
}
}
pub fn sentinel(profile: impl Into<String>) -> Self {
Self {
inner: Zeroizing::new(String::new()),
profile: profile.into(),
}
}
pub fn as_str(&self) -> &str {
self.inner.as_str()
}
pub fn profile(&self) -> &str {
&self.profile
}
}
#[derive(Debug)]
pub struct BootstrapInput<'a> {
pub env_id: &'a EnvId,
pub env_root: &'a Path,
pub admin: &'a ZeroizedAdmin,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootstrapOutcome {
pub rules_pack: RulesPack,
pub bound_credentials_ref: Option<SecretRef>,
}
#[derive(Debug, Error)]
pub enum BootstrapError {
#[error("{0}")]
NotApplicable(String),
#[error("admin credential rejected: {0}")]
AdminRejected(String),
#[error("bootstrap failed during {step}: {message}")]
ProvisioningFailed { step: String, message: String },
}
#[derive(Debug, Error)]
pub enum RunBootstrapError {
#[error("env `{0}` has no deployer slot bound; bind one with `op env-packs add` first")]
NoDeployerBound(EnvId),
#[error("env `{0}` already has credentials_ref; use `rotate` instead of `bootstrap`")]
AlreadyBootstrapped(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),
#[error(transparent)]
Bootstrap(#[from] BootstrapError),
#[error(transparent)]
RulesExport(#[from] RulesExportError),
}
pub fn run_bootstrap(
store: &LocalFsStore,
registry: &EnvPackRegistry,
env_id: &EnvId,
admin: &ZeroizedAdmin,
) -> Result<Credentials, RunBootstrapError> {
store.transact(env_id, |locked| {
let mut env = locked.load()?;
let deployer = env
.pack_for_slot(CapabilitySlot::Deployer)
.ok_or_else(|| RunBootstrapError::NoDeployerBound(env_id.clone()))?;
if env.credentials_ref.is_some() {
return Err(RunBootstrapError::AlreadyBootstrapped(env_id.clone()));
}
let handler = registry.resolve_for_slot(CapabilitySlot::Deployer, &deployer.kind)?;
let creds = handler.deployer_credentials().ok_or_else(|| {
RunBootstrapError::HandlerNotRegistered {
kind: deployer.kind.as_str().to_string(),
}
})?;
let env_root = store.env_dir(env_id)?;
let input = BootstrapInput {
env_id,
env_root: &env_root,
admin,
};
let outcome = creds.bootstrap(&input)?;
let consumed_at = Utc::now();
let rules_pack_ref = write_rules_pack(&env_root, &deployer.kind, &outcome.rules_pack)?;
let deployer_kind = deployer.kind.clone();
let (doc_ref, validation_result, missing_caps) =
if let Some(ref bound) = outcome.bound_credentials_ref {
(bound.clone(), CredentialsValidationResult::Pass, Vec::new())
} else {
let sentinel = SecretRef::try_new(format!(
"secret://{}/{}/bootstrap-incomplete",
env_id.as_str(),
deployer_kind.as_str()
))
.expect("sentinel SecretRef is well-formed");
(
sentinel,
CredentialsValidationResult::Fail,
vec!["credentials.bind-pending".to_string()],
)
};
let doc = Credentials {
schema: SchemaVersion::new(SchemaVersion::CREDENTIALS_V1),
env_id: env_id.clone(),
deployer_kind,
mode: CredentialsMode::Bootstrap,
provided_credentials_ref: doc_ref.clone(),
validation: CredentialsValidation {
last_run_at: consumed_at,
result: validation_result,
missing_capabilities: missing_caps,
},
bootstrap: Some(CredentialsBootstrap {
admin_credential_consumed_at: consumed_at,
rules_pack_ref,
generated_credentials_ref: doc_ref,
}),
expiry: None,
};
if outcome.bound_credentials_ref.is_some() {
env.credentials_ref = Some(doc.provided_credentials_ref.clone());
locked.save(&env)?;
}
Ok(doc)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn zeroized_admin_redacts_in_debug_output() {
let admin = ZeroizedAdmin::new("p1", "AKIASUPERSECRET".to_string());
let dbg = format!("{admin:?}");
assert!(dbg.contains("<redacted>"));
assert!(!dbg.contains("AKIASUPERSECRET"));
}
#[test]
fn zeroized_admin_as_str_returns_material() {
let admin = ZeroizedAdmin::new("p1", "x".to_string());
assert_eq!(admin.as_str(), "x");
assert_eq!(admin.profile(), "p1");
}
#[test]
fn sentinel_has_empty_material() {
let admin = ZeroizedAdmin::sentinel("p1");
assert!(admin.as_str().is_empty());
}
}