1use std::collections::BTreeSet;
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, anyhow};
11use serde_json::{Map as JsonMap, Value};
12
13use crate::answers_crypto;
14use crate::bundle;
15use crate::discovery;
16use crate::plan::*;
17use crate::platform_setup::{
18 PlatformSetupAnswers, StaticRoutesPolicy, load_effective_static_routes_defaults,
19 persist_static_routes_artifact,
20};
21use crate::setup_input;
22
23#[derive(Clone, Debug, Default)]
24pub struct LoadedAnswers {
25 pub platform_setup: PlatformSetupAnswers,
26 pub setup_answers: JsonMap<String, Value>,
27}
28
29#[derive(Clone, Debug, Default)]
31pub struct SetupRequest {
32 pub bundle: PathBuf,
33 pub bundle_name: Option<String>,
34 pub pack_refs: Vec<String>,
35 pub tenants: Vec<TenantSelection>,
36 pub default_assignments: Vec<PackDefaultSelection>,
37 pub providers: Vec<String>,
38 pub update_ops: BTreeSet<UpdateOp>,
39 pub remove_targets: BTreeSet<RemoveTarget>,
40 pub packs_remove: Vec<PackRemoveSelection>,
41 pub providers_remove: Vec<String>,
42 pub tenants_remove: Vec<TenantSelection>,
43 pub access_changes: Vec<AccessChangeSelection>,
44 pub static_routes: StaticRoutesPolicy,
45 pub deployment_targets: Vec<crate::deployment_targets::DeploymentTargetRecord>,
46 pub setup_answers: serde_json::Map<String, serde_json::Value>,
47 pub domain_filter: Option<String>,
49 pub parallel: usize,
51 pub backup: bool,
53 pub skip_secrets_init: bool,
55 pub best_effort: bool,
57}
58
59pub struct SetupConfig {
61 pub tenant: String,
62 pub team: Option<String>,
63 pub env: String,
64 pub offline: bool,
65 pub verbose: bool,
66}
67
68pub struct SetupEngine {
70 config: SetupConfig,
71}
72
73impl SetupEngine {
74 pub fn new(config: SetupConfig) -> Self {
75 Self { config }
76 }
77
78 pub fn plan(
80 &self,
81 mode: SetupMode,
82 request: &SetupRequest,
83 dry_run: bool,
84 ) -> anyhow::Result<SetupPlan> {
85 match mode {
86 SetupMode::Create => apply_create(request, dry_run),
87 SetupMode::Update => apply_update(request, dry_run),
88 SetupMode::Remove => apply_remove(request, dry_run),
89 }
90 }
91
92 pub fn print_plan(&self, plan: &SetupPlan) {
94 print_plan_summary(plan);
95 }
96
97 pub fn config(&self) -> &SetupConfig {
99 &self.config
100 }
101
102 pub fn execute(&self, plan: &SetupPlan) -> anyhow::Result<SetupExecutionReport> {
107 if plan.dry_run {
108 return Err(anyhow!("cannot execute a dry-run plan"));
109 }
110
111 let bundle = &plan.bundle;
112 let mut report = SetupExecutionReport {
113 bundle: bundle.clone(),
114 resolved_packs: Vec::new(),
115 resolved_manifests: Vec::new(),
116 provider_updates: 0,
117 warnings: Vec::new(),
118 };
119
120 for step in &plan.steps {
121 match step.kind {
122 SetupStepKind::NoOp => {
123 if self.config.verbose {
124 println!(" [skip] {}", step.description);
125 }
126 }
127 SetupStepKind::CreateBundle => {
128 self.execute_create_bundle(bundle, &plan.metadata)?;
129 if self.config.verbose {
130 println!(" [done] {}", step.description);
131 }
132 }
133 SetupStepKind::ResolvePacks => {
134 let resolved = self.execute_resolve_packs(bundle, &plan.metadata)?;
135 report.resolved_packs.extend(resolved);
136 if self.config.verbose {
137 println!(" [done] {}", step.description);
138 }
139 }
140 SetupStepKind::AddPacksToBundle => {
141 self.execute_add_packs_to_bundle(bundle, &report.resolved_packs)?;
142 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
143 bundle,
144 &plan.metadata.deployment_targets,
145 );
146 if self.config.verbose {
147 println!(" [done] {}", step.description);
148 }
149 }
150 SetupStepKind::ValidateCapabilities => {
151 let cap_report = crate::capabilities::validate_and_upgrade_packs(bundle)?;
152 for warn in &cap_report.warnings {
153 report.warnings.push(warn.message.clone());
154 }
155 if self.config.verbose {
156 println!(
157 " [done] {} (checked={}, upgraded={})",
158 step.description,
159 cap_report.checked,
160 cap_report.upgraded.len()
161 );
162 }
163 }
164 SetupStepKind::ApplyPackSetup => {
165 let count = self.execute_apply_pack_setup(bundle, &plan.metadata)?;
166 report.provider_updates += count;
167 if self.config.verbose {
168 println!(" [done] {}", step.description);
169 }
170 }
171 SetupStepKind::WriteGmapRules => {
172 self.execute_write_gmap_rules(bundle, &plan.metadata)?;
173 if self.config.verbose {
174 println!(" [done] {}", step.description);
175 }
176 }
177 SetupStepKind::RunResolver => {
178 if self.config.verbose {
180 println!(" [skip] {} (deferred to runtime)", step.description);
181 }
182 }
183 SetupStepKind::CopyResolvedManifest => {
184 let manifests = self.execute_copy_resolved_manifests(bundle, &plan.metadata)?;
185 report.resolved_manifests.extend(manifests);
186 if self.config.verbose {
187 println!(" [done] {}", step.description);
188 }
189 }
190 SetupStepKind::ValidateBundle => {
191 self.execute_validate_bundle(bundle)?;
192 if self.config.verbose {
193 println!(" [done] {}", step.description);
194 }
195 }
196 }
197 }
198
199 Ok(report)
200 }
201
202 pub fn emit_answers(
207 &self,
208 plan: &SetupPlan,
209 output_path: &Path,
210 key: Option<&str>,
211 interactive: bool,
212 ) -> anyhow::Result<()> {
213 let bundle = &plan.bundle;
214
215 let mut answers_doc = serde_json::json!({
217 "greentic_setup_version": "1.0.0",
218 "bundle_source": bundle.display().to_string(),
219 "tenant": self.config.tenant,
220 "team": self.config.team,
221 "env": self.config.env,
222 "platform_setup": {
223 "static_routes": plan.metadata.static_routes.to_answers(),
224 "deployment_targets": plan.metadata.deployment_targets
225 },
226 "setup_answers": {}
227 });
228
229 if !plan.metadata.static_routes.public_web_enabled
230 && plan.metadata.static_routes.public_base_url.is_none()
231 && let Some(existing) = load_effective_static_routes_defaults(
232 bundle,
233 &self.config.tenant,
234 self.config.team.as_deref(),
235 )?
236 {
237 answers_doc["platform_setup"]["static_routes"] =
238 serde_json::to_value(existing.to_answers())?;
239 }
240
241 let setup_answers = answers_doc
243 .get_mut("setup_answers")
244 .and_then(|v| v.as_object_mut())
245 .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
246
247 for (provider_id, answers) in &plan.metadata.setup_answers {
249 setup_answers.insert(provider_id.clone(), answers.clone());
250 }
251
252 if bundle.exists() {
256 let discovered = discovery::discover(bundle)?;
257 for provider in discovered.providers {
258 let provider_id = provider.provider_id.clone();
259 let existing_is_empty = setup_answers
260 .get(&provider_id)
261 .and_then(|v| v.as_object())
262 .is_some_and(|m| m.is_empty());
263 if !setup_answers.contains_key(&provider_id) || existing_is_empty {
264 let template =
266 if let Some(spec) = setup_input::load_setup_spec(&provider.pack_path)? {
267 let mut entries = JsonMap::new();
269 for question in &spec.questions {
270 let default_value = question
271 .default
272 .clone()
273 .unwrap_or_else(|| Value::String(String::new()));
274 entries.insert(question.name.clone(), default_value);
275 }
276 entries
277 } else {
278 JsonMap::new()
281 };
282 setup_answers.insert(provider_id, Value::Object(template));
283 }
284 }
285 }
286
287 self.encrypt_secret_answers(bundle, &mut answers_doc, key, interactive)?;
288
289 let output_content = serde_json::to_string_pretty(&answers_doc)
291 .context("failed to serialize answers document")?;
292
293 if let Some(parent) = output_path.parent() {
294 std::fs::create_dir_all(parent)
295 .with_context(|| format!("failed to create directory: {}", parent.display()))?;
296 }
297
298 std::fs::write(output_path, output_content)
299 .with_context(|| format!("failed to write answers to: {}", output_path.display()))?;
300
301 println!("Answers template written to: {}", output_path.display());
302 Ok(())
303 }
304
305 pub fn load_answers(
307 &self,
308 answers_path: &Path,
309 key: Option<&str>,
310 interactive: bool,
311 ) -> anyhow::Result<LoadedAnswers> {
312 let raw = setup_input::load_setup_input(answers_path)?;
313 let raw = if answers_crypto::has_encrypted_values(&raw) {
314 let resolved_key = match key {
315 Some(value) => value.to_string(),
316 None if interactive => answers_crypto::prompt_for_key("decrypting answers")?,
317 None => {
318 return Err(anyhow!(
319 "answers file contains encrypted secret values; rerun with --key or interactive input"
320 ));
321 }
322 };
323 answers_crypto::decrypt_tree(&raw, &resolved_key)?
324 } else {
325 raw
326 };
327 match raw {
328 Value::Object(map) => {
329 let platform_setup = map
330 .get("platform_setup")
331 .cloned()
332 .map(serde_json::from_value)
333 .transpose()
334 .context("parse platform_setup answers")?
335 .unwrap_or_default();
336
337 if let Some(Value::Object(setup_answers)) = map.get("setup_answers") {
338 Ok(LoadedAnswers {
339 platform_setup,
340 setup_answers: setup_answers.clone(),
341 })
342 } else if map.contains_key("bundle_source")
343 || map.contains_key("tenant")
344 || map.contains_key("team")
345 || map.contains_key("env")
346 || map.contains_key("platform_setup")
347 {
348 Ok(LoadedAnswers {
349 platform_setup,
350 setup_answers: JsonMap::new(),
351 })
352 } else {
353 Ok(LoadedAnswers {
354 platform_setup,
355 setup_answers: map,
356 })
357 }
358 }
359 _ => Err(anyhow!("answers file must be a JSON/YAML object")),
360 }
361 }
362
363 fn encrypt_secret_answers(
364 &self,
365 bundle: &Path,
366 answers_doc: &mut Value,
367 key: Option<&str>,
368 interactive: bool,
369 ) -> anyhow::Result<()> {
370 let setup_answers = answers_doc
371 .get_mut("setup_answers")
372 .and_then(Value::as_object_mut)
373 .ok_or_else(|| anyhow!("internal error: setup_answers not an object"))?;
374 let discovered = if bundle.exists() {
375 discovery::discover(bundle)?
376 } else {
377 return Ok(());
378 };
379
380 let mut secret_paths = Vec::new();
381 for provider in discovered.providers {
382 let Some(form_spec) = crate::setup_to_formspec::pack_to_form_spec(
383 &provider.pack_path,
384 &provider.provider_id,
385 ) else {
386 continue;
387 };
388 let Some(provider_answers) = setup_answers
389 .get_mut(&provider.provider_id)
390 .and_then(Value::as_object_mut)
391 else {
392 continue;
393 };
394 for question in form_spec.questions {
395 if !question.secret {
396 continue;
397 }
398 let Some(value) = provider_answers.get(&question.id).cloned() else {
399 continue;
400 };
401 if value.is_null() || value == Value::String(String::new()) {
402 continue;
403 }
404 secret_paths.push((provider.provider_id.clone(), question.id.clone(), value));
405 }
406 }
407
408 if secret_paths.is_empty() {
409 return Ok(());
410 }
411
412 let resolved_key = match key {
413 Some(value) => value.to_string(),
414 None if interactive => answers_crypto::prompt_for_key("encrypting answers")?,
415 None => {
416 return Err(anyhow!(
417 "answer document includes secret values; rerun with --key or interactive input"
418 ));
419 }
420 };
421
422 for (provider_id, field_id, value) in secret_paths {
423 let encrypted = answers_crypto::encrypt_value(&value, &resolved_key)?;
424 if let Some(provider_answers) = setup_answers
425 .get_mut(&provider_id)
426 .and_then(Value::as_object_mut)
427 {
428 provider_answers.insert(field_id, encrypted);
429 }
430 }
431
432 Ok(())
433 }
434
435 fn execute_create_bundle(
438 &self,
439 bundle_path: &Path,
440 metadata: &SetupPlanMetadata,
441 ) -> anyhow::Result<()> {
442 bundle::create_demo_bundle_structure(bundle_path, metadata.bundle_name.as_deref())
443 .context("failed to create bundle structure")
444 }
445
446 fn execute_resolve_packs(
447 &self,
448 _bundle_path: &Path,
449 metadata: &SetupPlanMetadata,
450 ) -> anyhow::Result<Vec<ResolvedPackInfo>> {
451 let mut resolved = Vec::new();
452
453 for pack_ref in &metadata.pack_refs {
454 let path = PathBuf::from(pack_ref);
457
458 let resolved_path = if path.is_absolute() {
460 path.clone()
461 } else {
462 std::env::current_dir()
463 .ok()
464 .map(|cwd| cwd.join(&path))
465 .unwrap_or_else(|| path.clone())
466 };
467
468 if resolved_path.exists() {
469 let canonical = resolved_path
470 .canonicalize()
471 .unwrap_or(resolved_path.clone());
472 resolved.push(ResolvedPackInfo {
473 source_ref: pack_ref.clone(),
474 mapped_ref: canonical.display().to_string(),
475 resolved_digest: format!("sha256:{}", compute_simple_hash(pack_ref)),
476 pack_id: canonical
477 .file_stem()
478 .and_then(|s| s.to_str())
479 .unwrap_or("unknown")
480 .to_string(),
481 entry_flows: Vec::new(),
482 cached_path: canonical.clone(),
483 output_path: canonical,
484 });
485 } else if pack_ref.starts_with("oci://")
486 || pack_ref.starts_with("repo://")
487 || pack_ref.starts_with("store://")
488 {
489 tracing::warn!("remote pack ref requires async resolution: {}", pack_ref);
492 } else {
493 tracing::warn!(
495 "pack ref not found: {} (resolved to: {})",
496 pack_ref,
497 resolved_path.display()
498 );
499 }
500 }
501
502 Ok(resolved)
503 }
504
505 fn execute_add_packs_to_bundle(
506 &self,
507 bundle_path: &Path,
508 resolved_packs: &[ResolvedPackInfo],
509 ) -> anyhow::Result<()> {
510 for pack in resolved_packs {
511 let target_dir = Self::get_pack_target_dir(bundle_path, &pack.pack_id);
513 std::fs::create_dir_all(&target_dir)?;
514
515 let target_path = target_dir.join(format!("{}.gtpack", pack.pack_id));
516 if pack.cached_path.exists() && !target_path.exists() {
517 std::fs::copy(&pack.cached_path, &target_path).with_context(|| {
518 format!(
519 "failed to copy pack {} to {}",
520 pack.cached_path.display(),
521 target_path.display()
522 )
523 })?;
524 }
525 }
526 Ok(())
527 }
528
529 fn get_pack_target_dir(bundle_path: &Path, pack_id: &str) -> PathBuf {
534 const DOMAIN_PREFIXES: &[&str] = &[
535 "messaging-",
536 "events-",
537 "oauth-",
538 "secrets-",
539 "mcp-",
540 "state-",
541 ];
542
543 for prefix in DOMAIN_PREFIXES {
544 if pack_id.starts_with(prefix) {
545 let domain = prefix.trim_end_matches('-');
546 return bundle_path.join("providers").join(domain);
547 }
548 }
549
550 bundle_path.join("packs")
552 }
553
554 fn execute_apply_pack_setup(
555 &self,
556 bundle_path: &Path,
557 metadata: &SetupPlanMetadata,
558 ) -> anyhow::Result<usize> {
559 let mut count = 0;
560
561 if !metadata.providers_remove.is_empty() {
562 count +=
563 self.execute_remove_provider_artifacts(bundle_path, &metadata.providers_remove)?;
564 }
565
566 self.auto_install_provider_packs(bundle_path, metadata);
569
570 let discovered = if bundle_path.exists() {
572 discovery::discover(bundle_path).ok()
573 } else {
574 None
575 };
576
577 for (provider_id, answers) in &metadata.setup_answers {
579 let config_dir = bundle_path.join("state").join("config").join(provider_id);
581 std::fs::create_dir_all(&config_dir)?;
582
583 let config_path = config_dir.join("setup-answers.json");
584 let content = serde_json::to_string_pretty(answers)
585 .context("failed to serialize setup answers")?;
586 std::fs::write(&config_path, content).with_context(|| {
587 format!(
588 "failed to write setup answers to: {}",
589 config_path.display()
590 )
591 })?;
592
593 let pack_path = discovered.as_ref().and_then(|d| {
596 d.providers
597 .iter()
598 .find(|p| p.provider_id == *provider_id)
599 .map(|p| p.pack_path.as_path())
600 });
601 let env = crate::resolve_env(Some(&self.config.env));
602 let rt = tokio::runtime::Runtime::new()
603 .context("failed to create tokio runtime for secrets persistence")?;
604 let persisted = rt.block_on(crate::qa::persist::persist_all_config_as_secrets(
605 bundle_path,
606 &env,
607 &self.config.tenant,
608 self.config.team.as_deref(),
609 provider_id,
610 answers,
611 pack_path,
612 ))?;
613 if self.config.verbose && !persisted.is_empty() {
614 println!(
615 " [secrets] persisted {} key(s) for {provider_id}",
616 persisted.len()
617 );
618 }
619
620 if let Some(result) = crate::webhook::register_webhook(
622 provider_id,
623 answers,
624 &self.config.tenant,
625 self.config.team.as_deref(),
626 ) {
627 let ok = result.get("ok").and_then(Value::as_bool).unwrap_or(false);
628 if ok {
629 println!(" [webhook] registered for {provider_id}");
630 } else {
631 let err = result
632 .get("error")
633 .and_then(Value::as_str)
634 .unwrap_or("unknown");
635 println!(" [webhook] WARNING: registration failed for {provider_id}: {err}");
636 }
637 }
638
639 count += 1;
640 }
641
642 persist_static_routes_artifact(bundle_path, &metadata.static_routes)?;
643 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
644 bundle_path,
645 &metadata.deployment_targets,
646 );
647
648 let provider_configs: Vec<(String, Value)> = metadata
650 .setup_answers
651 .iter()
652 .map(|(id, val)| (id.clone(), val.clone()))
653 .collect();
654 let team = self.config.team.as_deref().unwrap_or("default");
655 crate::webhook::print_post_setup_instructions(&provider_configs, &self.config.tenant, team);
656
657 Ok(count)
658 }
659
660 fn execute_remove_provider_artifacts(
661 &self,
662 bundle_path: &Path,
663 providers_remove: &[String],
664 ) -> anyhow::Result<usize> {
665 let mut removed = 0usize;
666 let discovered = discovery::discover(bundle_path).ok();
667 for provider_id in providers_remove {
668 if let Some(discovered) = discovered.as_ref()
669 && let Some(provider) = discovered
670 .providers
671 .iter()
672 .find(|provider| provider.provider_id == *provider_id)
673 {
674 if provider.pack_path.exists() {
675 std::fs::remove_file(&provider.pack_path).with_context(|| {
676 format!(
677 "failed to remove provider pack {}",
678 provider.pack_path.display()
679 )
680 })?;
681 }
682 removed += 1;
683 } else {
684 let target_dir = Self::get_pack_target_dir(bundle_path, provider_id);
685 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
686 if target_path.exists() {
687 std::fs::remove_file(&target_path).with_context(|| {
688 format!("failed to remove provider pack {}", target_path.display())
689 })?;
690 removed += 1;
691 }
692 }
693
694 let config_dir = bundle_path.join("state").join("config").join(provider_id);
695 if config_dir.exists() {
696 std::fs::remove_dir_all(&config_dir).with_context(|| {
697 format!(
698 "failed to remove provider config dir {}",
699 config_dir.display()
700 )
701 })?;
702 }
703 }
704 Ok(removed)
705 }
706
707 fn auto_install_provider_packs(&self, bundle_path: &Path, metadata: &SetupPlanMetadata) {
710 let bundle_abs =
711 std::fs::canonicalize(bundle_path).unwrap_or_else(|_| bundle_path.to_path_buf());
712
713 for provider_id in metadata.setup_answers.keys() {
714 let target_dir = Self::get_pack_target_dir(bundle_path, provider_id);
715 let target_path = target_dir.join(format!("{provider_id}.gtpack"));
716 if target_path.exists() {
717 continue;
718 }
719
720 let domain = Self::domain_from_provider_id(provider_id);
722
723 if let Some(source) = Self::find_provider_pack_source(provider_id, domain, &bundle_abs)
725 {
726 if let Err(err) = std::fs::create_dir_all(&target_dir) {
727 eprintln!(
728 " [provider] WARNING: failed to create {}: {err}",
729 target_dir.display()
730 );
731 continue;
732 }
733 match std::fs::copy(&source, &target_path) {
734 Ok(_) => println!(
735 " [provider] installed {provider_id}.gtpack from {}",
736 source.display()
737 ),
738 Err(err) => eprintln!(
739 " [provider] WARNING: failed to copy {}: {err}",
740 source.display()
741 ),
742 }
743 } else {
744 eprintln!(
745 " [provider] WARNING: {provider_id}.gtpack not found in sibling bundles"
746 );
747 }
748 }
749 }
750
751 fn domain_from_provider_id(provider_id: &str) -> &str {
753 const DOMAIN_PREFIXES: &[&str] = &[
754 "messaging-",
755 "events-",
756 "oauth-",
757 "secrets-",
758 "mcp-",
759 "state-",
760 "telemetry-",
761 ];
762 for prefix in DOMAIN_PREFIXES {
763 if provider_id.starts_with(prefix) {
764 return prefix.trim_end_matches('-');
765 }
766 }
767 "messaging" }
769
770 fn find_provider_pack_source(
776 provider_id: &str,
777 domain: &str,
778 bundle_abs: &Path,
779 ) -> Option<PathBuf> {
780 let parent = bundle_abs.parent()?;
781 let filename = format!("{provider_id}.gtpack");
782
783 if let Ok(entries) = std::fs::read_dir(parent) {
785 for entry in entries.flatten() {
786 let sibling = entry.path();
787 if sibling == *bundle_abs || !sibling.is_dir() {
788 continue;
789 }
790 let candidate = sibling.join("providers").join(domain).join(&filename);
791 if candidate.is_file() {
792 return Some(candidate);
793 }
794 }
795 }
796
797 for ancestor in parent.ancestors().take(4) {
799 let candidate = ancestor
800 .join("greentic-messaging-providers")
801 .join("target")
802 .join("packs")
803 .join(&filename);
804 if candidate.is_file() {
805 return Some(candidate);
806 }
807 }
808
809 None
810 }
811
812 fn execute_write_gmap_rules(
813 &self,
814 bundle_path: &Path,
815 metadata: &SetupPlanMetadata,
816 ) -> anyhow::Result<()> {
817 for tenant_sel in &metadata.tenants {
818 let gmap_path =
819 bundle::gmap_path(bundle_path, &tenant_sel.tenant, tenant_sel.team.as_deref());
820
821 if let Some(parent) = gmap_path.parent() {
822 std::fs::create_dir_all(parent)?;
823 }
824
825 let mut content = String::new();
827 if tenant_sel.allow_paths.is_empty() {
828 content.push_str("_ = forbidden\n");
829 } else {
830 for path in &tenant_sel.allow_paths {
831 content.push_str(&format!("{} = allowed\n", path));
832 }
833 content.push_str("_ = forbidden\n");
834 }
835
836 std::fs::write(&gmap_path, content)
837 .with_context(|| format!("failed to write gmap: {}", gmap_path.display()))?;
838 }
839 Ok(())
840 }
841
842 fn execute_copy_resolved_manifests(
843 &self,
844 bundle_path: &Path,
845 metadata: &SetupPlanMetadata,
846 ) -> anyhow::Result<Vec<PathBuf>> {
847 let mut manifests = Vec::new();
848 let resolved_dir = bundle_path.join("resolved");
849 std::fs::create_dir_all(&resolved_dir)?;
850
851 for tenant_sel in &metadata.tenants {
852 let filename =
853 bundle::resolved_manifest_filename(&tenant_sel.tenant, tenant_sel.team.as_deref());
854 let manifest_path = resolved_dir.join(&filename);
855
856 if !manifest_path.exists() {
858 std::fs::write(&manifest_path, "# Resolved manifest placeholder\n")?;
859 }
860 manifests.push(manifest_path);
861 }
862
863 Ok(manifests)
864 }
865
866 fn execute_validate_bundle(&self, bundle_path: &Path) -> anyhow::Result<()> {
867 bundle::validate_bundle_exists(bundle_path)
868 }
869}
870
871pub fn apply_create(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
874 if request.tenants.is_empty() {
875 return Err(anyhow!("at least one tenant selection is required"));
876 }
877
878 let pack_refs = dedup_sorted(&request.pack_refs);
879 let tenants = normalize_tenants(&request.tenants);
880
881 let mut steps = Vec::new();
882 if !pack_refs.is_empty() {
883 steps.push(step(
884 SetupStepKind::ResolvePacks,
885 "Resolve selected pack refs via distributor client",
886 [("count", pack_refs.len().to_string())],
887 ));
888 } else {
889 steps.push(step(
890 SetupStepKind::NoOp,
891 "No pack refs selected; skipping pack resolution",
892 [("reason", "empty_pack_refs".to_string())],
893 ));
894 }
895 steps.push(step(
896 SetupStepKind::CreateBundle,
897 "Create demo bundle scaffold using existing conventions",
898 [("bundle", request.bundle.display().to_string())],
899 ));
900 if !pack_refs.is_empty() {
901 steps.push(step(
902 SetupStepKind::AddPacksToBundle,
903 "Copy fetched packs into bundle/packs",
904 [("count", pack_refs.len().to_string())],
905 ));
906 steps.push(step(
907 SetupStepKind::ValidateCapabilities,
908 "Validate provider packs have capabilities extension",
909 [("check", "greentic.ext.capabilities.v1".to_string())],
910 ));
911 steps.push(step(
912 SetupStepKind::ApplyPackSetup,
913 "Apply pack-declared setup outputs through internal setup hooks",
914 [("status", "planned".to_string())],
915 ));
916 } else if !request.setup_answers.is_empty() {
917 steps.push(step(
919 SetupStepKind::ValidateCapabilities,
920 "Validate provider packs have capabilities extension",
921 [("check", "greentic.ext.capabilities.v1".to_string())],
922 ));
923 steps.push(step(
924 SetupStepKind::ApplyPackSetup,
925 "Apply setup answers to existing bundle packs",
926 [("providers", request.setup_answers.len().to_string())],
927 ));
928 } else {
929 steps.push(step(
930 SetupStepKind::NoOp,
931 "No fetched packs to add or setup",
932 [("reason", "empty_pack_refs".to_string())],
933 ));
934 }
935 steps.push(step(
936 SetupStepKind::WriteGmapRules,
937 "Write tenant/team allow rules to gmap",
938 [("targets", tenants.len().to_string())],
939 ));
940 steps.push(step(
941 SetupStepKind::RunResolver,
942 "Run resolver pipeline (same as demo allow)",
943 [("resolver", "project::sync_project".to_string())],
944 ));
945 steps.push(step(
946 SetupStepKind::CopyResolvedManifest,
947 "Copy state/resolved manifests into resolved/ for demo start",
948 [("targets", tenants.len().to_string())],
949 ));
950 steps.push(step(
951 SetupStepKind::ValidateBundle,
952 "Validate bundle is loadable by internal demo pipeline",
953 [("check", "resolved manifests present".to_string())],
954 ));
955
956 Ok(SetupPlan {
957 mode: "create".to_string(),
958 dry_run,
959 bundle: request.bundle.clone(),
960 steps,
961 metadata: build_metadata(request, pack_refs, tenants),
962 })
963}
964
965pub fn apply_update(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
966 let pack_refs = dedup_sorted(&request.pack_refs);
967 let tenants = normalize_tenants(&request.tenants);
968
969 let mut ops = request.update_ops.clone();
970 if ops.is_empty() {
971 infer_update_ops(&mut ops, &pack_refs, request, &tenants);
972 }
973
974 let mut steps = vec![step(
975 SetupStepKind::ValidateBundle,
976 "Validate target bundle exists before update",
977 [("mode", "update".to_string())],
978 )];
979
980 if ops.is_empty() {
981 steps.push(step(
982 SetupStepKind::NoOp,
983 "No update operations selected",
984 [("reason", "empty_update_ops".to_string())],
985 ));
986 }
987 if ops.contains(&UpdateOp::PacksAdd) {
988 if pack_refs.is_empty() {
989 steps.push(step(
990 SetupStepKind::NoOp,
991 "packs_add selected without pack refs",
992 [("reason", "empty_pack_refs".to_string())],
993 ));
994 } else {
995 steps.push(step(
996 SetupStepKind::ResolvePacks,
997 "Resolve selected pack refs via distributor client",
998 [("count", pack_refs.len().to_string())],
999 ));
1000 steps.push(step(
1001 SetupStepKind::AddPacksToBundle,
1002 "Copy fetched packs into bundle/packs",
1003 [("count", pack_refs.len().to_string())],
1004 ));
1005 }
1006 }
1007 if ops.contains(&UpdateOp::PacksRemove) {
1008 if request.packs_remove.is_empty() {
1009 steps.push(step(
1010 SetupStepKind::NoOp,
1011 "packs_remove selected without targets",
1012 [("reason", "empty_packs_remove".to_string())],
1013 ));
1014 } else {
1015 steps.push(step(
1016 SetupStepKind::AddPacksToBundle,
1017 "Remove pack artifacts/default links from bundle",
1018 [("count", request.packs_remove.len().to_string())],
1019 ));
1020 }
1021 }
1022 if ops.contains(&UpdateOp::ProvidersAdd) {
1023 if request.providers.is_empty() && pack_refs.is_empty() {
1024 steps.push(step(
1025 SetupStepKind::NoOp,
1026 "providers_add selected without providers or new packs",
1027 [("reason", "empty_providers_add".to_string())],
1028 ));
1029 } else {
1030 steps.push(step(
1031 SetupStepKind::ApplyPackSetup,
1032 "Enable providers in providers/providers.json",
1033 [("count", request.providers.len().to_string())],
1034 ));
1035 }
1036 }
1037 if ops.contains(&UpdateOp::ProvidersRemove) {
1038 if request.providers_remove.is_empty() {
1039 steps.push(step(
1040 SetupStepKind::NoOp,
1041 "providers_remove selected without providers",
1042 [("reason", "empty_providers_remove".to_string())],
1043 ));
1044 } else {
1045 steps.push(step(
1046 SetupStepKind::ApplyPackSetup,
1047 "Disable/remove providers in providers/providers.json",
1048 [("count", request.providers_remove.len().to_string())],
1049 ));
1050 }
1051 }
1052 if ops.contains(&UpdateOp::TenantsAdd) {
1053 if tenants.is_empty() {
1054 steps.push(step(
1055 SetupStepKind::NoOp,
1056 "tenants_add selected without tenant targets",
1057 [("reason", "empty_tenants_add".to_string())],
1058 ));
1059 } else {
1060 steps.push(step(
1061 SetupStepKind::WriteGmapRules,
1062 "Ensure tenant/team directories and allow rules",
1063 [("targets", tenants.len().to_string())],
1064 ));
1065 }
1066 }
1067 if ops.contains(&UpdateOp::TenantsRemove) {
1068 if request.tenants_remove.is_empty() {
1069 steps.push(step(
1070 SetupStepKind::NoOp,
1071 "tenants_remove selected without tenant targets",
1072 [("reason", "empty_tenants_remove".to_string())],
1073 ));
1074 } else {
1075 steps.push(step(
1076 SetupStepKind::WriteGmapRules,
1077 "Remove tenant/team directories and related rules",
1078 [("targets", request.tenants_remove.len().to_string())],
1079 ));
1080 }
1081 }
1082 if ops.contains(&UpdateOp::AccessChange) {
1083 let access_count = request.access_changes.len()
1084 + tenants.iter().filter(|t| !t.allow_paths.is_empty()).count();
1085 if access_count == 0 {
1086 steps.push(step(
1087 SetupStepKind::NoOp,
1088 "access_change selected without mutations",
1089 [("reason", "empty_access_changes".to_string())],
1090 ));
1091 } else {
1092 steps.push(step(
1093 SetupStepKind::WriteGmapRules,
1094 "Apply access rule updates",
1095 [("changes", access_count.to_string())],
1096 ));
1097 steps.push(step(
1098 SetupStepKind::RunResolver,
1099 "Run resolver pipeline (same as demo allow/forbid)",
1100 [("resolver", "project::sync_project".to_string())],
1101 ));
1102 steps.push(step(
1103 SetupStepKind::CopyResolvedManifest,
1104 "Copy state/resolved manifests into resolved/ for demo start",
1105 [("targets", tenants.len().to_string())],
1106 ));
1107 }
1108 }
1109 steps.push(step(
1110 SetupStepKind::ValidateBundle,
1111 "Validate bundle is loadable by internal demo pipeline",
1112 [("check", "resolved manifests present".to_string())],
1113 ));
1114
1115 Ok(SetupPlan {
1116 mode: SetupMode::Update.as_str().to_string(),
1117 dry_run,
1118 bundle: request.bundle.clone(),
1119 steps,
1120 metadata: build_metadata_with_ops(request, pack_refs, tenants, ops),
1121 })
1122}
1123
1124pub fn apply_remove(request: &SetupRequest, dry_run: bool) -> anyhow::Result<SetupPlan> {
1125 let tenants = normalize_tenants(&request.tenants);
1126
1127 let mut targets = request.remove_targets.clone();
1128 if targets.is_empty() {
1129 if !request.packs_remove.is_empty() {
1130 targets.insert(RemoveTarget::Packs);
1131 }
1132 if !request.providers_remove.is_empty() {
1133 targets.insert(RemoveTarget::Providers);
1134 }
1135 if !request.tenants_remove.is_empty() {
1136 targets.insert(RemoveTarget::TenantsTeams);
1137 }
1138 }
1139
1140 let mut steps = vec![step(
1141 SetupStepKind::ValidateBundle,
1142 "Validate target bundle exists before remove",
1143 [("mode", "remove".to_string())],
1144 )];
1145
1146 if targets.is_empty() {
1147 steps.push(step(
1148 SetupStepKind::NoOp,
1149 "No remove targets selected",
1150 [("reason", "empty_remove_targets".to_string())],
1151 ));
1152 }
1153 if targets.contains(&RemoveTarget::Packs) {
1154 if request.packs_remove.is_empty() {
1155 steps.push(step(
1156 SetupStepKind::NoOp,
1157 "packs target selected without pack identifiers",
1158 [("reason", "empty_packs_remove".to_string())],
1159 ));
1160 } else {
1161 steps.push(step(
1162 SetupStepKind::AddPacksToBundle,
1163 "Delete pack files/default links from bundle",
1164 [("count", request.packs_remove.len().to_string())],
1165 ));
1166 }
1167 }
1168 if targets.contains(&RemoveTarget::Providers) {
1169 if request.providers_remove.is_empty() {
1170 steps.push(step(
1171 SetupStepKind::NoOp,
1172 "providers target selected without provider ids",
1173 [("reason", "empty_providers_remove".to_string())],
1174 ));
1175 } else {
1176 steps.push(step(
1177 SetupStepKind::ApplyPackSetup,
1178 "Remove provider entries from providers/providers.json",
1179 [("count", request.providers_remove.len().to_string())],
1180 ));
1181 }
1182 }
1183 if targets.contains(&RemoveTarget::TenantsTeams) {
1184 if request.tenants_remove.is_empty() {
1185 steps.push(step(
1186 SetupStepKind::NoOp,
1187 "tenants_teams target selected without tenant/team ids",
1188 [("reason", "empty_tenants_remove".to_string())],
1189 ));
1190 } else {
1191 steps.push(step(
1192 SetupStepKind::WriteGmapRules,
1193 "Delete tenant/team directories and access rules",
1194 [("count", request.tenants_remove.len().to_string())],
1195 ));
1196 steps.push(step(
1197 SetupStepKind::RunResolver,
1198 "Run resolver pipeline after tenant/team removals",
1199 [("resolver", "project::sync_project".to_string())],
1200 ));
1201 steps.push(step(
1202 SetupStepKind::CopyResolvedManifest,
1203 "Copy state/resolved manifests into resolved/ for demo start",
1204 [("targets", tenants.len().to_string())],
1205 ));
1206 }
1207 }
1208 steps.push(step(
1209 SetupStepKind::ValidateBundle,
1210 "Validate bundle is loadable by internal demo pipeline",
1211 [("check", "resolved manifests present".to_string())],
1212 ));
1213
1214 Ok(SetupPlan {
1215 mode: SetupMode::Remove.as_str().to_string(),
1216 dry_run,
1217 bundle: request.bundle.clone(),
1218 steps,
1219 metadata: SetupPlanMetadata {
1220 bundle_name: request.bundle_name.clone(),
1221 pack_refs: Vec::new(),
1222 tenants,
1223 default_assignments: request.default_assignments.clone(),
1224 providers: request.providers.clone(),
1225 update_ops: request.update_ops.clone(),
1226 remove_targets: targets,
1227 packs_remove: request.packs_remove.clone(),
1228 providers_remove: request.providers_remove.clone(),
1229 tenants_remove: request.tenants_remove.clone(),
1230 access_changes: request.access_changes.clone(),
1231 static_routes: request.static_routes.clone(),
1232 deployment_targets: request.deployment_targets.clone(),
1233 setup_answers: request.setup_answers.clone(),
1234 },
1235 })
1236}
1237
1238pub fn print_plan_summary(plan: &SetupPlan) {
1240 println!("wizard plan: mode={} dry_run={}", plan.mode, plan.dry_run);
1241 println!("bundle: {}", plan.bundle.display());
1242 let noop_count = plan
1243 .steps
1244 .iter()
1245 .filter(|s| s.kind == SetupStepKind::NoOp)
1246 .count();
1247 if noop_count > 0 {
1248 println!("no-op steps: {noop_count}");
1249 }
1250 for (index, s) in plan.steps.iter().enumerate() {
1251 println!("{}. {}", index + 1, s.description);
1252 }
1253}
1254
1255fn dedup_sorted(refs: &[String]) -> Vec<String> {
1258 let mut v: Vec<String> = refs
1259 .iter()
1260 .map(|r| r.trim().to_string())
1261 .filter(|r| !r.is_empty())
1262 .collect();
1263 v.sort();
1264 v.dedup();
1265 v
1266}
1267
1268fn normalize_tenants(tenants: &[TenantSelection]) -> Vec<TenantSelection> {
1269 let mut result: Vec<TenantSelection> = tenants
1270 .iter()
1271 .map(|t| {
1272 let mut t = t.clone();
1273 t.allow_paths.sort();
1274 t.allow_paths.dedup();
1275 t
1276 })
1277 .collect();
1278 result.sort_by(|a, b| {
1279 a.tenant
1280 .cmp(&b.tenant)
1281 .then_with(|| a.team.cmp(&b.team))
1282 .then_with(|| a.allow_paths.cmp(&b.allow_paths))
1283 });
1284 result
1285}
1286
1287fn infer_update_ops(
1288 ops: &mut BTreeSet<UpdateOp>,
1289 pack_refs: &[String],
1290 request: &SetupRequest,
1291 tenants: &[TenantSelection],
1292) {
1293 if !pack_refs.is_empty() {
1294 ops.insert(UpdateOp::PacksAdd);
1295 }
1296 if !request.providers.is_empty() {
1297 ops.insert(UpdateOp::ProvidersAdd);
1298 }
1299 if !request.providers_remove.is_empty() {
1300 ops.insert(UpdateOp::ProvidersRemove);
1301 }
1302 if !request.packs_remove.is_empty() {
1303 ops.insert(UpdateOp::PacksRemove);
1304 }
1305 if !tenants.is_empty() {
1306 ops.insert(UpdateOp::TenantsAdd);
1307 }
1308 if !request.tenants_remove.is_empty() {
1309 ops.insert(UpdateOp::TenantsRemove);
1310 }
1311 if !request.access_changes.is_empty() || tenants.iter().any(|t| !t.allow_paths.is_empty()) {
1312 ops.insert(UpdateOp::AccessChange);
1313 }
1314}
1315
1316fn build_metadata(
1317 request: &SetupRequest,
1318 pack_refs: Vec<String>,
1319 tenants: Vec<TenantSelection>,
1320) -> SetupPlanMetadata {
1321 SetupPlanMetadata {
1322 bundle_name: request.bundle_name.clone(),
1323 pack_refs,
1324 tenants,
1325 default_assignments: request.default_assignments.clone(),
1326 providers: request.providers.clone(),
1327 update_ops: request.update_ops.clone(),
1328 remove_targets: request.remove_targets.clone(),
1329 packs_remove: request.packs_remove.clone(),
1330 providers_remove: request.providers_remove.clone(),
1331 tenants_remove: request.tenants_remove.clone(),
1332 access_changes: request.access_changes.clone(),
1333 static_routes: request.static_routes.clone(),
1334 deployment_targets: request.deployment_targets.clone(),
1335 setup_answers: request.setup_answers.clone(),
1336 }
1337}
1338
1339fn build_metadata_with_ops(
1340 request: &SetupRequest,
1341 pack_refs: Vec<String>,
1342 tenants: Vec<TenantSelection>,
1343 ops: BTreeSet<UpdateOp>,
1344) -> SetupPlanMetadata {
1345 let mut meta = build_metadata(request, pack_refs, tenants);
1346 meta.update_ops = ops;
1347 meta
1348}
1349
1350fn compute_simple_hash(input: &str) -> String {
1352 use std::collections::hash_map::DefaultHasher;
1353 use std::hash::{Hash, Hasher};
1354
1355 let mut hasher = DefaultHasher::new();
1356 input.hash(&mut hasher);
1357 format!("{:016x}", hasher.finish())
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362 use super::*;
1363 use crate::bundle;
1364 use crate::platform_setup::static_routes_artifact_path;
1365 use serde_json::json;
1366
1367 fn empty_request(bundle: PathBuf) -> SetupRequest {
1368 SetupRequest {
1369 bundle,
1370 bundle_name: None,
1371 pack_refs: Vec::new(),
1372 tenants: vec![TenantSelection {
1373 tenant: "demo".to_string(),
1374 team: Some("default".to_string()),
1375 allow_paths: vec!["packs/default".to_string()],
1376 }],
1377 default_assignments: Vec::new(),
1378 providers: Vec::new(),
1379 update_ops: BTreeSet::new(),
1380 remove_targets: BTreeSet::new(),
1381 packs_remove: Vec::new(),
1382 providers_remove: Vec::new(),
1383 tenants_remove: Vec::new(),
1384 access_changes: Vec::new(),
1385 static_routes: StaticRoutesPolicy::default(),
1386 setup_answers: serde_json::Map::new(),
1387 ..Default::default()
1388 }
1389 }
1390
1391 #[test]
1392 fn create_plan_is_deterministic() {
1393 let req = SetupRequest {
1394 bundle: PathBuf::from("bundle"),
1395 bundle_name: None,
1396 pack_refs: vec![
1397 "repo://zeta/pack@1".to_string(),
1398 "repo://alpha/pack@1".to_string(),
1399 "repo://alpha/pack@1".to_string(),
1400 ],
1401 tenants: vec![
1402 TenantSelection {
1403 tenant: "demo".to_string(),
1404 team: Some("default".to_string()),
1405 allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
1406 },
1407 TenantSelection {
1408 tenant: "alpha".to_string(),
1409 team: None,
1410 allow_paths: vec!["x".to_string()],
1411 },
1412 ],
1413 default_assignments: Vec::new(),
1414 providers: Vec::new(),
1415 update_ops: BTreeSet::new(),
1416 remove_targets: BTreeSet::new(),
1417 packs_remove: Vec::new(),
1418 providers_remove: Vec::new(),
1419 tenants_remove: Vec::new(),
1420 access_changes: Vec::new(),
1421 static_routes: StaticRoutesPolicy::default(),
1422 setup_answers: serde_json::Map::new(),
1423 ..Default::default()
1424 };
1425 let plan = apply_create(&req, true).unwrap();
1426 assert_eq!(
1427 plan.metadata.pack_refs,
1428 vec![
1429 "repo://alpha/pack@1".to_string(),
1430 "repo://zeta/pack@1".to_string()
1431 ]
1432 );
1433 assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
1434 assert_eq!(
1435 plan.metadata.tenants[1].allow_paths,
1436 vec!["pack/a".to_string(), "pack/b".to_string()]
1437 );
1438 }
1439
1440 #[test]
1441 fn dry_run_does_not_create_files() {
1442 let bundle = PathBuf::from("/tmp/nonexistent-bundle");
1443 let req = empty_request(bundle.clone());
1444 let _plan = apply_create(&req, true).unwrap();
1445 assert!(!bundle.exists());
1446 }
1447
1448 #[test]
1449 fn create_requires_tenants() {
1450 let req = SetupRequest {
1451 tenants: vec![],
1452 ..empty_request(PathBuf::from("x"))
1453 };
1454 assert!(apply_create(&req, true).is_err());
1455 }
1456
1457 #[test]
1458 fn load_answers_reads_platform_setup_and_provider_answers() {
1459 let temp = tempfile::tempdir().unwrap();
1460 let answers_path = temp.path().join("answers.yaml");
1461 std::fs::write(
1462 &answers_path,
1463 r#"
1464bundle_source: ./bundle
1465env: prod
1466platform_setup:
1467 static_routes:
1468 public_web_enabled: true
1469 public_base_url: https://example.com/base/
1470 deployment_targets:
1471 - target: aws
1472 provider_pack: packs/aws.gtpack
1473 default: true
1474setup_answers:
1475 messaging-webchat:
1476 jwt_signing_key: abc
1477"#,
1478 )
1479 .unwrap();
1480
1481 let engine = SetupEngine::new(SetupConfig {
1482 tenant: "demo".into(),
1483 team: None,
1484 env: "prod".into(),
1485 offline: false,
1486 verbose: false,
1487 });
1488 let loaded = engine.load_answers(&answers_path, None, false).unwrap();
1489 assert_eq!(
1490 loaded
1491 .platform_setup
1492 .static_routes
1493 .as_ref()
1494 .and_then(|v| v.public_base_url.as_deref()),
1495 Some("https://example.com/base/")
1496 );
1497 assert_eq!(
1498 loaded
1499 .setup_answers
1500 .get("messaging-webchat")
1501 .and_then(|v| v.get("jwt_signing_key"))
1502 .and_then(Value::as_str),
1503 Some("abc")
1504 );
1505 assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
1506 assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
1507 }
1508
1509 #[test]
1510 fn emit_answers_includes_platform_setup() {
1511 let temp = tempfile::tempdir().unwrap();
1512 let bundle_root = temp.path().join("bundle");
1513 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1514
1515 let engine = SetupEngine::new(SetupConfig {
1516 tenant: "demo".into(),
1517 team: None,
1518 env: "prod".into(),
1519 offline: false,
1520 verbose: false,
1521 });
1522 let request = SetupRequest {
1523 bundle: bundle_root.clone(),
1524 tenants: vec![TenantSelection {
1525 tenant: "demo".into(),
1526 team: None,
1527 allow_paths: Vec::new(),
1528 }],
1529 static_routes: StaticRoutesPolicy {
1530 public_web_enabled: true,
1531 public_base_url: Some("https://example.com".into()),
1532 public_surface_policy: "enabled".into(),
1533 default_route_prefix_policy: "pack_declared".into(),
1534 tenant_path_policy: "pack_declared".into(),
1535 ..StaticRoutesPolicy::default()
1536 },
1537 ..Default::default()
1538 };
1539 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
1540 let output = temp.path().join("answers.json");
1541 engine.emit_answers(&plan, &output, None, false).unwrap();
1542 let emitted: Value =
1543 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
1544 assert_eq!(
1545 emitted["platform_setup"]["static_routes"]["public_base_url"],
1546 json!("https://example.com")
1547 );
1548 assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
1549 }
1550
1551 #[test]
1552 fn emit_answers_falls_back_to_runtime_public_endpoint() {
1553 let temp = tempfile::tempdir().unwrap();
1554 let bundle_root = temp.path().join("bundle");
1555 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1556 let runtime_dir = bundle_root
1557 .join("state")
1558 .join("runtime")
1559 .join("demo.default");
1560 std::fs::create_dir_all(&runtime_dir).unwrap();
1561 std::fs::write(
1562 runtime_dir.join("endpoints.json"),
1563 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
1564 )
1565 .unwrap();
1566
1567 let engine = SetupEngine::new(SetupConfig {
1568 tenant: "demo".into(),
1569 team: Some("default".into()),
1570 env: "prod".into(),
1571 offline: false,
1572 verbose: false,
1573 });
1574 let request = SetupRequest {
1575 bundle: bundle_root.clone(),
1576 tenants: vec![TenantSelection {
1577 tenant: "demo".into(),
1578 team: Some("default".into()),
1579 allow_paths: Vec::new(),
1580 }],
1581 ..Default::default()
1582 };
1583 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
1584 let output = temp.path().join("answers-runtime.json");
1585 engine.emit_answers(&plan, &output, None, false).unwrap();
1586 let emitted: Value =
1587 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
1588 assert_eq!(
1589 emitted["platform_setup"]["static_routes"]["public_base_url"],
1590 json!("https://runtime.example.com")
1591 );
1592 }
1593
1594 #[test]
1595 fn execute_persists_static_routes_artifact() {
1596 let temp = tempfile::tempdir().unwrap();
1597 let bundle_root = temp.path().join("bundle");
1598 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1599
1600 let engine = SetupEngine::new(SetupConfig {
1601 tenant: "demo".into(),
1602 team: None,
1603 env: "prod".into(),
1604 offline: false,
1605 verbose: false,
1606 });
1607 let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
1608 metadata.static_routes = StaticRoutesPolicy {
1609 public_web_enabled: true,
1610 public_base_url: Some("https://example.com".into()),
1611 public_surface_policy: "enabled".into(),
1612 default_route_prefix_policy: "pack_declared".into(),
1613 tenant_path_policy: "pack_declared".into(),
1614 ..StaticRoutesPolicy::default()
1615 };
1616
1617 engine
1618 .execute_apply_pack_setup(&bundle_root, &metadata)
1619 .unwrap();
1620 let artifact = static_routes_artifact_path(&bundle_root);
1621 assert!(artifact.exists());
1622 let stored: Value =
1623 serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
1624 assert_eq!(stored["public_web_enabled"], json!(true));
1625 }
1626
1627 #[test]
1628 fn remove_execute_deletes_provider_artifact_and_config_dir() {
1629 let temp = tempfile::tempdir().unwrap();
1630 let bundle_root = temp.path().join("bundle");
1631 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1632 let provider_dir = bundle_root.join("providers").join("messaging");
1633 std::fs::create_dir_all(&provider_dir).unwrap();
1634 let provider_pack = provider_dir.join("messaging-webchat.gtpack");
1635 std::fs::copy(
1636 bundle_root.join("packs").join("default.gtpack"),
1637 &provider_pack,
1638 )
1639 .unwrap();
1640 let config_dir = bundle_root
1641 .join("state")
1642 .join("config")
1643 .join("messaging-webchat");
1644 std::fs::create_dir_all(&config_dir).unwrap();
1645 std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
1646
1647 let engine = SetupEngine::new(SetupConfig {
1648 tenant: "demo".into(),
1649 team: None,
1650 env: "prod".into(),
1651 offline: false,
1652 verbose: false,
1653 });
1654 let request = SetupRequest {
1655 bundle: bundle_root.clone(),
1656 providers_remove: vec!["messaging-webchat".into()],
1657 ..Default::default()
1658 };
1659 let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
1660 engine.execute(&plan).unwrap();
1661
1662 assert!(!provider_pack.exists());
1663 assert!(!config_dir.exists());
1664 }
1665
1666 #[test]
1667 fn update_plan_preserves_static_routes_policy() {
1668 let req = SetupRequest {
1669 bundle: PathBuf::from("bundle"),
1670 tenants: vec![TenantSelection {
1671 tenant: "demo".into(),
1672 team: None,
1673 allow_paths: Vec::new(),
1674 }],
1675 static_routes: StaticRoutesPolicy {
1676 public_web_enabled: true,
1677 public_base_url: Some("https://example.com/new".into()),
1678 public_surface_policy: "enabled".into(),
1679 default_route_prefix_policy: "pack_declared".into(),
1680 tenant_path_policy: "pack_declared".into(),
1681 ..StaticRoutesPolicy::default()
1682 },
1683 ..Default::default()
1684 };
1685 let plan = apply_update(&req, true).unwrap();
1686 assert_eq!(
1687 plan.metadata.static_routes.public_base_url.as_deref(),
1688 Some("https://example.com/new")
1689 );
1690 }
1691}