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}
86
87/// A summary of what uninstall did.
88#[derive(Debug, Clone, Default)]
89pub struct UninstallReport {
90    pub pip_uninstall_attempted: Vec<String>,
91    pub system_packages_purged: bool,
92    pub opt_rocm_removed: bool,
93    pub env_files_removed: Vec<String>,
94    pub sourcing_lines_stripped: Vec<String>,
95    pub registry_cleared: bool,
96    pub warnings: Vec<String>,
97}
98
99impl UninstallReport {
100    fn note(&mut self, msg: impl Into<String>) {
101        self.warnings.push(msg.into());
102    }
103}
104
105/// Uninstall the Rusty-managed ML stack.
106///
107/// See the module docs for what is removed. Never panics; sudo steps degrade
108/// gracefully when not root and sudo is unavailable.
109pub fn uninstall_stack(opts: &UninstallOptions) -> anyhow::Result<UninstallReport> {
110    let mut report = UninstallReport::default();
111    let home = resolve_user_home();
112
113    if !opts.yes {
114        // Caller (CLI) is expected to confirm; this is the non-interactive guard.
115        eprintln!(
116            "[uninstall] Proceeding (use --yes to skip this notice). keep_rocm={}, purge_dir={}",
117            opts.keep_rocm, opts.purge_mlstack_dir
118        );
119    }
120
121    // 1. pip uninstall ML packages via the canonical Python.
122    let python = resolve_canonical_python_bin();
123    println!("[uninstall] Removing ML pip packages via {python} …");
124    // Union the curated list with the installed-component registry's recorded
125    // pip packages (Tenet 2: the registry is now populated at install time, so
126    // uninstall consults it instead of relying solely on the hardcoded list).
127    let mut pkgs: Vec<String> = ML_PIP_PACKAGES.iter().map(|s| s.to_string()).collect();
128    pkgs.extend(crate::core::registry::InstalledComponentRegistry::load().all_pip_packages());
129    pkgs.sort();
130    pkgs.dedup();
131    report.pip_uninstall_attempted = pkgs.clone();
132    let mut pip_args = vec![
133        "-m".to_string(),
134        "pip".to_string(),
135        "uninstall".to_string(),
136        "-y".to_string(),
137    ];
138    pip_args.extend(pkgs);
139    let pip_status = Command::new(&python).args(&pip_args).status();
140    match pip_status {
141        Ok(s) if s.success() => {}
142        Ok(s) => report.note(format!(
143            "pip uninstall exited non-zero ({s}) — some packages may not have been installed"
144        )),
145        Err(e) => report.note(format!("could not run {python} -m pip ({e})")),
146    }
147
148    // 2. ROCm/amdgpu system packages (cross-distro) unless --keep-rocm.
149    if !opts.keep_rocm {
150        // Pre-filter to only the installed packages: apt/pacman exit non-zero
151        // if ANY named package in the purge list is absent (e.g. amdgpu-dkms on
152        // a partial stack), which would abort the whole purge and leave valid
153        // packages (e.g. rocm-core) installed. dnf/yum tolerate missing names,
154        // but filtering is harmless there too. Only purge what's present.
155        let installed = filter_installed_system_packages(ROCM_SYSTEM_PACKAGES);
156        if installed.is_empty() {
157            report.note(
158                "no ROCm/amdgpu system packages are installed — skipping system-package purge",
159            );
160        } else if let Some((pm, purge_args)) = build_system_purge_cmd(&installed) {
161            println!("[uninstall] Purging ROCm/amdgpu system packages via {pm} …");
162            let ran = run_privileged(&pm, &purge_args, &mut report);
163            report.system_packages_purged = ran;
164        } else {
165            report.note("no supported system package manager detected (apt/dnf/pacman/zypper) — skipping ROCm system-package purge");
166        }
167
168        // 3. /opt/rocm (sudo).
169        if PathBuf::from("/opt/rocm").exists() {
170            println!("[uninstall] Removing /opt/rocm …");
171            report.opt_rocm_removed = run_privileged(
172                "rm",
173                &["-rf".to_string(), "/opt/rocm".to_string()],
174                &mut report,
175            );
176        }
177    } else {
178        println!("[uninstall] --keep-rocm: leaving ROCm system packages and /opt/rocm in place");
179    }
180
181    // 4. Env files + shell sourcing lines.
182    remove_env_files(&home, &mut report);
183    strip_shell_sourcing(&home, &mut report);
184
185    // 5. Registry.
186    let mut registry = InstalledComponentRegistry::load();
187    if !registry.is_empty() {
188        registry.clear();
189        if let Err(e) = registry.save() {
190            report.note(format!("failed to clear registry: {e}"));
191        } else {
192            report.registry_cleared = true;
193        }
194    }
195
196    // Optional: ~/.mlstack data root.
197    if opts.purge_mlstack_dir {
198        let root = crate::platform::environment::mlstack_root();
199        if root.exists() {
200            // The global venv / cache may contain root-owned bits; try plain,
201            // then sudo if needed.
202            if std::fs::remove_dir_all(&root).is_ok() {
203                report
204                    .env_files_removed
205                    .push(format!("(dir) {}", root.display()));
206            } else {
207                let removed = run_privileged(
208                    "rm",
209                    &["-rf".to_string(), root.to_string_lossy().into_owned()],
210                    &mut report,
211                );
212                if removed {
213                    report
214                        .env_files_removed
215                        .push(format!("(dir, sudo) {}", root.display()));
216                }
217            }
218        }
219    }
220
221    Ok(report)
222}
223
224/// Build a (program, args) purge command for the detected package manager, or
225/// `None` if no supported manager is on PATH.
226fn build_system_purge_cmd(packages: &[&str]) -> Option<(String, Vec<String>)> {
227    let (pm, verb) = if command_on_path("apt") {
228        (
229            "apt",
230            vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()],
231        )
232    } else if command_on_path("pacman") {
233        ("pacman", vec!["-Rn".to_string(), "--noconfirm".to_string()])
234    } else if command_on_path("dnf") {
235        ("dnf", vec!["remove".to_string(), "-y".to_string()])
236    } else if command_on_path("zypper") {
237        ("zypper", vec!["remove".to_string(), "-y".to_string()])
238    } else if command_on_path("yum") {
239        ("yum", vec!["remove".to_string(), "-y".to_string()])
240    } else {
241        return None;
242    };
243    let mut args = verb;
244    args.extend(packages.iter().map(|s| s.to_string()));
245    Some((pm.to_string(), args))
246}
247
248/// Filter `candidates` to only those that are actually installed, using the
249/// detected package manager's "is installed" query. This makes the purge
250/// tolerant of partial stacks: apt/pacman exit non-zero if any named package in
251/// a purge list is absent, so we only pass installed names. Returns the
252/// original list verbatim if no manager is detected (let the purge path emit its
253/// own "no manager" note).
254fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
255    // Return the installed candidates with the same lifetime as the input
256    // (ROCM_SYSTEM_PACKAGES is &'static, so the call site gets &'static strs).
257    candidates
258        .iter()
259        .copied()
260        .filter(|pkg| is_system_package_installed(pkg))
261        .collect()
262}
263
264/// Is `pkg` installed according to the detected package manager?
265fn is_system_package_installed(pkg: &str) -> bool {
266    // dpkg-query: exit 0 iff installed. (apt-backed distros.)
267    if command_on_path("dpkg-query") {
268        return Command::new("dpkg-query")
269            .args(["-W", "-f=${Status}", pkg])
270            .output()
271            .map(|o| {
272                o.status.success()
273                    && String::from_utf8_lossy(&o.stdout).contains("install ok installed")
274            })
275            .unwrap_or(false);
276    }
277    // pacman -Q: exit 0 iff installed.
278    if command_on_path("pacman") {
279        return Command::new("pacman")
280            .args(["-Q", pkg])
281            .output()
282            .map(|o| o.status.success())
283            .unwrap_or(false);
284    }
285    // rpm -q: exit 0 iff installed (dnf/yum/zypper all back onto rpm).
286    if command_on_path("rpm") {
287        return Command::new("rpm")
288            .args(["-q", pkg])
289            .output()
290            .map(|o| o.status.success())
291            .unwrap_or(false);
292    }
293    // No detector: assume installed so the purge is attempted (the purge path
294    // will note "no manager" if none exists).
295    true
296}
297
298/// Run a command directly if root, otherwise via `sudo -n`. Returns `true` if
299/// the command ran successfully. Never stalls on a hidden prompt: `sudo -n`
300/// (non-interactive) fails fast instead of prompting for a password when no
301/// TTY / cached credentials are available, so the command degrades to a warning
302/// rather than hanging in CI/CD or SSH-without-TTY contexts.
303fn run_privileged(program: &str, args: &[String], report: &mut UninstallReport) -> bool {
304    let is_root = is_root();
305    let (cmd_program, cmd_args) = if is_root {
306        (program.to_string(), args.to_vec())
307    } else if command_on_path("sudo") {
308        // `-n` = non-interactive: fail instead of prompting. `-S` reads the
309        // password from stdin only if one is pending; combined with `-n` this
310        // never blocks. The caller passes no password, so sudo will refuse
311        // rather than stall.
312        let mut a = vec!["-n".to_string(), program.to_string()];
313        a.extend_from_slice(args);
314        ("sudo".to_string(), a)
315    } else {
316        report.note(format!(
317            "skipping `{program} {args:?}` — not root and sudo unavailable",
318        ));
319        return false;
320    };
321    match Command::new(&cmd_program).args(&cmd_args).status() {
322        Ok(s) if s.success() => true,
323        Ok(s) => {
324            report.note(format!("`{cmd_program}` exited non-zero ({s})"));
325            false
326        }
327        Err(e) => {
328            report.note(format!(
329                "could not run `{cmd_program}` ({e}) — sudo may require a password (no TTY)"
330            ));
331            false
332        }
333    }
334}
335
336#[cfg(unix)]
337fn is_root() -> bool {
338    // SAFETY: geteuid is always safe on Unix.
339    unsafe { libc::geteuid() == 0 }
340}
341#[cfg(not(unix))]
342fn is_root() -> bool {
343    false
344}
345
346/// Remove the env files Rusty wrote.
347fn remove_env_files(home: &std::path::Path, report: &mut UninstallReport) {
348    let targets = [
349        home.join(".mlstack_env"),
350        home.join(".config/fish/conf.d/mlstack_env.fish"),
351        home.join(".rocm_env"),
352    ];
353    for t in targets {
354        if t.exists() {
355            match std::fs::remove_file(&t) {
356                Ok(()) => report
357                    .env_files_removed
358                    .push(t.to_string_lossy().into_owned()),
359                Err(e) => report.note(format!("could not remove {}: {e}", t.display())),
360            }
361        }
362    }
363}
364
365/// Strip legacy `source ~/.mlstack_env` / `.mlstack_env` lines from shell rc
366/// files (fish/bash/zsh). Idempotent: only rewrites if a line was actually
367/// removed, and preserves the original trailing newline.
368///
369/// Only drops lines that actually SOURCE the env file — `source .mlstack_env`,
370/// `. ~/.mlstack_env` (POSIX dot), or `bass source ...mlstack_env` (fish). A
371/// bare mention of `.mlstack_env` in a comment/echo/assignment is NOT a source
372/// line and is preserved. (The previous `t.contains(".")` condition was always
373/// true for any `.mlstack_env` line and dropped everything mentioning it.)
374fn strip_shell_sourcing(home: &std::path::Path, report: &mut UninstallReport) {
375    let rcs = [
376        home.join(".bashrc"),
377        home.join(".zshrc"),
378        home.join(".config/fish/config.fish"),
379    ];
380    let had_trailing_newline = |content: &str| content.ends_with('\n');
381    for rc in rcs {
382        let Ok(content) = std::fs::read_to_string(&rc) else {
383            continue;
384        };
385        let mut filtered = String::with_capacity(content.len());
386        let mut changed = false;
387        for line in content.lines() {
388            let t = line.trim();
389            // A source line: it mentions .mlstack_env AND uses a source command
390            // — `source`/`.` (POSIX dot-source at the start of the line)/`bass`
391            // (fish wrapper). NOT a bare `.mlstack_env` mention (the old
392            // `t.contains(".")` matched the dot in the filename itself).
393            let is_source_line = t.contains(".mlstack_env")
394                && (t.contains("source") || t.contains("bass") || t.starts_with(". "));
395            if is_source_line {
396                changed = true;
397            } else {
398                filtered.push_str(line);
399                filtered.push('\n');
400            }
401        }
402        if changed {
403            // Preserve the original trailing-newline state. `content.lines()`
404            // drops a trailing newline; we always re-add '\n' per line above, so
405            // a file that originally ended without one would gain one — trim it
406            // back to match. A file that ended with '\n' keeps it (the last
407            // pushed '\n' stands in for the original terminator).
408            if !had_trailing_newline(&content) && filtered.ends_with('\n') {
409                filtered.pop();
410            }
411            let _ = std::fs::write(&rc, filtered);
412            report
413                .sourcing_lines_stripped
414                .push(rc.to_string_lossy().into_owned());
415        }
416    }
417}
418
419/// Path to the managed global env (used by callers/tests).
420pub fn global_env_dir() -> PathBuf {
421    mlstack_global_dir()
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    #[test]
429    fn build_system_purge_cmd_apt_when_present() {
430        // Only meaningful on a Debian host; just ensure it doesn't panic and
431        // returns either Some(apt...) or None.
432        let cmd = build_system_purge_cmd(&["rocm-hip-sdk"]);
433        if command_on_path("apt") {
434            let (pm, args) = cmd.expect("apt present");
435            assert_eq!(pm, "apt");
436            assert!(args.contains(&"purge".to_string()));
437            assert!(args.contains(&"rocm-hip-sdk".to_string()));
438        }
439    }
440
441    #[test]
442    fn strip_sourcing_removes_mlstack_lines() {
443        let dir = tempfile::tempdir().unwrap();
444        let rc = dir.path().join(".bashrc");
445        std::fs::write(
446            &rc,
447            "# my rc\nsource \"$HOME/.mlstack_env\"\nexport FOO=bar\n. ~/.mlstack_env\n",
448        )
449        .unwrap();
450        let mut report = UninstallReport::default();
451        strip_shell_sourcing(dir.path(), &mut report);
452        let after = std::fs::read_to_string(&rc).unwrap();
453        assert!(
454            !after.contains(".mlstack_env"),
455            "sourcing lines removed: {after}"
456        );
457        assert!(
458            after.contains("export FOO=bar"),
459            "other lines preserved: {after}"
460        );
461        assert_eq!(report.sourcing_lines_stripped.len(), 1);
462        // Trailing newline preserved (original ended with '\n').
463        assert!(
464            after.ends_with('\n'),
465            "trailing newline preserved: {after:?}"
466        );
467    }
468
469    #[test]
470    fn strip_sourcing_preserves_non_source_mentions() {
471        // D1 regression: a comment / echo / assignment that merely MENTIONS
472        // .mlstack_env is NOT a source line and must be kept. The old
473        // `t.contains(".")` matched the dot in the filename and dropped these.
474        let dir = tempfile::tempdir().unwrap();
475        let rc = dir.path().join(".bashrc");
476        let original = "# see ~/.mlstack_env for env\necho loaded .mlstack_env\nMLSTACK_ENV_FILE=.mlstack_env\n";
477        std::fs::write(&rc, original).unwrap();
478        let mut report = UninstallReport::default();
479        strip_shell_sourcing(dir.path(), &mut report);
480        let after = std::fs::read_to_string(&rc).unwrap();
481        assert_eq!(
482            report.sourcing_lines_stripped.len(),
483            0,
484            "no source lines, no rewrite: {after:?}"
485        );
486        // File untouched (changed == false → no write at all).
487        assert!(
488            after.contains("# see ~/.mlstack_env for env"),
489            "comment preserved: {after}"
490        );
491        assert!(
492            after.contains("MLSTACK_ENV_FILE=.mlstack_env"),
493            "assignment preserved: {after}"
494        );
495    }
496
497    #[test]
498    fn remove_env_files_deletes_targets() {
499        let dir = tempfile::tempdir().unwrap();
500        std::fs::write(dir.path().join(".mlstack_env"), "x").unwrap();
501        let mut report = UninstallReport::default();
502        remove_env_files(dir.path(), &mut report);
503        assert!(!dir.path().join(".mlstack_env").exists());
504        assert!(!report.env_files_removed.is_empty());
505    }
506}