#!/usr/bin/env python3
# NetBird operator scheduler.
#
# Runs once per minute (see /etc/cron.d/netbird-adl-scheduler). For every
# NetBird instance this operator manages it reads the per-instance cron
# settings `backup_schedule` and `update_schedule` from the instance's
# Config CR — the operator's service account may read configs (it may not
# read provisions), and the Config always reflects the current resolved
# settings. When the current (UTC) minute matches, it runs the corresponding
# operation (aepevent::backup / aepevent::update) through artie directly.
#
# 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 runs still use a normal Operation (created by an admin/the
# platform) which the operationagent executes — 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.

import fcntl
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 = "netbird"  # only act on instances of this service (definitionname)
ARTIE = "/usr/local/bin/artie"
# Under the operationagent home (always writable by the user artie runs as).
LOCKDIR = os.path.join(os.environ.get("HOME", "/var/lib/operationagent"), ".netbird-adl-locks")


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


def env_from_processes(key):
    # cron does not inherit the pod's KUBERNETES_SERVICE_* env, and cluster
    # DNS (kubernetes.default.svc) is not configured in the operator
    # container. Recover the value from a process that does have 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 cron_field_matches(field, value, minval, maxval):
    # Supports '*', '*/n', 'a', 'a-b', 'a,b,c' and combinations.
    if field == "*":
        return True
    for part in field.split(","):
        part = part.strip()
        step = 1
        if "/" in part:
            rng, step_s = part.split("/", 1)
            step = int(step_s)
        else:
            rng = part
        if rng == "*":
            lo, hi = minval, maxval
        elif "-" in rng:
            lo_s, hi_s = rng.split("-", 1)
            lo, hi = int(lo_s), int(hi_s)
        else:
            lo = hi = int(rng)
        if lo <= value <= hi and (value - lo) % step == 0:
            return True
    return False


def cron_due(expr, now):
    fields = expr.split()
    if len(fields) != 5:
        log(f"ignoring invalid cron expression {expr!r} (need 5 fields)")
        return False
    minute, hour, dom, mon, dow = fields
    wday_cron = (now.tm_wday + 1) % 7  # struct_time Mon=0..Sun=6 -> cron Sun=0
    return (
        cron_field_matches(minute, now.tm_min, 0, 59)
        and cron_field_matches(hour, now.tm_hour, 0, 23)
        and cron_field_matches(dom, now.tm_mday, 1, 31)
        and cron_field_matches(mon, now.tm_mon, 1, 12)
        and (
            cron_field_matches(dow, wday_cron, 0, 6)
            or cron_field_matches(dow, 7 if wday_cron == 0 else wday_cron, 0, 7)
        )
    )


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 run_operation(kenv, namespace, instance, event):
    # A per-instance/-event lock keeps a slow run from overlapping the next
    # minute's tick.
    os.makedirs(LOCKDIR, exist_ok=True)
    lockpath = f"{LOCKDIR}/{namespace}_{instance}_{event}.lock"
    lock = open(lockpath, "w")
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        log(f"{namespace}/{instance}: {event} still running; skipping this tick")
        return

    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 {event}")
    try:
        p = subprocess.run(
            [ARTIE, "operation", f"aepevent::{event}"],
            input=json.dumps(args).encode(),
            env=env,
            capture_output=True,
            timeout=1800,
        )
        tail = (p.stdout.decode(errors="replace").strip().splitlines() or [""])[-1]
        log(f"{namespace}/{instance}: {event} exit={p.returncode} {tail[:200]}")
    except subprocess.TimeoutExpired:
        log(f"{namespace}/{instance}: {event} timed out")
    finally:
        fcntl.flock(lock, fcntl.LOCK_UN)
        lock.close()


def main():
    base, token, ctx = api_setup()
    now = time.gmtime()

    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()
    for cfg in configs:
        m = cfg["metadata"]
        ns, instance = m["namespace"], m["name"]
        bs = setting_value(cfg, "backup_schedule")
        us = setting_value(cfg, "update_schedule")
        if bs and cron_due(bs, now):
            run_operation(kenv, ns, instance, "backup")
        if us and cron_due(us, now):
            run_operation(kenv, ns, instance, "update")


if __name__ == "__main__":
    main()
