#!/usr/bin/env python3
"""Cold-boot einer Liste von VMs (stdin, ein Name pro Zeile).

Aufruf:  ./coldboot.py [Optionen] [project-id] < vms.txt
Ohne project-id werden Namen im eigenen Auth-Scope aufgeloest
(kein --project; funktioniert auch ohne Admin-Rechte).
Nutzt die openstack-CLI; Auth via Environment (OS_* Variablen).

Pro VM: Ping-Vorabtest (fixed, dann floating) -> stop -> poll bis
SHUTOFF -> start -> Ping auf der vorab erreichbaren IP abwarten;
war vorab keine IP erreichbar, nur Statuskontrolle bis ACTIVE.

Optionen:
  --dry-run              nur lesende Aufrufe; stop/start werden
                         angezeigt statt ausgefuehrt
  --timeout SEC          Gesamtzeit von stop bis "wieder online"
                         (Default 900); bei Ueberschreitung bricht
                         das Skript ab
  --min-start-age HOURS  VM ueberspringen, wenn der letzte start
                         laut Actions-Log juenger ist
                         (Default 0 = Check aus)
"""
import argparse
import json
import re
import subprocess
import sys
import time
from datetime import datetime, timezone

POLL_INTERVAL = 5        # Sekunden zwischen Status-/Ping-Abfragen


def osc(*args):
    """openstack-CLI mit JSON-Ausgabe aufrufen."""
    cmd = ["openstack", *args, "-f", "json"]
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"{' '.join(cmd)}: {r.stderr.strip()}")
    return json.loads(r.stdout)


def osc_noout(*args):
    """openstack-CLI ohne Ausgabe (stop/start)."""
    cmd = ["openstack", *args]
    r = subprocess.run(cmd, capture_output=True, text=True)
    if r.returncode != 0:
        raise RuntimeError(f"{' '.join(cmd)}: {r.stderr.strip()}")


def resolve(name, project_id):
    """Name -> UUID (im angegebenen Projekt oder im eigenen Scope)."""
    proj = ["--project", project_id] if project_id else []
    servers = osc("server", "list", *proj, "--name", f"^{name}$")
    if len(servers) != 1:
        raise RuntimeError(
            f"{name}: {len(servers)} Treffer"
            + (f" im Projekt {project_id}" if project_id else ""))
    return servers[0]["ID"]


def server_status(uuid):
    return osc("server", "show", uuid, "-c", "status")["status"]


def all_ipv4(uuid):
    """Alle IPv4-Adressen der Instanz (fixed und floating).

    Je nach CLI-Version ist addresses ein dict
    {"netz": ["10.0.0.5", ...], ...} oder ein String
    "netz=10.0.0.5, 203.0.113.7; netz2=...". Regex deckt beides ab;
    die Reihenfolge (fixed vor floating) bleibt erhalten.
    """
    addr = osc("server", "show", uuid, "-c", "addresses")["addresses"]
    return re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", json.dumps(addr))


def reachable_ip(uuid):
    """Erste vorab pingbare IPv4 (fixed vor floating), sonst None."""
    for ip in all_ipv4(uuid):
        if ping_ok(ip):
            return ip
    return None


def _get(d, *keys):
    """Key-toleranter Zugriff (CLI-Versionen benennen Spalten verschieden)."""
    for k in keys:
        if k in d:
            return d[k]
    return None


def last_start_age_hours(uuid):
    """Stunden seit dem letzten start laut Actions-Log; None wenn keins."""
    events = osc("server", "event", "list", uuid)
    newest = None
    for ev in events:
        action = _get(ev, "Action", "action")
        if action != "start":
            continue
        ts = _get(ev, "Start Time", "start_time", "StartTime")
        if not ts:
            continue
        t = datetime.fromisoformat(ts).replace(tzinfo=timezone.utc)
        if newest is None or t > newest:
            newest = t
    if newest is None:
        return None
    return (datetime.now(timezone.utc) - newest).total_seconds() / 3600


def wait_status(uuid, wanted, deadline):
    while time.monotonic() < deadline:
        st = server_status(uuid)
        if st == wanted:
            return
        if st == "ERROR":
            raise RuntimeError(f"{uuid}: Status ERROR")
        time.sleep(POLL_INTERVAL)
    raise RuntimeError(f"{uuid}: Timeout beim Warten auf {wanted}")


def ping_ok(ip):
    return subprocess.run(
        ["ping", "-c", "1", "-W", "2", ip],
        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
    ).returncode == 0


def wait_ping(ip, deadline):
    while time.monotonic() < deadline:
        if ping_ok(ip):
            return
        time.sleep(POLL_INTERVAL)
    raise RuntimeError(f"{ip}: kein Ping vor Timeout")


def main():
    p = argparse.ArgumentParser(
        description="Cold-boot von VMs; Namen (einer pro Zeile) via stdin.",
        epilog="Beispiel: %(prog)s --min-start-age 24 <project-id> < vms.txt")
    p.add_argument("--dry-run", action="store_true",
                   help="nur lesende Aufrufe; stop/start anzeigen statt ausfuehren")
    p.add_argument("--timeout", type=int, default=900,
                   help="Sekunden von stop bis wieder online (Default 900)")
    p.add_argument("--min-start-age", type=float, default=0,
                   help="Stunden; juengere Starts ueberspringen (Default 0=aus)")
    p.add_argument("project_id", nargs="?", default=None,
                   help="OpenStack-Projekt-ID der VMs "
                        "(Default: eigener Auth-Scope)")
    opts = p.parse_args()

    if sys.stdin.isatty():
        sys.exit("Fehler: keine stdin-Daten (VM-Namen erwartet, "
                 "z.B. ./coldboot.py < vms.txt)")
    names = [l.strip() for l in sys.stdin if l.strip()]
    if not names:
        sys.exit("Fehler: stdin leer, keine VM-Namen gelesen")
    failed = []

    for name in names:
        try:
            uuid = resolve(name, opts.project_id)

            if opts.min_start_age > 0:
                age = last_start_age_hours(uuid)
                if age is not None and age < opts.min_start_age:
                    print(f"{name} ({uuid}): letzter Start vor "
                          f"{age:.1f}h < {opts.min_start_age}h -> SKIP",
                          flush=True)
                    continue

            ip = reachable_ip(uuid)
            if ip is None:
                print(f"{name} ({uuid}): vorab kein Ping -> "
                      "nachher nur Statuskontrolle", flush=True)

            if opts.dry_run:
                st = server_status(uuid)
                check = f"Ping {ip}" if ip else "nur Statuskontrolle"
                print(f"{name} ({uuid}): DRY-RUN status={st} "
                      f"-> wuerde stop/start, danach {check}", flush=True)
                continue

            print(f"{name} ({uuid}, ping={ip}): stop", flush=True)
            deadline = time.monotonic() + opts.timeout
            osc_noout("server", "stop", uuid)
            wait_status(uuid, "SHUTOFF", deadline)
            print(f"{name}: SHUTOFF -> start", flush=True)
            osc_noout("server", "start", uuid)
            wait_status(uuid, "ACTIVE", deadline)
            if ip is not None:
                print(f"{name}: ACTIVE, warte auf Ping {ip}", flush=True)
                wait_ping(ip, deadline)
            else:
                print(f"{name}: ACTIVE (kein Ping-Check)", flush=True)
            print(f"{name}: OK", flush=True)
        except RuntimeError as e:
            print(f"{name}: FEHLER: {e}", file=sys.stderr, flush=True)
            failed.append(name)
            break  # Abbruch: nicht blind weitermachen, wenn eine VM haengt

    if failed:
        sys.exit(f"Abgebrochen bei: {', '.join(failed)}")


if __name__ == "__main__":
    main()
