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/<vendor>/ (private repo); binaries are admin-supplied per each sdk/<vendor>/README.md. Vendor allowlist throughout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
549ca6c73f
commit
fffff1c016
15 changed files with 882 additions and 1 deletions
297
deploy/install-driver.sh
Normal file
297
deploy/install-driver.sh
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================================
|
||||||
|
# install-driver.sh <vendor>
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 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/<vendor>/ (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/<vendor>/
|
||||||
|
# 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 <blackmagic|aja|deltacast|ndi>" >&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
|
||||||
|
|
@ -55,10 +55,25 @@ services:
|
||||||
BMD_MODEL: ${BMD_MODEL:-}
|
BMD_MODEL: ${BMD_MODEL:-}
|
||||||
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
|
BMD_DEVICE_PREFIX: ${BMD_DEVICE_PREFIX:-dv}
|
||||||
LIVE_DIR: ${LIVE_DIR:-/mnt/NVME/MAM/wild-dragon-live}
|
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/<vendor>/ 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:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- /dev:/dev:ro
|
- /dev:/dev:ro
|
||||||
- /mnt/NVME/MAM/wild-dragon-live:/mnt/NVME/MAM/wild-dragon-live: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/<vendor>/ + 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:
|
devices:
|
||||||
- /dev/blackmagic:/dev/blackmagic
|
- /dev/blackmagic:/dev/blackmagic
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,11 @@ services:
|
||||||
DOCKER_NETWORK: wild-dragon_wild-dragon
|
DOCKER_NETWORK: wild-dragon_wild-dragon
|
||||||
NODE_IP: ${NODE_IP}
|
NODE_IP: ${NODE_IP}
|
||||||
NODE_HOSTNAME: ${NODE_HOSTNAME:-}
|
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}
|
CAPTURE_TOKEN: ${CAPTURE_TOKEN}
|
||||||
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
PLAYOUT_IMAGE: ${PLAYOUT_IMAGE:-wild-dragon-playout:latest}
|
||||||
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
PLAYOUT_AMCP_BASE_PORT: ${PLAYOUT_AMCP_BASE_PORT:-5250}
|
||||||
|
|
|
||||||
42
sdk/README.md
Normal file
42
sdk/README.md
Normal file
|
|
@ -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 <vendor>`](../deploy/install-driver.sh), which reads
|
||||||
|
the vendor files from `sdk/<vendor>/`. 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/<vendor>/` 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.
|
||||||
0
sdk/aja/.gitkeep
Normal file
0
sdk/aja/.gitkeep
Normal file
31
sdk/aja/README.md
Normal file
31
sdk/aja/README.md
Normal file
|
|
@ -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).
|
||||||
0
sdk/blackmagic/.gitkeep
Normal file
0
sdk/blackmagic/.gitkeep
Normal file
35
sdk/blackmagic/README.md
Normal file
35
sdk/blackmagic/README.md
Normal file
|
|
@ -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/<arch>/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.
|
||||||
0
sdk/deltacast/.gitkeep
Normal file
0
sdk/deltacast/.gitkeep
Normal file
31
sdk/deltacast/README.md
Normal file
31
sdk/deltacast/README.md
Normal file
|
|
@ -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* (<https://www.deltacast.tv/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 <dir>` 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.
|
||||||
0
sdk/ndi/.gitkeep
Normal file
0
sdk/ndi/.gitkeep
Normal file
35
sdk/ndi/README.md
Normal file
35
sdk/ndi/README.md
Normal file
|
|
@ -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.<N>` 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.
|
||||||
|
|
@ -350,6 +350,83 @@ router.get('/:id/ping', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} 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 <vendor> 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) => {
|
router.get('/metrics', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,16 @@ const NODE_ROLE = process.env.NODE_ROLE || 'worker';
|
||||||
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
|
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7436', 10);
|
||||||
const HEARTBEAT_MS = parseInt(process.env.HEARTBEAT_MS || '30000', 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 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/<vendor>/ 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,
|
// Pick the host's LAN IP. Inside a bridge-mode container,
|
||||||
// os.networkInterfaces() returns the container's docker-bridge IP (172.x),
|
// 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 <NODE_TOKEN>`. 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 <vendor> 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/<vendor>/ + 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) ───────────────────────────────────────────
|
// ── CPU sampling (500ms window) ───────────────────────────────────────────
|
||||||
function sampleCpu() {
|
function sampleCpu() {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
|
@ -572,6 +722,16 @@ const server = http.createServer((req, res) => {
|
||||||
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
const id = pathname.slice('/sidecar/'.length, -'/status'.length);
|
||||||
handleSidecarStatus(id, res);
|
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/')) {
|
} else if (req.method === 'GET' && pathname.startsWith('/live/')) {
|
||||||
serveLiveFile(pathname, res);
|
serveLiveFile(pathname, res);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ function _normalizeNode(n, x, y) {
|
||||||
online: b.online !== false,
|
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 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);
|
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
|
// Raw capabilities for the hardware panel
|
||||||
gpus,
|
gpus,
|
||||||
bmdPorts,
|
bmdPorts,
|
||||||
|
deltacastPorts,
|
||||||
// Legacy flat arrays kept for the stat-row summary cards
|
// Legacy flat arrays kept for the stat-row summary cards
|
||||||
gpuCount: gpus.length,
|
gpuCount: gpus.length,
|
||||||
bmdCount: bmdPorts.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 (
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<Icon name="download" size={11} />
|
||||||
|
Capture Drivers / SDKs
|
||||||
|
{status && status.kernel && (
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 10, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>
|
||||||
|
kernel {status.kernel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{statusErr && (
|
||||||
|
<div style={{ fontSize: 11, color: "var(--danger)", marginBottom: 6 }}>
|
||||||
|
Driver status unavailable: {statusErr}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{DRIVER_VENDORS.map(v => {
|
||||||
|
const installed = isInstalled(v.key);
|
||||||
|
const installing = busy === v.key;
|
||||||
|
return (
|
||||||
|
<div key={v.key} style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
padding: "6px 10px", background: "var(--bg-2)", borderRadius: 5,
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>{v.label}</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: "var(--text-4)" }}>{v.hint}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3,
|
||||||
|
background: installed ? "rgba(91,250,138,0.15)" : "rgba(255,255,255,0.05)",
|
||||||
|
color: installed ? "var(--success)" : "var(--text-3)",
|
||||||
|
}}>
|
||||||
|
{loading ? "…" : (installed ? "INSTALLED" : "NOT INSTALLED")}
|
||||||
|
</span>
|
||||||
|
<button className="btn ghost sm" disabled={installing || !!busy}
|
||||||
|
onClick={() => install(v.key)}>
|
||||||
|
{installing ? "Installing…" : (installed ? "Update" : "Install")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 10.5, marginBottom: 4, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<span style={{ color: "var(--text-3)" }}>{log.vendor} install output</span>
|
||||||
|
{log.ok === true && <span style={{ color: "var(--success)", fontWeight: 700 }}>SUCCESS</span>}
|
||||||
|
{log.ok === false && <span style={{ color: "var(--danger)", fontWeight: 700 }}>FAILED</span>}
|
||||||
|
{log.rebootRequired && (
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 700, color: "var(--warning, #f0a500)" }}>
|
||||||
|
⚠ REBOOT REQUIRED
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<pre style={{
|
||||||
|
margin: 0, maxHeight: 180, overflow: "auto",
|
||||||
|
fontSize: 10.5, lineHeight: 1.4, fontFamily: "var(--font-mono)",
|
||||||
|
color: "var(--text-2)", background: "var(--bg-1)",
|
||||||
|
border: "1px solid var(--border)", borderRadius: 4, padding: "6px 8px",
|
||||||
|
whiteSpace: "pre-wrap", wordBreak: "break-word",
|
||||||
|
}}>{log.text}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// BmdCardPanel - capture-card section inside the Cluster node detail panel.
|
// BmdCardPanel - capture-card section inside the Cluster node detail panel.
|
||||||
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -1556,6 +1706,9 @@ function Cluster() {
|
||||||
|
|
||||||
{/* ── Capture cards ── */}
|
{/* ── Capture cards ── */}
|
||||||
<BmdCardPanel sel={sel} portSignals={portSignals} />
|
<BmdCardPanel sel={sel} portSignals={portSignals} />
|
||||||
|
|
||||||
|
{/* ── Capture Drivers / SDKs ── */}
|
||||||
|
<DriverPanel sel={sel} />
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||||
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
|
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
|
||||||
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
|
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue