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