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 admin;
7pub mod answers_crypto;
8pub mod bundle;
9pub mod bundle_source;
10pub mod capabilities;
11pub mod card_setup;
12pub mod cli_args;
13pub mod cli_commands;
14pub mod cli_helpers;
15pub mod cli_i18n;
16pub mod config_envelope;
17pub mod deployment_targets;
18pub mod discovery;
19pub mod doctor;
20pub mod engine;
21pub mod env_mode;
22pub mod env_wizard;
23pub mod flow;
24pub mod gtbundle;
25pub mod no_ui_oauth;
26pub mod oauth_callback;
27pub mod oauth_device;
28pub mod plan;
29pub mod platform_setup;
30pub mod provider_state;
31pub mod reload;
32pub mod secret_name;
33pub mod secrets;
34pub mod setup_actions;
35pub mod setup_input;
36pub mod setup_to_formspec;
37pub mod setup_tunnel;
38pub mod tenant_config;
39pub mod webhook;
40
41#[cfg(feature = "ui")]
42pub mod ui;
43
44pub mod qa {
45    //! QA-driven configuration: FormSpec bridge, wizard prompts, answers
46    //! persistence, and setup input loading.
47    pub mod bridge;
48    pub mod persist;
49    pub mod prompts;
50    pub mod shared_questions;
51    pub mod wizard;
52}
53
54pub use bundle_source::BundleSource;
55pub use engine::SetupEngine;
56pub use plan::{SetupMode, SetupPlan, SetupStep, SetupStepKind};
57
58// Re-export shared questions types and functions for convenient multi-provider setup
59pub use qa::wizard::{
60    ProviderFormSpec, SHARED_QUESTION_IDS, SharedQuestionsResult, build_provider_form_specs,
61    collect_shared_questions, prompt_shared_questions, run_qa_setup_with_shared,
62};
63
64/// Returns the crate version.
65pub fn version() -> &'static str {
66    env!("CARGO_PKG_VERSION")
67}
68
69/// Default environment id when nothing is set. Flipped from `"dev"` to
70/// `"local"` as part of A4b — the `local` env is what `gtc setup` and
71/// `gtc start` auto-create per A4.
72pub const DEFAULT_ENV_ID: &str = "local";
73
74/// Legacy env id this crate accepts via the compat alias. Resolved values
75/// that match this string are remapped to [`DEFAULT_ENV_ID`] with a
76/// once-per-process warning, unless the operator disables the alias.
77pub const LEGACY_ENV_ID: &str = "dev";
78
79/// Env-var that disables the [`LEGACY_ENV_ID`] → [`DEFAULT_ENV_ID`] compat
80/// alias. Set to `1`, `true`, `yes`, or `on` (case-insensitive) to make any
81/// resolved value of `dev` hard-fail with a remediation hint. Intended for
82/// CI assertions that prove no production code-path still resolves to the
83/// legacy env id; remove once A4b PR3 flips the default in
84/// `greentic-config` and downstream consumers no longer pass `dev`.
85pub const DISABLE_ALIAS_ENV_VAR: &str = "GREENTIC_DISABLE_DEV_ALIAS";
86
87/// Resolve the effective environment string.
88///
89/// Priority: explicit override > `$GREENTIC_ENV` > [`DEFAULT_ENV_ID`]
90/// (`"local"`). After resolution, applies the [`LEGACY_ENV_ID`] →
91/// [`DEFAULT_ENV_ID`] compat alias: any value of `dev` is remapped to
92/// `local` with a once-per-process `tracing::warn!` unless
93/// [`DISABLE_ALIAS_ENV_VAR`] is set, in which case the resolution panics
94/// with a remediation hint.
95pub fn resolve_env(override_env: Option<&str>) -> String {
96    let raw = override_env
97        .map(|v| v.to_string())
98        .or_else(|| std::env::var("GREENTIC_ENV").ok())
99        .unwrap_or_else(|| DEFAULT_ENV_ID.to_string());
100    compat_alias::apply_dev_alias(&raw)
101}
102
103mod compat_alias {
104    //! `dev` → `local` compatibility alias (A4b).
105    //!
106    //! Centralized so `greentic-start` can mirror the contract verbatim;
107    //! the parallel implementation in that crate will be replaced with a
108    //! call into a shared helper if/when the duplication starts mattering.
109
110    use std::sync::atomic::{AtomicBool, Ordering};
111
112    use super::{DEFAULT_ENV_ID, DISABLE_ALIAS_ENV_VAR, LEGACY_ENV_ID};
113
114    static WARNED: AtomicBool = AtomicBool::new(false);
115
116    /// Apply the `dev` → `local` compat alias. Returns the remapped value
117    /// for any input equal to [`LEGACY_ENV_ID`]; returns the input
118    /// unchanged for any other value. Panics if the alias is disabled via
119    /// [`DISABLE_ALIAS_ENV_VAR`] and the input is the legacy id.
120    pub fn apply_dev_alias(env: &str) -> String {
121        if env != LEGACY_ENV_ID {
122            return env.to_string();
123        }
124        if alias_disabled() {
125            // Hard-fail expiry gate. The panic message is the remediation —
126            // tracing may not be wired in every binary that consumes
127            // `resolve_env`, and exit() bypasses test harnesses.
128            panic!(
129                "environment `{LEGACY_ENV_ID}` is no longer accepted (set via {DISABLE_ALIAS_ENV_VAR}=1). \
130                 Migrate to `{DEFAULT_ENV_ID}` via `gtc op env migrate-dev {DEFAULT_ENV_ID} --check` then `--apply`, \
131                 or pass `--env {DEFAULT_ENV_ID}` / unset $GREENTIC_ENV.",
132            );
133        }
134        if !WARNED.swap(true, Ordering::SeqCst) {
135            tracing::warn!(
136                target: "greentic_setup::compat_alias",
137                legacy = LEGACY_ENV_ID,
138                target_env = DEFAULT_ENV_ID,
139                "env `{LEGACY_ENV_ID}` is deprecated; resolving as `{DEFAULT_ENV_ID}` for this process. \
140                 Plan the migration with `gtc op env migrate-dev {DEFAULT_ENV_ID} --check`; \
141                 set {DISABLE_ALIAS_ENV_VAR}=1 to hard-fail on `{LEGACY_ENV_ID}` in CI.",
142            );
143        }
144        DEFAULT_ENV_ID.to_string()
145    }
146
147    fn alias_disabled() -> bool {
148        std::env::var(DISABLE_ALIAS_ENV_VAR)
149            .ok()
150            .map(|v| {
151                let v = v.trim().to_ascii_lowercase();
152                matches!(v.as_str(), "1" | "true" | "yes" | "on")
153            })
154            .unwrap_or(false)
155    }
156
157    /// Reset the warning latch. Test-only so multiple `apply_dev_alias`
158    /// invocations can each verify the once-per-process behavior.
159    #[cfg(test)]
160    pub(super) fn reset_warning_latch_for_tests() {
161        WARNED.store(false, Ordering::SeqCst);
162    }
163}
164
165/// Build a canonical secret URI: `secrets://{env}/{tenant}/{team}/{provider}/{key}`.
166///
167/// The team segment is normalized via `greentic-secrets`
168/// ([`greentic_secrets_lib::normalize_team`]) — the single source of truth for
169/// the "`_` everywhere" rule (empty / `"default"` / `None` → `_`) — and the key
170/// via the shared [`secret_name::canonical_secret_name`]. The empty-provider →
171/// `messaging` default and the infallible `String` shape are setup-local
172/// conveniences kept on top of the shared primitives.
173pub fn canonical_secret_uri(
174    env: &str,
175    tenant: &str,
176    team: Option<&str>,
177    provider: &str,
178    key: &str,
179) -> String {
180    let team_segment = greentic_secrets_lib::normalize_team(team)
181        .unwrap_or_else(|| greentic_secrets_lib::TEAM_PLACEHOLDER.to_string());
182    let provider_segment = if provider.is_empty() {
183        "messaging".to_string()
184    } else {
185        provider.to_string()
186    };
187    let normalized_key = secret_name::canonical_secret_name(key);
188    format!("secrets://{env}/{tenant}/{team_segment}/{provider_segment}/{normalized_key}")
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use std::sync::Mutex;
195
196    // `GREENTIC_ENV` and `GREENTIC_DISABLE_DEV_ALIAS` are process-global;
197    // serialize tests that mutate them so they don't interleave with each
198    // other or with tests in other modules that mutate the same vars.
199    static ENV_LOCK: Mutex<()> = Mutex::new(());
200
201    fn with_clean_env<R>(body: impl FnOnce() -> R) -> R {
202        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
203        let prev_env = std::env::var_os("GREENTIC_ENV");
204        let prev_disable = std::env::var_os(DISABLE_ALIAS_ENV_VAR);
205        // SAFETY: serialized by ENV_LOCK; tests are single-threaded inside
206        // the critical section. unsafe is required because set_var /
207        // remove_var are marked unsafe in Rust 2024 edition.
208        unsafe {
209            std::env::remove_var("GREENTIC_ENV");
210            std::env::remove_var(DISABLE_ALIAS_ENV_VAR);
211        }
212        compat_alias::reset_warning_latch_for_tests();
213        let out = body();
214        unsafe {
215            match prev_env {
216                Some(v) => std::env::set_var("GREENTIC_ENV", v),
217                None => std::env::remove_var("GREENTIC_ENV"),
218            }
219            match prev_disable {
220                Some(v) => std::env::set_var(DISABLE_ALIAS_ENV_VAR, v),
221                None => std::env::remove_var(DISABLE_ALIAS_ENV_VAR),
222            }
223        }
224        out
225    }
226
227    #[test]
228    fn version_is_correct() {
229        assert!(version().starts_with("1.1"));
230    }
231
232    #[test]
233    fn secret_uri_basic() {
234        let uri = canonical_secret_uri("dev", "demo", None, "messaging-telegram", "bot_token");
235        assert_eq!(uri, "secrets://dev/demo/_/messaging-telegram/bot_token");
236    }
237
238    #[test]
239    fn secret_uri_with_team() {
240        let uri = canonical_secret_uri("dev", "acme", Some("ops"), "state-redis", "redis_url");
241        assert_eq!(uri, "secrets://dev/acme/ops/state-redis/redis_url");
242    }
243
244    #[test]
245    fn secret_uri_default_team_becomes_wildcard() {
246        let uri = canonical_secret_uri(
247            "dev",
248            "demo",
249            Some("default"),
250            "messaging-slack",
251            "bot_token",
252        );
253        assert_eq!(uri, "secrets://dev/demo/_/messaging-slack/bot_token");
254    }
255
256    #[test]
257    fn resolve_env_returns_local_by_default() {
258        with_clean_env(|| {
259            assert_eq!(resolve_env(None), "local");
260        });
261    }
262
263    #[test]
264    fn resolve_env_passes_through_non_legacy_override() {
265        with_clean_env(|| {
266            assert_eq!(resolve_env(Some("staging")), "staging");
267            assert_eq!(resolve_env(Some("prod")), "prod");
268            assert_eq!(resolve_env(Some("local")), "local");
269        });
270    }
271
272    #[test]
273    fn resolve_env_remaps_dev_override_to_local() {
274        with_clean_env(|| {
275            assert_eq!(resolve_env(Some("dev")), "local");
276        });
277    }
278
279    #[test]
280    fn resolve_env_remaps_dev_env_var_to_local() {
281        with_clean_env(|| {
282            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
283            unsafe {
284                std::env::set_var("GREENTIC_ENV", "dev");
285            }
286            assert_eq!(resolve_env(None), "local");
287        });
288    }
289
290    #[test]
291    fn alias_warning_fires_only_once_per_process() {
292        // The warn target is the same across calls — the AtomicBool latch
293        // is what we're verifying. Direct call to apply_dev_alias avoids
294        // re-reading env vars.
295        with_clean_env(|| {
296            // First two calls: alias remaps both, but only the first fires
297            // the warn (visible via the AtomicBool latch — there's no
298            // easy way to count tracing events without wiring a subscriber,
299            // so we exercise the latch state by re-resetting and verifying
300            // a second non-firing path returns the same remapped value).
301            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
302            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
303            // Reset confirms the latch was set (the next call would warn
304            // again after reset).
305            compat_alias::reset_warning_latch_for_tests();
306            assert_eq!(compat_alias::apply_dev_alias("dev"), "local");
307        });
308    }
309
310    #[test]
311    fn disable_alias_env_var_panics_on_dev() {
312        with_clean_env(|| {
313            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
314            unsafe {
315                std::env::set_var(DISABLE_ALIAS_ENV_VAR, "1");
316            }
317            let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
318            assert!(
319                result.is_err(),
320                "resolve_env should panic when alias is disabled and input is `dev`"
321            );
322        });
323    }
324
325    #[test]
326    fn disable_alias_accepts_truthy_strings() {
327        for value in ["1", "true", "TRUE", "yes", "YES", "on", " true "] {
328            with_clean_env(|| {
329                // SAFETY: serialized via ENV_LOCK inside with_clean_env.
330                unsafe {
331                    std::env::set_var(DISABLE_ALIAS_ENV_VAR, value);
332                }
333                let result = std::panic::catch_unwind(|| resolve_env(Some("dev")));
334                assert!(
335                    result.is_err(),
336                    "DISABLE value `{value}` should hard-fail on dev resolution"
337                );
338            });
339        }
340    }
341
342    #[test]
343    fn disable_alias_does_not_panic_on_non_legacy_values() {
344        with_clean_env(|| {
345            // SAFETY: serialized via ENV_LOCK inside with_clean_env.
346            unsafe {
347                std::env::set_var(DISABLE_ALIAS_ENV_VAR, "1");
348            }
349            // Non-legacy values pass through unaffected even when the
350            // alias is disabled — the gate only fires on `dev`.
351            assert_eq!(resolve_env(Some("local")), "local");
352            assert_eq!(resolve_env(Some("staging")), "staging");
353            assert_eq!(resolve_env(None), "local");
354        });
355    }
356}