Skip to main content

rusty_stack/
uninstall.rs

1//! Uninstall orchestrator — removes the Rusty-managed ML stack so a clean
2//! reinstall is possible without manual purging.
3//!
4//! # Tenet (v0.3.0 remediation)
5//!
6//! "Reinstall functionality is currently broken and DOES NOT WORK. Rusty
7//! uninstall AND force-reinstall options MUST be fully functional across shells
8//! (fish, bash, zsh) AND across OS's (cross Linux distributions)."
9//!
10//! This module existed in v0.2.0 as dead primitives (`PackageManager::purge`,
11//! `purge_component_packages`) that were never wired into a user-facing flow.
12//! It is now a real `uninstall_stack()` reachable via `rusty uninstall`.
13//!
14//! # What it removes
15//!
16//! 1. Python ML packages (pip) installed by Rusty components.
17//! 2. ROCm/amdgpu system packages via the detected package manager (apt/dnf/
18//!    pacman/zypper) — cross-distro; skipped with `--keep-rocm`.
19//! 3. `/opt/rocm` (sudo) — skipped with `--keep-rocm`.
20//! 4. The env files Rusty wrote (`~/.mlstack_env`, the fish `conf.d` file) and
21//!    the legacy `source ~/.mlstack_env` lines from `~/.bashrc` / `~/.zshrc` /
22//!    `~/.config/fish/config.fish` (cross-shell).
23//! 5. The installed-component registry (`~/.mlstack/installed.json`).
24//!
25//! Sudo steps shell out to `sudo` (the user authenticates); they are skipped
26//! (with a warning) if not root and sudo is unavailable, so the command never
27//! stalls on a hidden prompt.
28
29use std::path::PathBuf;
30use std::process::Command;
31
32use crate::core::registry::InstalledComponentRegistry;
33use crate::platform::environment::{
34    command_on_path, mlstack_global_dir, resolve_canonical_python_bin, resolve_user_home,
35};
36
37/// ROCm/amdgpu system-package name candidates tried across distros. The package
38/// manager ignores names that aren't installed.
39const ROCM_SYSTEM_PACKAGES: &[&str] = &[
40    "rocm-hip-sdk",
41    "rocm-opencl-sdk",
42    "rocm-core",
43    "rocm-dev",
44    "rocm-opencl-runtime",
45    "amdgpu",
46    "amdgpu-dkms",
47];
48
49/// Python ML packages Rusty installs (pip). Each uninstall is non-fatal.
50const ML_PIP_PACKAGES: &[&str] = &[
51    "torch",
52    "torchvision",
53    "torchaudio",
54    "triton",
55    "pytorch-triton-rocm",
56    "pytorch-triton",
57    "vllm",
58    "deepspeed",
59    "megatron-core",
60    "bitsandbytes",
61    "flash-attn",
62    "flash_attn",
63    "aiter",
64    "onnxruntime",
65    "onnxruntime-rocm",
66    "migraphx",
67    "mpi4py",
68    "wandb",
69    "xformers",
70    "accelerate",
71    "transformers",
72];
73
74/// Options for [`uninstall_stack`].
75#[derive(Debug, Clone, Default)]
76pub struct UninstallOptions {
77    /// Keep ROCm/amdgpu system packages and `/opt/rocm` (only remove Python +
78    /// env/registry). Useful when reinstalling just the Python stack.
79    pub keep_rocm: bool,
80    /// Skip the interactive confirmation prompt.
81    pub yes: bool,
82    /// Also remove the `~/.mlstack/` data root (logs, cache, global venv).
83    /// Off by default — preserves logs/diagnostics across reinstalls.
84    pub purge_mlstack_dir: bool,
85    /// Optional sudo password for the privileged steps (system-package purge,
86    /// `/opt/rocm` removal). When set, privileged commands run via `sudo -A`
87    /// with an askpass helper so they work without a TTY or cached credentials
88    /// (which `sudo -n` cannot). When unset, falls back to `sudo -n`.
89    pub sudo_password: Option<String>,
90}
91
92/// A summary of what uninstall did.
93#[derive(Debug, Clone, Default)]
94pub struct UninstallReport {
95    pub pip_uninstall_attempted: Vec<String>,
96    pub system_packages_purged: bool,
97    pub opt_rocm_removed: bool,
98    pub env_files_removed: Vec<String>,
99    pub sourcing_lines_stripped: Vec<String>,
100    pub registry_cleared: bool,
101    pub warnings: Vec<String>,
102}
103
104impl UninstallReport {
105    fn note(&mut self, msg: impl Into<String>) {
106        self.warnings.push(msg.into());
107    }
108}
109
110/// Uninstall the Rusty-managed ML stack.
111///
112/// See the module docs for what is removed. Never panics; sudo steps degrade
113/// gracefully when not root and sudo is unavailable.
114pub fn uninstall_stack(opts: &UninstallOptions) -> anyhow::Result<UninstallReport> {
115    let mut report = UninstallReport::default();
116    let home = resolve_user_home();
117
118    if !opts.yes {
119        // Caller (CLI) is expected to confirm; this is the non-interactive guard.
120        eprintln!(
121            "[uninstall] Proceeding (use --yes to skip this notice). keep_rocm={}, purge_dir={}",
122            opts.keep_rocm, opts.purge_mlstack_dir
123        );
124    }
125
126    // 1. pip uninstall ML packages via the canonical Python.
127    let python = resolve_canonical_python_bin();
128    println!("[uninstall] Removing ML pip packages via {python} …");
129    // Union the curated list with the installed-component registry's recorded
130    // pip packages (Tenet 2: the registry is now populated at install time, so
131    // uninstall consults it instead of relying solely on the hardcoded list).
132    let mut pkgs: Vec<String> = ML_PIP_PACKAGES.iter().map(|s| s.to_string()).collect();
133    pkgs.extend(crate::core::registry::InstalledComponentRegistry::load().all_pip_packages());
134    pkgs.sort();
135    pkgs.dedup();
136    report.pip_uninstall_attempted = pkgs.clone();
137    let mut pip_args = vec![
138        "-m".to_string(),
139        "pip".to_string(),
140        "uninstall".to_string(),
141        "-y".to_string(),
142    ];
143    pip_args.extend(pkgs);
144    let pip_status = Command::new(&python).args(&pip_args).status();
145    match pip_status {
146        Ok(s) if s.success() => {}
147        Ok(s) => report.note(format!(
148            "pip uninstall exited non-zero ({s}) — some packages may not have been installed"
149        )),
150        Err(e) => report.note(format!("could not run {python} -m pip ({e})")),
151    }
152
153    // 2. ROCm/amdgpu system packages (cross-distro) unless --keep-rocm.
154    if !opts.keep_rocm {
155        // Pre-filter to only the installed packages: apt/pacman exit non-zero
156        // if ANY named package in the purge list is absent (e.g. amdgpu-dkms on
157        // a partial stack), which would abort the whole purge and leave valid
158        // packages (e.g. rocm-core) installed. dnf/yum tolerate missing names,
159        // but filtering is harmless there too. Only purge what's present.
160        let installed = filter_installed_system_packages(ROCM_SYSTEM_PACKAGES);
161        if installed.is_empty() {
162            report.note(
163                "no ROCm/amdgpu system packages are installed — skipping system-package purge",
164            );
165        } else if let Some((pm, purge_args)) = build_system_purge_cmd(&installed) {
166            println!("[uninstall] Purging ROCm/amdgpu system packages via {pm} …");
167            let ran = run_privileged(&pm, &purge_args, opts.sudo_password.as_deref(), &mut report);
168            report.system_packages_purged = ran;
169        } else {
170            report.note("no supported system package manager detected (apt/dnf/pacman/zypper) — skipping ROCm system-package purge");
171        }
172
173        // 3. /opt/rocm (sudo).
174        if PathBuf::from("/opt/rocm").exists() {
175            println!("[uninstall] Removing /opt/rocm …");
176            report.opt_rocm_removed = run_privileged(
177                "rm",
178                &["-rf".to_string(), "/opt/rocm".to_string()],
179                opts.sudo_password.as_deref(),
180                &mut report,
181            );
182        }
183    } else {
184        println!("[uninstall] --keep-rocm: leaving ROCm system packages and /opt/rocm in place");
185    }
186
187    // 4. Env files + shell sourcing lines.
188    remove_env_files(&home, &mut report);
189    strip_shell_sourcing(&home, &mut report);
190
191    // 5. Registry.
192    let mut registry = InstalledComponentRegistry::load();
193    if !registry.is_empty() {
194        registry.clear();
195        if let Err(e) = registry.save() {
196            report.note(format!("failed to clear registry: {e}"));
197        } else {
198            report.registry_cleared = true;
199        }
200    }
201
202    // Optional: ~/.mlstack data root.
203    if opts.purge_mlstack_dir {
204        let root = crate::platform::environment::mlstack_root();
205        if root.exists() {
206            // The global venv / cache may contain root-owned bits; try plain,
207            // then sudo if needed.
208            if std::fs::remove_dir_all(&root).is_ok() {
209                report
210                    .env_files_removed
211                    .push(format!("(dir) {}", root.display()));
212            } else {
213                let removed = run_privileged(
214                    "rm",
215                    &["-rf".to_string(), root.to_string_lossy().into_owned()],
216                    opts.sudo_password.as_deref(),
217                    &mut report,
218                );
219                if removed {
220                    report
221                        .env_files_removed
222                        .push(format!("(dir, sudo) {}", root.display()));
223                }
224            }
225        }
226    }
227
228    Ok(report)
229}
230
231/// Build a (program, args) purge command for the detected package manager, or
232/// `None` if no supported manager is on PATH.
233fn build_system_purge_cmd(packages: &[&str]) -> Option<(String, Vec<String>)> {
234    let (pm, verb) = if command_on_path("apt") {
235        (
236            "apt",
237            vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()],
238        )
239    } else if command_on_path("pacman") {
240        ("pacman", vec!["-Rn".to_string(), "--noconfirm".to_string()])
241    } else if command_on_path("dnf") {
242        ("dnf", vec!["remove".to_string(), "-y".to_string()])
243    } else if command_on_path("zypper") {
244        ("zypper", vec!["remove".to_string(), "-y".to_string()])
245    } else if command_on_path("yum") {
246        ("yum", vec!["remove".to_string(), "-y".to_string()])
247    } else {
248        return None;
249    };
250    let mut args = verb;
251    args.extend(packages.iter().map(|s| s.to_string()));
252    Some((pm.to_string(), args))
253}
254
255/// Filter `candidates` to only those that are actually installed, using the
256/// detected package manager's "is installed" query. This makes the purge
257/// tolerant of partial stacks: apt/pacman exit non-zero if any named package in
258/// a purge list is absent, so we only pass installed names. Returns the
259/// original list verbatim if no manager is detected (let the purge path emit its
260/// own "no manager" note).
261fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
262    // Return the installed candidates with the same lifetime as the input
263    // (ROCM_SYSTEM_PACKAGES is &'static, so the call site gets &'static strs).
264    candidates
265        .iter()
266        .copied()
267        .filter(|pkg| is_system_package_installed(pkg))
268        .collect()
269}
270
271/// Is `pkg` installed according to the detected package manager?
272fn is_system_package_installed(pkg: &str) -> bool {
273    // dpkg-query: exit 0 iff installed. (apt-backed distros.)
274    if command_on_path("dpkg-query") {
275        return Command::new("dpkg-query")
276            .args(["-W", "-f=${Status}", pkg])
277            .output()
278            .map(|o| {
279                o.status.success()
280                    && String::from_utf8_lossy(&o.stdout).contains("install ok installed")
281            })
282            .unwrap_or(false);
283    }
284    // pacman -Q: exit 0 iff installed.
285    if command_on_path("pacman") {
286        return Command::new("pacman")
287            .args(["-Q", pkg])
288            .output()
289            .map(|o| o.status.success())
290            .unwrap_or(false);
291    }
292    // rpm -q: exit 0 iff installed (dnf/yum/zypper all back onto rpm).
293    if command_on_path("rpm") {
294        return Command::new("rpm")
295            .args(["-q", pkg])
296            .output()
297            .map(|o| o.status.success())
298            .unwrap_or(false);
299    }
300    // No detector: assume installed so the purge is attempted (the purge path
301    // will note "no manager" if none exists).
302    true
303}
304
305/// Run a privileged command: directly if root, otherwise via sudo. Returns
306/// `true` if it ran successfully. Never stalls on a hidden prompt.
307///
308/// When `sudo_password` is supplied, the command runs as `sudo -A` with a
309/// temporary askpass helper (`SUDO_ASKPASS`), so it works with no TTY and no
310/// cached credentials — the case `sudo -n` cannot handle (it fails whenever a
311/// password is required, e.g. a normal desktop without NOPASSWD). When no
312/// password is supplied, it falls back to `sudo -n` (cached/NOPASSWD only),
313/// degrading to a warning rather than hanging in CI/CD or SSH-without-TTY.
314fn run_privileged(
315    program: &str,
316    args: &[String],
317    sudo_password: Option<&str>,
318    report: &mut UninstallReport,
319) -> bool {
320    if is_root() {
321        return run_command_status(program, args, None, report);
322    }
323    if !command_on_path("sudo") {
324        report.note(format!(
325            "skipping `{program} {args:?}` — not root and sudo unavailable",
326        ));
327        return false;
328    }
329    // Prefer askpass when a password is available — it works unattended where
330    // `sudo -n` cannot. The Askpass guard keeps its temp files alive for the
331    // duration of the (synchronous) command below.
332    if let Some(pw) = sudo_password.filter(|p| !p.is_empty()) {
333        match crate::installers::common::askpass::Askpass::new(pw) {
334            Ok(helper) => {
335                let mut sudo_args = vec!["-A".to_string(), program.to_string()];
336                sudo_args.extend_from_slice(args);
337                return run_command_status("sudo", &sudo_args, Some(helper.path()), report);
338            }
339            Err(e) => {
340                report.note(format!(
341                    "askpass setup failed ({e}); falling back to sudo -n"
342                ));
343            }
344        }
345    }
346    // No password (or askpass failed) → non-interactive sudo: fail fast instead
347    // of prompting when no cached credentials exist.
348    let mut a = vec!["-n".to_string(), program.to_string()];
349    a.extend_from_slice(args);
350    run_command_status("sudo", &a, None, report)
351}
352
353/// Spawn `program args` (optionally with `SUDO_ASKPASS` set) and return whether
354/// it succeeded. Records a note on failure.
355fn run_command_status(
356    program: &str,
357    args: &[String],
358    askpass_path: Option<&str>,
359    report: &mut UninstallReport,
360) -> bool {
361    let mut cmd = Command::new(program);
362    cmd.args(args);
363    if let Some(p) = askpass_path {
364        cmd.env("SUDO_ASKPASS", p);
365    }
366    match cmd.status() {
367        Ok(s) if s.success() => true,
368        Ok(s) => {
369            report.note(format!("`{program}` exited non-zero ({s})"));
370            false
371        }
372        Err(e) => {
373            report.note(format!(
374                "could not run `{program}` ({e}) — sudo may require a password (no TTY)"
375            ));
376            false
377        }
378    }
379}
380
381#[cfg(unix)]
382fn is_root() -> bool {
383    // SAFETY: geteuid is always safe on Unix.
384    unsafe { libc::geteuid() == 0 }
385}
386#[cfg(not(unix))]
387fn is_root() -> bool {
388    false
389}
390
391/// Remove the env files Rusty wrote.
392fn remove_env_files(home: &std::path::Path, report: &mut UninstallReport) {
393    let targets = [
394        home.join(".mlstack_env"),
395        home.join(".config/fish/conf.d/mlstack_env.fish"),
396        home.join(".rocm_env"),
397    ];
398    for t in targets {
399        if t.exists() {
400            match std::fs::remove_file(&t) {
401                Ok(()) => report
402                    .env_files_removed
403                    .push(t.to_string_lossy().into_owned()),
404                Err(e) => report.note(format!("could not remove {}: {e}", t.display())),
405            }
406        }
407    }
408}
409
410/// Strip legacy `source ~/.mlstack_env` / `.mlstack_env` lines from shell rc
411/// files (fish/bash/zsh). Idempotent: only rewrites if a line was actually
412/// removed, and preserves the original trailing newline.
413///
414/// Only drops lines that actually SOURCE the env file — `source .mlstack_env`,
415/// `. ~/.mlstack_env` (POSIX dot), or `bass source ...mlstack_env` (fish). A
416/// bare mention of `.mlstack_env` in a comment/echo/assignment is NOT a source
417/// line and is preserved. (The previous `t.contains(".")` condition was always
418/// true for any `.mlstack_env` line and dropped everything mentioning it.)
419fn strip_shell_sourcing(home: &std::path::Path, report: &mut UninstallReport) {
420    let rcs = [
421        home.join(".bashrc"),
422        home.join(".zshrc"),
423        home.join(".config/fish/config.fish"),
424    ];
425    let had_trailing_newline = |content: &str| content.ends_with('\n');
426    for rc in rcs {
427        let Ok(content) = std::fs::read_to_string(&rc) else {
428            continue;
429        };
430        let mut filtered = String::with_capacity(content.len());
431        let mut changed = false;
432        for line in content.lines() {
433            let t = line.trim();
434            // A source line: it mentions .mlstack_env AND uses a source command
435            // — `source`/`.` (POSIX dot-source at the start of the line)/`bass`
436            // (fish wrapper). NOT a bare `.mlstack_env` mention (the old
437            // `t.contains(".")` matched the dot in the filename itself).
438            let is_source_line = t.contains(".mlstack_env")
439                && (t.contains("source") || t.contains("bass") || t.starts_with(". "));
440            if is_source_line {
441                changed = true;
442            } else {
443                filtered.push_str(line);
444                filtered.push('\n');
445            }
446        }
447        if changed {
448            // Preserve the original trailing-newline state. `content.lines()`
449            // drops a trailing newline; we always re-add '\n' per line above, so
450            // a file that originally ended without one would gain one — trim it
451            // back to match. A file that ended with '\n' keeps it (the last
452            // pushed '\n' stands in for the original terminator).
453            if !had_trailing_newline(&content) && filtered.ends_with('\n') {
454                filtered.pop();
455            }
456            let _ = std::fs::write(&rc, filtered);
457            report
458                .sourcing_lines_stripped
459                .push(rc.to_string_lossy().into_owned());
460        }
461    }
462}
463
464/// Path to the managed global env (used by callers/tests).
465pub fn global_env_dir() -> PathBuf {
466    mlstack_global_dir()
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn build_system_purge_cmd_apt_when_present() {
475        // Only meaningful on a Debian host; just ensure it doesn't panic and
476        // returns either Some(apt...) or None.
477        let cmd = build_system_purge_cmd(&["rocm-hip-sdk"]);
478        if command_on_path("apt") {
479            let (pm, args) = cmd.expect("apt present");
480            assert_eq!(pm, "apt");
481            assert!(args.contains(&"purge".to_string()));
482            assert!(args.contains(&"rocm-hip-sdk".to_string()));
483        }
484    }
485
486    #[test]
487    fn strip_sourcing_removes_mlstack_lines() {
488        let dir = tempfile::tempdir().unwrap();
489        let rc = dir.path().join(".bashrc");
490        std::fs::write(
491            &rc,
492            "# my rc\nsource \"$HOME/.mlstack_env\"\nexport FOO=bar\n. ~/.mlstack_env\n",
493        )
494        .unwrap();
495        let mut report = UninstallReport::default();
496        strip_shell_sourcing(dir.path(), &mut report);
497        let after = std::fs::read_to_string(&rc).unwrap();
498        assert!(
499            !after.contains(".mlstack_env"),
500            "sourcing lines removed: {after}"
501        );
502        assert!(
503            after.contains("export FOO=bar"),
504            "other lines preserved: {after}"
505        );
506        assert_eq!(report.sourcing_lines_stripped.len(), 1);
507        // Trailing newline preserved (original ended with '\n').
508        assert!(
509            after.ends_with('\n'),
510            "trailing newline preserved: {after:?}"
511        );
512    }
513
514    #[test]
515    fn strip_sourcing_preserves_non_source_mentions() {
516        // D1 regression: a comment / echo / assignment that merely MENTIONS
517        // .mlstack_env is NOT a source line and must be kept. The old
518        // `t.contains(".")` matched the dot in the filename and dropped these.
519        let dir = tempfile::tempdir().unwrap();
520        let rc = dir.path().join(".bashrc");
521        let original = "# see ~/.mlstack_env for env\necho loaded .mlstack_env\nMLSTACK_ENV_FILE=.mlstack_env\n";
522        std::fs::write(&rc, original).unwrap();
523        let mut report = UninstallReport::default();
524        strip_shell_sourcing(dir.path(), &mut report);
525        let after = std::fs::read_to_string(&rc).unwrap();
526        assert_eq!(
527            report.sourcing_lines_stripped.len(),
528            0,
529            "no source lines, no rewrite: {after:?}"
530        );
531        // File untouched (changed == false → no write at all).
532        assert!(
533            after.contains("# see ~/.mlstack_env for env"),
534            "comment preserved: {after}"
535        );
536        assert!(
537            after.contains("MLSTACK_ENV_FILE=.mlstack_env"),
538            "assignment preserved: {after}"
539        );
540    }
541
542    #[test]
543    fn remove_env_files_deletes_targets() {
544        let dir = tempfile::tempdir().unwrap();
545        std::fs::write(dir.path().join(".mlstack_env"), "x").unwrap();
546        let mut report = UninstallReport::default();
547        remove_env_files(dir.path(), &mut report);
548        assert!(!dir.path().join(".mlstack_env").exists());
549        assert!(!report.env_files_removed.is_empty());
550    }
551}