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::{
18    persist_static_routes_artifact, persist_telemetry_artifact, persist_tunnel_artifact,
19};
20
21// Re-export types and functions for public API
22pub use answers::{emit_answers, encrypt_secret_answers, load_answers, prompt_secret_answers};
23pub use executors::{
24    auto_install_provider_packs, domain_from_provider_id, execute_add_packs_to_bundle,
25    execute_apply_pack_setup, execute_build_flow_index, execute_copy_resolved_manifests,
26    execute_create_bundle, execute_remove_provider_artifacts, execute_resolve_packs,
27    execute_validate_bundle, execute_write_gmap_rules, find_provider_pack_source,
28    get_pack_target_dir, invoke_setup_component_operation,
29};
30pub use plan_builders::{
31    apply_create, apply_remove, apply_update, build_metadata, build_metadata_with_ops,
32    compute_simple_hash, dedup_sorted, extract_default_from_help, infer_default_value,
33    infer_update_ops, normalize_tenants, print_plan_summary,
34};
35pub use types::{LoadedAnswers, SetupConfig, SetupRequest};
36
37/// The setup engine orchestrates plan → execute for bundle lifecycle.
38pub struct SetupEngine {
39    config: SetupConfig,
40}
41
42impl SetupEngine {
43    pub fn new(config: SetupConfig) -> Self {
44        Self { config }
45    }
46
47    /// Build a plan for the given mode and request.
48    pub fn plan(
49        &self,
50        mode: SetupMode,
51        request: &SetupRequest,
52        dry_run: bool,
53    ) -> anyhow::Result<SetupPlan> {
54        match mode {
55            SetupMode::Create => apply_create(request, dry_run),
56            SetupMode::Update => apply_update(request, dry_run),
57            SetupMode::Remove => apply_remove(request, dry_run),
58        }
59    }
60
61    /// Print a human-readable plan summary to stdout.
62    pub fn print_plan(&self, plan: &SetupPlan) {
63        print_plan_summary(plan);
64    }
65
66    /// Access the engine configuration.
67    pub fn config(&self) -> &SetupConfig {
68        &self.config
69    }
70
71    /// Execute a setup plan.
72    ///
73    /// Runs each step in the plan, performing the actual bundle setup operations.
74    /// Returns an execution report with details about what was done.
75    pub fn execute(&self, plan: &SetupPlan) -> anyhow::Result<SetupExecutionReport> {
76        if plan.dry_run {
77            return Err(anyhow!("cannot execute a dry-run plan"));
78        }
79
80        let bundle = &plan.bundle;
81        let mut report = SetupExecutionReport {
82            bundle: bundle.clone(),
83            resolved_packs: Vec::new(),
84            resolved_manifests: Vec::new(),
85            provider_updates: 0,
86            pending_setup_actions: Vec::new(),
87            warnings: Vec::new(),
88        };
89
90        for step in &plan.steps {
91            match step.kind {
92                SetupStepKind::NoOp => {
93                    if self.config.verbose {
94                        println!("  [skip] {}", step.description);
95                    }
96                }
97                SetupStepKind::CreateBundle => {
98                    execute_create_bundle(bundle, &plan.metadata)?;
99                    if self.config.verbose {
100                        println!("  [done] {}", step.description);
101                    }
102                }
103                SetupStepKind::ResolvePacks => {
104                    let resolved = execute_resolve_packs(bundle, &plan.metadata)?;
105                    report.resolved_packs.extend(resolved);
106                    if self.config.verbose {
107                        println!("  [done] {}", step.description);
108                    }
109                }
110                SetupStepKind::AddPacksToBundle => {
111                    if !plan.metadata.providers_remove.is_empty() {
112                        report.provider_updates += execute_remove_provider_artifacts(
113                            bundle,
114                            &plan.metadata.providers_remove,
115                        )?;
116                    }
117                    execute_add_packs_to_bundle(bundle, &report.resolved_packs)?;
118                    let _ = crate::deployment_targets::persist_explicit_deployment_targets(
119                        bundle,
120                        &plan.metadata.deployment_targets,
121                    );
122                    if self.config.verbose {
123                        println!("  [done] {}", step.description);
124                    }
125                }
126                SetupStepKind::ValidateCapabilities => {
127                    let cap_report = crate::capabilities::validate_and_upgrade_packs(bundle)?;
128                    for warn in &cap_report.warnings {
129                        report.warnings.push(warn.message.clone());
130                    }
131                    if self.config.verbose {
132                        println!(
133                            "  [done] {} (checked={}, upgraded={})",
134                            step.description,
135                            cap_report.checked,
136                            cap_report.upgraded.len()
137                        );
138                    }
139                }
140                SetupStepKind::ApplyPackSetup => {
141                    let setup_report =
142                        execute_apply_pack_setup(bundle, &plan.metadata, &self.config)?;
143                    report.provider_updates += setup_report.provider_updates;
144                    report
145                        .pending_setup_actions
146                        .extend(setup_report.pending_setup_actions);
147                    if self.config.verbose {
148                        println!("  [done] {}", step.description);
149                    }
150                }
151                SetupStepKind::WriteGmapRules => {
152                    execute_write_gmap_rules(bundle, &plan.metadata)?;
153                    if self.config.verbose {
154                        println!("  [done] {}", step.description);
155                    }
156                }
157                SetupStepKind::RunResolver => {
158                    // Resolver is typically run by the runtime, not setup
159                    if self.config.verbose {
160                        println!("  [skip] {} (deferred to runtime)", step.description);
161                    }
162                }
163                SetupStepKind::CopyResolvedManifest => {
164                    let manifests = execute_copy_resolved_manifests(bundle, &plan.metadata)?;
165                    report.resolved_manifests.extend(manifests);
166                    if self.config.verbose {
167                        println!("  [done] {}", step.description);
168                    }
169                }
170                SetupStepKind::ValidateBundle => {
171                    execute_validate_bundle(bundle)?;
172                    if self.config.verbose {
173                        println!("  [done] {}", step.description);
174                    }
175                }
176                SetupStepKind::BuildFlowIndex => {
177                    execute_build_flow_index(bundle, &self.config)?;
178                    if self.config.verbose {
179                        println!("  [done] {}", step.description);
180                    }
181                }
182            }
183        }
184
185        // Persist bundle-level platform metadata even when no provider pack setup
186        // steps ran, so create-only flows still materialize runtime/deployment config.
187        persist_static_routes_artifact(bundle, &plan.metadata.static_routes)?;
188        let _ = crate::deployment_targets::persist_explicit_deployment_targets(
189            bundle,
190            &plan.metadata.deployment_targets,
191        );
192        if let Some(tunnel) = plan.metadata.tunnel.as_ref() {
193            let _ = persist_tunnel_artifact(bundle, tunnel);
194        }
195        if let Some(telemetry) = plan.metadata.telemetry.as_ref() {
196            let _ = persist_telemetry_artifact(bundle, telemetry);
197        }
198
199        Ok(report)
200    }
201
202    /// Emit an answers template JSON file.
203    ///
204    /// Discovers all packs in the bundle and generates a template with all
205    /// setup questions. Users fill this in and pass it via `--answers`.
206    pub fn emit_answers(
207        &self,
208        plan: &SetupPlan,
209        output_path: &Path,
210        key: Option<&str>,
211        interactive: bool,
212    ) -> anyhow::Result<()> {
213        emit_answers(&self.config, plan, output_path, key, interactive)
214    }
215
216    /// Load answers from a JSON/YAML file.
217    pub fn load_answers(
218        &self,
219        answers_path: &Path,
220        key: Option<&str>,
221        interactive: bool,
222    ) -> anyhow::Result<LoadedAnswers> {
223        load_answers(answers_path, key, interactive)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::bundle;
231    use crate::platform_setup::{StaticRoutesPolicy, static_routes_artifact_path};
232    use serde_json::json;
233    use std::collections::BTreeSet;
234    use std::path::PathBuf;
235
236    fn empty_request(bundle: PathBuf) -> SetupRequest {
237        SetupRequest {
238            bundle,
239            bundle_name: None,
240            pack_refs: Vec::new(),
241            tenants: vec![TenantSelection {
242                tenant: "demo".to_string(),
243                team: Some("default".to_string()),
244                allow_paths: vec!["packs/default".to_string()],
245            }],
246            default_assignments: Vec::new(),
247            providers: Vec::new(),
248            update_ops: BTreeSet::new(),
249            remove_targets: BTreeSet::new(),
250            packs_remove: Vec::new(),
251            providers_remove: Vec::new(),
252            tenants_remove: Vec::new(),
253            access_changes: Vec::new(),
254            static_routes: StaticRoutesPolicy::default(),
255            setup_answers: serde_json::Map::new(),
256            ..Default::default()
257        }
258    }
259
260    #[test]
261    fn create_plan_is_deterministic() {
262        let req = SetupRequest {
263            bundle: PathBuf::from("bundle"),
264            bundle_name: None,
265            pack_refs: vec![
266                "repo://zeta/pack@1".to_string(),
267                "repo://alpha/pack@1".to_string(),
268                "repo://alpha/pack@1".to_string(),
269            ],
270            tenants: vec![
271                TenantSelection {
272                    tenant: "demo".to_string(),
273                    team: Some("default".to_string()),
274                    allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
275                },
276                TenantSelection {
277                    tenant: "alpha".to_string(),
278                    team: None,
279                    allow_paths: vec!["x".to_string()],
280                },
281            ],
282            default_assignments: Vec::new(),
283            providers: Vec::new(),
284            update_ops: BTreeSet::new(),
285            remove_targets: BTreeSet::new(),
286            packs_remove: Vec::new(),
287            providers_remove: Vec::new(),
288            tenants_remove: Vec::new(),
289            access_changes: Vec::new(),
290            static_routes: StaticRoutesPolicy::default(),
291            setup_answers: serde_json::Map::new(),
292            ..Default::default()
293        };
294        let plan = apply_create(&req, true).unwrap();
295        assert_eq!(
296            plan.metadata.pack_refs,
297            vec![
298                "repo://alpha/pack@1".to_string(),
299                "repo://zeta/pack@1".to_string()
300            ]
301        );
302        assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
303        assert_eq!(
304            plan.metadata.tenants[1].allow_paths,
305            vec!["pack/a".to_string(), "pack/b".to_string()]
306        );
307    }
308
309    #[test]
310    fn dry_run_does_not_create_files() {
311        let bundle = PathBuf::from("/tmp/nonexistent-bundle");
312        let req = empty_request(bundle.clone());
313        let _plan = apply_create(&req, true).unwrap();
314        assert!(!bundle.exists());
315    }
316
317    #[test]
318    fn create_requires_tenants() {
319        let req = SetupRequest {
320            tenants: vec![],
321            ..empty_request(PathBuf::from("x"))
322        };
323        assert!(apply_create(&req, true).is_err());
324    }
325
326    #[test]
327    fn load_answers_reads_platform_setup_and_provider_answers() {
328        let temp = tempfile::tempdir().unwrap();
329        let answers_path = temp.path().join("answers.yaml");
330        std::fs::write(
331            &answers_path,
332            r#"
333bundle_source: ./bundle
334tenant: acme
335team: core
336env: prod
337platform_setup:
338  static_routes:
339    public_web_enabled: true
340    public_base_url: https://example.com/base/
341  deployment_targets:
342    - target: aws
343      provider_pack: packs/aws.gtpack
344      default: true
345setup_answers:
346  messaging-webchat:
347    jwt_signing_key: abc
348"#,
349        )
350        .unwrap();
351
352        let loaded = load_answers(&answers_path, None, false).unwrap();
353        assert_eq!(
354            loaded
355                .platform_setup
356                .static_routes
357                .as_ref()
358                .and_then(|v| v.public_base_url.as_deref()),
359            Some("https://example.com/base/")
360        );
361        assert_eq!(
362            loaded
363                .setup_answers
364                .get("messaging-webchat")
365                .and_then(|v| v.get("jwt_signing_key"))
366                .and_then(serde_json::Value::as_str),
367            Some("abc")
368        );
369        assert_eq!(loaded.tenant.as_deref(), Some("acme"));
370        assert_eq!(loaded.team.as_deref(), Some("core"));
371        assert_eq!(loaded.env.as_deref(), Some("prod"));
372        assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
373        assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
374    }
375
376    #[test]
377    fn emit_answers_includes_platform_setup() {
378        let temp = tempfile::tempdir().unwrap();
379        let bundle_root = temp.path().join("bundle");
380        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
381
382        let engine = SetupEngine::new(SetupConfig {
383            tenant: "demo".into(),
384            team: None,
385            env: "prod".into(),
386            offline: false,
387            verbose: false,
388        });
389        let request = SetupRequest {
390            bundle: bundle_root.clone(),
391            tenants: vec![TenantSelection {
392                tenant: "demo".into(),
393                team: None,
394                allow_paths: Vec::new(),
395            }],
396            static_routes: StaticRoutesPolicy {
397                public_web_enabled: true,
398                public_base_url: Some("https://example.com".into()),
399                public_surface_policy: "enabled".into(),
400                default_route_prefix_policy: "pack_declared".into(),
401                tenant_path_policy: "pack_declared".into(),
402                ..StaticRoutesPolicy::default()
403            },
404            ..Default::default()
405        };
406        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
407        let output = temp.path().join("answers.json");
408        engine.emit_answers(&plan, &output, None, false).unwrap();
409        let emitted: serde_json::Value =
410            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
411        assert_eq!(
412            emitted["platform_setup"]["static_routes"]["public_base_url"],
413            json!("https://example.com")
414        );
415        assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
416    }
417
418    #[test]
419    fn emit_answers_falls_back_to_runtime_public_endpoint() {
420        let temp = tempfile::tempdir().unwrap();
421        let bundle_root = temp.path().join("bundle");
422        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
423        let runtime_dir = bundle_root
424            .join("state")
425            .join("runtime")
426            .join("demo.default");
427        std::fs::create_dir_all(&runtime_dir).unwrap();
428        std::fs::write(
429            runtime_dir.join("endpoints.json"),
430            r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
431        )
432        .unwrap();
433
434        let engine = SetupEngine::new(SetupConfig {
435            tenant: "demo".into(),
436            team: Some("default".into()),
437            env: "prod".into(),
438            offline: false,
439            verbose: false,
440        });
441        let request = SetupRequest {
442            bundle: bundle_root.clone(),
443            tenants: vec![TenantSelection {
444                tenant: "demo".into(),
445                team: Some("default".into()),
446                allow_paths: Vec::new(),
447            }],
448            ..Default::default()
449        };
450        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
451        let output = temp.path().join("answers-runtime.json");
452        engine.emit_answers(&plan, &output, None, false).unwrap();
453        let emitted: serde_json::Value =
454            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
455        assert_eq!(
456            emitted["platform_setup"]["static_routes"]["public_base_url"],
457            json!("https://runtime.example.com")
458        );
459    }
460
461    #[test]
462    fn execute_persists_static_routes_artifact() {
463        let temp = tempfile::tempdir().unwrap();
464        let bundle_root = temp.path().join("bundle");
465        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
466
467        let engine = SetupEngine::new(SetupConfig {
468            tenant: "demo".into(),
469            team: None,
470            env: "prod".into(),
471            offline: false,
472            verbose: false,
473        });
474        let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
475        metadata.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
484        execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
485        let artifact = static_routes_artifact_path(&bundle_root);
486        assert!(artifact.exists());
487        let stored: serde_json::Value =
488            serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
489        assert_eq!(stored["public_web_enabled"], json!(true));
490    }
491
492    #[test]
493    fn setup_actions_are_persisted_and_stripped_from_provider_config() {
494        // Focused coverage for the setup-actions handling that
495        // `execute_apply_pack_setup` performs before writing provider config:
496        // an `oauth_install_button` answer is extracted into a pending action,
497        // persisted to the per-provider actions state file, and removed from
498        // the answers that get written as provider config.
499        //
500        // This deliberately exercises the `setup_actions` module directly
501        // rather than the full `execute_apply_pack_setup` path: that path is
502        // gated by the B12a fail-closed secret-classification contract (a pack
503        // with no setup metadata is refused), which is covered by the unit
504        // tests in `engine::executors`.
505        let temp = tempfile::tempdir().unwrap();
506        let bundle_root = temp.path().to_path_buf();
507
508        let answers = json!({
509            "bot_token": "secret",
510            "setup_actions": [{
511                "id": "install",
512                "kind": "oauth_install_button",
513                "label": "Add to Example",
514                "authorize_url": "https://example.com/oauth"
515            }]
516        });
517
518        let actions = crate::setup_actions::extract_setup_actions(
519            "messaging-example",
520            "demo",
521            Some("default"),
522            &answers,
523        )
524        .unwrap();
525        assert_eq!(actions.len(), 1);
526        assert_eq!(
527            actions[0].kind,
528            crate::setup_actions::SetupActionKind::OauthInstallButton
529        );
530
531        crate::setup_actions::persist_setup_actions(&bundle_root, &actions).unwrap();
532        let action_path = crate::setup_actions::setup_actions_state_path(
533            &bundle_root,
534            "demo",
535            "default",
536            "messaging-example",
537        );
538        assert!(action_path.exists());
539        let state: crate::setup_actions::SetupActionStateFile =
540            serde_json::from_str(&std::fs::read_to_string(&action_path).unwrap()).unwrap();
541        assert_eq!(state.actions.len(), 1);
542        assert_eq!(state.actions[0].id, "install");
543
544        // Provider config keeps real answers but drops the setup-action payload.
545        let persisted = crate::setup_actions::strip_setup_actions(&answers);
546        assert!(persisted.get("setup_actions").is_none());
547        assert_eq!(persisted["bot_token"], json!("secret"));
548    }
549
550    #[test]
551    fn execute_apply_pack_setup_persists_pack_declared_setup_actions() {
552        use std::io::Write;
553        use zip::write::{FileOptions, ZipWriter};
554
555        let temp = tempfile::tempdir().unwrap();
556        let bundle_root = temp.path().join("bundle");
557        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
558        let providers_dir = bundle_root.join("providers/messaging");
559        std::fs::create_dir_all(&providers_dir).unwrap();
560        let pack_path = providers_dir.join("messaging-slack.gtpack");
561        let file = std::fs::File::create(&pack_path).unwrap();
562        let mut writer = ZipWriter::new(file);
563        let options: FileOptions<'_, ()> =
564            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
565        writer.start_file("pack.manifest.json", options).unwrap();
566        writer
567            .write_all(
568                json!({
569                    "pack_id": "messaging-slack",
570                    "display_name": "Slack"
571                })
572                .to_string()
573                .as_bytes(),
574            )
575            .unwrap();
576        writer.start_file("assets/setup.yaml", options).unwrap();
577        writer
578            .write_all(
579                br#"
580title: Slack
581questions: []
582setup_actions:
583  - id: add_to_slack
584    label: Add to Slack
585    kind: oauth_install_button
586    provider_id: slack
587    authorize_url: https://slack.example/install
588"#,
589            )
590            .unwrap();
591        writer.finish().unwrap();
592
593        let engine = SetupEngine::new(SetupConfig {
594            tenant: "demo".into(),
595            team: Some("default".into()),
596            env: "dev".into(),
597            offline: false,
598            verbose: false,
599        });
600        let request = empty_request(bundle_root.clone());
601        let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
602        assert!(
603            plan.steps
604                .iter()
605                .any(|step| step.kind == crate::plan::SetupStepKind::ApplyPackSetup),
606            "pack-declared setup actions should schedule ApplyPackSetup"
607        );
608        let metadata = build_metadata(&request, Vec::new(), vec![]);
609
610        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
611        assert_eq!(report.pending_setup_actions.len(), 1);
612        assert_eq!(report.pending_setup_actions[0].id, "add_to_slack");
613        assert_eq!(report.pending_setup_actions[0].label, "Add to Slack");
614        assert_eq!(
615            report.pending_setup_actions[0].provider_id,
616            "messaging-slack"
617        );
618        let action_path = crate::setup_actions::setup_actions_state_path(
619            &bundle_root,
620            "demo",
621            "default",
622            "messaging-slack",
623        );
624        assert!(action_path.exists());
625    }
626
627    #[test]
628    fn execute_apply_pack_setup_hydrates_oauth_install_url_from_answers() {
629        let temp = tempfile::tempdir().unwrap();
630        let bundle_root = temp.path().join("bundle");
631        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
632        // A pack with classifiable setup metadata so B12a can resolve a form
633        // spec for `messaging-example`; the install action itself is
634        // answer-provided, which is the behavior under test.
635        write_registration_test_pack(
636            &bundle_root,
637            r#"
638title: Example
639questions:
640  - name: workspace_name
641    kind: string
642"#,
643            json!({"operations": {}}),
644        );
645
646        let engine = SetupEngine::new(SetupConfig {
647            tenant: "demo".into(),
648            team: Some("default".into()),
649            env: "dev".into(),
650            offline: false,
651            verbose: false,
652        });
653        let mut request = empty_request(bundle_root.clone());
654        request.setup_answers.insert(
655            "messaging-example".into(),
656            json!({
657                "slack_client_id": "client-123",
658                "setup_actions": [{
659                    "id": "install",
660                    "kind": "oauth_install_button",
661                    "label": "Add",
662                    "authorize_url": "https://slack.com/oauth/v2/authorize",
663                    "client_id_field": "slack_client_id",
664                    "scopes": ["chat:write", "channels:read"]
665                }]
666            }),
667        );
668        let metadata = build_metadata(&request, Vec::new(), vec![]);
669
670        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
671        let url = report.pending_setup_actions[0]
672            .authorize_url
673            .as_deref()
674            .unwrap();
675        assert!(url.contains("client_id=client-123"), "{url}");
676        assert!(
677            url.contains("scope=chat%3Awrite%2Cchannels%3Aread"),
678            "{url}"
679        );
680    }
681
682    #[test]
683    fn execute_apply_pack_setup_runs_pack_declared_registration_before_oauth_hydration() {
684        let temp = tempfile::tempdir().unwrap();
685        let bundle_root = temp.path().join("bundle");
686        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
687        write_registration_test_pack(
688            &bundle_root,
689            r#"
690title: Example
691questions:
692  - name: workspace_name
693    kind: string
694setup_actions:
695  - id: install
696    label: Add
697    kind: oauth_install_button
698    authorize_url: https://example.com/oauth
699    client_id_source: registration
700    client_id_field: oauth_client_id
701    registration:
702      component_ref: components/registration.json
703      op: register
704      app_name_field: app_name
705      client_id_output: registered_client_id
706      client_secret_output: registered_client_secret
707      app_id_output: registered_app_id
708"#,
709            json!({
710                "operations": {
711                    "register": {
712                        "result": {
713                            "registered_client_id": "client-from-registration",
714                            "registered_client_secret": "secret-from-registration",
715                            "registered_app_id": "app-from-registration"
716                        }
717                    }
718                }
719            }),
720        );
721
722        let engine = SetupEngine::new(SetupConfig {
723            tenant: "demo".into(),
724            team: Some("default".into()),
725            env: "dev".into(),
726            offline: false,
727            verbose: false,
728        });
729        let mut request = empty_request(bundle_root.clone());
730        request
731            .setup_answers
732            .insert("messaging-example".into(), json!({"app_name": "Demo App"}));
733        let metadata = build_metadata(&request, Vec::new(), vec![]);
734
735        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
736        let url = report.pending_setup_actions[0]
737            .authorize_url
738            .as_deref()
739            .unwrap();
740        // The hydrated `client_id` proves the pack-declared registration ran
741        // and produced the OAuth client id BEFORE the install URL was built —
742        // the unique coverage of this test. Where those registration outputs
743        // land (setup-answers.json vs the dev secrets store) is the B12a
744        // redaction concern, covered by the `engine::executors` unit tests, so
745        // we don't re-assert it here.
746        assert!(url.contains("client_id=client-from-registration"), "{url}");
747    }
748
749    #[test]
750    fn execute_apply_pack_setup_resolves_open_url_action_even_when_already_registered() {
751        let temp = tempfile::tempdir().unwrap();
752        let bundle_root = temp.path().join("bundle");
753        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
754        write_registration_test_pack(
755            &bundle_root,
756            r#"
757title: Example
758questions:
759  - name: workspace_name
760    kind: string
761setup_actions:
762  - id: install
763    label: Install
764    kind: open_url
765    url_template: "https://example.com/apps/{app_id}/install-on-team?"
766    registration:
767      component_ref: components/registration.json
768      op: register
769      app_id_output: registered_app_id
770"#,
771            json!({
772                "operations": {
773                    "register": {
774                        // If registration re-ran on this pass it would return
775                        // this app id instead of the pre-existing one below —
776                        // asserting on the pre-existing value proves the op
777                        // was NOT re-invoked.
778                        "result": {
779                            "registered_app_id": "app-from-a-fresh-registration-call"
780                        }
781                    }
782                }
783            }),
784        );
785
786        let engine = SetupEngine::new(SetupConfig {
787            tenant: "demo".into(),
788            team: Some("default".into()),
789            env: "dev".into(),
790            offline: false,
791            verbose: false,
792        });
793        let mut request = empty_request(bundle_root.clone());
794        // Simulate a prior successful setup run: the registration output is
795        // already present in the persisted answers (as both the registration
796        // mapping's source key and the generic `app_id` key merge_registration_output
797        // writes), so a re-run should skip invoking `register` again.
798        request.setup_answers.insert(
799            "messaging-example".into(),
800            json!({
801                "workspace_name": "Demo App",
802                "app_id": "preexisting-app-id",
803                "registered_app_id": "preexisting-app-id"
804            }),
805        );
806        let metadata = build_metadata(&request, Vec::new(), vec![]);
807
808        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
809        let url = report.pending_setup_actions[0]
810            .extra
811            .get("url")
812            .and_then(serde_json::Value::as_str)
813            .unwrap();
814        assert_eq!(
815            url,
816            "https://example.com/apps/preexisting-app-id/install-on-team?"
817        );
818    }
819
820    #[test]
821    fn execute_apply_pack_setup_skips_actions_for_disabled_provider() {
822        let temp = tempfile::tempdir().unwrap();
823        let bundle_root = temp.path().join("bundle");
824        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
825        write_registration_test_pack(
826            &bundle_root,
827            r#"
828title: Example
829questions: []
830setup_actions:
831  - id: install
832    label: Add
833    kind: oauth_install_button
834    authorize_url: https://example.com/oauth
835    client_id_source: registration
836    client_id_field: oauth_client_id
837    registration:
838      component_ref: components/registration.json
839      op: register
840      client_id_output: registered_client_id
841"#,
842            json!({
843                "operations": {
844                    "register": {
845                        "result": {
846                            "registered_client_id": "client-from-registration"
847                        }
848                    }
849                }
850            }),
851        );
852
853        let engine = SetupEngine::new(SetupConfig {
854            tenant: "demo".into(),
855            team: Some("default".into()),
856            env: "dev".into(),
857            offline: false,
858            verbose: false,
859        });
860        let mut request = empty_request(bundle_root.clone());
861        request
862            .setup_answers
863            .insert("messaging-example".into(), json!({"enabled": false}));
864        let metadata = build_metadata(&request, Vec::new(), vec![]);
865
866        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
867
868        assert!(report.pending_setup_actions.is_empty());
869        assert!(
870            !bundle_root
871                .join("state/config/setup-actions/demo/default/messaging-example.json")
872                .exists()
873        );
874        let setup_answers_path =
875            bundle_root.join("state/config/messaging-example/setup-answers.json");
876        let stored: serde_json::Value =
877            serde_json::from_str(&std::fs::read_to_string(setup_answers_path).unwrap()).unwrap();
878        assert_eq!(stored["enabled"], json!(false));
879    }
880
881    #[test]
882    fn execute_apply_pack_setup_uses_bundle_name_for_registration_app_name_template() {
883        let temp = tempfile::tempdir().unwrap();
884        let bundle_root = temp.path().join("bundle");
885        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
886        write_registration_test_pack(
887            &bundle_root,
888            r#"
889title: Example
890questions:
891  - name: workspace_name
892    kind: string
893setup_actions:
894  - id: install
895    label: Add
896    kind: oauth_install_button
897    authorize_url: https://example.com/oauth
898    client_id_source: registration
899    client_id_field: oauth_client_id
900    app_name_template: "{{ bundle_name }} Slack"
901    default_app_name: "Greentic Slack"
902    registration:
903      component_ref: components/registration.json
904      op: register
905      app_name_field: slack_app_name
906      config_access_token_field: access_token
907      client_id_output: app_name
908      app_id_output: slack_app_name
909"#,
910            json!({
911                "operations": {
912                    "register": {
913                        "echo_request": true
914                    }
915                }
916            }),
917        );
918
919        let engine = SetupEngine::new(SetupConfig {
920            tenant: "demo".into(),
921            team: Some("default".into()),
922            env: "dev".into(),
923            offline: false,
924            verbose: false,
925        });
926        let mut request = empty_request(bundle_root.clone());
927        request.bundle_name = Some("Acme Support".into());
928        request
929            .setup_answers
930            .insert("messaging-example".into(), json!({"access_token": "token"}));
931        let metadata = build_metadata(&request, Vec::new(), vec![]);
932
933        execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
934
935        // The registration echoes the templated app name into both
936        // `app_name` and `slack_app_name`. Post-B12a these registration
937        // outputs are persisted to the dev secrets store (every config value
938        // is readable via the secrets API), not written back into
939        // setup-answers.json, so assert against the store. `canonical_secret_uri`
940        // collapses the literal "default" team into the `_` wildcard segment.
941        use greentic_secrets_lib::SecretsStore as _;
942        let store = crate::secrets::open_dev_store(&bundle_root).expect("open dev store");
943        let rt = tokio::runtime::Runtime::new().unwrap();
944        // setup uses the A4b `dev` -> `local` env alias for the secrets URI.
945        let env = crate::resolve_env(Some("dev"));
946        let read = |key: &str| -> String {
947            let uri = crate::canonical_secret_uri(
948                &env,
949                "demo",
950                Some("default"),
951                "messaging-example",
952                key,
953            );
954            let bytes = rt
955                .block_on(async { store.get(&uri).await })
956                .unwrap_or_else(|_| panic!("missing dev-store key: {key}"));
957            String::from_utf8(bytes).expect("utf8")
958        };
959        assert_eq!(read("slack_app_name"), "Acme Support Slack");
960        assert_eq!(read("app_name"), "Acme Support Slack");
961    }
962
963    #[test]
964    fn execute_apply_pack_setup_registration_failure_does_not_persist_broken_action() {
965        let temp = tempfile::tempdir().unwrap();
966        let bundle_root = temp.path().join("bundle");
967        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
968        write_registration_test_pack(
969            &bundle_root,
970            r#"
971title: Example
972questions: []
973setup_actions:
974  - id: install
975    label: Add
976    kind: oauth_install_button
977    authorize_url: https://example.com/oauth
978    client_id_source: registration
979    registration:
980      component_ref: components/registration.json
981      op: register
982      config_access_token_field: config_token
983      client_id_output: client_id
984"#,
985            json!({"operations": {}}),
986        );
987
988        let engine = SetupEngine::new(SetupConfig {
989            tenant: "demo".into(),
990            team: Some("default".into()),
991            env: "dev".into(),
992            offline: false,
993            verbose: false,
994        });
995        let mut request = empty_request(bundle_root.clone());
996        request
997            .setup_answers
998            .insert("messaging-example".into(), json!({"config_token": "token"}));
999        let metadata = build_metadata(&request, Vec::new(), vec![]);
1000
1001        let err = execute_apply_pack_setup(&bundle_root, &metadata, engine.config())
1002            .expect_err("registration failure should fail setup");
1003        assert!(
1004            err.to_string()
1005                .contains("failed to run setup action registration"),
1006            "{err:#}"
1007        );
1008        let action_path = crate::setup_actions::setup_actions_state_path(
1009            &bundle_root,
1010            "demo",
1011            "default",
1012            "messaging-example",
1013        );
1014        assert!(!action_path.exists());
1015    }
1016
1017    #[test]
1018    fn execute_apply_pack_setup_registration_passes_original_input_field_names() {
1019        let temp = tempfile::tempdir().unwrap();
1020        let bundle_root = temp.path().join("bundle");
1021        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1022        write_registration_test_pack(
1023            &bundle_root,
1024            r#"
1025title: Example
1026questions:
1027  - name: workspace_name
1028    kind: string
1029setup_actions:
1030  - id: install
1031    label: Add
1032    kind: oauth_install_button
1033    authorize_url: https://example.com/oauth
1034    client_id_source: registration
1035    registration:
1036      component_ref: components/registration.json
1037      op: register
1038      config_access_token_field: provider_specific_token
1039      client_id_output: provider_specific_token
1040"#,
1041            json!({
1042                "operations": {
1043                    "register": {
1044                        "echo_request": true
1045                    }
1046                }
1047            }),
1048        );
1049
1050        let engine = SetupEngine::new(SetupConfig {
1051            tenant: "demo".into(),
1052            team: Some("default".into()),
1053            env: "dev".into(),
1054            offline: false,
1055            verbose: false,
1056        });
1057        let mut request = empty_request(bundle_root.clone());
1058        request.setup_answers.insert(
1059            "messaging-example".into(),
1060            json!({"provider_specific_token": "client-from-original-field"}),
1061        );
1062        let metadata = build_metadata(&request, Vec::new(), vec![]);
1063
1064        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
1065        let url = report.pending_setup_actions[0]
1066            .authorize_url
1067            .as_deref()
1068            .unwrap();
1069        assert!(
1070            url.contains("client_id=client-from-original-field"),
1071            "{url}"
1072        );
1073    }
1074
1075    fn write_registration_test_pack(
1076        bundle_root: &std::path::Path,
1077        setup_yaml: &str,
1078        registration_component: serde_json::Value,
1079    ) {
1080        use std::io::Write;
1081        use zip::write::{FileOptions, ZipWriter};
1082
1083        let providers_dir = bundle_root.join("providers/messaging");
1084        std::fs::create_dir_all(&providers_dir).unwrap();
1085        let pack_path = providers_dir.join("messaging-example.gtpack");
1086        let file = std::fs::File::create(&pack_path).unwrap();
1087        let mut writer = ZipWriter::new(file);
1088        let options: FileOptions<'_, ()> =
1089            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1090        writer.start_file("pack.manifest.json", options).unwrap();
1091        writer
1092            .write_all(
1093                json!({
1094                    "pack_id": "messaging-example",
1095                    "display_name": "Example"
1096                })
1097                .to_string()
1098                .as_bytes(),
1099            )
1100            .unwrap();
1101        writer.start_file("assets/setup.yaml", options).unwrap();
1102        writer.write_all(setup_yaml.as_bytes()).unwrap();
1103        writer
1104            .start_file("components/registration.json", options)
1105            .unwrap();
1106        writer
1107            .write_all(registration_component.to_string().as_bytes())
1108            .unwrap();
1109        writer.finish().unwrap();
1110    }
1111
1112    #[test]
1113    fn execute_create_persists_platform_metadata_without_provider_steps() {
1114        let temp = tempfile::tempdir().unwrap();
1115        let bundle_root = temp.path().join("bundle");
1116
1117        let engine = SetupEngine::new(SetupConfig {
1118            tenant: "demo".into(),
1119            team: Some("default".into()),
1120            env: "prod".into(),
1121            offline: false,
1122            verbose: false,
1123        });
1124        let request = SetupRequest {
1125            bundle: bundle_root.clone(),
1126            static_routes: StaticRoutesPolicy {
1127                public_web_enabled: true,
1128                public_base_url: Some("https://example.com".into()),
1129                public_surface_policy: "enabled".into(),
1130                default_route_prefix_policy: "pack_declared".into(),
1131                tenant_path_policy: "pack_declared".into(),
1132                ..StaticRoutesPolicy::default()
1133            },
1134            deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
1135                target: "runtime".into(),
1136                provider_pack: None,
1137                default: Some(true),
1138            }],
1139            ..empty_request(bundle_root.clone())
1140        };
1141
1142        let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
1143        engine.execute(&plan).unwrap();
1144
1145        let routes_artifact = static_routes_artifact_path(&bundle_root);
1146        assert!(routes_artifact.exists());
1147
1148        let targets_artifact = bundle_root
1149            .join(".greentic")
1150            .join("deployment-targets.json");
1151        assert!(targets_artifact.exists());
1152        let stored: serde_json::Value =
1153            serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
1154        assert_eq!(stored["targets"][0]["target"], json!("runtime"));
1155        assert_eq!(stored["targets"][0]["default"], json!(true));
1156    }
1157
1158    #[test]
1159    fn remove_execute_deletes_provider_artifact_and_config_dir() {
1160        let temp = tempfile::tempdir().unwrap();
1161        let bundle_root = temp.path().join("bundle");
1162        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1163        let provider_dir = bundle_root.join("providers").join("messaging");
1164        std::fs::create_dir_all(&provider_dir).unwrap();
1165        let provider_pack = provider_dir.join("messaging-webchat.gtpack");
1166        std::fs::copy(
1167            bundle_root.join("packs").join("default.gtpack"),
1168            &provider_pack,
1169        )
1170        .unwrap();
1171        let config_dir = bundle_root
1172            .join("state")
1173            .join("config")
1174            .join("messaging-webchat");
1175        std::fs::create_dir_all(&config_dir).unwrap();
1176        std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
1177
1178        let engine = SetupEngine::new(SetupConfig {
1179            tenant: "demo".into(),
1180            team: None,
1181            env: "prod".into(),
1182            offline: false,
1183            verbose: false,
1184        });
1185        let request = SetupRequest {
1186            bundle: bundle_root.clone(),
1187            providers_remove: vec!["messaging-webchat".into()],
1188            ..Default::default()
1189        };
1190        let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
1191        engine.execute(&plan).unwrap();
1192
1193        assert!(!provider_pack.exists());
1194        assert!(!config_dir.exists());
1195    }
1196
1197    #[test]
1198    fn update_plan_preserves_static_routes_policy() {
1199        let req = SetupRequest {
1200            bundle: PathBuf::from("bundle"),
1201            tenants: vec![TenantSelection {
1202                tenant: "demo".into(),
1203                team: None,
1204                allow_paths: Vec::new(),
1205            }],
1206            static_routes: StaticRoutesPolicy {
1207                public_web_enabled: true,
1208                public_base_url: Some("https://example.com/new".into()),
1209                public_surface_policy: "enabled".into(),
1210                default_route_prefix_policy: "pack_declared".into(),
1211                tenant_path_policy: "pack_declared".into(),
1212                ..StaticRoutesPolicy::default()
1213            },
1214            ..Default::default()
1215        };
1216        let plan = apply_update(&req, true).unwrap();
1217        assert_eq!(
1218            plan.metadata.static_routes.public_base_url.as_deref(),
1219            Some("https://example.com/new")
1220        );
1221    }
1222
1223    #[test]
1224    fn extract_default_from_help_parses_parenthesized() {
1225        let help = "Slack API base URL (default: https://slack.com/api)";
1226        let result = extract_default_from_help(help);
1227        assert_eq!(result, Some("https://slack.com/api".to_string()));
1228    }
1229
1230    #[test]
1231    fn extract_default_from_help_parses_bracketed() {
1232        let help = "Enable feature [default: true]";
1233        let result = extract_default_from_help(help);
1234        assert_eq!(result, Some("true".to_string()));
1235    }
1236
1237    #[test]
1238    fn extract_default_from_help_case_insensitive() {
1239        let help = "Some setting (Default: custom_value)";
1240        let result = extract_default_from_help(help);
1241        assert_eq!(result, Some("custom_value".to_string()));
1242    }
1243
1244    #[test]
1245    fn extract_default_from_help_returns_none_without_default() {
1246        let help = "Just a plain help text with no default";
1247        let result = extract_default_from_help(help);
1248        assert_eq!(result, None);
1249    }
1250
1251    #[test]
1252    fn infer_default_value_uses_explicit_default() {
1253        use crate::setup_input::SetupQuestion;
1254        let question = SetupQuestion {
1255            name: "api_base_url".to_string(),
1256            kind: "string".to_string(),
1257            required: true,
1258            help: Some("Some help (default: wrong_value)".to_string()),
1259            choices: vec![],
1260            default: Some(json!("https://explicit.com")),
1261            secret: false,
1262            title: None,
1263            visible_if: None,
1264            ..Default::default()
1265        };
1266        let result = infer_default_value(&question);
1267        assert_eq!(result, json!("https://explicit.com"));
1268    }
1269
1270    #[test]
1271    fn infer_default_value_extracts_from_help() {
1272        use crate::setup_input::SetupQuestion;
1273        let question = SetupQuestion {
1274            name: "api_base_url".to_string(),
1275            kind: "string".to_string(),
1276            required: true,
1277            help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
1278            choices: vec![],
1279            default: None,
1280            secret: false,
1281            title: None,
1282            visible_if: None,
1283            ..Default::default()
1284        };
1285        let result = infer_default_value(&question);
1286        assert_eq!(result, json!("https://slack.com/api"));
1287    }
1288
1289    #[test]
1290    fn infer_default_value_returns_empty_without_default() {
1291        use crate::setup_input::SetupQuestion;
1292        let question = SetupQuestion {
1293            name: "bot_token".to_string(),
1294            kind: "string".to_string(),
1295            required: true,
1296            help: Some("Your bot token".to_string()),
1297            choices: vec![],
1298            default: None,
1299            secret: true,
1300            title: None,
1301            visible_if: None,
1302            ..Default::default()
1303        };
1304        let result = infer_default_value(&question);
1305        assert_eq!(result, json!(""));
1306    }
1307}