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