Skip to content

Commit 2e1c4ec

Browse files
travagliadclaude
andauthored
linode: attribute NodeBalancers by lke<id> label; tag volumes before every cluster delete (#1296)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30eaf90 commit 2e1c4ec

6 files changed

Lines changed: 95 additions & 35 deletions

File tree

.claude/integrations/slack/relay/relay.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,11 @@ async function reapLke() {
597597
for (const t of tags) { const mm = /^expires-(\d+)$/.exec(t); if (mm) { expiry = Number(mm[1]); break; } }
598598
if (expiry == null) { const created = Date.parse(c.created || "") / 1000; expiry = Number.isFinite(created) ? created + hardMax : 0; }
599599
if (nowS > expiry) {
600+
// Tag the cluster's volumes before deleting it: cluster-delete does not
601+
// cascade to them, and only an attached volume can be attributed. Best-effort.
602+
try {
603+
await execFileP("bash", [`${HA_DIR}/tag-lke-resources.sh`, String(c.id)], { env: process.env, timeout: 120000, maxBuffer: 4 * 1024 * 1024 });
604+
} catch (e) { console.error(`lke-reaper: pre-delete volume tag skipped (non-fatal) for ${c.id}: ${e.message}`); }
600605
const del = await linodeApi(`/lke/clusters/${c.id}`, { method: "DELETE" });
601606
console.log(`lke-reaper: deleted cluster ${c.id} "${c.label}" (expiry ${expiry} < now ${nowS}) -> ${del.status}`);
602607
if (del.ok) {

.claude/skills/linode-ha-provisioning/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,4 @@ curl -sS -m 240 --fail-with-body -X POST "$RELAY/linode/destroy-lke" \
200200
-d "$(jq -n --arg id "$RUN_ID" '{run_id:$id}')"
201201
```
202202

203-
Delete the cluster whether the run passed, failed, or was blocked — this is the last step, always. Unlike the single-VM path there is **no on-box self-destruct timer**; the guarantee is the **relay's TTL reaper**, which deletes any `pmm-qa-ephemeral` cluster past its `expires-<epoch>` tag (default 24h) even if this call never runs — the LKE equivalent of the VM's on-box timer. The SessionEnd hook fires the same `/linode/destroy-lke` for any run dir carrying an `lke` marker, so a normal session cleans up on its own; still call it explicitly at end of run — the reaper is the backstop, not the primary path. If you also created a box with `linode-docker-provisioning`, tear that VM down too — destroying the cluster does not touch it. Both teardown and the reaper now delete the cluster's unique account-level tags (`expires-<epoch>`, `pmm-qa-run:<id>`); Linode leaves those behind otherwise, so they pile up. Sweep leftovers on the relay with `LINODE_TOKEN=… terraform/linode-runner/prune-tags.sh --dry-run` (then without `--dry-run`). The **relay reaper** also deletes the orphaned Block Storage volumes and NodeBalancer that `cluster-delete` leaves behind — the biggest HA cost leak — via `.claude/skills/linode-ha-provisioning/scripts/prune-lke-orphans.sh` (`--dry-run` to preview). It deletes by **positive attribution**: `create-lke` stamps each cluster's volumes **and its NodeBalancer** with its `pmm-qa-run:<id>` tag (on every exit path, so failed bring-ups are covered), and the sweep removes one only when that run has **no live cluster** — so a live cluster's resources (volumes unattached during provisioning/failover) and other owners' volumes/NodeBalancers are never touched. `destroy-lke` does **not** sweep (cluster-delete is async — it would only see other runs' resources); the reaper owns it.
203+
Delete the cluster whether the run passed, failed, or was blocked — this is the last step, always. Unlike the single-VM path there is **no on-box self-destruct timer**; the guarantee is the **relay's TTL reaper**, which deletes any `pmm-qa-ephemeral` cluster past its `expires-<epoch>` tag (default 24h) even if this call never runs — the LKE equivalent of the VM's on-box timer. The SessionEnd hook fires the same `/linode/destroy-lke` for any run dir carrying an `lke` marker, so a normal session cleans up on its own; still call it explicitly at end of run — the reaper is the backstop, not the primary path. If you also created a box with `linode-docker-provisioning`, tear that VM down too — destroying the cluster does not touch it. Both teardown and the reaper now delete the cluster's unique account-level tags (`expires-<epoch>`, `pmm-qa-run:<id>`); Linode leaves those behind otherwise, so they pile up. Sweep leftovers on the relay with `LINODE_TOKEN=… terraform/linode-runner/prune-tags.sh --dry-run` (then without `--dry-run`). The **relay reaper** also deletes the orphaned Block Storage volumes and NodeBalancer that `cluster-delete` leaves behind — the biggest HA cost leak — via `.claude/skills/linode-ha-provisioning/scripts/prune-lke-orphans.sh` (`--dry-run` to preview). It deletes by **positive attribution**. **Volumes**: tagged with the cluster's `pmm-qa-run:<id>` at provision *and* again right before every cluster delete (`destroy-lke` and the reaper both call `tag-lke-resources.sh` while the nodes still exist), so volumes created after provisioning are covered too; the sweep removes a volume only when its run has **no live cluster**. **NodeBalancers**: attributed by their immutable `lke<clusterid>-` label — the Linode CCM reconciles a NodeBalancer's tags back to its defaults, so a `pmm-qa-run` tag does **not** survive on it and cannot be used; the sweep removes one only when its `lke<id>` cluster no longer exists. A live cluster's resources (volumes unattached during provisioning/failover) and other owners' `pvc-*` volumes are never touched. `destroy-lke` does **not** sweep (cluster-delete is async — it would only see other runs' resources); the reaper owns it.

.claude/skills/linode-ha-provisioning/scripts/create-lke-pmm-ha.sh

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -112,24 +112,14 @@ _diag() {
112112
kubectl get events -n "$NAMESPACE" --sort-by=.metadata.creationTimestamp >"$RUN_DIR/events.txt" 2>&1 || true
113113
kubectl describe pods -n "$NAMESPACE" >"$RUN_DIR/describe.txt" 2>&1 || true
114114
}
115-
# Stamp this cluster's volumes + NodeBalancer with pmm-qa-run:<id> so teardown
115+
# Stamp this cluster's volumes with pmm-qa-run:<id> so teardown
116116
# (prune-lke-orphans.sh) can attribute them. Runs on every exit path via the trap
117-
# so a failed bring-up is tagged too. Best-effort.
117+
# so a failed bring-up is tagged too. Best-effort. destroy-lke / the reaper tag
118+
# again right before deleting the cluster, catching volumes created after this.
118119
_tag_for_teardown() {
119120
[ -n "${CLUSTER_ID:-}" ] || return 0
120-
local ids vols lid vid nbid
121-
ids="$(linode-cli lke pools-list "$CLUSTER_ID" --json 2>/dev/null | jq -r '.[].nodes[].instance_id' 2>/dev/null)"
122-
vols="$(linode-cli volumes list --page-size 500 --json 2>/dev/null)"
123-
for lid in $ids; do
124-
[ -n "$lid" ] && [ "$lid" != "null" ] || continue
125-
for vid in $(printf '%s' "$vols" | jq -r --argjson l "$lid" '.[] | select(.linode_id==$l) | .id' 2>/dev/null); do
126-
linode-cli volumes update "$vid" --tags pmm-qa-ephemeral --tags "pmm-qa-run:$RUN_ID" >/dev/null 2>&1 || true
127-
done
128-
done
129-
for nbid in $(linode-cli nodebalancers list --page-size 500 --json 2>/dev/null \
130-
| jq -r --arg c "lke$CLUSTER_ID-" '.[] | select(.label|startswith($c)) | .id' 2>/dev/null); do
131-
linode-cli nodebalancers update "$nbid" --tags pmm-qa-ephemeral --tags "pmm-qa-run:$RUN_ID" >/dev/null 2>&1 || true
132-
done
121+
local SD; SD="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
122+
LINODE_TOKEN="$LINODE_TOKEN" bash "$SD/tag-lke-resources.sh" "$CLUSTER_ID" "$RUN_ID" || true
133123
}
134124
trap '_diag; _tag_for_teardown' EXIT
135125
# Linode reports the pool "ready" before the nodes register with the k8s API

.claude/skills/linode-ha-provisioning/scripts/destroy-lke.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ if [ -z "$CLUSTER_ID" ] && [ -n "${RUN_ID:-}" ]; then
1414
fi
1515
[ -n "$CLUSTER_ID" ] || { echo "usage: destroy-lke.sh <cluster_id> (or set RUN_ID)" >&2; exit 2; }
1616

17+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18+
19+
# Tag the cluster's volumes before deleting it: cluster-delete does not cascade to
20+
# them, and only an attached volume can be attributed. Best-effort.
21+
LINODE_TOKEN="$LINODE_TOKEN" bash "$SCRIPT_DIR/tag-lke-resources.sh" "$CLUSTER_ID" \
22+
|| echo "[pmm-ha] volume tagging skipped (non-fatal)" >&2
23+
1724
echo "[pmm-ha] Deleting LKE cluster $CLUSTER_ID"
1825
linode-cli lke cluster-delete "$CLUSTER_ID"
1926
echo "[pmm-ha] Deleted."
2027

21-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22-
2328
# Sweep the cluster's orphan tags (best-effort).
2429
PRUNE="$SCRIPT_DIR/../../../../terraform/linode-runner/prune-tags.sh"
2530
if [ -x "$PRUNE" ]; then

.claude/skills/linode-ha-provisioning/scripts/prune-lke-orphans.sh

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@
1515
# pmm-qa-run:<id> tag whose run has NO live cluster -- so a live cluster's
1616
# volumes (its run is still listed) and untagged/other-owner volumes are
1717
# never touched.
18-
# * NodeBalancers: create-lke stamps the cluster's NodeBalancer with the same
19-
# pmm-qa-run:<id> tag. Delete only one carrying that tag whose run has no live
20-
# cluster -- so another owner's NB, or a deliberately-kept migrate target, is
21-
# never touched.
22-
# If a volume was never tagged (tagging is best-effort at provision), it is left
23-
# alone -- the failure mode is a leak (recoverable by hand), never a wrong delete.
18+
# * NodeBalancers: attributed by their immutable lke<clusterid>- label, deleted
19+
# only when the cluster is gone AND no backend node is up (see the loop).
20+
# If a volume was never tagged (tagging is best-effort), it is left alone -- the
21+
# failure mode is a leak (recoverable by hand), never a wrong delete.
2422
#
2523
# Requires the token to carry Volumes and NodeBalancers Read/Write (plus LKE Read).
2624
# API-only (curl+jq); no cluster access needed.
@@ -48,6 +46,7 @@ pages() { # $1 = path (may contain ?query); prints each .data[] as compact JSON
4846

4947
clusters="$(pages /lke/clusters)"
5048
live_runs="$(printf '%s' "$clusters" | jq -r '.tags[]? | select(startswith("pmm-qa-run:"))' | sort -u)"
49+
live_cluster_ids="$(printf '%s' "$clusters" | jq -r '.id' | sort -u)"
5150

5251
vol=0 nb=0 fail=0
5352
# Volumes: ours (pmm-qa-run:<id> tag) AND run has no live cluster AND unattached.
@@ -63,20 +62,28 @@ done < <(pages /volumes | jq -r '
6362
. as $v | ($v.tags[]? | select(startswith("pmm-qa-run:"))) as $t
6463
| [$v.id, $v.label, $t, ($v.linode_id|tostring)] | @tsv')
6564

66-
# NodeBalancers: ours (pmm-qa-run:<id> tag, stamped at provision) AND run has no
67-
# live cluster. Same positive attribution as volumes -- a NodeBalancer without
68-
# our run tag (another owner's, or a deliberately-kept migrate target) is never
69-
# touched, even if its lke<id> cluster is gone.
70-
while IFS=$'\t' read -r id label run; do
65+
# NodeBalancers: the CCM strips custom tags, so attribute by the immutable
66+
# lke<clusterid>- label instead. Delete one only when its cluster is no longer
67+
# live AND no backend node is up -- the idle check guards against an NB adopted
68+
# and reused by another live cluster, which keeps the original cluster's id in
69+
# its label.
70+
while IFS=$'\t' read -r id label; do
7171
[ -n "$id" ] || continue
72-
grep -qx "$run" <<<"$live_runs" && continue
73-
if [ "$DRY" -eq 1 ]; then echo "would delete nodebalancer $id ($label) [$run]"; nb=$((nb + 1)); continue; fi
72+
cid="$(sed -n 's/^lke\([0-9]\{1,\}\)-.*/\1/p' <<<"$label")"
73+
[ -n "$cid" ] || continue # not an LKE NodeBalancer -> never ours
74+
grep -qx "$cid" <<<"$live_cluster_ids" && continue # its cluster is still live -> keep
75+
# An orphan has zero backends up once its cluster is gone; a reused NB (label
76+
# keeps the old cluster id) still serves traffic. If configs are unreadable, keep
77+
# it -- fail safe.
78+
cfg="$("${CURL[@]}" "$BASE/nodebalancers/$id/configs?page_size=100" 2>/dev/null)" \
79+
|| { echo "keeping nodebalancer $id ($label): configs unreadable" >&2; continue; }
80+
up="$(printf '%s' "$cfg" | jq '[.data[]?.nodes_status.up // 0] | add // 0')"
81+
[ "${up:-0}" -eq 0 ] || { echo "keeping nodebalancer $id ($label): $up backend(s) up (in use)" >&2; continue; }
82+
if [ "$DRY" -eq 1 ]; then echo "would delete nodebalancer $id ($label) [cluster $cid gone, idle]"; nb=$((nb + 1)); continue; fi
7483
if "${CURL[@]}" -o /dev/null -X DELETE "$BASE/nodebalancers/$id"; then
75-
echo "deleted nodebalancer $id ($label) [$run]"; nb=$((nb + 1))
84+
echo "deleted nodebalancer $id ($label) [cluster $cid gone, idle]"; nb=$((nb + 1))
7685
else echo "FAILED nodebalancer $id ($label)" >&2; fail=$((fail + 1)); fi
77-
done < <(pages /nodebalancers | jq -r '
78-
. as $n | ($n.tags[]? | select(startswith("pmm-qa-run:"))) as $t
79-
| [$n.id, $n.label, $t] | @tsv')
86+
done < <(pages /nodebalancers | jq -r '[.id, .label] | @tsv')
8087

8188
echo "prune-lke-orphans: volumes=$vol nodebalancers=$nb failed=$fail$([ "$DRY" -eq 1 ] && echo ' (dry-run)')"
8289
[ "$fail" -eq 0 ]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/usr/bin/env bash
2+
# Tag an LKE cluster's attached Block Storage volumes with its pmm-qa-run:<id> tag
3+
# so prune-lke-orphans.sh can attribute and delete them once the cluster is gone.
4+
# Must run while the cluster's nodes still exist -- cluster-delete does not cascade
5+
# to the volumes, and only an attached volume can be linked back to the cluster
6+
# here (a volume unattached at call time is not tagged). NodeBalancers are not
7+
# tagged: the CCM reconciles their tags away; the sweep attributes them by their
8+
# lke<id> label instead. Best-effort and API-only; never fails its caller.
9+
# LINODE_TOKEN=... tag-lke-resources.sh <cluster_id> [run_id]
10+
set -euo pipefail
11+
: "${LINODE_TOKEN:?LINODE_TOKEN must be set}"
12+
CID="${1:?usage: tag-lke-resources.sh <cluster_id> [run_id]}"
13+
RUN="${2:-}"
14+
15+
BASE="https://api.linode.com/v4"
16+
CURL=(curl -fsS --connect-timeout 10 --max-time 30 -H "Authorization: Bearer $LINODE_TOKEN")
17+
18+
# Resolve the run id from the cluster's own pmm-qa-run tag (or its label) when the
19+
# caller did not pass one -- destroy-lke / the reaper only know the cluster id.
20+
if [ -z "$RUN" ]; then
21+
cluster="$("${CURL[@]}" "$BASE/lke/clusters/$CID" 2>/dev/null || true)"
22+
RUN="$(printf '%s' "$cluster" | jq -r '.tags[]? | select(startswith("pmm-qa-run:")) | sub("^pmm-qa-run:";"")' 2>/dev/null | head -1)"
23+
[ -n "$RUN" ] || RUN="$(printf '%s' "$cluster" | jq -r '.label // empty' 2>/dev/null | sed 's/^pmm-ha-//')"
24+
fi
25+
[ -n "$RUN" ] || { echo "tag-lke-resources: could not resolve a run id for cluster $CID (skipping)" >&2; exit 0; }
26+
27+
ids="$("${CURL[@]}" "$BASE/lke/clusters/$CID/pools?page_size=500" 2>/dev/null \
28+
| jq -r '.data[].nodes[].instance_id' 2>/dev/null | grep -vx null || true)"
29+
30+
# All volumes as JSONL, across every page -- the account can hold more than one.
31+
vols=""
32+
page=1 total=1
33+
while [ "$page" -le "$total" ]; do
34+
resp="$("${CURL[@]}" "$BASE/volumes?page=$page&page_size=500" 2>/dev/null || true)"
35+
[ -n "$resp" ] || break
36+
vols+="$(printf '%s' "$resp" | jq -c '.data[]')"$'\n'
37+
total="$(printf '%s' "$resp" | jq -r '.pages // 1')"
38+
page=$((page + 1))
39+
done
40+
41+
n=0
42+
for lid in $ids; do
43+
while IFS= read -r vjson; do
44+
[ -n "$vjson" ] || continue
45+
vid="$(printf '%s' "$vjson" | jq -r '.id')"
46+
# Merge our tags with the volume's existing ones (PUT replaces the whole list),
47+
# so unrelated tags are preserved.
48+
tags="$(printf '%s' "$vjson" | jq -c --arg r "pmm-qa-run:$RUN" '((.tags // []) + ["pmm-qa-ephemeral", $r]) | unique')"
49+
"${CURL[@]}" -o /dev/null -X PUT -H "Content-Type: application/json" -d "{\"tags\":$tags}" \
50+
"$BASE/volumes/$vid" && n=$((n + 1)) || echo "tag-lke-resources: tag failed for volume $vid (non-fatal)" >&2
51+
done < <(printf '%s\n' "$vols" | jq -c --argjson l "$lid" 'select(.linode_id==$l)')
52+
done
53+
echo "tag-lke-resources: cluster $CID run=$RUN tagged $n volume(s)"

0 commit comments

Comments
 (0)