This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# System deps for curl and bash used by Teleport installer
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates bash tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy app
|
||||
COPY main.py /app/main.py
|
||||
|
||||
# Add entrypoint
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
# Default environment knobs
|
||||
ENV TELEPORT_EDITION=oss \
|
||||
EXECUTE=false \
|
||||
LOOP_INTERVAL_SECONDS=60
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
#
|
||||
# Teleport setup (installed at runtime to allow choosing version/edition via env)
|
||||
#
|
||||
if [[ -n "${TELEPORT_VERSION:-}" ]]; then
|
||||
echo "Installing Teleport ${TELEPORT_VERSION} (${TELEPORT_EDITION:-oss})..."
|
||||
curl -fsSL https://goteleport.com/static/install.sh | bash -s "${TELEPORT_VERSION}" "${TELEPORT_EDITION:-oss}"
|
||||
else
|
||||
echo "TELEPORT_VERSION not set; skipping Teleport install. Ensure tsh/tctl are available."
|
||||
fi
|
||||
|
||||
# Configure Teleport node if a token is provided (optional)
|
||||
if [[ -n "${TELEPORT_TOKEN:-}" && -n "${TELEPORT_URL:-}" ]]; then
|
||||
if [[ ! -f /etc/teleport.yaml ]]; then
|
||||
echo "Configuring Teleport node with token..."
|
||||
teleport configure --roles=node --token="${TELEPORT_TOKEN}" --proxy="${TELEPORT_URL}" --no-acme -o file
|
||||
fi
|
||||
# Ensure nodename matches container hostname
|
||||
HOSTNAME=$(hostname)
|
||||
sed -i "s/^ nodename:.*/ nodename: ${HOSTNAME}/" /etc/teleport.yaml || true
|
||||
echo "Starting Teleport node in background..."
|
||||
nohup teleport start >/var/log/teleport.log 2>&1 &
|
||||
fi
|
||||
|
||||
#
|
||||
# Build flags for tsh/tctl from environment
|
||||
#
|
||||
TSH_FLAGS=()
|
||||
TCTL_FLAGS=()
|
||||
|
||||
if [[ -n "${TELEPORT_URL:-}" ]]; then
|
||||
TSH_FLAGS+=("--proxy=${TELEPORT_URL}")
|
||||
TCTL_FLAGS+=("--proxy=${TELEPORT_URL}")
|
||||
fi
|
||||
|
||||
if [[ -n "${TELEPORT_IDENTITY_FILE:-}" && -f "${TELEPORT_IDENTITY_FILE}" ]]; then
|
||||
echo "Using Teleport identity: ${TELEPORT_IDENTITY_FILE}"
|
||||
TSH_FLAGS+=("--identity=${TELEPORT_IDENTITY_FILE}")
|
||||
TCTL_FLAGS+=("--identity=${TELEPORT_IDENTITY_FILE}")
|
||||
fi
|
||||
|
||||
# Optional: extra flags from env (space-separated)
|
||||
if [[ -n "${EXTRA_TSH_FLAGS:-}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
TSH_FLAGS+=( ${EXTRA_TSH_FLAGS} )
|
||||
fi
|
||||
if [[ -n "${EXTRA_TCTL_FLAGS:-}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
TCTL_FLAGS+=( ${EXTRA_TCTL_FLAGS} )
|
||||
fi
|
||||
|
||||
echo "Starting teleport-auto-remover main loop (interval: ${LOOP_INTERVAL_SECONDS:-60}s, execute: ${EXECUTE:-false})"
|
||||
|
||||
while true; do
|
||||
TS=$(date -Iseconds)
|
||||
echo "[${TS}] Running prune…"
|
||||
set +e
|
||||
# Build CLI args for main.py with repeated tsh/tctl flags (skip empties)
|
||||
ARGS=(/app/main.py)
|
||||
for f in "${TSH_FLAGS[@]}"; do
|
||||
[[ -n "$f" ]] && ARGS+=("--tsh-flag=$f")
|
||||
done
|
||||
for f in "${TCTL_FLAGS[@]}"; do
|
||||
[[ -n "$f" ]] && ARGS+=("--tctl-flag=$f")
|
||||
done
|
||||
if [[ "${EXECUTE:-false}" == "true" ]]; then ARGS+=(--execute); fi
|
||||
# Debug-print the exact command for troubleshooting
|
||||
printf 'Invoking: python3'
|
||||
for a in "${ARGS[@]}"; do printf ' %q' "$a"; done; printf '\n'
|
||||
python3 "${ARGS[@]}"
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
echo "Run finished with code ${EXIT_CODE}."
|
||||
sleep "${LOOP_INTERVAL_SECONDS:-60}"
|
||||
done
|
||||
@@ -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