diff --git a/Cargo.lock b/Cargo.lock index 1034442..d3cdf1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1227,7 +1227,7 @@ dependencies = [ [[package]] name = "greentic-bundle" -version = "0.4.28" +version = "0.4.29" dependencies = [ "anyhow", "assert_cmd", @@ -1252,7 +1252,7 @@ dependencies = [ [[package]] name = "greentic-bundle-reader" -version = "0.4.28" +version = "0.4.29" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 95ee055..c0f6fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/greentic-bundle-reader"] resolver = "2" [workspace.package] -version = "0.4.28" +version = "0.4.29" [workspace.dependencies] anyhow = "1" diff --git a/registries/providers.json b/registries/providers.json index 52b1a17..82bc7b9 100644 --- a/registries/providers.json +++ b/registries/providers.json @@ -87,7 +87,8 @@ "i18n_key": "provider.messaging.teams", "fallback": "MS-Teams" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-teams:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-teams:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-slack", @@ -96,7 +97,8 @@ "i18n_key": "provider.messaging.slack", "fallback": "Slack" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-slack:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-slack:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-webex", @@ -105,7 +107,8 @@ "i18n_key": "provider.messaging.webex", "fallback": "WebEx" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webex:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webex:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-webchat", @@ -114,7 +117,8 @@ "i18n_key": "provider.messaging.webchat", "fallback": "WebChat" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webchat:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webchat:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-webchat-gui", @@ -123,7 +127,8 @@ "i18n_key": "provider.messaging.webchat-gui", "fallback": "WebChat (GUI)" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-webchat-gui:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-whatsapp", @@ -132,7 +137,8 @@ "i18n_key": "provider.messaging.whatsapp", "fallback": "WhatsApp" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-whatsapp:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-whatsapp:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-telegram", @@ -141,7 +147,8 @@ "i18n_key": "provider.messaging.telegram", "fallback": "Telegram" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-telegram:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-telegram:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "messaging-email", @@ -150,7 +157,8 @@ "i18n_key": "provider.messaging.email", "fallback": "Email" }, - "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-email:latest" + "ref": "oci://ghcr.io/greenticai/packs/messaging/messaging-email:latest", + "required_capabilities": ["greentic:state/state-store"] }, { "id": "events-webhook", @@ -375,7 +383,8 @@ "i18n_key": "provider.state.memory", "fallback": "In-Memory State" }, - "ref": "oci://ghcr.io/greenticai/packs/state/state-memory:latest" + "ref": "oci://ghcr.io/greenticai/packs/state/state-memory:latest", + "provided_capabilities": ["greentic:state/state-store"] }, { "id": "state-redis", @@ -384,7 +393,8 @@ "i18n_key": "provider.state.redis", "fallback": "Redis State" }, - "ref": "oci://ghcr.io/greenticai/packs/state/state-redis:latest" + "ref": "oci://ghcr.io/greenticai/packs/state/state-redis:latest", + "provided_capabilities": ["greentic:state/state-store"] }, { "id": "component-pack2flow", diff --git a/src/build/mod.rs b/src/build/mod.rs index 44987a5..783b93a 100644 --- a/src/build/mod.rs +++ b/src/build/mod.rs @@ -58,6 +58,13 @@ pub struct UnbundleResult { pub fn build_workspace(root: &Path, output: Option<&Path>, dry_run: bool) -> Result { let state = plan::build_state(root)?; + + // Validate pack dependencies at build time. + let dep_warnings = validate_bundle_dependencies(root); + for warning in &dep_warnings { + eprintln!(" [dependency] {warning}"); + } + let artifact = output .map(|path| path.to_path_buf()) .unwrap_or_else(|| default_artifact_path(root, &state.manifest.bundle_id)); @@ -116,6 +123,8 @@ fn doctor_workspace(root: &Path) -> Result { let drift_ok = lock::lock_matches_manifest(&state.lock, &state.manifest); let reader_validation = open_workspace_build_dir(root); let reader_ok = reader_validation.is_ok(); + let dep_warnings = validate_bundle_dependencies(root); + let deps_ok = dep_warnings.is_empty(); let checks = vec![ DoctorCheck { name: "bundle.yaml".to_string(), @@ -150,6 +159,15 @@ fn doctor_workspace(root: &Path) -> Result { ) }, }, + DoctorCheck { + name: "pack dependencies".to_string(), + ok: deps_ok, + details: if deps_ok { + None + } else { + Some(dep_warnings.join("; ")) + }, + }, ]; Ok(DoctorReport { target: root.display().to_string(), @@ -215,6 +233,64 @@ pub fn unbundle_artifact(artifact: &Path, output_dir: &Path) -> Result Vec { + let catalog_entries = match crate::catalog::registry::bundled_provider_registry_entries() { + Ok(entries) => entries, + Err(_) => return Vec::new(), + }; + + // Read the workspace definition to get the list of extension providers. + let workspace = match crate::project::read_bundle_workspace(root) { + Ok(ws) => ws, + Err(_) => return Vec::new(), + }; + + // Build set of provider IDs present in the bundle. + let included_ids: std::collections::BTreeSet = workspace + .extension_providers + .iter() + .filter_map(|reference| { + catalog_entries + .iter() + .find(|e| e.reference == *reference) + .map(|e| e.id.clone()) + }) + .collect(); + + // Also include capabilities provided by included packs. + let mut provided_caps = std::collections::BTreeSet::new(); + for entry in &catalog_entries { + if included_ids.contains(&entry.id) { + for cap in &entry.provided_capabilities { + provided_caps.insert(cap.clone()); + } + } + } + + // Check required capabilities for each included provider. + let mut warnings = Vec::new(); + for entry in &catalog_entries { + if !included_ids.contains(&entry.id) { + continue; + } + for cap in &entry.required_capabilities { + if !provided_caps.contains(cap) { + warnings.push(format!( + "{} requires capability '{}' but no provider in the bundle satisfies it", + entry.id, cap, + )); + } + } + } + + warnings +} + pub fn default_artifact_path(root: &Path, bundle_id: &str) -> PathBuf { root.join("dist") .join(format!("{bundle_id}{FUTURE_ARTIFACT_EXTENSION}")) diff --git a/src/catalog/capability_resolver.rs b/src/catalog/capability_resolver.rs new file mode 100644 index 0000000..a966901 --- /dev/null +++ b/src/catalog/capability_resolver.rs @@ -0,0 +1,241 @@ +//! Capability-based pack dependency resolver. +//! +//! Maps `required_capabilities` declared in pack dependencies to catalog +//! entries that advertise matching `provided_capabilities`. Used by the +//! wizard to auto-include dependency packs (e.g. state-memory) when a +//! provider pack (e.g. messaging-slack) requires them. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::registry::CatalogEntry; + +/// A dependency requirement extracted from a pack manifest. +#[derive(Debug, Clone)] +pub struct CapabilityRequirement { + /// The capability string (e.g. `greentic:state/state-store`). + pub capability: String, + /// The pack_id that requires this capability. + pub required_by: String, +} + +/// Result of resolving dependencies against the catalog. +#[derive(Debug, Default)] +pub struct DependencyResolution { + /// Dependencies auto-resolved (only one catalog entry provides them). + pub auto_resolved: Vec, + /// Dependencies with multiple providers — user must choose. + pub choices: Vec, + /// Dependencies that no catalog entry can satisfy. + pub unresolved: Vec, +} + +#[derive(Debug, Clone)] +pub struct ResolvedDependency { + pub capability: String, + pub required_by: String, + pub provider: CatalogEntry, +} + +#[derive(Debug, Clone)] +pub struct CapabilityChoice { + pub capability: String, + pub required_by: String, + pub options: Vec, +} + +#[derive(Debug, Clone)] +pub struct UnresolvedCapability { + pub capability: String, + pub required_by: String, +} + +/// Build an index from capability name → catalog entries that provide it. +pub fn build_capability_index( + catalog_entries: &[CatalogEntry], +) -> BTreeMap> { + let mut index: BTreeMap> = BTreeMap::new(); + for entry in catalog_entries { + for cap in &entry.provided_capabilities { + index + .entry(cap.clone()) + .or_default() + .push(entry.clone()); + } + } + index +} + +/// Resolve a set of capability requirements against the catalog. +/// +/// - `requirements`: capabilities needed by packs already in the bundle. +/// - `catalog_entries`: all available catalog entries. +/// - `already_included_ids`: pack IDs already present in the bundle (skip those). +pub fn resolve_capabilities( + requirements: &[CapabilityRequirement], + catalog_entries: &[CatalogEntry], + already_included_ids: &BTreeSet, +) -> DependencyResolution { + let cap_index = build_capability_index(catalog_entries); + let mut resolution = DependencyResolution::default(); + let mut resolved_caps = BTreeSet::new(); + + for req in requirements { + if !resolved_caps.insert(req.capability.clone()) { + // Already resolved this capability from a previous requirement. + continue; + } + + let providers: Vec = cap_index + .get(&req.capability) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|entry| !already_included_ids.contains(&entry.id)) + .collect(); + + match providers.len() { + 0 => { + // Check if any already-included pack provides it. + let satisfied_by_existing = cap_index + .get(&req.capability) + .map(|entries| { + entries + .iter() + .any(|e| already_included_ids.contains(&e.id)) + }) + .unwrap_or(false); + + if !satisfied_by_existing { + resolution.unresolved.push(UnresolvedCapability { + capability: req.capability.clone(), + required_by: req.required_by.clone(), + }); + } + } + 1 => { + resolution.auto_resolved.push(ResolvedDependency { + capability: req.capability.clone(), + required_by: req.required_by.clone(), + provider: providers.into_iter().next().unwrap(), + }); + } + _ => { + resolution.choices.push(CapabilityChoice { + capability: req.capability.clone(), + required_by: req.required_by.clone(), + options: providers, + }); + } + } + } + + resolution +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: &str, caps: &[&str]) -> CatalogEntry { + CatalogEntry { + id: id.to_string(), + category: None, + category_label: None, + category_description: None, + label: Some(id.to_string()), + reference: format!("oci://test/{id}:latest"), + setup: None, + provided_capabilities: caps.iter().map(|s| s.to_string()).collect(), + required_capabilities: Vec::new(), + } + } + + #[test] + fn auto_resolves_single_provider() { + let entries = vec![entry("state-memory", &["greentic:state/state-store"])]; + let reqs = vec![CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }]; + let result = resolve_capabilities(&reqs, &entries, &BTreeSet::new()); + assert_eq!(result.auto_resolved.len(), 1); + assert_eq!(result.auto_resolved[0].provider.id, "state-memory"); + assert!(result.choices.is_empty()); + assert!(result.unresolved.is_empty()); + } + + #[test] + fn presents_choice_when_multiple_providers() { + let entries = vec![ + entry("state-memory", &["greentic:state/state-store"]), + entry("state-redis", &["greentic:state/state-store"]), + ]; + let reqs = vec![CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }]; + let result = resolve_capabilities(&reqs, &entries, &BTreeSet::new()); + assert!(result.auto_resolved.is_empty()); + assert_eq!(result.choices.len(), 1); + assert_eq!(result.choices[0].options.len(), 2); + } + + #[test] + fn skips_already_included() { + let entries = vec![ + entry("state-memory", &["greentic:state/state-store"]), + entry("state-redis", &["greentic:state/state-store"]), + ]; + let reqs = vec![CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }]; + let included = BTreeSet::from(["state-memory".to_string()]); + let result = resolve_capabilities(&reqs, &entries, &included); + // state-memory already included, so only state-redis is a candidate → auto-resolve + assert_eq!(result.auto_resolved.len(), 1); + assert_eq!(result.auto_resolved[0].provider.id, "state-redis"); + } + + #[test] + fn satisfied_by_existing_is_not_unresolved() { + let entries = vec![entry("state-memory", &["greentic:state/state-store"])]; + let reqs = vec![CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }]; + let included = BTreeSet::from(["state-memory".to_string()]); + let result = resolve_capabilities(&reqs, &entries, &included); + assert!(result.auto_resolved.is_empty()); + assert!(result.choices.is_empty()); + assert!(result.unresolved.is_empty()); + } + + #[test] + fn unresolved_when_no_provider() { + let entries = vec![]; + let reqs = vec![CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }]; + let result = resolve_capabilities(&reqs, &entries, &BTreeSet::new()); + assert_eq!(result.unresolved.len(), 1); + } + + #[test] + fn deduplicates_same_capability() { + let entries = vec![entry("state-memory", &["greentic:state/state-store"])]; + let reqs = vec![ + CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-slack".to_string(), + }, + CapabilityRequirement { + capability: "greentic:state/state-store".to_string(), + required_by: "messaging-telegram".to_string(), + }, + ]; + let result = resolve_capabilities(&reqs, &entries, &BTreeSet::new()); + assert_eq!(result.auto_resolved.len(), 1); + } +} diff --git a/src/catalog/mod.rs b/src/catalog/mod.rs index ef57034..309b4b8 100644 --- a/src/catalog/mod.rs +++ b/src/catalog/mod.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod capability_resolver; pub mod client; pub mod registry; pub mod resolve; diff --git a/src/catalog/registry.rs b/src/catalog/registry.rs index 4669a38..95475c8 100644 --- a/src/catalog/registry.rs +++ b/src/catalog/registry.rs @@ -6,6 +6,7 @@ use crate::setup::SetupSpecInput; pub const BUNDLED_PROVIDER_REGISTRY_SOURCE: &str = "registries/providers.json"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct CatalogEntry { pub id: String, #[serde(default)] @@ -20,6 +21,12 @@ pub struct CatalogEntry { pub reference: String, #[serde(default)] pub setup: Option, + /// Capabilities this pack provides (e.g. `greentic:state/state-store`). + #[serde(default)] + pub provided_capabilities: Vec, + /// Capabilities this pack requires from other packs. + #[serde(default)] + pub required_capabilities: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -78,6 +85,10 @@ struct ProviderRegistryItem { reference: String, #[serde(default)] setup: Option, + #[serde(default)] + provided_capabilities: Vec, + #[serde(default)] + required_capabilities: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -117,6 +128,10 @@ struct OciProviderRegistryItem { reference: String, #[serde(default)] setup: Option, + #[serde(default)] + provided_capabilities: Vec, + #[serde(default)] + required_capabilities: Vec, } pub fn parse_catalog_bytes(bytes: &[u8], source: &str) -> Result { @@ -205,6 +220,8 @@ fn parse_catalog_entries(bytes: &[u8], source: &str) -> Result label: (!item.label.fallback.is_empty()).then_some(item.label.fallback), reference: item.reference, setup: item.setup, + provided_capabilities: item.provided_capabilities, + required_capabilities: item.required_capabilities, } }) .collect()) @@ -260,6 +277,8 @@ impl From for CatalogEntry { label: (!item.label.fallback.is_empty()).then_some(item.label.fallback), reference: item.reference, setup: item.setup, + provided_capabilities: item.provided_capabilities, + required_capabilities: item.required_capabilities, } } } diff --git a/src/wizard/mod.rs b/src/wizard/mod.rs index cdb0100..2bf0193 100644 --- a/src/wizard/mod.rs +++ b/src/wizard/mod.rs @@ -1880,12 +1880,14 @@ fn edit_extension_providers( "1" => { if let Some(entry) = add_common_extension_provider(input, output, &state)? { state.extension_provider_entries.push(entry); + state = resolve_dependency_capabilities(input, output, state)?; state = rebuild_request(state); } } "2" => { if let Some(entry) = add_custom_extension_provider(input, output, &state)? { state.extension_provider_entries.push(entry); + state = resolve_dependency_capabilities(input, output, state)?; state = rebuild_request(state); } } @@ -1900,6 +1902,131 @@ fn edit_extension_providers( } } +/// After adding an extension provider, resolve any required capabilities. +/// +/// Checks all currently selected providers for `required_capabilities` against +/// the catalog. Auto-adds dependencies with a single provider, prompts when +/// multiple options exist, and warns when no provider is found. +fn resolve_dependency_capabilities( + input: &mut R, + output: &mut W, + mut state: NormalizedRequest, +) -> Result { + let (_, _, catalog_entries) = + resolve_extension_provider_catalog(&state.output_dir, &state.remote_catalogs)?; + if catalog_entries.is_empty() { + return Ok(state); + } + + // Collect required_capabilities from all currently selected providers. + let already_included: std::collections::BTreeSet = state + .extension_provider_entries + .iter() + .map(|e| e.provider_id.clone()) + .collect(); + + let mut requirements = Vec::new(); + for entry in &state.extension_provider_entries { + // Look up catalog entry for this provider to find required_capabilities. + if let Some(cat_entry) = catalog_entries.iter().find(|e| e.id == entry.provider_id) { + for cap in &cat_entry.required_capabilities { + requirements.push( + crate::catalog::capability_resolver::CapabilityRequirement { + capability: cap.clone(), + required_by: entry.provider_id.clone(), + }, + ); + } + } + } + + if requirements.is_empty() { + return Ok(state); + } + + let resolution = crate::catalog::capability_resolver::resolve_capabilities( + &requirements, + &catalog_entries, + &already_included, + ); + + // Auto-add single-provider dependencies. + for dep in &resolution.auto_resolved { + let label = dep + .provider + .label + .as_deref() + .unwrap_or(&dep.provider.id); + writeln!( + output, + " [auto] {} requires {} — adding {label}", + dep.required_by, dep.capability, + )?; + state.extension_provider_entries.push(ExtensionProviderEntry { + detected_kind: detected_reference_kind(&state.output_dir, &dep.provider.reference) + .to_string(), + reference: dep.provider.reference.clone(), + provider_id: dep.provider.id.clone(), + display_name: label.to_string(), + version: inferred_reference_version(&dep.provider.reference), + source_catalog: None, + group: Some("dependency".to_string()), + }); + } + + // Prompt user to choose when multiple providers satisfy a capability. + for choice in &resolution.choices { + writeln!( + output, + "\n{} requires capability: {}", + choice.required_by, choice.capability, + )?; + let labels: Vec = choice + .options + .iter() + .map(|e| { + e.label + .as_deref() + .unwrap_or(&e.id) + .to_string() + }) + .collect(); + if let Some(index) = choose_named_index( + input, + output, + &format!("Choose provider for {}", choice.capability), + &labels, + )? { + let selected = &choice.options[index]; + let label = selected + .label + .as_deref() + .unwrap_or(&selected.id); + state.extension_provider_entries.push(ExtensionProviderEntry { + detected_kind: detected_reference_kind(&state.output_dir, &selected.reference) + .to_string(), + reference: selected.reference.clone(), + provider_id: selected.id.clone(), + display_name: label.to_string(), + version: inferred_reference_version(&selected.reference), + source_catalog: None, + group: Some("dependency".to_string()), + }); + } + } + + // Warn about unresolved capabilities. + for unresolved in &resolution.unresolved { + writeln!( + output, + " [warn] {} requires {} but no provider found in catalog", + unresolved.required_by, unresolved.capability, + )?; + } + + Ok(state) +} + fn review_summary( input: &mut R, output: &mut W, @@ -4697,6 +4824,8 @@ mod tests { "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25" .to_string(), setup: None, + provided_capabilities: Vec::new(), + required_capabilities: Vec::new(), }; let latest = CatalogEntry { id: "greentic.secrets.aws-sm.latest".to_string(), @@ -4708,6 +4837,8 @@ mod tests { "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:latest" .to_string(), setup: None, + provided_capabilities: Vec::new(), + required_capabilities: Vec::new(), }; let entries = vec![&pinned, &latest]; let options = build_extension_provider_options(&entries); @@ -4731,6 +4862,8 @@ mod tests { label: Some("Greentic Secrets AWS SM (latest)".to_string()), reference: "oci://ghcr.io/example/secrets:latest".to_string(), setup: None, + provided_capabilities: Vec::new(), + required_capabilities: Vec::new(), }; let semver = CatalogEntry { id: "x.0.4.25".to_string(), @@ -4740,6 +4873,8 @@ mod tests { label: Some("Greentic Secrets AWS SM (0.4.25)".to_string()), reference: "oci://ghcr.io/example/secrets:0.4.25".to_string(), setup: None, + provided_capabilities: Vec::new(), + required_capabilities: Vec::new(), }; let pr = CatalogEntry { id: "x.pr".to_string(), @@ -4749,6 +4884,8 @@ mod tests { label: Some("Greentic Messaging Dummy (PR version)".to_string()), reference: "oci://ghcr.io/example/messaging:".to_string(), setup: None, + provided_capabilities: Vec::new(), + required_capabilities: Vec::new(), }; assert_eq!(