use std::collections::BTreeMap;
use greentic_deploy_spec::{CapabilitySlot, PackDescriptor};
use thiserror::Error;
use super::slot::{BUILTIN_HANDLERS, EnvPackHandler};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RegistryError {
#[error("no env-pack handler registered for `{0}`")]
Unknown(String),
#[error("env-pack `{kind}` is a `{actual}` handler but was bound to the `{expected}` slot")]
SlotMismatch {
kind: String,
expected: CapabilitySlot,
actual: CapabilitySlot,
},
#[error(
"env-pack `{kind}` pins version `{requested}` but the native handler implements `{supported}`"
)]
VersionUnsupported {
kind: String,
requested: String,
supported: String,
},
#[error("an env-pack handler is already registered for path `{0}`")]
DuplicateRegistration(String),
#[error(
"deployer env-pack `{kind}` does not ship a credentials contract (DeployerCredentials impl)"
)]
DeployerMissingCredentials { kind: String },
}
#[derive(Debug, Default)]
pub struct EnvPackRegistry {
handlers: BTreeMap<String, Box<dyn EnvPackHandler>>,
}
impl EnvPackRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn with_builtins() -> Self {
let mut registry = Self::new();
for handler in BUILTIN_HANDLERS {
registry
.register(Box::new(*handler))
.expect("built-in handler paths are unique");
}
registry
.register(Box::new(
super::local_process::LocalProcessDeployerHandler::default(),
))
.expect("local-process deployer handler path is unique");
#[cfg(feature = "creds-aws")]
registry
.register(Box::new(super::aws::AwsEcsDeployerHandler::default()))
.expect("aws-ecs deployer handler path is unique");
registry
}
pub fn register(&mut self, handler: Box<dyn EnvPackHandler>) -> Result<(), RegistryError> {
let path = handler.descriptor_path().to_string();
if self.handlers.contains_key(&path) {
return Err(RegistryError::DuplicateRegistration(path));
}
if handler.slot() == CapabilitySlot::Deployer && handler.deployer_credentials().is_none() {
return Err(RegistryError::DeployerMissingCredentials { kind: path });
}
self.handlers.insert(path, handler);
Ok(())
}
pub fn resolve(&self, kind: &PackDescriptor) -> Result<&dyn EnvPackHandler, RegistryError> {
let handler = self
.handlers
.get(kind.path())
.map(|h| h.as_ref())
.ok_or_else(|| RegistryError::Unknown(kind.as_str().to_string()))?;
let req = handler.supported_versions();
if !req.matches(&kind.version().0) {
return Err(RegistryError::VersionUnsupported {
kind: kind.as_str().to_string(),
requested: kind.version().to_string(),
supported: req.to_string(),
});
}
Ok(handler)
}
pub fn resolve_for_slot(
&self,
expected: CapabilitySlot,
kind: &PackDescriptor,
) -> Result<&dyn EnvPackHandler, RegistryError> {
let handler = self.resolve(kind)?;
let actual = handler.slot();
if actual != expected {
return Err(RegistryError::SlotMismatch {
kind: kind.as_str().to_string(),
expected,
actual,
});
}
Ok(handler)
}
pub fn wizard_qaspec_yaml_for_descriptor(
&self,
kind: &PackDescriptor,
) -> Result<Option<&'static str>, RegistryError> {
Ok(self.resolve(kind)?.wizard_qaspec_yaml())
}
pub fn len(&self) -> usize {
self.handlers.len()
}
pub fn is_empty(&self) -> bool {
self.handlers.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::defaults::{LOCAL_DEPLOYER_PACK, LOCAL_SECRETS_PACK};
fn descriptor(raw: &str) -> PackDescriptor {
PackDescriptor::try_new(raw).expect("descriptor parses")
}
#[test]
fn with_builtins_registers_baseline_handlers() {
let registry = EnvPackRegistry::with_builtins();
#[cfg(feature = "creds-aws")]
let expected = 6;
#[cfg(not(feature = "creds-aws"))]
let expected = 5;
assert_eq!(registry.len(), expected);
}
#[test]
fn resolve_built_in_descriptor() {
let registry = EnvPackRegistry::with_builtins();
let handler = registry.resolve(&descriptor(LOCAL_SECRETS_PACK)).unwrap();
assert_eq!(handler.slot(), CapabilitySlot::Secrets);
assert_eq!(handler.descriptor_path(), "greentic.secrets.dev-store");
}
#[test]
fn resolve_accepts_compatible_version() {
let registry = EnvPackRegistry::with_builtins();
let handler = registry
.resolve(&descriptor("greentic.secrets.dev-store@0.1.7"))
.unwrap();
assert_eq!(handler.slot(), CapabilitySlot::Secrets);
}
#[test]
fn resolve_rejects_unsupported_version() {
let registry = EnvPackRegistry::with_builtins();
let err = registry
.resolve(&descriptor("greentic.secrets.dev-store@9.9.9"))
.unwrap_err();
assert!(matches!(
err,
RegistryError::VersionUnsupported {
requested,
supported,
..
} if requested == "9.9.9" && supported == "^0.1.0"
));
}
#[test]
fn resolve_unknown_descriptor_errors() {
let registry = EnvPackRegistry::with_builtins();
let err = registry
.resolve(&descriptor("greentic.secrets.acme-vault@1.0.0"))
.unwrap_err();
assert!(matches!(err, RegistryError::Unknown(k) if k.contains("acme-vault")));
}
#[test]
fn resolve_for_slot_accepts_matching_slot() {
let registry = EnvPackRegistry::with_builtins();
registry
.resolve_for_slot(CapabilitySlot::Deployer, &descriptor(LOCAL_DEPLOYER_PACK))
.unwrap();
}
#[test]
fn resolve_for_slot_rejects_mismatched_slot() {
let registry = EnvPackRegistry::with_builtins();
let err = registry
.resolve_for_slot(CapabilitySlot::Secrets, &descriptor(LOCAL_DEPLOYER_PACK))
.unwrap_err();
assert!(matches!(
err,
RegistryError::SlotMismatch {
expected: CapabilitySlot::Secrets,
actual: CapabilitySlot::Deployer,
..
}
));
}
#[test]
fn register_rejects_duplicate_path() {
let mut registry = EnvPackRegistry::with_builtins();
let err = registry
.register(Box::new(super::super::slot::BUILTIN_HANDLERS[0]))
.unwrap_err();
assert!(matches!(err, RegistryError::DuplicateRegistration(_)));
}
#[test]
fn new_registry_is_empty() {
let registry = EnvPackRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
}
#[derive(Debug)]
struct ExtHandler;
impl EnvPackHandler for ExtHandler {
fn slot(&self) -> CapabilitySlot {
CapabilitySlot::Extension
}
fn descriptor_path(&self) -> &str {
"acme.oauth.auth0"
}
fn supported_versions(&self) -> semver::VersionReq {
"^1.0.0".parse().unwrap()
}
}
#[test]
fn resolve_for_slot_accepts_a_registered_extension_handler() {
let mut registry = EnvPackRegistry::with_builtins();
registry.register(Box::new(ExtHandler)).unwrap();
registry
.resolve_for_slot(
CapabilitySlot::Extension,
&descriptor("acme.oauth.auth0@1.2.0"),
)
.unwrap();
let err = registry
.resolve_for_slot(
CapabilitySlot::Secrets,
&descriptor("acme.oauth.auth0@1.2.0"),
)
.unwrap_err();
assert!(matches!(
err,
RegistryError::SlotMismatch {
expected: CapabilitySlot::Secrets,
actual: CapabilitySlot::Extension,
..
}
));
}
#[test]
fn unregistered_extension_is_unknown() {
let registry = EnvPackRegistry::with_builtins();
let err = registry
.resolve_for_slot(
CapabilitySlot::Extension,
&descriptor("acme.oauth.auth0@1.0.0"),
)
.unwrap_err();
assert!(matches!(err, RegistryError::Unknown(_)));
}
#[derive(Debug)]
struct DeployerWithoutCredentials;
impl EnvPackHandler for DeployerWithoutCredentials {
fn slot(&self) -> CapabilitySlot {
CapabilitySlot::Deployer
}
fn descriptor_path(&self) -> &str {
"acme.deployer.no-creds"
}
fn supported_versions(&self) -> semver::VersionReq {
"^1.0.0".parse().unwrap()
}
}
#[test]
fn register_rejects_deployer_without_credentials() {
let mut registry = EnvPackRegistry::new();
let err = registry
.register(Box::new(DeployerWithoutCredentials))
.unwrap_err();
assert!(
matches!(err, RegistryError::DeployerMissingCredentials { .. }),
"got {err:?}"
);
}
#[test]
fn every_handler_with_a_wizard_ships_a_well_formed_qaspec() {
let registry = EnvPackRegistry::with_builtins();
let mut checked = 0;
for (path, handler) in ®istry.handlers {
let Some(yaml) = handler.wizard_qaspec_yaml() else {
continue;
};
let spec: qa_spec::FormSpec = serde_yaml_bw::from_str(yaml)
.unwrap_or_else(|e| panic!("`{path}` wizard.qaspec.yaml parses: {e}"));
assert!(
!spec.questions.is_empty(),
"`{path}` wizard QASpec declares zero questions — the operator's wizard \
driver has nothing to ask",
);
checked += 1;
}
assert!(
checked >= 1,
"no built-in handler ships a wizard QASpec — the C6 seam is unexercised",
);
}
#[test]
fn wizard_qaspec_yaml_for_descriptor_resolves_local_process() {
let registry = EnvPackRegistry::with_builtins();
let yaml = registry
.wizard_qaspec_yaml_for_descriptor(&descriptor(LOCAL_DEPLOYER_PACK))
.expect("local-process deployer descriptor resolves")
.expect("local-process deployer ships a wizard QASpec");
assert!(yaml.contains("greentic.deployer.local-process.wizard"));
}
#[test]
fn wizard_qaspec_yaml_for_descriptor_none_for_metadata_only_handler() {
let registry = EnvPackRegistry::with_builtins();
let yaml = registry
.wizard_qaspec_yaml_for_descriptor(&descriptor(LOCAL_SECRETS_PACK))
.expect("dev-store secrets descriptor resolves");
assert!(
yaml.is_none(),
"metadata-only handler must not surface a wizard QASpec",
);
}
#[test]
fn wizard_qaspec_yaml_for_descriptor_propagates_version_skew() {
let registry = EnvPackRegistry::with_builtins();
let err = registry
.wizard_qaspec_yaml_for_descriptor(&descriptor("greentic.secrets.dev-store@9.9.9"))
.unwrap_err();
assert!(matches!(err, RegistryError::VersionUnsupported { .. }));
}
#[test]
fn builtin_deployer_has_credentials_contract() {
let registry = EnvPackRegistry::with_builtins();
let handler = registry.resolve(&descriptor(LOCAL_DEPLOYER_PACK)).unwrap();
assert!(
handler.deployer_credentials().is_some(),
"built-in deployer must ship a credentials contract"
);
}
}