#!/usr/bin/env python3
"""Render bare-Docker deploy scripts for a FermiHDI cluster — no orchestrator.

For operators who run several hosts with plain Docker and want neither
Kubernetes nor Ansible. Reads the same cluster.yaml every other tool here does
and writes one self-contained shell script per host:

    docker-deploy/<host>.sh     the `docker run` commands that host's block calls for
    docker-deploy/deploy-all.sh  copies each script to its host over SSH and runs it
    docker-deploy/README.md      the port map, the secrets to export, the run order

The node plane (scc, sn, bloomrf, ingester, proxy, graphql, mcp) runs with
`--network host`: every node finds its own address by matching an interface
against the plane CIDRs, which it cannot do from inside a bridge. Because host
networking shares the host's ports, two node containers on ONE host must not
want the same port — this tool computes the port map and refuses to emit a host
whose instances collide (see PLANE PORTS below).

The DMS is the exception. Its CA and Core both bind 6985 and both serve
observability on 8080, so the stack runs on its own bridge networks exactly as
the Ansible layer deploys it, and only Core's southbound port, the collector and
the WebUX are published to the host.

This changes nothing. It writes scripts; you read them, then run them.

Usage:
    install/fermihdi-docker [cluster.yaml] [-o docker-deploy]
    install/fermihdi-docker cluster.yaml --no-validate
"""
import argparse
import importlib.machinery
import importlib.util
import os
import pathlib
import re
import stat
import sys

try:
    import yaml
except ImportError:
    sys.exit("PyYAML is required:  apt-get install -y python3-yaml")

HERE = pathlib.Path(__file__).resolve().parent

# Reuse the validator's cluster model rather than keeping a second copy of the
# role floors and DMA kinds — a second table is a second table to get wrong.
_spec = importlib.util.spec_from_loader(
    "fermihdi_validate",
    importlib.machinery.SourceFileLoader("fermihdi_validate", str(HERE / "validate-cluster")),
)
_vc = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_vc)

# --- What differs between the node roles (mirrors roles/fermihdi_node/defaults) ---
NODE_SPEC = {
    "scc":     {"registers": True,  "hugepages": True,  "privileged": None, "healthcheck": "/app/hd_storage_controller"},
    "sn":      {"registers": True,  "hugepages": True,  "privileged": True, "healthcheck": "/app/hd_storage_node"},
    "bloomrf": {"registers": True,  "hugepages": True,  "privileged": None, "healthcheck": "/app/hd_bloomrf"},
    "ingester":{"registers": True,  "hugepages": True,  "privileged": None, "healthcheck": None},
    "proxy":   {"registers": True,  "hugepages": False, "privileged": None, "healthcheck": None},
    "graphql": {"registers": False, "hugepages": True,  "privileged": None, "healthcheck": None, "shm_size": "10g"},
    "mcp":     {"registers": False, "hugepages": True,  "privileged": None, "healthcheck": None, "shm_size": "10g"},
}
# Published image names, which are not always the role name (mirrors group_vars).
IMAGE_NAMES = {"scc": "scc", "sn": "sn", "bloomrf": "bloomrf", "proxy": "proxy",
               "ingester": "ingester", "graphql": "hd_graphql", "mcp": "hd_mcp"}
DMS_IMAGE_NAMES = {"dms-ca": "dms-ca", "dms-core": "dms-core",
                   "dms-opa-engine": "dms-opa", "dms-webux": "dms-webux"}
DPDK_CAPABLE = {"scc", "sn", "bloomrf", "ingester", "graphql", "mcp"}

# --- Defaults, kept identical to ansible/group_vars/all.yml -------------------
DEFAULTS = {
    "namespace": "hd",
    "tag": "latest",
    "data_dir": "/opt/fermihdi",
    "restart": "unless-stopped",
    "log_level": "INFO",
    "hp1g": "/dev/hugepages1G",
    "hp2m": "/dev/hugepages",
    "graphql_port": 8081,
    "mcp_listen": 8082,
    "mcp_stream": 8083,
    # DMS
    "dms_southbound_port": 6986,
    "dms_southbound_bind": "0.0.0.0",
    "dms_webux_bind": "127.0.0.1",
    "dms_webux_port": 8081,
    "dms_dashboard_port": 8082,
    "dms_otel_grpc": 4317,
    "dms_otel_http": 4318,
    "dms_data_dir": "/opt/fermihdi/dms",
    "dms_uid": 1001,
    "dms_otel_uid": 10001,
    "traefik_image": "traefik:v2.11",
    "otel_image": "otel/opentelemetry-collector-contrib:0.151.0",
    "dms_net_internal": "fermihdi-dms-internal",
    "dms_net_ca": "fermihdi-dms-ca",
    "dms_net_obs": "fermihdi-observability",
    "dms_internal_mgmt_port": 6985,
}

warnings = []


def warn(msg):
    warnings.append(msg)


def token_var(name):
    """Instance name -> its per-node bootstrap-token env var (group_vars convention)."""
    return "FERMIHDI_BOOTSTRAP_TOKEN_" + re.sub(r"[^A-Z0-9]", "_", name.upper())


def image_ref(cfg, role, names):
    """cluster.yaml images: map wins; else registry/namespace/name:tag."""
    imgs = cfg.get("images") or {}
    repo = imgs.get(role)
    if not repo:
        reg = (cfg.get("registry") or {}).get("url", "cr.fermihdi.io")
        repo = f"{reg}/{DEFAULTS['namespace']}/{names[role]}"
    tag = imgs.get("tag", DEFAULTS["tag"])
    # A reference that already carries a tag keeps it (test the last segment so a
    # registry host with a port is not mistaken for one).
    return repo if ":" in repo.rsplit("/", 1)[-1] else f"{repo}:{tag}"


def sh(s):
    """Single-quote a value for safe embedding in the generated shell."""
    return "'" + str(s).replace("'", "'\\''") + "'"


# --- Port map ----------------------------------------------------------------
# A plane is a PORT, not a network. Everything host-networked on one host shares
# these, so this is what a per-host collision check compares.
#   6985 DMS-CA signing / DMS-Core mgmt (DMS only, bridge — never host)
#   6986 SCC command  (control)          | DMS-Core southbound (DMS host)
#   6996 SCC verify   = command + 10     (control)
#   6997 SCC query-dispatch = command+11 (control)
#   6987 FHDWP data plane (ingest + query results)
#   8080 per-service observability
def exclusive_ports(inst, rtype):
    """Ports an instance binds as a SINGLE host-wide listener.

    Only these can collide under host networking, and this is what the per-host
    check compares. Deliberately narrow: the Ansible layer (the proven deploy)
    runs an SCC and several SNs on one host with host networking and no port
    deconfliction, so the SN/bloomrf data and per-service observability ports do
    NOT behave as conflicting fixed listeners — each node binds its own
    per-plane address, and multiple SNs on a box is the documented common case.
    What is genuinely exclusive is the SCC's control family (two SCCs, or an SCC
    sharing a host with DMS-Core, collide on the command port) and the single
    listener a graphql/mcp front end opens.
    """
    if rtype == "scc":
        base = int(inst.get("command_port", 6986))
        return {base: f"{inst['name']} command",
                base + 10: f"{inst['name']} verify",
                base + 11: f"{inst['name']} query-dispatch"}
    if rtype == "graphql":
        return {int(inst.get("listen_port", DEFAULTS["graphql_port"])): f"{inst['name']} graphql"}
    if rtype == "mcp":
        return {DEFAULTS["mcp_listen"]: f"{inst['name']} mcp",
                DEFAULTS["mcp_stream"]: f"{inst['name']} mcp-stream"}
    if rtype == "dms":
        return {DEFAULTS["dms_southbound_port"]: f"{inst['name']} dms-core southbound"}
    return {}


def check_host_ports(host, all_instances):
    """Refuse a host whose host-networked instances want the same exclusive port."""
    seen = {}
    bad = False
    for inst, rtype in all_instances:
        for port, label in exclusive_ports(inst, rtype).items():
            if port in seen:
                warn(f"{host['name']}: port {port} wanted by both '{seen[port]}' and "
                     f"'{label}'. Host networking shares ports; move one to another "
                     f"host, or give the SCC a non-default command_port in cluster.yaml.")
                bad = True
            seen[port] = label
    return not bad


# --- Node instance -> docker run ---------------------------------------------
def render_node(cfg, host, role, inst):
    rtype = role["type"]
    spec = NODE_SPEC[rtype]
    name = inst["name"]
    dpdk = bool(role.get("dpdk", False))
    lines = [f'echo "  -> {name} ({rtype})"']

    # Directories the container bind-mounts.
    data = DEFAULTS["data_dir"]
    lines.append(f'mkdir -p {sh(data + "/" + name + "/certs")}')

    run = ["docker run -d", f"--name {sh(name)}", f"--hostname {sh(name)}",
           "--network host", f"--restart {DEFAULTS['restart']}"]

    if inst.get("container_cpuset"):
        run.append(f"--cpuset-cpus {sh(inst['container_cpuset'])}")

    # VFIO devices are resolved on the host at run time: the IOMMU group number
    # can move under a kernel upgrade, so trust /sys over the value in the file.
    devs = (inst.get("storage") or []) + ([inst["nic"]] if inst.get("nic") else [])
    vfio_pcis = [d["pci"] for d in devs if d.get("driver") == "vfio" and d.get("pci")]
    block_devs = [d["path"] for d in (inst.get("storage") or [])
                  if d.get("path", "").startswith("/dev/")]
    if vfio_pcis:
        lines.append("_vfio_devs=(--device /dev/vfio/vfio)")
        for pci in vfio_pcis:
            lines.append(
                f'_g=$(basename "$(readlink -f /sys/bus/pci/devices/{pci}/iommu_group)") '
                f'&& _vfio_devs+=(--device "/dev/vfio/$_g") '
                f'|| {{ echo "ERROR: {pci} has no IOMMU group — is it bound to vfio-pci?" >&2; exit 1; }}')

    privileged = spec["privileged"]
    if privileged is None:
        privileged = bool(vfio_pcis)
    if privileged:
        run.append("--privileged")
    for bd in block_devs:
        run.append(f"--device {sh(bd)}")

    if spec["hugepages"]:
        run.append("--ulimit memlock=-1:-1")
        run.append(f"-v {sh(DEFAULTS['hp1g'] + ':/dev/hugepages1G:rw')}")
        run.append(f"-v {sh(DEFAULTS['hp2m'] + ':/dev/hugepages:rw')}")
    if spec.get("shm_size"):
        run.append(f"--shm-size {spec['shm_size']}")

    run.append(f"-v {sh(data + '/' + name + '/certs:/etc/fermihdi/certs:rw')}")

    if rtype == "graphql":
        proxies = f"{data}/graphql/proxies.yaml"
        lines.append(f'mkdir -p {sh(data + "/graphql")}')
        lines.append(f'[ -f {sh(proxies)} ] || printf "proxies: []\\n" > {sh(proxies)}')
        run.append(f"-v {sh(proxies + ':/app/proxies.yaml:ro')}")

    # Healthcheck (only where the in-image binary answers --healthcheck).
    if spec["healthcheck"]:
        run.append(f'--health-cmd {sh(spec["healthcheck"] + " --healthcheck")} '
                   "--health-interval 10s --health-timeout 5s --health-retries 3")

    # --- Environment (mirrors roles/fermihdi_node/tasks/instance.yml) ---
    env = {
        "FERMIHDI_NODE_NAME": sh(name),
        "MASTER_CPUSET": sh(inst["cpuset"]),
        "LOG_LEVEL": sh(DEFAULTS["log_level"]),
    }
    if not dpdk:
        env["FERMIHDI_FORCE_POSIX"] = "1"
    nets = cfg.get("networks") or {}
    if nets.get("control_plane"):
        env["FERMIHDI_CONTROL_PLANE_SUBNET"] = sh(nets["control_plane"])
    if nets.get("data_plane"):
        env["FERMIHDI_DATA_PLANE_SUBNET"] = sh(nets["data_plane"])
    if nets.get("observability"):
        env["FERMIHDI_OBSERVABILITY_SUBNET"] = sh(nets["observability"])

    for k, v in env.items():
        run.append(f"-e {k}={v}")

    if spec["registers"]:
        dms = cfg.get("dms") or {}
        shared = (dms.get("bootstrap_token_env") or "FERMIHDI_BOOTSTRAP_TOKEN")
        # Per-node token, cluster-wide token as the fallback — resolved in the
        # generated script so it reads the host's real environment.
        lines.append(f'_tok="${{{token_var(name)}:-${{{shared}:-}}}}"')
        run.append(f"-e FERMIHDI_DMS_URL={sh(dms.get('url', ''))}")
        run.append('-e FERMIHDI_BOOTSTRAP_TOKEN="$_tok"')
        run.append('-e FERMIHDI_CA_CERT_PEM="$FERMIHDI_CA_CERT_PEM"')
        run.append("-e FERMIHDI_CLIENT_CERT_FILE=/etc/fermihdi/certs/client.crt")
        run.append("-e FERMIHDI_CLIENT_KEY_FILE=/etc/fermihdi/certs/client.key")

    if rtype == "ingester":
        run.append('-e KAFKA_BROKERS="${KAFKA_BROKERS:-}"')
        run.append('-e KAFKA_TOPICS="${KAFKA_TOPICS:-}"')
        run.append('-e KAFKA_GROUP_ID="${KAFKA_GROUP_ID:-fermihdi-ingester}"')
    if rtype == "graphql":
        run.append("-e PROXY_CONFIG_PATH=/app/proxies.yaml")
        run.append(f"-e GRAPHQL_LISTEN_PORT={inst.get('listen_port', DEFAULTS['graphql_port'])}")
        run.append("-e FERMIHDI_DATA_RETURN_PORT=6987")
    if rtype == "mcp":
        run.append(f"-e MCP_LISTEN_PORT={DEFAULTS['mcp_listen']}")
        run.append(f"-e MCP_STREAM_PORT={DEFAULTS['mcp_stream']}")
        run.append("-e FERMIHDI_DATA_RETURN_PORT=6987")

    if vfio_pcis:
        run.append('"${_vfio_devs[@]}"')
    run.append(image_ref(cfg, rtype, IMAGE_NAMES))

    lines.append(f'docker rm -f {sh(name)} >/dev/null 2>&1 || true')
    lines.append(" \\\n    ".join(run))
    lines.append("")
    return "\n".join(lines)


# --- DMS stack -> docker run (mirrors roles/fermihdi_dms) ---------------------
def render_dms(cfg, host, inst):
    d = DEFAULTS
    ddir = d["dms_data_dir"]
    uid = d["dms_uid"]
    cpuset = ",".join(x for x in [inst.get("cpuset", ""), inst.get("container_cpuset", "")] if x)
    cpu_flag = f"--cpuset-cpus {sh(cpuset)}" if cpuset else ""
    img = {k: image_ref(cfg, k, DMS_IMAGE_NAMES) for k in DMS_IMAGE_NAMES}
    L = ['echo "  -> DMS stack (ca, core, opa-engine, webux, traefik, collector)"']

    L.append("# Bridge networks: CA and Core both bind 6985/8080, so they are")
    L.append("# isolated on their own networks rather than sharing the host's.")
    for net in (d["dms_net_internal"], d["dms_net_ca"], d["dms_net_obs"]):
        L.append(f'docker network inspect {sh(net)} >/dev/null 2>&1 || docker network create {sh(net)}')
    for sub in ("dms-ca/storage", "dms-ca/certs", "dms-core/storage", "dms-core/certs",
                "dms-opa-engine/certs", "otel"):
        L.append(f'mkdir -p {sh(ddir + "/" + sub)}')
    L.append(f'chown -R {uid}:{uid} {sh(ddir + "/dms-ca")} {sh(ddir + "/dms-core")} '
             f'{sh(ddir + "/dms-opa-engine")} 2>/dev/null || true')
    L.append(f'chown -R {d["dms_otel_uid"]}:{d["dms_otel_uid"]} {sh(ddir + "/otel")} 2>/dev/null || true')
    L.append("")

    def rm(name):
        return f'docker rm -f {sh(name)} >/dev/null 2>&1 || true'

    # Collector first so everything after it has somewhere to report.
    otel_cfg = (
        "receivers:\\n  otlp:\\n    protocols:\\n"
        f"      grpc:\\n        endpoint: 0.0.0.0:{d['dms_otel_grpc']}\\n"
        f"      http:\\n        endpoint: 0.0.0.0:{d['dms_otel_http']}\\n"
        "exporters:\\n  file:\\n    path: /otel-data/otel.log\\n"
        "service:\\n  pipelines:\\n    traces:\\n      receivers: [otlp]\\n      exporters: [file]\\n"
        "    metrics:\\n      receivers: [otlp]\\n      exporters: [file]\\n"
        "    logs:\\n      receivers: [otlp]\\n      exporters: [file]\\n")
    L.append(rm("otel-collector"))
    L.append(" \\\n    ".join([
        "docker run -d", "--name otel-collector", "--hostname otel-collector",
        f"--restart {d['restart']}", f"--network {sh(d['dms_net_obs'])}",
        f"-p {d['dms_otel_grpc']}:{d['dms_otel_grpc']}", f"-p {d['dms_otel_http']}:{d['dms_otel_http']}",
        f"-v {sh(ddir + '/otel:/otel-data:rw')}",
        f'-e OTEL_COLLECTOR_CONFIG="$(printf %b {sh(otel_cfg)})"',
        (cpu_flag if cpu_flag else "").strip() or "--label fermihdi.dms=1",
        d["otel_image"], "--config=env:OTEL_COLLECTOR_CONFIG"]))
    L.append("")

    # DMS-CA — its own network with Core and nothing else.
    L.append(rm("dms-ca"))
    L.append(" \\\n    ".join([
        "docker run -d", "--name dms-ca", "--hostname dms-ca",
        f"--restart {d['restart']}",
        f"--network {sh(d['dms_net_ca'])}",
        "-e STORAGE_PATH=/var/lib/fermihdi/ca-storage",
        '-e DMS_ICA_CERT_PEM="$FERMIHDI_DMS_CA_CERT_PEM"',
        '-e DMS_ICA_KEY_PEM="$FERMIHDI_DMS_CA_KEY_PEM"',
        '-e DMS_TLS_CERT_PEM="$FERMIHDI_DMS_CA_CERT_PEM"',
        '-e DMS_TLS_KEY_PEM="$FERMIHDI_DMS_CA_KEY_PEM"',
        '-e DMS_CA_CERT_PEM="$FERMIHDI_CA_CERT_PEM"',
        f'-e DMS_INTERNAL_SERVICE_URLS=https://dms-core:{d["dms_internal_mgmt_port"]}',
        f'-e LOG_LEVEL={sh(d["log_level"].lower())}',
        f"-v {sh(ddir + '/dms-ca/storage:/var/lib/fermihdi/ca-storage:rw')}",
        f"-v {sh(ddir + '/dms-ca/certs:/etc/fermihdi/certs:rw')}",
        img["dms-ca"]]))
    L.append(f'docker network connect {sh(d["dms_net_obs"])} dms-ca >/dev/null 2>&1 || true')
    L.append("")

    # Optional dataset schema, mounted over Core's active schema.
    L.append('if [ -n "${FERMIHDI_DMS_SCHEMA_FILE:-}" ]; then')
    L.append(f'  cp "$FERMIHDI_DMS_SCHEMA_FILE" {sh(ddir + "/dms-core/dataset_schema.json")}')
    L.append(f'  chown {uid}:{uid} {sh(ddir + "/dms-core/dataset_schema.json")} 2>/dev/null || true')
    L.append('fi')

    # DMS-Core — publishes only the southbound port to the host.
    core = ["docker run -d", "--name dms-core", "--hostname dms-core",
            f"--restart {d['restart']}",
            f"--network {sh(d['dms_net_internal'])}",
            f"-p {d['dms_southbound_bind']}:{d['dms_southbound_port']}:{d['dms_southbound_port']}",
            f"-e CA_SERVICE_URL=https://dms-ca:{d['dms_internal_mgmt_port']}",
            "-e STORAGE_PATH=/var/lib/fermihdi/storage",
            f"-e DMS_INTERNAL_MGMT_ADDR=0.0.0.0:{d['dms_internal_mgmt_port']}",
            '-e FERMIHDI_INTERNAL_FE_TOKEN="$FERMIHDI_DMS_FE_TOKEN"',
            '-e DMS_ENCRYPTION_KEY="$FERMIHDI_DMS_ENCRYPTION_KEY"',
            '-e DMS_IDENTITY_KEY="$FERMIHDI_DMS_IDENTITY_KEY"',
            '-e DMS_TLS_CERT_PEM="$FERMIHDI_DMS_CORE_CERT_PEM"',
            '-e DMS_TLS_KEY_PEM="$FERMIHDI_DMS_CORE_KEY_PEM"',
            '-e DMS_CA_CERT_PEM="$FERMIHDI_CA_CERT_PEM"',
            '-e DMS_ICA_CHAIN_PEM="${FERMIHDI_DMS_ICA_CHAIN_PEM:-}"',
            '-e DMS_LICENSE_PAYLOAD="${FERMIHDI_DMS_LICENSE_PAYLOAD:-}"',
            "-e DMS_ENABLE_ANALYSIS=false",
            f'-e LOG_LEVEL={sh(d["log_level"].lower())}',
            f"-v {sh(ddir + '/dms-core/storage:/var/lib/fermihdi/storage:rw')}",
            f"-v {sh(ddir + '/dms-core/certs:/etc/fermihdi/certs:rw')}",
            '--health-cmd "/usr/local/bin/dms-core --healthcheck" '
            "--health-interval 10s --health-timeout 5s --health-retries 3",
            img["dms-core"]]
    L.append(rm("dms-core"))
    L.append(" \\\n    ".join(core))
    L.append(f'docker network connect {sh(d["dms_net_ca"])} dms-core >/dev/null 2>&1 || true')
    L.append(f'docker network connect {sh(d["dms_net_obs"])} dms-core >/dev/null 2>&1 || true')
    L.append('if [ -n "${FERMIHDI_DMS_SCHEMA_FILE:-}" ]; then')
    L.append(f'  docker cp {sh(ddir + "/dms-core/dataset_schema.json")} '
             'dms-core:/app/schemas/dataset_schema.json || true')
    L.append('fi')
    L.append("")

    # DMS-OPA-Engine — registers with Core like a node.
    _shared_tok = (cfg.get("dms") or {}).get("bootstrap_token_env") or "FERMIHDI_BOOTSTRAP_TOKEN"
    L.append('_opa_tok="${FERMIHDI_BOOTSTRAP_TOKEN_DMS_OPA_ENGINE:-${' + _shared_tok + ':-}}"')
    L.append(rm("dms-opa-engine"))
    L.append(" \\\n    ".join([
        "docker run -d", "--name dms-opa-engine", "--hostname dms-opa-engine",
        f"--restart {d['restart']}", f"--network {sh(d['dms_net_internal'])}",
        f"-e DMS_CORE_URL=https://dms-core:{d['dms_southbound_port']}",
        f"-e CA_SERVICE_URL=https://dms-core:{d['dms_southbound_port']}",
        '-e DMS_CA_CERT_PEM="$FERMIHDI_CA_CERT_PEM"',
        '-e FERMIHDI_BOOTSTRAP_TOKEN="$_opa_tok"',
        "-e FERMIHDI_NODE_NAME=dms-opa-engine",
        "-e FERMIHDI_OPA_BUNDLE_DIR=/tmp/fermihdi/opa",
        "-v fermihdi-opa-bundles:/tmp/fermihdi/opa:rw",
        f"-v {sh(ddir + '/dms-opa-engine/certs:/etc/fermihdi/certs:rw')}",
        img["dms-opa-engine"]]))
    L.append(f'docker network connect {sh(d["dms_net_obs"])} dms-opa-engine >/dev/null 2>&1 || true')
    L.append("")

    # DMS-WebUX + Traefik in front of it.
    L.append(rm("dms-webux"))
    L.append(" \\\n    ".join([
        "docker run -d", "--name dms-webux", "--hostname dms-webux",
        f"--restart {d['restart']}", f"--network {sh(d['dms_net_internal'])}",
        f"-e DMS_NB_URL=https://dms-core:{d['dms_internal_mgmt_port']}",
        '-e FERMIHDI_INTERNAL_FE_TOKEN="$FERMIHDI_DMS_FE_TOKEN"',
        '-e DMS_TLS_CERT_PEM="$FERMIHDI_DMS_WEBUX_CERT_PEM"',
        '-e DMS_TLS_KEY_PEM="$FERMIHDI_DMS_WEBUX_KEY_PEM"',
        '-e DMS_CA_CERT_PEM="$FERMIHDI_CA_CERT_PEM"',
        '-e FE_CERT_PEM="$FERMIHDI_DMS_WEBUX_FE_CERT_PEM"',
        '-e FE_KEY_PEM="$FERMIHDI_DMS_WEBUX_FE_KEY_PEM"',
        "--label traefik.enable=true",
        f"--label traefik.docker.network={d['dms_net_internal']}",
        "--label traefik.instance=dms-internal",
        '--label "traefik.http.routers.webux.rule=PathPrefix(\\`/\\`)"',
        "--label traefik.http.routers.webux.entrypoints=web",
        "--label traefik.http.services.webux.loadbalancer.server.port=3000",
        "--label traefik.http.services.webux.loadbalancer.server.scheme=https",
        img["dms-webux"]]))
    L.append(f'docker network connect {sh(d["dms_net_obs"])} dms-webux >/dev/null 2>&1 || true')
    L.append("")

    L.append(rm("dms-traefik"))
    L.append(" \\\n    ".join([
        "docker run -d", "--name dms-traefik", "--hostname dms-traefik",
        f"--restart {d['restart']}", f"--network {sh(d['dms_net_internal'])}",
        f"-p {d['dms_webux_bind']}:{d['dms_webux_port']}:80",
        f"-p {d['dms_webux_bind']}:{d['dms_dashboard_port']}:8080",
        "-v /var/run/docker.sock:/var/run/docker.sock:ro",
        d["traefik_image"],
        "--api.insecure=true", "--providers.docker=true",
        "--providers.docker.exposedbydefault=false",
        "--serversTransport.insecureSkipVerify=true",
        "--entrypoints.web.address=:80",
        '"--providers.docker.constraints=Label(\\`traefik.instance\\`, \\`dms-internal\\`)"']))
    L.append("")
    warn(f"{host['name']}: the WebUX has no auth of its own and is published on "
         f"{d['dms_webux_bind']}:{d['dms_webux_port']} (loopback). Put a forward-auth "
         "in front of it before widening the bind address.")
    return "\n".join(L)


def host_script(cfg, host):
    node_instances = []
    dms_inst = None
    for role in host.get("roles") or []:
        for inst in role.get("instances") or []:
            if role["type"] == "dms":
                dms_inst = inst
            else:
                node_instances.append((inst, role["type"]))

    check_host_ports(host, node_instances + ([(dms_inst, "dms")] if dms_inst else []))

    out = ["#!/usr/bin/env bash",
           f"# FermiHDI HD — bare-Docker deploy for host '{host['name']}'",
           "# Generated by fermihdi-docker from cluster.yaml. Re-run the generator, not this.",
           "#",
           "# Secrets are read from THIS host's environment, never baked in. Export before running:",
           "#   FERMIHDI_REGISTRY_PASSWORD, FERMIHDI_CA_CERT_PEM,",
           "#   and each node's FERMIHDI_BOOTSTRAP_TOKEN_<NAME> (or one shared FERMIHDI_BOOTSTRAP_TOKEN).",
           "set -euo pipefail", ""]

    reg = cfg.get("registry") or {}
    if reg.get("url"):
        user = reg.get("username", "")
        pwvar = reg.get("password_env", "FERMIHDI_REGISTRY_PASSWORD")
        out.append("# Registry login (password from the environment).")
        out.append(f'if [ -n "${{{pwvar}:-}}" ]; then')
        out.append(f'  echo "${pwvar}" | docker login {sh(reg["url"])} -u {sh(user)} --password-stdin')
        out.append("fi\n")

    if dms_inst:
        out.append('echo "== DMS stack =="')
        out.append(render_dms(cfg, host, dms_inst))
    for inst, rtype in node_instances:
        out.append(f'echo "== node: {inst["name"]} =="')
        out.append(render_node(cfg, host, role={"type": rtype, **_role_of(host, inst)}, inst=inst))

    out.append('echo "Done. Check:  docker ps"')
    return "\n".join(out) + "\n"


def _role_of(host, inst):
    """Recover the role dict (for its dpdk flag) an instance belongs to."""
    for role in host.get("roles") or []:
        if inst in (role.get("instances") or []):
            return {"dpdk": role.get("dpdk", False)}
    return {"dpdk": False}


def deploy_all(cfg, hosts):
    """A convenience driver: copy each host's script over SSH and run it, DMS first."""
    ordered = ([h for h in hosts if any(r["type"] == "dms" for r in (h.get("roles") or []))]
               + [h for h in hosts if not any(r["type"] == "dms" for r in (h.get("roles") or []))])
    L = ["#!/usr/bin/env bash",
         "# Copy each host script to its host and run it. DMS first, then the nodes.",
         "#",
         "# Forwards the secrets in your current environment to each host. Run from a",
         "# machine that can SSH to every address in cluster.yaml.",
         "set -euo pipefail",
         'HERE="$(cd "$(dirname "$0")" && pwd)"',
         "",
         "# The env vars each host needs; only the set ones are forwarded.",
         'SECRETS=(FERMIHDI_REGISTRY_PASSWORD FERMIHDI_CA_CERT_PEM FERMIHDI_BOOTSTRAP_TOKEN \\',
         '  FERMIHDI_DMS_CA_CERT_PEM FERMIHDI_DMS_CA_KEY_PEM FERMIHDI_DMS_CORE_CERT_PEM \\',
         '  FERMIHDI_DMS_CORE_KEY_PEM FERMIHDI_DMS_ICA_CHAIN_PEM FERMIHDI_DMS_LICENSE_PAYLOAD \\',
         '  FERMIHDI_DMS_ENCRYPTION_KEY FERMIHDI_DMS_IDENTITY_KEY FERMIHDI_DMS_FE_TOKEN \\',
         '  FERMIHDI_DMS_WEBUX_CERT_PEM FERMIHDI_DMS_WEBUX_KEY_PEM \\',
         '  FERMIHDI_DMS_WEBUX_FE_CERT_PEM FERMIHDI_DMS_WEBUX_FE_KEY_PEM \\',
         '  FERMIHDI_DMS_SCHEMA_FILE KAFKA_BROKERS KAFKA_TOPICS KAFKA_GROUP_ID)',
         "",
         "forward_env() {  # build 'VAR=val ...' for the set secrets, plus any per-node tokens",
         '  local out="" v',
         '  for v in "${SECRETS[@]}"; do [ -n "${!v:-}" ] && out+="$v=$(printf %q "${!v}") "; done',
         '  while IFS= read -r v; do out+="$v=$(printf %q "${!v}") "; done \\',
         "    < <(compgen -v | grep \"^FERMIHDI_BOOTSTRAP_TOKEN_\")",
         '  printf %s "$out"',
         "}",
         ""]
    for h in ordered:
        addr = h.get("address", h["name"])
        user = h.get("ssh_user", "root")
        key = h.get("ssh_key", "")
        keyflag = f'-i {sh(os.path.expanduser(key))} ' if key else ""
        target = f"{user}@{addr}"
        L.append(f'echo "== {h["name"]} ({target}) =="')
        L.append(f'scp {keyflag}"$HERE/{h["name"]}.sh" {target}:/tmp/fermihdi-deploy.sh')
        L.append(f'ssh {keyflag}{target} "env $(forward_env) bash /tmp/fermihdi-deploy.sh"')
        L.append("")
    L.append('echo "All hosts deployed."')
    return "\n".join(L) + "\n"


def readme(cfg, hosts):
    lines = ["# Bare-Docker deployment", "",
             "Generated from `cluster.yaml` by `fermihdi-docker`. One script per host, plus",
             "`deploy-all.sh` which copies each to its host over SSH and runs it.", "",
             "## Run order", "",
             "1. The DMS host first — every node registers with it.",
             "2. Then each node host.", "",
             "`deploy-all.sh` does this for you. To run by hand, copy `<host>.sh` to that",
             "host and run it there with the secrets below exported.", "",
             "## Secrets — exported in the environment, never in cluster.yaml", "",
             "| Variable | Needed by |",
             "|---|---|",
             "| `FERMIHDI_REGISTRY_PASSWORD` | every host (registry login) |",
             "| `FERMIHDI_CA_CERT_PEM` | every host (trust bundle) |",
             "| `FERMIHDI_BOOTSTRAP_TOKEN_<NODE>` (or `FERMIHDI_BOOTSTRAP_TOKEN`) | each registering node |",
             "| `FERMIHDI_DMS_CA_CERT_PEM` / `_KEY_PEM` | DMS host |",
             "| `FERMIHDI_DMS_CORE_CERT_PEM` / `_KEY_PEM` | DMS host |",
             "| `FERMIHDI_DMS_ENCRYPTION_KEY`, `FERMIHDI_DMS_IDENTITY_KEY` | DMS host |",
             "| `FERMIHDI_DMS_FE_TOKEN`, `FERMIHDI_DMS_WEBUX_*` | DMS host |",
             "| `FERMIHDI_DMS_ICA_CHAIN_PEM`, `FERMIHDI_DMS_LICENSE_PAYLOAD` | DMS host (optional) |",
             "", "## Port map (planes are ports, not networks)", "",
             "Host networking shares the host's ports, so instances that co-locate must not",
             "collide. `fermihdi-docker` refuses to emit a host where they do.", "",
             "| Port | Plane |",
             "|---|---|",
             "| 6985 | DMS-CA signing / DMS-Core mgmt (bridge, DMS only) |",
             "| 6986 | SCC command / DMS-Core southbound |",
             "| 6996 | SCC verify (command+10) |",
             "| 6997 | SCC query-dispatch (command+11) |",
             "| 6987 | FHDWP data plane (ingest + query results) |",
             "| 8080 | per-service observability |", "",
             "## Hosts", ""]
    for h in hosts:
        roles = ", ".join(sorted({r["type"] for r in (h.get("roles") or [])}))
        lines.append(f"- **{h['name']}** (`{h.get('address', '?')}`): {roles}")
    lines.append("")
    lines.append("For symptoms after deploy, see the **fermihdi-hd-troubleshoot** skill.")
    return "\n".join(lines) + "\n"


def main():
    ap = argparse.ArgumentParser(description="Render bare-Docker deploy scripts from cluster.yaml")
    ap.add_argument("cluster", nargs="?", default="cluster.yaml")
    ap.add_argument("-o", "--out", default="docker-deploy", help="output directory")
    ap.add_argument("--no-validate", action="store_true", help="skip the cluster.yaml check")
    args = ap.parse_args()

    path = pathlib.Path(args.cluster)
    if not path.exists():
        sys.exit(f"no such cluster file: {path}")
    cfg = yaml.safe_load(path.read_text()) or {}

    if not args.no_validate:
        try:
            if _vc.check(cfg) is False:
                sys.exit("cluster.yaml failed validation; fix it or pass --no-validate")
        except SystemExit:
            raise
        except Exception as e:  # a validator change should not block generation outright
            warn(f"validation skipped ({e}); pass --no-validate to silence this")

    hosts = cfg.get("hosts") or []
    if not hosts:
        sys.exit("cluster.yaml has no hosts")

    out = pathlib.Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    for h in hosts:
        script = out / f"{h['name']}.sh"
        script.write_text(host_script(cfg, h))
        script.chmod(script.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP)
        print(f"wrote {script}")

    da = out / "deploy-all.sh"
    da.write_text(deploy_all(cfg, hosts))
    da.chmod(da.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP)
    print(f"wrote {da}")

    rm = out / "README.md"
    rm.write_text(readme(cfg, hosts))
    print(f"wrote {rm}")

    if warnings:
        print("\nwarnings:", file=sys.stderr)
        for w in warnings:
            print(f"  - {w}", file=sys.stderr)


if __name__ == "__main__":
    main()
