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