Skip to main content

greentic_setup/
lib.rs

1//! End-to-end bundle setup engine for the Greentic platform.
2//!
3//! Provides pack discovery, QA-driven configuration, secrets persistence,
4//! and bundle lifecycle management as a library crate.
5
6pub mod a2a_setup;
7pub mod admin;
8pub mod answers_crypto;
9pub mod bundle;
10pub mod bundle_source;
11pub mod capabilities;
12pub mod card_setup;
13pub mod cli_args;
14pub mod cli_commands;
15pub mod cli_helpers;
16pub mod cli_i18n;
17pub mod config_envelope;
18pub mod deployment_targets;
19pub mod discovery;
20pub mod doctor;
21pub mod engine;
22pub mod env_deploy;
23pub mod env_mode;
24pub mod env_wizard;
25pub mod flow;
26pub mod generated_secrets;
27pub mod gtbundle;
28pub mod http_client;
29pub mod mcp_setup;
30pub mod no_ui_oauth;
31pub mod oauth_callback;
32pub mod oauth_device;
33pub mod plan;
34pub mod platform_setup;
35pub mod provider_commands;
36pub mod provider_registry;
37pub mod provider_state;
38pub(crate) mod release_index;
39pub mod reload;
40pub mod schema_validation;
41pub mod secret_name;
42pub mod secrets;
43pub mod setup_actions;
44pub mod setup_backend_contract;
45pub mod setup_final_actions;
46pub mod setup_input;
47pub mod setup_machine;
48pub mod setup_to_formspec;
49pub mod setup_tunnel;
50pub mod shared_tunnel;
51pub mod tenant_config;
52pub mod webhook;
53
54#[cfg(feature = "ui")]
55pub mod ui;
56
57pub mod qa {
58    //! QA-driven configuration: FormSpec bridge, wizard prompts, answers
59    //! persistence, and setup input loading.
60    pub mod bridge;
61    pub mod persist;
62    pub mod prompts;
63    pub mod shared_questions;
64    pub mod wizard;
65}
66
67pub use bundle_source::BundleSource;
68pub use engine::SetupEngine;
69pub use plan::{SetupMode, SetupPlan, SetupStep, SetupStepKind};
70
71// Re-export shared questions types and functions for convenient multi-provider setup
72pub use qa::wizard::{
73    ProviderFormSpec, SHARED_QUESTION_IDS, SharedQuestionsResult, build_provider_form_specs,
74    collect_shared_questions, prompt_shared_questions, run_qa_setup_with_shared,
75};
76
77/// Returns the crate version.
78pub fn version() -> &'static str {
79    env!("CARGO_PKG_VERSION")
80}
81
82/// Default environment id when nothing is set. Flipped from `"dev"` to
83/// `"local"` as part of A4b — the `local` env is what `gtc setup` and
84/// `gtc start` auto-create per A4.
85pub const DEFAULT_ENV_ID: &str = "local";
86
87/// Legacy env id this crate accepts via the compat alias. Resolved values
88/// that match this string are remapped to [`DEFAULT_ENV_ID`] with a
89/// once-per-process warning, unless the operator disables the alias.
90pub const LEGACY_ENV_ID: &str = "dev";
91
92/// Env-var that disables the [`LEGACY_ENV_ID`] → [`DEFAULT_ENV_ID`] compat
93/// alias. Set to `1`, `true`, `yes`, or `on` (case-insensitive) to make any
94/// resolved value of `dev` hard-fail with a remediation hint. Intended for
95/// CI assertions that prove no production code-path still resolves to the
96/// legacy env id; remove once A4b PR3 flips the default in
97/// `greentic-config` and downstream consumers no longer pass `dev`.
98pub const DISABLE_ALIAS_ENV_VAR: &str = "GREENTIC_DISABLE_DEV_ALIAS";
99
100/// Resolve the effective environment string.
101///
102/// Priority: explicit override > `$GREENTIC_ENV` > [`DEFAULT_ENV_ID`]
103/// (`"local"`). After resolution, applies the [`LEGACY_ENV_ID`] →
104/// [`DEFAULT_ENV_ID`] compat alias: any value of `dev` is remapped to
105/// `local` with a once-per-process `tracing::warn!` unless
106/// [`DISABLE_ALIAS_ENV_VAR`] is set, in which case the resolution panics
107/// with a remediation hint.
108pub fn resolve_env(override_env: Option<&str>) -> String {
109    let raw = override_env
110        .map(|v| v.to_string())
111        .or_else(|| std::env::var("GREENTIC_ENV").ok())
112        .unwrap_or_else(|| DEFAULT_ENV_ID.to_string());
113    compat_alias::apply_dev_alias(&raw)
114}
115
116mod compat_alias {
117    //! `dev` → `local` compatibility alias (A4b).
118    //!
119    //! Centralized so `greentic-start` can mirror the contract verbatim;
120    //! the parallel implementation in that crate will be replaced with a
121    //! call into a shared helper if/when the duplication starts mattering.
122
123    use std::sync::atomic::{AtomicBool, Ordering};
124
125    use super::{DEFAULT_ENV_ID, DISABLE_ALIAS_ENV_VAR, LEGACY_ENV_ID};
126
127    static WARNED: AtomicBool = AtomicBool::new(false);
128
129    /// Apply the `dev` → `local` compat alias. Returns the remapped value
130    /// for any input equal to [`LEGACY_ENV_ID`]; returns the input
131    /// unchanged for any other value. Panics if the alias is disabled via
132    /// [`DISABLE_ALIAS_ENV_VAR`] and the input is the legacy id.
133    pub fn apply_dev_alias(env: &str) -> String {
134        if env != LEGACY_ENV_ID {
135            return env.to_string();
136        }
137        if alias_disabled() {
138            // Hard-fail expiry gate. The panic message is the remediation —
139            // tracing may not be wired in every binary that consumes
140            // `resolve_env`, and exit() bypasses test harnesses.
141            panic!(
142                "environment `{LEGACY_ENV_ID}` is no longer accepted (set via {DISABLE_ALIAS_ENV_VAR}=1). \
143                 Migrate to `{DEFAULT_ENV_ID}` via `gtc op env migrate-dev {DEFAULT_ENV_ID} --check` then `--apply`, \
144                 or pass `--env {DEFAULT_ENV_ID}` / unset $GREENTIC_ENV.",
145            );
146        }
147        if !WARNED.swap(true, Ordering::SeqCst) {
148            tracing::warn!(
149                target: "greentic_setup::compat_alias",
150                legacy = LEGACY_ENV_ID,
151                target_env = DEFAULT_ENV_ID,
152                "env `{LEGACY_ENV_ID}` is deprecated; resolving as `{DEFAULT_ENV_ID}` for this process. \
153                 Plan the migration with `gtc op env migrate-dev {DEFAULT_ENV_ID} --check`; \
154                 set {DISABLE_ALIAS_ENV_VAR}=1 to hard-fail on `{LEGACY_ENV_ID}` in CI.",
155            );
156        }
157        DEFAULT_ENV_ID.to_string()
158    }
159
160    fn alias_disabled() -> bool {
161        std::env::var(DISABLE_ALIAS_ENV_VAR)
162            .ok()
163            .map(|v| {
164                let v = v.trim().to_ascii_lowercase();
165                matches!(v.as_str(), "1" | "true" | "yes" | "on")
166            })
167            .unwrap_or(false)
168    }
169
170    /// Reset the warning latch. Test-only so multiple `apply_dev_alias`
171    /// invocations can each verify the once-per-process behavior.
172    #[cfg(test)]
173    pub(super) fn reset_warning_latch_for_tests() {
174        WARNED.store(false, Ordering::SeqCst);
175    }
176}
177
178/// Build a canonical secret URI: `secrets://{env}/{tenant}/{team}/{provider}/{key}`.
179///
180/// The team segment is normalized via `greentic-secrets`
181/// ([`greentic_secrets_lib::normalize_team`]) — the single source of truth for
182/// the "`_` everywhere" rule (empty / `"default"` / `None` → `_`) — and the key
183/// via the shared [`secret_name::canonical_secret_name`]. The empty-provider →
184/// `messaging` default and the infallible `String` shape are setup-local
185/// conveniences kept on top of the shared primitives.
186pub fn canonical_secret_uri(
187    env: &str,
188    tenant: &str,
189    team: Option<&str>,
190    provider: &str,
191    key: &str,
192) -> String {
193    let team_segment = greentic_secrets_lib::normalize_team(team)
194        .unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string());
195    // Normalize the provider segment the same way as the key (and as the cloud
196    // secret name / env-bridge key already do), so a value written under a
197    // provider id like `messaging-webchat-gui` resolves when a component fetches
198    // it under `messaging.webchat-gui` — both collapse to `messaging_webchat_gui`.
199    let provider_segment = if provider.is_empty() {
200        "messaging".to_string()
201    } else {
202        secret_name::canonical_secret_name(provider)
203    };
204    let normalized_key = secret_name::canonical_secret_name(key);
205    format!("secrets://{env}/{tenant}/{team_segment}/{provider_segment}/{normalized_key}")
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    // `GREENTIC_ENV` and `GREENTIC_DISABLE_DEV_ALIAS` are process-global;
213    // serialize tests that mutate them so they don't interleave with each other
214    // or with tests in other modules that read the same vars.
215    //
216    // Shares `secrets::test_support::env_lock` rather than keeping a private
217    // mutex: a second lock gave no mutual exclusion against the secrets-store
218    // tests, which read `GREENTIC_ENV` and `GREENTIC_DEV_SECRETS_PATH` while
219    // these tests were rewriting them — the store tests failed intermittently,
220    // with a different pair failing on each parallel run.
221    fn with_clean_env<R>(body: impl FnOnce() -> R) -> R {
222        let _guard = crate::secrets::test_support::env_lock();
223        let prev_env = std::env::var_os("GREENTIC_ENV");
224        let prev_disable = std::env::var_os(DISABLE_ALIAS_ENV_VAR);
225        // SAFETY: serialized by ENV_LOCK; tests are single-threaded inside
226        // the critical section. unsafe is required because set_var /
227        // remove_var are marked unsafe in Rust 2024 edition.
228        unsafe {
229            std::env::remove_var("GREENTIC_ENV");
230            std::env::remove_var(DISABLE_ALIAS_ENV_VAR);
231        }
232        compat_alias::reset_warning_latch_for_tests();
233        let out = body();
234        unsafe {
235            match prev_env {
236                Some(v) => std::env::set_var("GREENTIC_ENV", v),
237                None => std::env::remove_var("GREENTIC_ENV"),
238            }
239            match prev_disable {
240                Some(v) => std::env::set_var(DISABLE_ALIAS_ENV_VAR, v),
241                None => std::env::remove_var(DISABLE_ALIAS_ENV_VAR),
242            }
243        }
244        out
245    }
246
247    #[test]
248    fn version_is_correct() {
249        assert!(version().starts_with("1.2"));
250    }
251
252    #[test]
253    fn secret_uri_basic() {
254        let uri = canonical_secret_uri("dev", "demo", None, "messaging-telegram", "bot_token");
255        assert_eq!(uri, "secrets://dev/demo/_/messaging_telegram/bot_token");
256    }
257
258    #[test]
259    fn secret_uri_with_team() {
260        let uri = canonical_secret_uri("dev", "acme", Some("ops"), "state-redis", "redis_url");
261        assert_eq!(uri, "secrets://dev/acme/ops/state_redis/redis_url");
262    }
263
264    #[test]
265    fn secret_uri_default_team_becomes_wildcard() {
266        let uri = canonical_secret_uri(
267            "dev",
268            "demo",
269            Some("default"),
270            "messaging-slack",
271            "bot_token",
272        );
273        assert_eq!(uri, "secrets://dev/demo/_/messaging_slack/bot_token");
274    }
275
276    /// CROSS-SYSTEM SECRET CONTRACT — DO NOT CHANGE THIS TEST.
277    ///
278    /// greentic-setup WRITES a provider secret under this EXACT uri; the
279    /// greentic-start runtime READS it under the SAME uri (after its provider-slug
280    /// canonicalization). The golden string below is the single source of truth
281    /// both sides must agree on. Its mirror lives in greentic-start
282    /// (`secrets_gate.rs::webex_secret_read_uri_contract_do_not_change`) and pins
283    /// the read side to the same string. If they diverge, a setup-provisioned
284    /// secret silently goes "missing" at runtime — the exact bug this guards.
285    ///
286    /// Do NOT edit the expected value to make a build pass. Changing the
287    /// secret-uri scheme requires a NEW secrets plan verified end-to-end on BOTH
288    /// binaries (setup + start) and BOTH backends (local dev-store + cloud vault)
289    /// and public.
290    #[test]
291    fn webex_secret_uri_contract_do_not_change() {
292        // env=local, team=default→`_`, provider=messaging-webex→messaging_webex,
293        // key=webex_bot_token.
294        assert_eq!(
295            canonical_secret_uri(
296                "local",
297                "demo",
298                Some("default"),
299                "messaging-webex",
300                "webex_bot_token"
301            ),
302            "secrets://local/demo/_/messaging_webex/webex_bot_token",
303            "setup↔runtime secret-uri contract broke — read the DO-NOT-CHANGE doc above",
304        );
305    }
306
307    #[test]
308    fn secret_uri_normalizes_provider_segment() {
309        // The provider segment is normalized like the key, so a secret written
310        // under the pack id `messaging-webchat-gui` resolves when fetched under
311        // the component's dotted id `messaging.webchat-gui`.
312        let stored = canonical_secret_uri(
313            "dev",
314            "demo",
315            None,
316            "messaging-webchat-gui",
317            "jwt_signing_key",
318        );
319        let fetched = canonical_secret_uri(
320            "dev",
321            "demo",
322            None,
323            "messaging.webchat-gui",
324            "jwt_signing_key",
325        );
326        assert_eq!(stored, fetched);
327        assert_eq!(
328            stored,
329            "secrets://dev/demo/_/messaging_webchat_gui/jwt_signing_key"
330        );
331    }
332
333    #[test]
334    fn resolve_env_returns_local_by_default() {
335        with_clean_env(|| {
336            assert_eq!(resolve_env(None), "local");
337        });
338    }
339
340    #[test]
341    fn resolve_env_passes_through_non_legacy_override() {
342        with_clean_env(|| {
343            assert_eq!(resolve_env(Some("staging")), "staging");
344            assert_eq!(resolve_env(Some("prod")), "prod");
345            assert_eq!(resolve_env(Some("local")), "local");
346        });
347    }
348
349    #[test]
350    fn resolve_env_remaps_dev_override_to_local() {
351        with_clean_env(|| {
352            assert_eq!(resolve_env(Some("dev")), "local");
353        });
354    }
355
356    #[test]
357    fn resolve_env_remaps_dev_env_var_to_local() {
358        with_clean_env(|| {
359            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
360            unsafe {
361                std::env::set_var("GREENTIC_ENV", "dev");
362            }
363            assert_eq!(resolve_env(None), "local");
364        });
365    }
366
367    #[test]
368    fn alias_warning_fires_only_once_per_process() {
369        // The warn target is the same across calls — the AtomicBool latch
370        // is what we're verifying. Direct call to apply_dev_alias avoids
371        // re-reading env vars.
372        with_clean_env(|| {
373            // First two calls: alias remaps both, but only the first fires
374            // the warn (visible via the AtomicBool latch — there's no
375            // easy way to count tracing events without wiring a subscriber,
376            // so we exercise the latch state by re-resetting and verifying
377            // a second non-firing path returns the same remapped value).
378            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
379            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
380            // Reset confirms the latch was set (the next call would warn
381            // again after reset).
382            compat_alias::reset_warning_latch_for_tests();
383            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
384        });
385    }
386
387    #[test]
388    fn disable_alias_env_var_panics_on_dev() {
389        with_clean_env(|| {
390            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
391            unsafe {
392                std::env::set_var(DISABLE_ALIAS_ENV_VAR, "1");
393            }
394            let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
395            assert!(
396                result.is_err(),
397                "resolve_env should panic when alias is disabled and input is `dev`"
398            );
399        });
400    }
401
402    #[test]
403    fn disable_alias_accepts_truthy_strings() {
404        for value in ["1", "true", "TRUE", "yes", "YES", "on", " true "] {
405            with_clean_env(|| {
406                // SAFETY: serialized via ENV_LOCK inside with_clean_env.
407                unsafe {
408                    std::env::set_var(DISABLE_ALIAS_ENV_VAR, value);
409                }
410                let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
411                assert!(
412                    result.is_err(),
413                    "DISABLE value `{value}` should hard-fail on dev resolution"
414                );
415            });
416        }
417    }
418
419    #[test]
420    fn disable_alias_does_not_panic_on_non_legacy_values() {
421        with_clean_env(|| {
422            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
423            unsafe {
424                std::env::set_var(DISABLE_ALIAS_ENV_VAR, "1");
425            }
426            // Non-legacy values pass through unaffected even when the
427            // alias is disabled — the gate only fires on `dev`.
428            assert_eq!(resolve_env(Some("local")), "local");
429            assert_eq!(resolve_env(Some("staging")), "staging");
430            assert_eq!(resolve_env(None), "local");
431        });
432    }
433}