#!/usr/bin/env bash # One-off cleanup for the empty-app errors chunks in the dev deployment bucket # (branch fix-empty-app-key). Run by hand, by Paul, from a laptop. Nothing in # the platform runs this, and it never runs on a box. # # What it cleans: objects under exactly apps/bab// in carlos-dev-640012953448 # (eu-west-1). An app-less route on leo-climbing (proof.dev.carlosframework.com: # account bab, no app, an Addr upstream nobody listens on) had its refused 502s # filed under (bab, "") and shipped as apps/bab//errors/leo-climbing/... . # hostops.Apps refuses to walk a bucket holding that prefix. The writer fix # stops new ones, but the box's pruner no longer touches the prefix either, so # the ones already there stay until they are deleted. # # Usage: # 2026-10-16-empty-app-key-cleanup.bash dry run: list and count, delete nothing # 2026-10-16-empty-app-key-cleanup.bash --apply delete each current object by exact key # 2026-10-16-empty-app-key-cleanup.bash --apply --versions also delete every version and delete marker under the prefix, by key and version id # # The bucket is versioned. --apply alone deletes each current object with one # delete-object call per key, which leaves a delete marker: reversible, and # enough for hostops.Apps, which lists current objects only. --versions # deletes each listed version and delete marker by its version id, one call # each. That is permanent. # # Safety: the bucket, prefix, profile and region are fixed below and cannot # be overridden. The account is checked before anything else. Every key is # checked to start with exactly apps/bab// before it is acted on. There is no # --recursive and no prefix delete: each call names one key (and, with # --versions, one version id). The first failed call stops the run. # # Run it after the fixed agent has rolled to every box that uses this bucket, # or leo-climbing will write new ones (the old agent was still writing at # 2026-10-16 06:46Z). The listing and a log of every call go to a run # directory printed at the start. set -euo pipefail readonly BUCKET="carlos-dev-640012953448" readonly ACCOUNT="640012953448" readonly PREFIX="apps/bab//" readonly PROFILE="carloku-staging" readonly REGION="eu-west-1" apply=0 versions=0 for arg in "$@"; do case "$arg" in --apply) apply=1 ;; --versions) versions=1 ;; *) echo "usage: $0 [--apply [--versions]]" >&2 exit 2 ;; esac done if [ "$versions" = 1 ] && [ "$apply" = 0 ]; then echo "--versions only means something with --apply; a dry run already lists every version" >&2 exit 2 fi aws_() { aws --profile "$PROFILE" --region "$REGION" "$@"; } got_account="$(aws_ sts get-caller-identity --query Account --output text)" if [ "$got_account" != "$ACCOUNT" ]; then echo "profile $PROFILE is account $got_account, want $ACCOUNT; stopping" >&2 exit 1 fi run="$(mktemp -d "${TMPDIR:-/tmp}/empty-app-cleanup.XXXXXX")" echo "run directory: $run" # count prints "current versions markers" for the prefix. count() { local cur ver cur="$(aws_ s3api list-objects-v2 --bucket "$BUCKET" --prefix "$PREFIX" --output json | jq '[.Contents[]?] | length')" ver="$(aws_ s3api list-object-versions --bucket "$BUCKET" --prefix "$PREFIX" --output json | jq -r '"\([.Versions[]?] | length) \([.DeleteMarkers[]?] | length)"')" echo "$cur $ver" } # list writes the work list: one line per call, tab-separated # kind (object|version|marker), key, version id ("" for object). list() { aws_ s3api list-objects-v2 --bucket "$BUCKET" --prefix "$PREFIX" --output json | jq -r '.Contents[]? | ["object", .Key, ""] | @tsv' >"$run/objects.tsv" aws_ s3api list-object-versions --bucket "$BUCKET" --prefix "$PREFIX" --output json | jq -r '(.Versions[]? | ["version", .Key, .VersionId]), (.DeleteMarkers[]? | ["marker", .Key, .VersionId]) | @tsv' >"$run/versions.tsv" } # check refuses the whole run if any listed key is outside the prefix, or # carries a tab, newline or backslash (jq's @tsv escapes those with a # backslash, so the key read back would not be the key listed). check() { local kind key vid while IFS=$'\t' read -r kind key vid; do if [[ "$key" == *\\* ]]; then echo "listed key with an escaped character: $key; stopping, nothing deleted" >&2 exit 1 fi if [[ "$key" != "$PREFIX"* ]]; then echo "listed key outside $PREFIX: $key; stopping, nothing deleted" >&2 exit 1 fi case "$kind" in object | version | marker) ;; *) echo "unexpected row kind $kind for $key; stopping, nothing deleted" >&2 exit 1 ;; esac done <"$1" } read -r before_cur before_ver before_mark < <(count) echo "before: $before_cur current objects, $before_ver versions, $before_mark delete markers under $PREFIX" | tee "$run/counts.txt" list check "$run/objects.tsv" check "$run/versions.tsv" echo "listed: $(wc -l <"$run/objects.tsv") current objects ($run/objects.tsv), $(wc -l <"$run/versions.tsv") versions and markers ($run/versions.tsv)" if [ "$apply" = 0 ]; then echo "dry run: nothing deleted. First rows of each list:" head -5 "$run/objects.tsv" head -5 "$run/versions.tsv" echo "re-run with --apply (and --versions to remove every version too)" exit 0 fi work="$run/objects.tsv" if [ "$versions" = 1 ]; then work="$run/versions.tsv" fi n=0 while IFS=$'\t' read -r kind key vid; do if [ "$kind" = object ]; then aws_ s3api delete-object --bucket "$BUCKET" --key "$key" --output json >>"$run/calls.log" else aws_ s3api delete-object --bucket "$BUCKET" --key "$key" --version-id "$vid" --output json >>"$run/calls.log" fi printf '%s\t%s\t%s\n' "$kind" "$key" "$vid" >>"$run/deleted.tsv" n=$((n + 1)) done <"$work" echo "deleted: $n calls ($run/deleted.tsv)" read -r after_cur after_ver after_mark < <(count) echo "after: $after_cur current objects, $after_ver versions, $after_mark delete markers under $PREFIX" | tee -a "$run/counts.txt"