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;
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_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
34/// The setup engine orchestrates plan → execute for bundle lifecycle.
35pub struct SetupEngine {
36    config: SetupConfig,
37}
38
39impl SetupEngine {
40    pub fn new(config: SetupConfig) -> Self {
41        Self { config }
42    }
43
44    /// Build a plan for the given mode and request.
45    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    /// Print a human-readable plan summary to stdout.
59    pub fn print_plan(&self, plan: &SetupPlan) {
60        print_plan_summary(plan);
61    }
62
63    /// Access the engine configuration.
64    pub fn config(&self) -> &SetupConfig {
65        &self.config
66    }
67
68    /// Execute a setup plan.
69    ///
70    /// Runs each step in the plan, performing the actual bundle setup operations.
71    /// Returns an execution report with details about what was done.
72    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                    // Resolver is typically run by the runtime, not setup
145                    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 bundle-level platform metadata even when no provider pack setup
166        // steps ran, so create-only flows still materialize runtime/deployment config.
167        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    /// Emit an answers template JSON file.
177    ///
178    /// Discovers all packs in the bundle and generates a template with all
179    /// setup questions. Users fill this in and pass it via `--answers`.
180    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    /// Load answers from a JSON/YAML file.
191    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}