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