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