#!/usr/bin/env python3
"""boneio-containers — privileged container operations, by name only.

The ``boneio`` account is in the ``docker`` group, which is root-equivalent: a
container can bind-mount the host root and write to it. Removing the account
from that group is the fix, but the application still needs to start Node-RED
and reload Caddy — so those operations move here, behind a closed list of
verbs.

Two things make this worth doing rather than granting ``sudo docker compose``:

  * **The vector is the compose file, not the argument list.** ``docker compose
    up`` reads ``docker-compose.yaml`` from the project directory, and that file
    lives under /home/boneio. A sudoers rule permitting only ``docker compose
    up`` still lets anyone who can write that file run a container as root with
    the host filesystem mounted. So this helper refuses to act at all unless the
    compose file is root-owned and not writable by anyone else.
  * **No argument reaches docker from the caller.** Each verb expands to a fixed
    command. The only caller-supplied values are a log line count and a domain
    name, both validated.

Usage:
    boneio-containers <verb> [argument]
    boneio-containers --list-verbs
    boneio-containers --selftest

Exit codes:
    0  success
    1  refused, or the underlying command failed
    2  the compose file is not root-owned, so nothing was run
"""

from __future__ import annotations

import argparse
import json
import logging
import logging.handlers
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

LOG_FILE = "/var/log/boneio-containers.log"

#: The compose project. Hard-coded on purpose: a caller-supplied project
#: directory would be a caller-supplied compose file, which is the vector.
PROJECT_DIR = Path("/home/boneio/docker/nodered")
COMPOSE_FILE = PROJECT_DIR / "docker-compose.yaml"

#: Root-owned templates, restored from the pristine copy rather than written by
#: the application. The application used to write the compose file itself,
#: which is precisely what must stop.
TRUSTED_DIR = Path("/usr/lib/boneio/trusted")
COMPOSE_TEMPLATE = TRUSTED_DIR / "docker-compose.yaml"
COMPOSE_CLOUD_TEMPLATE = TRUSTED_DIR / "docker-compose-cloud.yaml"

CADDY_SERVICE = "caddy"
NODERED_SERVICE = "node-red"

_MAX_LOG_LINES = 2000

#: A container image tag, per Docker's own rules and no looser. Validated
#: before it can reach the compose file, which is what `docker compose up`
#: executes — the application used to rewrite that file itself to change the
#: Node-RED version, which is the same escalation path by another route.
_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$")
#: The line this helper is willing to change, and nothing else in the file.
_NODERED_IMAGE_RE = re.compile(
    r"^(?P<prefix>\s*image:\s*nodered/node-red:)(?P<tag>\S+)(?P<suffix>\s*)$",
    re.MULTILINE,
)

#: The Caddy image line, in a template or in the live compose file.
_CADDY_IMAGE_RE = re.compile(
    r"^(?P<prefix>\s*image:\s*)(?P<image>caddy:\S+)(?P<suffix>\s*)$",
    re.MULTILINE,
)
#: What a pinned Caddy image looks like: an exact tag and, normally, the digest
#: of the multi-arch index. Checked even though it comes from a root-owned
#: template, because it is written into the file ``docker compose up`` executes.
_PINNED_CADDY_RE = re.compile(
    r"^caddy:[A-Za-z0-9_][A-Za-z0-9._-]{0,127}(@sha256:[0-9a-f]{64})?$"
)


def _configure_logging() -> logging.Logger:
    """Set up logging without making the log file a hard requirement.

    Returns:
        The helper's logger.
    """
    handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
    try:
        handlers.append(
            logging.handlers.RotatingFileHandler(
                LOG_FILE, maxBytes=524_288, backupCount=1, encoding="utf-8"
            )
        )
    except OSError as exc:
        print(f"boneio-containers: cannot open {LOG_FILE}: {exc}", file=sys.stderr)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s [%(levelname)s] %(message)s",
        handlers=handlers,
    )
    return logging.getLogger("boneio-containers")


_LOGGER = _configure_logging()


class Refused(Exception):
    """The request was rejected; nothing was run."""


#: Verb → the arguments that follow ``docker compose -f <file>``. Constants
#: only: nothing in this table can be influenced from outside, and building the
#: argv at call time keeps it honest about which compose file is in play rather
#: than freezing the path at import.
VERBS: dict[str, tuple[str, ...]] = {
    "status": ("ps", "--format", "json"),
    "up": ("up", "-d"),
    "down": ("down",),
    "pull": ("pull",),
    "start-nodered": ("up", "-d", NODERED_SERVICE),
    "stop-nodered": ("stop", NODERED_SERVICE),
    "restart-nodered": ("restart", NODERED_SERVICE),
    "pull-nodered": ("pull", NODERED_SERVICE),
    "start-caddy": ("up", "-d", CADDY_SERVICE),
    "restart-caddy": ("restart", CADDY_SERVICE),
    "reload-caddy": (
        # The container runs /tmp/Caddyfile, written by init-certs.sh on every
        # start. /etc/caddy/Caddyfile is the image's stock config: reloading it
        # takes HTTPS down until the container is restarted.
        "exec", CADDY_SERVICE, "caddy", "reload", "--config", "/tmp/Caddyfile"
    ),
}

#: Verbs that talk to the docker daemon rather than to a compose project.
DAEMON_VERBS: dict[str, tuple[str, ...]] = {
    "ps": ("ps", "-a", "--format", "json"),
    "names": ("ps", "-a", "--format", "{{.Names}}"),
}

#: How much of a container log the diagnostics bundle takes.
_CONTAINER_LOG_LINES = 200
#: A container name, per Docker's own rules. Checked, and then checked again
#: against the containers that actually exist — a name is the one piece of
#: caller data that reaches docker here, so a regex alone is not enough: it
#: would still admit anything shaped like a name, including a flag.
_CONTAINER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")


def _compose(*args: str) -> list[str]:
    """A docker compose command for the fixed project.

    Args:
        *args: Verb and arguments to pass to compose.

    Returns:
        The full argv.
    """
    return ["docker", "compose", "-f", str(COMPOSE_FILE), *args]


def _argv_for(verb: str) -> list[str]:
    """The command a fixed verb expands to.

    Args:
        verb: One of :data:`VERBS` or :data:`DAEMON_VERBS`.

    Returns:
        The full argv.

    Raises:
        KeyError: If the verb is not a fixed one.
    """
    if verb in DAEMON_VERBS:
        return ["docker", *DAEMON_VERBS[verb]]
    return _compose(*VERBS[verb])

#: Verbs that take one validated argument.
PARAMETERISED_VERBS = {
    "logs-caddy",
    "logs-nodered",
    "apply-cloud-template",
    "remove-cloud-template",
    "set-nodered-image",
    "logs-container",
}

#: Verbs that take no argument but do more than one compose command.
COMPOSITE_VERBS = {"caddy-image-state", "caddy-image-apply"}

ALL_VERBS = sorted(set(VERBS) | set(DAEMON_VERBS) | PARAMETERISED_VERBS | COMPOSITE_VERBS)

#: Verbs that only read. They are allowed to run even when the compose file is
#: not yet root-owned, because refusing them would leave the UI unable to say
#: what is wrong.
READ_ONLY_VERBS = {
    "status", "ps", "names", "logs-caddy", "logs-nodered", "logs-container",
    "caddy-image-state",
}


def _assert_root() -> None:
    """Exit unless running as root."""
    if os.geteuid() != 0:
        _LOGGER.error("boneio-containers must be run as root via sudo.")
        sys.exit(1)


def _root_owned(path: Path) -> bool:
    """Whether *path* is a real file owned by root and not writable by others.

    Args:
        path: Path to inspect.

    Returns:
        True when root can trust the file's contents.
    """
    try:
        st = path.lstat()
    except OSError:
        return False
    if not os.path.isfile(path) or os.path.islink(path):
        return False
    return st.st_uid == 0 and not st.st_mode & 0o022


def _assert_compose_trustworthy() -> None:
    """Refuse to run compose against a file the caller could have written.

    Raises:
        Refused: If the compose file is missing or not root-owned.
    """
    if not _root_owned(COMPOSE_FILE):
        raise Refused(
            f"{COMPOSE_FILE} is missing, a symlink, or not owned by root. "
            "Running compose against it would let whoever can write that file "
            "start a container as root with the host filesystem mounted — the "
            "file is the vector, not the command line. Reinstall it from "
            f"{COMPOSE_TEMPLATE} with a migration."
        )


def _run(argv: list[str], timeout: int = 120) -> int:
    """Run a fixed command in the project directory.

    Args:
        argv: The command to run.
        timeout: Seconds to allow.

    Returns:
        Process exit status; output is passed through.
    """
    _LOGGER.info("RUN: %s", " ".join(argv))
    try:
        result = subprocess.run(
            argv, cwd=str(PROJECT_DIR), capture_output=True, text=True, timeout=timeout
        )
    except subprocess.TimeoutExpired:
        _LOGGER.error("timed out after %ds: %s", timeout, " ".join(argv))
        return 1
    except OSError as exc:
        _LOGGER.error("cannot run %s: %s", argv[0], exc)
        return 1

    if result.stdout:
        sys.stdout.write(result.stdout)
    if result.returncode != 0:
        _LOGGER.error("rc=%d: %s", result.returncode, result.stderr.strip())
        sys.stderr.write(result.stderr)
    return result.returncode


def _logs(service: str, argument: str | None) -> int:
    """Show the tail of a service's log.

    Args:
        service: Compose service name.
        argument: Number of lines, as text.

    Returns:
        Process exit status.

    Raises:
        Refused: If the line count is not a sane integer.
    """
    lines = 200
    if argument is not None:
        if not argument.isdigit() or not 1 <= int(argument) <= _MAX_LOG_LINES:
            raise Refused(
                f"log line count must be an integer between 1 and {_MAX_LOG_LINES}"
            )
        lines = int(argument)
    return _run(_compose("logs", "--tail", str(lines), "--no-color", service), timeout=60)


def _install_compose(source: Path) -> int:
    """Install a root-owned compose file, copied from a trusted template.

    Nothing is substituted into it. The cloud template serves a wildcard
    certificate and takes the hostname from the container itself, so no
    caller-supplied value belongs in this file — which is the point, since the
    file is what ``docker compose up`` executes.

    Args:
        source: Template in the pristine copy.

    Returns:
        0 on success.

    Raises:
        Refused: If the template is missing or not root-owned.
    """
    if not _root_owned(source):
        raise Refused(
            f"template {source} is missing or not root-owned. The compose file "
            "must come from the pristine copy, not from the application."
        )
    content = source.read_text(encoding="utf-8")

    PROJECT_DIR.mkdir(parents=True, exist_ok=True)
    _back_up_if_customised(content)
    with tempfile.NamedTemporaryFile(
        dir=PROJECT_DIR, delete=False, suffix=".tmp", mode="w", encoding="utf-8"
    ) as tmp:
        tmp.write(content)
        tmp_path = tmp.name
    try:
        os.chmod(tmp_path, 0o644)
        if os.geteuid() == 0:
            # The whole point of installing it from here is that it ends up
            # root-owned; running as root this cannot fail, and not running as
            # root there is no privilege to protect.
            os.chown(tmp_path, 0, 0)
        os.replace(tmp_path, COMPOSE_FILE)
    except OSError:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    _LOGGER.info("INSTALLED: %s from %s (root:root 0644)", COMPOSE_FILE, source.name)
    return 0


def _back_up_if_customised(replacement: str) -> None:
    """Keep a copy of a compose file that matches neither template.

    The code this replaces wrote a ``.yaml.bak`` before switching, which mattered
    for anyone who had hand-edited the file. Hand-editing is what is being taken
    away — the file is what ``docker compose up`` executes — but taking it away
    should not silently discard what somebody already wrote.

    Args:
        replacement: The content about to be installed.
    """
    if not COMPOSE_FILE.is_file() or COMPOSE_FILE.is_symlink():
        return
    try:
        current = COMPOSE_FILE.read_text(encoding="utf-8")
    except OSError as exc:
        _LOGGER.warning("Cannot read %s to back it up: %s", COMPOSE_FILE, exc)
        return
    if current == replacement:
        return

    known = []
    for template in (COMPOSE_TEMPLATE, COMPOSE_CLOUD_TEMPLATE):
        try:
            known.append(template.read_text(encoding="utf-8"))
        except OSError:
            continue
    if current in known:
        # One of ours; the template it came from is the backup.
        return

    backup = COMPOSE_FILE.with_suffix(".yaml.bak")
    if backup.exists():
        _LOGGER.info("Keeping the existing backup at %s", backup)
        return
    try:
        backup.write_text(current, encoding="utf-8")
        os.chmod(backup, 0o644)
        _LOGGER.info(
            "Backed up a customised %s to %s before replacing it",
            COMPOSE_FILE, backup,
        )
    except OSError as exc:
        _LOGGER.warning("Could not back up %s: %s", COMPOSE_FILE, exc)


def _logs_container(argument: str | None) -> int:
    """Show the tail of one container's log, by name.

    The diagnostics bundle reads every container's log, and compose prefixes
    the names with the project (``nodered-caddy-1``, not ``caddy``), so a fixed
    list would produce "No such container" for exactly the logs somebody wanted.
    The name therefore has to come from the caller — and is accepted only if it
    matches a container that exists, which is a stronger check than any pattern.

    Args:
        argument: The container name.

    Returns:
        Process exit status.

    Raises:
        Refused: If the name is malformed or is not a container on this host.
    """
    if not argument or not _CONTAINER_NAME_RE.match(argument):
        raise Refused(f"not a valid container name: {argument!r}")

    listing = subprocess.run(
        ["docker", *DAEMON_VERBS["names"]],
        cwd=str(PROJECT_DIR), capture_output=True, text=True, timeout=30,
    )
    if listing.returncode != 0:
        raise Refused(
            f"cannot list containers, so {argument!r} cannot be checked: "
            f"{listing.stderr.strip()}"
        )
    existing = {line.strip() for line in listing.stdout.splitlines() if line.strip()}
    if argument not in existing:
        raise Refused(f"no such container: {argument!r}")

    return _run(
        ["docker", "logs", "--tail", str(_CONTAINER_LOG_LINES), argument], timeout=60
    )


def _set_nodered_image(argument: str | None) -> int:
    """Change the Node-RED image tag in the compose file, and nothing else.

    The application used to rewrite the whole compose file to do this. That file
    is what ``docker compose up`` executes, so being able to write it is being
    able to run a container as root with the host filesystem mounted — the same
    hole as the docker group, reached through the update flow. Here the only
    thing that can change is one tag, matched by a fixed pattern.

    Args:
        argument: The image tag.

    Returns:
        0 on success.

    Raises:
        Refused: If the tag is implausible or the image line is not there.
    """
    if not argument or not _TAG_RE.match(argument):
        raise Refused(
            f"not a valid image tag: {argument!r}. Checked before it reaches the "
            "compose file, so a tag cannot smuggle YAML into it."
        )

    content = COMPOSE_FILE.read_text(encoding="utf-8")
    match = _NODERED_IMAGE_RE.search(content)
    if match is None:
        raise Refused(
            f"no 'image: nodered/node-red:<tag>' line in {COMPOSE_FILE}; refusing "
            "to guess what to edit"
        )
    if match.group("tag") == argument:
        _LOGGER.info("Node-RED image is already nodered/node-red:%s", argument)
        return 0

    updated = _NODERED_IMAGE_RE.sub(
        lambda m: f"{m.group('prefix')}{argument}{m.group('suffix')}", content, count=1
    )
    _back_up_if_customised(updated)

    with tempfile.NamedTemporaryFile(
        dir=PROJECT_DIR, delete=False, suffix=".tmp", mode="w", encoding="utf-8"
    ) as tmp:
        tmp.write(updated)
        tmp_path = tmp.name
    try:
        os.chmod(tmp_path, 0o644)
        if os.geteuid() == 0:
            os.chown(tmp_path, 0, 0)
        os.replace(tmp_path, COMPOSE_FILE)
    except OSError:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise
    _LOGGER.info(
        "Node-RED image set to nodered/node-red:%s (was %s)",
        argument, match.group("tag"),
    )
    return 0


def _caddy_images() -> tuple[str, str | None]:
    """The Caddy image this release pins, and the one the compose file uses.

    Returns:
        ``(pinned, configured)`` — configured is None when the compose file has
        no Caddy image line.

    Raises:
        Refused: If the template is not root-owned or pins nothing usable.
    """
    if not _root_owned(COMPOSE_TEMPLATE):
        raise Refused(f"template {COMPOSE_TEMPLATE} is missing or not root-owned")
    match = _CADDY_IMAGE_RE.search(COMPOSE_TEMPLATE.read_text(encoding="utf-8"))
    if match is None or not _PINNED_CADDY_RE.match(match.group("image")):
        raise Refused(f"{COMPOSE_TEMPLATE} pins no Caddy image this helper accepts")
    pinned = match.group("image")
    try:
        current = _CADDY_IMAGE_RE.search(COMPOSE_FILE.read_text(encoding="utf-8"))
    except OSError:
        current = None
    return pinned, current.group("image") if current else None


def _caddy_image_state() -> int:
    """Print the pinned and the configured Caddy image. Changes nothing.

    Returns:
        0, with JSON on stdout.
    """
    pinned, configured = _caddy_images()
    print(json.dumps({
        "pinned": pinned,
        "configured": configured,
        "update_available": configured != pinned,
    }))
    return 0


def _caddy_image_apply() -> int:
    """Move Caddy to the image this boneIO release pins.

    Caddy sat on ``caddy:2-alpine``: whatever that tag meant the day an image
    was built, never updated after, and different on every controller. The
    release now pins an exact version and digest in the root-owned template,
    and this takes that one value into the live compose file — the one line,
    nothing else, the way set-nodered-image changes only Node-RED's tag. There
    is no argument: which version Caddy runs is the release's decision, not the
    caller's.

    Then the image is pulled and only Caddy is recreated. The HTTPS panel drops
    for the seconds that takes.

    Returns:
        0 on success.

    Raises:
        Refused: If there is nothing to pin or no Caddy line to change.
    """
    pinned, configured = _caddy_images()
    if configured is None:
        raise Refused(f"no 'image: caddy:...' line in {COMPOSE_FILE}; refusing to guess")
    if configured != pinned:
        content = COMPOSE_FILE.read_text(encoding="utf-8")
        updated = _CADDY_IMAGE_RE.sub(
            lambda m: f"{m.group('prefix')}{pinned}{m.group('suffix')}", content, count=1
        )
        _back_up_if_customised(updated)
        with tempfile.NamedTemporaryFile(
            dir=PROJECT_DIR, delete=False, suffix=".tmp", mode="w", encoding="utf-8"
        ) as tmp:
            tmp.write(updated)
            tmp_path = tmp.name
        try:
            os.chmod(tmp_path, 0o644)
            if os.geteuid() == 0:
                os.chown(tmp_path, 0, 0)
            os.replace(tmp_path, COMPOSE_FILE)
        except OSError:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
            raise
        _LOGGER.info("Caddy image set to %s (was %s)", pinned, configured)
    rc = _run(_compose("pull", CADDY_SERVICE), timeout=900)
    if rc != 0:
        return rc
    return _run(_compose("up", "-d", CADDY_SERVICE), timeout=300)


def _apply_cloud_template(argument: str | None) -> int:
    """Switch the compose project to the cloud template.

    Args:
        argument: Must be absent. The template needs no parameter, so accepting
            one would only create a path for caller data to reach the file.

    Returns:
        Process exit status.

    Raises:
        Refused: If an argument is supplied.
    """
    if argument is not None:
        raise Refused("apply-cloud-template takes no argument")
    _LOGGER.info("applying the cloud compose template")
    return _install_compose(COMPOSE_CLOUD_TEMPLATE)


def _remove_cloud_template(argument: str | None) -> int:
    """Restore the plain compose template.

    Args:
        argument: Unused.

    Returns:
        Process exit status.
    """
    if argument is not None:
        raise Refused("remove-cloud-template takes no argument")
    _LOGGER.info("restoring the plain compose template")
    return _install_compose(COMPOSE_TEMPLATE)


def selftest() -> int:
    """Check the helper can do its job, changing nothing.

    Returns:
        0 when healthy.
    """
    problems: list[str] = []
    if shutil.which("docker") is None:
        problems.append("docker is not installed")
    if not PROJECT_DIR.is_dir():
        problems.append(f"the compose project directory {PROJECT_DIR} does not exist")
    if not _root_owned(COMPOSE_FILE):
        problems.append(
            f"{COMPOSE_FILE} is not root-owned, so every write verb will refuse"
        )
    for template in (COMPOSE_TEMPLATE, COMPOSE_CLOUD_TEMPLATE):
        if not _root_owned(template):
            problems.append(f"template {template} is missing or not root-owned")

    for problem in problems:
        _LOGGER.error("selftest: %s", problem)
    if problems:
        return 1
    _LOGGER.info("selftest: %d verbs available, compose file trusted.", len(ALL_VERBS))
    return 0


def main(argv: list[str] | None = None) -> int:
    """Entry point.

    Returns:
        Process exit status.
    """
    parser = argparse.ArgumentParser(
        description="boneIO privileged container operations"
    )
    parser.add_argument("verb", nargs="?", help=f"one of: {', '.join(ALL_VERBS)}")
    # A value starting with "-" would otherwise be parsed as an option and make
    # argparse exit before the domain validator ever sees it. The validator
    # should be the thing that rejects it, with a message that says why.
    parser.add_argument(
        "argument", nargs="?", default=None,
        help="log line count, or a domain",
    )
    parser.add_argument(
        "--list-verbs", action="store_true", help="print the allowed verbs as JSON"
    )
    parser.add_argument(
        "--selftest", action="store_true", help="check the helper, change nothing"
    )
    args, unparsed = parser.parse_known_args(argv)
    if unparsed:
        # Anything argparse could not place is still caller input, so it is
        # refused rather than ignored.
        if args.argument is None and len(unparsed) == 1:
            args.argument = unparsed[0]
        else:
            _LOGGER.error("REFUSED: unexpected arguments: %s", unparsed)
            return 1

    if args.list_verbs:
        print(json.dumps(ALL_VERBS))
        return 0

    _assert_root()

    if args.selftest:
        return selftest()

    if not args.verb:
        parser.error("a verb is required")

    try:
        if args.verb not in ALL_VERBS:
            raise Refused(
                f"unknown verb {args.verb!r}. Allowed: {', '.join(ALL_VERBS)}"
            )

        if args.verb not in READ_ONLY_VERBS:
            _assert_compose_trustworthy()

        if args.verb == "logs-caddy":
            return _logs(CADDY_SERVICE, args.argument)
        if args.verb == "logs-nodered":
            return _logs(NODERED_SERVICE, args.argument)
        if args.verb == "logs-container":
            return _logs_container(args.argument)
        if args.verb == "set-nodered-image":
            return _set_nodered_image(args.argument)
        if args.verb in COMPOSITE_VERBS and args.argument is not None:
            raise Refused(f"verb {args.verb!r} takes no argument")
        if args.verb == "caddy-image-state":
            return _caddy_image_state()
        if args.verb == "caddy-image-apply":
            return _caddy_image_apply()
        if args.verb == "apply-cloud-template":
            return _apply_cloud_template(args.argument)
        if args.verb == "remove-cloud-template":
            return _remove_cloud_template(args.argument)

        if args.argument is not None:
            raise Refused(f"verb {args.verb!r} takes no argument")
        return _run(_argv_for(args.verb))
    except Refused as exc:
        _LOGGER.error("REFUSED: %s", exc)
        return 2 if "not owned by root" in str(exc) else 1


if __name__ == "__main__":
    sys.exit(main())
