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