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