1mod answers;
8mod executors;
9mod plan_builders;
10mod types;
11
12use std::path::Path;
13
14use anyhow::anyhow;
15
16use crate::plan::*;
17use crate::platform_setup::persist_static_routes_artifact;
18
19pub use answers::{emit_answers, encrypt_secret_answers, load_answers, prompt_secret_answers};
21pub use executors::{
22 auto_install_provider_packs, domain_from_provider_id, execute_add_packs_to_bundle,
23 execute_apply_pack_setup, execute_build_flow_index, execute_copy_resolved_manifests,
24 execute_create_bundle, execute_remove_provider_artifacts, execute_resolve_packs,
25 execute_validate_bundle, execute_write_gmap_rules, find_provider_pack_source,
26 get_pack_target_dir,
27};
28pub use plan_builders::{
29 apply_create, apply_remove, apply_update, build_metadata, build_metadata_with_ops,
30 compute_simple_hash, dedup_sorted, extract_default_from_help, infer_default_value,
31 infer_update_ops, normalize_tenants, print_plan_summary,
32};
33pub use types::{LoadedAnswers, SetupConfig, SetupRequest};
34
35pub struct SetupEngine {
37 config: SetupConfig,
38}
39
40impl SetupEngine {
41 pub fn new(config: SetupConfig) -> Self {
42 Self { config }
43 }
44
45 pub fn plan(
47 &self,
48 mode: SetupMode,
49 request: &SetupRequest,
50 dry_run: bool,
51 ) -> anyhow::Result<SetupPlan> {
52 match mode {
53 SetupMode::Create => apply_create(request, dry_run),
54 SetupMode::Update => apply_update(request, dry_run),
55 SetupMode::Remove => apply_remove(request, dry_run),
56 }
57 }
58
59 pub fn print_plan(&self, plan: &SetupPlan) {
61 print_plan_summary(plan);
62 }
63
64 pub fn config(&self) -> &SetupConfig {
66 &self.config
67 }
68
69 pub fn execute(&self, plan: &SetupPlan) -> anyhow::Result<SetupExecutionReport> {
74 if plan.dry_run {
75 return Err(anyhow!("cannot execute a dry-run plan"));
76 }
77
78 let bundle = &plan.bundle;
79 let mut report = SetupExecutionReport {
80 bundle: bundle.clone(),
81 resolved_packs: Vec::new(),
82 resolved_manifests: Vec::new(),
83 provider_updates: 0,
84 warnings: Vec::new(),
85 };
86
87 for step in &plan.steps {
88 match step.kind {
89 SetupStepKind::NoOp => {
90 if self.config.verbose {
91 println!(" [skip] {}", step.description);
92 }
93 }
94 SetupStepKind::CreateBundle => {
95 execute_create_bundle(bundle, &plan.metadata)?;
96 if self.config.verbose {
97 println!(" [done] {}", step.description);
98 }
99 }
100 SetupStepKind::ResolvePacks => {
101 let resolved = execute_resolve_packs(bundle, &plan.metadata)?;
102 report.resolved_packs.extend(resolved);
103 if self.config.verbose {
104 println!(" [done] {}", step.description);
105 }
106 }
107 SetupStepKind::AddPacksToBundle => {
108 execute_add_packs_to_bundle(bundle, &report.resolved_packs)?;
109 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
110 bundle,
111 &plan.metadata.deployment_targets,
112 );
113 if self.config.verbose {
114 println!(" [done] {}", step.description);
115 }
116 }
117 SetupStepKind::ValidateCapabilities => {
118 let cap_report = crate::capabilities::validate_and_upgrade_packs(bundle)?;
119 for warn in &cap_report.warnings {
120 report.warnings.push(warn.message.clone());
121 }
122 if self.config.verbose {
123 println!(
124 " [done] {} (checked={}, upgraded={})",
125 step.description,
126 cap_report.checked,
127 cap_report.upgraded.len()
128 );
129 }
130 }
131 SetupStepKind::ApplyPackSetup => {
132 let count = execute_apply_pack_setup(bundle, &plan.metadata, &self.config)?;
133 report.provider_updates += count;
134 if self.config.verbose {
135 println!(" [done] {}", step.description);
136 }
137 }
138 SetupStepKind::WriteGmapRules => {
139 execute_write_gmap_rules(bundle, &plan.metadata)?;
140 if self.config.verbose {
141 println!(" [done] {}", step.description);
142 }
143 }
144 SetupStepKind::RunResolver => {
145 if self.config.verbose {
147 println!(" [skip] {} (deferred to runtime)", step.description);
148 }
149 }
150 SetupStepKind::CopyResolvedManifest => {
151 let manifests = execute_copy_resolved_manifests(bundle, &plan.metadata)?;
152 report.resolved_manifests.extend(manifests);
153 if self.config.verbose {
154 println!(" [done] {}", step.description);
155 }
156 }
157 SetupStepKind::ValidateBundle => {
158 execute_validate_bundle(bundle)?;
159 if self.config.verbose {
160 println!(" [done] {}", step.description);
161 }
162 }
163 SetupStepKind::BuildFlowIndex => {
164 execute_build_flow_index(bundle, &self.config)?;
165 if self.config.verbose {
166 println!(" [done] {}", step.description);
167 }
168 }
169 }
170 }
171
172 persist_static_routes_artifact(bundle, &plan.metadata.static_routes)?;
175 let _ = crate::deployment_targets::persist_explicit_deployment_targets(
176 bundle,
177 &plan.metadata.deployment_targets,
178 );
179
180 Ok(report)
181 }
182
183 pub fn emit_answers(
188 &self,
189 plan: &SetupPlan,
190 output_path: &Path,
191 key: Option<&str>,
192 interactive: bool,
193 ) -> anyhow::Result<()> {
194 emit_answers(&self.config, plan, output_path, key, interactive)
195 }
196
197 pub fn load_answers(
199 &self,
200 answers_path: &Path,
201 key: Option<&str>,
202 interactive: bool,
203 ) -> anyhow::Result<LoadedAnswers> {
204 load_answers(answers_path, key, interactive)
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use crate::bundle;
212 use crate::platform_setup::{StaticRoutesPolicy, static_routes_artifact_path};
213 use serde_json::json;
214 use std::collections::BTreeSet;
215 use std::path::PathBuf;
216
217 fn empty_request(bundle: PathBuf) -> SetupRequest {
218 SetupRequest {
219 bundle,
220 bundle_name: None,
221 pack_refs: Vec::new(),
222 tenants: vec![TenantSelection {
223 tenant: "demo".to_string(),
224 team: Some("default".to_string()),
225 allow_paths: vec!["packs/default".to_string()],
226 }],
227 default_assignments: Vec::new(),
228 providers: Vec::new(),
229 update_ops: BTreeSet::new(),
230 remove_targets: BTreeSet::new(),
231 packs_remove: Vec::new(),
232 providers_remove: Vec::new(),
233 tenants_remove: Vec::new(),
234 access_changes: Vec::new(),
235 static_routes: StaticRoutesPolicy::default(),
236 setup_answers: serde_json::Map::new(),
237 ..Default::default()
238 }
239 }
240
241 #[test]
242 fn create_plan_is_deterministic() {
243 let req = SetupRequest {
244 bundle: PathBuf::from("bundle"),
245 bundle_name: None,
246 pack_refs: vec![
247 "repo://zeta/pack@1".to_string(),
248 "repo://alpha/pack@1".to_string(),
249 "repo://alpha/pack@1".to_string(),
250 ],
251 tenants: vec![
252 TenantSelection {
253 tenant: "demo".to_string(),
254 team: Some("default".to_string()),
255 allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
256 },
257 TenantSelection {
258 tenant: "alpha".to_string(),
259 team: None,
260 allow_paths: vec!["x".to_string()],
261 },
262 ],
263 default_assignments: Vec::new(),
264 providers: Vec::new(),
265 update_ops: BTreeSet::new(),
266 remove_targets: BTreeSet::new(),
267 packs_remove: Vec::new(),
268 providers_remove: Vec::new(),
269 tenants_remove: Vec::new(),
270 access_changes: Vec::new(),
271 static_routes: StaticRoutesPolicy::default(),
272 setup_answers: serde_json::Map::new(),
273 ..Default::default()
274 };
275 let plan = apply_create(&req, true).unwrap();
276 assert_eq!(
277 plan.metadata.pack_refs,
278 vec![
279 "repo://alpha/pack@1".to_string(),
280 "repo://zeta/pack@1".to_string()
281 ]
282 );
283 assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
284 assert_eq!(
285 plan.metadata.tenants[1].allow_paths,
286 vec!["pack/a".to_string(), "pack/b".to_string()]
287 );
288 }
289
290 #[test]
291 fn dry_run_does_not_create_files() {
292 let bundle = PathBuf::from("/tmp/nonexistent-bundle");
293 let req = empty_request(bundle.clone());
294 let _plan = apply_create(&req, true).unwrap();
295 assert!(!bundle.exists());
296 }
297
298 #[test]
299 fn create_requires_tenants() {
300 let req = SetupRequest {
301 tenants: vec![],
302 ..empty_request(PathBuf::from("x"))
303 };
304 assert!(apply_create(&req, true).is_err());
305 }
306
307 #[test]
308 fn load_answers_reads_platform_setup_and_provider_answers() {
309 let temp = tempfile::tempdir().unwrap();
310 let answers_path = temp.path().join("answers.yaml");
311 std::fs::write(
312 &answers_path,
313 r#"
314bundle_source: ./bundle
315tenant: acme
316team: core
317env: prod
318platform_setup:
319 static_routes:
320 public_web_enabled: true
321 public_base_url: https://example.com/base/
322 deployment_targets:
323 - target: aws
324 provider_pack: packs/aws.gtpack
325 default: true
326setup_answers:
327 messaging-webchat:
328 jwt_signing_key: abc
329"#,
330 )
331 .unwrap();
332
333 let loaded = load_answers(&answers_path, None, false).unwrap();
334 assert_eq!(
335 loaded
336 .platform_setup
337 .static_routes
338 .as_ref()
339 .and_then(|v| v.public_base_url.as_deref()),
340 Some("https://example.com/base/")
341 );
342 assert_eq!(
343 loaded
344 .setup_answers
345 .get("messaging-webchat")
346 .and_then(|v| v.get("jwt_signing_key"))
347 .and_then(serde_json::Value::as_str),
348 Some("abc")
349 );
350 assert_eq!(loaded.tenant.as_deref(), Some("acme"));
351 assert_eq!(loaded.team.as_deref(), Some("core"));
352 assert_eq!(loaded.env.as_deref(), Some("prod"));
353 assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
354 assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
355 }
356
357 #[test]
358 fn emit_answers_includes_platform_setup() {
359 let temp = tempfile::tempdir().unwrap();
360 let bundle_root = temp.path().join("bundle");
361 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
362
363 let engine = SetupEngine::new(SetupConfig {
364 tenant: "demo".into(),
365 team: None,
366 env: "prod".into(),
367 offline: false,
368 verbose: false,
369 });
370 let request = SetupRequest {
371 bundle: bundle_root.clone(),
372 tenants: vec![TenantSelection {
373 tenant: "demo".into(),
374 team: None,
375 allow_paths: Vec::new(),
376 }],
377 static_routes: StaticRoutesPolicy {
378 public_web_enabled: true,
379 public_base_url: Some("https://example.com".into()),
380 public_surface_policy: "enabled".into(),
381 default_route_prefix_policy: "pack_declared".into(),
382 tenant_path_policy: "pack_declared".into(),
383 ..StaticRoutesPolicy::default()
384 },
385 ..Default::default()
386 };
387 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
388 let output = temp.path().join("answers.json");
389 engine.emit_answers(&plan, &output, None, false).unwrap();
390 let emitted: serde_json::Value =
391 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
392 assert_eq!(
393 emitted["platform_setup"]["static_routes"]["public_base_url"],
394 json!("https://example.com")
395 );
396 assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
397 }
398
399 #[test]
400 fn emit_answers_falls_back_to_runtime_public_endpoint() {
401 let temp = tempfile::tempdir().unwrap();
402 let bundle_root = temp.path().join("bundle");
403 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
404 let runtime_dir = bundle_root
405 .join("state")
406 .join("runtime")
407 .join("demo.default");
408 std::fs::create_dir_all(&runtime_dir).unwrap();
409 std::fs::write(
410 runtime_dir.join("endpoints.json"),
411 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
412 )
413 .unwrap();
414
415 let engine = SetupEngine::new(SetupConfig {
416 tenant: "demo".into(),
417 team: Some("default".into()),
418 env: "prod".into(),
419 offline: false,
420 verbose: false,
421 });
422 let request = SetupRequest {
423 bundle: bundle_root.clone(),
424 tenants: vec![TenantSelection {
425 tenant: "demo".into(),
426 team: Some("default".into()),
427 allow_paths: Vec::new(),
428 }],
429 ..Default::default()
430 };
431 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
432 let output = temp.path().join("answers-runtime.json");
433 engine.emit_answers(&plan, &output, None, false).unwrap();
434 let emitted: serde_json::Value =
435 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
436 assert_eq!(
437 emitted["platform_setup"]["static_routes"]["public_base_url"],
438 json!("https://runtime.example.com")
439 );
440 }
441
442 #[test]
443 fn execute_persists_static_routes_artifact() {
444 let temp = tempfile::tempdir().unwrap();
445 let bundle_root = temp.path().join("bundle");
446 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
447
448 let engine = SetupEngine::new(SetupConfig {
449 tenant: "demo".into(),
450 team: None,
451 env: "prod".into(),
452 offline: false,
453 verbose: false,
454 });
455 let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
456 metadata.static_routes = StaticRoutesPolicy {
457 public_web_enabled: true,
458 public_base_url: Some("https://example.com".into()),
459 public_surface_policy: "enabled".into(),
460 default_route_prefix_policy: "pack_declared".into(),
461 tenant_path_policy: "pack_declared".into(),
462 ..StaticRoutesPolicy::default()
463 };
464
465 execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
466 let artifact = static_routes_artifact_path(&bundle_root);
467 assert!(artifact.exists());
468 let stored: serde_json::Value =
469 serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
470 assert_eq!(stored["public_web_enabled"], json!(true));
471 }
472
473 #[test]
474 fn execute_create_persists_platform_metadata_without_provider_steps() {
475 let temp = tempfile::tempdir().unwrap();
476 let bundle_root = temp.path().join("bundle");
477
478 let engine = SetupEngine::new(SetupConfig {
479 tenant: "demo".into(),
480 team: Some("default".into()),
481 env: "prod".into(),
482 offline: false,
483 verbose: false,
484 });
485 let request = SetupRequest {
486 bundle: bundle_root.clone(),
487 static_routes: StaticRoutesPolicy {
488 public_web_enabled: true,
489 public_base_url: Some("https://example.com".into()),
490 public_surface_policy: "enabled".into(),
491 default_route_prefix_policy: "pack_declared".into(),
492 tenant_path_policy: "pack_declared".into(),
493 ..StaticRoutesPolicy::default()
494 },
495 deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
496 target: "runtime".into(),
497 provider_pack: None,
498 default: Some(true),
499 }],
500 ..empty_request(bundle_root.clone())
501 };
502
503 let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
504 engine.execute(&plan).unwrap();
505
506 let routes_artifact = static_routes_artifact_path(&bundle_root);
507 assert!(routes_artifact.exists());
508
509 let targets_artifact = bundle_root
510 .join(".greentic")
511 .join("deployment-targets.json");
512 assert!(targets_artifact.exists());
513 let stored: serde_json::Value =
514 serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
515 assert_eq!(stored["targets"][0]["target"], json!("runtime"));
516 assert_eq!(stored["targets"][0]["default"], json!(true));
517 }
518
519 #[test]
520 fn remove_execute_deletes_provider_artifact_and_config_dir() {
521 let temp = tempfile::tempdir().unwrap();
522 let bundle_root = temp.path().join("bundle");
523 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
524 let provider_dir = bundle_root.join("providers").join("messaging");
525 std::fs::create_dir_all(&provider_dir).unwrap();
526 let provider_pack = provider_dir.join("messaging-webchat.gtpack");
527 std::fs::copy(
528 bundle_root.join("packs").join("default.gtpack"),
529 &provider_pack,
530 )
531 .unwrap();
532 let config_dir = bundle_root
533 .join("state")
534 .join("config")
535 .join("messaging-webchat");
536 std::fs::create_dir_all(&config_dir).unwrap();
537 std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
538
539 let engine = SetupEngine::new(SetupConfig {
540 tenant: "demo".into(),
541 team: None,
542 env: "prod".into(),
543 offline: false,
544 verbose: false,
545 });
546 let request = SetupRequest {
547 bundle: bundle_root.clone(),
548 providers_remove: vec!["messaging-webchat".into()],
549 ..Default::default()
550 };
551 let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
552 engine.execute(&plan).unwrap();
553
554 assert!(!provider_pack.exists());
555 assert!(!config_dir.exists());
556 }
557
558 #[test]
559 fn update_plan_preserves_static_routes_policy() {
560 let req = SetupRequest {
561 bundle: PathBuf::from("bundle"),
562 tenants: vec![TenantSelection {
563 tenant: "demo".into(),
564 team: None,
565 allow_paths: Vec::new(),
566 }],
567 static_routes: StaticRoutesPolicy {
568 public_web_enabled: true,
569 public_base_url: Some("https://example.com/new".into()),
570 public_surface_policy: "enabled".into(),
571 default_route_prefix_policy: "pack_declared".into(),
572 tenant_path_policy: "pack_declared".into(),
573 ..StaticRoutesPolicy::default()
574 },
575 ..Default::default()
576 };
577 let plan = apply_update(&req, true).unwrap();
578 assert_eq!(
579 plan.metadata.static_routes.public_base_url.as_deref(),
580 Some("https://example.com/new")
581 );
582 }
583
584 #[test]
585 fn extract_default_from_help_parses_parenthesized() {
586 let help = "Slack API base URL (default: https://slack.com/api)";
587 let result = extract_default_from_help(help);
588 assert_eq!(result, Some("https://slack.com/api".to_string()));
589 }
590
591 #[test]
592 fn extract_default_from_help_parses_bracketed() {
593 let help = "Enable feature [default: true]";
594 let result = extract_default_from_help(help);
595 assert_eq!(result, Some("true".to_string()));
596 }
597
598 #[test]
599 fn extract_default_from_help_case_insensitive() {
600 let help = "Some setting (Default: custom_value)";
601 let result = extract_default_from_help(help);
602 assert_eq!(result, Some("custom_value".to_string()));
603 }
604
605 #[test]
606 fn extract_default_from_help_returns_none_without_default() {
607 let help = "Just a plain help text with no default";
608 let result = extract_default_from_help(help);
609 assert_eq!(result, None);
610 }
611
612 #[test]
613 fn infer_default_value_uses_explicit_default() {
614 use crate::setup_input::SetupQuestion;
615 let question = SetupQuestion {
616 name: "api_base_url".to_string(),
617 kind: "string".to_string(),
618 required: true,
619 help: Some("Some help (default: wrong_value)".to_string()),
620 choices: vec![],
621 default: Some(json!("https://explicit.com")),
622 secret: false,
623 title: None,
624 visible_if: None,
625 ..Default::default()
626 };
627 let result = infer_default_value(&question);
628 assert_eq!(result, json!("https://explicit.com"));
629 }
630
631 #[test]
632 fn infer_default_value_extracts_from_help() {
633 use crate::setup_input::SetupQuestion;
634 let question = SetupQuestion {
635 name: "api_base_url".to_string(),
636 kind: "string".to_string(),
637 required: true,
638 help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
639 choices: vec![],
640 default: None,
641 secret: false,
642 title: None,
643 visible_if: None,
644 ..Default::default()
645 };
646 let result = infer_default_value(&question);
647 assert_eq!(result, json!("https://slack.com/api"));
648 }
649
650 #[test]
651 fn infer_default_value_returns_empty_without_default() {
652 use crate::setup_input::SetupQuestion;
653 let question = SetupQuestion {
654 name: "bot_token".to_string(),
655 kind: "string".to_string(),
656 required: true,
657 help: Some("Your bot token".to_string()),
658 choices: vec![],
659 default: None,
660 secret: true,
661 title: None,
662 visible_if: None,
663 ..Default::default()
664 };
665 let result = infer_default_value(&question);
666 assert_eq!(result, json!(""));
667 }
668}