hack/seaweedfs-naming-audit.sh
Rama main · réplica de lectura del repositorio original.
#!/bin/sh
# SeaweedFS 4.31 rename — fleet audit (READ-ONLY).
#
# Classifies every SeaweedFS tenant into the three states the chart's naming guard
# (packages/system/seaweedfs/templates/naming-guard.yaml) decides between:
#
# L exactly the chart-named generation -> the upgrade adopts it. Nothing to do.
# S exactly the release-named generation -> re-bind first (runbook Step 2).
# MIXED both generations -> the chart REFUSES; an operator must remove the empty one.
#
# For MIXED it also reports which generation is ORIGINAL, from durable evidence:
#
# sh.helm.release.v1.<name>-system.v1 — the manifest of revision 1 names either
# seaweedfs-master (born pre-4.31) or <release>-master (born 4.31). Exact, but
# revision 1 may have been pruned by Helm's history limit.
# PersistentVolume creation timestamps — the original generation's PVs predate the
# duplicate's, which were provisioned later by the bad upgrade. Compared only
# against each other (see the relative rule below); info.first_deployed is
# printed as context but decides nothing.
#
# PV timestamps, not PVC ones: runbook Step 2 deletes each release-named claim and
# recreates it under the chart name against the SAME PV, so claim age is not durable
# and inverts for a tenant interrupted mid-re-bind. The PV survives and keeps its
# creationTimestamp.
#
# Direction is decided by a RELATIVE rule, never a clock window: a generation is
# the candidate duplicate only when EVERY one of its bound PVs is strictly newer
# than every bound PV of the other generation — the same precondition runbook
# Step 2a enforces before deleting anything. Overlapping vintages mean an
# interrupted Step 2 re-bind (both generations on original PVs) or interleaved
# provisioning, and the audit refuses to name a candidate. An absolute window
# measured from first_deployed was tried first and is unsound: the shipped
# StorageClasses are WaitForFirstConsumer, so PVs appear at pod-SCHEDULE time,
# and ordinary cold-cluster latency puts even the original generation's PVs
# minutes past first_deployed — which pushed a mid-rebind tenant into the branch
# whose advice deletes the un-re-bound claims.
#
# IMPORTANT — what this does NOT tell you. "Which generation is original" is not
# "the other one is empty". A duplicate that never scheduled (safe to delete) and one
# that served writes and later crashed (holds unique objects) are identical on every
# durable signal — same revision-1 scheme, same first_deployed deltas. Establishing
# emptiness needs the duplicate's volume files and the filer's volume list, which this
# script does not inspect. It narrows the question; it does not answer it.
#
# Usage: hack/seaweedfs-naming-audit.sh [namespace...]
# KUBECONFIG=<file> hack/seaweedfs-naming-audit.sh
# Exit: 0 the audit RAN TO COMPLETION; the table is the whole answer
# (an empty table then means a genuinely clean fleet).
# non-zero a kubectl query FAILED, so the audit is INCOMPLETE and the
# table must NOT be trusted -- a message naming the failed query
# is on stderr. This is fail-CLOSED by design (see run_kubectl):
# the output gates a destructive runbook step, so an unreachable
# API aborts loudly rather than printing an empty table that
# reads identically to "nothing to do".
# Nothing is ever mutated on either path -- every kubectl call is read-only.
#
# POSIX sh, deliberately. hack/seaweedfs-naming-audit.bats sources the pure
# helpers under Bats, then drives the executable path repeatedly with explicit
# `sh hack/seaweedfs-naming-audit.sh` subprocesses. Those subprocess assertions,
# plus the narrow cozytest.sh compatibility pass in the root Makefile, keep the
# tested shell and the executed shell aligned; retain at least one explicit sh
# invocation if this suite is refactored. A bash shebang would not save sourced
# helpers: `.` runs the file's contents in the caller's shell and the shebang is
# just a comment. `pipefail` in particular is not POSIX and dash 0.5.12 (Ubuntu)
# rejects it outright.
set -u
# run_kubectl <what> <kubectl-args...> -- run ONE read-only kubectl query
# fail-CLOSED and print its stdout. This audit is the documented gate in front of
# a destructive runbook step (deleting a tenant's PVCs), so a query that FAILS
# must never be mistaken for a query that found nothing: an enumerating LIST
# returns empty stdout on a timeout or an RBAC denial, byte-identical to the same
# LIST succeeding on a genuinely clean fleet, and the old `2>/dev/null` on each
# call turned an unreachable API into a silent false "nothing to do". Every
# enumeration is routed through here so any failure is loud and terminal instead.
#
# On success prints kubectl's stdout verbatim -- which may legitimately be empty,
# because a LIST that matched nothing is honestly clean; keeping that case
# distinct from failure is the whole point. On a non-zero exit prints the failed
# query to stderr and returns that exit code; kubectl's own stderr stays on fd 2
# and reaches the operator unfiltered. Callers MUST propagate the non-zero status
# (`|| return`/`|| exit`) -- a bare `for x in $(run_kubectl ...)` would swallow
# it, because a for-loop ignores the exit status of its word-list command, and an
# `exit` inside the `$(...)` subshell would only leave that subshell.
run_kubectl() {
_rk_what="$1"; shift
if _rk_out=$(kubectl "$@"); then
printf '%s' "$_rk_out"
return 0
else
_rk_rc=$?
printf 'seaweedfs-naming-audit: FATAL: %s failed (kubectl %s -> exit %s). Refusing to report a clean fleet on an unreachable API.\n' \
"$_rk_what" "$*" "$_rk_rc" >&2
return "$_rk_rc"
fi
}
# audit_fatal <message> -- the counterpart to run_kubectl for corruption the API
# call itself did NOT report: the query succeeded, but what it returned is
# impossible for a healthy object (a helm.sh/release.v1 Secret with no decodable
# release payload). Same fail-CLOSED contract -- print a FATAL line naming the
# object to stderr and return non-zero; callers MUST propagate it. A silent skip
# here would be the identical false-clean this whole script guards against, just
# one field deeper than an unreachable API.
audit_fatal() {
printf 'seaweedfs-naming-audit: FATAL: %s Refusing to report a clean fleet on corrupt state.\n' "$1" >&2
return 1
}
# renamed_volume_prefix <release> -- reconstruct the name 4.31 gives the volume
# component of <release>. Mirrors seaweedfs.fullname + seaweedfs.componentName
# (see packages/system/seaweedfs/templates/_naming.tpl, which the chart's guard
# uses for exactly the same purpose): the fullname gains -seaweedfs when the
# release name does not already contain it, is capped at 63, and componentName
# then cuts it to (62 - len("volume")) = 56 before appending -volume.
renamed_volume_prefix() {
release="$1"
case "$release" in
*seaweedfs*) full="$release" ;;
*) full="${release}-seaweedfs" ;;
esac
full=$(printf '%s' "$full" | cut -c1-63 | sed 's/-$//')
printf '%s-volume' "$(printf '%s' "$full" | cut -c1-56 | sed 's/-$//')"
}
# release_json <ns> <release> [rev] -- decode a Helm release secret's payload, or
# "" when that revision's secret does not exist. Helm stores base64(gzip(json)) in
# Secret.data.release, and Kubernetes base64s the data value again, hence the two
# decodes.
#
# This is a by-NAME GET, but it is NOT best-effort: system_releases decides whether
# a release IS SeaweedFS from the result, so a swallowed failure silently drops a
# real tenant -- the false-clean this script exists to prevent. EXISTENCE and
# EXTRACTION are therefore two separate questions, because `--ignore-not-found
# -o jsonpath` cannot tell them apart (it returns empty + exit 0 both for an absent
# Secret AND for a present Secret whose .data.release is missing):
#
# 1. Existence, via `--ignore-not-found -o name`. Empty + exit 0 = the revision
# was pruned by Helm's history limit -- a legitimate absence; return "" and
# let rev1_scheme / first_deployed treat it as "pruned". A real error (RBAC,
# timeout, apiserver down) stays non-zero and run_kubectl makes it fatal.
# 2. The Secret EXISTS, so its payload MUST decode. A helm.sh/release.v1 Secret
# with no .data.release, a payload that is not base64(base64(gzip(...))), or an
# empty JSON after decoding is CORRUPT state, not an absence -- and here that
# is safety-critical, so every such anomaly is audit_fatal, never a silent skip
# (contrast pv_epoch, where an empty field is a benign "no evidence").
#
# The extraction GET drops --ignore-not-found deliberately: the Secret existed a
# moment ago, so a NotFound now is a mid-audit deletion race, and failing closed on
# it is correct.
release_json() {
_rj_secret="sh.helm.release.v1.$2.v${3:-1}"
_rj_exists=$(run_kubectl "check Helm release secret '$_rj_secret' in namespace '$1'" \
get secret -n "$1" "$_rj_secret" --ignore-not-found -o name) || return $?
[ -n "$_rj_exists" ] || return 0
_rj_field=$(run_kubectl "read .data.release of secret '$_rj_secret' in namespace '$1'" \
get secret -n "$1" "$_rj_secret" -o jsonpath='{.data.release}') || return $?
[ -n "$_rj_field" ] || { audit_fatal "secret '$_rj_secret' in namespace '$1' exists but has no .data.release payload (corrupt Helm release)."; return 1; }
_rj_json=$(printf '%s' "$_rj_field" | base64 -d 2>/dev/null | base64 -d 2>/dev/null | gunzip 2>/dev/null) \
|| { audit_fatal "secret '$_rj_secret' in namespace '$1': .data.release is not decodable base64(base64(gzip(json))) (corrupt Helm release)."; return 1; }
[ -n "$_rj_json" ] || { audit_fatal "secret '$_rj_secret' in namespace '$1': release payload decoded to nothing (corrupt Helm release)."; return 1; }
printf '%s' "$_rj_json"
}
# revisions <ns> <release> -- retained revision numbers, oldest first.
revisions() {
_rev_out=$(run_kubectl "list Helm revision secrets for '$2' in namespace '$1'" \
get secret -n "$1" -l "name=$2,owner=helm" \
-o jsonpath='{range .items[*]}{.metadata.labels.version}{"\n"}{end}') || return $?
printf '%s\n' "$_rev_out" | sort -n
}
# system_releases <ns> -- the SeaweedFS <name>-system Helm releases in a namespace.
# Filtering on the `-system` suffix alone is not enough: every Cozystack app has a
# <name>-system release (ingress-nginx-system, bucket-*-system, ...), and since the
# generation scan below matches PVCs by NAME across the whole namespace, an
# unrelated release in a namespace that happens to run SeaweedFS would be reported
# as a SeaweedFS tenant. Confirm the chart.
system_releases() {
_sr_secrets=$(run_kubectl "list Helm release secrets in namespace '$1'" \
get secret -n "$1" \
-o jsonpath='{range .items[?(@.type=="helm.sh/release.v1")]}{.metadata.labels.name}{"\n"}{end}') || return $?
for rel in $(printf '%s\n' "$_sr_secrets" | grep -E -- '-system$' | sort -u); do
_sr_revs=$(revisions "$1" "$rel") || return $?
for rev in $_sr_revs; do
_sr_json=$(release_json "$1" "$rel" "$rev") || return $?
# Empty "" here means the revision secret was pruned (release_json's absence
# path) -- a legitimate skip. A NON-empty payload, however, was fetched and
# decoded successfully, so it MUST name its chart: a helm.sh/release.v1 release
# always carries chart.metadata.name. A payload without one ({} , or any valid
# JSON lacking it) is corrupt/unexpected, and since "no chart name" is
# indistinguishable downstream from "not SeaweedFS", silently skipping it is
# the very false-clean this guard exists to stop -- so it is FATAL. A present
# but different chart name is a real, non-SeaweedFS release and stays a
# legitimate skip, filtered exactly as before by the cozy-seaweedfs test.
#
# Two properties make this match trustworthy, and both are load-bearing now
# that a miss is FATAL:
#
# * FIRST match, not last. Helm's Release marshals "chart" before
# "config" (the user's values), so a values subtree that happens to
# spell chart.metadata.name sits LATER in the payload -- and a greedy
# `sed 's/.*"chart"...'` would return that decoy instead of the real
# chart. A wrong name reads downstream as "not SeaweedFS" and drops a
# real release from the report: the exact silent false-clean this guard
# exists to stop. `grep -o | head -1` takes the leftmost match.
# * The chart -> metadata -> name key path stays ADJACENT. A looser "any
# 'name' after 'metadata'" matches chart.templates[].name, which Helm
# serializes immediately after metadata on EVERY healthy release, so it
# would return a template path for every tenant.
#
# Newlines are folded first so a pretty-printed payload parses at all (sed
# and grep are line-based), and whitespace around the punctuation is
# tolerated. Failing loudly on a payload this cannot read is the safe
# direction -- over-strictness stops the runbook, over-looseness lets it
# delete data -- so the message says what shape was expected.
if [ -n "$_sr_json" ]; then
_sr_chart=$(printf '%s' "$_sr_json" | tr '\n' ' ' \
| grep -o '"chart"[[:space:]]*:[[:space:]]*{[[:space:]]*"metadata"[[:space:]]*:[[:space:]]*{[[:space:]]*"name"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 \
| sed -n 's/.*"\([^"]*\)"$/\1/p')
[ -n "$_sr_chart" ] || { audit_fatal "could not read the chart name of secret 'sh.helm.release.v1.$rel.v$rev' in namespace '$1' at .chart.metadata.name (expected 'name' as metadata's first key; a corrupt Helm release, or a payload format this parser does not handle)."; return 1; }
if [ "$_sr_chart" = cozy-seaweedfs ]; then printf '%s\n' "$rel"; fi
fi
break
done
done
}
# parse_epoch <rfc3339> -- an RFC3339 timestamp -> epoch seconds, portably across
# GNU and BSD date. GNU spells this `date -d`, BSD spells it `date -j -f <fmt>`,
# and each REJECTS the other's flag, so both are attempted (each with its own
# stderr muted) and the first that yields an epoch wins. Only the leading 19
# chars are fed in -- "YYYY-MM-DDTHH:MM:SS", dropping any fractional seconds and
# the zone suffix -- which both accept and which the callers compare purely
# against each other, never against a wall clock.
#
# The value passed here is always NON-EMPTY: an absent field is a benign "no
# evidence" case the callers settle BEFORE reaching this. So a value that NEITHER
# form can read is not absent evidence -- it is a clock the host could not read: a
# `date` with no usable form on PATH, or a value no healthy apiserver / Helm
# payload emits. Folding that into the empty-field "direction cannot be
# established" branch is exactly issue #4273 -- a host-tool failure printed as a
# statement about the cluster, on every tenant, on any host without GNU date. So
# it is FATAL and names the value, kept loud and distinct from the silent
# absent-evidence path by construction. The muted stderr above hides only the
# expected "illegal option" from the wrong spelling; a genuine total failure is
# reported here, not swallowed.
parse_epoch() {
_pe_in=$(printf '%s' "$1" | cut -c1-19)
if _pe_out=$(date -u -d "$_pe_in" +%s 2>/dev/null); then printf '%s' "$_pe_out"; return 0; fi
if _pe_out=$(date -u -j -f '%Y-%m-%dT%H:%M:%S' "$_pe_in" +%s 2>/dev/null); then printf '%s' "$_pe_out"; return 0; fi
audit_fatal "could not parse timestamp '$1' as epoch seconds with either GNU (date -d) or BSD (date -j -f) date. The host has no usable date on PATH, or the value is not an RFC3339 timestamp."
return 1
}
# first_deployed <ns> <release> -- epoch seconds of the release's first install,
# from any retained revision (the field is identical on all of them).
first_deployed() {
_fd_revs=$(revisions "$1" "$2") || return $?
for rev in $_fd_revs; do
_fd_json=$(release_json "$1" "$2" "$rev") || return $?
# Same extraction discipline as the chart name above: fold newlines so a
# pretty-printed payload parses at all, tolerate whitespace around the
# punctuation, and take the FIRST match (.info precedes .config, so a values
# subtree cannot shadow the real timestamp). An ABSENT first_deployed is not
# fatal -- the loop simply falls through and the caller degrades to the
# documented safe fallback -- but a WRONG timestamp would silently change a
# vintage verdict, which is worse than none. A PRESENT-but-unparseable one is
# handled by parse_epoch, which fails loudly rather than degrading (#4273):
# the two are different, an absent field being no evidence and an unreadable
# field being a clock the host could not read.
ts=$(printf '%s' "$_fd_json" | tr '\n' ' ' \
| grep -o '"first_deployed"[[:space:]]*:[[:space:]]*"[^"]*"' \
| head -1 \
| sed -n 's/.*"\([^"]*\)"$/\1/p')
if [ -n "$ts" ]; then parse_epoch "$ts"; return; fi
done
}
# rev1_scheme <ns> <release> -- "legacy" | "renamed" | "" (revision 1 pruned).
rev1_scheme() {
m=$(release_json "$1" "$2" 1) || return $?
[ -n "$m" ] || return 0
if printf '%s' "$m" | grep -q 'name: seaweedfs-master'; then printf 'legacy'
elif printf '%s' "$m" | grep -qE "name: $2(-seaweedfs)?-master"; then printf 'renamed'
fi
}
# pv_epoch <ns> <pvc> -- creation epoch of the PV the claim is BOUND to, or "" when
# there is no PV age to read (an unbound claim, or a NotFound PVC/PV). A PV age
# that is PRESENT but unreadable is not this case -- it is fatal (see below). Two
# by-NAME GETs.
#
# A real API error must abort (an epoch silently missing from a range can invert
# the strict-newer comparison and name the WRONG deletion candidate), so both GETs
# go through run_kubectl. But UNLIKE release_json, an EMPTY field here is NOT
# corruption and is NOT fatal -- because the consequence differs. When release_json
# returns empty for a present Secret the tenant is dropped from the report: a
# false-clean. When pv_epoch returns "" the claim merely contributes no epoch,
# which marks its whole generation INCOMPLETE (see audit_ns) and forces the safe
# "direction cannot be established -> classify by hand" branch. That degradation is
# conservative by construction: it never yields a false-clean and never names a
# candidate. So the two empty-field cases are deliberately kept benign:
# * PVC .spec.volumeName empty -- a Pending / unbound claim, a routine state;
# * PV .metadata.creationTimestamp empty -- shouldn't happen (the apiserver always
# stamps it), but if it ever did the only effect is one missing epoch, i.e. the
# same safe incomplete fallback, so a hard stop is not warranted here.
# `--ignore-not-found` keeps a genuinely-absent PVC/PV (NotFound) on that same
# benign path rather than turning it into a run_kubectl failure. A timestamp that
# is PRESENT but unparseable is deliberately NOT on that path: parse_epoch makes
# it FATAL (issue #4273), because a host whose `date` cannot read the clock is not
# the same as a claim that carries no evidence, and the old `2>/dev/null || return
# 0` collapsed the two -- reporting every tenant "direction cannot be established"
# on any host without GNU date. Only a real run_kubectl error, or now an
# unreadable-but-present timestamp, propagates; the two empty-field cases above
# stay benign.
pv_epoch() {
pv=$(run_kubectl "get PVC '$2' in namespace '$1'" \
get pvc -n "$1" "$2" --ignore-not-found -o jsonpath='{.spec.volumeName}') || return $?
[ -n "$pv" ] || return 0
t=$(run_kubectl "get PV '$pv' (bound by PVC '$2' in namespace '$1')" \
get pv "$pv" --ignore-not-found -o jsonpath='{.metadata.creationTimestamp}') || return $?
[ -n "$t" ] || return 0
parse_epoch "$t"
}
# classify_mixed_direction <lmin> <lmax> <rmin> <rmax> -- direction of a MIXED
# tenant, from the bound-PV creation-epoch ranges of the chart-named (l*) and
# release-named (r*) generations. Prints one of:
#
# legacy-original every release-named PV strictly newer -> it is the candidate
# duplicate (runbook Step 3)
# renamed-original every chart-named PV strictly newer -> S-damaged, the
# chart-named set is the candidate duplicate (Step 2a, then 2)
# overlap vintages interleave or touch -> no candidate. An interrupted
# Step 2 re-bind puts BOTH generations on original-vintage PVs.
#
# Purely relative — no clock, no window, no first_deployed. Ties (second-resolution
# timestamps) count as overlap: refusing a candidate is recoverable, naming the
# wrong one is not.
classify_mixed_direction() {
if [ "$3" -gt "$2" ]; then printf 'legacy-original'
elif [ "$1" -gt "$4" ]; then printf 'renamed-original'
else printf 'overlap'
fi
}
audit_ns() {
ns="$1"
_rels=$(system_releases "$ns") || return $?
for rel in $_rels; do
prefix=$(renamed_volume_prefix "$rel")
legacy_pvcs=""; renamed_pvcs=""
_pvcs=$(run_kubectl "list PVCs in namespace '$ns'" get pvc -n "$ns" -o name) || return $?
for pvc in $(printf '%s\n' "$_pvcs" | sed 's|persistentvolumeclaim/||'); do
case "$pvc" in
"data1-${prefix}"*) renamed_pvcs="$renamed_pvcs $pvc" ;;
data1-seaweedfs-volume*) legacy_pvcs="$legacy_pvcs $pvc" ;;
esac
done
legacy_sts=""; renamed_sts=""
_sts_list=$(run_kubectl "list SeaweedFS StatefulSets in namespace '$ns'" \
get sts -n "$ns" -l app.kubernetes.io/name=seaweedfs -o name) || return $?
for sts in $(printf '%s\n' "$_sts_list" | sed 's|statefulset.apps/||'); do
case "$sts" in
"${prefix}"*) renamed_sts="$renamed_sts $sts" ;;
seaweedfs-volume*) legacy_sts="$legacy_sts $sts" ;;
esac
done
has_legacy=0; has_renamed=0
[ -n "$legacy_pvcs$legacy_sts" ] && has_legacy=1
[ -n "$renamed_pvcs$renamed_sts" ] && has_renamed=1
[ "$has_legacy" = 0 ] && [ "$has_renamed" = 0 ] && continue
if [ "$has_legacy" = 1 ] && [ "$has_renamed" = 0 ]; then
printf '%-24s %-14s %-8s %s\n' "$ns" "$rel" "L" "chart-named only; the upgrade adopts it, nothing to do"
continue
fi
if [ "$has_renamed" = 1 ] && [ "$has_legacy" = 0 ]; then
printf '%-24s %-14s %-8s %s\n' "$ns" "$rel" "S" "release-named only; re-bind the volumes (Step 2) BEFORE upgrading"
continue
fi
# Both generations. Report which is ORIGINAL from durable evidence.
fd=$(first_deployed "$ns" "$rel") || return $?
scheme=$(rev1_scheme "$ns" "$rel") || return $?
printf '%-24s %-14s %-8s %s\n' "$ns" "$rel" "MIXED" "both generations; the chart REFUSES until one is removed"
if [ -n "$scheme" ]; then
printf '%-24s %-14s %-8s revision 1 was installed with the %s names => the %s generation is ORIGINAL\n' \
"" "" "" "$scheme" "$scheme"
fi
# The direction rule is "EVERY PV of one generation strictly newer than every
# PV of the other". That holds only if we saw EVERY bound PV: a claim whose PV
# age we could not read (unbound, or PV gone) leaves the range unbounded on one
# side, and a silently-narrowed range can flip OVERLAP into a confident (wrong)
# candidate. So a generation with any unreadable claim is INCOMPLETE
# and forces the safe "cannot establish" branch -- distinct from a real API
# error, which pv_epoch has already turned into a hard failure above.
lmin=""; lmax=""; rmin=""; rmax=""; l_incomplete=0; r_incomplete=0
for p in $legacy_pvcs; do
e=$(pv_epoch "$ns" "$p") || return $?
if [ -z "$e" ]; then l_incomplete=1; continue; fi
{ [ -z "$lmin" ] || [ "$e" -lt "$lmin" ]; } && lmin=$e
{ [ -z "$lmax" ] || [ "$e" -gt "$lmax" ]; } && lmax=$e
done
for p in $renamed_pvcs; do
e=$(pv_epoch "$ns" "$p") || return $?
if [ -z "$e" ]; then r_incomplete=1; continue; fi
{ [ -z "$rmin" ] || [ "$e" -lt "$rmin" ]; } && rmin=$e
{ [ -z "$rmax" ] || [ "$e" -gt "$rmax" ]; } && rmax=$e
done
if [ -n "$fd" ] && [ -n "$lmin" ] && [ -n "$rmin" ]; then
printf '%-24s %-14s %-8s oldest PV vs first_deployed: chart-named +%ss, release-named +%ss (context only, not the rule)\n' \
"" "" "" "$((lmin - fd))" "$((rmin - fd))"
fi
if [ -n "$lmin" ] && [ -n "$rmin" ] && [ "$l_incomplete" = 0 ] && [ "$r_incomplete" = 0 ]; then
case $(classify_mixed_direction "$lmin" "$lmax" "$rmin" "$rmax") in
legacy-original)
printf '%-24s %-14s %-8s every release-named PV is strictly newer => chart-named is ORIGINAL, the release-named set is the candidate duplicate (Step 3)\n' "" "" "" ;;
renamed-original)
printf '%-24s %-14s %-8s every chart-named PV is strictly newer => release-named is ORIGINAL, the chart-named set is the candidate duplicate (Step 2a, then Step 2)\n' "" "" "" ;;
overlap)
printf '%-24s %-14s %-8s PV vintages OVERLAP => no candidate. An interrupted Step 2 re-bind looks exactly like this (both generations on original PVs). Finish Step 2 if one is in progress; otherwise escalate. Do NOT run Step 2a.\n' "" "" "" ;;
esac
else
printf '%-24s %-14s %-8s direction cannot be established from PV ages: a generation has no bound PVs (Pending/unbound claims, or StatefulSets only) or a bound PV age could not be read. Resolve those claims or classify by hand. Do NOT run Step 2a.\n' "" "" ""
fi
printf '%-24s %-14s %-8s CANDIDATE ONLY: "original" does not mean the other set is EMPTY. A duplicate that\n' "" "" ""
printf '%-24s %-14s %-8s served writes and later crashed looks identical here. Verify emptiness before deleting.\n' "" "" ""
done
}
main() {
printf '%-24s %-14s %-8s %s\n' NAMESPACE RELEASE CLASS NOTE
printf '%-24s %-14s %-8s %s\n' --------- ------- ----- ----
if [ "$#" -gt 0 ]; then
for ns in "$@"; do audit_ns "$ns" || exit $?; done
else
_ns_list=$(run_kubectl 'list namespaces (whole-cluster mode)' get ns -o name) || exit $?
for ns in $(printf '%s\n' "$_ns_list" | sed 's|namespace/||'); do audit_ns "$ns" || exit $?; done
fi
}
# Sourced by hack/seaweedfs-naming-audit.bats to exercise the classifier directly.
[ "${SEAWEEDFS_AUDIT_LIB:-0}" = "1" ] || main "$@"