Skip to main content

greentic_setup/engine/
mod.rs

1//! Setup engine — orchestrates plan building and execution for
2//! create/update/remove workflows.
3//!
4//! This is the main entry point that consumers (e.g. greentic-operator)
5//! use to drive bundle setup.
6
7mod 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
19// Re-export types and functions for public API
20pub 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
35/// The setup engine orchestrates plan → execute for bundle lifecycle.
36pub struct SetupEngine {
37    config: SetupConfig,
38}
39
40impl SetupEngine {
41    pub fn new(config: SetupConfig) -> Self {
42        Self { config }
43    }
44
45    /// Build a plan for the given mode and request.
46    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    /// Print a human-readable plan summary to stdout.
60    pub fn print_plan(&self, plan: &SetupPlan) {
61        print_plan_summary(plan);
62    }
63
64    /// Access the engine configuration.
65    pub fn config(&self) -> &SetupConfig {
66        &self.config
67    }
68
69    /// Execute a setup plan.
70    ///
71    /// Runs each step in the plan, performing the actual bundle setup operations.
72    /// Returns an execution report with details about what was done.
73    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                    // Resolver is typically run by the runtime, not setup
146                    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 bundle-level platform metadata even when no provider pack setup
173        // steps ran, so create-only flows still materialize runtime/deployment config.
174        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    /// Emit an answers template JSON file.
187    ///
188    /// Discovers all packs in the bundle and generates a template with all
189    /// setup questions. Users fill this in and pass it via `--answers`.
190    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    /// Load answers from a JSON/YAML file.
201    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}