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 bootstrap: Option<BootstrapConfig>,
24    #[serde(default)]
25    pub components: Vec<ComponentConfig>,
26    #[serde(default)]
27    pub dependencies: Vec<DependencyConfig>,
28    #[serde(default)]
29    pub flows: Vec<FlowConfig>,
30    #[serde(default)]
31    pub assets: Vec<AssetConfig>,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub extensions: Option<BTreeMap<String, ExtensionRef>>,
34}
35
36#[derive(Debug, Clone, Deserialize, Serialize)]
37pub struct ComponentConfig {
38    pub id: String,
39    pub version: String,
40    pub world: String,
41    #[serde(default)]
42    pub supports: Vec<FlowKindLabel>,
43    pub profiles: ComponentProfiles,
44    pub capabilities: ComponentCapabilities,
45    pub wasm: PathBuf,
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub operations: Vec<ComponentOperationConfig>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub config_schema: Option<JsonValue>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub resources: Option<ResourceHints>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub configurators: Option<ComponentConfiguratorConfig>,
54}
55
56#[derive(Debug, Clone, Deserialize, Serialize)]
57pub struct ComponentOperationConfig {
58    pub name: String,
59    pub input_schema: JsonValue,
60    pub output_schema: JsonValue,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct FlowConfig {
65    pub id: String,
66    pub file: PathBuf,
67    #[serde(default)]
68    pub tags: Vec<String>,
69    #[serde(default)]
70    pub entrypoints: Vec<String>,
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74pub struct DependencyConfig {
75    pub alias: String,
76    pub pack_id: String,
77    pub version_req: String,
78    #[serde(default)]
79    pub required_capabilities: Vec<String>,
80}
81
82#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct AssetConfig {
84    pub path: PathBuf,
85}
86
87#[derive(Debug, Clone, Deserialize, Serialize)]
88pub struct BootstrapConfig {
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub install_flow: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub upgrade_flow: Option<String>,
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub installer_component: Option<String>,
95}
96
97#[derive(Debug, Clone, Deserialize, Serialize)]
98pub struct ComponentConfiguratorConfig {
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub basic: Option<String>,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub full: Option<String>,
103}
104
105#[derive(Debug, Clone, Deserialize, Serialize)]
106#[serde(rename_all = "lowercase")]
107pub enum FlowKindLabel {
108    Messaging,
109    Event,
110    #[serde(
111        rename = "componentconfig",
112        alias = "component-config",
113        alias = "component_config"
114    )]
115    ComponentConfig,
116    Job,
117    Http,
118}
119
120impl FlowKindLabel {
121    pub fn to_kind(&self) -> FlowKind {
122        match self {
123            FlowKindLabel::Messaging => FlowKind::Messaging,
124            FlowKindLabel::Event => FlowKind::Event,
125            FlowKindLabel::ComponentConfig => FlowKind::ComponentConfig,
126            FlowKindLabel::Job => FlowKind::Job,
127            FlowKindLabel::Http => FlowKind::Http,
128        }
129    }
130}
131
132pub fn load_pack_config(root: &Path) -> Result<PackConfig> {
133    let manifest_path = normalize_under_root(root, Path::new("pack.yaml"))?;
134    let contents = std::fs::read_to_string(&manifest_path)
135        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
136    let mut cfg: PackConfig = serde_yaml_bw::from_str(&contents)
137        .with_context(|| format!("{} is not a valid pack.yaml", manifest_path.display()))?;
138
139    // Normalize relative paths to be under the pack root so downstream logic can treat them as absolute.
140    for component in cfg.components.iter_mut() {
141        component.wasm = normalize_under_root(root, &component.wasm)?;
142    }
143    for flow in cfg.flows.iter_mut() {
144        flow.file = normalize_under_root(root, &flow.file)?;
145    }
146    for asset in cfg.assets.iter_mut() {
147        asset.path = normalize_under_root(root, &asset.path)?;
148    }
149
150    validate_extensions(cfg.extensions.as_ref(), strict_extensions())?;
151
152    Ok(cfg)
153}
154
155fn strict_extensions() -> bool {
156    matches!(
157        std::env::var("GREENTIC_PACK_STRICT_EXTENSIONS")
158            .unwrap_or_default()
159            .as_str(),
160        "1" | "true" | "TRUE"
161    )
162}
163
164fn validate_extensions(
165    extensions: Option<&BTreeMap<String, ExtensionRef>>,
166    strict: bool,
167) -> Result<()> {
168    let Some(exts) = extensions else {
169        return Ok(());
170    };
171
172    for (key, ext) in exts {
173        if ext.kind.trim().is_empty() {
174            bail!("extensions[{key}] kind must not be empty");
175        }
176        if ext.version.trim().is_empty() {
177            bail!("extensions[{key}] version must not be empty");
178        }
179        if ext.kind != *key {
180            bail!(
181                "extensions[{key}] kind `{}` must match the extension key",
182                ext.kind
183            );
184        }
185        if strict && let Some(location) = ext.location.as_deref() {
186            let digest_missing = ext
187                .digest
188                .as_ref()
189                .map(|d| d.trim().is_empty())
190                .unwrap_or(true);
191            if digest_missing {
192                bail!("extensions[{key}] location requires digest in strict mode");
193            }
194            let allowed = location.starts_with("oci://")
195                || location.starts_with("file://")
196                || location.starts_with("https://");
197            if !allowed {
198                bail!(
199                    "extensions[{key}] location `{location}` uses an unsupported scheme; allowed: oci://, file://, https://"
200                );
201            }
202        }
203
204        if ext.kind == PROVIDER_EXTENSION_ID || ext.kind == LEGACY_PROVIDER_EXTENSION_KIND {
205            validate_provider_extension(key, ext)?;
206        }
207    }
208
209    Ok(())
210}
211
212fn validate_provider_extension(key: &str, ext: &ExtensionRef) -> Result<()> {
213    let inline = ext
214        .inline
215        .as_ref()
216        .ok_or_else(|| anyhow::anyhow!("extensions[{key}] inline payload is required"))?;
217    let providers = match inline {
218        ExtensionInline::Provider(value) => value.providers.clone(),
219        ExtensionInline::Other(value) => {
220            serde_json::from_value::<ProviderExtensionInline>(value.clone())
221                .with_context(|| {
222                    format!("extensions[{key}].inline is not a valid provider extension")
223                })?
224                .providers
225        }
226    };
227    if providers.is_empty() {
228        bail!("extensions[{key}].inline.providers must not be empty");
229    }
230
231    for (idx, provider) in providers.iter().enumerate() {
232        validate_provider_decl(provider, key, idx)?;
233    }
234
235    Ok(())
236}
237
238fn validate_provider_decl(provider: &ProviderDecl, key: &str, idx: usize) -> Result<()> {
239    if provider.provider_type.trim().is_empty() {
240        bail!("extensions[{key}].inline.providers[{idx}].provider_type must not be empty");
241    }
242    if provider.config_schema_ref.trim().is_empty() {
243        bail!("extensions[{key}].inline.providers[{idx}].config_schema_ref must not be empty");
244    }
245    if provider.runtime.world != PROVIDER_RUNTIME_WORLD {
246        bail!(
247            "extensions[{key}].inline.providers[{idx}].runtime.world must be `{}`",
248            PROVIDER_RUNTIME_WORLD
249        );
250    }
251    if provider.runtime.component_ref.trim().is_empty() || provider.runtime.export.trim().is_empty()
252    {
253        bail!(
254            "extensions[{key}].inline.providers[{idx}].runtime component_ref/export must not be empty"
255        );
256    }
257    validate_string_vec(&provider.capabilities, "capabilities", key, idx)?;
258    validate_string_vec(&provider.ops, "ops", key, idx)?;
259    Ok(())
260}
261
262fn validate_string_vec(entries: &[String], field: &str, key: &str, idx: usize) -> Result<()> {
263    if entries.is_empty() {
264        bail!("extensions[{key}].inline.providers[{idx}].{field} must not be empty");
265    }
266    for (entry_idx, entry) in entries.iter().enumerate() {
267        if entry.trim().is_empty() {
268            bail!(
269                "extensions[{key}].inline.providers[{idx}].{field}[{entry_idx}] must be a non-empty string"
270            );
271        }
272    }
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use serde_json::json;
280
281    fn provider_extension_inline() -> JsonValue {
282        json!({
283            "providers": [
284                {
285                    "provider_type": "messaging.telegram.bot",
286                    "capabilities": ["send", "receive"],
287                    "ops": ["send", "reply"],
288                    "config_schema_ref": "schemas/messaging/telegram/config.schema.json",
289                    "state_schema_ref": "schemas/messaging/telegram/state.schema.json",
290                    "runtime": {
291                        "component_ref": "telegram-provider",
292                        "export": "provider",
293                        "world": PROVIDER_RUNTIME_WORLD
294                    },
295                    "docs_ref": "schemas/messaging/telegram/README.md"
296                }
297            ]
298        })
299    }
300
301    #[test]
302    fn provider_extension_validates() {
303        let mut extensions = BTreeMap::new();
304        extensions.insert(
305            PROVIDER_EXTENSION_ID.to_string(),
306            ExtensionRef {
307                kind: PROVIDER_EXTENSION_ID.into(),
308                version: "1.0.0".into(),
309                digest: Some("sha256:abc123".into()),
310                location: None,
311                inline: Some(
312                    serde_json::from_value(provider_extension_inline()).expect("inline parse"),
313                ),
314            },
315        );
316        validate_extensions(Some(&extensions), false).expect("provider extension should validate");
317    }
318
319    #[test]
320    fn provider_extension_missing_required_fields_fails() {
321        let mut extensions = BTreeMap::new();
322        extensions.insert(
323            PROVIDER_EXTENSION_ID.to_string(),
324            ExtensionRef {
325                kind: PROVIDER_EXTENSION_ID.into(),
326                version: "1.0.0".into(),
327                digest: None,
328                location: None,
329                inline: Some(
330                    serde_json::from_value(json!({
331                        "providers": [{
332                            "provider_type": "",
333                            "capabilities": [],
334                            "ops": ["send"],
335                            "config_schema_ref": "",
336                            "state_schema_ref": "schemas/state.json",
337                            "runtime": {
338                                "component_ref": "",
339                                "export": "",
340                                "world": "greentic:provider/schema-core@1.0.0"
341                            }
342                        }]
343                    }))
344                    .expect("inline parse"),
345                ),
346            },
347        );
348        assert!(
349            validate_extensions(Some(&extensions), false).is_err(),
350            "missing fields should fail validation"
351        );
352    }
353
354    #[test]
355    fn strict_mode_requires_digest_for_remote_extension() {
356        let mut extensions = BTreeMap::new();
357        extensions.insert(
358            "greentic.ext.provider".to_string(),
359            ExtensionRef {
360                kind: PROVIDER_EXTENSION_ID.into(),
361                version: "1.0.0".into(),
362                digest: None,
363                location: Some("oci://registry/extensions/provider".into()),
364                inline: None,
365            },
366        );
367        assert!(
368            validate_extensions(Some(&extensions), true).is_err(),
369            "strict mode should require digest when location is set"
370        );
371    }
372
373    #[test]
374    fn unknown_extensions_are_allowed() {
375        let mut extensions = BTreeMap::new();
376        extensions.insert(
377            "acme.ext.logging".to_string(),
378            ExtensionRef {
379                kind: "acme.ext.logging".into(),
380                version: "0.1.0".into(),
381                digest: None,
382                location: None,
383                inline: None,
384            },
385        );
386        validate_extensions(Some(&extensions), false).expect("unknown extensions should pass");
387    }
388}