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)]
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 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 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}