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