#!/usr/bin/env python3
"""bullpen-verify — den laufenden bullpen gegen deploy/roster.yaml halten.

WOZU. Das Architekturmanifest nennt als staerkstes Argument fuer einen
deklarativen Erbauer nicht die Bequemlichkeit, sondern die Sichtbarkeit: zwei
stille Drifts (der Kollaps der Zwei-Endpunkt-Architektur und das abgeklemmte
Lease-Tor) blieben monatelang unbemerkt, weil nichts sie ANGEZEIGT hat. Dieses
Werkzeug macht Drift zu einem Diff.

Es AENDERT NICHTS. Wer den Aufbau herstellen will, benutzt `bullpen-up`; dies
hier sagt nur, wo Soll und Ist auseinanderlaufen — und ist damit auch die
Abnahme fuer jeden kuenftigen Erbauer-Lauf.

Rueckgabe: 0 wenn Soll == Ist, sonst 1. Damit ist es in einer Unit oder einem
Timer benutzbar, ohne dass jemand die Ausgabe lesen muss.

Aufruf:  pen-verify [--roster deploy/roster.yaml] [--host hertz]
"""
import argparse
import json
import os
import subprocess
import sys

try:
    import yaml
except ImportError:
    sys.exit("pen-verify braucht python3-yaml")


def lauf(host, argv, timeout=60):
    """Ein Kommando auf dem Wirt, ueber sic. Gibt (rc, stdout) zurueck."""
    p = subprocess.run(["sic", host, "sh", "-c", argv],
                       capture_output=True, text=True, timeout=timeout)
    return p.returncode, (p.stdout or "").strip()


class Bericht:
    def __init__(self):
        self.abweichungen = []
        self.geprueft = 0

    def pruefe(self, gegenstand, soll, ist, hinweis=""):
        self.geprueft += 1
        if str(soll) == str(ist):
            print(f"  [ok ] {gegenstand:<38} {ist}")
        else:
            self.abweichungen.append((gegenstand, soll, ist, hinweis))
            zusatz = f"  ({hinweis})" if hinweis else ""
            print(f"  [!! ] {gegenstand:<38} soll={soll}  ist={ist}{zusatz}")


def main():
    ap = argparse.ArgumentParser()
    # Im Paket liegt das Roster unter /usr/share/bullpen/deploy, in einer
    # Arbeitskopie daneben. Beides ohne Argument finden — ein Werkzeug, das
    # man mit einem Pfad fuettern muss, benutzt im Zweifel niemand.
    vorgabe = next((p for p in ("deploy/roster.yaml",
                                "/usr/share/bullpen/deploy/roster.yaml")
                    if os.path.isfile(p)), "deploy/roster.yaml")
    ap.add_argument("--roster", default=vorgabe)
    ap.add_argument("--host", default="hertz")
    a = ap.parse_args()

    r = yaml.safe_load(open(a.roster, encoding="utf-8"))
    eltern, kinder = r["eltern"], r["kinder"]
    b = Bericht()
    P = f"incus exec {eltern['name']} -- incus"

    print(f"== Eltern: {eltern['name']} auf {eltern['wirt']} ==")
    for schluessel, feld in (("limits.memory", "limits_memory"),
                             ("security.nesting", "security_nesting"),
                             ("security.idmap.size", "security_idmap_size"),
                             ("boot.autostart", "boot_autostart")):
        rc, ist = lauf(a.host, f"incus config get {eltern['name']} {schluessel}")
        soll = eltern[feld]
        b.pruefe(f"eltern {schluessel}", str(soll).lower(), (ist or "-").lower())

    rc, pool = lauf(a.host, f"incus config show {eltern['name']} | sed -n 's/^ *pool: //p' | head -1")
    b.pruefe("eltern pool", eltern["pool"], pool or "-")

    print()
    print("== Kinder ==")
    rc, vorhanden = lauf(a.host, f"{P} list -c ns --format csv")
    ist_zustand = dict(z.split(",", 1) for z in vorhanden.splitlines() if "," in z)
    b.pruefe("anzahl kinder", len(kinder), len(ist_zustand))

    for k in kinder:
        name = k["name"]
        print(f"  -- {name} --")
        b.pruefe(f"{name}: existiert und laeuft", "RUNNING", ist_zustand.get(name, "FEHLT"))
        if name not in ist_zustand:
            continue
        rc, mem = lauf(a.host, f"{P} config get {name} limits.memory")
        b.pruefe(f"{name}: limits.memory", k["limits_memory"], mem or "-")
        rc, auto = lauf(a.host, f"{P} config get {name} boot.autostart")
        b.pruefe(f"{name}: boot.autostart", str(k["boot_autostart"]).lower(),
                 (auto or "-").lower(), k.get("abweichung", "")[:60])

        # Geheimnisse: genau die Nicks, die das Roster diesem Container gibt.
        soll_nicks = sorted(k.get("nicks", []))
        if k.get("geheimnisse") == "alle":
            soll_nicks = sorted(n for kk in kinder for n in kk.get("nicks", []))
        rc, ist_nicks = lauf(a.host, f"{P} exec {name} -- sh -c 'ls /etc/bullpen/post-secret.d 2>/dev/null'")
        b.pruefe(f"{name}: geheimnisse", ",".join(soll_nicks),
                 ",".join(sorted(ist_nicks.split())) or "-")

        # Units: aktiviert, nicht bloss vorhanden.
        soll_units = sorted(k.get("units", []))
        if soll_units:
            rc, ist_units = lauf(
                a.host,
                f"{P} exec {name} -- sh -c \"systemctl list-unit-files --state=enabled --no-legend 2>/dev/null "
                f"| awk '{{print \\$1}}' | grep -E '^(bullpen|bullseye|pi-web|lmcp|caddy)' | sort | tr '\\n' ' '\"")
            fehlend = [u for u in soll_units if u not in ist_units.split()]
            b.pruefe(f"{name}: units aktiviert", "keine fehlen",
                     "keine fehlen" if not fehlend else f"fehlt: {' '.join(fehlend)}")

    # Der Raum ist der einzige Ort, an dem eine Invariante messbar ist, die
    # sich nicht aus der Konfiguration ergibt: welche Werkzeuge er anbietet.
    raum = next((k for k in kinder if k.get("lmcp_tool_allow")), None)
    if raum:
        print()
        print("== Raum: Werkzeugliste (die Invariante hinter B6) ==")
        rc, allow = lauf(a.host, f"{P} exec {raum['name']} -- sh -c "
                                 f"'systemctl show lmcp -p Environment --value | tr \" \" \"\\n\" "
                                 f"| sed -n \"s/^LMCP_TOOL_ALLOW=//p\"'")
        b.pruefe("room: LMCP_TOOL_ALLOW",
                 ",".join(raum["lmcp_tool_allow"]), allow or "NICHT GESETZT",
                 "ohne die Liste hat jeder Token-Halter eine Wurzelschale im Raum")

    # Speicherdruck: GEMESSEN, nicht aus Deckeln addiert. Deckel sind
    # Sicherungen je Container; ihre Summe ist eine Buchhaltungsgroesse, weil
    # neun Container auf einem Wirt Seitencache, Bibliotheken und COW-Seiten
    # teilen. Entschieden wurde gegen eine Zulassungssteuerung -- dafuer muss
    # der Druck sichtbar sein.
    print()
    print("== Speicher: belegt gegen Deckel (gemessen, nicht addiert) ==")
    for k in kinder:
        rc, roh = lauf(a.host, f"{P} info {k['name']} 2>/dev/null | awk '/Memory .current./{{print $3$4}}'")
        print(f"  {k['name']:<12} belegt={roh or '?':<12} Deckel={k['limits_memory']}")
    rc, eltern_ist = lauf(a.host, f"incus info {eltern['name']} 2>/dev/null "
                                  f"| awk '/Memory .current./{{print $3$4}}'")
    print(f"  {'ELTERN':<12} belegt={eltern_ist or '?':<12} Deckel={eltern['limits_memory']}")
    rc, frei = lauf(a.host, "free -m | awk '/^Mem:/{print $7\" MB verfuegbar\"}'")
    print(f"  {'WIRT':<12} {frei}")

    print()
    print(f"{len(b.abweichungen)} Abweichungen bei {b.geprueft} Pruefungen")
    if b.abweichungen:
        print()
        print("Soll und Ist laufen auseinander. Das ist der Befund, nicht der Fehler —")
        print("entweder das Roster luegt oder der Aufbau ist gedriftet. Beides gehoert")
        print("entschieden, nicht weggeklickt.")
    return 1 if b.abweichungen else 0


sys.exit(main())
