#!/usr/bin/env bash # DevGrail uninstaller — companion to install.sh. # # curl -fsSL https:///uninstall.sh | sudo bash # keep workspaces + data # curl -fsSL https:///uninstall.sh | sudo bash -s -- --purge # full wipe # sudo bash uninstall.sh # tear down, PRESERVE workspaces, data + config # sudo bash uninstall.sh --purge # full wipe: also remove data, certs, files # sudo bash uninstall.sh --keep-workspaces # # remove all of DevGrail's own state, but # # leave the workspace containers, their # # volumes and their networks on the host # sudo bash uninstall.sh --nuke # --purge, plus undo the host-level changes # # install.sh made, so the VPS is as it was # sudo bash uninstall.sh --dry-run # report what would be destroyed, change nothing # # Every run opens with an inventory of what it is about to destroy and what it # will keep, named object by object, before the first question is asked. # --dry-run prints that same report and stops there. It is the safe first move # on a host you are not certain about, and it combines with the flags below: # `--nuke --dry-run` reports what a --nuke would do without doing any of it. # # --keep-workspaces is a --purge with one carve-out. Everything of DevGrail's # own goes — the stack, the database, the certificates, the images and the # config on disk — while the workspace containers, their volumes, their # per-workspace networks and the image they run on stay where they are. It is # the mode for starting the install over without throwing the work away: a fresh # install.sh finds those containers again and the dashboard's Re-add tab offers # them back with their files, volume and ports intact. What it cannot give back # is what only ever lived in the database — a workspace's name, description, # documentation, project, app subdomains and API-key grants, and its SSH # password, which is re-generated. Restoring the pre-wipe backup onto the new # install is what brings those back. It is refused with --nuke and # --remove-docker, both of which destroy the containers it exists to keep. # # --purge takes a backup (database + config.yaml) before deleting anything, so # the one irreversible command in the product still leaves a way back. Pass # --no-backup to skip it — which is the only way to destroy the data outright. # # Removes what install.sh created: the Compose stack (infra containers + # managed networks + volumes), the external `devgrail` edge network, the loaded # images (every tag, not just :latest) and — with --purge — the workspace # containers, their separately-spawned per-workspace networks and their volumes # (labeled `devgrail.managed`, not owned by Compose), the persistent data volumes # and the on-disk config in /opt/devgrail and /etc/devgrail. # # Default run deletes nothing of yours. The workspace containers are stopped and # left where they are — a workspace's filesystem is not all in its volume, so # removing the container would throw away installed packages, shell history and # whatever else lives in its writable layer — and so are their volumes, their # per-workspace networks (a stopped container is bound to its network by id, so # removing one would strand it), the devgrail-container image they run on, the # SQLite data volume (devgrail-data), the Let's Encrypt certs (traefik-acme) and # the config files. Only the stack containers go, and install.sh recreates those, # so a later re-run resumes in place with every workspace intact. # --purge leaves nothing of DevGrail's own. # # --purge still leaves the host itself changed, because install.sh changes more # than DevGrail's own namespace: it edits /etc/docker/daemon.json (userns-remap, # default-address-pools), which moves Docker's data root and creates the # `dockremap` user, and it installs Docker and jq. --nuke reverts all of that # except the packages; --remove-docker additionally uninstalls Docker Engine. # Neither ever removes openssl — too much on a VPS depends on it. # # Vars: # DEVGRAIL_INSTALL_DIR install root (default /opt/devgrail) # DEVGRAIL_DAEMON_JSON Docker daemon config to revert (default /etc/docker/daemon.json) set -euo pipefail INSTALL_DIR="${DEVGRAIL_INSTALL_DIR:-/opt/devgrail}" DEPLOY_DIR="$INSTALL_DIR/deploy" CONFIG_DIR=/etc/devgrail COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml" OVERRIDE_FILE="$DEPLOY_DIR/docker-compose.override.yml" # Compose derives the project name from the project directory, so this is the # prefix on every object the stack owns (deploy_devgrail-data, deploy-traefik-1). # It is how orphans are found once $COMPOSE_FILE itself is gone. PROJECT="$(basename "$DEPLOY_DIR")" DAEMON_JSON="${DEVGRAIL_DAEMON_JSON:-/etc/docker/daemon.json}" PURGE=0 ASSUME_YES=0 # Report and stop. Nothing below this flag's check may write, delete or restart # anything, which is what lets --dry-run skip the no-terminal guard entirely. DRY_RUN=0 # --nuke implies --purge and additionally reverts the host-level changes; # --remove-docker implies --nuke and additionally uninstalls Docker Engine. NUKE=0 REMOVE_DOCKER=0 # A --purge takes a backup first unless the operator explicitly opts out. It is # the only irreversible operation here: the database, every workspace volume and # the config.yaml whose jwt_secret decrypts the database all go at once. NO_BACKUP=0 # --keep-workspaces: a --purge that stops short of the workspaces themselves. KEEP_WORKSPACES=0 # Derived from the two above by refresh_modes, never set by hand. Declared here # so the steps below can read it before the first call. WIPE_WS=0 log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mWARNING:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } usage() { cat </dev/null 2>&1 || HAVE_DOCKER=0 if [ "$HAVE_DOCKER" = 0 ] && [ "$NUKE" = 0 ]; then die "docker not found — nothing to do, or already removed" fi # compose runs the stack's own compose file, including the operator's override # if there is one. Matching install.sh matters here: an override that adds a # service means `down` without it leaves that service running. compose() { if [ -f "$OVERRIDE_FILE" ]; then docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" -f "$OVERRIDE_FILE" "$@" else docker compose --project-directory "$DEPLOY_DIR" -f "$COMPOSE_FILE" "$@" fi } # --- prompting ------------------------------------------------------------- # stdin is the piped installer, so questions go to /dev/tty. Returns 0 on yes. ask() { local msg="$1" ans printf '%s ' "$msg" > /dev/tty read -r ans < /dev/tty || ans="" case "$ans" in y|Y|yes|YES) return 0 ;; *) return 1 ;; esac } # have_tty — whether there is a terminal to ask questions on. It *opens* # /dev/tty rather than testing that the path exists, which is the same test # install.sh makes and for the same reason: the device node is present in # plenty of contexts that cannot open it (a detached systemd unit, a container # started without a tty, some CI runners). Testing existence there sends the # run down the prompt path, every `read` fails, and a --purge aborts with # "aborted" instead of taking the documented no-terminal branch below. have_tty() { { : < /dev/tty; } 2>/dev/null; } # --- host revert helpers --------------------------------------------------- # Extracted by test/installer/uninstall_nuke_test.sh and by # test/installer/uninstall_inventory_test.sh between this marker and the # matching "end host revert helpers" one. Keep both comments intact, and keep # everything between them free of top-level side effects so the tests can source # it on a host with no Docker and no root. # # These sit above the questions rather than below them because the report that # precedes those questions asks classify_daemon_json what a --nuke would # actually revert. Definitions only — nothing here runs until it is called. # Restart dockerd across the init systems install.sh supports: systemd on the # Debian/RHEL images, OpenRC on Alpine. Non-zero means no known mechanism # worked, which callers treat as "put the config back". restart_docker() { if command -v systemctl >/dev/null 2>&1; then systemctl restart docker >/dev/null 2>&1 && return 0 fi if command -v rc-service >/dev/null 2>&1; then rc-service docker restart >/dev/null 2>&1 && return 0 fi if command -v service >/dev/null 2>&1; then service docker restart >/dev/null 2>&1 && return 0 fi return 1 } # Wait for the daemon to answer again. Coming off a remapped data root it has a # different tree to re-index, so this window is generous on purpose. wait_for_docker() { local i=0 while [ "$i" -lt 90 ]; do docker info >/dev/null 2>&1 && return 0 sleep 1 i=$((i + 1)) done return 1 } # Which of install.sh's keys this run actually removed. Read afterwards by the # data-root and dockremap cleanup, which must only run when userns-remap was # genuinely ours to turn off. classify_daemon_json sets them as candidates; # revert_daemon_json clears them again if the write or the restart fails. REVERTED_USERNS=0 REVERTED_POOLS=0 # Newline-separated ":" lines explaining whatever # classify_daemon_json declined to touch, rendered by the caller rather than # printed where they are found. That is what lets the --dry-run report and the # real revert share one classification without the report warning about the # same file twice. DAEMON_JSON_NOTE="" # classify_daemon_json — which of install.sh's two keys are still ours to # remove. Pure and silent: reads $DAEMON_JSON, sets REVERTED_USERNS / # REVERTED_POOLS and fills DAEMON_JSON_NOTE, and returns 1 when nothing # install.sh wrote is left in the file. Writes nothing, restarts nothing, so # the dry-run report can call it too. # # Keys count as ours only while they still hold the exact values install.sh # writes. An operator who has since set userns-remap to a named user, or tuned # the pools to their own addressing, made a decision this script has no business # reversing — those are reported and left. A daemon.json that does not parse is # never touched, for the same reason install.sh does not touch it: that is an # operator edit in progress, and clobbering it would destroy work we cannot see. classify_daemon_json() { local current remap REVERTED_USERNS=0 REVERTED_POOLS=0 DAEMON_JSON_NOTE="" if [ ! -s "$DAEMON_JSON" ]; then DAEMON_JSON_NOTE="log:No $DAEMON_JSON on this host — nothing to revert." return 1 fi if ! jq -e . "$DAEMON_JSON" >/dev/null 2>&1; then DAEMON_JSON_NOTE="unparseable:$DAEMON_JSON exists but is not valid JSON — leaving it alone." return 1 fi current="$(cat "$DAEMON_JSON")" remap="$(printf '%s' "$current" | jq -r '."userns-remap" // empty')" case "$remap" in default) REVERTED_USERNS=1 ;; '') ;; *) DAEMON_JSON_NOTE="warn:$DAEMON_JSON sets userns-remap to \"$remap\", which install.sh never writes — leaving it." ;; esac # The two candidate ranges install.sh offers, each carved into a /24 (see its # "default address pools" section). Anything else is the operator's own # addressing plan, and narrowing or widening it is their call. if printf '%s' "$current" | jq -e ' ."default-address-pools" as $p | ($p | type) == "array" and ($p | length) == 1 and ($p[0].size == 24) and ($p[0].base == "172.16.0.0/12" or $p[0].base == "10.201.0.0/16")' >/dev/null 2>&1; then REVERTED_POOLS=1 elif printf '%s' "$current" | jq -e 'has("default-address-pools")' >/dev/null 2>&1; then DAEMON_JSON_NOTE="${DAEMON_JSON_NOTE:+$DAEMON_JSON_NOTE }warn:$DAEMON_JSON has default-address-pools install.sh did not write — leaving them." fi if [ "$REVERTED_USERNS" = 0 ] && [ "$REVERTED_POOLS" = 0 ]; then DAEMON_JSON_NOTE="${DAEMON_JSON_NOTE:+$DAEMON_JSON_NOTE }log:Nothing in $DAEMON_JSON came from install.sh — leaving it untouched." return 1 fi return 0 } # render_daemon_json_note — say out loud what classify_daemon_json left alone. # Separate from the classification so calling it twice (once for the report, # once for the revert) does not print the same complaint twice. render_daemon_json_note() { local line [ -n "$DAEMON_JSON_NOTE" ] || return 0 while IFS= read -r line; do case "$line" in warn:*) warn "${line#warn:}" ;; unparseable:*) warn "${line#unparseable:}" echo " Remove the userns-remap and default-address-pools keys by hand." >&2 ;; log:*) log "${line#log:}" ;; esac done </dev/null)" || [ -z "$merged" ]; then warn "could not compute the reverted $DAEMON_JSON — leaving it alone." REVERTED_USERNS=0; REVERTED_POOLS=0 return 1 fi # The file is left as {} rather than deleted when it empties out: whether it # predated the install is unknowable from here, and an empty object is inert. stamp="$(date +%Y%m%d%H%M%S)" backup="$DAEMON_JSON.devgrail-revert-$stamp" if ! cp -p "$DAEMON_JSON" "$backup"; then warn "could not back up $DAEMON_JSON — not changing it." REVERTED_USERNS=0; REVERTED_POOLS=0 return 1 fi if ! printf '%s\n' "$merged" > "$DAEMON_JSON.devgrail-new"; then warn "could not write $DAEMON_JSON — leaving it alone." rm -f "$backup" "$DAEMON_JSON.devgrail-new" REVERTED_USERNS=0; REVERTED_POOLS=0 return 1 fi chmod 0644 "$DAEMON_JSON.devgrail-new" mv -f "$DAEMON_JSON.devgrail-new" "$DAEMON_JSON" # No daemon to restart — the file is the whole change. This is the ordinary # case when --nuke follows an earlier --remove-docker, and treating it as a # failed restart would restore the keys and abort over nothing. if ! command -v docker >/dev/null 2>&1; then log "Docker is not installed on this host, so there is no daemon to restart." rm -f "$backup" return 0 fi log "Restarting Docker on the reverted daemon config..." if restart_docker && wait_for_docker; then rm -f "$backup" return 0 fi warn "Docker did not come back after the revert — restoring the previous $DAEMON_JSON." mv -f "$backup" "$DAEMON_JSON" REVERTED_USERNS=0; REVERTED_POOLS=0 if restart_docker && wait_for_docker; then warn "Docker is back on its previous configuration; $DAEMON_JSON was NOT reverted." return 1 fi die "Docker is not responding and restoring the previous configuration did not revive it. $DAEMON_JSON holds what it held before this run. Investigate with: journalctl -u docker --no-pager -n 50" } # remapped_root_reclaimable — true when # is the per-UID data root userns-remap was using and the daemon has since moved # off it, leaving that whole tree unreachable. # # Deliberately narrow: the path must be a direct child of the daemon's current # root, named ., and still exist. Anything else — a configured # data-root, a bind mount, an unreadable `docker info` — is not a path to hand # to `rm -rf` on a guess. remapped_root_reclaimable() { local prior="$1" now="$2" base [ -n "$prior" ] && [ -n "$now" ] || return 1 [ "$prior" != "$now" ] || return 1 [ "$(dirname "$prior")" = "$now" ] || return 1 base="$(basename "$prior")" case "$base" in *[!0-9.]*) return 1 ;; # digits and dots only .*|*.) return 1 ;; # no leading or trailing dot *.*.*) return 1 ;; # exactly one dot *.*) ;; *) return 1 ;; # ...and it must have one esac [ -d "$prior" ] } # Drop the subordinate-id ranges dockerd allocated for its remap user, and the # user itself. Only called when userns-remap was actually reverted, so this # never removes a dockremap the operator still needs. /etc/subuid and # /etc/subgid themselves stay: install.sh only ever creates them empty, which is # indistinguishable from a stock host. remove_dockremap() { local f tmpf for f in /etc/subuid /etc/subgid; do [ -f "$f" ] || continue grep -q '^dockremap:' "$f" 2>/dev/null || continue tmpf="$f.devgrail-tmp" { grep -v '^dockremap:' "$f" || true; } > "$tmpf" # Written back through the original inode so ownership and mode survive. cat "$tmpf" > "$f" rm -f "$tmpf" log "Removed the dockremap ranges from $f" done if command -v userdel >/dev/null 2>&1 && id dockremap >/dev/null 2>&1; then userdel dockremap >/dev/null 2>&1 && log "Removed the dockremap user." || true fi return 0 } # --- end host revert helpers ----------------------------------------------- # compose_project_containers — ids of containers belonging to *this* install's # Compose project. The project label alone is not enough (it is just the deploy # directory's basename, which another stack could share), so each candidate is # confirmed against the config-file path Compose records on it. compose_project_containers() { local id conf for id in $({ docker ps -aq --filter "label=com.docker.compose.project=$PROJECT" 2>/dev/null || true; }); do conf="$(docker inspect -f '{{index .Config.Labels "com.docker.compose.project.config_files"}}' "$id" 2>/dev/null || true)" case "$conf" in "$DEPLOY_DIR"/*) printf '%s\n' "$id" ;; esac done } # compose_project_object — the project's copy of # a volume or network declared in docker-compose.yml. Volumes and networks carry # no config_files label, so identity comes from the project label plus Compose's # own name label; an unrelated stack would have to match both exactly. compose_project_object() { local kind="$1" name="$2" docker "$kind" ls -q \ --filter "label=com.docker.compose.project=$PROJECT" \ --filter "label=com.docker.compose.$kind=$name" 2>/dev/null || true } # image_refs — every repo:tag currently pointing into . # install.sh leaves three per image (:latest, the displaced build as :previous, # and the release tag `docker load` unpacked), so removing :latest by name used # to leave two behind. # # Every stage is guarded: on a host where the images are already gone, `grep -v` # matches nothing and exits 1, which under `set -o pipefail` would surface as a # failed assignment and kill the script mid-uninstall. image_refs() { { docker images --filter "reference=$1" --format '{{.Repository}}:{{.Tag}}' 2>/dev/null || true; } \ | { grep -v ':$' || true; } | sort -u } remove_image_refs() { local ref refs for ref in "$@"; do refs="$(image_refs "$ref")" [ -n "$refs" ] || continue # shellcheck disable=SC2086 # deliberate word splitting over the ref list docker rmi $refs >/dev/null 2>&1 || true done } # --- inventory helpers ----------------------------------------------------- # Extracted by test/installer/uninstall_inventory_test.sh between this marker # and the matching "end inventory helpers" one. Keep both comments intact, and # keep everything between them free of top-level side effects so the tests can # source it on a host with no Docker and no root. # # Nothing in here writes, deletes or restarts anything. That is load-bearing # twice over: the report is printed before the first question is asked, so the # answer to that question is an informed one, and the same code is the whole of # what --dry-run does. # How many names to print per category before summarising the remainder. A host # with sixty workspaces should not bury the verdict under sixty lines. INVENTORY_LIST_CAP=20 # Counts the report gathers and the confirmation prompts below quote back, so # "delete ALL DevGrail data" can name what "all" actually is on this host. INV_WS_N=0 INV_WS_VOL_N=0 INV_BACKUP_N=0 INV_OTHER_C_N=0 INV_OTHER_V_N=0 # How much of DevGrail's own state (config on disk, data volumes) is still here. # The prompts read it to decide whether offering --keep-workspaces means # anything on this host. INV_OWN_N=0 # refresh_modes — derive WIPE_WS from the flags as they stand. # # Everything workspace-shaped (the containers, their volumes, their networks, # the image they run on) tests WIPE_WS rather than PURGE, because # --keep-workspaces is precisely the split between "wipe DevGrail" and "wipe the # workspaces with it". It lives in this block, and inventory_report calls it # first, so the report derives the mode instead of trusting a variable somebody # set earlier — and so the prompts, which can still turn PURGE on, only have to # call it again. refresh_modes() { WIPE_WS=0 if [ "$PURGE" = 1 ] && [ "$KEEP_WORKSPACES" = 0 ]; then WIPE_WS=1; fi return 0 } # inv_count — how many non-empty lines. `grep -c .` rather than # `wc -l`: it counts a final line that has no trailing newline and skips blank # ones, which is exactly the shape `docker ... -q` hands back. wc -l reports 0 # for a one-line answer that ended without a newline. inv_count() { printf '%s\n' "${1:-}" | grep -c '.' || true; } # inv_container_name — the name Docker knows a container by, without the # leading slash, falling back to the id when inspect cannot answer. A report # that aborts because one container is being removed underneath it is worse # than one that prints a short id. inv_container_name() { local n n="$(docker inspect -f '{{.Name}}' "$1" 2>/dev/null || true)" n="${n#/}" [ -n "$n" ] || n="$1" printf '%s' "$n" } # inv_verdict — the word this run has earned for a category. inv_verdict() { if [ "$1" = 1 ]; then printf 'DESTROY'; else printf 'keep'; fi; } # inv_head