#!/usr/bin/python3
"""vexor-jobd — root background-job executor for Vexor.

Runs as root (its own systemd unit, NOT NoNewPrivileges) so it can perform the
few privileged operations the hardened vexor-api (NoNewPrivileges=yes, cannot
sudo) needs — currently: installing plugin runtime dependencies
(dnf / cpanm / pip3 / gem).

Security model
--------------
* The only way to submit a job is to drop a JSON file into
  /run/vexor/jobs/queue/, which is mode 0770 root:vexor — i.e. only the vexor
  user (vexor-api) and root can write there.
* jobd NEVER executes arbitrary commands. It dispatches on a fixed ``type`` and
  every package/module name is validated against a strict allow-list regex.
  Perl modules with a known distro RPM are installed via dnf; otherwise cpanm.
* Output is streamed to /run/vexor/jobs/<id>/output.log and the final state to
  /run/vexor/jobs/<id>/status.json so vexor-api can tail/stream it to the UI.
"""
from __future__ import annotations

import glob
import json
import os
import re
import signal
import subprocess
import sys
import time

JOBS_DIR = "/run/vexor/jobs"
QUEUE_DIR = os.path.join(JOBS_DIR, "queue")
POLL_SEC = 0.5

# Perl module -> distro RPM (prefer dnf over cpanm: faster + safer). Mirrors
# _PERL_RPM_MAP in app/routers/plugin_catalog_router.py.
PERL_RPM_MAP = {
    "Net::SNMP": "perl-Net-SNMP", "Net::DNS": "perl-Net-DNS",
    "Net::Telnet": "perl-Net-Telnet", "Net::LDAP": "perl-LDAP",
    "Net::Ping": "perl-Net-Ping", "Net::SSH::Perl": "perl-Net-SSH-Perl",
    "DBI": "perl-DBI", "DBD::mysql": "perl-DBD-MySQL",
    "DBD::Pg": "perl-DBD-Pg", "DBD::ODBC": "perl-DBD-ODBC",
    "Net::SMTP::SSL": "perl-Net-SMTP-SSL", "Net::SSLeay": "perl-Net-SSLeay",
    "IO::Socket::SSL": "perl-IO-Socket-SSL", "JSON": "perl-JSON",
    "JSON::XS": "perl-JSON-XS", "JSON::PP": "perl-JSON-PP",
    "LWP::UserAgent": "perl-libwww-perl", "LWP::Protocol::https": "perl-LWP-Protocol-https",
    "Time::HiRes": "perl-Time-HiRes", "Time::Local": "perl-Time-Local",
    "Date::Format": "perl-TimeDate", "Date::Parse": "perl-TimeDate",
    "Number::Format": "perl-Number-Format", "XML::Simple": "perl-XML-Simple",
    "XML::LibXML": "perl-XML-LibXML", "YAML": "perl-YAML",
    "YAML::XS": "perl-YAML-LibYAML", "Crypt::SSLeay": "perl-Crypt-SSLeay",
    "Digest::HMAC_SHA1": "perl-Digest-HMAC", "Net::Netmask": "perl-Net-Netmask",
}

PERL_RE = re.compile(r"^[A-Za-z0-9:][A-Za-z0-9:._-]*$")
PKG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")

_running = True


def _log(msg: str) -> None:
    sys.stderr.write(f"[vexor-jobd] {msg}\n")
    sys.stderr.flush()


def _ensure_dirs() -> None:
    os.makedirs(QUEUE_DIR, exist_ok=True)
    # /run/vexor/jobs    0775 root:vexor  (API reads status/log)
    # /run/vexor/jobs/queue 0770 root:vexor (only vexor/root submit)
    try:
        import grp
        gid = grp.getgrnam("vexor").gr_gid
    except Exception:
        gid = -1
    os.chown(JOBS_DIR, 0, gid)
    os.chmod(JOBS_DIR, 0o775)
    os.chown(QUEUE_DIR, 0, gid)
    os.chmod(QUEUE_DIR, 0o770)


def _write_status(job_dir: str, status: dict) -> None:
    tmp = os.path.join(job_dir, "status.json.tmp")
    with open(tmp, "w") as fh:
        json.dump(status, fh)
    os.replace(tmp, os.path.join(job_dir, "status.json"))
    try:
        os.chmod(os.path.join(job_dir, "status.json"), 0o644)
    except OSError:
        pass


def _run_streamed(argv: list, log_fh, env=None) -> int:
    """Run a command, streaming combined stdout/stderr to the open log file."""
    log_fh.write(f"$ {' '.join(argv)}\n")
    log_fh.flush()
    try:
        proc = subprocess.Popen(
            argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, bufsize=1, env=env,
        )
    except FileNotFoundError as exc:
        log_fh.write(f"command not found: {exc}\n")
        log_fh.flush()
        return 127
    assert proc.stdout is not None
    for line in proc.stdout:
        log_fh.write(line)
        log_fh.flush()
    proc.wait()
    log_fh.write(f"[exit {proc.returncode}]\n")
    log_fh.flush()
    return proc.returncode


def _job_plugin_deps(args: dict, log_fh) -> tuple[bool, str]:
    """Install requested Perl/Python/Ruby modules. Returns (ok, summary)."""
    perl = [m for m in args.get("perl", []) if isinstance(m, str) and PERL_RE.match(m)]
    python = [m for m in args.get("python", []) if isinstance(m, str) and PKG_RE.match(m)]
    ruby = [m for m in args.get("ruby", []) if isinstance(m, str) and PKG_RE.match(m)]
    rejected = ([m for m in args.get("perl", []) if m not in perl]
                + [m for m in args.get("python", []) if m not in python]
                + [m for m in args.get("ruby", []) if m not in ruby])
    for r in rejected:
        log_fh.write(f"! rejected invalid package name: {r!r}\n")
    failures: list[str] = []

    if perl:
        rpms, via_cpanm = [], []
        for mod in perl:
            rpm = PERL_RPM_MAP.get(mod)
            (rpms if rpm else via_cpanm).append(rpm or mod)
        if rpms:
            log_fh.write(f"\n== Perl via dnf: {', '.join(rpms)} ==\n")
            if _run_streamed(["/usr/bin/dnf", "-y", "install", *rpms], log_fh) != 0:
                failures.append("perl/dnf")
        for mod in via_cpanm:
            log_fh.write(f"\n== Perl via cpanm: {mod} ==\n")
            if _run_streamed(["/usr/bin/cpanm", "--notest", "--quiet", "--", mod], log_fh) != 0:
                failures.append(f"perl/{mod}")

    if python:
        log_fh.write(f"\n== Python via pip3: {', '.join(python)} ==\n")
        if _run_streamed(["/usr/bin/pip3", "install", "--", *python], log_fh) != 0:
            failures.append("python/pip3")

    if ruby:
        log_fh.write(f"\n== Ruby via gem: {', '.join(ruby)} ==\n")
        if _run_streamed(["/usr/bin/gem", "install", "--no-document", "--", *ruby], log_fh) != 0:
            failures.append("ruby/gem")

    if not (perl or python or ruby):
        log_fh.write("nothing to install (no valid package names)\n")
        return False, "no valid packages"
    if failures:
        return False, "failed: " + ", ".join(failures)
    return True, "all requested modules installed"


def _job_keycloak_backup(args: dict, log_fh) -> tuple[bool, str]:
    """Run the Keycloak DB dump on demand so a Vexor backup can embed a
    point-in-time-consistent copy of the realm (users, roles, clients).

    Delegates to the same root-only script the nightly timer uses; that
    script writes the dump group-readable by ``vexor`` so vexor-api can fold
    it into the backup archive.
    """
    script = "/usr/local/sbin/vexor-keycloak-backup"
    if not (os.path.isfile(script) and os.access(script, os.X_OK)):
        log_fh.write(f"keycloak backup script not found/executable: {script}\n")
        return False, "keycloak backup script not installed"
    rc = _run_streamed([script], log_fh)
    if rc != 0:
        return False, f"keycloak dump failed (exit {rc})"
    return True, "keycloak dump complete"


DISPATCH = {
    "plugin-deps": _job_plugin_deps,
    "keycloak-backup": _job_keycloak_backup,
}


def _process(queue_file: str) -> None:
    # Claim the job atomically so we never run it twice.
    taken = queue_file + ".taken"
    try:
        os.rename(queue_file, taken)
    except OSError:
        return
    try:
        with open(taken) as fh:
            spec = json.load(fh)
    except Exception as exc:
        _log(f"bad job spec {queue_file}: {exc}")
        try:
            os.unlink(taken)
        except OSError:
            pass
        return

    jid = str(spec.get("id", "")).strip()
    jtype = spec.get("type", "")
    if not re.match(r"^[A-Za-z0-9_-]{1,64}$", jid) or jtype not in DISPATCH:
        _log(f"rejecting job id={jid!r} type={jtype!r}")
        try:
            os.unlink(taken)
        except OSError:
            pass
        return

    job_dir = os.path.join(JOBS_DIR, jid)
    os.makedirs(job_dir, exist_ok=True)
    os.chmod(job_dir, 0o755)
    started = time.time()
    _write_status(job_dir, {"id": jid, "type": jtype, "state": "running",
                            "started": started})
    _log(f"running job {jid} ({jtype})")
    log_path = os.path.join(job_dir, "output.log")
    ok, summary = False, "internal error"
    with open(log_path, "a") as log_fh:
        try:
            os.chmod(log_path, 0o644)
        except OSError:
            pass
        try:
            ok, summary = DISPATCH[jtype](spec.get("args", {}) or {}, log_fh)
        except Exception as exc:  # noqa: BLE001
            log_fh.write(f"\n[vexor-jobd] job crashed: {exc}\n")
            ok, summary = False, f"crashed: {exc}"
    _write_status(job_dir, {"id": jid, "type": jtype,
                            "state": "ok" if ok else "failed",
                            "started": started, "ended": time.time(),
                            "summary": summary})
    _log(f"job {jid} -> {'ok' if ok else 'failed'} ({summary})")
    try:
        os.unlink(taken)
    except OSError:
        pass


def _stop(*_a) -> None:
    global _running
    _running = False


def main() -> int:
    signal.signal(signal.SIGTERM, _stop)
    signal.signal(signal.SIGINT, _stop)
    _ensure_dirs()
    _log("started")
    while _running:
        try:
            files = sorted(glob.glob(os.path.join(QUEUE_DIR, "*.json")),
                           key=lambda p: os.path.getmtime(p))
            for qf in files:
                _process(qf)
        except Exception as exc:  # noqa: BLE001
            _log(f"loop error: {exc}")
        time.sleep(POLL_SEC)
    _log("stopped")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
