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
308env: prod
309platform_setup:
310 static_routes:
311 public_web_enabled: true
312 public_base_url: https://example.com/base/
313 deployment_targets:
314 - target: aws
315 provider_pack: packs/aws.gtpack
316 default: true
317setup_answers:
318 messaging-webchat:
319 jwt_signing_key: abc
320"#,
321 )
322 .unwrap();
323
324 let loaded = load_answers(&answers_path, None, false).unwrap();
325 assert_eq!(
326 loaded
327 .platform_setup
328 .static_routes
329 .as_ref()
330 .and_then(|v| v.public_base_url.as_deref()),
331 Some("https://example.com/base/")
332 );
333 assert_eq!(
334 loaded
335 .setup_answers
336 .get("messaging-webchat")
337 .and_then(|v| v.get("jwt_signing_key"))
338 .and_then(serde_json::Value::as_str),
339 Some("abc")
340 );
341 assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
342 assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
343 }
344
345 #[test]
346 fn emit_answers_includes_platform_setup() {
347 let temp = tempfile::tempdir().unwrap();
348 let bundle_root = temp.path().join("bundle");
349 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
350
351 let engine = SetupEngine::new(SetupConfig {
352 tenant: "demo".into(),
353 team: None,
354 env: "prod".into(),
355 offline: false,
356 verbose: false,
357 });
358 let request = SetupRequest {
359 bundle: bundle_root.clone(),
360 tenants: vec![TenantSelection {
361 tenant: "demo".into(),
362 team: None,
363 allow_paths: Vec::new(),
364 }],
365 static_routes: StaticRoutesPolicy {
366 public_web_enabled: true,
367 public_base_url: Some("https://example.com".into()),
368 public_surface_policy: "enabled".into(),
369 default_route_prefix_policy: "pack_declared".into(),
370 tenant_path_policy: "pack_declared".into(),
371 ..StaticRoutesPolicy::default()
372 },
373 ..Default::default()
374 };
375 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
376 let output = temp.path().join("answers.json");
377 engine.emit_answers(&plan, &output, None, false).unwrap();
378 let emitted: serde_json::Value =
379 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
380 assert_eq!(
381 emitted["platform_setup"]["static_routes"]["public_base_url"],
382 json!("https://example.com")
383 );
384 assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
385 }
386
387 #[test]
388 fn emit_answers_falls_back_to_runtime_public_endpoint() {
389 let temp = tempfile::tempdir().unwrap();
390 let bundle_root = temp.path().join("bundle");
391 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
392 let runtime_dir = bundle_root
393 .join("state")
394 .join("runtime")
395 .join("demo.default");
396 std::fs::create_dir_all(&runtime_dir).unwrap();
397 std::fs::write(
398 runtime_dir.join("endpoints.json"),
399 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
400 )
401 .unwrap();
402
403 let engine = SetupEngine::new(SetupConfig {
404 tenant: "demo".into(),
405 team: Some("default".into()),
406 env: "prod".into(),
407 offline: false,
408 verbose: false,
409 });
410 let request = SetupRequest {
411 bundle: bundle_root.clone(),
412 tenants: vec![TenantSelection {
413 tenant: "demo".into(),
414 team: Some("default".into()),
415 allow_paths: Vec::new(),
416 }],
417 ..Default::default()
418 };
419 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
420 let output = temp.path().join("answers-runtime.json");
421 engine.emit_answers(&plan, &output, None, false).unwrap();
422 let emitted: serde_json::Value =
423 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
424 assert_eq!(
425 emitted["platform_setup"]["static_routes"]["public_base_url"],
426 json!("https://runtime.example.com")
427 );
428 }
429
430 #[test]
431 fn execute_persists_static_routes_artifact() {
432 let temp = tempfile::tempdir().unwrap();
433 let bundle_root = temp.path().join("bundle");
434 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
435
436 let engine = SetupEngine::new(SetupConfig {
437 tenant: "demo".into(),
438 team: None,
439 env: "prod".into(),
440 offline: false,
441 verbose: false,
442 });
443 let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
444 metadata.static_routes = StaticRoutesPolicy {
445 public_web_enabled: true,
446 public_base_url: Some("https://example.com".into()),
447 public_surface_policy: "enabled".into(),
448 default_route_prefix_policy: "pack_declared".into(),
449 tenant_path_policy: "pack_declared".into(),
450 ..StaticRoutesPolicy::default()
451 };
452
453 execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
454 let artifact = static_routes_artifact_path(&bundle_root);
455 assert!(artifact.exists());
456 let stored: serde_json::Value =
457 serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
458 assert_eq!(stored["public_web_enabled"], json!(true));
459 }
460
461 #[test]
462 fn execute_create_persists_platform_metadata_without_provider_steps() {
463 let temp = tempfile::tempdir().unwrap();
464 let bundle_root = temp.path().join("bundle");
465
466 let engine = SetupEngine::new(SetupConfig {
467 tenant: "demo".into(),
468 team: Some("default".into()),
469 env: "prod".into(),
470 offline: false,
471 verbose: false,
472 });
473 let request = SetupRequest {
474 bundle: bundle_root.clone(),
475 static_routes: StaticRoutesPolicy {
476 public_web_enabled: true,
477 public_base_url: Some("https://example.com".into()),
478 public_surface_policy: "enabled".into(),
479 default_route_prefix_policy: "pack_declared".into(),
480 tenant_path_policy: "pack_declared".into(),
481 ..StaticRoutesPolicy::default()
482 },
483 deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
484 target: "runtime".into(),
485 provider_pack: None,
486 default: Some(true),
487 }],
488 ..empty_request(bundle_root.clone())
489 };
490
491 let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
492 engine.execute(&plan).unwrap();
493
494 let routes_artifact = static_routes_artifact_path(&bundle_root);
495 assert!(routes_artifact.exists());
496
497 let targets_artifact = bundle_root
498 .join(".greentic")
499 .join("deployment-targets.json");
500 assert!(targets_artifact.exists());
501 let stored: serde_json::Value =
502 serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
503 assert_eq!(stored["targets"][0]["target"], json!("runtime"));
504 assert_eq!(stored["targets"][0]["default"], json!(true));
505 }
506
507 #[test]
508 fn remove_execute_deletes_provider_artifact_and_config_dir() {
509 let temp = tempfile::tempdir().unwrap();
510 let bundle_root = temp.path().join("bundle");
511 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
512 let provider_dir = bundle_root.join("providers").join("messaging");
513 std::fs::create_dir_all(&provider_dir).unwrap();
514 let provider_pack = provider_dir.join("messaging-webchat.gtpack");
515 std::fs::copy(
516 bundle_root.join("packs").join("default.gtpack"),
517 &provider_pack,
518 )
519 .unwrap();
520 let config_dir = bundle_root
521 .join("state")
522 .join("config")
523 .join("messaging-webchat");
524 std::fs::create_dir_all(&config_dir).unwrap();
525 std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
526
527 let engine = SetupEngine::new(SetupConfig {
528 tenant: "demo".into(),
529 team: None,
530 env: "prod".into(),
531 offline: false,
532 verbose: false,
533 });
534 let request = SetupRequest {
535 bundle: bundle_root.clone(),
536 providers_remove: vec!["messaging-webchat".into()],
537 ..Default::default()
538 };
539 let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
540 engine.execute(&plan).unwrap();
541
542 assert!(!provider_pack.exists());
543 assert!(!config_dir.exists());
544 }
545
546 #[test]
547 fn update_plan_preserves_static_routes_policy() {
548 let req = SetupRequest {
549 bundle: PathBuf::from("bundle"),
550 tenants: vec![TenantSelection {
551 tenant: "demo".into(),
552 team: None,
553 allow_paths: Vec::new(),
554 }],
555 static_routes: StaticRoutesPolicy {
556 public_web_enabled: true,
557 public_base_url: Some("https://example.com/new".into()),
558 public_surface_policy: "enabled".into(),
559 default_route_prefix_policy: "pack_declared".into(),
560 tenant_path_policy: "pack_declared".into(),
561 ..StaticRoutesPolicy::default()
562 },
563 ..Default::default()
564 };
565 let plan = apply_update(&req, true).unwrap();
566 assert_eq!(
567 plan.metadata.static_routes.public_base_url.as_deref(),
568 Some("https://example.com/new")
569 );
570 }
571
572 #[test]
573 fn extract_default_from_help_parses_parenthesized() {
574 let help = "Slack API base URL (default: https://slack.com/api)";
575 let result = extract_default_from_help(help);
576 assert_eq!(result, Some("https://slack.com/api".to_string()));
577 }
578
579 #[test]
580 fn extract_default_from_help_parses_bracketed() {
581 let help = "Enable feature [default: true]";
582 let result = extract_default_from_help(help);
583 assert_eq!(result, Some("true".to_string()));
584 }
585
586 #[test]
587 fn extract_default_from_help_case_insensitive() {
588 let help = "Some setting (Default: custom_value)";
589 let result = extract_default_from_help(help);
590 assert_eq!(result, Some("custom_value".to_string()));
591 }
592
593 #[test]
594 fn extract_default_from_help_returns_none_without_default() {
595 let help = "Just a plain help text with no default";
596 let result = extract_default_from_help(help);
597 assert_eq!(result, None);
598 }
599
600 #[test]
601 fn infer_default_value_uses_explicit_default() {
602 use crate::setup_input::SetupQuestion;
603 let question = SetupQuestion {
604 name: "api_base_url".to_string(),
605 kind: "string".to_string(),
606 required: true,
607 help: Some("Some help (default: wrong_value)".to_string()),
608 choices: vec![],
609 default: Some(json!("https://explicit.com")),
610 secret: false,
611 title: None,
612 visible_if: None,
613 };
614 let result = infer_default_value(&question);
615 assert_eq!(result, json!("https://explicit.com"));
616 }
617
618 #[test]
619 fn infer_default_value_extracts_from_help() {
620 use crate::setup_input::SetupQuestion;
621 let question = SetupQuestion {
622 name: "api_base_url".to_string(),
623 kind: "string".to_string(),
624 required: true,
625 help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
626 choices: vec![],
627 default: None,
628 secret: false,
629 title: None,
630 visible_if: None,
631 };
632 let result = infer_default_value(&question);
633 assert_eq!(result, json!("https://slack.com/api"));
634 }
635
636 #[test]
637 fn infer_default_value_returns_empty_without_default() {
638 use crate::setup_input::SetupQuestion;
639 let question = SetupQuestion {
640 name: "bot_token".to_string(),
641 kind: "string".to_string(),
642 required: true,
643 help: Some("Your bot token".to_string()),
644 choices: vec![],
645 default: None,
646 secret: true,
647 title: None,
648 visible_if: None,
649 };
650 let result = infer_default_value(&question);
651 assert_eq!(result, json!(""));
652 }
653}