#!/usr/bin/env bash # Lifecycle for Mike's Docker stacks: start, stop, and garbage-collect. # # scripts/stack.sh up [compose up args…] # gc, then `docker compose up` # scripts/stack.sh down [--purge] # `docker compose down` (+ -v --rmi local) # scripts/stack.sh gc [--dry-run] [--deep] # remove leftovers this repo created # scripts/stack.sh status # list this repo's stacks and disk use # # Why this exists: `docker compose stop`, `docker stop`, and Ctrl-C leave # containers in Exited state. Exited containers pin their images and volumes, # and each checkout (worktree) or `-p` project adds another set. Nothing reclaims # any of it, so disk and RAM fill up with stacks nobody is using. `gc` runs # before every `up` (and from scripts/e2e-local-stack.sh) so leftovers are # swept on the next setup instead of accumulating. # # What `gc` removes — only resources it can attribute to this repository: # 1. Fully stopped Compose projects whose compose file is this repo's # docker-compose.yml (or whose checkout directory no longer exists and # whose project name starts with "mike"): containers + network go # (non-forced `docker rm`); named volumes are kept until the # project has been idle for MIKE_GC_VOLUME_AGE_DAYS (default 7). # 2. Fully stopped Supabase CLI stacks for this checkout's project_id # (backend/supabase/config.toml) or any id in MIKE_GC_SUPABASE_PROJECTS # (comma-separated): same rule, data volumes kept until idle long enough. # 3. Volumes unreferenced after planned teardown with verified ownership # and enough recorded idle time. Unknown named volumes are kept. Allowed # Supabase and anonymous orphans start a grace period when first observed. # 4. Dangling images, plus build cache older than MIKE_GC_VOLUME_AGE_DAYS. # --deep additionally removes ALL unused images and ALL build cache # (machine-wide, Docker's own `prune -a` semantics; the next start re-pulls). # # What `gc` never touches: active containers (including paused/restarting; they are # listed with the command to stop them), containers/volumes of projects it # cannot attribute to this repo, and the Supabase CLI stacks of other ids # (listed with the `supabase stop --project-id` command instead). # # Options: -p/--project NAME selects the Compose project (default: # COMPOSE_PROJECT_NAME, else the checkout directory name — Compose's default). # MIKE_GC=0 skips the sweep in `up` and in the e2e setup script. # MIKE_GC_ONLY= restricts the sweep to matching project names and skips # the machine-wide parts (anonymous volumes, images, build cache). # MIKE_GC_STATE_DIR overrides the shared cleanup record directory (default: # ${XDG_STATE_HOME:-$HOME/.local/state}/mike/stack-gc). Records are isolated by # Docker daemon and volume creation timestamp. Dry runs do not change them. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" COMPOSE_FILE="$ROOT/docker-compose.yml" AGE_DAYS="${MIKE_GC_VOLUME_AGE_DAYS:-7}" case "$AGE_DAYS" in ''|*[!0-9]*) printf '%s\n' 'MIKE_GC_VOLUME_AGE_DAYS must be a non-negative integer.' >&2; exit 1;; esac AGE_SECS=$((10#$AGE_DAYS * 86400)) DRY_RUN=0 DEEP=0 PROJECT="${COMPOSE_PROJECT_NAME:-}" ONLY="${MIKE_GC_ONLY:-}" # True when NAME is inside the MIKE_GC_ONLY filter (or no filter is set). in_scope() { [ -z "$ONLY" ] || case "$1" in $ONLY) true;; *) false;; esac; } log() { printf '%s\n' "$*"; } act() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else printf ' %s\n' "$*"; fi; } note() { printf ' %s\n' "$*"; } die() { printf '%s\n' "$*" >&2; exit 1; } usage() { # The header comment above, up to the first line of code. awk 'NR > 1 && /^set -euo/ { exit } NR > 1 { sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" exit "${1:-0}" } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required (install it and retry)."; } docker_ready() { docker info >/dev/null 2>&1; } # Compose's own default project name: the directory basename, lower-cased, # with characters outside [a-z0-9_-] dropped. default_project() { basename "$ROOT" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g' } # Is this compose file ours? It is identified by the marker on its first lines # rather than by path, so worktrees and renamed checkouts are all recognised. is_mike_compose_file() { [ -f "$1" ] && head -5 "$1" | grep -q 'MikeOSS'; } # Inventories print "-" for empty fields (a bare tab would be collapsed by # `read`); blank() maps it back to the empty string. blank() { [ "$1" = "-" ] && echo "" || echo "$1"; } # --------------------------------------------------------------------------- # Inventory helpers. Each prints one record per line. # --------------------------------------------------------------------------- # All containers with a compose project label: # projectstateconfig_filessupabase_projectlast_activity list_compose_containers() { docker ps -a --filter label=com.docker.compose.project --format '{{.ID}}' | xargs -r docker inspect | jq -r '.[] | [ .Config.Labels["com.docker.compose.project"], .State.Status, (.Config.Labels["com.docker.compose.project.config_files"] // "-"), (.Config.Labels["com.supabase.cli.project"] // "-"), (if .State.Status == "running" then "running" else ([.State.FinishedAt, .State.StartedAt, .Created] | max) end) ] | @tsv' } # Classify a compose project. Prints one of: # mike — this repo's docker-compose.yml, checkout still exists # orphan — checkout directory is gone and the name looks like ours # supabase — Supabase CLI stack (handled separately) # other — not ours; never touched classify_project() { local project=$1 config=$2 supa=$3 if [ -n "$supa" ]; then echo supabase; return; fi if [ -z "$config" ]; then echo other; return; fi # config_files may list several paths separated by commas; use the first. local first=${config%%,*} if [ -e "$first" ]; then if is_mike_compose_file "$first"; then echo mike; else echo other; fi else case "$project" in mike*) echo orphan;; *) echo other;; esac fi } supabase_allowed() { local id=$1 allowed allowed="$(sed -n 's/^project_id *= *"\(.*\)"/\1/p' "$ROOT/backend/supabase/config.toml" 2>/dev/null | head -1)" allowed="$allowed,${MIKE_GC_SUPABASE_PROJECTS:-}" case ",$allowed," in *",$id,"*) return 0;; *) return 1;; esac } # --------------------------------------------------------------------------- # gc # --------------------------------------------------------------------------- gc() ( need jq local work state_dir state_file daemon lock_owned=0 state_tmp="" work="$(mktemp -d)" trap 'rm -rf "$work"; [ -z "$state_tmp" ] || rm -f "$state_tmp"; [ "$lock_owned" = 0 ] || rmdir "$state_file.lock"' EXIT state_dir="${MIKE_GC_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mike/stack-gc}" daemon="$(docker info --format '{{.ID}}')" [ -n "$daemon" ] || die "Cannot identify the Docker daemon; refusing cleanup." state_file="$state_dir/$(printf '%s' "$daemon" | tr -c '[:alnum:]_-' '_').json" # Serialize real sweeps across worktrees. A dry run only reads the last # atomically published record and never changes it. if [ "$DRY_RUN" != 1 ]; then mkdir -p "$state_dir" if ! mkdir "$state_file.lock" 2>/dev/null; then die "Cleanup is locked: $state_file.lock (another sweep may be running)." fi lock_owned=1 fi if [ -f "$state_file" ]; then jq -e 'type == "object" and .version == 1 and (.volumes | type == "object")' "$state_file" >/dev/null || die "Invalid cleanup record: $state_file; refusing cleanup." cp "$state_file" "$work/state.json" else printf '{"version":1,"volumes":{}}\n' >"$work/state.json" fi log "Stack garbage collection (volume idle threshold: ${AGE_DAYS}d$( [ "$DRY_RUN" = 1 ] && echo ', dry run')${ONLY:+, only '$ONLY'})" # Include non-Compose containers: they may hold volumes shared with a # stopped Compose project. Slurp handles multiple xargs inspect batches. docker ps -aq | xargs -r docker inspect | jq -s 'add // []' >"$work/containers.json" docker volume ls -q | xargs -r docker volume inspect | jq -s 'add // []' >"$work/volumes.json" jq -r '.[] | select(.Config.Labels["com.docker.compose.project"] != null) | [.Config.Labels["com.docker.compose.project"], (.Config.Labels["com.docker.compose.project.config_files"] // "-"), (.Config.Labels["com.supabase.cli.project"] // "-")] | @tsv' \ "$work/containers.json" >"$work/inventory.tsv" { cut -f1 "$work/inventory.tsv"; jq -r '.[] | (.Labels["com.docker.compose.project"] // .Labels["com.supabase.cli.project"] // empty)' "$work/volumes.json"; } | sort -u >"$work/projects.txt" local project config supa kind classification scope : >"$work/projects.tsv" while IFS= read -r project; do [ -n "$project" ] || continue classification=unknown # Every container must agree on ownership; a foreign member vetoes it. while IFS=$'\t' read -r config supa; do [ -n "$config" ] || continue config="$(blank "$config")"; supa="$(blank "$supa")" kind="$(classify_project "$project" "$config" "$supa")" if [ "$kind" = supabase ] && ! supabase_allowed "$supa"; then kind=other; fi if [ "$classification" = unknown ]; then classification=$kind elif [ "$classification" != "$kind" ]; then classification=other; break; fi done < <(awk -F'\t' -v p="$project" '$1 == p {print $2 "\t" $3}' "$work/inventory.tsv") scope=false; if in_scope "$project"; then scope=true; fi printf '%s\t%s\t%s\n' "$project" "$classification" "$scope" >>"$work/projects.tsv" done <"$work/projects.txt" local allowed allowed="$(sed -n 's/^project_id *= *"\(.*\)"/\1/p' "$ROOT/backend/supabase/config.toml" | head -1),${MIKE_GC_SUPABASE_PROJECTS:-}" jq -n --slurpfile containers "$work/containers.json" --slurpfile volumes "$work/volumes.json" \ --slurpfile previous "$work/state.json" --rawfile classifications "$work/projects.tsv" \ --arg allowed "$allowed" --arg only "$ONLY" --argjson now "$(date -u +%s)" \ --argjson age "$AGE_SECS" -f "$ROOT/scripts/stack-volumes.jq" >"$work/plan.json" # Persist ownership and activity BEFORE removing any container. If writing # fails, its last-use evidence remains in Docker and nothing is deleted. if [ "$DRY_RUN" != 1 ]; then state_tmp="$(mktemp "$state_dir/.state.XXXXXX")" jq '.state' "$work/plan.json" >"$state_tmp" mv "$state_tmp" "$state_file" state_tmp="" fi log "Stopped stacks:" awk -F'\t' '$3 != "-" {print $1 "\t" $3}' "$work/inventory.tsv" | sort -u | while IFS=$'\t' read -r project supa; do in_scope "$project" || continue if ! supabase_allowed "$supa"; then note "not mine, left alone: supabase cli '$supa' (stop with: supabase stop --project-id $supa)" fi done jq -r '.protected[]' "$work/plan.json" | while IFS= read -r project; do note "active, left alone: $project (stop with: scripts/stack.sh down -p $project; for CLI stacks use supabase stop --project-id $project)" done # Never force removal or use compose down here: a container may have # started/paused since the snapshot. Docker rm refuses active containers. jq -r '.containers[]' "$work/plan.json" | while IFS= read -r id; do act "docker rm $id" if [ "$DRY_RUN" != 1 ]; then docker rm "$id" >/dev/null || note "kept container $id (removal failed; it may have become active)" fi done local net netproj docker network ls --filter label=com.docker.compose.project --format '{{.Name}}\t{{.Label "com.docker.compose.project"}}' | while IFS=$'\t' read -r net netproj; do jq -e --arg p "$netproj" '.removed_projects | index($p) != null' "$work/plan.json" >/dev/null || continue act "docker network rm $net" [ "$DRY_RUN" = 1 ] || docker network rm "$net" >/dev/null || note "kept network $net (still in use or removal failed)" done log "Volumes (unreferenced after planned teardown, idle ≥ ${AGE_DAYS}d):" local vol jq -r '.kept[]' "$work/plan.json" | while IFS= read -r vol; do note "kept (recently used or still referenced): $vol" done jq -r '.remove[]' "$work/plan.json" | while IFS= read -r vol; do act "docker volume rm $vol" # Docker refuses volumes referenced by any container, including one # created after the snapshot. Never force removal. [ "$DRY_RUN" = 1 ] || docker volume rm "$vol" >/dev/null || note "kept volume $vol (still in use or removal failed)" done # --- 4. Images and build cache ---------------------------------------- log "Images and build cache:" if [ -n "$ONLY" ]; then note "skipped (MIKE_GC_ONLY is set)" elif [ "$DEEP" = 1 ]; then act "docker image prune -a -f # all unused images (machine-wide)" act "docker builder prune -a -f # all build cache" if [ "$DRY_RUN" != 1 ]; then docker image prune -a -f | tail -1 | sed 's/^/ /' docker builder prune -a -f | tail -1 | sed 's/^/ /' fi else act "docker image prune -f # dangling images only" act "docker builder prune -f --filter until=${AGE_DAYS}d" if [ "$DRY_RUN" != 1 ]; then docker image prune -f | tail -1 | sed 's/^/ /' docker builder prune -f --filter "until=$((AGE_SECS / 3600))h" | tail -1 | sed 's/^/ /' fi fi log "Done. Disk use now:" docker system df | sed 's/^/ /' ) # --------------------------------------------------------------------------- # status # --------------------------------------------------------------------------- status() { need jq log "Compose project for this checkout: $PROJECT" log "Containers from this repo (and Supabase CLI stacks):" list_compose_containers | while IFS=$'\t' read -r project state config supa last; do config="$(blank "$config")"; supa="$(blank "$supa")" classify="$(classify_project "$project" "$config" "$supa")" [ "$classify" = other ] && continue printf ' %-28s %-9s %s\n' "$project" "$state" "${supa:+supabase cli '$supa'}" done | sort | uniq -c | sed 's/^ *\([0-9]*\) / \1× /' log "Volumes:" docker volume ls --format '{{.Name}}\t{{.Label "com.docker.compose.project"}}' | awk -F'\t' '$2 ~ /^mike/ || $1 ~ /^supabase_/ { print " " $1 }' log "Disk:" docker system df | sed 's/^/ /' } # --------------------------------------------------------------------------- # argument parsing # --------------------------------------------------------------------------- [ $# -ge 1 ] || usage 1 CMD=$1; shift PASSTHRU=() PURGE=0 while [ $# -gt 0 ]; do case "$1" in -p|--project) PROJECT=$2; shift 2;; --project=*) PROJECT=${1#*=}; shift;; --dry-run) DRY_RUN=1; shift;; --deep) DEEP=1; shift;; --purge) PURGE=1; shift;; -h|--help) usage 0;; --) shift; PASSTHRU+=("$@"); break;; *) PASSTHRU+=("$1"); shift;; esac done [ -n "$PROJECT" ] || PROJECT="$(default_project)" case "$CMD" in up) docker_ready || die "Docker is not running — start it first (open -a Docker) and retry." if [ "${MIKE_GC:-1}" != 0 ]; then gc; log; fi log "Starting compose project '$PROJECT'" # The commit becomes the Sentry release of every image compose builds # (GIT_SHA build arg, docs/observability.md); without it an event # cannot be mapped to the code that produced it. if [ -z "${GIT_SHA:-}" ] && command -v git >/dev/null 2>&1; then GIT_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" export GIT_SHA fi exec docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up ${PASSTHRU[@]+"${PASSTHRU[@]}"} ;; down) docker_ready || die "Docker is not running." if [ "$PURGE" = 1 ]; then log "Removing compose project '$PROJECT' including its data volumes and local images" exec docker compose -p "$PROJECT" down -v --rmi local --remove-orphans ${PASSTHRU[@]+"${PASSTHRU[@]}"} else log "Stopping compose project '$PROJECT' (data volumes kept; add --purge to delete them)" exec docker compose -p "$PROJECT" down --remove-orphans ${PASSTHRU[@]+"${PASSTHRU[@]}"} fi ;; gc) docker_ready || die "Docker is not running." gc ;; status) docker_ready || die "Docker is not running." status ;; *) usage 1;; esac