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