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