Skip to main content

packc/
config.rs

1use crate::path_safety::normalize_under_root;
2use anyhow::{Context, Result, bail};
3use greentic_types::pack_manifest::ExtensionInline;
4use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderDecl, ProviderExtensionInline};
5use greentic_types::{
6    ComponentCapabilities, ComponentProfiles, ExtensionRef, FlowKind, ResourceHints,
7};
8use serde::{Deserialize, Serialize};
9use serde_json::Value as JsonValue;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13const PROVIDER_RUNTIME_WORLD: &str = "greentic:provider/schema-core@1.0.0";
14const LEGACY_PROVIDER_EXTENSION_KIND: &str = "greentic.ext.provider";
15
16#[derive(Debug, Clone, Deserialize, Serialize)]
17#[non_exhaustive]
18pub struct PackConfig {
19    pub pack_id: String,
20    pub version: String,
21    pub kind: String,
22    pub publisher: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub name: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub display_name: Option<String>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub bootstrap: Option<BootstrapConfig>,
29    /// Pack-level capability declarations. These let a pack advertise
30    /// intent that is independent of any single component — fast2flow
31    /// opt-in, for example, is per-pack, not per-component. Merged with
32    /// the auto-derived per-component caps at manifest-build time.
33    #[serde(default)]
34    pub capabilities: Vec<PackCapabilityConfig>,
35    #[serde(default)]
36    pub components: Vec<ComponentConfig>,
37    #[serde(default)]
38    pub dependencies: Vec<DependencyConfig>,
39    #[serde(default)]
40    pub flows: Vec<FlowConfig>,
41    #[serde(default)]
42    pub assets: Vec<AssetConfig>,
43    #[serde(
44        default,
45        skip_serializing_if = "Option::is_none",
46        deserialize_with = "deserialize_extensions"
47    )]
48    pub extensions: Option<BTreeMap<String, ExtensionRef>>,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct PackCapabilityConfig {
53    pub name: String,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub description: Option<String>,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
59struct RawExtensionRef {
60    pub kind: String,
61    pub version: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub digest: Option<String>,
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub location: Option<String>,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub inline: Option<JsonValue>,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
71pub struct ComponentConfig {
72    pub id: String,
73    pub version: String,
74    pub world: String,
75    #[serde(default)]
76    pub supports: Vec<FlowKindLabel>,
77    pub profiles: ComponentProfiles,
78    pub capabilities: ComponentCapabilities,
79    pub wasm: PathBuf,
80    #[serde(default, skip_serializing_if = "Vec::is_empty")]
81    pub operations: Vec<ComponentOperationConfig>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub config_schema: Option<JsonValue>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub resources: Option<ResourceHints>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub configurators: Option<ComponentConfiguratorConfig>,
88}
89
90#[derive(Debug, Clone, Deserialize, Serialize)]
91pub struct ComponentOperationConfig {
92    pub name: String,
93    pub input_schema: JsonValue,
94    pub output_schema: JsonValue,
95}
96
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct FlowConfig {
99    pub id: String,
100    pub file: PathBuf,
101    #[serde(default)]
102    pub tags: Vec<String>,
103    #[serde(default)]
104    pub entrypoints: Vec<String>,
105}
106
107#[derive(Debug, Clone, Deserialize, Serialize)]
108pub struct DependencyConfig {
109    pub alias: String,
110    pub pack_id: String,
111    pub version_req: String,
112    #[serde(default)]
113    pub required_capabilities: Vec<String>,
114}
115
116#[derive(Debug, Clone, Deserialize, Serialize)]
117pub struct AssetConfig {
118    pub path: PathBuf,
119}
120
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct BootstrapConfig {
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub install_flow: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub upgrade_flow: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub installer_component: Option<String>,
129}
130
131#[derive(Debug, Clone, Deserialize, Serialize)]
132pub struct ComponentConfiguratorConfig {
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub basic: Option<String>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub full: Option<String>,
137}
138
139#[derive(Debug, Clone, Deserialize, Serialize)]
140#[serde(rename_all = "lowercase")]
141pub enum FlowKindLabel {
142    Messaging,
143    Event,
144    #[serde(
145        rename = "componentconfig",
146        alias = "component-config",
147        alias = "component_config"
148    )]
149    ComponentConfig,
150    Job,
151    Http,
152}
153
154impl FlowKindLabel {
155    pub fn to_kind(&self) -> FlowKind {
156        match self {
157            FlowKindLabel::Messaging => FlowKind::Messaging,
158            FlowKindLabel::Event => FlowKind::Event,
159            FlowKindLabel::ComponentConfig => FlowKind::ComponentConfig,
160            FlowKindLabel::Job => FlowKind::Job,
161            FlowKindLabel::Http => FlowKind::Http,
162        }
163    }
164}
165
166fn deserialize_extensions<'de, D>(
167    deserializer: D,
168) -> std::result::Result<Option<BTreeMap<String, ExtensionRef>>, D::Error>
169where
170    D: serde::Deserializer<'de>,
171{
172    let raw = Option::<BTreeMap<String, RawExtensionRef>>::deserialize(deserializer)?;
173    raw.map(convert_extensions)
174        .transpose()
175        .map_err(serde::de::Error::custom)
176}
177
178fn convert_extensions(
179    raw: BTreeMap<String, RawExtensionRef>,
180) -> Result<BTreeMap<String, ExtensionRef>> {
181    raw.into_iter()
182        .map(|(key, value)| Ok((key, convert_extension_ref(value)?)))
183        .collect()
184}
185
186fn convert_extension_ref(raw: RawExtensionRef) -> Result<ExtensionRef> {
187    let inline = raw
188        .inline
189        .map(|value| convert_extension_inline(&raw.kind, value))
190        .transpose()?;
191    Ok(ExtensionRef {
192        kind: raw.kind,
193        version: raw.version,
194        digest: raw.digest,
195        location: raw.location,
196        inline,
197    })
198}
199
200fn convert_extension_inline(kind: &str, value: JsonValue) -> Result<ExtensionInline> {
201    if kind == PROVIDER_EXTENSION_ID || kind == LEGACY_PROVIDER_EXTENSION_KIND {
202        let provider = serde_json::from_value::<ProviderExtensionInline>(value.clone())
203            .with_context(|| {
204                format!("extensions[{kind}].inline is not a valid provider extension")
205            })?;
206        return Ok(ExtensionInline::Provider(provider));
207    }
208    Ok(ExtensionInline::Other(value))
209}
210
211pub fn load_pack_config(root: &Path) -> Result<PackConfig> {
212    let manifest_path = normalize_under_root(root, Path::new("pack.yaml"))?;
213    let contents = std::fs::read_to_string(&manifest_path)
214        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
215    let mut cfg: PackConfig = serde_yaml_bw::from_str(&contents)
216        .with_context(|| format!("{} is not a valid pack.yaml", manifest_path.display()))?;
217
218    // Normalize relative paths to be under the pack root so downstream logic can treat them as absolute.
219    for component in cfg.components.iter_mut() {
220        component.wasm = normalize_under_root(root, &component.wasm)?;
221    }
222    for flow in cfg.flows.iter_mut() {
223        flow.file = normalize_under_root(root, &flow.file)?;
224    }
225    for asset in cfg.assets.iter_mut() {
226        asset.path = normalize_under_root(root, &asset.path)?;
227    }
228
229    validate_extensions(cfg.extensions.as_ref(), strict_extensions())?;
230
231    Ok(cfg)
232}
233
234fn strict_extensions() -> bool {
235    matches!(
236        std::env::var("GREENTIC_PACK_STRICT_EXTENSIONS")
237            .unwrap_or_default()
238            .as_str(),
239        "1" | "true" | "TRUE"
240    )
241}
242
243fn validate_extensions(
244    extensions: Option<&BTreeMap<String, ExtensionRef>>,
245    strict: bool,
246) -> Result<()> {
247    let Some(exts) = extensions else {
248        return Ok(());
249    };
250
251    for (key, ext) in exts {
252        if ext.kind.trim().is_empty() {
253            bail!("extensions[{key}] kind must not be empty");
254        }
255        if ext.version.trim().is_empty() {
256            bail!("extensions[{key}] version must not be empty");
257        }
258        if ext.kind != *key {
259            bail!(
260                "extensions[{key}] kind `{}` must match the extension key",
261                ext.kind
262            );
263        }
264        if strict && let Some(location) = ext.location.as_deref() {
265            let digest_missing = ext
266                .digest
267                .as_ref()
268                .map(|d| d.trim().is_empty())
269                .unwrap_or(true);
270            if digest_missing {
271                bail!("extensions[{key}] location requires digest in strict mode");
272            }
273            let allowed = location.starts_with("oci://")
274                || location.starts_with("file://")
275                || location.starts_with("https://");
276            if !allowed {
277                bail!(
278                    "extensions[{key}] location `{location}` uses an unsupported scheme; allowed: oci://, file://, https://"
279                );
280            }
281        }
282
283        if ext.kind == PROVIDER_EXTENSION_ID || ext.kind == LEGACY_PROVIDER_EXTENSION_KIND {
284            validate_provider_extension(key, ext)?;
285        }
286    }
287
288    Ok(())
289}
290
291fn validate_provider_extension(key: &str, ext: &ExtensionRef) -> Result<()> {
292    let inline = ext
293        .inline
294        .as_ref()
295        .ok_or_else(|| anyhow::anyhow!("extensions[{key}] inline payload is required"))?;
296    let providers = match inline {
297        ExtensionInline::Provider(value) => value.providers.clone(),
298        ExtensionInline::Other(value) => {
299            serde_json::from_value::<ProviderExtensionInline>(value.clone())
300                .with_context(|| {
301                    format!("extensions[{key}].inline is not a valid provider extension")
302                })?
303                .providers
304        }
305    };
306    if providers.is_empty() {
307        bail!("extensions[{key}].inline.providers must not be empty");
308    }
309
310    for (idx, provider) in providers.iter().enumerate() {
311        validate_provider_decl(provider, key, idx)?;
312    }
313
314    Ok(())
315}
316
317fn validate_provider_decl(provider: &ProviderDecl, key: &str, idx: usize) -> Result<()> {
318    if provider.provider_type.trim().is_empty() {
319        bail!("extensions[{key}].inline.providers[{idx}].provider_type must not be empty");
320    }
321    if provider.config_schema_ref.trim().is_empty() {
322        bail!("extensions[{key}].inline.providers[{idx}].config_schema_ref must not be empty");
323    }
324    if provider.runtime.world != PROVIDER_RUNTIME_WORLD {
325        bail!(
326            "extensions[{key}].inline.providers[{idx}].runtime.world must be `{}`",
327            PROVIDER_RUNTIME_WORLD
328        );
329    }
330    if provider.runtime.component_ref.trim().is_empty() || provider.runtime.export.trim().is_empty()
331    {
332        bail!(
333            "extensions[{key}].inline.providers[{idx}].runtime component_ref/export must not be empty"
334        );
335    }
336    validate_string_vec(&provider.capabilities, "capabilities", key, idx)?;
337    validate_string_vec(&provider.ops, "ops", key, idx)?;
338    Ok(())
339}
340
341fn validate_string_vec(entries: &[String], field: &str, key: &str, idx: usize) -> Result<()> {
342    if entries.is_empty() {
343        bail!("extensions[{key}].inline.providers[{idx}].{field} must not be empty");
344    }
345    for (entry_idx, entry) in entries.iter().enumerate() {
346        if entry.trim().is_empty() {
347            bail!(
348                "extensions[{key}].inline.providers[{idx}].{field}[{entry_idx}] must be a non-empty string"
349            );
350        }
351    }
352    Ok(())
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358    use serde_json::json;
359
360    fn provider_extension_inline() -> JsonValue {
361        json!({
362            "providers": [
363                {
364                    "provider_type": "messaging.telegram.bot",
365                    "capabilities": ["send", "receive"],
366                    "ops": ["send", "reply"],
367                    "config_schema_ref": "schemas/messaging/telegram/config.schema.json",
368                    "state_schema_ref": "schemas/messaging/telegram/state.schema.json",
369                    "runtime": {
370                        "component_ref": "telegram-provider",
371                        "export": "provider",
372                        "world": PROVIDER_RUNTIME_WORLD
373                    },
374                    "docs_ref": "schemas/messaging/telegram/README.md"
375                }
376            ]
377        })
378    }
379
380    #[test]
381    fn provider_extension_validates() {
382        let mut extensions = BTreeMap::new();
383        extensions.insert(
384            PROVIDER_EXTENSION_ID.to_string(),
385            ExtensionRef {
386                kind: PROVIDER_EXTENSION_ID.into(),
387                version: "1.0.0".into(),
388                digest: Some("sha256:abc123".into()),
389                location: None,
390                inline: Some(
391                    serde_json::from_value(provider_extension_inline()).expect("inline parse"),
392                ),
393            },
394        );
395        validate_extensions(Some(&extensions), false).expect("provider extension should validate");
396    }
397
398    #[test]
399    fn provider_extension_missing_required_fields_fails() {
400        let mut extensions = BTreeMap::new();
401        extensions.insert(
402            PROVIDER_EXTENSION_ID.to_string(),
403            ExtensionRef {
404                kind: PROVIDER_EXTENSION_ID.into(),
405                version: "1.0.0".into(),
406                digest: None,
407                location: None,
408                inline: Some(
409                    serde_json::from_value(json!({
410                        "providers": [{
411                            "provider_type": "",
412                            "capabilities": [],
413                            "ops": ["send"],
414                            "config_schema_ref": "",
415                            "state_schema_ref": "schemas/state.json",
416                            "runtime": {
417                                "component_ref": "",
418                                "export": "",
419                                "world": "greentic:provider/schema-core@1.0.0"
420                            }
421                        }]
422                    }))
423                    .expect("inline parse"),
424                ),
425            },
426        );
427        assert!(
428            validate_extensions(Some(&extensions), false).is_err(),
429            "missing fields should fail validation"
430        );
431    }
432
433    #[test]
434    fn strict_mode_requires_digest_for_remote_extension() {
435        let mut extensions = BTreeMap::new();
436        extensions.insert(
437            "greentic.ext.provider".to_string(),
438            ExtensionRef {
439                kind: PROVIDER_EXTENSION_ID.into(),
440                version: "1.0.0".into(),
441                digest: None,
442                location: Some("oci://registry/extensions/provider".into()),
443                inline: None,
444            },
445        );
446        assert!(
447            validate_extensions(Some(&extensions), true).is_err(),
448            "strict mode should require digest when location is set"
449        );
450    }
451
452    #[test]
453    fn unknown_extensions_are_allowed() {
454        let mut extensions = BTreeMap::new();
455        extensions.insert(
456            "acme.ext.logging".to_string(),
457            ExtensionRef {
458                kind: "acme.ext.logging".into(),
459                version: "0.1.0".into(),
460                digest: None,
461                location: None,
462                inline: None,
463            },
464        );
465        validate_extensions(Some(&extensions), false).expect("unknown extensions should pass");
466    }
467
468    #[test]
469    fn pack_config_preserves_unknown_inline_extension_payload() {
470        let cfg: PackConfig = serde_yaml_bw::from_str(
471            r#"pack_id: dev.local.static-routes
472version: 0.1.0
473kind: application
474publisher: Test
475extensions:
476  greentic.static-routes.v1:
477    kind: greentic.static-routes.v1
478    version: 0.4.37
479    inline:
480      version: 1
481      routes:
482        - id: webchat-gui
483          public_path: /v1/web/webchat/{tenant}
484          source_root: assets/webchat-gui
485          scope:
486            tenant: true
487            team: false
488          index_file: index.html
489          spa_fallback: index.html
490"#,
491        )
492        .expect("deserialize pack config");
493
494        let ext = cfg
495            .extensions
496            .as_ref()
497            .and_then(|extensions| extensions.get("greentic.static-routes.v1"))
498            .expect("static routes extension present");
499        assert_eq!(ext.version, "0.4.37");
500
501        let inline = match ext.inline.as_ref() {
502            Some(ExtensionInline::Other(value)) => value,
503            other => panic!("unexpected inline payload: {other:?}"),
504        };
505        assert_eq!(inline.get("version"), Some(&json!(1)));
506        assert_eq!(
507            inline
508                .get("routes")
509                .and_then(JsonValue::as_array)
510                .map(Vec::len),
511            Some(1)
512        );
513    }
514
515    #[test]
516    fn pack_capabilities_round_trip() {
517        let cfg: PackConfig = serde_yaml_bw::from_str(
518            r#"
519pack_id: example.pack
520version: 0.1.0
521kind: application
522publisher: Greentic
523capabilities:
524  - name: greentic.cap.fast2flow.v1
525    description: opts into fast2flow free-text routing
526  - name: greentic.cap.observer.v1
527"#,
528        )
529        .expect("deserialize pack config with capabilities");
530        assert_eq!(cfg.capabilities.len(), 2);
531        assert_eq!(cfg.capabilities[0].name, "greentic.cap.fast2flow.v1");
532        assert_eq!(
533            cfg.capabilities[0].description.as_deref(),
534            Some("opts into fast2flow free-text routing")
535        );
536        assert_eq!(cfg.capabilities[1].name, "greentic.cap.observer.v1");
537        assert!(cfg.capabilities[1].description.is_none());
538    }
539
540    #[test]
541    fn pack_capabilities_default_empty() {
542        // Existing packs without the new field should keep parsing.
543        let cfg: PackConfig = serde_yaml_bw::from_str(
544            r#"
545pack_id: example.pack
546version: 0.1.0
547kind: application
548publisher: Greentic
549"#,
550        )
551        .expect("deserialize pack config without capabilities");
552        assert!(cfg.capabilities.is_empty());
553    }
554}