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