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 ensure_layout(root)?;
338 if let Ok(workspace) = read_bundle_workspace(root) {
339 materialize_workspace_dependencies(root, &workspace)?;
340 }
341 for tenant in list_tenants(root)? {
342 let teams = list_teams(root, &tenant)?;
343 if teams.is_empty() {
344 let manifest = build_manifest(root, &tenant, None);
345 write_resolved_outputs(root, &tenant, None, &manifest)?;
346 } else {
347 let tenant_manifest = build_manifest(root, &tenant, None);
348 write_resolved_outputs(root, &tenant, None, &tenant_manifest)?;
349 for team in teams {
350 let manifest = build_manifest(root, &tenant, Some(&team));
351 write_resolved_outputs(root, &tenant, Some(&team), &manifest)?;
352 }
353 }
354 }
355 Ok(())
356}
357
358fn materialize_workspace_dependencies(
359 root: &Path,
360 workspace: &BundleWorkspaceDefinition,
361) -> Result<()> {
362 let app_targets = app_pack_copy_targets(workspace);
363 let provider_targets: Vec<_> = workspace
364 .extension_providers
365 .iter()
366 .filter(|p| !should_skip_extension_provider_materialization(p))
367 .collect();
368 let total = app_targets.len() + provider_targets.len();
369 let mut current = 0usize;
370
371 for mapping in &app_targets {
372 current += 1;
373 let dest = root.join(&mapping.destination);
374 if dest.exists() {
375 eprintln!(" [{current}/{total}] Cached: {}", mapping.reference);
376 } else {
377 eprintln!(
378 " [{current}/{total}] Resolving app pack: {}",
379 mapping.reference
380 );
381 }
382 materialize_reference_into(root, &mapping.reference, &mapping.destination)?;
383 }
384 for provider in &provider_targets {
385 current += 1;
386 let destination = provider_destination_path(provider);
387 let dest = root.join(&destination);
388 if dest.exists() {
389 eprintln!(" [{current}/{total}] Cached: {provider}");
390 } else {
391 eprintln!(" [{current}/{total}] Resolving provider: {provider}");
392 }
393 materialize_reference_into(root, provider, &destination)?;
394 }
395 if total > 0 {
396 eprintln!(" [done] Resolved {total} package(s)");
397 }
398 Ok(())
399}
400
401fn should_skip_extension_provider_materialization(reference: &str) -> bool {
402 bundled_catalog_mode()
403 && (reference.starts_with("oci://")
404 || reference.starts_with("repo://")
405 || reference.starts_with("store://")
406 || reference.starts_with("https://"))
407}
408
409fn bundled_catalog_mode() -> bool {
410 std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
411 .map(|value| value == "1" || value.eq_ignore_ascii_case("true"))
412 .unwrap_or(false)
413}
414
415struct MaterializedCopyTarget {
416 reference: String,
417 destination: PathBuf,
418}
419
420fn app_pack_copy_targets(workspace: &BundleWorkspaceDefinition) -> Vec<MaterializedCopyTarget> {
421 if workspace.app_pack_mappings.is_empty() {
422 return workspace
423 .app_packs
424 .iter()
425 .map(|reference| MaterializedCopyTarget {
426 reference: reference.clone(),
427 destination: PathBuf::from("packs")
428 .join(format!("{}.gtpack", inferred_access_pack_id(reference))),
429 })
430 .collect();
431 }
432
433 workspace
434 .app_pack_mappings
435 .iter()
436 .map(|mapping| {
437 let filename = format!("{}.gtpack", inferred_access_pack_id(&mapping.reference));
438 let destination = match mapping.scope {
439 MappingScope::Global => PathBuf::from("packs").join(filename),
440 MappingScope::Tenant => PathBuf::from("tenants")
441 .join(mapping.tenant.as_deref().unwrap_or("default"))
442 .join("packs")
443 .join(filename),
444 MappingScope::Team => PathBuf::from("tenants")
445 .join(mapping.tenant.as_deref().unwrap_or("default"))
446 .join("teams")
447 .join(mapping.team.as_deref().unwrap_or("default"))
448 .join("packs")
449 .join(filename),
450 };
451 MaterializedCopyTarget {
452 reference: mapping.reference.clone(),
453 destination,
454 }
455 })
456 .collect()
457}
458
459fn provider_destination_path(reference: &str) -> PathBuf {
460 let provider_type = inferred_provider_type(reference);
461 let provider_name = inferred_provider_filename(reference);
462 PathBuf::from("providers")
463 .join(provider_type)
464 .join(format!("{provider_name}.gtpack"))
465}
466
467fn materialize_reference_into(
468 root: &Path,
469 reference: &str,
470 relative_destination: &Path,
471) -> Result<()> {
472 let destination = root.join(relative_destination);
473 if destination.exists() {
474 return Ok(());
475 }
476 if let Some(parent) = destination.parent() {
477 ensure_dir(parent)?;
478 }
479
480 if let Some(local_path) = parse_local_pack_reference(root, reference) {
481 if local_path.is_dir() {
482 return Ok(());
483 }
484 std::fs::copy(&local_path, &destination).with_context(|| {
485 format!("copy {} to {}", local_path.display(), destination.display())
486 })?;
487 return Ok(());
488 }
489
490 if !(reference.starts_with("oci://")
491 || reference.starts_with("repo://")
492 || reference.starts_with("store://")
493 || reference.starts_with("https://"))
494 {
495 return Ok(());
496 }
497
498 let path = resolve_remote_pack_path(root, reference)?;
499 std::fs::copy(&path, &destination)
500 .with_context(|| format!("copy {} to {}", path.display(), destination.display()))?;
501
502 Ok(())
503}
504
505fn parse_local_pack_reference(root: &Path, reference: &str) -> Option<PathBuf> {
506 if let Some(path) = reference.strip_prefix("file://") {
507 let path = PathBuf::from(path.trim());
508 return path.exists().then_some(path);
509 }
510 if reference.contains("://") {
511 return None;
512 }
513 let candidate = PathBuf::from(reference);
514 if candidate.is_absolute() {
515 return candidate.exists().then_some(candidate);
516 }
517 let joined = root.join(&candidate);
518 joined.exists().then_some(joined)
519}
520
521fn resolve_remote_pack_path(root: &Path, reference: &str) -> Result<PathBuf> {
522 if let Some(oci_reference) = reference.strip_prefix("oci://") {
523 let mut options = PackFetchOptions {
524 allow_tags: true,
525 offline: crate::runtime::offline(),
526 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
527 ..PackFetchOptions::default()
528 };
529 options.accepted_layer_media_types.extend([
530 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
531 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
532 ]);
533 options.preferred_layer_media_types.splice(
534 0..0,
535 [
536 GREENTIC_GTPACK_TAR_MEDIA_TYPE.to_string(),
537 GREENTIC_GTPACK_TAR_GZIP_MEDIA_TYPE.to_string(),
538 ],
539 );
540 let fetcher: OciPackFetcher<DefaultRegistryClient> = OciPackFetcher::new(options);
541 let runtime = Runtime::new().context("create OCI pack resolver runtime")?;
542 let resolved = runtime
543 .block_on(fetcher.fetch_pack_to_cache(oci_reference))
544 .with_context(|| format!("resolve OCI pack ref {reference}"))?;
545 return Ok(resolved.path);
546 }
547
548 let options = DistOptions {
549 allow_tags: true,
550 offline: crate::runtime::offline(),
551 cache_dir: root.join(crate::catalog::CACHE_ROOT_DIR).join("artifacts"),
552 ..DistOptions::default()
553 };
554 let client = DistClient::new(options);
555 let runtime = Runtime::new().context("create artifact resolver runtime")?;
556 let source = client
557 .parse_source(reference)
558 .with_context(|| format!("parse artifact ref {reference}"))?;
559 let descriptor = runtime
560 .block_on(client.resolve(source, ResolvePolicy))
561 .with_context(|| format!("resolve artifact ref {reference}"))?;
562 let resolved = runtime
563 .block_on(client.fetch(&descriptor, CachePolicy))
564 .with_context(|| format!("fetch artifact ref {reference}"))?;
565 if let Some(path) = resolved.wasm_path {
566 return Ok(path);
567 }
568 if let Some(bytes) = resolved.wasm_bytes {
569 let digest = resolved.resolved_digest.trim_start_matches("sha256:");
570 let temp_path = root
571 .join(crate::catalog::CACHE_ROOT_DIR)
572 .join("artifacts")
573 .join("inline")
574 .join(format!("{digest}.gtpack"));
575 if let Some(parent) = temp_path.parent() {
576 ensure_dir(parent)?;
577 }
578 std::fs::write(&temp_path, bytes)
579 .with_context(|| format!("write cached inline artifact {}", temp_path.display()))?;
580 return Ok(temp_path);
581 }
582 anyhow::bail!("artifact ref {reference} resolved without file payload");
583}
584
585pub fn list_tenants(root: &Path) -> Result<Vec<String>> {
586 let tenants_dir = root.join("tenants");
587 let mut tenants = Vec::new();
588 if !tenants_dir.exists() {
589 return Ok(tenants);
590 }
591 for entry in std::fs::read_dir(tenants_dir)? {
592 let entry = entry?;
593 if entry.file_type()?.is_dir() {
594 tenants.push(entry.file_name().to_string_lossy().to_string());
595 }
596 }
597 tenants.sort();
598 Ok(tenants)
599}
600
601pub fn list_teams(root: &Path, tenant: &str) -> Result<Vec<String>> {
602 let teams_dir = root.join("tenants").join(tenant).join("teams");
603 let mut teams = Vec::new();
604 if !teams_dir.exists() {
605 return Ok(teams);
606 }
607 for entry in std::fs::read_dir(teams_dir)? {
608 let entry = entry?;
609 if entry.file_type()?.is_dir() {
610 teams.push(entry.file_name().to_string_lossy().to_string());
611 }
612 }
613 teams.sort();
614 Ok(teams)
615}
616
617pub fn write_bundle_lock(root: &Path, lock: &BundleLock) -> Result<()> {
618 let path = root.join(LOCK_FILE);
619 if let Some(parent) = path.parent() {
620 ensure_dir(parent)?;
621 }
622 std::fs::write(&path, format!("{}\n", serde_json::to_string_pretty(lock)?))?;
623 Ok(())
624}
625
626pub fn read_bundle_lock(root: &Path) -> Result<BundleLock> {
627 let path = root.join(LOCK_FILE);
628 let raw = std::fs::read_to_string(&path)?;
629 Ok(serde_json::from_str(&raw)?)
630}
631
632fn build_manifest(root: &Path, tenant: &str, team: Option<&str>) -> ResolvedManifest {
633 let workspace = read_workspace_or_default(root);
634 let tenant_gmap = relative_path(root, &root.join("tenants").join(tenant).join("tenant.gmap"));
635 let team_gmap = team.map(|team| {
636 relative_path(
637 root,
638 &root
639 .join("tenants")
640 .join(tenant)
641 .join("teams")
642 .join(team)
643 .join("team.gmap"),
644 )
645 });
646
647 let app_packs = evaluate_app_pack_policies(root, tenant, team, &workspace.app_packs);
648
649 ResolvedManifest {
650 version: "1".to_string(),
651 tenant: tenant.to_string(),
652 team: team.map(ToOwned::to_owned),
653 project_root: root.display().to_string(),
654 bundle: BundleSummary {
655 bundle_id: workspace.bundle_id,
656 bundle_name: workspace.bundle_name,
657 locale: workspace.locale,
658 mode: workspace.mode,
659 advanced_setup: workspace.advanced_setup,
660 setup_execution_intent: workspace.setup_execution_intent,
661 export_intent: workspace.export_intent,
662 },
663 policy: PolicySection {
664 source: PolicySource {
665 tenant_gmap,
666 team_gmap,
667 },
668 default: "forbidden".to_string(),
669 },
670 catalogs: workspace.remote_catalogs,
671 app_packs,
672 extension_providers: workspace.extension_providers,
673 hooks: workspace.hooks,
674 subscriptions: workspace.subscriptions,
675 capabilities: workspace.capabilities,
676 }
677}
678
679fn render_bundle_workspace(workspace: &BundleWorkspaceDefinition) -> String {
680 format!(
681 concat!(
682 "schema_version: {}\n",
683 "bundle_id: {}\n",
684 "bundle_name: {}\n",
685 "locale: {}\n",
686 "mode: {}\n",
687 "advanced_setup: {}\n",
688 "app_packs:{}\n",
689 "app_pack_mappings:{}\n",
690 "extension_providers:{}\n",
691 "remote_catalogs:{}\n",
692 "hooks:{}\n",
693 "subscriptions:{}\n",
694 "capabilities:{}\n",
695 "setup_execution_intent: {}\n",
696 "export_intent: {}\n"
697 ),
698 workspace.schema_version,
699 workspace.bundle_id,
700 workspace.bundle_name,
701 workspace.locale,
702 workspace.mode,
703 workspace.advanced_setup,
704 yaml_list(&workspace.app_packs),
705 yaml_mapping_list(&workspace.app_pack_mappings),
706 yaml_list(&workspace.extension_providers),
707 yaml_list(&workspace.remote_catalogs),
708 yaml_list(&workspace.hooks),
709 yaml_list(&workspace.subscriptions),
710 yaml_list(&workspace.capabilities),
711 workspace.setup_execution_intent,
712 workspace.export_intent
713 )
714}
715
716fn yaml_mapping_list(values: &[AppPackMapping]) -> String {
717 if values.is_empty() {
718 " []".to_string()
719 } else {
720 values
721 .iter()
722 .map(|value| {
723 let mut out = format!(
724 "\n - reference: {}\n scope: {}",
725 value.reference,
726 match value.scope {
727 MappingScope::Global => "global",
728 MappingScope::Tenant => "tenant",
729 MappingScope::Team => "team",
730 }
731 );
732 if let Some(tenant) = &value.tenant {
733 out.push_str(&format!("\n tenant: {tenant}"));
734 }
735 if let Some(team) = &value.team {
736 out.push_str(&format!("\n team: {team}"));
737 }
738 out
739 })
740 .collect::<String>()
741 }
742}
743
744fn empty_bundle_lock(workspace: &BundleWorkspaceDefinition) -> BundleLock {
745 BundleLock {
746 schema_version: LOCK_SCHEMA_VERSION,
747 bundle_id: workspace.bundle_id.clone(),
748 requested_mode: workspace.mode.clone(),
749 execution: "execute".to_string(),
750 cache_policy: "workspace-local".to_string(),
751 tool_version: env!("CARGO_PKG_VERSION").to_string(),
752 build_format_version: "bundle-lock-v1".to_string(),
753 workspace_root: WORKSPACE_ROOT_FILE.to_string(),
754 lock_file: LOCK_FILE.to_string(),
755 catalogs: Vec::new(),
756 app_packs: workspace
757 .app_packs
758 .iter()
759 .map(|reference| DependencyLock {
760 reference: reference.clone(),
761 digest: None,
762 })
763 .collect(),
764 extension_providers: workspace
765 .extension_providers
766 .iter()
767 .map(|reference| DependencyLock {
768 reference: reference.clone(),
769 digest: None,
770 })
771 .collect(),
772 setup_state_files: Vec::new(),
773 }
774}
775
776fn yaml_list(values: &[String]) -> String {
777 if values.is_empty() {
778 " []".to_string()
779 } else {
780 values
781 .iter()
782 .map(|value| format!("\n - {value}"))
783 .collect::<String>()
784 }
785}
786
787fn sort_unique(values: &mut Vec<String>) {
788 values.retain(|value| !value.trim().is_empty());
789 values.sort();
790 values.dedup();
791}
792
793fn canonicalize_mappings(values: &mut Vec<AppPackMapping>) {
794 values.retain(|value| !value.reference.trim().is_empty());
795 for value in values.iter_mut() {
796 if value
797 .tenant
798 .as_deref()
799 .is_some_and(|tenant| tenant.trim().is_empty())
800 {
801 value.tenant = None;
802 }
803 if value
804 .team
805 .as_deref()
806 .is_some_and(|team| team.trim().is_empty())
807 {
808 value.team = None;
809 }
810 if matches!(value.scope, MappingScope::Global) {
811 value.tenant = None;
812 value.team = None;
813 } else if matches!(value.scope, MappingScope::Tenant) {
814 value.team = None;
815 }
816 }
817 values.sort_by(|left, right| {
818 left.reference
819 .cmp(&right.reference)
820 .then(left.scope.cmp(&right.scope))
821 .then(left.tenant.cmp(&right.tenant))
822 .then(left.team.cmp(&right.team))
823 });
824 values.dedup_by(|left, right| {
825 left.reference == right.reference
826 && left.scope == right.scope
827 && left.tenant == right.tenant
828 && left.team == right.team
829 });
830}
831
832fn default_schema_version() -> u32 {
833 1
834}
835
836fn default_locale() -> String {
837 "en".to_string()
838}
839
840fn default_mode() -> String {
841 "create".to_string()
842}
843
844fn write_resolved_outputs(
845 root: &Path,
846 tenant: &str,
847 team: Option<&str>,
848 manifest: &ResolvedManifest,
849) -> Result<()> {
850 let yaml = render_manifest_yaml(manifest);
851 for output in resolved_output_paths(root, tenant, team) {
852 if let Some(parent) = output.parent() {
853 ensure_dir(parent)?;
854 }
855 std::fs::write(output, &yaml)?;
856 }
857 Ok(())
858}
859
860fn render_manifest_yaml(manifest: &ResolvedManifest) -> String {
861 let mut lines = vec![
862 format!("version: {}", manifest.version),
863 format!("tenant: {}", manifest.tenant),
864 ];
865 if let Some(team) = &manifest.team {
866 lines.push(format!("team: {}", team));
867 }
868 lines.extend([
869 format!("project_root: {}", manifest.project_root),
870 "bundle:".to_string(),
871 format!(" bundle_id: {}", manifest.bundle.bundle_id),
872 format!(" bundle_name: {}", manifest.bundle.bundle_name),
873 format!(" locale: {}", manifest.bundle.locale),
874 format!(" mode: {}", manifest.bundle.mode),
875 format!(" advanced_setup: {}", manifest.bundle.advanced_setup),
876 format!(
877 " setup_execution_intent: {}",
878 manifest.bundle.setup_execution_intent
879 ),
880 format!(" export_intent: {}", manifest.bundle.export_intent),
881 "policy:".to_string(),
882 " source:".to_string(),
883 format!(" tenant_gmap: {}", manifest.policy.source.tenant_gmap),
884 ]);
885 if let Some(team_gmap) = &manifest.policy.source.team_gmap {
886 lines.push(format!(" team_gmap: {}", team_gmap));
887 }
888 lines.push(format!(" default: {}", manifest.policy.default));
889 lines.push("catalogs:".to_string());
890 lines.extend(render_yaml_list(" ", &manifest.catalogs));
891 lines.push("app_packs:".to_string());
892 if manifest.app_packs.is_empty() {
893 lines.push(" []".to_string());
894 } else {
895 for entry in &manifest.app_packs {
896 lines.push(format!(" - reference: {}", entry.reference));
897 lines.push(format!(" policy: {}", entry.policy));
898 }
899 }
900 lines.push("extension_providers:".to_string());
901 lines.extend(render_yaml_list(" ", &manifest.extension_providers));
902 lines.push("hooks:".to_string());
903 lines.extend(render_yaml_list(" ", &manifest.hooks));
904 lines.push("subscriptions:".to_string());
905 lines.extend(render_yaml_list(" ", &manifest.subscriptions));
906 lines.push("capabilities:".to_string());
907 lines.extend(render_yaml_list(" ", &manifest.capabilities));
908 format!("{}\n", lines.join("\n"))
909}
910
911fn read_workspace_or_default(root: &Path) -> BundleWorkspaceDefinition {
912 read_bundle_workspace(root).unwrap_or_else(|_| {
913 let bundle_id = root
914 .file_name()
915 .and_then(|value| value.to_str())
916 .map(ToOwned::to_owned)
917 .filter(|value| !value.trim().is_empty())
918 .unwrap_or_else(|| "bundle".to_string());
919 BundleWorkspaceDefinition::new(
920 bundle_id.clone(),
921 bundle_id,
922 default_locale(),
923 default_mode(),
924 )
925 })
926}
927
928fn evaluate_app_pack_policies(
929 root: &Path,
930 tenant: &str,
931 team: Option<&str>,
932 app_packs: &[String],
933) -> Vec<ResolvedReferencePolicy> {
934 let tenant_rules =
935 crate::access::parse_file(&root.join("tenants").join(tenant).join("tenant.gmap"))
936 .unwrap_or_default();
937 let team_rules = team
938 .and_then(|team_name| {
939 crate::access::parse_file(
940 &root
941 .join("tenants")
942 .join(tenant)
943 .join("teams")
944 .join(team_name)
945 .join("team.gmap"),
946 )
947 .ok()
948 })
949 .unwrap_or_default();
950
951 let mut entries = app_packs
952 .iter()
953 .map(|reference| {
954 let target = crate::access::GmapPath {
955 pack: Some(inferred_access_pack_id(reference)),
956 flow: None,
957 node: None,
958 };
959 let policy = if team.is_some() {
960 crate::access::eval_with_overlay(&tenant_rules, &team_rules, &target)
961 } else {
962 crate::access::eval_policy(&tenant_rules, &target)
963 };
964 ResolvedReferencePolicy {
965 reference: reference.clone(),
966 policy: policy
967 .map(|decision| decision.policy.to_string())
968 .unwrap_or_else(|| "unset".to_string()),
969 }
970 })
971 .collect::<Vec<_>>();
972 entries.sort_by(|left, right| left.reference.cmp(&right.reference));
973 entries
974}
975
976fn inferred_access_pack_id(reference: &str) -> String {
977 let cleaned = reference
978 .trim_end_matches('/')
979 .rsplit('/')
980 .next()
981 .unwrap_or(reference)
982 .split('@')
983 .next()
984 .unwrap_or(reference)
985 .split(':')
986 .next()
987 .unwrap_or(reference)
988 .trim_end_matches(".json")
989 .trim_end_matches(".gtpack")
990 .trim_end_matches(".yaml")
991 .trim_end_matches(".yml");
992 let mut normalized = String::with_capacity(cleaned.len());
993 let mut last_dash = false;
994 for ch in cleaned.chars() {
995 let out = if ch.is_ascii_alphanumeric() {
996 last_dash = false;
997 ch.to_ascii_lowercase()
998 } else if last_dash {
999 continue;
1000 } else {
1001 last_dash = true;
1002 '-'
1003 };
1004 normalized.push(out);
1005 }
1006 normalized.trim_matches('-').to_string()
1007}
1008
1009fn inferred_provider_type(reference: &str) -> String {
1010 let raw = reference.trim();
1011 for marker in ["/providers/", "/packs/"] {
1012 if let Some((_, rest)) = raw.split_once(marker)
1013 && let Some(segment) = rest.split('/').next()
1014 && !segment.is_empty()
1015 {
1016 return segment.to_string();
1017 }
1018 }
1019
1020 let inferred = inferred_access_pack_id(reference);
1021 let mut parts = inferred.split('-');
1022 match (parts.next(), parts.next()) {
1023 (Some("greentic"), Some(domain)) if !domain.is_empty() => domain.to_string(),
1024 (Some(domain), Some(_)) if !domain.is_empty() => domain.to_string(),
1025 (Some(_domain), None) => "other".to_string(),
1026 _ => "other".to_string(),
1027 }
1028}
1029
1030fn inferred_provider_filename(reference: &str) -> String {
1031 let cleaned = reference
1032 .trim_end_matches('/')
1033 .rsplit('/')
1034 .next()
1035 .unwrap_or(reference)
1036 .split('@')
1037 .next()
1038 .unwrap_or(reference)
1039 .split(':')
1040 .next()
1041 .unwrap_or(reference)
1042 .trim_end_matches(".gtpack");
1043 if cleaned.is_empty() {
1044 inferred_access_pack_id(reference)
1045 } else {
1046 cleaned.to_string()
1047 }
1048}
1049
1050fn render_yaml_list(indent: &str, values: &[String]) -> Vec<String> {
1051 if values.is_empty() {
1052 vec![format!("{indent}[]")]
1053 } else {
1054 values
1055 .iter()
1056 .map(|value| format!("{indent}- {value}"))
1057 .collect()
1058 }
1059}
1060
1061fn relative_path(root: &Path, path: &Path) -> String {
1062 path.strip_prefix(root)
1063 .unwrap_or(path)
1064 .display()
1065 .to_string()
1066}
1067
1068fn ensure_dir(path: &Path) -> Result<()> {
1069 std::fs::create_dir_all(path)?;
1070 Ok(())
1071}
1072
1073fn write_if_missing(path: &Path, contents: &str) -> Result<()> {
1074 if path.exists() {
1075 return Ok(());
1076 }
1077 if let Some(parent) = path.parent() {
1078 ensure_dir(parent)?;
1079 }
1080 std::fs::write(path, contents)?;
1081 Ok(())
1082}
1083
1084pub fn scaffold_assets_from_packs(root: &Path) -> Result<Vec<PathBuf>> {
1090 let mut written = Vec::new();
1091 let providers_dir = root.join("providers");
1092 if !providers_dir.is_dir() {
1093 return Ok(written);
1094 }
1095 for dir_entry in collect_gtpack_files(&providers_dir)? {
1096 match extract_pack_assets(root, &dir_entry) {
1097 Ok(paths) => written.extend(paths),
1098 Err(err) => {
1099 eprintln!(
1100 "Warning: could not scaffold assets from {}: {err}",
1101 dir_entry.display()
1102 );
1103 }
1104 }
1105 }
1106 Ok(written)
1107}
1108
1109fn collect_gtpack_files(dir: &Path) -> Result<Vec<PathBuf>> {
1110 let mut files = Vec::new();
1111 for entry in std::fs::read_dir(dir)? {
1112 let entry = entry?;
1113 let path = entry.path();
1114 if path.is_dir() {
1115 files.extend(collect_gtpack_files(&path)?);
1116 } else if path.extension().is_some_and(|ext| ext == "gtpack") {
1117 files.push(path);
1118 }
1119 }
1120 Ok(files)
1121}
1122
1123fn extract_pack_assets(root: &Path, pack_path: &Path) -> Result<Vec<PathBuf>> {
1124 let file =
1125 std::fs::File::open(pack_path).with_context(|| format!("open {}", pack_path.display()))?;
1126 let mut archive =
1127 zip::ZipArchive::new(file).with_context(|| format!("read zip {}", pack_path.display()))?;
1128 let mut written = Vec::new();
1129 for i in 0..archive.len() {
1130 let mut entry = archive.by_index(i)?;
1131 let name = entry.name().to_string();
1132 if !name.starts_with("assets/webchat-gui/") || entry.is_dir() {
1133 continue;
1134 }
1135 let target = root.join(&name);
1136 if target.exists() {
1137 continue;
1138 }
1139 if let Some(parent) = target.parent() {
1140 std::fs::create_dir_all(parent)?;
1141 }
1142 let mut out = std::fs::File::create(&target)?;
1143 std::io::copy(&mut entry, &mut out)?;
1144 written.push(target);
1145 }
1146 Ok(written)
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::should_skip_extension_provider_materialization;
1152
1153 #[test]
1154 fn bundled_catalog_mode_skips_https_provider_materialization() {
1155 unsafe {
1156 std::env::set_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG", "1");
1157 }
1158 assert!(should_skip_extension_provider_materialization(
1159 "https://example.com/providers/events-webhook.gtpack"
1160 ));
1161 unsafe {
1162 std::env::remove_var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG");
1163 }
1164 }
1165}