mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
Merge commit '7508fc5edf68855b1c089fd92f1c5ccd05a16966' into maintainer-async/20261026T194603Z-delivery-trust/pulse
Change-source: pulse-maintainer
This commit is contained in:
@@ -759,6 +759,41 @@ Unix real-agent controls keep their independent two-second watchdog, with
|
||||
process-group cancellation and separate build/probe timings. These source
|
||||
fixtures do not establish signed native installation, persistence or recovery.
|
||||
|
||||
### Legacy credential cleanup needs complete inventory and preserved keys
|
||||
|
||||
Cluster cleanup must read the complete successful `pvesh` JSON response, validate
|
||||
all node identities and resolve every address before changing any SSH key file.
|
||||
Python receives its program through `-c`, not the response's stdin. Missing tools,
|
||||
empty/malformed inventory, failed reads or unresolved nodes return nonzero; they
|
||||
must not silently select local-only cleanup. An explicit `--local-only` remains
|
||||
available on each node without cluster discovery or SSH. Local keys are cleaned
|
||||
once and duplicate remote addresses once, with strict SSH host verification intact.
|
||||
|
||||
Local key replacement uses an adjacent temporary file and preserves the original
|
||||
on read/comparison/metadata/rename failure. A no-match grep result is distinct
|
||||
from a read failure; removing an all-managed file may legitimately produce an
|
||||
empty file. Only the two existing Pulse comment suffixes are eligible, and
|
||||
unrelated keys and file metadata remain intact. Conditional helper invocation
|
||||
must propagate failure rather than print a successful key-removal claim.
|
||||
|
||||
Opted-in API cleanup likewise requires a complete valid JSON token inventory.
|
||||
It does not reinterpret a failed or malformed response as empty or fall back to
|
||||
a guessed table. User deletion follows only successful removal of every listed
|
||||
token; unavailable tools and token/user removal failures remain nonzero through
|
||||
main, including when the caller disables shell errexit. Normal local service
|
||||
reload still runs after file cleanup. No new access is acquired or trust enrolled.
|
||||
|
||||
`TestUninstallSensorProxyClusterInventoryUsesEveryValidatedNode`,
|
||||
`TestUninstallSensorProxyIncompleteInventoryNeverStartsKeyCleanup`,
|
||||
`TestUninstallSensorProxyMissingInventoryToolRequiresExplicitLocalOnly`,
|
||||
`TestUninstallSensorProxyKeyReplacementPreservesOriginalOnEveryFailure`,
|
||||
`TestUninstallSensorProxyLocalOnlyPropagatesKeyFailure`,
|
||||
`TestUninstallSensorProxyAPICleanupRequiresCompleteInventoryAndRemovals` and
|
||||
`TestUninstallSensorProxyMainCannotHideAPIRemovalFailure` execute closed shell
|
||||
fixtures through the repository's Go suite. Existing SSH trust and container
|
||||
state tests remain necessary. These are executable source/producer checks, not
|
||||
native Proxmox cleanup, installed recovery or reporter acceptance.
|
||||
|
||||
### Legacy sensor cleanup requires observed stopped targets
|
||||
|
||||
The legacy sensor uninstaller must confirm each existing unit has stopped and
|
||||
|
||||
@@ -252,7 +252,7 @@ main --quiet
|
||||
if !ok || exitErr.ExitCode() != 23 {
|
||||
t.Fatalf("main returned %v, want remote cleanup status 23:\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "Local cleanup completed") {
|
||||
if !strings.Contains(string(out), "Local file cleanup completed") {
|
||||
t.Fatalf("remote trust failure did not explain local cleanup state:\n%s", out)
|
||||
}
|
||||
content, readErr := os.ReadFile(marker)
|
||||
@@ -309,7 +309,7 @@ func TestUninstallSensorProxyScriptRemovesTempFootprintAndManagedKeys(t *testing
|
||||
t.Fatalf("write authorized_keys: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bash", repoFile("scripts", "uninstall-sensor-proxy.sh"), "--uninstall", "--purge", "--quiet")
|
||||
cmd := exec.Command("bash", repoFile("scripts", "uninstall-sensor-proxy.sh"), "--uninstall", "--purge", "--quiet", "--local-only")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"PULSE_SENSOR_PROXY_BINARY_PATH="+binaryPath,
|
||||
@@ -345,3 +345,246 @@ func TestUninstallSensorProxyScriptRemovesTempFootprintAndManagedKeys(t *testing
|
||||
t.Fatalf("non-Pulse SSH key was not preserved:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only closed API/resolver/key-writer doubles are used. No live pvesh, pveum,
|
||||
// SSH connection, privileged path or container lifecycle is reached here.
|
||||
const proxyInventoryFixture = `source "$1"
|
||||
pvesh() {
|
||||
[[ "$*" == 'get /nodes --output-format json' ]] || return 90
|
||||
cat "$FIXTURE_DIR/inventory"
|
||||
[[ "$SCENARIO" != read_failure ]]
|
||||
}
|
||||
getent() {
|
||||
[[ "$1" == ahostsv4 || "$1" == hosts ]] || return 90
|
||||
case "$2" in
|
||||
pve-a) echo '192.0.2.10 STREAM pve-a' ;;
|
||||
pve-b)
|
||||
[[ "$SCENARIO" != unresolved ]] || return 2
|
||||
if [[ "$SCENARIO" == resolver_failure ]]; then echo '192.0.2.11 STREAM pve-b'; return 2; fi
|
||||
if [[ "$SCENARIO" == invalid_address ]]; then echo 'not-an-address pve-b'; else echo '192.0.2.11 STREAM pve-b'; fi ;;
|
||||
pve-v6) [[ "$1" == hosts ]] || return 2; echo '2001:db8::12 pve-v6' ;;
|
||||
*) return 2 ;;
|
||||
esac
|
||||
}
|
||||
is_local_host() { [[ "$1" == 192.0.2.10 ]]; }
|
||||
cleanup_local_authorized_keys() { printf 'local\n' >>"$FIXTURE_DIR/actions"; }
|
||||
cleanup_remote_authorized_keys() { printf 'remote %s\n' "$1" >>"$FIXTURE_DIR/actions"; }
|
||||
if cleanup_cluster_authorized_keys; then exit 0; else exit $?; fi
|
||||
`
|
||||
|
||||
func TestUninstallSensorProxyClusterInventoryUsesEveryValidatedNode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "inventory"), []byte(`[{"node":"pve-b"},{"node":"pve-a"},{"node":"pve-b"},{"node":"pve-v6"}]`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, "success", proxyInventoryFixture)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, out)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "actions"))
|
||||
if err != nil || string(got) != "local\nremote 192.0.2.11\nremote 2001:db8::12\n" {
|
||||
t.Fatalf("incomplete or duplicated cleanup: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyIncompleteInventoryNeverStartsKeyCleanup(t *testing.T) {
|
||||
for _, tc := range []struct{ name, inventory string }{
|
||||
{"empty", `[]`}, {"object", `{}`}, {"null", `null`}, {"malformed", `[{`},
|
||||
{"missing", `[{"node":"pve-a"},{}]`}, {"wrong_type", `[{"node":7}]`},
|
||||
{"unsafe", `[{"node":"pve-a"},{"node":"-oProxyCommand=bad"}]`},
|
||||
{"multiline", `[{"node":"pve-a\npve-b"}]`}, {"non_object", `["pve-a"]`},
|
||||
{"read_failure", `[{"node":"pve-a"}]`},
|
||||
{"unresolved", `[{"node":"pve-a"},{"node":"pve-b"}]`},
|
||||
{"invalid_address", `[{"node":"pve-a"},{"node":"pve-b"}]`},
|
||||
{"resolver_failure", `[{"node":"pve-a"},{"node":"pve-b"}]`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "inventory"), []byte(tc.inventory), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, tc.name, proxyInventoryFixture)
|
||||
if code == 0 || !strings.Contains(out, "[WARN]") {
|
||||
t.Fatalf("incomplete inventory accepted: exit %d: %s", code, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "actions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("keys changed before inventory validation: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyMissingInventoryToolRequiresExplicitLocalOnly(t *testing.T) {
|
||||
for _, tool := range []string{"pvesh", "python3"} {
|
||||
t.Run(tool, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
body := `source "$1"
|
||||
command() {
|
||||
if [[ "$1" == -v && "$2" == "$SCENARIO" ]]; then return 1; fi
|
||||
builtin command "$@"
|
||||
}
|
||||
cleanup_local_authorized_keys() { echo local >>"$FIXTURE_DIR/actions"; }
|
||||
cleanup_remote_authorized_keys() { echo remote >>"$FIXTURE_DIR/actions"; }
|
||||
if cleanup_cluster_authorized_keys; then exit 0; else exit $?; fi
|
||||
`
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, tool, body)
|
||||
if code == 0 || !strings.Contains(out, "--local-only") {
|
||||
t.Fatalf("missing tool accepted: exit %d: %s", code, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "actions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("implicit local fallback changed keys: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyKeyReplacementPreservesOriginalOnEveryFailure(t *testing.T) {
|
||||
const original = "ssh-ed25519 AAAAlegacy pulse # pulse-managed-key\nssh-ed25519 AAAAkeep user-key\n"
|
||||
for _, phase := range []string{"mktemp", "grep", "cmp", "chmod", "chown", "mv", "success", "all_managed", "unchanged", "missing"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
keys := original
|
||||
if phase == "all_managed" {
|
||||
keys = "ssh-ed25519 AAAAlegacy pulse # pulse-proxy-key\n"
|
||||
} else if phase == "unchanged" {
|
||||
keys = "ssh-ed25519 AAAAkeep user-key\n"
|
||||
}
|
||||
path := filepath.Join(dir, "authorized_keys")
|
||||
if phase != "missing" {
|
||||
if err := os.WriteFile(path, []byte(keys), 0640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
body := `source "$1"
|
||||
AUTHORIZED_KEYS_PATH="$FIXTURE_DIR/authorized_keys"
|
||||
mktemp() { [[ "$SCENARIO" != mktemp ]] || return 9; command mktemp "$@"; }
|
||||
grep() { command grep "$@"; local code=$?; [[ "$SCENARIO" != grep ]] || return 2; return "$code"; }
|
||||
cmp() { [[ "$SCENARIO" != cmp ]] || return 2; command cmp "$@"; }
|
||||
chmod() { [[ "$SCENARIO" != chmod ]] || return 9; command chmod "$@"; }
|
||||
chown() { [[ "$SCENARIO" != chown ]] || return 9; command chown "$@"; }
|
||||
mv() { [[ "$SCENARIO" != mv ]] || return 9; command mv "$@"; }
|
||||
# Conditional invocation deliberately disables bash errexit in the helpers.
|
||||
if cleanup_local_authorized_keys; then exit 0; else exit $?; fi
|
||||
`
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, phase, body)
|
||||
success := phase == "success" || phase == "all_managed" || phase == "unchanged" || phase == "missing"
|
||||
if (code == 0) != success || strings.Contains(out, "[ OK ]") != success {
|
||||
t.Fatalf("replacement status mismatch: exit %d: %s", code, out)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if phase == "missing" {
|
||||
if !os.IsNotExist(err) {
|
||||
t.Fatalf("missing keys created: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := keys
|
||||
if phase == "success" {
|
||||
want = "ssh-ed25519 AAAAkeep user-key\n"
|
||||
} else if phase == "all_managed" {
|
||||
want = ""
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("keys corrupted: %q, want %q", got, want)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Mode().Perm() != 0640 {
|
||||
t.Fatalf("key permissions changed: %v, %v", info, err)
|
||||
}
|
||||
leftovers, err := filepath.Glob(path + ".pulse-cleanup.*")
|
||||
if err != nil || len(leftovers) != 0 {
|
||||
t.Fatalf("replacement temp files remain: %v, %v", leftovers, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyLocalOnlyPropagatesKeyFailure(t *testing.T) {
|
||||
body := `source "$1"
|
||||
LOCAL_ONLY=true
|
||||
cleanup_local_authorized_keys() { return 23; }
|
||||
if cleanup_cluster_authorized_keys; then exit 0; else exit $?; fi
|
||||
`
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), t.TempDir(), "failure", body)
|
||||
if code != 23 {
|
||||
t.Fatalf("local-only failure hidden: exit %d: %s", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyAPICleanupRequiresCompleteInventoryAndRemovals(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, inventory, actions string
|
||||
success bool
|
||||
}{
|
||||
{"success", `[{"tokenid":"alpha"},{"tokenid":"beta"}]`, "list\nremove alpha\nremove beta\ndelete\n", true},
|
||||
{"empty", `[]`, "list\ndelete\n", true},
|
||||
{"opt_out", `[]`, "", true},
|
||||
{"malformed", `{`, "list\n", false},
|
||||
{"missing_id", `[{"tokenid":"alpha"},{}]`, "list\n", false},
|
||||
{"unsafe_id", `[{"tokenid":"alpha"},{"tokenid":"--other"}]`, "list\n", false},
|
||||
{"read_failure", `[{"tokenid":"alpha"}]`, "list\n", false},
|
||||
{"remove_failure", `[{"tokenid":"alpha"},{"tokenid":"beta"}]`, "list\nremove alpha\nremove beta\n", false},
|
||||
{"delete_failure", `[]`, "list\ndelete\n", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "inventory"), []byte(tc.inventory), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `source "$1"
|
||||
REMOVE_PROXMOX_ACCESS=true
|
||||
[[ "$SCENARIO" != opt_out ]] || REMOVE_PROXMOX_ACCESS=false
|
||||
pveum() {
|
||||
case "$*" in
|
||||
'user token list pulse-monitor@pam --output-format json')
|
||||
echo list >>"$FIXTURE_DIR/actions"
|
||||
cat "$FIXTURE_DIR/inventory"
|
||||
[[ "$SCENARIO" != read_failure ]] ;;
|
||||
'user token remove pulse-monitor@pam alpha'|'user token remove pulse-monitor@pam beta')
|
||||
echo "remove $5" >>"$FIXTURE_DIR/actions"
|
||||
[[ "$SCENARIO" != remove_failure || "$5" != alpha ]] ;;
|
||||
'user delete pulse-monitor@pam')
|
||||
echo delete >>"$FIXTURE_DIR/actions"
|
||||
[[ "$SCENARIO" != delete_failure ]] ;;
|
||||
*) return 90 ;;
|
||||
esac
|
||||
}
|
||||
if remove_proxmox_access; then exit 0; else exit $?; fi
|
||||
`
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, tc.name, body)
|
||||
if (code == 0) != tc.success {
|
||||
t.Fatalf("API status mismatch: exit %d: %s", code, out)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(dir, "actions"))
|
||||
if tc.name == "opt_out" && os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
if err != nil || string(got) != tc.actions {
|
||||
t.Fatalf("unexpected API mutation: %q, %v; want %q", got, err, tc.actions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallSensorProxyMainCannotHideAPIRemovalFailure(t *testing.T) {
|
||||
body := `source "$1"
|
||||
disable_legacy_units() { :; }
|
||||
cleanup_cluster_authorized_keys() { :; }
|
||||
cleanup_stale_sensor_proxy_mounts() { :; }
|
||||
remove_legacy_files() { :; }
|
||||
remove_proxmox_access() { return 23; }
|
||||
systemctl_if_available() { echo reload >"$FIXTURE_DIR/actions"; }
|
||||
if main --remove-proxmox-access; then exit 0; else exit $?; fi
|
||||
`
|
||||
dir := t.TempDir()
|
||||
out, code := runSensorProxySafetyShell(t, repoFile("scripts", "uninstall-sensor-proxy.sh"), dir, "failure", body)
|
||||
if code != 23 || strings.Contains(out, "cleanup complete") {
|
||||
t.Fatalf("main hid API failure: exit %d: %s", code, out)
|
||||
}
|
||||
if got, err := os.ReadFile(filepath.Join(dir, "actions")); err != nil || string(got) != "reload\n" {
|
||||
t.Fatalf("file cleanup reload skipped: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,27 +138,42 @@ disable_legacy_units() {
|
||||
remove_managed_keys_from_authorized_keys_file() {
|
||||
local file="$1"
|
||||
local tmp_file=""
|
||||
local status=0
|
||||
|
||||
if [[ ! -f "$file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
tmp_file=$(mktemp)
|
||||
grep -v -E '# pulse-(managed|proxy)-key$' "$file" >"$tmp_file" 2>/dev/null || true
|
||||
if cmp -s "$tmp_file" "$file"; then
|
||||
rm -f "$tmp_file"
|
||||
# Keep replacement on the same filesystem. A failed read, metadata copy or
|
||||
# rename must leave the original keys intact, even under conditional calls.
|
||||
tmp_file=$(mktemp "${file}.pulse-cleanup.XXXXXX") || return 1
|
||||
grep -v -E '# pulse-(managed|proxy)-key$' "$file" >"$tmp_file" 2>/dev/null || status=$?
|
||||
if (( status > 1 )); then
|
||||
rm -f -- "$tmp_file"
|
||||
return 1
|
||||
fi
|
||||
status=0
|
||||
cmp -s "$tmp_file" "$file" || status=$?
|
||||
if (( status == 0 )); then
|
||||
rm -f -- "$tmp_file" || return 1
|
||||
return 0
|
||||
fi
|
||||
|
||||
chmod --reference="$file" "$tmp_file" 2>/dev/null || chmod 600 "$tmp_file" 2>/dev/null || true
|
||||
chown --reference="$file" "$tmp_file" 2>/dev/null || true
|
||||
mv "$tmp_file" "$file"
|
||||
if (( status > 1 )) ||
|
||||
! chmod --reference="$file" "$tmp_file" ||
|
||||
! chown --reference="$file" "$tmp_file" ||
|
||||
! mv -- "$tmp_file" "$file"; then
|
||||
rm -f -- "$tmp_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_local_authorized_keys() {
|
||||
local auth_file=""
|
||||
auth_file=$(resolve_path "$AUTHORIZED_KEYS_PATH")
|
||||
remove_managed_keys_from_authorized_keys_file "$auth_file"
|
||||
auth_file=$(resolve_path "$AUTHORIZED_KEYS_PATH") || return 1
|
||||
if ! remove_managed_keys_from_authorized_keys_file "$auth_file"; then
|
||||
print_warn "Local legacy SSH key cleanup failed; original keys were preserved"
|
||||
return 1
|
||||
fi
|
||||
print_success "Removed legacy Pulse SSH key entries from ${auth_file}"
|
||||
}
|
||||
|
||||
@@ -183,45 +198,61 @@ is_local_host() {
|
||||
[[ "$target" == "127.0.0.1" || "$target" == "localhost" ]]
|
||||
}
|
||||
|
||||
# The program is passed with -c so stdin remains the complete API response.
|
||||
# Validate every row before emitting any identities; a partial list is unsafe.
|
||||
sensor_proxy_inventory() {
|
||||
python3 -c '
|
||||
import json, re, sys
|
||||
field = sys.argv[1]
|
||||
try:
|
||||
rows = json.load(sys.stdin)
|
||||
if not isinstance(rows, list) or (field == "node" and not rows):
|
||||
raise ValueError()
|
||||
values = []
|
||||
for row in rows:
|
||||
value = row.get(field) if isinstance(row, dict) else None
|
||||
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,252}", value):
|
||||
raise ValueError()
|
||||
values.append(value)
|
||||
except (ValueError, TypeError):
|
||||
sys.exit(1)
|
||||
for value in values:
|
||||
print(value)
|
||||
' "$1"
|
||||
}
|
||||
|
||||
discover_cluster_node_addresses() {
|
||||
local nodes=""
|
||||
local node=""
|
||||
local node_ip=""
|
||||
local addresses=""
|
||||
|
||||
if command -v pvesh >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
|
||||
while IFS= read -r node; do
|
||||
[[ -z "$node" ]] && continue
|
||||
node_ip=$(getent ahostsv4 "$node" 2>/dev/null | awk 'NR==1 {print $1}') || true
|
||||
if [[ -z "$node_ip" ]]; then
|
||||
node_ip=$(getent hosts "$node" 2>/dev/null | awk 'NR==1 {print $1}') || true
|
||||
fi
|
||||
[[ -n "$node_ip" ]] && printf '%s\n' "$node_ip"
|
||||
done < <(
|
||||
pvesh get /nodes --output-format json 2>/dev/null | python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
if ! command -v pvesh >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
|
||||
print_warn "Cluster inventory requires pvesh and python3; use --local-only once on each node for local cleanup"
|
||||
return 1
|
||||
fi
|
||||
if ! nodes=$(pvesh get /nodes --output-format json 2>/dev/null | sensor_proxy_inventory node); then
|
||||
print_warn "Cluster inventory is unavailable or invalid; no SSH key cleanup attempted"
|
||||
return 1
|
||||
fi
|
||||
while IFS= read -r node; do
|
||||
node_ip=$(getent ahostsv4 "$node" 2>/dev/null | awk 'NR==1 {print $1}') || node_ip=""
|
||||
if [[ -z "$node_ip" ]]; then
|
||||
node_ip=$(getent hosts "$node" 2>/dev/null | awk 'NR==1 {print $1}') || node_ip=""
|
||||
fi
|
||||
if [[ -z "$node_ip" ]] || ! node_ip=$(python3 -c '
|
||||
import ipaddress, sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
node = item.get("node")
|
||||
if node:
|
||||
print(node)
|
||||
PY
|
||||
)
|
||||
return 0
|
||||
fi
|
||||
|
||||
if command -v pvecm >/dev/null 2>&1; then
|
||||
pvecm status 2>/dev/null | awk '
|
||||
/0x[0-9a-f]+/ {
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) print $i
|
||||
}
|
||||
}
|
||||
'
|
||||
fi
|
||||
print(ipaddress.ip_address(sys.argv[1]))
|
||||
except ValueError:
|
||||
sys.exit(1)
|
||||
' "$node_ip"); then
|
||||
print_warn "A cluster node has no valid resolved address; no SSH key cleanup attempted"
|
||||
return 1
|
||||
fi
|
||||
addresses+="${node_ip}"$'\n'
|
||||
done <<<"$nodes"
|
||||
printf '%s' "$addresses"
|
||||
}
|
||||
|
||||
cleanup_remote_authorized_keys() {
|
||||
@@ -261,27 +292,27 @@ fi'
|
||||
|
||||
cleanup_cluster_authorized_keys() {
|
||||
local host=""
|
||||
local saw_nodes=false
|
||||
local hosts=""
|
||||
local status=0
|
||||
|
||||
if [[ "$LOCAL_ONLY" == "true" ]]; then
|
||||
cleanup_local_authorized_keys
|
||||
return 0
|
||||
return $?
|
||||
fi
|
||||
|
||||
# Capture the discovery status, not a process substitution whose failure
|
||||
# would otherwise look like an empty, successful single-host installation.
|
||||
if ! hosts=$(discover_cluster_node_addresses) || [[ -z "$hosts" ]]; then
|
||||
return 1
|
||||
fi
|
||||
hosts=$(printf '%s\n' "$hosts" | sort -u) || return 1
|
||||
cleanup_local_authorized_keys || status=1
|
||||
while IFS= read -r host; do
|
||||
[[ -z "$host" ]] && continue
|
||||
saw_nodes=true
|
||||
if is_local_host "$host"; then
|
||||
cleanup_local_authorized_keys
|
||||
else
|
||||
if ! is_local_host "$host"; then
|
||||
cleanup_remote_authorized_keys "$host" || status=1
|
||||
fi
|
||||
done < <(discover_cluster_node_addresses | sort -u)
|
||||
|
||||
if [[ "$saw_nodes" != "true" ]]; then
|
||||
cleanup_local_authorized_keys
|
||||
fi
|
||||
done <<<"$hosts"
|
||||
return "$status"
|
||||
}
|
||||
|
||||
@@ -469,58 +500,37 @@ remove_legacy_files() {
|
||||
remove_proxmox_access() {
|
||||
local token_ids=""
|
||||
local token_id=""
|
||||
local status=0
|
||||
|
||||
if [[ "$REMOVE_PROXMOX_ACCESS" != "true" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v pveum >/dev/null 2>&1; then
|
||||
print_warn "pveum is not available; skipping pulse-monitor@pam cleanup"
|
||||
return 0
|
||||
if ! command -v pveum >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then
|
||||
print_warn "API access cleanup requires pveum and python3; cleanup is incomplete"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
token_ids=$(pveum user token list pulse-monitor@pam --output-format json 2>/dev/null | python3 - <<'PY'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
token_id = item.get("tokenid")
|
||||
if token_id:
|
||||
print(token_id)
|
||||
PY
|
||||
) || true
|
||||
if ! token_ids=$(pveum user token list pulse-monitor@pam --output-format json 2>/dev/null | sensor_proxy_inventory tokenid); then
|
||||
print_warn "API token inventory is unavailable or invalid; API access was left unchanged"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z "$token_ids" ]]; then
|
||||
token_ids=$(pveum user token list pulse-monitor@pam 2>/dev/null | awk '
|
||||
{
|
||||
line=$0
|
||||
gsub(/\r/, "", line)
|
||||
gsub(/│/, "|", line)
|
||||
if (index(line, "|") == 0) next
|
||||
n = split(line, parts, "|")
|
||||
if (n < 3) next
|
||||
name = parts[2]
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", name)
|
||||
if (name != "" && name != "tokenid") print name
|
||||
}') || true
|
||||
fi
|
||||
|
||||
while IFS= read -r token_id; do
|
||||
[[ -z "$token_id" ]] && continue
|
||||
pveum user token remove pulse-monitor@pam "$token_id" >/dev/null 2>&1 || print_warn "Failed to remove pulse-monitor@pam token ${token_id}"
|
||||
if ! pveum user token remove pulse-monitor@pam "$token_id" >/dev/null 2>&1; then
|
||||
print_warn "A legacy API token could not be removed; API user deletion will not be attempted"
|
||||
status=1
|
||||
fi
|
||||
done <<<"$token_ids"
|
||||
|
||||
pveum user delete pulse-monitor@pam >/dev/null 2>&1 || print_warn "pulse-monitor@pam was not removed; it may already be absent or still in use"
|
||||
(( status == 0 )) || return "$status"
|
||||
if ! pveum user delete pulse-monitor@pam >/dev/null 2>&1; then
|
||||
print_warn "Legacy API user removal is unconfirmed; cleanup is incomplete"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local cluster_key_cleanup_status=0
|
||||
local container_cleanup_status=0
|
||||
local api_cleanup_status=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -585,15 +595,16 @@ main() {
|
||||
return "$container_cleanup_status"
|
||||
fi
|
||||
remove_legacy_files
|
||||
remove_proxmox_access
|
||||
remove_proxmox_access || api_cleanup_status=$?
|
||||
if ! systemctl_if_available daemon-reload; then
|
||||
print_warn "Cannot reload service configuration; cleanup is incomplete"
|
||||
return 1
|
||||
fi
|
||||
if (( cluster_key_cleanup_status != 0 )); then
|
||||
print_warn "Local cleanup completed, but one or more remote cluster nodes still require trusted, local cleanup"
|
||||
print_warn "Local file cleanup completed, but cluster SSH key cleanup is incomplete; use --local-only on each node or repair trusted cluster inventory"
|
||||
return "$cluster_key_cleanup_status"
|
||||
fi
|
||||
(( api_cleanup_status == 0 )) || return "$api_cleanup_status"
|
||||
print_success "Legacy pulse-sensor-proxy cleanup complete"
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user