Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).

Expand Down
30 changes: 24 additions & 6 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
22 changes: 20 additions & 2 deletions internal/cli/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,12 @@ func runUninstall(force bool) error {
}
step("Removing config and data directories")
os.RemoveAll(config.ConfigDir())
os.RemoveAll(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())
Expand All @@ -206,6 +210,20 @@ func runUninstall(force bool) error {
return nil
}

// 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 {
return feedback.Confirm("Remove MCP integration (global skills + per-site .mcp/.claude/.cursor/.junie files)?", false)
}
Expand Down
75 changes: 75 additions & 0 deletions internal/cli/uninstall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
70 changes: 69 additions & 1 deletion tests/installer/installer.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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" {
Expand Down Expand Up @@ -642,6 +654,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
Expand Down
Loading