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, persist_tunnel_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 if let Some(tunnel) = plan.metadata.tunnel.as_ref() {
180 let _ = persist_tunnel_artifact(bundle, tunnel);
181 }
182
183 Ok(report)
184 }
185
186 pub fn emit_answers(
191 &self,
192 plan: &SetupPlan,
193 output_path: &Path,
194 key: Option<&str>,
195 interactive: bool,
196 ) -> anyhow::Result<()> {
197 emit_answers(&self.config, plan, output_path, key, interactive)
198 }
199
200 pub fn load_answers(
202 &self,
203 answers_path: &Path,
204 key: Option<&str>,
205 interactive: bool,
206 ) -> anyhow::Result<LoadedAnswers> {
207 load_answers(answers_path, key, interactive)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::bundle;
215 use crate::platform_setup::{StaticRoutesPolicy, static_routes_artifact_path};
216 use serde_json::json;
217 use std::collections::BTreeSet;
218 use std::path::PathBuf;
219
220 fn empty_request(bundle: PathBuf) -> SetupRequest {
221 SetupRequest {
222 bundle,
223 bundle_name: None,
224 pack_refs: Vec::new(),
225 tenants: vec![TenantSelection {
226 tenant: "demo".to_string(),
227 team: Some("default".to_string()),
228 allow_paths: vec!["packs/default".to_string()],
229 }],
230 default_assignments: Vec::new(),
231 providers: Vec::new(),
232 update_ops: BTreeSet::new(),
233 remove_targets: BTreeSet::new(),
234 packs_remove: Vec::new(),
235 providers_remove: Vec::new(),
236 tenants_remove: Vec::new(),
237 access_changes: Vec::new(),
238 static_routes: StaticRoutesPolicy::default(),
239 setup_answers: serde_json::Map::new(),
240 ..Default::default()
241 }
242 }
243
244 #[test]
245 fn create_plan_is_deterministic() {
246 let req = SetupRequest {
247 bundle: PathBuf::from("bundle"),
248 bundle_name: None,
249 pack_refs: vec![
250 "repo://zeta/pack@1".to_string(),
251 "repo://alpha/pack@1".to_string(),
252 "repo://alpha/pack@1".to_string(),
253 ],
254 tenants: vec![
255 TenantSelection {
256 tenant: "demo".to_string(),
257 team: Some("default".to_string()),
258 allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
259 },
260 TenantSelection {
261 tenant: "alpha".to_string(),
262 team: None,
263 allow_paths: vec!["x".to_string()],
264 },
265 ],
266 default_assignments: Vec::new(),
267 providers: Vec::new(),
268 update_ops: BTreeSet::new(),
269 remove_targets: BTreeSet::new(),
270 packs_remove: Vec::new(),
271 providers_remove: Vec::new(),
272 tenants_remove: Vec::new(),
273 access_changes: Vec::new(),
274 static_routes: StaticRoutesPolicy::default(),
275 setup_answers: serde_json::Map::new(),
276 ..Default::default()
277 };
278 let plan = apply_create(&req, true).unwrap();
279 assert_eq!(
280 plan.metadata.pack_refs,
281 vec![
282 "repo://alpha/pack@1".to_string(),
283 "repo://zeta/pack@1".to_string()
284 ]
285 );
286 assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
287 assert_eq!(
288 plan.metadata.tenants[1].allow_paths,
289 vec!["pack/a".to_string(), "pack/b".to_string()]
290 );
291 }
292
293 #[test]
294 fn dry_run_does_not_create_files() {
295 let bundle = PathBuf::from("/tmp/nonexistent-bundle");
296 let req = empty_request(bundle.clone());
297 let _plan = apply_create(&req, true).unwrap();
298 assert!(!bundle.exists());
299 }
300
301 #[test]
302 fn create_requires_tenants() {
303 let req = SetupRequest {
304 tenants: vec![],
305 ..empty_request(PathBuf::from("x"))
306 };
307 assert!(apply_create(&req, true).is_err());
308 }
309
310 #[test]
311 fn load_answers_reads_platform_setup_and_provider_answers() {
312 let temp = tempfile::tempdir().unwrap();
313 let answers_path = temp.path().join("answers.yaml");
314 std::fs::write(
315 &answers_path,
316 r#"
317bundle_source: ./bundle
318tenant: acme
319team: core
320env: prod
321platform_setup:
322 static_routes:
323 public_web_enabled: true
324 public_base_url: https://example.com/base/
325 deployment_targets:
326 - target: aws
327 provider_pack: packs/aws.gtpack
328 default: true
329setup_answers:
330 messaging-webchat:
331 jwt_signing_key: abc
332"#,
333 )
334 .unwrap();
335
336 let loaded = load_answers(&answers_path, None, false).unwrap();
337 assert_eq!(
338 loaded
339 .platform_setup
340 .static_routes
341 .as_ref()
342 .and_then(|v| v.public_base_url.as_deref()),
343 Some("https://example.com/base/")
344 );
345 assert_eq!(
346 loaded
347 .setup_answers
348 .get("messaging-webchat")
349 .and_then(|v| v.get("jwt_signing_key"))
350 .and_then(serde_json::Value::as_str),
351 Some("abc")
352 );
353 assert_eq!(loaded.tenant.as_deref(), Some("acme"));
354 assert_eq!(loaded.team.as_deref(), Some("core"));
355 assert_eq!(loaded.env.as_deref(), Some("prod"));
356 assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
357 assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
358 }
359
360 #[test]
361 fn emit_answers_includes_platform_setup() {
362 let temp = tempfile::tempdir().unwrap();
363 let bundle_root = temp.path().join("bundle");
364 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
365
366 let engine = SetupEngine::new(SetupConfig {
367 tenant: "demo".into(),
368 team: None,
369 env: "prod".into(),
370 offline: false,
371 verbose: false,
372 });
373 let request = SetupRequest {
374 bundle: bundle_root.clone(),
375 tenants: vec![TenantSelection {
376 tenant: "demo".into(),
377 team: None,
378 allow_paths: Vec::new(),
379 }],
380 static_routes: StaticRoutesPolicy {
381 public_web_enabled: true,
382 public_base_url: Some("https://example.com".into()),
383 public_surface_policy: "enabled".into(),
384 default_route_prefix_policy: "pack_declared".into(),
385 tenant_path_policy: "pack_declared".into(),
386 ..StaticRoutesPolicy::default()
387 },
388 ..Default::default()
389 };
390 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
391 let output = temp.path().join("answers.json");
392 engine.emit_answers(&plan, &output, None, false).unwrap();
393 let emitted: serde_json::Value =
394 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
395 assert_eq!(
396 emitted["platform_setup"]["static_routes"]["public_base_url"],
397 json!("https://example.com")
398 );
399 assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
400 }
401
402 #[test]
403 fn emit_answers_falls_back_to_runtime_public_endpoint() {
404 let temp = tempfile::tempdir().unwrap();
405 let bundle_root = temp.path().join("bundle");
406 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
407 let runtime_dir = bundle_root
408 .join("state")
409 .join("runtime")
410 .join("demo.default");
411 std::fs::create_dir_all(&runtime_dir).unwrap();
412 std::fs::write(
413 runtime_dir.join("endpoints.json"),
414 r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
415 )
416 .unwrap();
417
418 let engine = SetupEngine::new(SetupConfig {
419 tenant: "demo".into(),
420 team: Some("default".into()),
421 env: "prod".into(),
422 offline: false,
423 verbose: false,
424 });
425 let request = SetupRequest {
426 bundle: bundle_root.clone(),
427 tenants: vec![TenantSelection {
428 tenant: "demo".into(),
429 team: Some("default".into()),
430 allow_paths: Vec::new(),
431 }],
432 ..Default::default()
433 };
434 let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
435 let output = temp.path().join("answers-runtime.json");
436 engine.emit_answers(&plan, &output, None, false).unwrap();
437 let emitted: serde_json::Value =
438 serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
439 assert_eq!(
440 emitted["platform_setup"]["static_routes"]["public_base_url"],
441 json!("https://runtime.example.com")
442 );
443 }
444
445 #[test]
446 fn execute_persists_static_routes_artifact() {
447 let temp = tempfile::tempdir().unwrap();
448 let bundle_root = temp.path().join("bundle");
449 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
450
451 let engine = SetupEngine::new(SetupConfig {
452 tenant: "demo".into(),
453 team: None,
454 env: "prod".into(),
455 offline: false,
456 verbose: false,
457 });
458 let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
459 metadata.static_routes = StaticRoutesPolicy {
460 public_web_enabled: true,
461 public_base_url: Some("https://example.com".into()),
462 public_surface_policy: "enabled".into(),
463 default_route_prefix_policy: "pack_declared".into(),
464 tenant_path_policy: "pack_declared".into(),
465 ..StaticRoutesPolicy::default()
466 };
467
468 execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
469 let artifact = static_routes_artifact_path(&bundle_root);
470 assert!(artifact.exists());
471 let stored: serde_json::Value =
472 serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
473 assert_eq!(stored["public_web_enabled"], json!(true));
474 }
475
476 #[test]
477 fn execute_create_persists_platform_metadata_without_provider_steps() {
478 let temp = tempfile::tempdir().unwrap();
479 let bundle_root = temp.path().join("bundle");
480
481 let engine = SetupEngine::new(SetupConfig {
482 tenant: "demo".into(),
483 team: Some("default".into()),
484 env: "prod".into(),
485 offline: false,
486 verbose: false,
487 });
488 let request = SetupRequest {
489 bundle: bundle_root.clone(),
490 static_routes: StaticRoutesPolicy {
491 public_web_enabled: true,
492 public_base_url: Some("https://example.com".into()),
493 public_surface_policy: "enabled".into(),
494 default_route_prefix_policy: "pack_declared".into(),
495 tenant_path_policy: "pack_declared".into(),
496 ..StaticRoutesPolicy::default()
497 },
498 deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
499 target: "runtime".into(),
500 provider_pack: None,
501 default: Some(true),
502 }],
503 ..empty_request(bundle_root.clone())
504 };
505
506 let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
507 engine.execute(&plan).unwrap();
508
509 let routes_artifact = static_routes_artifact_path(&bundle_root);
510 assert!(routes_artifact.exists());
511
512 let targets_artifact = bundle_root
513 .join(".greentic")
514 .join("deployment-targets.json");
515 assert!(targets_artifact.exists());
516 let stored: serde_json::Value =
517 serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
518 assert_eq!(stored["targets"][0]["target"], json!("runtime"));
519 assert_eq!(stored["targets"][0]["default"], json!(true));
520 }
521
522 #[test]
523 fn remove_execute_deletes_provider_artifact_and_config_dir() {
524 let temp = tempfile::tempdir().unwrap();
525 let bundle_root = temp.path().join("bundle");
526 bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
527 let provider_dir = bundle_root.join("providers").join("messaging");
528 std::fs::create_dir_all(&provider_dir).unwrap();
529 let provider_pack = provider_dir.join("messaging-webchat.gtpack");
530 std::fs::copy(
531 bundle_root.join("packs").join("default.gtpack"),
532 &provider_pack,
533 )
534 .unwrap();
535 let config_dir = bundle_root
536 .join("state")
537 .join("config")
538 .join("messaging-webchat");
539 std::fs::create_dir_all(&config_dir).unwrap();
540 std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
541
542 let engine = SetupEngine::new(SetupConfig {
543 tenant: "demo".into(),
544 team: None,
545 env: "prod".into(),
546 offline: false,
547 verbose: false,
548 });
549 let request = SetupRequest {
550 bundle: bundle_root.clone(),
551 providers_remove: vec!["messaging-webchat".into()],
552 ..Default::default()
553 };
554 let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
555 engine.execute(&plan).unwrap();
556
557 assert!(!provider_pack.exists());
558 assert!(!config_dir.exists());
559 }
560
561 #[test]
562 fn update_plan_preserves_static_routes_policy() {
563 let req = SetupRequest {
564 bundle: PathBuf::from("bundle"),
565 tenants: vec![TenantSelection {
566 tenant: "demo".into(),
567 team: None,
568 allow_paths: Vec::new(),
569 }],
570 static_routes: StaticRoutesPolicy {
571 public_web_enabled: true,
572 public_base_url: Some("https://example.com/new".into()),
573 public_surface_policy: "enabled".into(),
574 default_route_prefix_policy: "pack_declared".into(),
575 tenant_path_policy: "pack_declared".into(),
576 ..StaticRoutesPolicy::default()
577 },
578 ..Default::default()
579 };
580 let plan = apply_update(&req, true).unwrap();
581 assert_eq!(
582 plan.metadata.static_routes.public_base_url.as_deref(),
583 Some("https://example.com/new")
584 );
585 }
586
587 #[test]
588 fn extract_default_from_help_parses_parenthesized() {
589 let help = "Slack API base URL (default: https://slack.com/api)";
590 let result = extract_default_from_help(help);
591 assert_eq!(result, Some("https://slack.com/api".to_string()));
592 }
593
594 #[test]
595 fn extract_default_from_help_parses_bracketed() {
596 let help = "Enable feature [default: true]";
597 let result = extract_default_from_help(help);
598 assert_eq!(result, Some("true".to_string()));
599 }
600
601 #[test]
602 fn extract_default_from_help_case_insensitive() {
603 let help = "Some setting (Default: custom_value)";
604 let result = extract_default_from_help(help);
605 assert_eq!(result, Some("custom_value".to_string()));
606 }
607
608 #[test]
609 fn extract_default_from_help_returns_none_without_default() {
610 let help = "Just a plain help text with no default";
611 let result = extract_default_from_help(help);
612 assert_eq!(result, None);
613 }
614
615 #[test]
616 fn infer_default_value_uses_explicit_default() {
617 use crate::setup_input::SetupQuestion;
618 let question = SetupQuestion {
619 name: "api_base_url".to_string(),
620 kind: "string".to_string(),
621 required: true,
622 help: Some("Some help (default: wrong_value)".to_string()),
623 choices: vec![],
624 default: Some(json!("https://explicit.com")),
625 secret: false,
626 title: None,
627 visible_if: None,
628 ..Default::default()
629 };
630 let result = infer_default_value(&question);
631 assert_eq!(result, json!("https://explicit.com"));
632 }
633
634 #[test]
635 fn infer_default_value_extracts_from_help() {
636 use crate::setup_input::SetupQuestion;
637 let question = SetupQuestion {
638 name: "api_base_url".to_string(),
639 kind: "string".to_string(),
640 required: true,
641 help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
642 choices: vec![],
643 default: None,
644 secret: false,
645 title: None,
646 visible_if: None,
647 ..Default::default()
648 };
649 let result = infer_default_value(&question);
650 assert_eq!(result, json!("https://slack.com/api"));
651 }
652
653 #[test]
654 fn infer_default_value_returns_empty_without_default() {
655 use crate::setup_input::SetupQuestion;
656 let question = SetupQuestion {
657 name: "bot_token".to_string(),
658 kind: "string".to_string(),
659 required: true,
660 help: Some("Your bot token".to_string()),
661 choices: vec![],
662 default: None,
663 secret: true,
664 title: None,
665 visible_if: None,
666 ..Default::default()
667 };
668 let result = infer_default_value(&question);
669 assert_eq!(result, json!(""));
670 }
671}