#!/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