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    /// Isolates the process-global dev-secrets store for one test.
261    ///
262    /// Every executor below persists answers through
263    /// `secrets::ensure_path_for_env`, which resolves `GREENTIC_DEV_SECRETS_PATH`
264    /// before anything else, so a test without this guard writes into the
265    /// developer's real `~/.greentic` environment store and races every sibling
266    /// test that *does* install an override.
267    use crate::secrets::test_support::isolated_store as isolated_dev_store;
268
269    #[test]
270    fn create_plan_is_deterministic() {
271        let req = SetupRequest {
272            bundle: PathBuf::from("bundle"),
273            bundle_name: None,
274            pack_refs: vec![
275                "repo://zeta/pack@1".to_string(),
276                "repo://alpha/pack@1".to_string(),
277                "repo://alpha/pack@1".to_string(),
278            ],
279            tenants: vec![
280                TenantSelection {
281                    tenant: "demo".to_string(),
282                    team: Some("default".to_string()),
283                    allow_paths: vec!["pack/b".to_string(), "pack/a".to_string()],
284                },
285                TenantSelection {
286                    tenant: "alpha".to_string(),
287                    team: None,
288                    allow_paths: vec!["x".to_string()],
289                },
290            ],
291            default_assignments: Vec::new(),
292            providers: Vec::new(),
293            update_ops: BTreeSet::new(),
294            remove_targets: BTreeSet::new(),
295            packs_remove: Vec::new(),
296            providers_remove: Vec::new(),
297            tenants_remove: Vec::new(),
298            access_changes: Vec::new(),
299            static_routes: StaticRoutesPolicy::default(),
300            setup_answers: serde_json::Map::new(),
301            ..Default::default()
302        };
303        let plan = apply_create(&req, true).unwrap();
304        assert_eq!(
305            plan.metadata.pack_refs,
306            vec![
307                "repo://alpha/pack@1".to_string(),
308                "repo://zeta/pack@1".to_string()
309            ]
310        );
311        assert_eq!(plan.metadata.tenants[0].tenant, "alpha");
312        assert_eq!(
313            plan.metadata.tenants[1].allow_paths,
314            vec!["pack/a".to_string(), "pack/b".to_string()]
315        );
316    }
317
318    #[test]
319    fn dry_run_does_not_create_files() {
320        let bundle = PathBuf::from("/tmp/nonexistent-bundle");
321        let req = empty_request(bundle.clone());
322        let _plan = apply_create(&req, true).unwrap();
323        assert!(!bundle.exists());
324    }
325
326    #[test]
327    fn create_requires_tenants() {
328        let req = SetupRequest {
329            tenants: vec![],
330            ..empty_request(PathBuf::from("x"))
331        };
332        assert!(apply_create(&req, true).is_err());
333    }
334
335    #[test]
336    fn load_answers_reads_platform_setup_and_provider_answers() {
337        let temp = tempfile::tempdir().unwrap();
338        let answers_path = temp.path().join("answers.yaml");
339        std::fs::write(
340            &answers_path,
341            r#"
342bundle_source: ./bundle
343tenant: acme
344team: core
345env: prod
346platform_setup:
347  static_routes:
348    public_web_enabled: true
349    public_base_url: https://example.com/base/
350  deployment_targets:
351    - target: aws
352      provider_pack: packs/aws.gtpack
353      default: true
354setup_answers:
355  messaging-webchat:
356    jwt_signing_key: abc
357"#,
358        )
359        .unwrap();
360
361        let loaded = load_answers(&answers_path, None, false).unwrap();
362        assert_eq!(
363            loaded
364                .platform_setup
365                .static_routes
366                .as_ref()
367                .and_then(|v| v.public_base_url.as_deref()),
368            Some("https://example.com/base/")
369        );
370        assert_eq!(
371            loaded
372                .setup_answers
373                .get("messaging-webchat")
374                .and_then(|v| v.get("jwt_signing_key"))
375                .and_then(serde_json::Value::as_str),
376            Some("abc")
377        );
378        assert_eq!(loaded.tenant.as_deref(), Some("acme"));
379        assert_eq!(loaded.team.as_deref(), Some("core"));
380        assert_eq!(loaded.env.as_deref(), Some("prod"));
381        assert_eq!(loaded.platform_setup.deployment_targets.len(), 1);
382        assert_eq!(loaded.platform_setup.deployment_targets[0].target, "aws");
383    }
384
385    #[test]
386    fn emit_answers_includes_platform_setup() {
387        let temp = tempfile::tempdir().unwrap();
388        let bundle_root = temp.path().join("bundle");
389        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
390
391        let engine = SetupEngine::new(SetupConfig {
392            tenant: "demo".into(),
393            team: None,
394            env: "prod".into(),
395            offline: false,
396            verbose: false,
397        });
398        let request = SetupRequest {
399            bundle: bundle_root.clone(),
400            tenants: vec![TenantSelection {
401                tenant: "demo".into(),
402                team: None,
403                allow_paths: Vec::new(),
404            }],
405            static_routes: StaticRoutesPolicy {
406                public_web_enabled: true,
407                public_base_url: Some("https://example.com".into()),
408                public_surface_policy: "enabled".into(),
409                default_route_prefix_policy: "pack_declared".into(),
410                tenant_path_policy: "pack_declared".into(),
411                ..StaticRoutesPolicy::default()
412            },
413            ..Default::default()
414        };
415        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
416        let output = temp.path().join("answers.json");
417        engine.emit_answers(&plan, &output, None, false).unwrap();
418        let emitted: serde_json::Value =
419            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
420        assert_eq!(
421            emitted["platform_setup"]["static_routes"]["public_base_url"],
422            json!("https://example.com")
423        );
424        assert_eq!(emitted["platform_setup"]["deployment_targets"], json!([]));
425    }
426
427    #[test]
428    fn emit_answers_falls_back_to_runtime_public_endpoint() {
429        let temp = tempfile::tempdir().unwrap();
430        let bundle_root = temp.path().join("bundle");
431        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
432        let runtime_dir = bundle_root
433            .join("state")
434            .join("runtime")
435            .join("demo.default");
436        std::fs::create_dir_all(&runtime_dir).unwrap();
437        std::fs::write(
438            runtime_dir.join("endpoints.json"),
439            r#"{"tenant":"demo","team":"default","public_base_url":"https://runtime.example.com"}"#,
440        )
441        .unwrap();
442
443        let engine = SetupEngine::new(SetupConfig {
444            tenant: "demo".into(),
445            team: Some("default".into()),
446            env: "prod".into(),
447            offline: false,
448            verbose: false,
449        });
450        let request = SetupRequest {
451            bundle: bundle_root.clone(),
452            tenants: vec![TenantSelection {
453                tenant: "demo".into(),
454                team: Some("default".into()),
455                allow_paths: Vec::new(),
456            }],
457            ..Default::default()
458        };
459        let plan = engine.plan(SetupMode::Create, &request, true).unwrap();
460        let output = temp.path().join("answers-runtime.json");
461        engine.emit_answers(&plan, &output, None, false).unwrap();
462        let emitted: serde_json::Value =
463            serde_json::from_str(&std::fs::read_to_string(output).unwrap()).unwrap();
464        assert_eq!(
465            emitted["platform_setup"]["static_routes"]["public_base_url"],
466            json!("https://runtime.example.com")
467        );
468    }
469
470    #[test]
471    fn execute_persists_static_routes_artifact() {
472        let _store = isolated_dev_store();
473        let temp = tempfile::tempdir().unwrap();
474        let bundle_root = temp.path().join("bundle");
475        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
476
477        let engine = SetupEngine::new(SetupConfig {
478            tenant: "demo".into(),
479            team: None,
480            env: "prod".into(),
481            offline: false,
482            verbose: false,
483        });
484        let mut metadata = build_metadata(&empty_request(bundle_root.clone()), Vec::new(), vec![]);
485        metadata.static_routes = StaticRoutesPolicy {
486            public_web_enabled: true,
487            public_base_url: Some("https://example.com".into()),
488            public_surface_policy: "enabled".into(),
489            default_route_prefix_policy: "pack_declared".into(),
490            tenant_path_policy: "pack_declared".into(),
491            ..StaticRoutesPolicy::default()
492        };
493
494        execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
495        let artifact = static_routes_artifact_path(&bundle_root);
496        assert!(artifact.exists());
497        let stored: serde_json::Value =
498            serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap();
499        assert_eq!(stored["public_web_enabled"], json!(true));
500    }
501
502    #[test]
503    fn setup_actions_are_persisted_and_stripped_from_provider_config() {
504        // Focused coverage for the setup-actions handling that
505        // `execute_apply_pack_setup` performs before writing provider config:
506        // an `oauth_install_button` answer is extracted into a pending action,
507        // persisted to the per-provider actions state file, and removed from
508        // the answers that get written as provider config.
509        //
510        // This deliberately exercises the `setup_actions` module directly
511        // rather than the full `execute_apply_pack_setup` path: that path is
512        // gated by the B12a fail-closed secret-classification contract (a pack
513        // with no setup metadata is refused), which is covered by the unit
514        // tests in `engine::executors`.
515        let temp = tempfile::tempdir().unwrap();
516        let bundle_root = temp.path().to_path_buf();
517
518        let answers = json!({
519            "bot_token": "secret",
520            "setup_actions": [{
521                "id": "install",
522                "kind": "oauth_install_button",
523                "label": "Add to Example",
524                "authorize_url": "https://example.com/oauth"
525            }]
526        });
527
528        let actions = crate::setup_actions::extract_setup_actions(
529            "messaging-example",
530            "demo",
531            Some("default"),
532            &answers,
533        )
534        .unwrap();
535        assert_eq!(actions.len(), 1);
536        assert_eq!(
537            actions[0].kind,
538            crate::setup_actions::SetupActionKind::OauthInstallButton
539        );
540
541        crate::setup_actions::persist_setup_actions(&bundle_root, &actions).unwrap();
542        let action_path = crate::setup_actions::setup_actions_state_path(
543            &bundle_root,
544            "demo",
545            "default",
546            "messaging-example",
547        );
548        assert!(action_path.exists());
549        let state: crate::setup_actions::SetupActionStateFile =
550            serde_json::from_str(&std::fs::read_to_string(&action_path).unwrap()).unwrap();
551        assert_eq!(state.actions.len(), 1);
552        assert_eq!(state.actions[0].id, "install");
553
554        // Provider config keeps real answers but drops the setup-action payload.
555        let persisted = crate::setup_actions::strip_setup_actions(&answers);
556        assert!(persisted.get("setup_actions").is_none());
557        assert_eq!(persisted["bot_token"], json!("secret"));
558    }
559
560    #[test]
561    fn execute_apply_pack_setup_persists_pack_declared_setup_actions() {
562        let _store = isolated_dev_store();
563        use std::io::Write;
564        use zip::write::{FileOptions, ZipWriter};
565
566        let temp = tempfile::tempdir().unwrap();
567        let bundle_root = temp.path().join("bundle");
568        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
569        let providers_dir = bundle_root.join("providers/messaging");
570        std::fs::create_dir_all(&providers_dir).unwrap();
571        let pack_path = providers_dir.join("messaging-slack.gtpack");
572        let file = std::fs::File::create(&pack_path).unwrap();
573        let mut writer = ZipWriter::new(file);
574        let options: FileOptions<'_, ()> =
575            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
576        writer.start_file("pack.manifest.json", options).unwrap();
577        writer
578            .write_all(
579                json!({
580                    "pack_id": "messaging-slack",
581                    "display_name": "Slack"
582                })
583                .to_string()
584                .as_bytes(),
585            )
586            .unwrap();
587        writer.start_file("assets/setup.yaml", options).unwrap();
588        writer
589            .write_all(
590                br#"
591title: Slack
592questions: []
593setup_actions:
594  - id: add_to_slack
595    label: Add to Slack
596    kind: oauth_install_button
597    provider_id: slack
598    authorize_url: https://slack.example/install
599"#,
600            )
601            .unwrap();
602        writer.finish().unwrap();
603
604        let engine = SetupEngine::new(SetupConfig {
605            tenant: "demo".into(),
606            team: Some("default".into()),
607            env: "dev".into(),
608            offline: false,
609            verbose: false,
610        });
611        let request = empty_request(bundle_root.clone());
612        let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
613        assert!(
614            plan.steps
615                .iter()
616                .any(|step| step.kind == crate::plan::SetupStepKind::ApplyPackSetup),
617            "pack-declared setup actions should schedule ApplyPackSetup"
618        );
619        let metadata = build_metadata(&request, Vec::new(), vec![]);
620
621        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
622        assert_eq!(report.pending_setup_actions.len(), 1);
623        assert_eq!(report.pending_setup_actions[0].id, "add_to_slack");
624        assert_eq!(report.pending_setup_actions[0].label, "Add to Slack");
625        assert_eq!(
626            report.pending_setup_actions[0].provider_id,
627            "messaging-slack"
628        );
629        let action_path = crate::setup_actions::setup_actions_state_path(
630            &bundle_root,
631            "demo",
632            "default",
633            "messaging-slack",
634        );
635        assert!(action_path.exists());
636    }
637
638    #[test]
639    fn execute_apply_pack_setup_hydrates_oauth_install_url_from_answers() {
640        let _store = isolated_dev_store();
641        let temp = tempfile::tempdir().unwrap();
642        let bundle_root = temp.path().join("bundle");
643        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
644        // A pack with classifiable setup metadata so B12a can resolve a form
645        // spec for `messaging-example`; the install action itself is
646        // answer-provided, which is the behavior under test.
647        write_registration_test_pack(
648            &bundle_root,
649            r#"
650title: Example
651questions:
652  - name: workspace_name
653    kind: string
654"#,
655            json!({"operations": {}}),
656        );
657
658        let engine = SetupEngine::new(SetupConfig {
659            tenant: "demo".into(),
660            team: Some("default".into()),
661            env: "dev".into(),
662            offline: false,
663            verbose: false,
664        });
665        let mut request = empty_request(bundle_root.clone());
666        request.setup_answers.insert(
667            "messaging-example".into(),
668            json!({
669                "slack_client_id": "client-123",
670                "setup_actions": [{
671                    "id": "install",
672                    "kind": "oauth_install_button",
673                    "label": "Add",
674                    "authorize_url": "https://slack.com/oauth/v2/authorize",
675                    "client_id_field": "slack_client_id",
676                    "scopes": ["chat:write", "channels:read"]
677                }]
678            }),
679        );
680        let metadata = build_metadata(&request, Vec::new(), vec![]);
681
682        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
683        let url = report.pending_setup_actions[0]
684            .authorize_url
685            .as_deref()
686            .unwrap();
687        assert!(url.contains("client_id=client-123"), "{url}");
688        assert!(
689            url.contains("scope=chat%3Awrite%2Cchannels%3Aread"),
690            "{url}"
691        );
692    }
693
694    #[test]
695    fn execute_apply_pack_setup_runs_pack_declared_registration_before_oauth_hydration() {
696        let _store = isolated_dev_store();
697        let temp = tempfile::tempdir().unwrap();
698        let bundle_root = temp.path().join("bundle");
699        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
700        write_registration_test_pack(
701            &bundle_root,
702            r#"
703title: Example
704questions:
705  - name: workspace_name
706    kind: string
707setup_actions:
708  - id: install
709    label: Add
710    kind: oauth_install_button
711    authorize_url: https://example.com/oauth
712    client_id_source: registration
713    client_id_field: oauth_client_id
714    registration:
715      component_ref: components/registration.json
716      op: register
717      app_name_field: app_name
718      client_id_output: registered_client_id
719      client_secret_output: registered_client_secret
720      app_id_output: registered_app_id
721"#,
722            json!({
723                "operations": {
724                    "register": {
725                        "result": {
726                            "registered_client_id": "client-from-registration",
727                            "registered_client_secret": "secret-from-registration",
728                            "registered_app_id": "app-from-registration"
729                        }
730                    }
731                }
732            }),
733        );
734
735        let engine = SetupEngine::new(SetupConfig {
736            tenant: "demo".into(),
737            team: Some("default".into()),
738            env: "dev".into(),
739            offline: false,
740            verbose: false,
741        });
742        let mut request = empty_request(bundle_root.clone());
743        request
744            .setup_answers
745            .insert("messaging-example".into(), json!({"app_name": "Demo App"}));
746        let metadata = build_metadata(&request, Vec::new(), vec![]);
747
748        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
749        let url = report.pending_setup_actions[0]
750            .authorize_url
751            .as_deref()
752            .unwrap();
753        // The hydrated `client_id` proves the pack-declared registration ran
754        // and produced the OAuth client id BEFORE the install URL was built —
755        // the unique coverage of this test. Where those registration outputs
756        // land (setup-answers.json vs the dev secrets store) is the B12a
757        // redaction concern, covered by the `engine::executors` unit tests, so
758        // we don't re-assert it here.
759        assert!(url.contains("client_id=client-from-registration"), "{url}");
760    }
761
762    #[test]
763    fn execute_apply_pack_setup_resolves_open_url_action_even_when_already_registered() {
764        let _store = isolated_dev_store();
765        let temp = tempfile::tempdir().unwrap();
766        let bundle_root = temp.path().join("bundle");
767        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
768        write_registration_test_pack(
769            &bundle_root,
770            r#"
771title: Example
772questions:
773  - name: workspace_name
774    kind: string
775setup_actions:
776  - id: install
777    label: Install
778    kind: open_url
779    url_template: "https://example.com/apps/{app_id}/install-on-team?"
780    registration:
781      component_ref: components/registration.json
782      op: register
783      app_id_output: registered_app_id
784"#,
785            json!({
786                "operations": {
787                    "register": {
788                        // If registration re-ran on this pass it would return
789                        // this app id instead of the pre-existing one below —
790                        // asserting on the pre-existing value proves the op
791                        // was NOT re-invoked.
792                        "result": {
793                            "registered_app_id": "app-from-a-fresh-registration-call"
794                        }
795                    }
796                }
797            }),
798        );
799
800        let engine = SetupEngine::new(SetupConfig {
801            tenant: "demo".into(),
802            team: Some("default".into()),
803            env: "dev".into(),
804            offline: false,
805            verbose: false,
806        });
807        let mut request = empty_request(bundle_root.clone());
808        // Simulate a prior successful setup run: the registration output is
809        // already present in the persisted answers (as both the registration
810        // mapping's source key and the generic `app_id` key merge_registration_output
811        // writes), so a re-run should skip invoking `register` again.
812        request.setup_answers.insert(
813            "messaging-example".into(),
814            json!({
815                "workspace_name": "Demo App",
816                "app_id": "preexisting-app-id",
817                "registered_app_id": "preexisting-app-id"
818            }),
819        );
820        let metadata = build_metadata(&request, Vec::new(), vec![]);
821
822        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
823        let url = report.pending_setup_actions[0]
824            .extra
825            .get("url")
826            .and_then(serde_json::Value::as_str)
827            .unwrap();
828        assert_eq!(
829            url,
830            "https://example.com/apps/preexisting-app-id/install-on-team?"
831        );
832    }
833
834    #[test]
835    fn execute_apply_pack_setup_skips_actions_for_disabled_provider() {
836        let _store = isolated_dev_store();
837        let temp = tempfile::tempdir().unwrap();
838        let bundle_root = temp.path().join("bundle");
839        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
840        write_registration_test_pack(
841            &bundle_root,
842            r#"
843title: Example
844questions: []
845setup_actions:
846  - id: install
847    label: Add
848    kind: oauth_install_button
849    authorize_url: https://example.com/oauth
850    client_id_source: registration
851    client_id_field: oauth_client_id
852    registration:
853      component_ref: components/registration.json
854      op: register
855      client_id_output: registered_client_id
856"#,
857            json!({
858                "operations": {
859                    "register": {
860                        "result": {
861                            "registered_client_id": "client-from-registration"
862                        }
863                    }
864                }
865            }),
866        );
867
868        let engine = SetupEngine::new(SetupConfig {
869            tenant: "demo".into(),
870            team: Some("default".into()),
871            env: "dev".into(),
872            offline: false,
873            verbose: false,
874        });
875        let mut request = empty_request(bundle_root.clone());
876        request
877            .setup_answers
878            .insert("messaging-example".into(), json!({"enabled": false}));
879        let metadata = build_metadata(&request, Vec::new(), vec![]);
880
881        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
882
883        assert!(report.pending_setup_actions.is_empty());
884        assert!(
885            !bundle_root
886                .join("state/config/setup-actions/demo/default/messaging-example.json")
887                .exists()
888        );
889        let setup_answers_path =
890            bundle_root.join("state/config/messaging-example/setup-answers.json");
891        let stored: serde_json::Value =
892            serde_json::from_str(&std::fs::read_to_string(setup_answers_path).unwrap()).unwrap();
893        assert_eq!(stored["enabled"], json!(false));
894    }
895
896    #[test]
897    fn execute_apply_pack_setup_uses_bundle_name_for_registration_app_name_template() {
898        let _store = isolated_dev_store();
899        let temp = tempfile::tempdir().unwrap();
900        let bundle_root = temp.path().join("bundle");
901        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
902        write_registration_test_pack(
903            &bundle_root,
904            r#"
905title: Example
906questions:
907  - name: workspace_name
908    kind: string
909setup_actions:
910  - id: install
911    label: Add
912    kind: oauth_install_button
913    authorize_url: https://example.com/oauth
914    client_id_source: registration
915    client_id_field: oauth_client_id
916    app_name_template: "{{ bundle_name }} Slack"
917    default_app_name: "Greentic Slack"
918    registration:
919      component_ref: components/registration.json
920      op: register
921      app_name_field: slack_app_name
922      config_access_token_field: access_token
923      client_id_output: app_name
924      app_id_output: slack_app_name
925"#,
926            json!({
927                "operations": {
928                    "register": {
929                        "echo_request": true
930                    }
931                }
932            }),
933        );
934
935        let engine = SetupEngine::new(SetupConfig {
936            tenant: "demo".into(),
937            team: Some("default".into()),
938            env: "dev".into(),
939            offline: false,
940            verbose: false,
941        });
942        let mut request = empty_request(bundle_root.clone());
943        request.bundle_name = Some("Acme Support".into());
944        request
945            .setup_answers
946            .insert("messaging-example".into(), json!({"access_token": "token"}));
947        let metadata = build_metadata(&request, Vec::new(), vec![]);
948
949        execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
950
951        // The registration echoes the templated app name into both
952        // `app_name` and `slack_app_name`. Post-B12a these registration
953        // outputs are persisted to the dev secrets store (every config value
954        // is readable via the secrets API), not written back into
955        // setup-answers.json, so assert against the store. `canonical_secret_uri`
956        // collapses the literal "default" team into the `_` wildcard segment.
957        use greentic_secrets_lib::SecretsStore as _;
958        let store =
959            crate::secrets::open_dev_store_for_env(&bundle_root, "dev").expect("open dev store");
960        let rt = tokio::runtime::Runtime::new().unwrap();
961        // setup uses the A4b `dev` -> `local` env alias for the secrets URI.
962        let env = crate::resolve_env(Some("dev"));
963        let read = |key: &str| -> String {
964            let uri = crate::canonical_secret_uri(
965                &env,
966                "demo",
967                Some("default"),
968                "messaging-example",
969                key,
970            );
971            let bytes = rt
972                .block_on(async { store.get(&uri).await })
973                .unwrap_or_else(|_| panic!("missing dev-store key: {key}"));
974            String::from_utf8(bytes).expect("utf8")
975        };
976        assert_eq!(read("slack_app_name"), "Acme Support Slack");
977        assert_eq!(read("app_name"), "Acme Support Slack");
978    }
979
980    #[test]
981    fn execute_apply_pack_setup_registration_failure_does_not_persist_broken_action() {
982        let _store = isolated_dev_store();
983        let temp = tempfile::tempdir().unwrap();
984        let bundle_root = temp.path().join("bundle");
985        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
986        write_registration_test_pack(
987            &bundle_root,
988            r#"
989title: Example
990questions: []
991setup_actions:
992  - id: install
993    label: Add
994    kind: oauth_install_button
995    authorize_url: https://example.com/oauth
996    client_id_source: registration
997    registration:
998      component_ref: components/registration.json
999      op: register
1000      config_access_token_field: config_token
1001      client_id_output: client_id
1002"#,
1003            json!({"operations": {}}),
1004        );
1005
1006        let engine = SetupEngine::new(SetupConfig {
1007            tenant: "demo".into(),
1008            team: Some("default".into()),
1009            env: "dev".into(),
1010            offline: false,
1011            verbose: false,
1012        });
1013        let mut request = empty_request(bundle_root.clone());
1014        request
1015            .setup_answers
1016            .insert("messaging-example".into(), json!({"config_token": "token"}));
1017        let metadata = build_metadata(&request, Vec::new(), vec![]);
1018
1019        let err = execute_apply_pack_setup(&bundle_root, &metadata, engine.config())
1020            .expect_err("registration failure should fail setup");
1021        assert!(
1022            err.to_string()
1023                .contains("failed to run setup action registration"),
1024            "{err:#}"
1025        );
1026        let action_path = crate::setup_actions::setup_actions_state_path(
1027            &bundle_root,
1028            "demo",
1029            "default",
1030            "messaging-example",
1031        );
1032        assert!(!action_path.exists());
1033    }
1034
1035    #[test]
1036    fn execute_apply_pack_setup_registration_passes_original_input_field_names() {
1037        let _store = isolated_dev_store();
1038        let temp = tempfile::tempdir().unwrap();
1039        let bundle_root = temp.path().join("bundle");
1040        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1041        write_registration_test_pack(
1042            &bundle_root,
1043            r#"
1044title: Example
1045questions:
1046  - name: workspace_name
1047    kind: string
1048setup_actions:
1049  - id: install
1050    label: Add
1051    kind: oauth_install_button
1052    authorize_url: https://example.com/oauth
1053    client_id_source: registration
1054    registration:
1055      component_ref: components/registration.json
1056      op: register
1057      config_access_token_field: provider_specific_token
1058      client_id_output: provider_specific_token
1059"#,
1060            json!({
1061                "operations": {
1062                    "register": {
1063                        "echo_request": true
1064                    }
1065                }
1066            }),
1067        );
1068
1069        let engine = SetupEngine::new(SetupConfig {
1070            tenant: "demo".into(),
1071            team: Some("default".into()),
1072            env: "dev".into(),
1073            offline: false,
1074            verbose: false,
1075        });
1076        let mut request = empty_request(bundle_root.clone());
1077        request.setup_answers.insert(
1078            "messaging-example".into(),
1079            json!({"provider_specific_token": "client-from-original-field"}),
1080        );
1081        let metadata = build_metadata(&request, Vec::new(), vec![]);
1082
1083        let report = execute_apply_pack_setup(&bundle_root, &metadata, engine.config()).unwrap();
1084        let url = report.pending_setup_actions[0]
1085            .authorize_url
1086            .as_deref()
1087            .unwrap();
1088        assert!(
1089            url.contains("client_id=client-from-original-field"),
1090            "{url}"
1091        );
1092    }
1093
1094    fn write_registration_test_pack(
1095        bundle_root: &std::path::Path,
1096        setup_yaml: &str,
1097        registration_component: serde_json::Value,
1098    ) {
1099        use std::io::Write;
1100        use zip::write::{FileOptions, ZipWriter};
1101
1102        let providers_dir = bundle_root.join("providers/messaging");
1103        std::fs::create_dir_all(&providers_dir).unwrap();
1104        let pack_path = providers_dir.join("messaging-example.gtpack");
1105        let file = std::fs::File::create(&pack_path).unwrap();
1106        let mut writer = ZipWriter::new(file);
1107        let options: FileOptions<'_, ()> =
1108            FileOptions::default().compression_method(zip::CompressionMethod::Stored);
1109        writer.start_file("pack.manifest.json", options).unwrap();
1110        writer
1111            .write_all(
1112                json!({
1113                    "pack_id": "messaging-example",
1114                    "display_name": "Example"
1115                })
1116                .to_string()
1117                .as_bytes(),
1118            )
1119            .unwrap();
1120        writer.start_file("assets/setup.yaml", options).unwrap();
1121        writer.write_all(setup_yaml.as_bytes()).unwrap();
1122        writer
1123            .start_file("components/registration.json", options)
1124            .unwrap();
1125        writer
1126            .write_all(registration_component.to_string().as_bytes())
1127            .unwrap();
1128        writer.finish().unwrap();
1129    }
1130
1131    #[test]
1132    fn execute_create_persists_platform_metadata_without_provider_steps() {
1133        let _store = isolated_dev_store();
1134        let temp = tempfile::tempdir().unwrap();
1135        let bundle_root = temp.path().join("bundle");
1136
1137        let engine = SetupEngine::new(SetupConfig {
1138            tenant: "demo".into(),
1139            team: Some("default".into()),
1140            env: "prod".into(),
1141            offline: false,
1142            verbose: false,
1143        });
1144        let request = SetupRequest {
1145            bundle: bundle_root.clone(),
1146            static_routes: StaticRoutesPolicy {
1147                public_web_enabled: true,
1148                public_base_url: Some("https://example.com".into()),
1149                public_surface_policy: "enabled".into(),
1150                default_route_prefix_policy: "pack_declared".into(),
1151                tenant_path_policy: "pack_declared".into(),
1152                ..StaticRoutesPolicy::default()
1153            },
1154            deployment_targets: vec![crate::deployment_targets::DeploymentTargetRecord {
1155                target: "runtime".into(),
1156                provider_pack: None,
1157                default: Some(true),
1158            }],
1159            ..empty_request(bundle_root.clone())
1160        };
1161
1162        let plan = engine.plan(SetupMode::Create, &request, false).unwrap();
1163        engine.execute(&plan).unwrap();
1164
1165        let routes_artifact = static_routes_artifact_path(&bundle_root);
1166        assert!(routes_artifact.exists());
1167
1168        let targets_artifact = bundle_root
1169            .join(".greentic")
1170            .join("deployment-targets.json");
1171        assert!(targets_artifact.exists());
1172        let stored: serde_json::Value =
1173            serde_json::from_str(&std::fs::read_to_string(targets_artifact).unwrap()).unwrap();
1174        assert_eq!(stored["targets"][0]["target"], json!("runtime"));
1175        assert_eq!(stored["targets"][0]["default"], json!(true));
1176    }
1177
1178    #[test]
1179    fn remove_execute_deletes_provider_artifact_and_config_dir() {
1180        let _store = isolated_dev_store();
1181        let temp = tempfile::tempdir().unwrap();
1182        let bundle_root = temp.path().join("bundle");
1183        bundle::create_demo_bundle_structure(&bundle_root, Some("demo")).unwrap();
1184        let provider_dir = bundle_root.join("providers").join("messaging");
1185        std::fs::create_dir_all(&provider_dir).unwrap();
1186        let provider_pack = provider_dir.join("messaging-webchat.gtpack");
1187        std::fs::copy(
1188            bundle_root.join("packs").join("default.gtpack"),
1189            &provider_pack,
1190        )
1191        .unwrap();
1192        let config_dir = bundle_root
1193            .join("state")
1194            .join("config")
1195            .join("messaging-webchat");
1196        std::fs::create_dir_all(&config_dir).unwrap();
1197        std::fs::write(config_dir.join("setup-answers.json"), "{}").unwrap();
1198
1199        let engine = SetupEngine::new(SetupConfig {
1200            tenant: "demo".into(),
1201            team: None,
1202            env: "prod".into(),
1203            offline: false,
1204            verbose: false,
1205        });
1206        let request = SetupRequest {
1207            bundle: bundle_root.clone(),
1208            providers_remove: vec!["messaging-webchat".into()],
1209            ..Default::default()
1210        };
1211        let plan = engine.plan(SetupMode::Remove, &request, false).unwrap();
1212        engine.execute(&plan).unwrap();
1213
1214        assert!(!provider_pack.exists());
1215        assert!(!config_dir.exists());
1216    }
1217
1218    #[test]
1219    fn update_plan_preserves_static_routes_policy() {
1220        let req = SetupRequest {
1221            bundle: PathBuf::from("bundle"),
1222            tenants: vec![TenantSelection {
1223                tenant: "demo".into(),
1224                team: None,
1225                allow_paths: Vec::new(),
1226            }],
1227            static_routes: StaticRoutesPolicy {
1228                public_web_enabled: true,
1229                public_base_url: Some("https://example.com/new".into()),
1230                public_surface_policy: "enabled".into(),
1231                default_route_prefix_policy: "pack_declared".into(),
1232                tenant_path_policy: "pack_declared".into(),
1233                ..StaticRoutesPolicy::default()
1234            },
1235            ..Default::default()
1236        };
1237        let plan = apply_update(&req, true).unwrap();
1238        assert_eq!(
1239            plan.metadata.static_routes.public_base_url.as_deref(),
1240            Some("https://example.com/new")
1241        );
1242    }
1243
1244    #[test]
1245    fn extract_default_from_help_parses_parenthesized() {
1246        let help = "Slack API base URL (default: https://slack.com/api)";
1247        let result = extract_default_from_help(help);
1248        assert_eq!(result, Some("https://slack.com/api".to_string()));
1249    }
1250
1251    #[test]
1252    fn extract_default_from_help_parses_bracketed() {
1253        let help = "Enable feature [default: true]";
1254        let result = extract_default_from_help(help);
1255        assert_eq!(result, Some("true".to_string()));
1256    }
1257
1258    #[test]
1259    fn extract_default_from_help_case_insensitive() {
1260        let help = "Some setting (Default: custom_value)";
1261        let result = extract_default_from_help(help);
1262        assert_eq!(result, Some("custom_value".to_string()));
1263    }
1264
1265    #[test]
1266    fn extract_default_from_help_returns_none_without_default() {
1267        let help = "Just a plain help text with no default";
1268        let result = extract_default_from_help(help);
1269        assert_eq!(result, None);
1270    }
1271
1272    #[test]
1273    fn infer_default_value_uses_explicit_default() {
1274        use crate::setup_input::SetupQuestion;
1275        let question = SetupQuestion {
1276            name: "api_base_url".to_string(),
1277            kind: "string".to_string(),
1278            required: true,
1279            help: Some("Some help (default: wrong_value)".to_string()),
1280            choices: vec![],
1281            default: Some(json!("https://explicit.com")),
1282            secret: false,
1283            title: None,
1284            visible_if: None,
1285            ..Default::default()
1286        };
1287        let result = infer_default_value(&question);
1288        assert_eq!(result, json!("https://explicit.com"));
1289    }
1290
1291    #[test]
1292    fn infer_default_value_extracts_from_help() {
1293        use crate::setup_input::SetupQuestion;
1294        let question = SetupQuestion {
1295            name: "api_base_url".to_string(),
1296            kind: "string".to_string(),
1297            required: true,
1298            help: Some("Slack API base URL (default: https://slack.com/api)".to_string()),
1299            choices: vec![],
1300            default: None,
1301            secret: false,
1302            title: None,
1303            visible_if: None,
1304            ..Default::default()
1305        };
1306        let result = infer_default_value(&question);
1307        assert_eq!(result, json!("https://slack.com/api"));
1308    }
1309
1310    #[test]
1311    fn infer_default_value_returns_empty_without_default() {
1312        use crate::setup_input::SetupQuestion;
1313        let question = SetupQuestion {
1314            name: "bot_token".to_string(),
1315            kind: "string".to_string(),
1316            required: true,
1317            help: Some("Your bot token".to_string()),
1318            choices: vec![],
1319            default: None,
1320            secret: true,
1321            title: None,
1322            visible_if: None,
1323            ..Default::default()
1324        };
1325        let result = infer_default_value(&question);
1326        assert_eq!(result, json!(""));
1327    }
1328}