Skip to main content

greentic_setup/cli_commands/
setup.rs

1//! Setup and update commands for bundle configuration.
2
3use std::io::{self, Write};
4use std::thread;
5use std::time::Duration;
6
7use anyhow::{Context, Result, bail};
8use greentic_deployer::cli::bootstrap::{LocalEnvOutcome, ensure_local_environment};
9use greentic_deployer::environment::LocalFsStore;
10
11use crate::cli_args::*;
12use crate::cli_helpers::{
13    complete_loaded_answers_with_prompts, ensure_deployment_targets_present,
14    ensure_required_setup_answers_present, maybe_start_cli_setup_tunnel, resolve_bundle_dir,
15    resolve_setup_scope, run_interactive_wizard,
16};
17use crate::cli_i18n::CliI18n;
18use crate::engine::{LoadedAnswers, SetupConfig, SetupRequest};
19use crate::plan::TenantSelection;
20use crate::platform_setup::StaticRoutesPolicy;
21use crate::{SetupEngine, SetupMode, bundle, resolve_env};
22
23/// Run the setup command.
24pub fn setup(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
25    setup_or_update(args, SetupMode::Create, i18n)
26}
27
28/// Run the update command.
29pub fn update(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
30    setup_or_update(args, SetupMode::Update, i18n)
31}
32
33/// Shared implementation for setup and update commands.
34fn setup_or_update(args: BundleSetupArgs, mode: SetupMode, i18n: &CliI18n) -> Result<()> {
35    let bundle_dir = resolve_bundle_dir(args.bundle)?;
36    let BundleSetupArgs {
37        provider_id,
38        bundle: _,
39        tenant: cli_tenant,
40        team: cli_team,
41        env: cli_env,
42        domain,
43        dry_run,
44        emit_answers,
45        answers,
46        key,
47        non_interactive,
48        advanced,
49        parallel,
50        backup,
51        skip_secrets_init,
52        best_effort,
53    } = args;
54
55    // A10: thread the env_id through the wizard surface as the canonical
56    // env id. resolve_env applies the A4b `dev` → `local` compat alias so
57    // a user passing `--env dev` doesn't slip past as a raw legacy string.
58    let cli_env = resolve_env(Some(&cli_env));
59
60    bundle::validate_bundle_exists(&bundle_dir).context(i18n.t("cli.error.invalid_bundle"))?;
61
62    bootstrap_local_environment(i18n)?;
63
64    let provider_display = provider_id.clone().unwrap_or_else(|| "all".to_string());
65
66    let header_key = match mode {
67        SetupMode::Update => "cli.bundle.update.updating",
68        _ => "cli.bundle.setup.setting_up",
69    };
70    println!("{}", i18n.t(header_key));
71    println!(
72        "{}",
73        i18n.tf("cli.bundle.setup.provider", &[&provider_display])
74    );
75    println!(
76        "{}",
77        i18n.tf(
78            "cli.bundle.add.bundle",
79            &[&bundle_dir.display().to_string()]
80        )
81    );
82    let loader_engine = SetupEngine::new(SetupConfig {
83        tenant: cli_tenant.clone(),
84        team: cli_team.clone(),
85        env: cli_env.clone(),
86        offline: false,
87        verbose: true,
88    });
89
90    let loaded_answers = if let Some(answers_path) = &answers {
91        loader_engine
92            .load_answers(answers_path, key.as_deref(), !non_interactive)
93            .context(i18n.t("cli.error.failed_read_answers"))?
94    } else if emit_answers.is_some() {
95        LoadedAnswers::default()
96    } else if non_interactive {
97        bail!("{}", i18n.t("cli.error.answers_required"));
98    } else {
99        println!("\n{}", i18n.t("cli.simple.interactive_mode"));
100        println!();
101        run_interactive_wizard(
102            &bundle_dir,
103            &cli_tenant,
104            cli_team.as_deref(),
105            &cli_env,
106            advanced,
107        )?
108    };
109    let (tenant, team, env) = if answers.is_some() {
110        resolve_setup_scope(cli_tenant, cli_team, cli_env, &loaded_answers)
111    } else {
112        (cli_tenant, cli_team, cli_env)
113    };
114
115    println!("{}", i18n.tf("cli.bundle.add.tenant", &[&tenant]));
116    println!(
117        "{}",
118        i18n.tf(
119            "cli.bundle.add.team",
120            &[team.as_deref().unwrap_or("default")]
121        )
122    );
123    println!("{}", i18n.tf("cli.bundle.add.env", &[&env]));
124    println!("{}", i18n.tf("cli.bundle.setup.domain", &[&domain]));
125
126    let mut loaded_answers = if answers.is_some() {
127        complete_loaded_answers_with_prompts(
128            &bundle_dir,
129            &tenant,
130            team.as_deref(),
131            &env,
132            advanced,
133            non_interactive,
134            loaded_answers,
135        )?
136    } else {
137        loaded_answers
138    };
139    if non_interactive {
140        ensure_deployment_targets_present(&bundle_dir, &loaded_answers)?;
141    }
142
143    let is_dry_run = dry_run || emit_answers.is_some();
144    let mut no_ui_oauth_server = if !is_dry_run {
145        Some(
146            crate::no_ui_oauth::start_callback_server(&bundle_dir, &env)
147                .context("failed to start no-UI OAuth callback server")?,
148        )
149    } else {
150        None
151    };
152    let _setup_tunnel = if !is_dry_run {
153        let local_base_url = no_ui_oauth_server
154            .as_ref()
155            .map(|server| server.local_base_url.as_str())
156            .unwrap_or("http://127.0.0.1:1");
157        let tunnel = maybe_start_cli_setup_tunnel(&mut loaded_answers, local_base_url)
158            .context("failed to start setup tunnel")?;
159        if let Some(tunnel) = tunnel.as_ref() {
160            println!("Setup tunnel public_base_url: {}", tunnel.public_base_url);
161        } else {
162            no_ui_oauth_server = None;
163        }
164        tunnel
165    } else {
166        None
167    };
168    if non_interactive {
169        ensure_required_setup_answers_present(&bundle_dir, &loaded_answers)
170            .context("Missing required answers in --non-interactive mode")?;
171    }
172
173    let providers = provider_id.clone().map_or_else(Vec::new, |id| vec![id]);
174
175    let request = SetupRequest {
176        bundle: bundle_dir.clone(),
177        bundle_name: crate::bundle::read_bundle_name(&bundle_dir).ok().flatten(),
178        providers,
179        tenants: vec![TenantSelection {
180            tenant: tenant.clone(),
181            team: team.clone(),
182            allow_paths: Vec::new(),
183        }],
184        static_routes: StaticRoutesPolicy::normalize(
185            loaded_answers.platform_setup.static_routes.as_ref(),
186            &env,
187        )
188        .context(i18n.t("cli.error.failed_read_answers"))?,
189        deployment_targets: loaded_answers.platform_setup.deployment_targets,
190        tunnel: loaded_answers.platform_setup.tunnel,
191        telemetry: loaded_answers.platform_setup.telemetry,
192        setup_answers: loaded_answers.setup_answers,
193        domain_filter: if domain == "all" {
194            None
195        } else {
196            Some(domain.clone())
197        },
198        parallel,
199        backup,
200        skip_secrets_init,
201        best_effort,
202        ..Default::default()
203    };
204
205    let engine = SetupEngine::new(SetupConfig {
206        tenant: tenant.clone(),
207        team: team.clone(),
208        env: env.clone(),
209        offline: false,
210        verbose: true,
211    });
212
213    let plan = engine
214        .plan(mode, &request, is_dry_run)
215        .context(i18n.t("cli.error.failed_build_plan"))?;
216
217    engine.print_plan(&plan);
218
219    if let Some(emit_path) = &emit_answers {
220        let emit_path_str = emit_path.display().to_string();
221        engine
222            .emit_answers(&plan, emit_path, key.as_deref(), !non_interactive)
223            .context(i18n.t("cli.error.failed_emit_answers"))?;
224        println!(
225            "\n{}",
226            i18n.tf("cli.bundle.setup.emit_written", &[&emit_path_str])
227        );
228        let usage_key = match mode {
229            SetupMode::Update => "cli.bundle.update.emit_usage",
230            _ => "cli.bundle.setup.emit_usage",
231        };
232        println!("{}", i18n.tf(usage_key, &[&emit_path_str]));
233        return Ok(());
234    }
235
236    if dry_run {
237        let dry_key = match mode {
238            SetupMode::Update => "cli.bundle.update.dry_run",
239            _ => "cli.bundle.setup.dry_run",
240        };
241        println!("\n{}", i18n.tf(dry_key, &[&provider_display]));
242        return Ok(());
243    }
244
245    let report = engine
246        .execute(&plan)
247        .context(i18n.t("cli.error.failed_execute_plan"))?;
248    print_pending_setup_actions(&report.pending_setup_actions);
249    wait_for_pending_oauth_callbacks(no_ui_oauth_server, &report.pending_setup_actions)?;
250    if !non_interactive {
251        execute_pending_oauth_device_actions(&bundle_dir, &env, &report.pending_setup_actions)?;
252    }
253
254    let done_key = match mode {
255        SetupMode::Update => "cli.bundle.update.complete",
256        _ => "cli.bundle.setup.complete",
257    };
258    println!("\n{}", i18n.tf(done_key, &[&provider_display]));
259
260    Ok(())
261}
262
263fn print_pending_setup_actions(actions: &[crate::setup_actions::SetupAction]) {
264    let visible_actions: Vec<_> = actions
265        .iter()
266        .filter(|action| {
267            matches!(
268                action.kind,
269                crate::setup_actions::SetupActionKind::OauthInstallButton
270            ) && action.status == crate::setup_actions::SetupActionStatus::Pending
271        })
272        .collect();
273    if visible_actions.is_empty() {
274        return;
275    }
276
277    println!();
278    for action in visible_actions {
279        if let Some(url) = action.authorize_url.as_deref() {
280            println!("{url}");
281        }
282        if action.callback_path.is_some() {
283            println!(
284                "After completing the OAuth flow, re-run setup if the callback was not handled automatically."
285            );
286        }
287        println!();
288    }
289}
290
291/// Idempotently auto-create the `local` Environment on first `gtc setup`.
292///
293/// Per A4 of `plans/next-gen-deployment.md`: every `gtc setup` (and update)
294/// invocation guarantees a `local` Environment exists with the five default
295/// capability-slot bindings (deployer/secrets/telemetry/sessions/state).
296/// Subsequent calls find the env on disk and stay silent.
297pub(crate) fn bootstrap_local_environment(i18n: &CliI18n) -> Result<()> {
298    let root = LocalFsStore::default_root()
299        .context("Cannot determine default environment store root (no home directory).")?;
300    let store = LocalFsStore::new(root.clone());
301    // greentic-setup never seeds a `public_base_url` at bootstrap time; the
302    // operator sets it later via `gtc op env init --public-url <URL>` or
303    // `gtc op env set-public-url`. Passing `None` preserves prior behavior on
304    // both first-run and idempotent re-runs.
305    let (_env, outcome) = ensure_local_environment(&store, None)
306        .with_context(|| format!("Bootstrapping `local` environment at {}", root.display()))?;
307    if outcome == LocalEnvOutcome::Created {
308        println!(
309            "{}",
310            i18n.tf(
311                "cli.bundle.setup.env_bootstrap_created",
312                &[&root.display().to_string()]
313            )
314        );
315    }
316    Ok(())
317}
318
319fn wait_for_pending_oauth_callbacks(
320    server: Option<crate::no_ui_oauth::NoUiOAuthCallbackServer>,
321    actions: &[crate::setup_actions::SetupAction],
322) -> Result<()> {
323    let pending = crate::no_ui_oauth::pending_oauth_install_actions(actions);
324    if pending.is_empty() {
325        return Ok(());
326    }
327    let Some(server) = server else {
328        return Ok(());
329    };
330    println!("Waiting for OAuth callback...");
331    let message = server.wait_for_callback()?;
332    println!("{message}");
333    Ok(())
334}
335
336fn execute_pending_oauth_device_actions(
337    bundle_dir: &std::path::Path,
338    env: &str,
339    actions: &[crate::setup_actions::SetupAction],
340) -> Result<()> {
341    for action in actions {
342        if action.kind != crate::setup_actions::SetupActionKind::OauthDeviceCode
343            || action.status != crate::setup_actions::SetupActionStatus::Pending
344        {
345            continue;
346        }
347        println!("Starting {}...", action.label);
348        let start = crate::oauth_device::start_oauth_device_code(
349            bundle_dir,
350            &crate::oauth_device::OAuthDeviceStartInput {
351                provider_id: action.provider_id.clone(),
352                tenant: action.tenant.clone(),
353                team: action.team.clone(),
354                action_id: action.id.clone(),
355            },
356            crate::oauth_device::DEFAULT_EXTENSION_KEY,
357        )?;
358        println!("Open {}", start.verification_uri);
359        println!("Enter code: {}", start.user_code);
360        print!("Press Enter after approving, or wait while setup polls...");
361        io::stdout().flush().ok();
362        let mut line = String::new();
363        let _ = io::stdin().read_line(&mut line);
364
365        let runtime =
366            tokio::runtime::Runtime::new().context("failed to create OAuth polling runtime")?;
367        let mut interval = start.interval.max(1);
368        loop {
369            let report = runtime.block_on(crate::oauth_device::poll_oauth_device_code(
370                bundle_dir,
371                env,
372                &crate::oauth_device::OAuthDevicePollInput {
373                    session_id: start.session_id.clone(),
374                },
375                crate::oauth_device::DEFAULT_EXTENSION_KEY,
376            ))?;
377            match report.status {
378                crate::oauth_device::OAuthDevicePollStatus::Complete => {
379                    println!("OAuth device-code setup complete for {}.", action.label);
380                    break;
381                }
382                crate::oauth_device::OAuthDevicePollStatus::Pending
383                | crate::oauth_device::OAuthDevicePollStatus::SlowDown => {
384                    interval = report.interval.unwrap_or(interval).max(1);
385                    if crate::setup_actions::current_epoch_secs() >= start.expires_at {
386                        bail!("OAuth device code expired before authorization completed");
387                    }
388                    thread::sleep(Duration::from_secs(interval.min(30)));
389                }
390                crate::oauth_device::OAuthDevicePollStatus::Failed => {
391                    if !report.checklist.is_empty() {
392                        println!("Checklist:");
393                        for item in &report.checklist {
394                            println!("- {item}");
395                        }
396                    }
397                    bail!(
398                        "{}",
399                        report
400                            .message
401                            .unwrap_or_else(|| "OAuth device-code setup failed".to_string())
402                    );
403                }
404            }
405        }
406    }
407    Ok(())
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use std::sync::Mutex;
414
415    // `HOME` is process-global; serialize tests that mutate it.
416    static HOME_LOCK: Mutex<()> = Mutex::new(());
417
418    fn with_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
419        let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
420        let prev = std::env::var_os("HOME");
421        // SAFETY: serialized by HOME_LOCK; tests are single-threaded inside the
422        // critical section. unsafe is required because set_var/remove_var are
423        // marked unsafe in Rust 2024 edition.
424        unsafe {
425            std::env::set_var("HOME", tmp);
426        }
427        let out = body();
428        unsafe {
429            match prev {
430                Some(v) => std::env::set_var("HOME", v),
431                None => std::env::remove_var("HOME"),
432            }
433        }
434        out
435    }
436
437    #[test]
438    fn bootstrap_creates_local_env_under_default_root() {
439        let tmp = tempfile::TempDir::new().expect("tempdir");
440        let i18n = CliI18n::from_request(Some("en")).expect("i18n");
441        with_home(tmp.path(), || {
442            bootstrap_local_environment(&i18n).expect("first bootstrap");
443        });
444        let env_file = tmp
445            .path()
446            .join(".greentic")
447            .join("environments")
448            .join("local")
449            .join("environment.json");
450        assert!(env_file.exists(), "expected env file at {env_file:?}");
451    }
452
453    #[test]
454    fn bootstrap_is_idempotent_across_calls() {
455        let tmp = tempfile::TempDir::new().expect("tempdir");
456        let i18n = CliI18n::from_request(Some("en")).expect("i18n");
457        with_home(tmp.path(), || {
458            bootstrap_local_environment(&i18n).expect("first bootstrap");
459            bootstrap_local_environment(&i18n).expect("second bootstrap");
460        });
461        let env_file = tmp
462            .path()
463            .join(".greentic")
464            .join("environments")
465            .join("local")
466            .join("environment.json");
467        assert!(env_file.exists());
468    }
469}