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