Skip to main content

xbp_cli/commands/deploy_engine/
reset.rs

1//! Reset project deploy / kubernetes settings (config + local history).
2//!
3//! Does **not** delete live cluster resources.
4
5use 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    /// When set with service_deploy, only these services (empty = all).
22    pub services: Vec<String>,
23    /// When set, only remove this env key from services[].deploy.envs (narrow).
24    pub env: Option<String>,
25    /// Wipe entire services[].deploy even if Cloudflare is configured.
26    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
55/// Entry: resolve scopes (interactive if needed), mutate config, write, delete history.
56pub 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    // Capture history paths before deploy-config clear may wipe custom paths.
119    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                // Keep path fields if custom, or clear to defaults by setting None
264                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        // Drop empty deploy block
271        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    // Keep CF-facing fields; strip envs (k8s) and non-CF destinations.
379    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 was pure kubernetes, switch to cloudflare if we still have CF dest / worker.
385    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()); // pure k8s wiped
550        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}