1use 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
23pub fn setup(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
25 setup_or_update(args, SetupMode::Create, i18n)
26}
27
28pub fn update(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
30 setup_or_update(args, SetupMode::Update, i18n)
31}
32
33fn 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 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
291pub(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 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 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 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}