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