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