From fffff1c016bea185847e0c0f731035f1a39ca7ea Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 18:14:59 -0400 Subject: [PATCH] feat(cluster): install capture-card drivers/SDKs from the admin screen Per-node "Capture Drivers / SDKs" panel installs Blackmagic / AJA / Deltacast / NDI drivers without SSH. node-agent gains NODE_TOKEN-gated /driver/install + /driver/status (spawns a one-shot privileged ubuntu container that bind- mounts host kernel paths + the repo and runs deploy/install-driver.sh); mam-api adds admin-gated /cluster/:id/install-driver + /driver-status. Driver files live in-repo under sdk// (private repo); binaries are admin-supplied per each sdk//README.md. Vendor allowlist throughout. Co-Authored-By: Claude Opus 4.8 --- deploy/install-driver.sh | 297 +++++++++++++++++++++++ docker-compose.worker.yml | 15 ++ docker-compose.yml | 5 + sdk/README.md | 42 ++++ sdk/aja/.gitkeep | 0 sdk/aja/README.md | 31 +++ sdk/blackmagic/.gitkeep | 0 sdk/blackmagic/README.md | 35 +++ sdk/deltacast/.gitkeep | 0 sdk/deltacast/README.md | 31 +++ sdk/ndi/.gitkeep | 0 sdk/ndi/README.md | 35 +++ services/mam-api/src/routes/cluster.js | 77 ++++++ services/node-agent/index.js | 162 ++++++++++++- services/web-ui/public/screens-admin.jsx | 153 ++++++++++++ 15 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 deploy/install-driver.sh create mode 100644 sdk/README.md create mode 100644 sdk/aja/.gitkeep create mode 100644 sdk/aja/README.md create mode 100644 sdk/blackmagic/.gitkeep create mode 100644 sdk/blackmagic/README.md create mode 100644 sdk/deltacast/.gitkeep create mode 100644 sdk/deltacast/README.md create mode 100644 sdk/ndi/.gitkeep create mode 100644 sdk/ndi/README.md diff --git a/deploy/install-driver.sh b/deploy/install-driver.sh new file mode 100644 index 0000000..090c24f --- /dev/null +++ b/deploy/install-driver.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# ============================================================================ +# install-driver.sh +# ---------------------------------------------------------------------------- +# Idempotent HOST installer for capture-card runtime drivers / SDKs. +# +# Runs ON the cluster node's HOST kernel. The node-agent invokes it inside a +# one-shot PRIVILEGED ubuntu container that bind-mounts this repo plus the host +# paths needed to affect the host kernel (/lib/modules, /usr/src, /boot, /dev, +# and the host apt/dpkg via the mounted root). dkms / modprobe / ldconfig +# therefore operate against the running host kernel. +# +# Reads the proprietary vendor file(s) from sdk// (in this repo). +# NO binaries are committed — if the expected file is missing the script exits +# non-zero with a clear message telling the operator what to drop in. +# +# Vendors (ALLOWLIST — nothing else is accepted): +# blackmagic Desktop Video .deb (DKMS kernel module) +# aja NTV2 driver source/SDK (built kernel module) +# deltacast VideoMaster installer (kernel module) +# ndi redistributable runtime libs (user-space only, no module) +# +# Exit codes: +# 0 installed (or already present / up to date) +# 2 bad usage / unknown vendor +# 3 expected vendor file missing in sdk// +# 4 missing kernel headers (cannot build DKMS / module) +# 5 build / install / module-load failure +# +# `bash -n` must pass. set -euo pipefail with `|| true` guarding every probe. +# ============================================================================ +set -euo pipefail + +# --------------------------------------------------------------------------- +# Resolve our own repo dir (deploy/ -> repo root), regardless of CWD. +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +REPO_DIR="$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)" +SDK_ROOT="$REPO_DIR/sdk" + +VENDOR="${1:-}" +KVER="$(uname -r 2>/dev/null || echo unknown)" +REBOOT_REQUIRED=0 + +log() { echo "[install-driver] $*"; } +warn() { echo "[install-driver] WARN: $*" >&2; } +die() { echo "[install-driver] ERROR: $*" >&2; exit "${2:-5}"; } + +usage() { + echo "Usage: install-driver.sh " >&2 + exit 2 +} + +[ -n "$VENDOR" ] || usage +case "$VENDOR" in + blackmagic|aja|deltacast|ndi) : ;; + *) echo "[install-driver] ERROR: unknown vendor '$VENDOR' (allowed: blackmagic aja deltacast ndi)" >&2; exit 2 ;; +esac + +VENDOR_DIR="$SDK_ROOT/$VENDOR" +log "vendor=$VENDOR kernel=$KVER repo=$REPO_DIR" +log "reading vendor files from $VENDOR_DIR" +[ -d "$VENDOR_DIR" ] || die "vendor dir $VENDOR_DIR does not exist (repo not mounted?)" 3 + +# Pick the newest file matching a glob; echo its path or empty. +newest_match() { + # shellcheck disable=SC2012 + ls -1t $1 2>/dev/null | head -n1 || true +} + +ensure_headers() { + if [ -d "/lib/modules/$KVER/build" ] || dpkg -s "linux-headers-$KVER" >/dev/null 2>&1; then + log "kernel headers for $KVER present" + return 0 + fi + log "installing linux-headers-$KVER ..." + apt-get update -y >/dev/null 2>&1 || true + if ! apt-get install -y "linux-headers-$KVER" >/dev/null 2>&1; then + # Fall back to the generic meta-package; still may not match a custom kernel. + apt-get install -y linux-headers-generic >/dev/null 2>&1 || true + fi + if [ -d "/lib/modules/$KVER/build" ] || dpkg -s "linux-headers-$KVER" >/dev/null 2>&1; then + log "kernel headers ready" + return 0 + fi + die "kernel headers for $KVER unavailable — cannot build module. Install linux-headers-$KVER on the host." 4 +} + +# =========================================================================== +# blackmagic — Desktop Video .deb (DKMS) + modprobe + restart desktopvideo +# =========================================================================== +install_blackmagic() { + # Detect existing state first (idempotent skip). + if lsmod 2>/dev/null | grep -q '^blackmagic' && [ -e /dev/blackmagic ]; then + log "blackmagic module loaded and /dev/blackmagic present — already installed" + if command -v dkms >/dev/null 2>&1; then dkms status 2>/dev/null | grep -i blackmagic || true; fi + return 0 + fi + + local deb + deb="$(newest_match "$VENDOR_DIR/desktopvideo_*_amd64.deb")" + [ -n "$deb" ] && [ -f "$deb" ] || die \ + "no desktopvideo_*_amd64.deb in $VENDOR_DIR — download Desktop Video for Ubuntu 22.04 and drop the .deb there (see sdk/blackmagic/README.md)." 3 + log "using package: $(basename "$deb")" + + ensure_headers + + log "apt-get install (DKMS build) ..." + apt-get update -y >/dev/null 2>&1 || true + apt-get install -y "$deb" || die "apt-get install of $(basename "$deb") failed (DKMS build?)" 5 + + log "depmod + modprobe blackmagic ..." + depmod -a "$KVER" 2>/dev/null || true + if ! modprobe blackmagic 2>/dev/null; then + warn "modprobe blackmagic failed — a reboot may be required for the DKMS module to bind" + REBOOT_REQUIRED=1 + fi + + # Restart the DesktopVideoHelper daemon if present. + if command -v systemctl >/dev/null 2>&1; then + systemctl restart desktopvideo 2>/dev/null \ + || systemctl restart DesktopVideoHelper 2>/dev/null || true + fi + + if lsmod 2>/dev/null | grep -q '^blackmagic' || [ -e /dev/blackmagic ]; then + log "blackmagic installed (module loaded / device present)" + else + warn "blackmagic installed but module not yet loaded — reboot required" + REBOOT_REQUIRED=1 + fi +} + +# =========================================================================== +# aja — build ntv2 kernel module from source archive + modprobe +# =========================================================================== +install_aja() { + if lsmod 2>/dev/null | grep -q 'ajantv2'; then + log "ajantv2 module already loaded — already installed" + return 0 + fi + + local src + src="$(newest_match "$VENDOR_DIR/ntv2sdk*.zip")" + [ -n "$src" ] || src="$(newest_match "$VENDOR_DIR/libajantv2*.tar.gz")" + [ -n "$src" ] && [ -f "$src" ] || die \ + "no ntv2sdk*.zip / libajantv2*.tar.gz in $VENDOR_DIR — download the AJA NTV2 Linux SDK and drop it there (see sdk/aja/README.md)." 3 + log "using source: $(basename "$src")" + + ensure_headers + apt-get install -y build-essential unzip >/dev/null 2>&1 || true + + local work; work="$(mktemp -d)" + log "extracting into $work ..." + case "$src" in + *.zip) unzip -q -o "$src" -d "$work" || die "failed to unzip $(basename "$src")" 5 ;; + *.tar.gz) tar -xzf "$src" -C "$work" || die "failed to untar $(basename "$src")" 5 ;; + esac + + # Find the linux driver dir (contains a Makefile producing ajantv2.ko). + local drvdir + drvdir="$(dirname "$(find "$work" -type d -path '*driver/linux' -print -quit 2>/dev/null || true)/." 2>/dev/null)" + [ -d "$drvdir" ] || drvdir="$(dirname "$(find "$work" -name 'ajantv2.c' -print -quit 2>/dev/null || true)" 2>/dev/null)" + [ -n "$drvdir" ] && [ -d "$drvdir" ] || die "could not locate AJA driver/linux source inside the archive" 5 + log "building module in $drvdir ..." + make -C "$drvdir" >/dev/null 2>&1 || die "AJA module build failed" 5 + + local ko + ko="$(find "$drvdir" -name 'ajantv2.ko' -print -quit 2>/dev/null || true)" + if [ -n "$ko" ]; then + install -D -m 0644 "$ko" "/lib/modules/$KVER/extra/ajantv2.ko" 2>/dev/null || true + depmod -a "$KVER" 2>/dev/null || true + fi + if ! modprobe ajantv2 2>/dev/null; then + # Fall back to the SDK's own load script if shipped. + local loader; loader="$(find "$work" -name 'load_ajantv2' -print -quit 2>/dev/null || true)" + if [ -n "$loader" ]; then bash "$loader" 2>/dev/null || true; fi + fi + + rm -rf "$work" 2>/dev/null || true + + if lsmod 2>/dev/null | grep -q 'ajantv2'; then + log "ajantv2 installed and loaded" + else + warn "ajantv2 built but not loaded — a reboot may be required (old in-tree module wedged?)" + REBOOT_REQUIRED=1 + fi +} + +# =========================================================================== +# deltacast — VideoMaster installer + module load +# =========================================================================== +install_deltacast() { + if lsmod 2>/dev/null | grep -q 'videomaster' || ls /dev/deltacast* >/dev/null 2>&1; then + log "Deltacast module loaded / device present — already installed" + return 0 + fi + + local pkg + pkg="$(newest_match "$VENDOR_DIR/VideoMaster*.run")" + [ -n "$pkg" ] || pkg="$(newest_match "$VENDOR_DIR/VideoMaster*.tar.gz")" + [ -n "$pkg" ] && [ -f "$pkg" ] || die \ + "no VideoMaster*.run / VideoMaster*.tar.gz in $VENDOR_DIR — obtain the Deltacast VideoMaster Linux installer and drop it there (see sdk/deltacast/README.md)." 3 + log "using installer: $(basename "$pkg")" + + ensure_headers + apt-get install -y build-essential dkms >/dev/null 2>&1 || true + + local work; work="$(mktemp -d)" + case "$pkg" in + *.run) + log "extracting self-extractor ..." + chmod +x "$pkg" 2>/dev/null || true + "$pkg" --noexec --target "$work" >/dev/null 2>&1 \ + || cp "$pkg" "$work/installer.run" + ;; + *.tar.gz) + tar -xzf "$pkg" -C "$work" || die "failed to untar $(basename "$pkg")" 5 + ;; + esac + + local installer + installer="$(find "$work" -name 'install.sh' -print -quit 2>/dev/null || true)" + [ -n "$installer" ] || installer="$(find "$work" -name 'installer.run' -print -quit 2>/dev/null || true)" + [ -n "$installer" ] && [ -f "$installer" ] || die "Deltacast installer (install.sh) not found inside the package" 5 + log "running vendor installer: $(basename "$installer") ..." + chmod +x "$installer" 2>/dev/null || true + ( cd "$(dirname "$installer")" && bash "$installer" ) || die "Deltacast VideoMaster installer failed" 5 + + depmod -a "$KVER" 2>/dev/null || true + modprobe videomasterhd 2>/dev/null || modprobe videomaster 2>/dev/null || true + + rm -rf "$work" 2>/dev/null || true + + if lsmod 2>/dev/null | grep -q 'videomaster' || ls /dev/deltacast* >/dev/null 2>&1; then + log "Deltacast VideoMaster installed" + fi + # First-time VideoMaster installs lay down udev rules + firmware that need a reboot. + warn "Deltacast: a REBOOT is recommended after a first-time VideoMaster install (udev + firmware)" + REBOOT_REQUIRED=1 +} + +# =========================================================================== +# ndi — copy redistributable runtime libs to /usr/local/lib + ldconfig +# =========================================================================== +install_ndi() { + local target="/opt/ndi-lib" + local found=0 + # shellcheck disable=SC2231 + for f in "$VENDOR_DIR"/libndi*.so*; do + [ -e "$f" ] || continue + found=1 + break + done + [ "$found" = 1 ] || die \ + "no libndi*.so* in $VENDOR_DIR — drop the NDI runtime redistributable libs there (see sdk/ndi/README.md)." 3 + + log "copying NDI runtime libs to $target ..." + mkdir -p "$target" + cp -av "$VENDOR_DIR"/libndi*.so* "$target"/ 2>/dev/null || die "failed copying NDI libs" 5 + + # Recreate the libndi.so dev symlink if only versioned libs were shipped. + if [ ! -e "$target/libndi.so" ]; then + local versioned + versioned="$(newest_match "$target/libndi.so.*")" + if [ -n "$versioned" ]; then + ln -sf "$(basename "$versioned")" "$target/libndi.so" 2>/dev/null || true + fi + fi + + echo "$target" > /etc/ld.so.conf.d/ndi.conf + ldconfig 2>/dev/null || true + + if ldconfig -p 2>/dev/null | grep -q 'libndi'; then + log "NDI runtime registered with the dynamic linker" + else + die "NDI libs copied but ldconfig did not resolve libndi" 5 + fi + log "NDI: no kernel module and no reboot required." + log "NDI: restart any process that already loaded an older libndi to pick up the new version." +} + +# --------------------------------------------------------------------------- +case "$VENDOR" in + blackmagic) install_blackmagic ;; + aja) install_aja ;; + deltacast) install_deltacast ;; + ndi) install_ndi ;; +esac + +if [ "$REBOOT_REQUIRED" = 1 ]; then + log "RESULT: $VENDOR install completed — REBOOT REQUIRED" + echo "[install-driver] REBOOT_REQUIRED=1" +else + log "RESULT: $VENDOR install completed — no reboot required" + echo "[install-driver] REBOOT_REQUIRED=0" +fi +exit 0 diff --git a/docker-compose.worker.yml b/docker-compose.worker.yml index 110ccd0..895c906 100644 --- a/docker-compose.worker.yml +++ b/docker-compose.worker.yml @@ -55,10 +55,25 @@ services: BMD_MODEL: ${BMD_MODEL:-} BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv} LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live} + # REPO_DIR: host path to the checked-out repo. The agent passes this to the + # one-shot driver-install container so install-driver.sh can read + # sdk// and run deploy/install-driver.sh. Must match the host path + # bind-mounted below (onboard-node.sh clones to /opt/wild-dragon). + REPO_DIR: ${REPO_DIR:-/opt/wild-dragon} volumes: - /var/run/docker.sock:/var/run/docker.sock - /dev:/dev:ro - /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live:ro + # Capture-driver deployment ("Capture Drivers / SDKs" in the Cluster admin + # screen): the agent itself does NOT run dkms/modprobe — it spawns a + # separate privileged ubuntu container that bind-mounts these host paths. + # The agent only needs to *see* the repo path so it can pass it through as + # a bind to that container; no extra privileges are granted to the agent. + # /opt/wild-dragon → repo (sdk// + deploy/install-driver.sh) + # The install container additionally mounts /lib/modules,/usr/src,/boot, + # /dev and /opt from the host (handled in the agent, not here) so DKMS / + # modprobe / ldconfig affect the host kernel. + - ${REPO_DIR:-/opt/wild-dragon}:${REPO_DIR:-/opt/wild-dragon}:ro devices: - /dev/blackmagic:/dev/blackmagic diff --git a/docker-compose.yml b/docker-compose.yml index 3e66986..7b35400 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,11 @@ services: DOCKER_NETWORK: wild-dragon_wild-dragon NODE_IP: ${NODE_IP} NODE_HOSTNAME: ${NODE_HOSTNAME:-} + # Bearer mam-api forwards to a node-agent when installing capture drivers + # ("Capture Drivers / SDKs" panel). Set to the same value as the agents' + # NODE_TOKEN. If empty, agents with an empty NODE_TOKEN accept the call + # (dev); agents with a token will reject it (401). + NODE_AGENT_TOKEN: ${NODE_AGENT_TOKEN:-} CAPTURE_TOKEN: ${CAPTURE_TOKEN} PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest} PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250} diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..af70c59 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,42 @@ +# Capture-card SDK / driver file store + +This directory holds the **proprietary, non-redistributable** vendor SDKs and +drivers used to enable SDI / NDI capture cards on cluster nodes. + +> **INTERNAL ONLY.** These files are licensed by their respective vendors and +> must **not** be published, redistributed, or committed to any public mirror. +> This repository is private. Do not change that. + +## Why these live in the repo + +The cluster admin screen lets an operator install/update capture-card drivers on +a node from the web UI (no SSH). The node-agent spawns a one-shot privileged +container that bind-mounts this repository and runs +[`deploy/install-driver.sh `](../deploy/install-driver.sh), which reads +the vendor files from `sdk//`. Because the install must work offline on +an isolated broadcast LAN, the binaries ship in-repo rather than being fetched at +install time. + +## Layout + +``` +sdk/ + README.md ← this file + blackmagic/ ← Blackmagic Desktop Video (DeckLink) .deb + aja/ ← AJA ntv2 driver source / installer + deltacast/ ← Deltacast VideoMaster installer + ndi/ ← NDI redistributable runtime libs +``` + +Each vendor directory has its own `README.md` listing **exactly** which files an +admin must drop in. A `.gitkeep` keeps the empty directory committed. + +## Important + +- **No binaries are committed by default.** The directory structure + READMEs + are the deliverable. An admin downloads the proprietary files from the vendor + (per their licence) and drops them in the matching `sdk//` directory. +- The install script **fails gracefully** with a clear message if the expected + file is absent — it never fabricates or downloads binaries. +- Target host OS for all install paths is **Ubuntu 22.04 LTS (jammy), x86_64**, + matching the cluster worker nodes. diff --git a/sdk/aja/.gitkeep b/sdk/aja/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sdk/aja/README.md b/sdk/aja/README.md new file mode 100644 index 0000000..dac93d1 --- /dev/null +++ b/sdk/aja/README.md @@ -0,0 +1,31 @@ +# AJA NTV2 driver + +Drop the **AJA NTV2** driver source/SDK archive for Linux into this directory. + +## Required file + +| File | Notes | +|------|-------| +| `ntv2sdk*linux*.zip` **or** `libajantv2*.tar.gz` | The NTV2 SDK / open-source `libajantv2` source tree containing `driver/linux/` with the kernel-module `Makefile` and `load_ajantv2` / `unload_ajantv2` scripts. | + +Example names: `ntv2sdklinux_17.0.1.zip`, `libajantv2-17.0.1.tar.gz` + +The installer reads the **newest** matching archive. + +## Where to get it + +AJA → Support → Software & firmware → *NTV2 SDK* (Linux), or the public +`aja-video/libajantv2` source release. Download the Linux SDK zip / source +tarball and copy it here unmodified. + +## What the install script does + +1. Ensures `linux-headers-$(uname -r)`, `build-essential` are present. +2. Extracts the archive into a scratch build dir. +3. Builds the `ajantv2` kernel module from `driver/linux` (`make`). +4. Installs the module under `/lib/modules/$(uname -r)/extra`, runs `depmod`, + `modprobe ajantv2` (falls back to the SDK's `load_ajantv2` script). +5. Verifies the `ajantv2` module is loaded. + +A **reboot is not normally required**; the module loads immediately after build. +The script reports if a reboot is needed (e.g. an old in-tree module is wedged). diff --git a/sdk/blackmagic/.gitkeep b/sdk/blackmagic/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sdk/blackmagic/README.md b/sdk/blackmagic/README.md new file mode 100644 index 0000000..eafc5aa --- /dev/null +++ b/sdk/blackmagic/README.md @@ -0,0 +1,35 @@ +# Blackmagic Desktop Video (DeckLink) driver + +Drop the **Blackmagic Desktop Video** Debian package for **Ubuntu 22.04 (x86_64)** +into this directory. + +## Required file + +| File | Notes | +|------|-------| +| `desktopvideo_*_amd64.deb` | The `desktopvideo` package from the Desktop Video installer archive. Provides the `blackmagic` kernel module (built via DKMS) and the `DesktopVideoHelper` daemon. | + +Example name: `desktopvideo_14.4.1a4_amd64.deb` + +The installer reads the **newest** matching `desktopvideo_*_amd64.deb` if more +than one is present. + +## Where to get it + +Blackmagic Design → Support → *Desktop Video* (Linux). Download the +"Desktop Video x.y.z Linux" tarball, extract it, and copy the +`deb//desktopvideo_*_amd64.deb` file here. + +> Optional: `desktopvideo-gui_*_amd64.deb` is **not** required for headless +> capture and is not installed. + +## What the install script does + +1. Ensures `linux-headers-$(uname -r)` is present (needed for the DKMS build). +2. `apt-get install -y ./desktopvideo_*_amd64.deb` (pulls DKMS deps). +3. Triggers the DKMS build, `depmod`, `modprobe blackmagic`. +4. Restarts the `DesktopVideoHelper` daemon. +5. Verifies `/dev/blackmagic` appears. + +A **reboot is usually not required** but a DKMS rebuild against a freshly +installed kernel may need one — the script reports this. diff --git a/sdk/deltacast/.gitkeep b/sdk/deltacast/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sdk/deltacast/README.md b/sdk/deltacast/README.md new file mode 100644 index 0000000..ec81c6a --- /dev/null +++ b/sdk/deltacast/README.md @@ -0,0 +1,31 @@ +# Deltacast VideoMaster driver / SDK + +Drop the **Deltacast VideoMaster** Linux installer into this directory. + +## Required file + +| File | Notes | +|------|-------| +| `VideoMaster*.run` **or** `VideoMaster*linux*.tar.gz` | The VideoMaster SDK + driver installer for Linux. Contains the `videomasterhd` kernel module sources and the `install.sh` driver installer. | + +Example names: `VideoMaster-6.25.0.run`, `VideoMaster_6_25_Linux.tar.gz` + +The installer reads the **newest** matching file. + +## Where to get it + +Deltacast → Products → *SDK* (). Request +the VideoMaster Linux package (licence-gated) and copy the `.run` self-extractor +or the `.tar.gz` here unmodified. + +## What the install script does + +1. Ensures `linux-headers-$(uname -r)`, `build-essential`, `dkms` are present. +2. Runs the vendor installer: + - `.run` → executed with `--noexec --target ` then its `install.sh`, + - `.tar.gz` → extracted, then its bundled `install.sh` is run. +3. Loads the Deltacast module (`modprobe videomasterhd` / vendor load script). +4. Verifies a `/dev/deltacast*` device node appears. + +A **reboot may be required** after a first-time VideoMaster install (udev rules ++ firmware). The script reports this explicitly. diff --git a/sdk/ndi/.gitkeep b/sdk/ndi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/sdk/ndi/README.md b/sdk/ndi/README.md new file mode 100644 index 0000000..4907199 --- /dev/null +++ b/sdk/ndi/README.md @@ -0,0 +1,35 @@ +# NDI redistributable runtime + +Drop the **NDI runtime redistributable** shared libraries into this directory. +NDI has **no kernel module** — it is purely user-space shared libraries, so this +is the lowest-risk install (no DKMS, no reboot). + +## Required files + +| File | Notes | +|------|-------| +| `libndi.so.*` | The versioned NDI runtime shared object, e.g. `libndi.so.6`. **Required.** | +| `libndi.so` *(optional)* | Dev symlink. The installer recreates it if absent. | + +You may instead drop the whole **NDI SDK / Advanced SDK** `lib/x86_64-linux-gnu/` +directory contents here; the installer copies every `libndi*.so*` it finds. + +Example name: `libndi.so.6.1.1` + +## Where to get it + +NDI → Tools / SDK download (NDI 6 SDK or NDI Advanced SDK for Linux). The +runtime libs live under `lib/x86_64-linux-gnu/` in the SDK. Per the NDI licence +the runtime is redistributable **within your own product** only — keep it in this +private repo, do not publish it. + +## What the install script does + +1. Copies every `libndi*.so*` from here into `/opt/ndi-lib`. +2. Writes `/etc/ld.so.conf.d/ndi.conf` pointing at `/opt/ndi-lib` and runs + `ldconfig`. +3. Recreates the `libndi.so` → `libndi.so.` dev symlink if missing. +4. Verifies `ldconfig -p | grep libndi` resolves. + +**No reboot required.** Running processes that already loaded an old `libndi` +must be restarted to pick up the new version — the script notes this. diff --git a/services/mam-api/src/routes/cluster.js b/services/mam-api/src/routes/cluster.js index 6fe3dde..5f02fe6 100644 --- a/services/mam-api/src/routes/cluster.js +++ b/services/mam-api/src/routes/cluster.js @@ -350,6 +350,83 @@ router.get('/:id/ping', async (req, res, next) => { } catch (err) { next(err); } }); +// ── Capture-driver / SDK deployment ──────────────────────────────────────── +// Admins install/update vendor capture-card drivers on a node from the UI. +// We resolve the node's api_url (like /:id/ping) and forward to its node-agent, +// which runs deploy/install-driver.sh in a privileged one-shot +// container against the host kernel. Vendor is allowlisted here AND on the +// agent. We never echo the agent token or proprietary paths back to the client. +const DRIVER_VENDORS = ['blackmagic', 'aja', 'deltacast', 'ndi']; + +// Bearer the agent expects (its NODE_TOKEN). Configured server-side; never +// derived from client input and never returned to the browser. +function agentAuthHeaders() { + const tok = process.env.NODE_AGENT_TOKEN || ''; + return tok ? { Authorization: `Bearer ${tok}` } : {}; +} + +async function resolveNode(id) { + const r = await pool.query('SELECT id, hostname, api_url, capabilities FROM cluster_nodes WHERE id = $1', [id]); + return r.rowCount === 0 ? null : r.rows[0]; +} + +router.get('/:id/driver-status', requireAdmin, async (req, res, next) => { + try { + const node = await resolveNode(req.params.id); + if (!node) return res.status(404).json({ error: 'Node not found' }); + if (!node.api_url) return res.status(409).json({ error: 'Node has no api_url registered' }); + try { + const upstream = await fetch(`${node.api_url}/driver/status`, { + headers: agentAuthHeaders(), + signal: AbortSignal.timeout(6000), + }); + const body = await upstream.json().catch(() => ({})); + if (!upstream.ok) { + return res.status(502).json({ error: 'Agent driver-status failed', status: upstream.status }); + } + res.json(body); + } catch (err) { + res.status(502).json({ error: 'Node unreachable', reason: err.message }); + } + } catch (err) { next(err); } +}); + +router.post('/:id/install-driver', requireAdmin, async (req, res, next) => { + try { + const vendor = String(req.body?.vendor || '').toLowerCase(); + if (!DRIVER_VENDORS.includes(vendor)) { + return res.status(400).json({ error: `Invalid vendor (allowed: ${DRIVER_VENDORS.join(', ')})` }); + } + const node = await resolveNode(req.params.id); + if (!node) return res.status(404).json({ error: 'Node not found' }); + if (!node.api_url) return res.status(409).json({ error: 'Node has no api_url registered' }); + + try { + // DKMS builds can take minutes — generous timeout. + const upstream = await fetch(`${node.api_url}/driver/install`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...agentAuthHeaders() }, + body: JSON.stringify({ vendor }), + signal: AbortSignal.timeout(600000), + }); + const body = await upstream.json().catch(() => ({})); + // Relay logs/result. install-driver.sh never echoes secrets; the agent + // returns only its structured [install-driver] log lines + status. + res.status(upstream.ok ? 200 : 502).json({ + ok: !!body.ok, + vendor, + exitCode: body.exitCode ?? null, + rebootRequired: !!body.rebootRequired, + status: body.status ?? null, + logs: typeof body.logs === 'string' ? body.logs : '', + error: body.ok ? undefined : (body.error || 'Install failed — see logs'), + }); + } catch (err) { + res.status(502).json({ error: 'Node unreachable or install timed out', reason: err.message }); + } + } catch (err) { next(err); } +}); + router.get('/metrics', async (req, res, next) => { try { const r = await pool.query( diff --git a/services/node-agent/index.js b/services/node-agent/index.js index f21c896..f40e7f2 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -8,7 +8,16 @@ const NODE_ROLE = process.env.NODE_ROLE || 'worker'; const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10); const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 10); const LIVE_DIR = process.env.LIVE_DIR || '/mnt/NVME/MAM/wild-dragon-live'; -const VERSION = '1.3.0'; +// Host path to the checked-out repo (onboard-node.sh clones to /opt/wild-dragon). +// The driver-install container bind-mounts this so install-driver.sh can read +// sdk// and run from deploy/. Overridable for non-standard layouts. +const REPO_DIR = process.env.REPO_DIR || '/opt/wild-dragon'; +const VERSION = '1.4.0'; + +// Capture-driver vendor allowlist. NOTHING outside this set is ever passed to +// the host installer — the value is only ever used to pick a script arg, never +// interpolated into a shell string. +const DRIVER_VENDORS = ['blackmagic', 'aja', 'deltacast', 'ndi']; // Pick the host's LAN IP. Inside a bridge-mode container, // os.networkInterfaces() returns the container's docker-bridge IP (172.x), @@ -227,6 +236,147 @@ async function handleSidecarStatus(containerId, res) { } } +// ── Agent auth ──────────────────────────────────────────────────────────── +// When NODE_TOKEN is configured, privileged control endpoints (driver install) +// require a matching `Authorization: Bearer `. mam-api forwards the +// node's stored token. If NODE_TOKEN is empty (dev), auth is not enforced. +function checkAgentAuth(req) { + if (!NODE_TOKEN) return true; + const hdr = req.headers['authorization'] || ''; + const m = /^Bearer\s+(.+)$/i.exec(hdr); + return !!m && m[1] === NODE_TOKEN; +} + +// ── Driver/SDK install ──────────────────────────────────────────────────── +// Probe host presence of each capture-driver vendor. Mirrors the detection the +// install script uses, so the UI can show "installed / not installed" without +// running the installer. Best-effort: every probe is guarded. +function probeDriverStatus() { + const out = {}; + + // blackmagic — kernel module + /dev/blackmagic device tree. + let bmLoaded = false; + try { bmLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /^blackmagic\b/.test(l)); } catch (_) {} + let bmDev = false; + try { bmDev = fs.existsSync('/dev/blackmagic') && fs.readdirSync('/dev/blackmagic').length > 0; } catch (_) {} + out.blackmagic = { installed: bmLoaded || bmDev, module_loaded: bmLoaded, device_present: bmDev }; + + // aja — ajantv2 kernel module. + let ajaLoaded = false; + try { ajaLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /ajantv2/.test(l)); } catch (_) {} + out.aja = { installed: ajaLoaded, module_loaded: ajaLoaded }; + + // deltacast — videomaster module or /dev/deltacast* node. + let dcLoaded = false; + try { dcLoaded = fs.readFileSync('/proc/modules', 'utf8').split('\n').some(l => /videomaster/.test(l)); } catch (_) {} + let dcDev = false; + try { dcDev = fs.readdirSync('/dev').some(n => /^deltacast\d+$/.test(n)); } catch (_) {} + out.deltacast = { installed: dcLoaded || dcDev, module_loaded: dcLoaded, device_present: dcDev }; + + // ndi — user-space libs only. Look in the install target + common lib dirs. + let ndiPresent = false; + try { + for (const dir of ['/opt/ndi-lib', '/usr/local/lib', '/usr/lib/x86_64-linux-gnu']) { + let entries = []; + try { entries = fs.readdirSync(dir); } catch (_) { continue; } + if (entries.some(n => /^libndi\.so/.test(n))) { ndiPresent = true; break; } + } + } catch (_) {} + out.ndi = { installed: ndiPresent, libs_present: ndiPresent }; + + return out; +} + +async function handleDriverStatus(res) { + try { + jsonResponse(res, 200, { kernel: os.release(), vendors: probeDriverStatus() }); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } +} + +// Run install-driver.sh inside a one-shot PRIVILEGED ubuntu container. +// The repo is bind-mounted read-only at /repo; host kernel paths are mounted so +// dkms/modprobe/ldconfig affect the host. Logs are streamed back to the caller. +async function handleDriverInstall(body, res) { + const vendor = String(body?.vendor || '').toLowerCase(); + if (!DRIVER_VENDORS.includes(vendor)) { + return jsonResponse(res, 400, { error: `Invalid vendor (allowed: ${DRIVER_VENDORS.join(', ')})` }); + } + + let containerId; + try { + // Host paths the installer needs to reach the host kernel: + // /lib/modules,/usr/src,/boot → DKMS / module build + install + // /dev → device-node visibility + udev + // The repo (sdk// + deploy/install-driver.sh) is mounted read-only. + const binds = [ + `${REPO_DIR}:/repo:ro`, + '/lib/modules:/lib/modules', + '/usr/src:/usr/src', + '/boot:/boot', + '/dev:/dev', + // NDI install target lives under /opt; expose host /opt so libs land on host. + '/opt:/opt', + ]; + + const spec = { + Image: 'ubuntu:22.04', + // NOTE: vendor is a value from DRIVER_VENDORS only — never arbitrary input. + // Passed as a distinct argv element (Cmd array), not a shell string. + Cmd: ['bash', '/repo/deploy/install-driver.sh', vendor], + Env: [`REPO_DIR=/repo`], + WorkingDir: '/repo', + HostConfig: { + Privileged: true, + NetworkMode: 'host', + Binds: binds, + AutoRemove: false, + }, + }; + + const createRes = await dockerApi('POST', '/containers/create', spec); + if (createRes.status !== 201) { + return jsonResponse(res, 502, { error: 'Failed to create install container', details: createRes.data }); + } + containerId = createRes.data.Id; + console.log(`[driver-install] ${containerId} vendor=${vendor}`); + + const startRes = await dockerApi('POST', `/containers/${containerId}/start`); + if (startRes.status !== 204) { + await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {}); + return jsonResponse(res, 502, { error: 'Failed to start install container', details: startRes.data }); + } + + // Wait for the install to finish (DKMS builds can take a minute+). + let exitCode = null; + for (let i = 0; i < 600; i++) { + await new Promise(r => setTimeout(r, 1000)); + const inspect = await dockerApi('GET', `/containers/${containerId}/json`); + const state = inspect.data?.State; + if (state && !state.Running) { exitCode = state.ExitCode; break; } + } + + const logs = await fetchContainerLogs(containerId); + const rebootRequired = /REBOOT_REQUIRED=1/.test(logs); + const ok = exitCode === 0; + jsonResponse(res, ok ? 200 : 500, { + ok, + vendor, + exitCode, + rebootRequired, + logs, + status: probeDriverStatus()[vendor] || null, + }); + } catch (err) { + jsonResponse(res, 500, { error: err.message }); + } finally { + if (containerId) { + await dockerApi('DELETE', `/containers/${containerId}?force=true`).catch(() => {}); + } + } +} + // ── CPU sampling (500ms window) ─────────────────────────────────────────── function sampleCpu() { return new Promise(resolve => { @@ -572,6 +722,16 @@ const server = http.createServer((req, res) => { const id = pathname.slice('/sidecar/'.length, -'/status'.length); handleSidecarStatus(id, res); + } else if (req.method === 'GET' && pathname === '/driver/status') { + if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' }); + handleDriverStatus(res); + + } else if (req.method === 'POST' && pathname === '/driver/install') { + if (!checkAgentAuth(req)) return jsonResponse(res, 401, { error: 'Unauthorized' }); + readBody(req) + .then(body => handleDriverInstall(body, res)) + .catch(() => jsonResponse(res, 400, { error: 'Invalid request body' })); + } else if (req.method === 'GET' && pathname.startsWith('/live/')) { serveLiveFile(pathname, res); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 4bbe56a..522fa2d 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -21,6 +21,13 @@ function _normalizeNode(n, x, y) { online: b.online !== false, })); + // Deltacast ports — used by the Capture Drivers panel as a secondary + // "driver present?" signal (heartbeat only reports a port if the card is seen). + const deltacastPorts = (cap.deltacast || []).map(d => ({ + index: d.index ?? 0, + device: d.device || null, + })); + const memUsedMb = n.mem_used_mb || n.memory_used_mb || (n.mem && n.mem < 1000 ? n.mem * 1024 : n.mem || 0); const memTotalMb = n.mem_total_mb || n.memory_total_mb || (n.memTotal && n.memTotal < 1000 ? n.memTotal * 1024 : n.memTotal || 0); @@ -38,6 +45,7 @@ function _normalizeNode(n, x, y) { // Raw capabilities for the hardware panel gpus, bmdPorts, + deltacastPorts, // Legacy flat arrays kept for the stat-row summary cards gpuCount: gpus.length, bmdCount: bmdPorts.length, @@ -1169,6 +1177,148 @@ function Containers() { } // ──────────────────────────────────────────────────────────────────────────── +// DriverPanel - "Capture Drivers / SDKs" section inside the node detail panel. +// Per vendor (Blackmagic / AJA / Deltacast / NDI): shows detected status from +// GET /cluster/:id/driver-status (host probe) cross-checked with heartbeat +// capabilities, plus an "Install / Update" button that POSTs +// /cluster/:id/install-driver {vendor} and streams the agent log into a live +// output area, surfacing success/failure + "reboot required". +const DRIVER_VENDORS = [ + { key: 'blackmagic', label: 'Blackmagic', hint: 'Desktop Video driver (.deb)' }, + { key: 'aja', label: 'AJA', hint: 'NTV2 driver / SDK' }, + { key: 'deltacast', label: 'Deltacast', hint: 'VideoMaster installer' }, + { key: 'ndi', label: 'NDI', hint: 'Redistributable runtime libs' }, +]; + +function DriverPanel({ sel }) { + const [status, setStatus] = React.useState(null); // { kernel, vendors:{...} } + const [loading, setLoading] = React.useState(true); + const [statusErr, setStatusErr] = React.useState(null); + const [busy, setBusy] = React.useState(null); // vendor key currently installing + const [log, setLog] = React.useState(null); // { vendor, text, ok, rebootRequired } + + const loadStatus = React.useCallback(() => { + if (!sel.dbId) return; + setLoading(true); setStatusErr(null); + window.ZAMPP_API.fetch(`/cluster/${sel.dbId}/driver-status`) + .then(d => { setStatus(d); setLoading(false); }) + .catch(e => { setStatusErr(e.message || 'unreachable'); setLoading(false); }); + }, [sel.dbId]); + + React.useEffect(() => { loadStatus(); }, [loadStatus]); + + // Heartbeat-reported capabilities give a second signal for the two card types + // the cluster already enumerates (Blackmagic ports, Deltacast ports). + const capPresent = (vendor) => { + if (vendor === 'blackmagic') return (sel.bmdPorts || []).length > 0; + if (vendor === 'deltacast') return (sel.deltacastPorts || []).length > 0; + return false; + }; + + const isInstalled = (vendor) => { + const v = status && status.vendors && status.vendors[vendor]; + return (v && v.installed) || capPresent(vendor); + }; + + const install = (vendor) => { + setBusy(vendor); + setLog({ vendor, text: `[ui] requesting install of ${vendor} on ${sel.id}…\n`, ok: null, rebootRequired: false }); + // Raw fetch: we need the JSON body (logs) even on a non-2xx response. + fetch(`/api/v1/cluster/${sel.dbId}/install-driver`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ vendor }), + }) + .then(async (res) => { + let body = {}; + try { body = await res.json(); } catch (_) {} + const text = (body.logs && body.logs.trim()) + ? body.logs + : (body.error || `Install ${res.ok ? 'completed' : 'failed'} (HTTP ${res.status})`); + setLog({ vendor, text, ok: !!body.ok, rebootRequired: !!body.rebootRequired }); + setBusy(null); + loadStatus(); + }) + .catch((e) => { + setLog({ vendor, text: `[ui] request failed: ${e.message}`, ok: false, rebootRequired: false }); + setBusy(null); + }); + }; + + return ( +
+
+ + Capture Drivers / SDKs + {status && status.kernel && ( + + kernel {status.kernel} + + )} +
+ + {statusErr && ( +
+ Driver status unavailable: {statusErr} +
+ )} + +
+ {DRIVER_VENDORS.map(v => { + const installed = isInstalled(v.key); + const installing = busy === v.key; + return ( +
+
+
{v.label}
+
{v.hint}
+
+ + {loading ? "…" : (installed ? "INSTALLED" : "NOT INSTALLED")} + + +
+ ); + })} +
+ + {log && ( +
+
+ {log.vendor} install output + {log.ok === true && SUCCESS} + {log.ok === false && FAILED} + {log.rebootRequired && ( + + ⚠ REBOOT REQUIRED + + )} +
+
{log.text}
+
+ )} +
+ ); +} + // BmdCardPanel - capture-card section inside the Cluster node detail panel. // Shows port chips with live video-presence dots AND the BMD SVG card diagram. // ──────────────────────────────────────────────────────────────────────────── @@ -1556,6 +1706,9 @@ function Cluster() { {/* ── Capture cards ── */} + + {/* ── Capture Drivers / SDKs ── */} +