Newer
Older
openstack-caracal-ipv4 / clientdocs / scripts / ci-cleanup-sweep.sh
#!/usr/bin/env bash
# ci-cleanup-sweep.sh -- delete leftover pipeline resources (yours to run/schedule).
#
# What: the periodic sweep recommended in section 6 of the CI/Automation
#       Integration Guide: finds resources in your project whose NAME starts
#       with a prefix (default: ci-) and that are OLDER than a cutoff
#       (default: 24 hours), and deletes them in a safe order:
#         servers -> load balancers -> floating ips -> volumes
#         -> security groups -> networks
#       Volumes are only deleted when detached ('available'); floating ips
#       only when detached AND their description starts with the prefix
#       (floating ips have no name -- tag yours via --description when you
#       create them, as acceptance-run.sh does).
# Why:  leaked CI resources quietly eat your quota -- floating ips count even
#       when detached. A scheduled sweep older-than-your-longest-pipeline is
#       the cheapest way to keep headroom.
# Safety: DRY-RUN BY DEFAULT -- prints what it WOULD delete and touches
#       nothing until you pass --apply. It never touches a resource whose
#       name (or floating ip description) does not start with the prefix.
#
# Usage: ci-cleanup-sweep.sh [--prefix ci-] [--hours 24] [--apply]
# Auth:  your normal OpenStack client environment (OS_CLOUD or OS_* variables).
# Exit:  0 sweep completed (dry-run or applied) | 1 a delete or listing failed
#        | 2 environment/usage problem.
set -uo pipefail

PREFIX="ci-"; HOURS=24; APPLY=0
usage() { echo "usage: ci-cleanup-sweep.sh [--prefix ci-] [--hours 24] [--apply]"; }
while [ $# -gt 0 ]; do
  case "$1" in
    --prefix) [ $# -ge 2 ] || { usage; exit 2; }; PREFIX="$2"; shift 2 ;;
    --hours)  [ $# -ge 2 ] || { usage; exit 2; }; HOURS="$2";  shift 2 ;;
    --apply)  APPLY=1; shift ;;
    -h|--help) usage; exit 0 ;;
    *) echo "unknown argument: $1"; usage; exit 2 ;;
  esac
done
case "$HOURS" in ''|*[!0-9.]*) echo "--hours must be a number"; exit 2 ;; esac
[ -n "$PREFIX" ] || { echo "--prefix must not be empty"; exit 2; }

command -v openstack >/dev/null 2>&1 || {
  echo "SETUP: the 'openstack' client is not installed (pip install python-openstackclient)"; exit 2; }
command -v python3 >/dev/null 2>&1 || { echo "SETUP: python3 is required"; exit 2; }
if [ -z "${OS_CLOUD:-}" ] && [ -z "${OS_AUTH_URL:-}" ]; then
  echo "SETUP: no OpenStack environment found (set OS_CLOUD or the OS_* variables)."
  exit 2
fi

WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
OS_ERR="$WORK/stderr.txt"
SWEEP_FAIL=0; N_DEL=0; N_KEPT=0

# Structured captures take stdout ONLY; stderr goes to a file so client
# warnings and notices can never corrupt the parsed output.
cap() { # cap <var> <openstack args...>
  local __v="$1"; shift
  local out rc
  out=$(openstack "$@" </dev/null 2>"$OS_ERR"); rc=$?
  printf -v "$__v" '%s' "$out"
  return "$rc"
}
oerr() { head -1 "$OS_ERR" 2>/dev/null || true; }
ids_names() { # stdin: list JSON -> lines "<id>\t<name>" (key casing varies per service)
  python3 -c '
import json,sys
try: rows=json.load(sys.stdin)
except Exception: sys.exit(1)
for r in rows:
    i=r.get("ID") or r.get("Id") or r.get("id") or ""
    n=r.get("Name") or r.get("name") or ""
    print("%s\t%s" % (i,n))'
}
jget() { # jget <json> <key>... -> first present key value (or empty, rc 1)
  printf '%s' "$1" | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: sys.exit(1)
for k in sys.argv[1:]:
    if d.get(k) not in (None,""):
        print(d[k]); sys.exit(0)
sys.exit(1)' "${@:2}"
}
is_older() { # is_older <iso-timestamp> -> 0 when age >= HOURS
  python3 -c '
import sys,datetime
ts=sys.argv[1].strip().replace("Z","+00:00")
try: d=datetime.datetime.fromisoformat(ts)
except ValueError: sys.exit(2)
if d.tzinfo is None: d=d.replace(tzinfo=datetime.timezone.utc)
age=(datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()/3600.0
sys.exit(0 if age >= float(sys.argv[2]) else 1)' "$1" "$HOURS"
}
act() { # act <label> <openstack delete args...>
  local label="$1"; shift
  if [ "$APPLY" = 1 ]; then
    if openstack "$@" </dev/null >/dev/null 2>"$OS_ERR"; then
      echo "  DELETED $label"; N_DEL=$((N_DEL+1))
    else
      echo "  DELETE FAILED $label: $(oerr)"; SWEEP_FAIL=1
    fi
  else
    echo "  WOULD DELETE $label"; N_DEL=$((N_DEL+1))
  fi
}
keep() { echo "  keep $1"; N_KEPT=$((N_KEPT+1)); }

MODE="DRY-RUN (pass --apply to act)"; [ "$APPLY" = 1 ] && MODE="APPLY"
echo "=== CI leftover sweep: prefix '$PREFIX', older than ${HOURS}h -- $MODE ==="

sweep_class() { # sweep_class <label> <list args -- json> ; per-item hook via CB
  local label="$1"; shift
  local LIST id name
  echo "--- $label ---"
  if ! cap LIST "$@"; then
    echo "  LIST FAILED: $(oerr)"; SWEEP_FAIL=1; return
  fi
  while IFS=$'\t' read -r id name; do
    [ -n "$id" ] || continue
    case "$name" in "$PREFIX"*) ;; *) continue ;; esac
    "$CB" "$id" "$name"
  done < <(printf '%s' "$LIST" | ids_names)
}

cb_server() {
  local SHOW created
  cap SHOW server show "$1" -f json || { echo "  show failed for server $2: $(oerr)"; SWEEP_FAIL=1; return; }
  created=$(jget "$SHOW" created created_at || true)
  is_older "$created" || { keep "server $2 (younger than ${HOURS}h)"; return; }
  act "server $2" server delete "$1" --wait
}
cb_lb() {
  local SHOW created
  cap SHOW loadbalancer show "$1" -f json || { echo "  show failed for load balancer $2: $(oerr)"; SWEEP_FAIL=1; return; }
  created=$(jget "$SHOW" created_at || true)
  is_older "$created" || { keep "load balancer $2 (younger than ${HOURS}h)"; return; }
  act "load balancer $2 (cascade)" loadbalancer delete "$1" --cascade --wait
}
cb_volume() {
  local SHOW created status
  cap SHOW volume show "$1" -f json || { echo "  show failed for volume $2: $(oerr)"; SWEEP_FAIL=1; return; }
  status=$(jget "$SHOW" status || true)
  [ "$status" = "available" ] || { keep "volume $2 (status '$status', not detached)"; return; }
  created=$(jget "$SHOW" created_at || true)
  is_older "$created" || { keep "volume $2 (younger than ${HOURS}h)"; return; }
  act "volume $2" volume delete "$1"
}
cb_sg() {
  local SHOW created
  cap SHOW security group show "$1" -f json || { echo "  show failed for security group $2: $(oerr)"; SWEEP_FAIL=1; return; }
  created=$(jget "$SHOW" created_at || true)
  is_older "$created" || { keep "security group $2 (younger than ${HOURS}h)"; return; }
  act "security group $2" security group delete "$1"
}
cb_network() {
  local SHOW created
  cap SHOW network show "$1" -f json || { echo "  show failed for network $2: $(oerr)"; SWEEP_FAIL=1; return; }
  created=$(jget "$SHOW" created_at || true)
  is_older "$created" || { keep "network $2 (younger than ${HOURS}h)"; return; }
  act "network $2 (and its subnets)" network delete "$1"
}

CB=cb_server;  sweep_class "servers" server list -f json
CB=cb_lb;      sweep_class "load balancers" loadbalancer list -f json

# Floating ips have no name: match on description, and only when detached.
echo "--- floating ips (matched by description; detached only) ---"
if cap FIPS floating ip list -f json; then
  while IFS=$'\t' read -r id _; do
    [ -n "$id" ] || continue
    cap SHOW floating ip show "$id" -f json || { echo "  show failed for floating ip $id: $(oerr)"; SWEEP_FAIL=1; continue; }
    desc=$(jget "$SHOW" description || true)
    case "$desc" in "$PREFIX"*) ;; *) continue ;; esac
    addr=$(jget "$SHOW" floating_ip_address || true)
    port=$(jget "$SHOW" port_id || true)
    [ -z "$port" ] || { keep "floating ip ${addr:-$id} (still attached)"; continue; }
    created=$(jget "$SHOW" created_at || true)
    is_older "$created" || { keep "floating ip ${addr:-$id} (younger than ${HOURS}h)"; continue; }
    act "floating ip ${addr:-$id} (released)" floating ip delete "$id"
  done < <(printf '%s' "$FIPS" | ids_names)
else
  echo "  LIST FAILED: $(oerr)"; SWEEP_FAIL=1
fi

CB=cb_volume;  sweep_class "volumes (detached only)" volume list -f json
CB=cb_sg;      sweep_class "security groups" security group list -f json
CB=cb_network; sweep_class "networks" network list -f json

echo
VERB="would be deleted"; [ "$APPLY" = 1 ] && VERB="deleted"
echo "SWEEP SUMMARY: $N_DEL resource(s) $VERB, $N_KEPT kept (prefix '$PREFIX', ${HOURS}h cutoff)"
if [ "$SWEEP_FAIL" = 1 ]; then
  echo "RESULT: SWEEP FAIL (one or more listings or deletions failed above)"
  exit 1
fi
echo "RESULT: SWEEP OK"
exit 0