This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
teleport_prune_by_hostname_v2.py
|
||||
|
||||
- Groups nodes by spec.hostname
|
||||
- In each duplicate group, removes the node with the *oldest version*
|
||||
- If versions are tied, removes the one with the earliest metadata.expires (oldest heartbeat)
|
||||
- Dry-run by default; pass --execute to delete with `tctl rm node/<metadata.name> --confirm`
|
||||
"""
|
||||
|
||||
import argparse, json, shutil, subprocess, sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
|
||||
# ---------- tiny shell helpers ----------
|
||||
def ensure_binary(name: str):
|
||||
if shutil.which(name) is None:
|
||||
sys.exit(f"ERROR: '{name}' not found on PATH.")
|
||||
|
||||
def run(cmd: List[str]) -> subprocess.CompletedProcess:
|
||||
try:
|
||||
return subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
sys.stderr.write(f"\nCommand failed: {' '.join(cmd)}\nSTDERR:\n{e.stderr}\n")
|
||||
raise
|
||||
|
||||
# ---------- JSON field helpers ----------
|
||||
def get_hostname(n: Dict[str, Any]) -> Optional[str]:
|
||||
return (n.get("spec") or {}).get("hostname") or None
|
||||
|
||||
def get_uuid(n: Dict[str, Any]) -> Optional[str]:
|
||||
return (n.get("metadata") or {}).get("name") or None
|
||||
|
||||
def get_version(n: Dict[str, Any]) -> str:
|
||||
return (n.get("spec") or {}).get("version") or "0.0.0"
|
||||
|
||||
def get_expires(n: Dict[str, Any]) -> Optional[datetime]:
|
||||
exp = (n.get("metadata") or {}).get("expires")
|
||||
if not exp:
|
||||
return None
|
||||
# tolerate fractional seconds + Z
|
||||
try:
|
||||
# Example: 2025-10-26T17:45:26.132967655Z
|
||||
exp = exp.rstrip("Z")
|
||||
# trim to microseconds if too long
|
||||
if "." in exp:
|
||||
head, tail = exp.split(".", 1)
|
||||
tail = ''.join(ch for ch in tail if ch.isdigit())
|
||||
tail = (tail + "000000")[:6]
|
||||
exp = f"{head}.{tail}"
|
||||
return datetime.fromisoformat(exp)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def parse_version_tuple(v: str) -> Tuple[int, ...]:
|
||||
parts = []
|
||||
for token in (v or "").split("."):
|
||||
num = ""
|
||||
for ch in token:
|
||||
if ch.isdigit(): num += ch
|
||||
else: break
|
||||
parts.append(int(num) if num else 0)
|
||||
return tuple(parts) if parts else (0,)
|
||||
|
||||
# ---------- core ----------
|
||||
def list_nodes(tsh_bin: str, tsh_flags: List[str]) -> List[Dict[str, Any]]:
|
||||
cp = run([tsh_bin, "ls", "--format=json", *tsh_flags])
|
||||
try:
|
||||
data = json.loads(cp.stdout)
|
||||
except json.JSONDecodeError:
|
||||
sys.exit("ERROR: Could not parse JSON from `tsh ls --format=json`.")
|
||||
if not isinstance(data, list):
|
||||
sys.exit("ERROR: Unexpected JSON (expected list).")
|
||||
return data
|
||||
|
||||
def decide_deletions(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
groups: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for n in nodes:
|
||||
host = get_hostname(n)
|
||||
print(host)
|
||||
if not host: # ignore ungroupable entries
|
||||
continue
|
||||
entry = {
|
||||
"uuid": get_uuid(n),
|
||||
"hostname": host,
|
||||
"version": get_version(n),
|
||||
"expires": get_expires(n),
|
||||
}
|
||||
entry["version_key"] = parse_version_tuple(entry["version"])
|
||||
groups[host].append(entry)
|
||||
|
||||
to_delete: List[Dict[str, Any]] = []
|
||||
for host, items in groups.items():
|
||||
if len(items) <= 1:
|
||||
continue
|
||||
# Sort by: newest version first, then latest expiry first
|
||||
items_sorted = sorted(
|
||||
items,
|
||||
key=lambda x: (x["version_key"], x["expires"] or datetime.min),
|
||||
reverse=True
|
||||
)
|
||||
# choose the *oldest* by version; if tie, earliest expiry
|
||||
victim = items_sorted[-1]
|
||||
to_delete.append(victim)
|
||||
return to_delete
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Remove the oldest Teleport node per duplicated hostname (Teleport v2 JSON).")
|
||||
ap.add_argument("--tsh", default="tsh")
|
||||
ap.add_argument("--tctl", default="tctl")
|
||||
ap.add_argument("--tsh-flag", action="append", default=[], help="Extra flags for tsh (repeatable)")
|
||||
ap.add_argument("--tctl-flag", action="append", default=[], help="Extra flags for tctl (repeatable)")
|
||||
ap.add_argument("--execute", action="store_true", help="Actually delete (default is dry-run)")
|
||||
args = ap.parse_args()
|
||||
|
||||
ensure_binary(args.tsh); ensure_binary(args.tctl)
|
||||
|
||||
print("Listing nodes via tsh...")
|
||||
nodes = list_nodes(args.tsh, args.tsh_flag)
|
||||
|
||||
deletions = decide_deletions(nodes)
|
||||
if not deletions:
|
||||
print("No duplicate hostnames found. Nothing to do.")
|
||||
return
|
||||
|
||||
print("\nPlanned deletions (oldest per duplicate hostname):")
|
||||
for d in deletions:
|
||||
exp = d["expires"].isoformat() if d["expires"] else "unknown"
|
||||
print(f" host={d['hostname']:<30} uuid={d['uuid']} version={d['version']} expires={exp}")
|
||||
|
||||
if not args.execute:
|
||||
print("\nDRY-RUN: No changes made. Re-run with --execute to delete the above nodes.")
|
||||
return
|
||||
|
||||
print("\nDeleting via tctl…")
|
||||
for d in deletions:
|
||||
if not d["uuid"]:
|
||||
print(f"SKIP (no UUID): host={d['hostname']} version={d['version']}")
|
||||
continue
|
||||
cmd = [args.tctl, *args.tctl_flag, "rm", f"node/{d['uuid']}"]
|
||||
print(" ", " ".join(cmd))
|
||||
try:
|
||||
run(cmd); print(" -> OK")
|
||||
except Exception: print(" -> FAILED")
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user