xbp_cli/commands/deploy_engine/
reset.rs1use std::collections::HashSet;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use colored::Colorize;
10
11use crate::cli::interactive::{confirm, print_picker_header, searchable_multi_select};
12use crate::strategies::deployment_config::{ServiceConfig, XbpConfig};
13use crate::utils::{find_xbp_config_upwards, write_xbp_project_config_at_path};
14
15#[derive(Debug, Clone, Default)]
16pub struct DeployResetRequest {
17 pub kubernetes: bool,
18 pub deploy_config: bool,
19 pub service_deploy: bool,
20 pub history: bool,
21 pub services: Vec<String>,
23 pub env: Option<String>,
25 pub full_service_deploy: bool,
27 pub dry_run: bool,
28 pub yes: bool,
29}
30
31impl DeployResetRequest {
32 pub fn any_scope(&self) -> bool {
33 self.kubernetes
34 || self.deploy_config
35 || self.service_deploy
36 || self.history
37 || self.env.as_ref().is_some_and(|e| !e.trim().is_empty())
38 }
39
40 pub fn enable_all(&mut self) {
41 self.kubernetes = true;
42 self.deploy_config = true;
43 self.service_deploy = true;
44 self.history = true;
45 }
46}
47
48#[derive(Debug, Clone, Default)]
49pub struct DeployResetReport {
50 pub notes: Vec<String>,
51 pub wrote_config: bool,
52 pub deleted_paths: Vec<PathBuf>,
53}
54
55pub fn run_deploy_reset(
57 project_root: &Path,
58 mut config: XbpConfig,
59 mut request: DeployResetRequest,
60) -> Result<DeployResetReport, String> {
61 if !request.any_scope() {
62 if secrets_is_interactive() && !request.yes {
63 request = prompt_reset_scopes(request)?;
64 } else {
65 return Err(
66 "deploy reset requires a scope. Pass --all, or one of \
67 --reset-kubernetes / --reset-deploy-config / --reset-service-deploy / \
68 --reset-history / --reset-env <ENV>. In a TTY, omit scopes to pick interactively."
69 .into(),
70 );
71 }
72 }
73
74 if !request.any_scope() {
75 return Err("deploy reset cancelled: no scopes selected".into());
76 }
77
78 let summary = summarize_reset(&config, project_root, &request);
79 println!();
80 println!(
81 "{} {}",
82 "Deploy reset".bright_cyan().bold(),
83 "(config + local history only — not cluster resources)".dimmed()
84 );
85 for line in &summary {
86 println!(" {} {}", "·".bright_yellow(), line);
87 }
88 println!();
89
90 if request.dry_run {
91 println!(
92 "{} {}",
93 "[dry-run]".bright_yellow(),
94 "No files written or deleted.".bright_black()
95 );
96 return Ok(DeployResetReport {
97 notes: summary,
98 wrote_config: false,
99 deleted_paths: vec![],
100 });
101 }
102
103 let proceed = if request.yes {
104 true
105 } else if secrets_is_interactive() {
106 confirm("Apply deploy/kubernetes settings reset?", false)?
107 } else {
108 return Err(
109 "deploy reset requires confirmation. Re-run with --yes, or in an interactive TTY."
110 .into(),
111 );
112 };
113 if !proceed {
114 return Err("deploy reset cancelled".into());
115 }
116
117 let mut report = DeployResetReport::default();
118 let history_targets = if request.history {
120 Some(history_paths(project_root, &config))
121 } else {
122 None
123 };
124
125 apply_reset_to_config(&mut config, &request, &mut report.notes);
126
127 let config_path = find_xbp_config_upwards(project_root)
128 .map(|f| f.config_path)
129 .ok_or_else(|| "No project config found to write".to_string())?;
130
131 write_xbp_project_config_at_path(&config_path, &config)?;
132 report.wrote_config = true;
133 report.notes.push(format!("wrote {}", config_path.display()));
134
135 if let Some((hist, lock)) = history_targets {
136 for path in delete_paths(&[hist, lock])? {
137 report
138 .notes
139 .push(format!("deleted {}", path.display()));
140 report.deleted_paths.push(path);
141 }
142 }
143
144 println!(
145 "{} deploy settings reset complete",
146 "✓".bright_green().bold()
147 );
148 for note in &report.notes {
149 println!(" {} {}", "·".bright_black(), note.dimmed());
150 }
151 println!();
152 println!(
153 "Re-run {} to recreate deploy.envs via the setup wizard.",
154 "xbp deploy <target> --env <env>".bright_white().bold()
155 );
156
157 Ok(report)
158}
159
160fn secrets_is_interactive() -> bool {
161 use std::io::IsTerminal;
162 std::io::stdin().is_terminal()
163 && std::io::stdout().is_terminal()
164 && std::env::var_os("XBP_NON_INTERACTIVE").is_none()
165}
166
167fn prompt_reset_scopes(mut request: DeployResetRequest) -> Result<DeployResetRequest, String> {
168 print_picker_header(
169 "xbp deploy · reset scopes",
170 "Space toggles · Enter confirms · config/local only (not cluster)",
171 );
172 let labels = [
173 "kubernetes: project block",
174 "deploy: groups + default_env / history paths",
175 "services[].deploy (CF-aware k8s clear by default)",
176 "local history dir + deploy lock file",
177 "ALL of the above",
178 ];
179 let defaults = [false, false, false, false, false];
180 let selected = searchable_multi_select("Reset which scopes?", &labels, &defaults)?;
181 if selected.is_empty() {
182 return Ok(request);
183 }
184 if selected.contains(&4) {
185 request.enable_all();
186 return Ok(request);
187 }
188 if selected.contains(&0) {
189 request.kubernetes = true;
190 }
191 if selected.contains(&1) {
192 request.deploy_config = true;
193 }
194 if selected.contains(&2) {
195 request.service_deploy = true;
196 }
197 if selected.contains(&3) {
198 request.history = true;
199 }
200 Ok(request)
201}
202
203fn summarize_reset(config: &XbpConfig, project_root: &Path, request: &DeployResetRequest) -> Vec<String> {
204 let mut lines = Vec::new();
205 if request.kubernetes {
206 lines.push(if config.kubernetes.is_some() {
207 "clear project kubernetes:".into()
208 } else {
209 "kubernetes: already empty".into()
210 });
211 }
212 if request.deploy_config {
213 let groups = config
214 .deploy
215 .as_ref()
216 .map(|d| d.groups.len())
217 .unwrap_or(0);
218 lines.push(format!("clear project deploy: (groups={groups}, default_env, paths)"));
219 }
220 if let Some(env) = request.env.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
221 lines.push(format!("remove services[].deploy.envs.{env}"));
222 } else if request.service_deploy {
223 let mode = if request.full_service_deploy {
224 "full wipe"
225 } else {
226 "CF-aware (keep cloudflare provider fields)"
227 };
228 let scope = if request.services.is_empty() {
229 "all services".into()
230 } else {
231 format!("services: {}", request.services.join(", "))
232 };
233 lines.push(format!("reset services[].deploy ({mode}) · {scope}"));
234 }
235 if request.history {
236 let (hist, lock) = history_paths(project_root, config);
237 lines.push(format!(
238 "delete history {} and lock {}",
239 hist.display(),
240 lock.display()
241 ));
242 }
243 lines
244}
245
246pub fn apply_reset_to_config(
247 config: &mut XbpConfig,
248 request: &DeployResetRequest,
249 notes: &mut Vec<String>,
250) {
251 if request.kubernetes {
252 if config.kubernetes.take().is_some() {
253 notes.push("cleared kubernetes:".into());
254 }
255 }
256
257 if request.deploy_config {
258 match config.deploy.as_mut() {
259 Some(deploy) => {
260 let n = deploy.groups.len();
261 deploy.groups.clear();
262 deploy.default_env = None;
263 deploy.history_dir = None;
265 deploy.lock_file = None;
266 notes.push(format!("cleared deploy.groups ({n}) and default_env/paths"));
267 }
268 None => notes.push("deploy: already absent".into()),
269 }
270 if let Some(deploy) = config.deploy.as_ref() {
272 if deploy.groups.is_empty()
273 && deploy.default_env.is_none()
274 && deploy.history_dir.is_none()
275 && deploy.lock_file.is_none()
276 {
277 config.deploy = None;
278 }
279 }
280 }
281
282 let env_only = request
283 .env
284 .as_deref()
285 .map(str::trim)
286 .filter(|s| !s.is_empty())
287 .map(str::to_string);
288
289 if let Some(env) = env_only {
290 let filter = service_filter(&request.services);
291 let mut removed = 0usize;
292 if let Some(services) = config.services.as_mut() {
293 for svc in services.iter_mut() {
294 if !service_matches(svc, &filter) {
295 continue;
296 }
297 if let Some(deploy) = svc.deploy.as_mut() {
298 if deploy.envs.remove(&env).is_some() {
299 removed += 1;
300 }
301 if deploy.envs.is_empty()
302 && deploy.destinations.is_empty()
303 && deploy.provider.is_none()
304 && deploy.worker.is_none()
305 && deploy.rollout.is_none()
306 {
307 svc.deploy = None;
308 }
309 }
310 }
311 }
312 notes.push(format!(
313 "removed deploy.envs.{env} from {removed} service(s)"
314 ));
315 } else if request.service_deploy {
316 let filter = service_filter(&request.services);
317 let mut touched = 0usize;
318 if let Some(services) = config.services.as_mut() {
319 for svc in services.iter_mut() {
320 if !service_matches(svc, &filter) {
321 continue;
322 }
323 if svc.deploy.is_none() {
324 continue;
325 }
326 if request.full_service_deploy {
327 svc.deploy = None;
328 touched += 1;
329 continue;
330 }
331 clear_service_deploy_cf_aware(svc);
332 touched += 1;
333 }
334 }
335 notes.push(format!("reset services[].deploy on {touched} service(s)"));
336 }
337}
338
339fn service_filter(names: &[String]) -> Option<HashSet<String>> {
340 if names.is_empty() {
341 return None;
342 }
343 Some(
344 names
345 .iter()
346 .map(|s| s.trim().to_string())
347 .filter(|s| !s.is_empty())
348 .collect(),
349 )
350}
351
352fn service_matches(svc: &ServiceConfig, filter: &Option<HashSet<String>>) -> bool {
353 match filter {
354 None => true,
355 Some(set) => set.contains(&svc.name),
356 }
357}
358
359fn clear_service_deploy_cf_aware(svc: &mut ServiceConfig) {
360 let Some(deploy) = svc.deploy.as_mut() else {
361 return;
362 };
363
364 let provider = deploy.provider.as_deref().unwrap_or("").to_ascii_lowercase();
365 let is_cf = provider.contains("cloudflare")
366 || provider == "worker"
367 || deploy.destinations.keys().any(|k| {
368 let k = k.to_ascii_lowercase();
369 k.contains("cloudflare") || k == "worker"
370 })
371 || deploy.worker.is_some();
372
373 if !is_cf {
374 svc.deploy = None;
375 return;
376 }
377
378 deploy.envs.clear();
380 deploy.destinations.retain(|name, _| {
381 let n = name.to_ascii_lowercase();
382 n.contains("cloudflare") || n == "worker"
383 });
384 if !provider.contains("cloudflare") && provider != "worker" {
386 if deploy.worker.is_some()
387 || deploy
388 .destinations
389 .values()
390 .any(|d| d.provider.as_deref().is_some_and(|p| p.contains("cloudflare")))
391 {
392 deploy.provider = Some("cloudflare-containers".into());
393 }
394 }
395 if deploy.envs.is_empty()
396 && deploy.destinations.is_empty()
397 && deploy.provider.as_deref().is_some_and(|p| {
398 let p = p.to_ascii_lowercase();
399 p.contains("kubernetes") || p == "k8s"
400 })
401 {
402 svc.deploy = None;
403 }
404}
405
406fn history_paths(project_root: &Path, config: &XbpConfig) -> (PathBuf, PathBuf) {
407 let hist_rel = config
408 .deploy
409 .as_ref()
410 .and_then(|d| d.history_dir.as_deref())
411 .filter(|s| !s.trim().is_empty())
412 .unwrap_or(".xbp/deployments");
413 let lock_rel = config
414 .deploy
415 .as_ref()
416 .and_then(|d| d.lock_file.as_deref())
417 .filter(|s| !s.trim().is_empty())
418 .unwrap_or(".xbp/deploy-lock.json");
419 (project_root.join(hist_rel), project_root.join(lock_rel))
420}
421
422fn delete_paths(paths: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
423 let mut deleted = Vec::new();
424 for path in paths {
425 if !path.exists() {
426 continue;
427 }
428 if path.is_dir() {
429 fs::remove_dir_all(path)
430 .map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
431 } else {
432 fs::remove_file(path)
433 .map_err(|e| format!("Failed to remove {}: {e}", path.display()))?;
434 }
435 deleted.push(path.clone());
436 }
437 Ok(deleted)
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 fn sample_config() -> XbpConfig {
445 serde_json::from_value(serde_json::json!({
446 "project_name": "demo",
447 "version": "0.1.0",
448 "port": 3000,
449 "build_dir": "./",
450 "kubernetes": {
451 "default_context": "docker-desktop",
452 "default_namespace": "default"
453 },
454 "deploy": {
455 "default_env": "production",
456 "history_dir": ".xbp/deployments",
457 "lock_file": ".xbp/deploy-lock.json",
458 "groups": {
459 "all": {
460 "services": ["api", "web"]
461 }
462 }
463 },
464 "services": [
465 {
466 "name": "api",
467 "target": "docker",
468 "branch": "main",
469 "port": 8080,
470 "deploy": {
471 "provider": "kubernetes",
472 "envs": {
473 "production": { "namespace": "prod" },
474 "staging": { "namespace": "stg" }
475 }
476 }
477 },
478 {
479 "name": "web",
480 "target": "nodejs",
481 "branch": "main",
482 "port": 3000,
483 "deploy": {
484 "provider": "cloudflare-containers",
485 "worker": "web",
486 "envs": {
487 "production": { "namespace": "prod" },
488 "staging": { "namespace": "stg" }
489 },
490 "destinations": {
491 "cloudflare": { "provider": "cloudflare-containers" },
492 "kubernetes": { "provider": "kubernetes" }
493 }
494 }
495 }
496 ]
497 }))
498 .expect("sample XbpConfig")
499 }
500
501 #[test]
502 fn reset_kubernetes_only() {
503 let mut config = sample_config();
504 let mut notes = Vec::new();
505 apply_reset_to_config(
506 &mut config,
507 &DeployResetRequest {
508 kubernetes: true,
509 ..Default::default()
510 },
511 &mut notes,
512 );
513 assert!(config.kubernetes.is_none());
514 assert!(config.services.as_ref().unwrap()[0].deploy.is_some());
515 }
516
517 #[test]
518 fn reset_env_only() {
519 let mut config = sample_config();
520 let mut notes = Vec::new();
521 apply_reset_to_config(
522 &mut config,
523 &DeployResetRequest {
524 env: Some("production".into()),
525 ..Default::default()
526 },
527 &mut notes,
528 );
529 let api = &config.services.as_ref().unwrap()[0];
530 let envs = &api.deploy.as_ref().unwrap().envs;
531 assert!(!envs.contains_key("production"));
532 assert!(envs.contains_key("staging"));
533 }
534
535 #[test]
536 fn cf_aware_keeps_cloudflare() {
537 let mut config = sample_config();
538 let mut notes = Vec::new();
539 apply_reset_to_config(
540 &mut config,
541 &DeployResetRequest {
542 service_deploy: true,
543 full_service_deploy: false,
544 ..Default::default()
545 },
546 &mut notes,
547 );
548 let services = config.services.as_ref().unwrap();
549 assert!(services[0].deploy.is_none()); let web = services[1].deploy.as_ref().unwrap();
551 assert!(web.worker.as_deref() == Some("web"));
552 assert!(web.envs.is_empty());
553 assert!(web.destinations.contains_key("cloudflare"));
554 assert!(!web.destinations.contains_key("kubernetes"));
555 }
556
557 #[test]
558 fn full_wipe_clears_cf_service() {
559 let mut config = sample_config();
560 let mut notes = Vec::new();
561 apply_reset_to_config(
562 &mut config,
563 &DeployResetRequest {
564 service_deploy: true,
565 full_service_deploy: true,
566 services: vec!["web".into()],
567 ..Default::default()
568 },
569 &mut notes,
570 );
571 assert!(config.services.as_ref().unwrap()[1].deploy.is_none());
572 assert!(config.services.as_ref().unwrap()[0].deploy.is_some());
573 }
574}