fix: reap the whole generated-ISO family without over-matching, and stop building python from the filename

Two filed issues in one rewrite of preflight-cleanup.sh's ISO block, because
they are the same twenty lines.

#111 — ISO_FILENAME was interpolated into python3 -c PROGRAM TEXT inside a
single-quoted literal, so a quote in the value escaped it and executed
arbitrary Python in a container holding PVE_API_TOKEN, PVE_PASSWORD, the
Terraform state and the storage VM's SSH key. It now arrives through the
environment and is read with os.environ. The volid is passed to urllib's
quote() via argv for the same reason, and an empty encode result now skips
the volume instead of issuing a DELETE against the bare collection URL.

#105 — generated ISOs embed a hash of first-boot.sh, so every change to that
script mints a new filename. Deleting only the exact current name orphaned
each earlier ISO on the storage permanently, because force-cleanup wipes the
Terraform state that could otherwise reclaim it. The family is now swept by
rebuilding the full generated shape: the captured prefix plus twelve hex
characters plus .iso. A prefix test alone would also have matched a longer
FQDN's family and any hand-uploaded "-manual-backup.iso" sibling, which in a
script whose job is deletion is worse than the leak it fixes.

Multi-delete applies only to that family. A name that is not generated — the
storage VM's cloud image — keeps the original one-shot behaviour, since a
basename can repeat across content namespaces and a plain name carries nothing
that identifies a family.

Adds preflight-cleanup.test.sh, wired into shell-selfchecks. The script had no
coverage at all. It stubs curl and sleep, then asserts on the DELETEs issued:
the family goes, the pinned base ISO and unrelated uploads stay, a non-hash
sibling stays, the cloud image takes only itself, a quoted payload is data
rather than code, and unset storage skips only the ISO branch. Every case also
asserts the script ran to completion and removed the Terraform state, so a path
that dies early cannot pass by having issued the right DELETEs first.
This commit is contained in:
goodolclint-claude[bot]
2026-09-01 17:51:34 -05:00
parent 8623118557
commit 122e79407c
3 changed files with 226 additions and 17 deletions
@@ -63,25 +63,84 @@ elif [ -z "$ISO_STORAGE" ]; then
fi
echo "WARNING: TF_VAR_iso_storage is unset — skipping ISO cleanup rather than guessing a storage pool"
else
ISO_EXISTS=$(curl -sk -H "Authorization: PVEAPIToken=${API_TOKEN}" \
# Generated auto-install ISOs carry a hash of first-boot.sh in the name, so each
# change to that script mints a new filename. Deleting only the current name
# strands every earlier one on the storage, and force-cleanup wipes the
# Terraform state that could otherwise reclaim them. Match the whole family.
ISO_MATCHES=$(curl -sk -H "Authorization: PVEAPIToken=${API_TOKEN}" \
"${API_BASE}/nodes/${NODE}/storage/${ISO_STORAGE}/content" 2>/dev/null \
| python3 -c "
import json, sys
data = json.load(sys.stdin).get('data', [])
for item in data:
if item.get('volid', '').endswith('/${ISO_FILENAME}'):
print(item['volid'])
break
" 2>/dev/null || true)
| ISO_FILENAME="$ISO_FILENAME" ISO_STORAGE="$ISO_STORAGE" python3 -c '
import json, os, re, sys
if [ -n "$ISO_EXISTS" ]; then
echo "Found orphaned ISO: ${ISO_EXISTS}"
echo " Deleting..."
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${ISO_EXISTS}', safe=''))")
curl -sk -X DELETE -H "Authorization: PVEAPIToken=${API_TOKEN}" \
"${API_BASE}/nodes/${NODE}/storage/${ISO_STORAGE}/content/${ENCODED}" >/dev/null 2>&1
sleep 2
echo " ISO cleanup done"
name = os.environ["ISO_FILENAME"]
storage = os.environ["ISO_STORAGE"]
# Generated names are <base>-auto-<storage-vm-fqdn-dashed>-<12 hex of first-boot.sh>.iso.
# Siblings differ only in the hash, so sweep the family by rebuilding the full
# shape — a prefix test alone would also match a longer FQDN or a hand-uploaded
# "-manual-backup.iso", and this script deletes what it matches.
family = re.match(r"^(.+-auto-.+-)[0-9a-f]{12}\.iso$", name)
try:
data = json.load(sys.stdin).get("data", [])
except Exception:
sys.exit(0)
def candidates():
for item in data:
volid = item.get("volid", "")
# The channel to the shell is newline-delimited, so a volid carrying a
# newline would arrive as two lines and the tail would be deleted without
# ever having matched. The anchored sibling pattern below already
# excludes such a volid, so this is unreachable today and no test can
# cover it — it is here so loosening that pattern cannot silently
# reintroduce the split.
if any(c in volid for c in "\r\n\0"):
continue
# A volid names its own storage. Deleting one through a different
# storage endpoint is never right.
if not volid.startswith(storage + ":"):
continue
yield volid, volid.rsplit("/", 1)[-1]
if family:
sibling = re.compile(r"^" + re.escape(family.group(1)) + r"[0-9a-f]{12}\.iso$")
for volid, base in candidates():
if sibling.match(base):
print(volid)
else:
# Anything else (the storage VM cloud image) keeps the original one-shot
# behaviour: a basename can repeat across content namespaces, and a
# non-generated name carries nothing that identifies a family.
for volid, base in candidates():
if base == name:
print(volid)
break
' 2>/dev/null || true)
if [ -n "$ISO_MATCHES" ]; then
while IFS= read -r volid; do
[ -n "$volid" ] || continue
echo "Found orphaned ISO: ${volid}"
echo " Deleting..."
ENCODED=$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$volid")
if [ -z "$ENCODED" ]; then
echo " WARNING: could not encode ${volid} — skipping rather than issuing a bare DELETE" >&2
continue
fi
code=$(curl -sk -o /dev/null -w '%{http_code}' -X DELETE \
-H "Authorization: PVEAPIToken=${API_TOKEN}" \
"${API_BASE}/nodes/${NODE}/storage/${ISO_STORAGE}/content/${ENCODED}" 2>/dev/null || echo 000)
sleep 2
case "$code" in
2*) echo " ISO cleanup done" ;;
*) echo " WARNING: DELETE of ${volid} returned ${code} — it is still on ${ISO_STORAGE}" >&2
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
echo "::warning::ISO ${volid} was not deleted (HTTP ${code}); it will accumulate on ${ISO_STORAGE}"
fi ;;
esac
done <<EOF
$ISO_MATCHES
EOF
else
echo "No orphaned ISO found"
fi