1pub mod agent_wiring;
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use greentic_distributor_client::{
8 CachePolicy, DistClient, DistOptions, OciPackFetcher, PackFetchOptions, ResolvePolicy,
9 oci_packs::DefaultRegistryClient,
10};
11use serde::{Deserialize, Serialize};
12use tokio::runtime::Runtime;
13
14pub const WORKSPACE_ROOT_FILE: &str = "bundle.yaml";
15pub const LOCK_FILE: &str = "bundle.lock.json";
16pub const LOCK_SCHEMA_VERSION: u32 = 1;
17
18const DEFAULT_GMAP: &str = "_ = forbidden\n";
19const GREENTIC_GTPACK_TAR_MEDIA_TYPE: &str = "application/vnd.greentic.gtpack.layer.v1+tar";
20const GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE: &str =
21 "application/vnd.greentic.gtpack.layer.v1.tar+gzip";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct BundleWorkspaceDefinition {
25 #[serde(default = "default_schema_version")]
26 pub schema_version: u32,
27 pub bundle_id: String,
28 pub bundle_name: String,
29 #[serde(default = "default_locale")]
30 pub locale: String,
31 #[serde(default = "default_mode")]
32 pub mode: String,
33 #[serde(default)]
34 pub advanced_setup: bool,
35 #[serde(default)]
36 pub app_packs: Vec<String>,
37 #[serde(default)]
41 pub agent_packs: BTreeMap<String, String>,
42 #[serde(default)]
43 pub app_pack_mappings: Vec<AppPackMapping>,
44 #[serde(default)]
45 pub extension_providers: Vec<String>,
46 #[serde(default)]
47 pub remote_catalogs: Vec<String>,
48 #[serde(default)]
49 pub hooks: Vec<String>,
50 #[serde(default)]
51 pub subscriptions: Vec<String>,
52 #[serde(default)]
53 pub capabilities: Vec<String>,
54 #[serde(default)]
55 pub setup_execution_intent: bool,
56 #[serde(default)]
57 pub export_intent: bool,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct AppPackMapping {
62 pub reference: String,
63 pub scope: MappingScope,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub tenant: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub team: Option<String>,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum MappingScope {
73 Global,
74 Tenant,
75 Team,
76}
77
78#[derive(Debug, Serialize)]
79struct ResolvedManifest {
80 version: String,
81 tenant: String,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 team: Option<String>,
84 project_root: String,
85 bundle: BundleSummary,
86 policy: PolicySection,
87 catalogs: Vec<String>,
88 app_packs: Vec<ResolvedReferencePolicy>,
89 extension_providers: Vec<String>,
90 hooks: Vec<String>,
91 subscriptions: Vec<String>,
92 capabilities: Vec<String>,
93}
94
95#[derive(Debug, Serialize)]
96struct BundleSummary {
97 bundle_id: String,
98 bundle_name: String,
99 locale: String,
100 mode: String,
101 advanced_setup: bool,
102 setup_execution_intent: bool,
103 export_intent: bool,
104}
105
106#[derive(Debug, Serialize)]
107struct PolicySection {
108 source: PolicySource,
109 default: String,
110}
111
112#[derive(Debug, Serialize)]
113struct PolicySource {
114 tenant_gmap: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 team_gmap: Option<String>,
117}
118
119#[derive(Debug, Serialize)]
120struct ResolvedReferencePolicy {
121 reference: String,
122 policy: String,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct BundleLock {
127 pub schema_version: u32,
128 pub bundle_id: String,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub env_id: Option<String>,
136 pub requested_mode: String,
137 pub execution: String,
138 pub cache_policy: String,
139 pub tool_version: String,
140 pub build_format_version: String,
141 pub workspace_root: String,
142 pub lock_file: String,
143 pub catalogs: Vec<crate::catalog::resolve::CatalogLockEntry>,
144 pub app_packs: Vec<DependencyLock>,
145 pub extension_providers: Vec<DependencyLock>,
146 pub setup_state_files: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct DependencyLock {
151 pub reference: String,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub digest: Option<String>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ReferenceField {
158 AppPack,
159 ExtensionProvider,
160}
161
162impl BundleWorkspaceDefinition {
163 pub fn new(bundle_name: String, bundle_id: String, locale: String, mode: String) -> Self {
164 Self {
165 schema_version: default_schema_version(),
166 bundle_id,
167 bundle_name,
168 locale,
169 mode,
170 advanced_setup: false,
171 app_packs: Vec::new(),
172 agent_packs: BTreeMap::new(),
173 app_pack_mappings: Vec::new(),
174 extension_providers: Vec::new(),
175 remote_catalogs: Vec::new(),
176 hooks: Vec::new(),
177 subscriptions: Vec::new(),
178 capabilities: Vec::new(),
179 setup_execution_intent: false,
180 export_intent: false,
181 }
182 }
183
184 pub fn canonicalize(&mut self) {
185 canonicalize_mappings(&mut self.app_pack_mappings);
186 self.app_packs.extend(
187 self.app_pack_mappings
188 .iter()
189 .map(|entry| entry.reference.clone()),
190 );
191 sort_unique(&mut self.app_packs);
192 sort_unique(&mut self.extension_providers);
193 sort_unique(&mut self.remote_catalogs);
194 sort_unique(&mut self.hooks);
195 sort_unique(&mut self.subscriptions);
196 sort_unique(&mut self.capabilities);
197 }
198
199 pub fn references(&self, field: ReferenceField) -> &[String] {
200 match field {
201 ReferenceField::AppPack => &self.app_packs,
202 ReferenceField::ExtensionProvider => &self.extension_providers,
203 }
204 }
205
206 pub fn references_mut(&mut self, field: ReferenceField) -> &mut Vec<String> {
207 match field {
208 ReferenceField::AppPack => &mut self.app_packs,
209 ReferenceField::ExtensionProvider => &mut self.extension_providers,
210 }
211 }
212}
213
214pub fn ensure_layout(root: &Path) -> Result<()> {
215 ensure_dir(&root.join("tenants"))?;
216 ensure_dir(&root.join("tenants").join("default"))?;
217 ensure_dir(&root.join("tenants").join("default").join("teams"))?;
218 ensure_dir(&root.join("resolved"))?;
219 ensure_dir(&root.join("state").join("resolved"))?;
220 write_if_missing(&root.join(WORKSPACE_ROOT_FILE), "schema_version: 1\n")?;
221 write_if_missing(
222 &root.join("tenants").join("default").join("tenant.gmap"),
223 DEFAULT_GMAP,
224 )?;
225 Ok(())
226}
227
228pub const CAP_BUNDLE_ASSETS_READ_V1: &str = "greentic.cap.bundle_assets.read.v1";
230
231pub fn ensure_assets_dir(root: &Path) -> Result<()> {
233 ensure_dir(&root.join("assets"))
234}
235
236pub fn read_bundle_workspace(root: &Path) -> Result<BundleWorkspaceDefinition> {
237 let raw = std::fs::read_to_string(root.join(WORKSPACE_ROOT_FILE))?;
238 let mut definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&raw)?;
239 definition.canonicalize();
240 Ok(definition)
241}
242
243pub fn write_bundle_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
244 let mut workspace = workspace.clone();
245 workspace.canonicalize();
246 let path = root.join(WORKSPACE_ROOT_FILE);
247 if let Some(parent) = path.parent() {
248 ensure_dir(parent)?;
249 }
250 std::fs::write(path, render_bundle_workspace(&workspace))?;
251 Ok(())
252}
253
254pub fn init_bundle_workspace(
255 root: &Path,
256 workspace: &BundleWorkspaceDefinition,
257) -> Result<Vec<PathBuf>> {
258 ensure_layout(root)?;
259 let has_bundle_assets = workspace
260 .capabilities
261 .iter()
262 .any(|c| c == CAP_BUNDLE_ASSETS_READ_V1);
263 if has_bundle_assets {
264 ensure_assets_dir(root)?;
265 }
266 write_bundle_workspace(root, workspace)?;
267 let lock = empty_bundle_lock(workspace);
268 write_bundle_lock(root, &lock)?;
269 sync_project(root)?;
270 let mut files = vec![
271 root.join(WORKSPACE_ROOT_FILE),
272 root.join(LOCK_FILE),
273 root.join("tenants/default/tenant.gmap"),
274 root.join("resolved/default.yaml"),
275 root.join("state/resolved/default.yaml"),
276 ];
277 if has_bundle_assets {
278 files.push(root.join("assets"));
279 }
280 Ok(files)
281}
282
283pub fn sync_lock_with_workspace(root: &Path, workspace: &BundleWorkspaceDefinition) -> Result<()> {
284 let mut lock = if root.join(LOCK_FILE).exists() {
285 read_bundle_lock(root)?
286 } else {
287 empty_bundle_lock(workspace)
288 };
289 lock.bundle_id = workspace.bundle_id.clone();
290 lock.requested_mode = workspace.mode.clone();
291 lock.workspace_root = WORKSPACE_ROOT_FILE.to_string();
292 lock.lock_file = LOCK_FILE.to_string();
293 lock.app_packs = workspace
294 .app_packs
295 .iter()
296 .map(|reference| DependencyLock {
297 reference: reference.clone(),
298 digest: None,
299 })
300 .collect();
301 lock.extension_providers = workspace
302 .extension_providers
303 .iter()
304 .map(|reference| DependencyLock {
305 reference: reference.clone(),
306 digest: None,
307 })
308 .collect();
309 write_bundle_lock(root, &lock)
310}
311
312pub fn ensure_tenant(root: &Path, tenant: &str) -> Result<()> {
313 let tenant_dir = root.join("tenants").join(tenant);
314 ensure_dir(&tenant_dir.join("teams"))?;
315 write_if_missing(&tenant_dir.join("tenant.gmap"), DEFAULT_GMAP)?;
316 Ok(())
317}
318
319pub fn ensure_team(root: &Path, tenant: &str, team: &str) -> Result<()> {
320 ensure_tenant(root, tenant)?;
321 let team_dir = root.join("tenants").join(tenant).join("teams").join(team);
322 ensure_dir(&team_dir)?;
323 write_if_missing(&team_dir.join("team.gmap"), DEFAULT_GMAP)?;
324 Ok(())
325}
326
327pub fn gmap_path(root: &Path, target: &crate::access::GmapTarget) -> PathBuf {
328 if let Some(team) = &target.team {
329 root.join("tenants")
330 .join(&target.tenant)
331 .join("teams")
332 .join(team)
333 .join("team.gmap")
334 } else {
335 root.join("tenants")
336 .join(&target.tenant)
337 .join("tenant.gmap")
338 }
339}
340
341pub fn resolved_output_paths(root: &Path, tenant: &str, team: Option<&str>) -> Vec<PathBuf> {
342 let filename = match team {
343 Some(team) => format!("{tenant}.{team}.yaml"),
344 None => format!("{tenant}.yaml"),
345 };
346 vec![
347 root.join("resolved").join(&filename),
348 root.join("state").join("resolved").join(filename),
349 ]
350}
351
352pub fn sync_project(root: &Path) -> Result<()> {
353 sync_project_with_reference_roots(root, &[])
354}
355
356pub fn sync_project_with_reference_roots(root: &Path, reference_roots: &[PathBuf]) -> Result<()> {
357 ensure_layout(root)?;
358 if let Ok(workspace) = read_bundle_workspace(root) {
359 materialize_workspace_dependencies(root, &workspace, reference_roots)?;
360 }
361 for tenant in list_tenants(root)? {
362 let teams = list_teams(root, &tenant)?;
363 if teams.is_empty() {
364 let manifest = build_manifest(root, &tenant, None);
365 write_resolved_outputs(root, &tenant, None, &manifest)?;
366 } else {
367 let tenant_manifest = build_manifest(root, &tenant, None);
368 write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
369 for team in teams {
370 let manifest = build_manifest(root, &tenant, Some(&team));
371 write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
372 }
373 }
374 }
375 Ok(())
376}
377
378fn materialize_workspace_dependencies(
379 root: &Path,
380 workspace: &BundleWorkspaceDefinition,
381 reference_roots: &[PathBuf],
382) -> Result<()> {
383 let app_targets = app_pack_copy_targets(workspace);
384 let provider_targets: Vec<_> = workspace
385 .extension_providers
386 .iter()
387 .filter(|p| !should_skip_extension_provider_materialization(p))
388 .collect();
389 let total = app_targets.len() + provider_targets.len();
390 let mut current = 0usize;
391 let force_refresh = crate::runtime::refresh();
392
393 for mapping in &app_targets {
394 current += 1;
395 let dest = root.join(&mapping.destination);
396 if dest.exists() {
397 if force_refresh {
398 eprintln!(
399 " [{current}/{total}] Refreshing app pack: {}",
400 mapping.reference
401 );
402 } else {
403 eprintln!(
404 " [{current}/{total}] Reused (local file exists): {}",
405 mapping.reference
406 );
407 }
408 } else {
409 eprintln!(
410 " [{current}/{total}] Resolving app pack: {}",
411 mapping.reference
412 );
413 }
414 materialize_reference_into(
415 root,
416 reference_roots,
417 &mapping.reference,
418 &mapping.destination,
419 )?;
420 }
421 for provider in &provider_targets {
422 current += 1;
423 let destination = provider_destination_path(provider);
424 let dest = root.join(&destination);
425 if dest.exists() {
426 if force_refresh {
427 eprintln!(" [{current}/{total}] Refreshing provider: {provider}");
428 } else {
429 eprintln!(" [{current}/{total}] Reused (local file exists): {provider}");
430 }
431 } else {
432 eprintln!(" [{current}/{total}] Resolving provider: {provider}");
433 }
434 materialize_reference_into(root, reference_roots, provider, &destination)?;
435 }
436 if total > 0 {
437 eprintln!(" [done] Resolved {total} package(s)");
438 }
439
440 run_agent_pack_auto_wiring(root, workspace, &app_targets)?;
445
446 Ok(())
447}
448
449fn read_gtpack_entry(pack_path: &Path, entry_name: &str) -> Option<Vec<u8>> {
454 use std::io::Read;
455 let file = std::fs::File::open(pack_path).ok()?;
456 let mut archive = zip::ZipArchive::new(file).ok()?;
457 let mut entry = archive.by_name(entry_name).ok()?;
458 let mut buf = Vec::new();
459 entry.read_to_end(&mut buf).ok()?;
460 Some(buf)
461}
462
463fn run_agent_pack_auto_wiring(
466 root: &Path,
467 workspace: &BundleWorkspaceDefinition,
468 app_targets: &[MaterializedCopyTarget],
469) -> Result<()> {
470 if workspace.agent_packs.is_empty() {
473 return Ok(());
474 }
475
476 let mut flow_manifests: Vec<Vec<u8>> = Vec::new();
477 let mut provided_sidecars: Vec<Vec<u8>> = Vec::new();
478
479 for target in app_targets {
480 let pack_path = root.join(&target.destination);
481 if !pack_path.exists() {
482 continue;
483 }
484 if let Some(cbor) = read_gtpack_entry(&pack_path, "manifest.cbor") {
485 flow_manifests.push(cbor);
486 }
487 if let Some(sidecar) = read_gtpack_entry(&pack_path, "dw-agents.json") {
488 provided_sidecars.push(sidecar);
489 }
490 }
491
492 let manifest_refs: Vec<&[u8]> = flow_manifests.iter().map(Vec::as_slice).collect();
493 let sidecar_refs: Vec<&[u8]> = provided_sidecars.iter().map(Vec::as_slice).collect();
494
495 let packs_dir = root.join("packs");
496 let cache_dir = root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts");
497 let trust = greentic_distributor_client::signing::TrustRoot::default();
505
506 let materialized = agent_wiring::auto_wire_agent_packs(
507 workspace,
508 &manifest_refs,
509 &sidecar_refs,
510 &packs_dir,
511 &cache_dir,
512 crate::runtime::offline(),
513 &trust,
514 )?;
515
516 if !materialized.is_empty() {
517 eprintln!(
518 " [agent-packs] Auto-wired {} agent pack(s): {}",
519 materialized.len(),
520 materialized.join(", ")
521 );
522 }
523 Ok(())
524}
525
526fn should_skip_extension_provider_materialization(reference: &str) -> bool {
527 bundled_catalog_mode()
528 && (reference.starts_with("oci://")
529 || reference.starts_with("repo://")
530 || reference.starts_with("store://")
531 || reference.starts_with("https://"))
532}
533
534fn bundled_catalog_mode() -> bool {
535 std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
536 .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
537 .unwrap_or(false)
538}
539
540struct MaterializedCopyTarget {
541 reference: String,
542 destination: PathBuf,
543}
544
545fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
546 if workspace.app_pack_mappings.is_empty() {
547 return workspace
548 .app_packs
549 .iter()
550 .map(|reference| MaterializedCopyTarget {
551 reference: reference.clone(),
552 destination: PathBuf::from("packs")
553 .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
554 })
555 .collect();
556 }
557
558 workspace
559 .app_pack_mappings
560 .iter()
561 .map(|mapping| {
562 let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
563 let destination = match mapping.scope {
564 MappingScope::Global => PathBuf::from("packs").join(filename),
565 MappingScope::Tenant => PathBuf::from("tenants")
566 .join(mapping.tenant.as_deref().unwrap_or("default"))
567 .join("packs")
568 .join(filename),
569 MappingScope::Team => PathBuf::from("tenants")
570 .join(mapping.tenant.as_deref().unwrap_or("default"))
571 .join("teams")
572 .join(mapping.team.as_deref().unwrap_or("default"))
573 .join("packs")
574 .join(filename),
575 };
576 MaterializedCopyTarget {
577 reference: mapping.reference.clone(),
578 destination,
579 }
580 })
581 .collect()
582}
583
584fn provider_destination_path(reference: &str) -> PathBuf {
585 let provider_type = inferred_provider_type(reference);
586 let provider_name = inferred_provider_filename(reference);
587 PathBuf::from("providers")
588 .join(provider_type)
589 .join(format!("{provider_name}.gtpack"))
590}
591
592fn materialize_reference_into(
593 root: &Path,
594 reference_roots: &[PathBuf],
595 reference: &str,
596 relative_destination: &Path,
597) -> Result<()> {
598 let destination = root.join(relative_destination);
599 if destination.exists() {
600 if !crate::runtime::refresh() {
601 return Ok(());
602 }
603 std::fs::remove_file(&destination)
604 .with_context(|| format!("remove existing {} before refresh", destination.display()))?;
605 }
606 if let Some(parent) = destination.parent() {
607 ensure_dir(parent)?;
608 }
609
610 if let Some(local_path) = parse_local_pack_reference(root, reference_roots, reference) {
611 if local_path.is_dir() {
612 return Ok(());
613 }
614 std::fs::copy(&local_path, &destination).with_context(|| {
615 format!("copy {} to {}", local_path.display(), destination.display())
616 })?;
617 return Ok(());
618 }
619
620 if !(reference.starts_with("oci://")
621 || reference.starts_with("repo://")
622 || reference.starts_with("store://")
623 || reference.starts_with("https://"))
624 {
625 return Ok(());
626 }
627
628 let path = resolve_remote_pack_path(root, reference)?;
629 std::fs::copy(&path, &destination)
630 .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
631
632 Ok(())
633}
634
635fn parse_local_pack_reference(
636 root: &Path,
637 reference_roots: &[PathBuf],
638 reference: &str,
639) -> Option<PathBuf> {
640 if let Some(path) = reference.strip_prefix("file://") {
641 let path = PathBuf::from(path.trim());
642 if path.is_absolute() {
643 return path.exists().then_some(path);
644 }
645 for base in reference_roots
646 .iter()
647 .map(PathBuf::as_path)
648 .chain(std::iter::once(root))
649 {
650 let candidate = base.join(&path);
651 if candidate.exists() {
652 return Some(candidate);
653 }
654 }
655 return None;
656 }
657 if reference.contains("://") {
658 return None;
659 }
660 let candidate = PathBuf::from(reference);
661 if candidate.is_absolute() {
662 return candidate.exists().then_some(candidate);
663 }
664 for base in reference_roots
665 .iter()
666 .map(PathBuf::as_path)
667 .chain(std::iter::once(root))
668 {
669 let joined = base.join(&candidate);
670 if joined.exists() {
671 return Some(joined);
672 }
673 }
674 None
675}
676
677fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
678 if let Some(oci_reference) = reference.strip_prefix("oci://") {
679 let mut options = PackFetchOptions {
680 allow_tags: true,
681 offline: crate::runtime::offline(),
682 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
683 ..PackFetchOptions::default()
684 };
685 options.accepted_layer_media_types.extend([
686 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
687 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
688 ]);
689 options.preferred_layer_media_types.splice(
690 0..0,
691 [
692 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
693 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
694 ],
695 );
696 let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
697 let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
698 let resolved = runtime
699 .block_on(fetcher.fetch_pack_to_cache(oci_reference))
700 .with_context(|| format!("resolve OCI pack ref {reference}"))?;
701 return Ok(resolved.path);
702 }
703
704 let options = DistOptions {
705 allow_tags: true,
706 offline: crate::runtime::offline(),
707 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
708 ..DistOptions::default()
709 };
710 let client = DistClient::new(options);
711 let runtime = Runtime::new().context("create artifact resolver runtime")?;
712 let source = client
713 .parse_source(reference)
714 .with_context(|| format!("parse artifact ref {reference}"))?;
715 let descriptor = runtime
716 .block_on(client.resolve(source, ResolvePolicy))
717 .with_context(|| format!("resolve artifact ref {reference}"))?;
718 let resolved = runtime
719 .block_on(client.fetch(&descriptor, CachePolicy))
720 .with_context(|| format!("fetch artifact ref {reference}"))?;
721 if let Some(path) = resolved.wasm_path {
722 return Ok(path);
723 }
724 if let Some(bytes) = resolved.wasm_bytes {
725 let digest = resolved.resolved_digest.trim_start_matches("sha256:");
726 let temp_path = root
727 .join(crate::catalog::CACHE_ROOT_DIR)
728 .join("artifacts")
729 .join("inline")
730 .join(format!("{digest}.gtpack"));
731 if let Some(parent) = temp_path.parent() {
732 ensure_dir(parent)?;
733 }
734 std::fs::write(&temp_path, bytes)
735 .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
736 return Ok(temp_path);
737 }
738 anyhow::bail!("artifact ref {reference} resolved without file payload");
739}
740
741pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
742 let tenants_dir = root.join("tenants");
743 let mut tenants = Vec::new();
744 if !tenants_dir.exists() {
745 return Ok(tenants);
746 }
747 for entry in std::fs::read_dir(tenants_dir)? {
748 let entry = entry?;
749 if entry.file_type()?.is_dir() {
750 tenants.push(entry.file_name().to_string_lossy().to_string());
751 }
752 }
753 tenants.sort();
754 Ok(tenants)
755}
756
757pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
758 let teams_dir = root.join("tenants").join(tenant).join("teams");
759 let mut teams = Vec::new();
760 if !teams_dir.exists() {
761 return Ok(teams);
762 }
763 for entry in std::fs::read_dir(teams_dir)? {
764 let entry = entry?;
765 if entry.file_type()?.is_dir() {
766 teams.push(entry.file_name().to_string_lossy().to_string());
767 }
768 }
769 teams.sort();
770 Ok(teams)
771}
772
773pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
774 let path = root.join(LOCK_FILE);
775 if let Some(parent) = path.parent() {
776 ensure_dir(parent)?;
777 }
778 std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
779 Ok(())
780}
781
782pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
783 let path = root.join(LOCK_FILE);
784 let raw = std::fs::read_to_string(&path)?;
785 Ok(serde_json::from_str(&raw)?)
786}
787
788fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
789 let workspace = read_workspace_or_default(root);
790 let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
791 let team_gmap = team.map(|team| {
792 relative_path(
793 root,
794 &root
795 .join("tenants")
796 .join(tenant)
797 .join("teams")
798 .join(team)
799 .join("team.gmap"),
800 )
801 });
802
803 let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
804
805 ResolvedManifest {
806 version: "1".to_string(),
807 tenant: tenant.to_string(),
808 team: team.map(ToOwned::to_owned),
809 project_root: root.display().to_string(),
810 bundle: BundleSummary {
811 bundle_id: workspace.bundle_id,
812 bundle_name: workspace.bundle_name,
813 locale: workspace.locale,
814 mode: workspace.mode,
815 advanced_setup: workspace.advanced_setup,
816 setup_execution_intent: workspace.setup_execution_intent,
817 export_intent: workspace.export_intent,
818 },
819 policy: PolicySection {
820 source: PolicySource {
821 tenant_gmap,
822 team_gmap,
823 },
824 default: "forbidden".to_string(),
825 },
826 catalogs: workspace.remote_catalogs,
827 app_packs,
828 extension_providers: workspace.extension_providers,
829 hooks: workspace.hooks,
830 subscriptions: workspace.subscriptions,
831 capabilities: workspace.capabilities,
832 }
833}
834
835fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
836 format!(
839 concat!(
840 "schema_version: {}\n",
841 "bundle_id: {}\n",
842 "bundle_name: {}\n",
843 "locale: {}\n",
844 "mode: {}\n",
845 "advanced_setup: {}\n",
846 "agent_packs:{}\n",
847 "app_packs:{}\n",
848 "app_pack_mappings:{}\n",
849 "extension_providers:{}\n",
850 "remote_catalogs:{}\n",
851 "hooks:{}\n",
852 "subscriptions:{}\n",
853 "capabilities:{}\n",
854 "setup_execution_intent: {}\n",
855 "export_intent: {}\n"
856 ),
857 workspace.schema_version,
858 workspace.bundle_id,
859 workspace.bundle_name,
860 workspace.locale,
861 workspace.mode,
862 workspace.advanced_setup,
863 yaml_sorted_string_map(&workspace.agent_packs),
864 yaml_list(&workspace.app_packs),
865 yaml_mapping_list(&workspace.app_pack_mappings),
866 yaml_list(&workspace.extension_providers),
867 yaml_list(&workspace.remote_catalogs),
868 yaml_list(&workspace.hooks),
869 yaml_list(&workspace.subscriptions),
870 yaml_list(&workspace.capabilities),
871 workspace.setup_execution_intent,
872 workspace.export_intent
873 )
874}
875
876fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
877 if values.is_empty() {
878 " []".to_string()
879 } else {
880 values
881 .iter()
882 .map(|value| {
883 let mut out = format!(
884 "\n - reference: {}\n scope: {}",
885 value.reference,
886 match value.scope {
887 MappingScope::Global => "global",
888 MappingScope::Tenant => "tenant",
889 MappingScope::Team => "team",
890 }
891 );
892 if let Some(tenant) = &value.tenant {
893 out.push_str(&format!("\n tenant: {tenant}"));
894 }
895 if let Some(team) = &value.team {
896 out.push_str(&format!("\n team: {team}"));
897 }
898 out
899 })
900 .collect::<String>()
901 }
902}
903
904fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
905 BundleLock {
906 schema_version: LOCK_SCHEMA_VERSION,
907 bundle_id: workspace.bundle_id.clone(),
908 env_id: None,
909 requested_mode: workspace.mode.clone(),
910 execution: "execute".to_string(),
911 cache_policy: "workspace-local".to_string(),
912 tool_version: env!("CARGO_PKG_VERSION").to_string(),
913 build_format_version: "bundle-lock-v1".to_string(),
914 workspace_root: WORKSPACE_ROOT_FILE.to_string(),
915 lock_file: LOCK_FILE.to_string(),
916 catalogs: Vec::new(),
917 app_packs: workspace
918 .app_packs
919 .iter()
920 .map(|reference| DependencyLock {
921 reference: reference.clone(),
922 digest: None,
923 })
924 .collect(),
925 extension_providers: workspace
926 .extension_providers
927 .iter()
928 .map(|reference| DependencyLock {
929 reference: reference.clone(),
930 digest: None,
931 })
932 .collect(),
933 setup_state_files: Vec::new(),
934 }
935}
936
937fn yaml_list(values: &[String]) -> String {
938 if values.is_empty() {
939 " []".to_string()
940 } else {
941 values
942 .iter()
943 .map(|value| format!("\n - {value}"))
944 .collect::<String>()
945 }
946}
947
948fn yaml_sorted_string_map(map: &BTreeMap<String, String>) -> String {
955 if map.is_empty() {
956 return " {}".to_string();
957 }
958 map.iter()
959 .map(|(key, value)| format!("\n {key}: \"{value}\""))
960 .collect()
961}
962
963fn sort_unique(values: &mut Vec<String>) {
964 values.retain(|value| !value.trim().is_empty());
965 values.sort();
966 values.dedup();
967}
968
969fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
970 values.retain(|value| !value.reference.trim().is_empty());
971 for value in values.iter_mut() {
972 if value
973 .tenant
974 .as_deref()
975 .is_some_and(|tenant| tenant.trim().is_empty())
976 {
977 value.tenant = None;
978 }
979 if value
980 .team
981 .as_deref()
982 .is_some_and(|team| team.trim().is_empty())
983 {
984 value.team = None;
985 }
986 if matches!(value.scope, MappingScope::Global) {
987 value.tenant = None;
988 value.team = None;
989 } else if matches!(value.scope, MappingScope::Tenant) {
990 value.team = None;
991 }
992 }
993 values.sort_by(|left, right| {
994 left.reference
995 .cmp(&right.reference)
996 .then(left.scope.cmp(&right.scope))
997 .then(left.tenant.cmp(&right.tenant))
998 .then(left.team.cmp(&right.team))
999 });
1000 values.dedup_by(|left, right| {
1001 left.reference == right.reference
1002 && left.scope == right.scope
1003 && left.tenant == right.tenant
1004 && left.team == right.team
1005 });
1006}
1007
1008fn default_schema_version() -> u32 {
1009 1
1010}
1011
1012fn default_locale() -> String {
1013 "en".to_string()
1014}
1015
1016fn default_mode() -> String {
1017 "create".to_string()
1018}
1019
1020fn write_resolved_outputs(
1021 root: &Path,
1022 tenant: &str,
1023 team: Option<&str>,
1024 manifest: &ResolvedManifest,
1025) -> Result<()> {
1026 let yaml = render_manifest_yaml(manifest);
1027 for output in resolved_output_paths(root, tenant, team) {
1028 if let Some(parent) = output.parent() {
1029 ensure_dir(parent)?;
1030 }
1031 std::fs::write(output, &yaml)?;
1032 }
1033 Ok(())
1034}
1035
1036fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
1037 let mut lines = vec![
1038 format!("version: {}", manifest.version),
1039 format!("tenant: {}", manifest.tenant),
1040 ];
1041 if let Some(team) = &manifest.team {
1042 lines.push(format!("team: {}", team));
1043 }
1044 lines.extend([
1045 format!("project_root: {}", manifest.project_root),
1046 "bundle:".to_string(),
1047 format!(" bundle_id: {}", manifest.bundle.bundle_id),
1048 format!(" bundle_name: {}", manifest.bundle.bundle_name),
1049 format!(" locale: {}", manifest.bundle.locale),
1050 format!(" mode: {}", manifest.bundle.mode),
1051 format!(" advanced_setup: {}", manifest.bundle.advanced_setup),
1052 format!(
1053 " setup_execution_intent: {}",
1054 manifest.bundle.setup_execution_intent
1055 ),
1056 format!(" export_intent: {}", manifest.bundle.export_intent),
1057 "policy:".to_string(),
1058 " source:".to_string(),
1059 format!(" tenant_gmap: {}", manifest.policy.source.tenant_gmap),
1060 ]);
1061 if let Some(team_gmap) = &manifest.policy.source.team_gmap {
1062 lines.push(format!(" team_gmap: {}", team_gmap));
1063 }
1064 lines.push(format!(" default: {}", manifest.policy.default));
1065 lines.push("catalogs:".to_string());
1066 lines.extend(render_yaml_list(" ", &manifest.catalogs));
1067 lines.push("app_packs:".to_string());
1068 if manifest.app_packs.is_empty() {
1069 lines.push(" []".to_string());
1070 } else {
1071 for entry in &manifest.app_packs {
1072 lines.push(format!(" - reference: {}", entry.reference));
1073 lines.push(format!(" policy: {}", entry.policy));
1074 }
1075 }
1076 lines.push("extension_providers:".to_string());
1077 lines.extend(render_yaml_list(" ", &manifest.extension_providers));
1078 lines.push("hooks:".to_string());
1079 lines.extend(render_yaml_list(" ", &manifest.hooks));
1080 lines.push("subscriptions:".to_string());
1081 lines.extend(render_yaml_list(" ", &manifest.subscriptions));
1082 lines.push("capabilities:".to_string());
1083 lines.extend(render_yaml_list(" ", &manifest.capabilities));
1084 format!("{}\n", lines.join("\n"))
1085}
1086
1087fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
1088 read_bundle_workspace(root).unwrap_or_else(|_| {
1089 let bundle_id = root
1090 .file_name()
1091 .and_then(|value| value.to_str())
1092 .map(ToOwned::to_owned)
1093 .filter(|value| !value.trim().is_empty())
1094 .unwrap_or_else(|| "bundle".to_string());
1095 BundleWorkspaceDefinition::new(
1096 bundle_id.clone(),
1097 bundle_id,
1098 default_locale(),
1099 default_mode(),
1100 )
1101 })
1102}
1103
1104fn evaluate_app_pack_policies(
1105 root: &Path,
1106 tenant: &str,
1107 team: Option<&str>,
1108 app_packs: &[String],
1109) -> Vec<ResolvedReferencePolicy> {
1110 let tenant_rules =
1111 crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
1112 .unwrap_or_default();
1113 let team_rules = team
1114 .and_then(|team_name| {
1115 crate::access::parse_file(
1116 &root
1117 .join("tenants")
1118 .join(tenant)
1119 .join("teams")
1120 .join(team_name)
1121 .join("team.gmap"),
1122 )
1123 .ok()
1124 })
1125 .unwrap_or_default();
1126
1127 let mut entries = app_packs
1128 .iter()
1129 .map(|reference| {
1130 let target = crate::access::GmapPath {
1131 pack: Some(inferred_access_pack_id(reference)),
1132 flow: None,
1133 node: None,
1134 };
1135 let policy = if team.is_some() {
1136 crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
1137 } else {
1138 crate::access::eval_policy(&tenant_rules, &target)
1139 };
1140 ResolvedReferencePolicy {
1141 reference: reference.clone(),
1142 policy: policy
1143 .map(|decision| decision.policy.to_string())
1144 .unwrap_or_else(|| "unset".to_string()),
1145 }
1146 })
1147 .collect::<Vec<_>>();
1148 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1149 entries
1150}
1151
1152fn inferred_access_pack_id(reference: &str) -> String {
1153 let cleaned = reference
1154 .trim_end_matches('/')
1155 .rsplit('/')
1156 .next()
1157 .unwrap_or(reference)
1158 .split('@')
1159 .next()
1160 .unwrap_or(reference)
1161 .split(':')
1162 .next()
1163 .unwrap_or(reference)
1164 .trim_end_matches(".json")
1165 .trim_end_matches(".gtpack")
1166 .trim_end_matches(".yaml")
1167 .trim_end_matches(".yml");
1168 let mut normalized = String::with_capacity(cleaned.len());
1169 let mut last_dash = false;
1170 for ch in cleaned.chars() {
1171 let out = if ch.is_ascii_alphanumeric() {
1172 last_dash = false;
1173 ch.to_ascii_lowercase()
1174 } else if last_dash {
1175 continue;
1176 } else {
1177 last_dash = true;
1178 '-'
1179 };
1180 normalized.push(out);
1181 }
1182 normalized.trim_matches('-').to_string()
1183}
1184
1185fn inferred_provider_type(reference: &str) -> String {
1186 let raw = reference.trim();
1187 for marker in ["/providers/", "/packs/"] {
1188 if let Some((_, rest)) = raw.split_once(marker)
1189 && let Some(segment) = rest.split('/').next()
1190 && !segment.is_empty()
1191 {
1192 return segment.to_string();
1193 }
1194 }
1195
1196 let inferred = inferred_access_pack_id(reference);
1197 let mut parts = inferred.split('-');
1198 match (parts.next(), parts.next()) {
1199 (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1200 (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1201 (Some(_domain), None) => "other".to_string(),
1202 _ => "other".to_string(),
1203 }
1204}
1205
1206fn inferred_provider_filename(reference: &str) -> String {
1207 let cleaned = reference
1208 .trim_end_matches('/')
1209 .rsplit('/')
1210 .next()
1211 .unwrap_or(reference)
1212 .split('@')
1213 .next()
1214 .unwrap_or(reference)
1215 .split(':')
1216 .next()
1217 .unwrap_or(reference)
1218 .trim_end_matches(".gtpack");
1219 if let Some(deployer_target) = cleaned.strip_prefix("greentic.deploy.")
1220 && !deployer_target.trim().is_empty()
1221 {
1222 return deployer_target.trim().to_string();
1223 }
1224 if cleaned.is_empty() {
1225 inferred_access_pack_id(reference)
1226 } else {
1227 cleaned.to_string()
1228 }
1229}
1230
1231fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1232 if values.is_empty() {
1233 vec![format!("{indent}[]")]
1234 } else {
1235 values
1236 .iter()
1237 .map(|value| format!("{indent}- {value}"))
1238 .collect()
1239 }
1240}
1241
1242fn relative_path(root: &Path, path: &Path) -> String {
1243 path.strip_prefix(root)
1244 .unwrap_or(path)
1245 .display()
1246 .to_string()
1247}
1248
1249fn ensure_dir(path: &Path) -> Result<()> {
1250 std::fs::create_dir_all(path)?;
1251 Ok(())
1252}
1253
1254fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1255 if path.exists() {
1256 return Ok(());
1257 }
1258 if let Some(parent) = path.parent() {
1259 ensure_dir(parent)?;
1260 }
1261 std::fs::write(path, contents)?;
1262 Ok(())
1263}
1264
1265pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1271 let mut written = Vec::new();
1272 let providers_dir = root.join("providers");
1273 if !providers_dir.is_dir() {
1274 return Ok(written);
1275 }
1276 for dir_entry in collect_gtpack_files(&providers_dir)? {
1277 match extract_pack_assets(root, &dir_entry) {
1278 Ok(paths) => written.extend(paths),
1279 Err(err) => {
1280 eprintln!(
1281 "Warning: could not scaffold assets from {}: {err}",
1282 dir_entry.display()
1283 );
1284 }
1285 }
1286 }
1287 Ok(written)
1288}
1289
1290fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1291 let mut files = Vec::new();
1292 for entry in std::fs::read_dir(dir)? {
1293 let entry = entry?;
1294 let path = entry.path();
1295 if path.is_dir() {
1296 files.extend(collect_gtpack_files(&path)?);
1297 } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1298 files.push(path);
1299 }
1300 }
1301 Ok(files)
1302}
1303
1304fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1305 let file =
1306 std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1307 let mut archive =
1308 zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1309 let mut written = Vec::new();
1310 for i in 0..archive.len() {
1311 let mut entry = archive.by_index(i)?;
1312 let name = entry.name().to_string();
1313 if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1314 continue;
1315 }
1316 let target = root.join(&name);
1317 if target.exists() {
1318 continue;
1319 }
1320 if let Some(parent) = target.parent() {
1321 std::fs::create_dir_all(parent)?;
1322 }
1323 let mut out = std::fs::File::create(&target)?;
1324 std::io::copy(&mut entry, &mut out)?;
1325 written.push(target);
1326 }
1327 Ok(written)
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use std::path::PathBuf;
1333
1334 use super::BundleWorkspaceDefinition;
1335 use super::{provider_destination_path, should_skip_extension_provider_materialization};
1336
1337 #[test]
1338 fn agent_packs_parses_into_map() {
1339 let raw = concat!(
1340 "schema_version: 1\n",
1341 "bundle_id: demo\n",
1342 "bundle_name: Demo Bundle\n",
1343 "agent_packs:\n",
1344 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1345 );
1346 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1347 .expect("config with agent_packs should parse");
1348 assert_eq!(
1349 definition
1350 .agent_packs
1351 .get("tavily_researcher")
1352 .map(String::as_str),
1353 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1354 );
1355 }
1356
1357 #[test]
1358 fn agent_packs_defaults_to_empty_map() {
1359 let raw = concat!(
1360 "schema_version: 1\n",
1361 "bundle_id: demo\n",
1362 "bundle_name: Demo Bundle\n",
1363 );
1364 let definition = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw)
1365 .expect("config without agent_packs should parse");
1366 assert!(definition.agent_packs.is_empty());
1367 }
1368
1369 #[test]
1370 fn bundled_catalog_mode_skips_https_provider_materialization() {
1371 unsafe {
1372 std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1373 }
1374 assert!(should_skip_extension_provider_materialization(
1375 "https://example.com/providers/events-webhook.gtpack"
1376 ));
1377 unsafe {
1378 std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1379 }
1380 }
1381
1382 #[test]
1383 fn deployer_provider_destination_uses_canonical_filename() {
1384 assert_eq!(
1385 provider_destination_path(
1386 "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
1387 ),
1388 PathBuf::from("providers/deployer/aws.gtpack")
1389 );
1390 }
1391
1392 #[test]
1397 fn agent_packs_round_trips_through_render_bundle_workspace() {
1398 use super::render_bundle_workspace;
1399
1400 let raw = concat!(
1401 "schema_version: 1\n",
1402 "bundle_id: demo\n",
1403 "bundle_name: Demo Bundle\n",
1404 "agent_packs:\n",
1405 " crm_assistant: \"store://greentic.crm-assistant@1.2.0\"\n",
1406 " tavily_researcher: \"store://greentic.agentic-research-tavily-agent@0.1.0\"\n",
1407 );
1408 let original =
1409 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1410
1411 let rendered = render_bundle_workspace(&original);
1413 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1414 .expect("re-parse after render");
1415
1416 assert_eq!(
1418 round_tripped
1419 .agent_packs
1420 .get("tavily_researcher")
1421 .map(String::as_str),
1422 Some("store://greentic.agentic-research-tavily-agent@0.1.0"),
1423 "tavily_researcher must survive the render round-trip"
1424 );
1425 assert_eq!(
1426 round_tripped
1427 .agent_packs
1428 .get("crm_assistant")
1429 .map(String::as_str),
1430 Some("store://greentic.crm-assistant@1.2.0"),
1431 "crm_assistant must survive the render round-trip"
1432 );
1433 assert_eq!(
1434 round_tripped.agent_packs.len(),
1435 2,
1436 "no extra entries should appear after round-trip"
1437 );
1438 }
1439
1440 #[test]
1442 fn empty_agent_packs_round_trips_as_empty_map() {
1443 use super::render_bundle_workspace;
1444
1445 let raw = concat!(
1446 "schema_version: 1\n",
1447 "bundle_id: demo\n",
1448 "bundle_name: Demo Bundle\n",
1449 );
1450 let original =
1451 serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(raw).expect("parse original");
1452 assert!(original.agent_packs.is_empty());
1453
1454 let rendered = render_bundle_workspace(&original);
1455 let round_tripped = serde_yaml_bw::from_str::<BundleWorkspaceDefinition>(&rendered)
1456 .expect("re-parse after render");
1457
1458 assert!(
1459 round_tripped.agent_packs.is_empty(),
1460 "empty agent_packs must survive the render round-trip"
1461 );
1462 }
1463}