#!/usr/bin/env python3
# Mailrelay TLS certificate watcher.
#
# Runs every 10 minutes (see /etc/cron.d/mailrelay-adl-cert-watch). For
# every mailrelay instance this operator manages it reads the tls_secret
# setting from the instance's Config CR, fetches the named
# kubernetes.io/tls secret from the asag namespace and compares its
# content hash with the last applied state. On change (or unknown state)
# it runs the idempotent aepevent::rotate_tls operation through artie,
# which replaces the certificate on every node and reloads Postfix.
#
# Scheduled runs go straight through artie rather than a fresh Operation
# CR: the operator SA is intentionally minimal and may not create
# Operations. On-demand rotation still works via a normal Operation CR —
# same operation code.
#
# kubectl/jq are not on the operator image, so it talks to the API
# server with the pod's service-account token via urllib (pattern taken
# from the netbird-scheduler).

import fcntl
import hashlib
import json
import os
import ssl
import subprocess
import sys
import time
import urllib.parse
import urllib.request

SA = "/var/run/secrets/kubernetes.io/serviceaccount"
GROUP = "service.aep.asag.io"
VERSION = "v1"
DEF_NAME = "mailrelay"  # only act on instances of this service (definitionname)
SECRET_NS = "asag"      # TLS secrets live in asag by platform convention
ARTIE = "/usr/local/bin/artie"
HOME = os.environ.get("HOME", "/var/lib/operationagent")
LOCKDIR = os.path.join(HOME, ".mailrelay-adl-locks")
STATEDIR = os.path.join(HOME, ".mailrelay-adl-state")


def log(msg):
    print(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} mailrelay-cert-watch: {msg}", flush=True)


def env_from_processes(key):
    # cron does not inherit the pod's KUBERNETES_SERVICE_* env, and
    # cluster DNS is not configured in the operator container. Recover
    # the value from a process that has it (the operationagent runs as
    # the same user, so its /proc/<pid>/environ is readable).
    for pid in os.listdir("/proc"):
        if not pid.isdigit():
            continue
        try:
            with open(f"/proc/{pid}/environ", "rb") as f:
                for kv in f.read().split(b"\x00"):
                    if kv.startswith(key.encode() + b"="):
                        return kv.split(b"=", 1)[1].decode()
        except OSError:
            continue
    return None


def kube_env():
    host = os.environ.get("KUBERNETES_SERVICE_HOST") or env_from_processes("KUBERNETES_SERVICE_HOST")
    port = os.environ.get("KUBERNETES_SERVICE_PORT") or env_from_processes("KUBERNETES_SERVICE_PORT") or "443"
    return host, port


def api_setup():
    host, port = kube_env()
    if not host or not os.path.exists(f"{SA}/token"):
        log("not running in-cluster (no API host / SA token); abort")
        sys.exit(0)
    with open(f"{SA}/token") as f:
        token = f.read().strip()
    ctx = ssl.create_default_context(cafile=f"{SA}/ca.crt")
    return f"https://{host}:{port}", token, ctx


def api_get(base, token, ctx, path):
    req = urllib.request.Request(base + path, method="GET")
    req.add_header("Authorization", f"Bearer {token}")
    req.add_header("Accept", "application/json")
    with urllib.request.urlopen(req, context=ctx) as resp:
        return json.loads(resp.read().decode())


def setting_value(cfg, name):
    s = ((cfg.get("data") or {}).get("settings") or {}).get(name)
    if isinstance(s, dict):
        s = s.get("value")
    return s.strip() if isinstance(s, str) else ""


def secret_hash(base, token, ctx, name):
    path = f"/api/v1/namespaces/{SECRET_NS}/secrets/{urllib.parse.quote(name)}"
    data = api_get(base, token, ctx, path).get("data") or {}
    h = hashlib.sha256()
    h.update((data.get("tls.crt") or "").encode())
    h.update((data.get("tls.key") or "").encode())
    return h.hexdigest()


def run_rotate(kenv, namespace, instance):
    os.makedirs(LOCKDIR, exist_ok=True)
    lockpath = f"{LOCKDIR}/{namespace}_{instance}_rotate_tls.lock"
    lock = open(lockpath, "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        log(f"{namespace}/{instance}: rotate_tls still running; skipping this tick")
        return False

    args = {
        "service": {"name": instance, "namespace": namespace},
        "definition": {"labels": {f"{GROUP}/spec.name": DEF_NAME}},
    }
    env = dict(os.environ)
    host, port = kenv
    env["KUBERNETES_SERVICE_HOST"] = host
    env["KUBERNETES_SERVICE_PORT"] = port
    env.setdefault("HOME", "/var/lib/operationagent")
    log(f"{namespace}/{instance}: running rotate_tls")
    try:
        p = subprocess.run(
            [ARTIE, "operation", "aepevent::rotate_tls"],
            input=json.dumps(args).encode(),
            env=env,
            capture_output=True,
            timeout=600,
        )
        tail = (p.stdout.decode(errors="replace").strip().splitlines() or [""])[-1]
        log(f"{namespace}/{instance}: rotate_tls exit={p.returncode} {tail[:200]}")
        return p.returncode == 0
    except subprocess.TimeoutExpired:
        log(f"{namespace}/{instance}: rotate_tls timed out")
        return False
    finally:
        fcntl.flock(lock, fcntl.LOCK_UN)
        lock.close()


def main():
    base, token, ctx = api_setup()

    sel = f"{GROUP}/definitionname={DEF_NAME}"
    path = f"/apis/{GROUP}/{VERSION}/configs?labelSelector={urllib.parse.quote(sel)}"
    try:
        configs = api_get(base, token, ctx, path).get("items", [])
    except Exception as e:  # noqa: BLE001
        log(f"listing configs failed: {e}")
        sys.exit(1)

    kenv = kube_env()
    os.makedirs(STATEDIR, exist_ok=True)
    for cfg in configs:
        m = cfg["metadata"]
        ns, instance = m["namespace"], m["name"]
        secret = setting_value(cfg, "tls_secret")
        if not secret:
            continue
        try:
            current = secret_hash(base, token, ctx, secret)
        except Exception as e:  # noqa: BLE001
            log(f"{ns}/{instance}: reading secret {SECRET_NS}/{secret} failed: {e}")
            continue
        statefile = f"{STATEDIR}/{ns}_{instance}_tls.hash"
        try:
            with open(statefile) as f:
                last = f.read().strip()
        except OSError:
            last = ""
        if current == last:
            continue
        log(f"{ns}/{instance}: secret {SECRET_NS}/{secret} changed (or state unknown)")
        if run_rotate(kenv, ns, instance):
            with open(statefile, "w") as f:
                f.write(current)


if __name__ == "__main__":
    main()
