From c1b1b9bf3138afffde15614ce2bd72fcc36af9b3 Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Thu, 13 Aug 2026 23:05:55 +0300 Subject: [PATCH 1/2] fix(uninstall): clear the data directory from the installer too The Go uninstall was routed through the user namespace but the installer was not, and the installer is where this was reported. Both cmd_uninstall_linux and cmd_uninstall_macos removed the data directory with a plain rm, so the first service tree written as a subuid failed and, under set -e, took the whole script down with it. By that point the binary and the units were already gone, so the user was left holding a directory they could not delete and nothing to help them do it. Both paths now go through remove_lerd_dir, which retries the removal under podman unshare and keeps the uninstall running to its success line either way. When even the user namespace cannot clear the directory, the uninstall says which one survived and prints the command to remove it by hand, rather than reporting a removal that did not happen. The Go path reports the same way instead of printing a tick over a directory that is still on disk. --- docs/troubleshooting.md | 12 ++++++ install.sh | 30 +++++++++++--- internal/cli/uninstall.go | 25 ++++++++---- internal/cli/uninstall_test.go | 75 ++++++++++++++++++++++++++++++++++ tests/installer/installer.bats | 56 +++++++++++++++++++++++++ 5 files changed, 183 insertions(+), 15 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 88ef8a12a..79c509c91 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -344,6 +344,18 @@ The exact command lerd suggests in `lerd doctor` and `lerd start` output is alre `lerd doctor` also checks for port conflicts as part of its full diagnostic, and adds a dedicated **[Stopped service ports]** section that flags installed services whose host port is already bound by another process. The same warning is shown next to the inactive status pill in the web UI, so you can spot the conflict without running anything: most often this is a system-installed service (Postgres, MySQL, Redis) listening on the default port. Stop the conflicting process and the warning clears on the next snapshot refresh. ::: +::: details Uninstall leaves the data directory behind +Services write their files as a subuid inside the rootless user namespace, so MySQL, Postgres, MongoDB, Redis and RabbitMQ all leave trees under `~/.local/share/lerd` that your own user cannot delete. Both `lerd uninstall` and the installer's `--uninstall` remove them through `podman unshare`, which enters that namespace, so this normally happens without you noticing. + +If podman is already gone by then, the uninstall finishes and tells you what survived. Remove it yourself with: + +```bash +podman unshare rm -rf ~/.local/share/lerd +``` + +If podman is no longer installed either, `sudo rm -rf ~/.local/share/lerd` is the last resort. +::: + ::: details Workers missing after reinstall If you ran `lerd uninstall` and then reinstalled, worker units and service quadlets are deleted during uninstall. Running `lerd start` after reinstalling automatically restores them from the `workers` list saved in each site's `.lerd.yaml`. If `.lerd.yaml` does not exist or was not committed, you will need to start workers again manually (`lerd queue:start`, etc.). diff --git a/install.sh b/install.sh index 25573c2af..1a1aaa113 100755 --- a/install.sh +++ b/install.sh @@ -672,6 +672,22 @@ cmd_update() { } # ── Uninstall ──────────────────────────────────────────────────────────────── + +# Containers write their files as a subuid inside the rootless user namespace, +# so a plain rm cannot remove them and set -e would abort the uninstall there. +# podman unshare enters that namespace, where they are removable. +remove_lerd_dir() { + local dir="$1" + [ -e "$dir" ] || return 0 + rm -rf "$dir" 2>/dev/null || true + [ -e "$dir" ] || return 0 + podman unshare rm -rf "$dir" >/dev/null 2>&1 || true + [ -e "$dir" ] || return 0 + warn "Could not remove $dir" + info "Remove it with: podman unshare rm -rf $dir" + return 1 +} + cmd_uninstall() { if [ "$(detect_os)" = "darwin" ]; then cmd_uninstall_macos @@ -740,9 +756,10 @@ cmd_uninstall_macos() { remove_from_path if ask "Remove all Lerd data and config? (~/.config/lerd, ~/.local/share/lerd)"; then - rm -rf "$LERD_CONFIG_DIR" - rm -rf "$LERD_DATA_DIR" - success "Removed config and data directories" + local kept=0 + remove_lerd_dir "$LERD_CONFIG_DIR" || kept=1 + remove_lerd_dir "$LERD_DATA_DIR" || kept=1 + [ "$kept" -eq 1 ] || success "Removed config and data directories" else info "Config kept at $LERD_CONFIG_DIR" info "Data kept at $LERD_DATA_DIR" @@ -856,9 +873,10 @@ cmd_uninstall_linux() { # Optionally remove data if ask "Remove all Lerd data and config? (~/.config/lerd, ~/.local/share/lerd)"; then - rm -rf "$LERD_CONFIG_DIR" - rm -rf "$LERD_DATA_DIR" - success "Removed config and data directories" + local kept=0 + remove_lerd_dir "$LERD_CONFIG_DIR" || kept=1 + remove_lerd_dir "$LERD_DATA_DIR" || kept=1 + [ "$kept" -eq 1 ] || success "Removed config and data directories" else info "Config kept at $LERD_CONFIG_DIR" info "Data kept at $LERD_DATA_DIR" diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 02a9be8a9..e5968106a 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -195,8 +195,12 @@ func runUninstall(force bool) error { } step("Removing config and data directories") os.RemoveAll(config.ConfigDir()) - removeDataDir(config.DataDir()) - ok() + if kept := removeDataDir(config.DataDir()); kept != "" { + fmt.Println(feedback.Amber("!")) + feedback.Note("could not remove " + kept + ", remove it with: podman unshare rm -rf " + kept) + } else { + ok() + } } else { feedback.Note("config kept at " + config.ConfigDir()) feedback.Note("data kept at " + config.DataDir()) @@ -206,15 +210,18 @@ func runUninstall(force bool) error { return nil } -// removeDataDir removes the lerd data directory. Containers write files as a -// subuid, so os.RemoveAll fails; podman unshare rm -rf enters the user -// namespace where they are removable. -func removeDataDir(dir string) { - os.RemoveAll(dir) - if _, err := os.Stat(dir); err != nil { - return +// removeDataDir removes the lerd data directory and returns the path if it +// survived. Containers write files as a subuid, so os.RemoveAll fails; podman +// unshare rm -rf enters the user namespace where they are removable. +func removeDataDir(dir string) string { + if err := os.RemoveAll(dir); err == nil { + return "" } _ = podman.Cmd("unshare", "rm", "-rf", dir).Run() + if _, err := os.Stat(dir); err == nil { + return dir + } + return "" } func confirmRemoveMCPIntegration() bool { diff --git a/internal/cli/uninstall_test.go b/internal/cli/uninstall_test.go index 1a3a14dc5..02f4152b8 100644 --- a/internal/cli/uninstall_test.go +++ b/internal/cli/uninstall_test.go @@ -311,3 +311,78 @@ func TestRemoveInstalledBinaries_leavesUnrelatedNeighbours(t *testing.T) { t.Errorf("removed an unrelated neighbour %s: %v", other, err) } } + +// ── removeDataDir ──────────────────────────────────────────────────────────── + +// undeletableDir builds a tree os.RemoveAll cannot clear: the inner directory +// has no write bit, which is what a subuid-owned service tree looks like to us. +func undeletableDir(t *testing.T) string { + t.Helper() + dir := filepath.Join(t.TempDir(), "data") + sub := filepath.Join(dir, "redis") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "dump.rdb"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(sub, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(sub, 0755) }) + return dir +} + +// fakePodman puts a podman on PATH standing in for the user namespace. It is +// handed the one directory the test built and refuses anything else, so a +// mistake here can never widen into a path the test does not own. +func fakePodman(t *testing.T, owned, script string) { + t.Helper() + dir := t.TempDir() + guard := "#!/bin/sh\ncase \"$4\" in\n " + owned + ") ;;\n *) echo \"refusing $4\" >&2; exit 2 ;;\nesac\n" + if err := os.WriteFile(filepath.Join(dir, "podman"), []byte(guard+script), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func TestRemoveDataDir_removesAPlainTree(t *testing.T) { + dir := filepath.Join(t.TempDir(), "data") + if err := os.MkdirAll(filepath.Join(dir, "mysql"), 0755); err != nil { + t.Fatal(err) + } + + if kept := removeDataDir(dir); kept != "" { + t.Errorf("removeDataDir = %q, want an empty string", kept) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("%s still present", dir) + } +} + +func TestRemoveDataDir_fallsBackToPodmanUnshare(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root removes the tree without the fallback") + } + dir := undeletableDir(t) + fakePodman(t, dir, "chmod -R u+w \"$4\" && rm -rf \"$4\"\n") + + if kept := removeDataDir(dir); kept != "" { + t.Errorf("removeDataDir = %q, want an empty string", kept) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("%s survived the podman unshare fallback", dir) + } +} + +func TestRemoveDataDir_reportsTheDirectoryWhenTheFallbackFails(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root removes the tree without the fallback") + } + dir := undeletableDir(t) + fakePodman(t, dir, "exit 1\n") + + if kept := removeDataDir(dir); kept != dir { + t.Errorf("removeDataDir = %q, want %q so the uninstall can say so", kept, dir) + } +} diff --git a/tests/installer/installer.bats b/tests/installer/installer.bats index 26979250c..47757fae9 100644 --- a/tests/installer/installer.bats +++ b/tests/installer/installer.bats @@ -642,6 +642,62 @@ _stub_dns_files() { [ "$dns_at" -lt "$bin_at" ] } +# ── remove_lerd_dir ─────────────────────────────────────────────────────────── + +# A service tree written as a subuid looks exactly like this to the uninstall: +# a directory whose contents rm cannot touch. Everything here stays under the +# isolated HOME the setup exports. +_undeletable_dir() { + local dir="$HOME/share/lerd" + mkdir -p "$dir/redis" + : > "$dir/redis/dump.rdb" + chmod 500 "$dir/redis" + echo "$dir" +} + +@test "remove_lerd_dir removes an ordinary directory" { + local dir="$HOME/config/lerd" + mkdir -p "$dir/certs" + run remove_lerd_dir "$dir" + [ "$status" -eq 0 ] + [ ! -e "$dir" ] +} + +@test "remove_lerd_dir is a no-op when the directory was never there" { + run remove_lerd_dir "$HOME/nothing-here" + [ "$status" -eq 0 ] + [ "$output" = "" ] +} + +@test "remove_lerd_dir falls back to podman unshare on a subuid-owned tree" { + [ "$(id -u)" -eq 0 ] && skip "root removes the tree without the fallback" + local dir; dir="$(_undeletable_dir)" + podman() { chmod -R u+w "$4"; command rm -rf "$4"; } + run remove_lerd_dir "$dir" + [ "$status" -eq 0 ] + [ ! -e "$dir" ] +} + +@test "remove_lerd_dir reports the directory when the fallback fails too" { + [ "$(id -u)" -eq 0 ] && skip "root removes the tree without the fallback" + local dir; dir="$(_undeletable_dir)" + podman() { return 1; } + run remove_lerd_dir "$dir" + chmod -R u+w "$dir" + [ "$status" -eq 1 ] + [[ "$output" == *"Could not remove $dir"* ]] + [[ "$output" == *"podman unshare rm -rf $dir"* ]] +} + +@test "both uninstall paths route the data removal through remove_lerd_dir" { + for fn in cmd_uninstall_linux cmd_uninstall_macos; do + local body; body="$(declare -f "$fn")" + [[ "$body" == *'remove_lerd_dir "$LERD_DATA_DIR"'* ]] + [[ "$body" == *'remove_lerd_dir "$LERD_CONFIG_DIR"'* ]] + [[ "$body" != *'rm -rf "$LERD_DATA_DIR"'* ]] + done +} + # ── controlling terminal detection ──────────────────────────────────────────── # [ -r /dev/tty ] tests the permission bits on the device node, which pass even From 31cc5e1055963f2f1f1c16128137a69ff4fee7f1 Mon Sep 17 00:00:00 2001 From: George Dumitrescu Date: Thu, 13 Aug 2026 23:15:15 +0300 Subject: [PATCH 2/2] test(installer): pin the uninstall tests to the throwaway HOME install.sh derives the config and data directories from XDG_CONFIG_HOME and XDG_DATA_HOME and only falls back to HOME when those are unset, so overriding HOME on its own left the suite pointing at the real directories on any machine where they are set. Nothing executed a removal against them, but the isolation held by luck rather than by construction. The setup now clears the XDG variables alongside HOME, and the first test asserts both directories land inside the throwaway one, so a change that reintroduces the leak fails here instead of on someone's disk. --- tests/installer/installer.bats | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/installer/installer.bats b/tests/installer/installer.bats index 47757fae9..0a3d176bf 100644 --- a/tests/installer/installer.bats +++ b/tests/installer/installer.bats @@ -7,8 +7,11 @@ INSTALLER="$BATS_TEST_DIRNAME/../../install.sh" # Source the installer so we can call its functions directly. # The guard at the bottom prevents main() from running when sourced. setup() { - # Isolate HOME so the installer never touches the real shell rc files. + # Isolate HOME so the installer never touches the real shell rc files. The + # XDG variables go with it: LERD_DATA_DIR falls back to $HOME only when they + # are unset, so leaving them would point the data directory at the real one. export HOME="$BATS_TMPDIR/home-$$" + unset XDG_DATA_HOME XDG_CONFIG_HOME XDG_STATE_HOME XDG_CACHE_HOME mkdir -p "$HOME" # Source the script to load all function definitions. @@ -20,6 +23,15 @@ teardown() { rm -rf "$BATS_TMPDIR/home-$$" } +# Pins the isolation the whole file rests on: whatever the environment running +# the suite looks like, the directories the uninstall removes must sit inside +# the throwaway HOME and never in the real one. +@test "the harness keeps the config and data directories inside the test HOME" { + [[ "$HOME" == "$BATS_TMPDIR/"* ]] + [[ "$LERD_CONFIG_DIR" == "$HOME/"* ]] + [[ "$LERD_DATA_DIR" == "$HOME/"* ]] +} + # ── detect_arch ─────────────────────────────────────────────────────────────── @test "detect_arch returns amd64 for x86_64" {