From c86f1b936e051b4a74740c8a90ae6eb42b860a19 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 16 Apr 2026 16:56:17 +0000 Subject: [PATCH] Add modular installer scripts Worker scripts with JSON event streaming: - detect.sh: system/Docker/Frigate/go2rtc detection - prepare.sh: Docker and Compose installation - strix.sh: deploy Strix standalone - strix-frigate.sh: deploy Strix + Frigate with HW autodetect - proxmox-lxc-create.sh: create Ubuntu LXC on Proxmox - install.sh: animated frontend with owl display --- scripts/detect.sh | 159 +++++++++ scripts/install.sh | 262 +++++++++++++++ scripts/prepare.sh | 400 ++++++++++++++++++++++ scripts/proxmox-lxc-create.sh | 606 ++++++++++++++++++++++++++++++++++ scripts/strix-frigate.sh | 428 ++++++++++++++++++++++++ scripts/strix.sh | 274 +++++++++++++++ 6 files changed, 2129 insertions(+) create mode 100755 scripts/detect.sh create mode 100755 scripts/install.sh create mode 100755 scripts/prepare.sh create mode 100755 scripts/proxmox-lxc-create.sh create mode 100755 scripts/strix-frigate.sh create mode 100755 scripts/strix.sh diff --git a/scripts/detect.sh b/scripts/detect.sh new file mode 100755 index 0000000..f00f190 --- /dev/null +++ b/scripts/detect.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- detect.sh (worker) +# +# Detects system environment: OS type, Docker, Compose, Frigate, go2rtc. +# Fast, silent, returns JSON events to stdout. +# +# Protocol: +# - Every action is reported as a single-line JSON to stdout. +# - Types: check, ok, miss, error, done +# - Exit code: 0 always (detection never "fails", it just reports what it finds) +# +# Usage: +# bash scripts/detect.sh +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# JSON helpers +# --------------------------------------------------------------------------- +emit() { + local type="$1" + local msg="$2" + local data="${3:-}" + + msg="${msg//\\/\\\\}" + msg="${msg//\"/\\\"}" + + if [[ -n "$data" ]]; then + printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data" + else + printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg" + fi +} + +# --------------------------------------------------------------------------- +# 1. System type +# --------------------------------------------------------------------------- +detect_system() { + emit "check" "Detecting system" + + if command -v pveversion &>/dev/null; then + local pve_ver + pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown") + emit "ok" "Proxmox VE ${pve_ver}" "{\"type\":\"proxmox\",\"pve_version\":\"${pve_ver}\"}" + + elif [[ "$(uname -s 2>/dev/null)" == "Darwin" ]]; then + local mac_ver + mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown") + local arch + arch=$(uname -m 2>/dev/null || echo "unknown") + emit "ok" "macOS ${mac_ver} (${arch})" "{\"type\":\"macos\",\"version\":\"${mac_ver}\",\"arch\":\"${arch}\"}" + + else + local os_name="Linux" + local os_id="unknown" + local os_ver="unknown" + local arch + arch=$(uname -m 2>/dev/null || echo "unknown") + + if [[ -f /etc/os-release ]]; then + . /etc/os-release + os_name="${PRETTY_NAME:-Linux}" + os_id="${ID:-unknown}" + os_ver="${VERSION_ID:-unknown}" + fi + + emit "ok" "${os_name} (${arch})" "{\"type\":\"linux\",\"id\":\"${os_id}\",\"version\":\"${os_ver}\",\"arch\":\"${arch}\"}" + fi +} + +# --------------------------------------------------------------------------- +# 2. Docker +# --------------------------------------------------------------------------- +detect_docker() { + emit "check" "Checking Docker" + + if command -v docker &>/dev/null; then + local ver + ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") + emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}" + else + emit "miss" "Docker not installed" + fi +} + +# --------------------------------------------------------------------------- +# 3. Docker Compose +# --------------------------------------------------------------------------- +detect_compose() { + emit "check" "Checking Docker Compose" + + if docker compose version &>/dev/null 2>&1; then + local ver + ver=$(docker compose version --short 2>/dev/null || echo "unknown") + emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"plugin\"}" + elif command -v docker-compose &>/dev/null; then + local ver + ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") + emit "ok" "Compose ${ver}" "{\"version\":\"${ver}\",\"type\":\"standalone\"}" + else + emit "miss" "Docker Compose not installed" + fi +} + +# --------------------------------------------------------------------------- +# 4. Frigate +# --------------------------------------------------------------------------- +detect_frigate() { + emit "check" "Checking Frigate" + + if command -v curl &>/dev/null; then + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then + emit "ok" "Frigate on port 5000" "{\"url\":\"http://localhost:5000\",\"port\":5000}" + return + fi + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:8971/api/config" &>/dev/null; then + emit "ok" "Frigate on port 8971" "{\"url\":\"http://localhost:8971\",\"port\":8971}" + return + fi + fi + + emit "miss" "Frigate not found" +} + +# --------------------------------------------------------------------------- +# 5. go2rtc +# --------------------------------------------------------------------------- +detect_go2rtc() { + emit "check" "Checking go2rtc" + + if command -v curl &>/dev/null; then + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:1984/api" &>/dev/null; then + emit "ok" "go2rtc on port 1984" "{\"url\":\"http://localhost:1984\",\"port\":1984}" + return + fi + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:11984/api" &>/dev/null; then + emit "ok" "go2rtc on port 11984" "{\"url\":\"http://localhost:11984\",\"port\":11984}" + return + fi + fi + + emit "miss" "go2rtc not found" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + detect_system + detect_docker + detect_compose + detect_frigate + detect_go2rtc + printf '{"type":"done","ok":true}\n' +} + +main diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..d9e9df1 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- install.sh (navigator / frontend) +# +# Main entry point for Strix installation. +# Shows animated owl + STRIX logo while running background checks. +# Detects system type (Proxmox / Linux / macOS), Docker, Frigate, go2rtc. +# Then guides the user through installation by calling worker scripts. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/scripts/install.sh | bash +# bash scripts/install.sh +# ============================================================================= + +# --------------------------------------------------------------------------- +# Owl definitions (5 lines each: line1, line2, line3, line4, name) +# --------------------------------------------------------------------------- +OWL_COUNT=5 + +# Wide-eyed owl +OWL_0_1=" ___" +OWL_0_2=" " +OWL_0_3=" [\`-']" +OWL_0_4=" -\"-\"-" +OWL_0_NAME="wide-eyed owl" + +# Happy owl +OWL_1_1=" ___" +OWL_1_2=" <^,^>" +OWL_1_3=" [\`-']" +OWL_1_4=" -\"-\"-" +OWL_1_NAME="happy owl" + +# Winking owl +OWL_2_1=" ___" +OWL_2_2=" <*,->" +OWL_2_3=" [\`-']" +OWL_2_4=" -\"-\"-" +OWL_2_NAME="winking owl" + +# Flying owl +OWL_3_1=" ___" +OWL_3_2=" <*,*>" +OWL_3_3=" =^\`-'^=" +OWL_3_4=" \" \"" +OWL_3_NAME="flying owl" + +# Super owl +OWL_4_1=" ___" +OWL_4_2=" <*,*>" +OWL_4_3=" [\`S']" +OWL_4_4=" -\"-\"-" +OWL_4_NAME="super owl" + +# --------------------------------------------------------------------------- +# Terminal helpers +# --------------------------------------------------------------------------- +term_width() { + local w + w=$(tput cols 2>/dev/null || echo 80) + [[ "$w" -lt 20 ]] && w=80 + echo "$w" +} + +term_height() { + local h + h=$(tput lines 2>/dev/null || echo 24) + [[ "$h" -lt 10 ]] && h=24 + echo "$h" +} + +# Print text centered horizontally at a specific row +# Usage: print_at_center ROW "text" [color_code] +print_at_center() { + local row="$1" + local text="$2" + local color="${3:-}" + local reset="\033[0m" + + local w + w=$(term_width) + + # Strip ANSI for length calc + local stripped + stripped=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g') + local len=${#stripped} + local col=$(( (w - len) / 2 )) + [[ "$col" -lt 0 ]] && col=0 + + tput cup "$row" "$col" 2>/dev/null + if [[ -n "$color" ]]; then + echo -ne "${color}${text}${reset}" + else + echo -ne "${text}" + fi +} + +# --------------------------------------------------------------------------- +# Show a single owl centered on screen +# Usage: show_owl INDEX [brightness] +# brightness: "bright" "dim" "verydim" "hidden" +# --------------------------------------------------------------------------- +show_owl() { + local idx="$1" + local brightness="${2:-bright}" + + local color="" + case "$brightness" in + bright) color="\033[97m" ;; # bright white + dim) color="\033[37m" ;; # normal white + verydim) color="\033[90m" ;; # dark gray + hidden) color="\033[30m" ;; # black (invisible) + esac + + local name_color="" + case "$brightness" in + bright) name_color="\033[36m" ;; # cyan + dim) name_color="\033[2;36m" ;; # dim cyan + verydim) name_color="\033[90m" ;; # dark gray + hidden) name_color="\033[30m" ;; # black + esac + + # Get owl lines by index + local l1 l2 l3 l4 name + eval "l1=\"\$OWL_${idx}_1\"" + eval "l2=\"\$OWL_${idx}_2\"" + eval "l3=\"\$OWL_${idx}_3\"" + eval "l4=\"\$OWL_${idx}_4\"" + eval "name=\"\$OWL_${idx}_NAME\"" + + # Position: top area of screen + local start_row=2 + + print_at_center "$start_row" "$l1" "$color" + print_at_center "$((start_row+1))" "$l2" "$color" + print_at_center "$((start_row+2))" "$l3" "$color" + print_at_center "$((start_row+3))" "$l4" "$color" +} + +# --------------------------------------------------------------------------- +# Clear owl area (rows 2-6) +# --------------------------------------------------------------------------- +clear_owl_area() { + local w + w=$(term_width) + local blank + blank=$(printf "%*s" "$w" "") + + for row in 2 3 4 5 6; do + tput cup "$row" 0 2>/dev/null + echo -ne "$blank" + done +} + +# --------------------------------------------------------------------------- +# Transition: fade out current owl, fade in next +# --------------------------------------------------------------------------- +transition_owl() { + local from_idx="$1" + local to_idx="$2" + + # Fade out: bright -> dim -> verydim -> hidden + show_owl "$from_idx" "dim" + sleep 0.1 + show_owl "$from_idx" "verydim" + sleep 0.1 + clear_owl_area + + # Fade in: hidden -> verydim -> dim -> bright + show_owl "$to_idx" "verydim" + sleep 0.1 + show_owl "$to_idx" "dim" + sleep 0.1 + show_owl "$to_idx" "bright" +} + +# --------------------------------------------------------------------------- +# Cycle through all owls with animation +# Usage: cycle_owls [cycles] [delay_between_seconds] +# cycles=0 means infinite +# --------------------------------------------------------------------------- +cycle_owls() { + local cycles="${1:-3}" + local delay="${2:-2}" + local infinite=false + [[ "$cycles" -eq 0 ]] && infinite=true + + local current=0 + local i=0 + + while [[ "$infinite" == true ]] || [[ "$i" -lt $((cycles * OWL_COUNT)) ]]; do + [[ "${_OWL_RUNNING:-true}" == false ]] && return + + local next=$(( (current + 1) % OWL_COUNT )) + + if [[ "$i" -eq 0 ]]; then + show_owl "$current" "verydim" + sleep 0.1 || return + show_owl "$current" "dim" + sleep 0.1 || return + show_owl "$current" "bright" + else + transition_owl "$current" "$next" + current=$next + fi + + sleep "$delay" || return + i=$((i + 1)) + done +} + +# --------------------------------------------------------------------------- +# Setup screen +# --------------------------------------------------------------------------- +setup_screen() { + tput civis 2>/dev/null + clear +} + +# --------------------------------------------------------------------------- +# Restore screen +# --------------------------------------------------------------------------- +restore_screen() { + tput cnorm 2>/dev/null + echo -ne "\033[0m" + clear +} + +# --------------------------------------------------------------------------- +# STRIX block title (static, drawn once below owl area) +# --------------------------------------------------------------------------- +show_title() { + local c="\033[35;1m" # bold magenta + local tr=7 + print_at_center "$((tr))" '███████╗████████╗██████╗ ██╗██╗ ██╗' "$c" + print_at_center "$((tr+1))" '██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝' "$c" + print_at_center "$((tr+2))" '███████╗ ██║ ██████╔╝██║ ╚███╔╝' "$c" + print_at_center "$((tr+3))" '╚════██║ ██║ ██╔══██╗██║ ██╔██╗' "$c" + print_at_center "$((tr+4))" '███████║ ██║ ██║ ██║██║██╔╝ ██╗' "$c" + print_at_center "$((tr+5))" '╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝' "$c" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + _OWL_RUNNING=true + + _owl_cleanup() { + _OWL_RUNNING=false + restore_screen + exit 0 + } + + trap _owl_cleanup INT TERM + trap restore_screen EXIT + + setup_screen + show_title + cycle_owls 0 2 + restore_screen +fi diff --git a/scripts/prepare.sh b/scripts/prepare.sh new file mode 100755 index 0000000..c8ddab5 --- /dev/null +++ b/scripts/prepare.sh @@ -0,0 +1,400 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- prepare.sh (worker) +# +# Silent backend worker that prepares the system for Strix deployment. +# Detects OS, installs Docker and Docker Compose if missing. +# +# Protocol: +# - Every action is reported as a single-line JSON to stdout. +# - Types: check, ok, miss, install, error, done +# - Field "msg" is always human-readable. +# - Field "data" is optional, carries machine-readable details. +# - Last line is always: {"type":"done","ok":true} or {"type":"done","ok":false,"error":"..."} +# - All internal command output goes to /dev/null or stderr (never stdout). +# - Exit code: 0 = success, 1 = failure. +# +# Usage: +# bash scripts/prepare.sh +# result=$(bash scripts/prepare.sh) +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# JSON helpers (no jq dependency) +# --------------------------------------------------------------------------- + +# Emit a JSON event line to stdout. +# Usage: emit "type" "msg" '{"key":"val"}' +emit() { + local type="$1" + local msg="$2" + local data="${3:-}" + + # Escape double quotes in msg + msg="${msg//\\/\\\\}" + msg="${msg//\"/\\\"}" + + if [[ -n "$data" ]]; then + printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data" + else + printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg" + fi +} + +# Emit final done event and exit. +emit_done() { + local ok="$1" + local error="${2:-}" + + if [[ "$ok" == "true" ]]; then + printf '{"type":"done","ok":true}\n' + exit 0 + else + error="${error//\\/\\\\}" + error="${error//\"/\\\"}" + printf '{"type":"done","ok":false,"error":"%s"}\n' "$error" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# OS detection +# --------------------------------------------------------------------------- +detect_os() { + emit "check" "Detecting operating system" + + local kernel + kernel=$(uname -s 2>/dev/null || echo "unknown") + + case "$kernel" in + Linux) + local os_id="unknown" + local os_ver="unknown" + local os_name="Unknown Linux" + + if [[ -f /etc/os-release ]]; then + # shellcheck disable=SC1091 + . /etc/os-release + os_id="${ID:-unknown}" + os_ver="${VERSION_ID:-unknown}" + os_name="${PRETTY_NAME:-${ID} ${VERSION_ID}}" + fi + + local arch + arch=$(uname -m 2>/dev/null || echo "unknown") + local arch_label="$arch" + case "$arch" in + x86_64) arch_label="amd64" ;; + aarch64) arch_label="arm64" ;; + armv7l) arch_label="armv7" ;; + esac + + OS_TYPE="linux" + OS_ID="$os_id" + OS_VER="$os_ver" + OS_NAME="$os_name" + OS_ARCH="$arch_label" + + emit "ok" "${os_name} (${arch_label})" \ + "{\"os\":\"linux\",\"id\":\"${os_id}\",\"ver\":\"${os_ver}\",\"arch\":\"${arch_label}\"}" + ;; + + Darwin) + local mac_ver + mac_ver=$(sw_vers -productVersion 2>/dev/null || echo "unknown") + + local arch + arch=$(uname -m 2>/dev/null || echo "unknown") + local arch_label="$arch" + case "$arch" in + x86_64) arch_label="amd64" ;; + arm64) arch_label="arm64" ;; + esac + + OS_TYPE="mac" + OS_ID="macos" + OS_VER="$mac_ver" + OS_NAME="macOS ${mac_ver}" + OS_ARCH="$arch_label" + + emit "ok" "macOS ${mac_ver} (${arch_label})" \ + "{\"os\":\"mac\",\"id\":\"macos\",\"ver\":\"${mac_ver}\",\"arch\":\"${arch_label}\"}" + ;; + + *) + emit "error" "Unsupported OS: ${kernel}" \ + "{\"kernel\":\"${kernel}\"}" + emit_done "false" "Unsupported operating system: ${kernel}" + ;; + esac +} + +# --------------------------------------------------------------------------- +# Root check (Linux only) +# --------------------------------------------------------------------------- +check_root() { + if [[ "$OS_TYPE" == "mac" ]]; then + return + fi + + emit "check" "Checking root privileges" + + if [[ "$(id -u)" -eq 0 ]]; then + emit "ok" "Running as root" + else + emit "error" "Root privileges required. Run with sudo." + emit_done "false" "Not running as root" + fi +} + +# --------------------------------------------------------------------------- +# curl (required for Docker install and compose download) +# --------------------------------------------------------------------------- +ensure_curl() { + emit "check" "Checking curl" + + if command -v curl &>/dev/null; then + emit "ok" "curl available" + return 0 + fi + + emit "miss" "curl not found" + emit "install" "Installing curl" + + local pkg_mgr="unknown" + if command -v apt-get &>/dev/null; then + pkg_mgr="apt" + emit "check" "Updating apt package lists" + if ! apt-get update -qq &>/dev/null; then + emit "error" "apt-get update failed" + emit_done "false" "Failed to update package lists" + fi + emit "ok" "Package lists updated" + emit "install" "Installing curl via apt" + apt-get install -y -qq curl &>/dev/null + elif command -v yum &>/dev/null; then + pkg_mgr="yum" + emit "install" "Installing curl via yum" + yum install -y -q curl &>/dev/null + elif command -v dnf &>/dev/null; then + pkg_mgr="dnf" + emit "install" "Installing curl via dnf" + dnf install -y -q curl &>/dev/null + elif command -v apk &>/dev/null; then + pkg_mgr="apk" + emit "install" "Installing curl via apk" + apk add --no-cache curl &>/dev/null + elif command -v pacman &>/dev/null; then + pkg_mgr="pacman" + emit "install" "Installing curl via pacman" + pacman -Sy --noconfirm curl &>/dev/null + elif command -v zypper &>/dev/null; then + pkg_mgr="zypper" + emit "install" "Installing curl via zypper" + zypper install -y curl &>/dev/null + else + emit "error" "No supported package manager found" "{\"tried\":\"apt,yum,dnf,apk,pacman,zypper\"}" + emit_done "false" "Cannot install curl: no supported package manager" + fi + + if command -v curl &>/dev/null; then + emit "ok" "curl installed via ${pkg_mgr}" + return 0 + fi + + emit "error" "curl installation failed via ${pkg_mgr}" + emit_done "false" "curl installation failed" +} + +# --------------------------------------------------------------------------- +# Docker +# --------------------------------------------------------------------------- +check_docker() { + emit "check" "Checking Docker" + + if command -v docker &>/dev/null; then + local ver + ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") + emit "ok" "Docker ${ver}" "{\"version\":\"${ver}\"}" + return 0 + fi + + emit "miss" "Docker not found" + return 1 +} + +install_docker_linux() { + emit "install" "Downloading Docker install script from get.docker.com" + + local tmp_script="/tmp/get-docker.sh" + if ! curl -fsSL https://get.docker.com -o "$tmp_script" 2>/dev/null; then + emit "error" "Failed to download get.docker.com" + emit_done "false" "Docker download failed" + fi + + emit "ok" "Docker install script downloaded" + emit "install" "Running Docker install script (this may take a minute)" + + if sh "$tmp_script" &>/dev/null; then + rm -f "$tmp_script" + emit "ok" "Docker install script completed" + else + rm -f "$tmp_script" + emit "error" "Docker install script failed" + emit_done "false" "Docker installation failed" + fi + + # Enable and start via systemd + if command -v systemctl &>/dev/null; then + emit "check" "Enabling Docker service" + systemctl enable docker &>/dev/null || true + systemctl start docker &>/dev/null || true + + if systemctl is-active docker &>/dev/null; then + emit "ok" "Docker service started" + else + emit "error" "Docker service failed to start" + emit_done "false" "Docker service failed to start" + fi + fi + + # Verify docker binary works + if command -v docker &>/dev/null; then + local ver + ver=$(docker --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") + emit "ok" "Docker ${ver} installed" "{\"version\":\"${ver}\"}" + return 0 + fi + + emit "error" "Docker binary not found after install" + emit_done "false" "Docker installation failed" +} + +install_docker_mac() { + emit "check" "Checking Docker Desktop for Mac" + + # Docker Desktop should already be installed on Mac. + # We can't silently install it -- it requires GUI interaction. + emit "error" "Docker not found. Install Docker Desktop from https://docker.com/products/docker-desktop" + emit_done "false" "Docker Desktop not installed on Mac" +} + +# --------------------------------------------------------------------------- +# Docker Compose +# --------------------------------------------------------------------------- +check_compose() { + emit "check" "Checking Docker Compose" + + # Plugin (v2): docker compose + if docker compose version &>/dev/null; then + local ver + ver=$(docker compose version --short 2>/dev/null || echo "unknown") + COMPOSE_CMD="docker compose" + emit "ok" "Docker Compose ${ver} (plugin)" "{\"version\":\"${ver}\",\"type\":\"plugin\"}" + return 0 + fi + + # Standalone: docker-compose + if command -v docker-compose &>/dev/null; then + local ver + ver=$(docker-compose --version 2>/dev/null | grep -oP '\d+\.\d+\.\d+' | head -1 || echo "unknown") + COMPOSE_CMD="docker-compose" + emit "ok" "Docker Compose ${ver} (standalone)" "{\"version\":\"${ver}\",\"type\":\"standalone\"}" + return 0 + fi + + emit "miss" "Docker Compose not found" + return 1 +} + +install_compose_linux() { + emit "install" "Installing Docker Compose plugin" + + local installed=false + + # Try package manager first + if command -v apt-get &>/dev/null; then + apt-get update -qq &>/dev/null && apt-get install -y -qq docker-compose-plugin &>/dev/null && installed=true + elif command -v yum &>/dev/null; then + yum install -y -q docker-compose-plugin &>/dev/null && installed=true + elif command -v dnf &>/dev/null; then + dnf install -y -q docker-compose-plugin &>/dev/null && installed=true + fi + + # Fallback: download binary + if [[ "$installed" == false ]]; then + emit "install" "Downloading Docker Compose binary" + + local compose_ver="v2.29.1" + local compose_arch + case "$OS_ARCH" in + amd64) compose_arch="x86_64" ;; + arm64) compose_arch="aarch64" ;; + *) compose_arch="$(uname -m)" ;; + esac + + mkdir -p /usr/local/lib/docker/cli-plugins &>/dev/null + if curl -fsSL "https://github.com/docker/compose/releases/download/${compose_ver}/docker-compose-linux-${compose_arch}" \ + -o /usr/local/lib/docker/cli-plugins/docker-compose &>/dev/null; then + chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + installed=true + fi + fi + + # Verify + if [[ "$installed" == true ]] && docker compose version &>/dev/null; then + local ver + ver=$(docker compose version --short 2>/dev/null || echo "unknown") + COMPOSE_CMD="docker compose" + emit "ok" "Docker Compose ${ver} installed" "{\"version\":\"${ver}\"}" + return 0 + fi + + emit "error" "Docker Compose installation failed" + emit_done "false" "Docker Compose installation failed" +} + +install_compose_mac() { + # On Mac, Docker Compose comes with Docker Desktop + emit "error" "Docker Compose not found. It should be included with Docker Desktop." + emit_done "false" "Docker Compose missing on Mac" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + # 1. Detect OS + detect_os + + # 2. Root check + check_root + + # 3. curl (needed for Docker install, always present on Mac) + if [[ "$OS_TYPE" == "linux" ]]; then + ensure_curl + fi + + # 4. Docker + if ! check_docker; then + case "$OS_TYPE" in + linux) install_docker_linux ;; + mac) install_docker_mac ;; + esac + fi + + # 5. Docker Compose + if ! check_compose; then + case "$OS_TYPE" in + linux) install_compose_linux ;; + mac) install_compose_mac ;; + esac + fi + + # 6. All good + emit_done "true" +} + +main diff --git a/scripts/proxmox-lxc-create.sh b/scripts/proxmox-lxc-create.sh new file mode 100755 index 0000000..470c1e9 --- /dev/null +++ b/scripts/proxmox-lxc-create.sh @@ -0,0 +1,606 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- proxmox-lxc-create.sh (worker) +# +# Creates an unprivileged Ubuntu LXC container on Proxmox with Docker support. +# Runs ON the Proxmox host. Uses only official CLI tools (pct, pveam, pvesm). +# Does NOT install anything inside the container -- just creates and starts it. +# +# Protocol: +# - Every action is reported as a single-line JSON to stdout. +# - Types: check, ok, miss, install, error, done +# - Last line: {"type":"done","ok":true,"data":{...}} or {"type":"done","ok":false,"error":"..."} +# - Exit code: 0 = success, 1 = failure. +# +# Parameters (all optional): +# --id ID Container ID (default: auto, next free) +# --hostname NAME Hostname (default: strix) +# --memory MB RAM in MB (default: 2048) +# --swap MB Swap in MB (default: 512) +# --disk GB Disk size in GB (default: 32) +# --cores N CPU cores (default: 2) +# --storage NAME Storage for container disk (default: auto) +# --bridge NAME Network bridge (default: auto, first vmbr*) +# --ip CIDR IP address, e.g. 10.0.99.110/24 (default: dhcp) +# --gateway IP Gateway (required if --ip is static) +# --password PASS Root password (default: auto-generated) +# +# Usage: +# bash scripts/proxmox-lxc-create.sh +# bash scripts/proxmox-lxc-create.sh --hostname strix --memory 4096 --cores 4 +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +CT_ID="" +CT_HOSTNAME="strix" +CT_MEMORY="2048" +CT_SWAP="512" +CT_DISK="32" +CT_CORES="2" +CT_STORAGE="" +CT_BRIDGE="" +CT_IP="dhcp" +CT_GATEWAY="" +CT_PASSWORD="" + +TEMPLATE_STORAGE="" +TEMPLATE="" + +# --------------------------------------------------------------------------- +# Parse CLI arguments +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --id) CT_ID="$2"; shift 2 ;; + --hostname) CT_HOSTNAME="$2"; shift 2 ;; + --memory) CT_MEMORY="$2"; shift 2 ;; + --swap) CT_SWAP="$2"; shift 2 ;; + --disk) CT_DISK="$2"; shift 2 ;; + --cores) CT_CORES="$2"; shift 2 ;; + --storage) CT_STORAGE="$2"; shift 2 ;; + --bridge) CT_BRIDGE="$2"; shift 2 ;; + --ip) CT_IP="$2"; shift 2 ;; + --gateway) CT_GATEWAY="$2"; shift 2 ;; + --password) CT_PASSWORD="$2"; shift 2 ;; + *) shift ;; + esac +done + +# --------------------------------------------------------------------------- +# JSON helpers +# --------------------------------------------------------------------------- +emit() { + local type="$1" + local msg="$2" + local data="${3:-}" + + msg="${msg//\\/\\\\}" + msg="${msg//\"/\\\"}" + + if [[ -n "$data" ]]; then + printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data" + else + printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg" + fi +} + +emit_done_ok() { + local data="$1" + printf '{"type":"done","ok":true,"data":%s}\n' "$data" + exit 0 +} + +emit_done_fail() { + local error="$1" + error="${error//\\/\\\\}" + error="${error//\"/\\\"}" + printf '{"type":"done","ok":false,"error":"%s"}\n' "$error" + exit 1 +} + +# Cleanup on failure: destroy container if it was partially created +cleanup_on_fail() { + local id="$1" + local msg="$2" + if pct status "$id" &>/dev/null; then + pct stop "$id" &>/dev/null || true + pct destroy "$id" --purge &>/dev/null || true + emit "ok" "Rolled back: container ${id} destroyed" + fi + emit "error" "$msg" + emit_done_fail "$msg" +} + +# --------------------------------------------------------------------------- +# 1. Verify Proxmox environment +# --------------------------------------------------------------------------- +check_proxmox() { + emit "check" "Verifying Proxmox environment" + + if ! command -v pct &>/dev/null; then + emit "error" "pct not found -- this script must run on a Proxmox host" + emit_done_fail "Not a Proxmox host" + fi + + if ! command -v pveam &>/dev/null; then + emit "error" "pveam not found -- this script must run on a Proxmox host" + emit_done_fail "Not a Proxmox host" + fi + + local pve_ver + pve_ver=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' || echo "unknown") + emit "ok" "Proxmox VE ${pve_ver}" "{\"pve_version\":\"${pve_ver}\"}" +} + +# --------------------------------------------------------------------------- +# 2. Auto-detect container ID +# --------------------------------------------------------------------------- +resolve_ct_id() { + emit "check" "Resolving container ID" + + if [[ -n "$CT_ID" ]]; then + # Verify it's free + if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then + emit "error" "Container/VM ID ${CT_ID} is already in use" + emit_done_fail "CT ID ${CT_ID} already in use" + fi + emit "ok" "Using specified ID: ${CT_ID}" + else + CT_ID=$(pvesh get /cluster/nextid 2>/dev/null || echo "") + if [[ -z "$CT_ID" ]]; then + emit "error" "Failed to get next free container ID" + emit_done_fail "Cannot get next free CT ID" + fi + # Double-check it's actually free + if pct status "$CT_ID" &>/dev/null || qm status "$CT_ID" &>/dev/null; then + CT_ID=$((CT_ID + 1)) + fi + emit "ok" "Auto-assigned ID: ${CT_ID}" "{\"id\":\"${CT_ID}\"}" + fi +} + +# --------------------------------------------------------------------------- +# 3. Auto-detect storage +# --------------------------------------------------------------------------- +resolve_storage() { + # Container storage (rootdir) + emit "check" "Resolving container storage" + + if [[ -n "$CT_STORAGE" ]]; then + if ! pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "$CT_STORAGE"; then + emit "error" "Storage '${CT_STORAGE}' not found" + emit_done_fail "Storage ${CT_STORAGE} not found" + fi + emit "ok" "Using specified storage: ${CT_STORAGE}" + else + # Find first storage that supports rootdir content + CT_STORAGE=$(pvesm status -content rootdir 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}') + if [[ -z "$CT_STORAGE" ]]; then + # Fallback: try local-lvm, then local + if pvesm status 2>/dev/null | awk 'NR>1{print $1}' | grep -qx "local-lvm"; then + CT_STORAGE="local-lvm" + else + CT_STORAGE="local" + fi + fi + emit "ok" "Auto-detected storage: ${CT_STORAGE}" "{\"storage\":\"${CT_STORAGE}\"}" + fi + + # Template storage (vztmpl) + emit "check" "Resolving template storage" + TEMPLATE_STORAGE=$(pvesm status -content vztmpl 2>/dev/null | awk 'NR>1 && $2=="active"{print $1; exit}') + if [[ -z "$TEMPLATE_STORAGE" ]]; then + TEMPLATE_STORAGE="local" + fi + emit "ok" "Template storage: ${TEMPLATE_STORAGE}" "{\"template_storage\":\"${TEMPLATE_STORAGE}\"}" +} + +# --------------------------------------------------------------------------- +# 4. Check free space +# --------------------------------------------------------------------------- +check_free_space() { + emit "check" "Checking free space on ${CT_STORAGE}" + + local avail_kb + avail_kb=$(pvesm status 2>/dev/null | awk -v s="$CT_STORAGE" '$1==s{print $6}') + + if [[ -n "$avail_kb" ]]; then + local avail_gb=$((avail_kb / 1024 / 1024)) + local required_gb=$CT_DISK + + if [[ "$avail_gb" -lt "$required_gb" ]]; then + emit "error" "Not enough space: ${avail_gb}GB available, ${required_gb}GB required" + emit_done_fail "Not enough disk space on ${CT_STORAGE}" + fi + emit "ok" "${avail_gb}GB available, ${required_gb}GB required" + else + emit "ok" "Could not determine free space, proceeding" + fi +} + +# --------------------------------------------------------------------------- +# 5. Auto-detect network bridge +# --------------------------------------------------------------------------- +resolve_bridge() { + emit "check" "Resolving network bridge" + + if [[ -n "$CT_BRIDGE" ]]; then + emit "ok" "Using specified bridge: ${CT_BRIDGE}" + return + fi + + # Find first vmbr* interface + CT_BRIDGE=$(ip link show 2>/dev/null | grep -oP 'vmbr\d+' | head -1) + + if [[ -z "$CT_BRIDGE" ]]; then + CT_BRIDGE="vmbr0" + emit "ok" "Defaulting to bridge: vmbr0" + else + emit "ok" "Auto-detected bridge: ${CT_BRIDGE}" "{\"bridge\":\"${CT_BRIDGE}\"}" + fi +} + +# --------------------------------------------------------------------------- +# 6. Generate password +# --------------------------------------------------------------------------- +resolve_password() { + if [[ -n "$CT_PASSWORD" ]]; then + return + fi + + emit "check" "Generating root password" + CT_PASSWORD=$(openssl rand -base64 12 2>/dev/null | tr -d '/+=' | head -c 16) + if [[ -z "$CT_PASSWORD" ]]; then + # Fallback if openssl not available + CT_PASSWORD=$(head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 16) + fi + emit "ok" "Root password generated" +} + +# --------------------------------------------------------------------------- +# 7. Download Ubuntu template +# --------------------------------------------------------------------------- +download_template() { + emit "check" "Searching for Ubuntu template" + + # Check if already downloaded locally + TEMPLATE=$(pveam list "$TEMPLATE_STORAGE" 2>/dev/null \ + | awk '$1 ~ /ubuntu-24\.04.*-standard_/ {print $1}' \ + | sed 's|.*/||' \ + | sort -V \ + | tail -1) + + if [[ -n "$TEMPLATE" ]]; then + emit "ok" "Template found locally: ${TEMPLATE}" + return + fi + + # Not local, try online + emit "miss" "No local Ubuntu 24.04 template" + emit "install" "Updating template catalog" + + if command -v timeout &>/dev/null; then + timeout 30 pveam update &>/dev/null || true + else + pveam update &>/dev/null || true + fi + + # Search for Ubuntu 24.04 + TEMPLATE=$(pveam available --section system 2>/dev/null \ + | awk '$2 ~ /ubuntu-24\.04.*-standard_/ {print $2}' \ + | sort -V \ + | tail -1) + + # Fallback to 22.04 + if [[ -z "$TEMPLATE" ]]; then + emit "miss" "Ubuntu 24.04 not available, trying 22.04" + TEMPLATE=$(pveam available --section system 2>/dev/null \ + | awk '$2 ~ /ubuntu-22\.04.*-standard_/ {print $2}' \ + | sort -V \ + | tail -1) + fi + + if [[ -z "$TEMPLATE" ]]; then + emit "error" "No Ubuntu template found" + emit_done_fail "No Ubuntu template available" + fi + + emit "install" "Downloading template: ${TEMPLATE}" + + local attempt + for attempt in 1 2 3; do + if pveam download "$TEMPLATE_STORAGE" "$TEMPLATE" &>/dev/null; then + emit "ok" "Template downloaded: ${TEMPLATE}" + return + fi + if [[ "$attempt" -lt 3 ]]; then + emit "check" "Download failed, retrying (${attempt}/3)" + sleep $((attempt * 5)) + fi + done + + emit "error" "Template download failed after 3 attempts" + emit_done_fail "Template download failed" +} + +# --------------------------------------------------------------------------- +# 8. Ensure subuid/subgid (required for unprivileged containers) +# --------------------------------------------------------------------------- +fix_subuid_subgid() { + emit "check" "Checking subuid/subgid mappings" + + local changed=false + + if ! grep -q "root:100000:65536" /etc/subuid 2>/dev/null; then + echo "root:100000:65536" >> /etc/subuid + changed=true + fi + + if ! grep -q "root:100000:65536" /etc/subgid 2>/dev/null; then + echo "root:100000:65536" >> /etc/subgid + changed=true + fi + + if [[ "$changed" == true ]]; then + emit "ok" "subuid/subgid mappings added" + else + emit "ok" "subuid/subgid mappings present" + fi +} + +# --------------------------------------------------------------------------- +# 9. Create container +# --------------------------------------------------------------------------- +create_container() { + emit "install" "Creating LXC container ${CT_ID}" + + # Build network string + local net_string="name=eth0,bridge=${CT_BRIDGE}" + if [[ "$CT_IP" == "dhcp" ]]; then + net_string="${net_string},ip=dhcp,ip6=dhcp" + else + net_string="${net_string},ip=${CT_IP}" + [[ -n "$CT_GATEWAY" ]] && net_string="${net_string},gw=${CT_GATEWAY}" + fi + + local pct_cmd=( + pct create "$CT_ID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" + -hostname "$CT_HOSTNAME" + -cores "$CT_CORES" + -memory "$CT_MEMORY" + -swap "$CT_SWAP" + -rootfs "${CT_STORAGE}:${CT_DISK}" + -net0 "$net_string" + -features "nesting=1,keyctl=1" + -unprivileged 1 + -onboot 1 + -password "$CT_PASSWORD" + ) + + if "${pct_cmd[@]}" &>/dev/null; then + emit "ok" "Container ${CT_ID} created" + else + # Retry once -- could be race condition on ID + if pct status "$CT_ID" &>/dev/null; then + emit "error" "Container ID ${CT_ID} was claimed by another process" + CT_ID=$((CT_ID + 1)) + pct_cmd[2]="$CT_ID" + if "${pct_cmd[@]}" &>/dev/null; then + emit "ok" "Container ${CT_ID} created (reassigned ID)" + else + emit "error" "Container creation failed" + emit_done_fail "pct create failed" + fi + else + emit "error" "Container creation failed" + emit_done_fail "pct create failed" + fi + fi +} + +# --------------------------------------------------------------------------- +# 10. Start container +# --------------------------------------------------------------------------- +start_container() { + emit "install" "Starting container ${CT_ID}" + + if pct start "$CT_ID" &>/dev/null; then + emit "ok" "Container ${CT_ID} started" + else + cleanup_on_fail "$CT_ID" "Failed to start container ${CT_ID}" + fi +} + +# --------------------------------------------------------------------------- +# 11. Setup autologin for Proxmox console +# --------------------------------------------------------------------------- +setup_autologin() { + emit "check" "Configuring console autologin" + + # Wait a moment for systemd to initialize inside the container + sleep 2 + + pct exec "$CT_ID" -- bash -c ' + mkdir -p /etc/systemd/system/container-getty@1.service.d + cat > /etc/systemd/system/container-getty@1.service.d/override.conf </dev/null + + if [[ $? -eq 0 ]]; then + emit "ok" "Console autologin enabled" + else + # Non-fatal -- container works fine without it + emit "ok" "Console autologin skipped (non-critical)" + fi +} + +# --------------------------------------------------------------------------- +# 12. Select fastest apt mirror and update +# --------------------------------------------------------------------------- +setup_apt_mirror() { + emit "check" "Selecting fastest apt mirror" + + # Wait for network inside container first + local net_ready=false + for (( i = 1; i <= 15; i++ )); do + if pct exec "$CT_ID" -- ping -c 1 -W 2 archive.ubuntu.com &>/dev/null; then + net_ready=true + break + fi + sleep 1 + done + + if [[ "$net_ready" == false ]]; then + emit "ok" "Network not ready, skipping mirror selection" + return + fi + + # Ping mirrors in parallel, pick fastest + local best_mirror="archive.ubuntu.com" + local best_time=9999 + + local mirrors=( + "archive.ubuntu.com" + "mirror.yandex.ru" + "de.archive.ubuntu.com" + "nl.archive.ubuntu.com" + "us.archive.ubuntu.com" + "mirror.linux-ia64.org" + ) + + local tmpdir + tmpdir=$(pct exec "$CT_ID" -- mktemp -d 2>/dev/null || echo "/tmp/mirror-test") + + # Launch all pings in parallel inside the container + pct exec "$CT_ID" -- bash -c " + mkdir -p ${tmpdir} + for m in ${mirrors[*]}; do + (ping -c 1 -W 2 \$m 2>/dev/null | grep -oP 'time=\K[0-9.]+' > ${tmpdir}/\$m || echo 9999 > ${tmpdir}/\$m) & + done + wait + " &>/dev/null + + # Read results + for m in "${mirrors[@]}"; do + local ms + ms=$(pct exec "$CT_ID" -- cat "${tmpdir}/${m}" 2>/dev/null | head -1) + ms="${ms:-9999}" + + # Compare as integers (strip decimal) + local ms_int="${ms%%.*}" + ms_int="${ms_int:-9999}" + + if [[ "$ms_int" -lt "$best_time" ]]; then + best_time="$ms_int" + best_mirror="$m" + fi + done + + # Cleanup + pct exec "$CT_ID" -- rm -rf "$tmpdir" &>/dev/null + + emit "ok" "Fastest mirror: ${best_mirror} (${best_time}ms)" "{\"mirror\":\"${best_mirror}\",\"latency_ms\":${best_time}}" + + # Apply mirror if different from default + if [[ "$best_mirror" != "archive.ubuntu.com" ]]; then + emit "install" "Configuring apt mirror: ${best_mirror}" + pct exec "$CT_ID" -- bash -c " + sed -i 's|http://archive.ubuntu.com|http://${best_mirror}|g' /etc/apt/sources.list + " &>/dev/null + emit "ok" "Apt mirror set to ${best_mirror}" + fi + + # Run apt update + emit "install" "Updating package lists" + if pct exec "$CT_ID" -- bash -c "apt-get update -qq" &>/dev/null; then + emit "ok" "Package lists updated" + else + emit "ok" "Package lists update had warnings (non-critical)" + fi +} + +# --------------------------------------------------------------------------- +# 13. Wait for network and get IP +# --------------------------------------------------------------------------- +wait_for_network() { + emit "check" "Waiting for network" + + local ip="" + local retries=30 + + for (( i = 1; i <= retries; i++ )); do + ip=$(pct exec "$CT_ID" -- ip -4 -o addr show dev eth0 2>/dev/null \ + | awk '{print $4}' \ + | cut -d/ -f1 \ + | head -1) + + if [[ -n "$ip" && "$ip" != "127.0.0.1" ]]; then + emit "ok" "Container IP: ${ip}" "{\"ip\":\"${ip}\"}" + CT_ACTUAL_IP="$ip" + return + fi + sleep 1 + done + + # Fallback: no IP but container is running + CT_ACTUAL_IP="unknown" + emit "ok" "Container running but IP not detected (check network manually)" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + # 1. Verify we're on Proxmox + check_proxmox + + # 2. Container ID + resolve_ct_id + + # 3. Storage + resolve_storage + + # 4. Free space + check_free_space + + # 5. Network bridge + resolve_bridge + + # 6. Password + resolve_password + + # 7. Template + download_template + + # 8. subuid/subgid + fix_subuid_subgid + + # 9. Create + create_container + + # 10. Start + start_container + + # 11. Autologin + setup_autologin + + # 12. Apt mirror + update + setup_apt_mirror + + # 13. Network + wait_for_network + + # 14. Done + emit_done_ok "{\"id\":\"${CT_ID}\",\"hostname\":\"${CT_HOSTNAME}\",\"ip\":\"${CT_ACTUAL_IP}\",\"password\":\"${CT_PASSWORD}\",\"memory\":\"${CT_MEMORY}\",\"swap\":\"${CT_SWAP}\",\"disk\":\"${CT_DISK}\",\"cores\":\"${CT_CORES}\",\"storage\":\"${CT_STORAGE}\",\"bridge\":\"${CT_BRIDGE}\",\"template\":\"${TEMPLATE}\"}" +} + +main diff --git a/scripts/strix-frigate.sh b/scripts/strix-frigate.sh new file mode 100755 index 0000000..6f7a6e5 --- /dev/null +++ b/scripts/strix-frigate.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- strix-frigate.sh (worker) +# +# Deploys Strix + Frigate together via Docker Compose. +# Generates docker-compose.yml dynamically (devices depend on hardware), +# creates .env, pulls images, starts containers, runs healthchecks. +# +# Protocol: +# - Every action is reported as a single-line JSON to stdout. +# - Types: check, ok, miss, install, error, done +# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."} +# - Exit code: 0 = success, 1 = failure. +# +# Parameters (all optional): +# --port PORT Strix listen port (default: 4567) +# --tag TAG Strix image tag (default: latest) +# --log-level LEVEL Log level: debug, info, warn, error, trace +# --go2rtc-url URL External go2rtc URL +# --shm-size SIZE Frigate shm_size (default: 512mb) +# --frigate-tag TAG Frigate image tag (default: stable) +# --dir DIR Working directory (default: /opt/strix) +# +# Usage: +# bash scripts/strix-frigate.sh +# bash scripts/strix-frigate.sh --port 4567 --frigate-tag stable-tensorrt +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +STRIX_PORT="4567" +STRIX_TAG="latest" +STRIX_LOG_LEVEL="" +STRIX_GO2RTC_URL="" +FRIGATE_SHM="512mb" +FRIGATE_TAG="stable" +STRIX_DIR="/opt/strix" +STRIX_IMAGE="eduard256/strix" +FRIGATE_IMAGE="ghcr.io/blakeblackshear/frigate" + +# Detected devices (populated by detect_devices) +DEVICES=() +DEVICE_NAMES=() + +# --------------------------------------------------------------------------- +# Parse CLI arguments +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --port) STRIX_PORT="$2"; shift 2 ;; + --tag) STRIX_TAG="$2"; shift 2 ;; + --log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;; + --go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;; + --shm-size) FRIGATE_SHM="$2"; shift 2 ;; + --frigate-tag) FRIGATE_TAG="$2"; shift 2 ;; + --dir) STRIX_DIR="$2"; shift 2 ;; + *) shift ;; + esac +done + +# --------------------------------------------------------------------------- +# JSON helpers +# --------------------------------------------------------------------------- +emit() { + local type="$1" + local msg="$2" + local data="${3:-}" + + msg="${msg//\\/\\\\}" + msg="${msg//\"/\\\"}" + + if [[ -n "$data" ]]; then + printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data" + else + printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg" + fi +} + +emit_done_ok() { + # Accepts raw JSON data string + local data="$1" + printf '{"type":"done","ok":true,"data":%s}\n' "$data" + exit 0 +} + +emit_done_fail() { + local error="$1" + error="${error//\\/\\\\}" + error="${error//\"/\\\"}" + printf '{"type":"done","ok":false,"error":"%s"}\n' "$error" + exit 1 +} + +# --------------------------------------------------------------------------- +# Detect LAN IP +# --------------------------------------------------------------------------- +detect_lan_ip() { + local ip="" + ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) + [[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}') + [[ -z "$ip" ]] && ip="localhost" + echo "$ip" +} + +# --------------------------------------------------------------------------- +# 1. Working directory +# --------------------------------------------------------------------------- +setup_dir() { + emit "check" "Checking working directory ${STRIX_DIR}" + + if [[ -d "$STRIX_DIR" ]]; then + emit "ok" "Directory exists: ${STRIX_DIR}" + else + emit "install" "Creating directory ${STRIX_DIR}" + if mkdir -p "$STRIX_DIR" 2>/dev/null; then + emit "ok" "Directory created: ${STRIX_DIR}" + else + emit "error" "Failed to create directory ${STRIX_DIR}" + emit_done_fail "Cannot create ${STRIX_DIR}" + fi + fi + + # Frigate subdirectories + emit "check" "Checking Frigate directories" + + mkdir -p "${STRIX_DIR}/frigate/config" 2>/dev/null + mkdir -p "${STRIX_DIR}/frigate/storage" 2>/dev/null + + if [[ -d "${STRIX_DIR}/frigate/config" ]] && [[ -d "${STRIX_DIR}/frigate/storage" ]]; then + emit "ok" "Frigate directories ready" + else + emit "error" "Failed to create Frigate directories" + emit_done_fail "Cannot create Frigate directories" + fi +} + +# --------------------------------------------------------------------------- +# 2. Detect hardware devices +# --------------------------------------------------------------------------- +detect_devices() { + emit "check" "Detecting hardware accelerators" + + local found=0 + + # USB Coral + emit "check" "Checking for USB Coral" + if command -v lsusb &>/dev/null && lsusb 2>/dev/null | grep -qE "1a6e:089a|18d1:9302"; then + DEVICES+=("/dev/bus/usb:/dev/bus/usb") + DEVICE_NAMES+=("usb_coral") + emit "ok" "USB Coral detected" "{\"device\":\"usb_coral\",\"path\":\"/dev/bus/usb\"}" + found=$((found + 1)) + else + emit "miss" "USB Coral not found" + fi + + # PCIe Coral + emit "check" "Checking for PCIe Coral" + if [[ -e /dev/apex_0 ]]; then + DEVICES+=("/dev/apex_0:/dev/apex_0") + DEVICE_NAMES+=("pcie_coral") + emit "ok" "PCIe Coral detected" "{\"device\":\"pcie_coral\",\"path\":\"/dev/apex_0\"}" + found=$((found + 1)) + else + emit "miss" "PCIe Coral not found" + fi + + # Intel / AMD GPU + emit "check" "Checking for Intel/AMD GPU" + if [[ -e /dev/dri/renderD128 ]]; then + DEVICES+=("/dev/dri:/dev/dri") + DEVICE_NAMES+=("gpu") + emit "ok" "GPU detected (Intel/AMD)" "{\"device\":\"gpu\",\"path\":\"/dev/dri\"}" + found=$((found + 1)) + else + emit "miss" "Intel/AMD GPU not found" + fi + + # Intel NPU + emit "check" "Checking for Intel NPU" + if [[ -e /dev/accel ]]; then + DEVICES+=("/dev/accel:/dev/accel") + DEVICE_NAMES+=("intel_npu") + emit "ok" "Intel NPU detected" "{\"device\":\"intel_npu\",\"path\":\"/dev/accel\"}" + found=$((found + 1)) + else + emit "miss" "Intel NPU not found" + fi + + # Raspberry Pi 4 video + emit "check" "Checking for Raspberry Pi video device" + if [[ -e /dev/video11 ]]; then + DEVICES+=("/dev/video11:/dev/video11") + DEVICE_NAMES+=("rpi_video") + emit "ok" "Raspberry Pi video device detected" "{\"device\":\"rpi_video\",\"path\":\"/dev/video11\"}" + found=$((found + 1)) + else + emit "miss" "Raspberry Pi video device not found" + fi + + if [[ "$found" -eq 0 ]]; then + emit "ok" "No hardware accelerators found, using CPU only" + else + emit "ok" "${found} hardware accelerator(s) detected" + fi +} + +# --------------------------------------------------------------------------- +# 3. Generate docker-compose.yml +# --------------------------------------------------------------------------- +generate_compose() { + emit "check" "Generating docker-compose.yml" + + # Build devices section + local devices_block="" + if [[ ${#DEVICES[@]} -gt 0 ]]; then + devices_block=" devices:" + for dev in "${DEVICES[@]}"; do + devices_block="${devices_block} + - ${dev}" + done + fi + + # Build compose file + cat > "${STRIX_DIR}/docker-compose.yml" < "${STRIX_DIR}/.env" <> "${STRIX_DIR}/.env" + emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}" + fi + + if [[ -n "$STRIX_LOG_LEVEL" ]]; then + echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env" + emit "ok" "Log level: ${STRIX_LOG_LEVEL}" + fi + + emit "ok" ".env generated (port ${STRIX_PORT})" +} + +# --------------------------------------------------------------------------- +# 5. Pull images +# --------------------------------------------------------------------------- +pull_images() { + emit "check" "Pulling Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG} (this may take a while)" + + if docker pull "${FRIGATE_IMAGE}:${FRIGATE_TAG}" &>/dev/null; then + emit "ok" "Frigate image pulled: ${FRIGATE_TAG}" + else + emit "error" "Failed to pull Frigate image ${FRIGATE_IMAGE}:${FRIGATE_TAG}" + emit_done_fail "Frigate image pull failed" + fi + + emit "check" "Pulling Strix image ${STRIX_IMAGE}:${STRIX_TAG}" + + if docker pull "${STRIX_IMAGE}:${STRIX_TAG}" &>/dev/null; then + emit "ok" "Strix image pulled: ${STRIX_TAG}" + else + emit "error" "Failed to pull Strix image ${STRIX_IMAGE}:${STRIX_TAG}" + emit_done_fail "Strix image pull failed" + fi +} + +# --------------------------------------------------------------------------- +# 6. Start containers +# --------------------------------------------------------------------------- +start_containers() { + local running_frigate=false + local running_strix=false + + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^frigate$' && running_frigate=true + docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$' && running_strix=true + + if [[ "$running_frigate" == true ]] || [[ "$running_strix" == true ]]; then + emit "check" "Existing containers found, recreating" + if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d --force-recreate &>/dev/null; then + emit "ok" "Containers recreated" + else + emit "error" "Failed to recreate containers" + emit_done_fail "Container recreate failed" + fi + else + emit "install" "Starting Frigate and Strix containers" + if docker compose -f "${STRIX_DIR}/docker-compose.yml" up -d &>/dev/null; then + emit "ok" "Containers started" + else + emit "error" "Failed to start containers" + emit_done_fail "Container start failed" + fi + fi +} + +# --------------------------------------------------------------------------- +# 7. Healthchecks +# --------------------------------------------------------------------------- +healthcheck_frigate() { + emit "check" "Waiting for Frigate to respond on port 5000" + + local retries=30 + for (( i = 1; i <= retries; i++ )); do + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then + emit "ok" "Frigate is running on port 5000" + return 0 + fi + sleep 2 + done + + emit "error" "Frigate healthcheck failed after ${retries} attempts" + emit_done_fail "Frigate healthcheck failed" +} + +healthcheck_strix() { + emit "check" "Waiting for Strix to respond on port ${STRIX_PORT}" + + local retries=15 + for (( i = 1; i <= retries; i++ )); do + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then + local version + version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown") + emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\"}" + return 0 + fi + sleep 1 + done + + emit "error" "Strix healthcheck failed after ${retries} attempts" + emit_done_fail "Strix healthcheck failed" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + # 1. Working directory + setup_dir + + # 2. Detect hardware + detect_devices + + # 3. Generate compose (with detected devices) + generate_compose + + # 4. Generate .env + generate_env + + # 5. Pull images + pull_images + + # 6. Start containers + start_containers + + # 7. Healthchecks + healthcheck_frigate + healthcheck_strix + + # 8. Done -- all URLs + local lan_ip + lan_ip=$(detect_lan_ip) + + local strix_version + strix_version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown") + + # Build device names JSON array + local devices_json="[" + local first=true + for name in "${DEVICE_NAMES[@]}"; do + [[ "$first" == true ]] && first=false || devices_json="${devices_json}," + devices_json="${devices_json}\"${name}\"" + done + devices_json="${devices_json}]" + + emit_done_ok "{\"ip\":\"${lan_ip}\",\"strix_url\":\"http://${lan_ip}:${STRIX_PORT}\",\"strix_version\":\"${strix_version}\",\"frigate_url\":\"http://${lan_ip}:8971\",\"frigate_internal\":\"http://${lan_ip}:5000\",\"go2rtc_url\":\"http://${lan_ip}:1984\",\"frigate_tag\":\"${FRIGATE_TAG}\",\"port\":\"${STRIX_PORT}\",\"devices\":${devices_json}}" +} + +main diff --git a/scripts/strix.sh b/scripts/strix.sh new file mode 100755 index 0000000..3bc76be --- /dev/null +++ b/scripts/strix.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# ============================================================================= +# Strix -- strix.sh (worker) +# +# Deploys Strix container via Docker Compose. +# Downloads docker-compose.yml from GitHub (if not already present), +# generates .env from parameters, pulls image, starts container, healthchecks. +# +# Protocol: +# - Every action is reported as a single-line JSON to stdout. +# - Types: check, ok, miss, install, error, done +# - Field "msg" is always human-readable. +# - Field "data" is optional, carries machine-readable details. +# - Last line is always: {"type":"done","ok":true,...} or {"type":"done","ok":false,"error":"..."} +# - Exit code: 0 = success, 1 = failure. +# +# Parameters (all optional): +# --port PORT Strix listen port (default: 4567) +# --frigate-url URL Frigate URL, e.g. http://192.168.1.50:5000 +# --go2rtc-url URL go2rtc URL, e.g. http://192.168.1.50:1984 +# --log-level LEVEL Log level: debug, info, warn, error, trace (default: info) +# --tag TAG Docker image tag (default: latest) +# --dir DIR Working directory (default: /opt/strix) +# +# Usage: +# bash scripts/strix.sh +# bash scripts/strix.sh --port 4567 --frigate-url http://192.168.1.50:5000 +# ============================================================================= + +set -uo pipefail + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +STRIX_PORT="4567" +STRIX_FRIGATE_URL="" +STRIX_GO2RTC_URL="" +STRIX_LOG_LEVEL="" +STRIX_TAG="latest" +STRIX_DIR="/opt/strix" +COMPOSE_URL="https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.yml" +IMAGE="eduard256/strix" + +# --------------------------------------------------------------------------- +# Parse CLI arguments +# --------------------------------------------------------------------------- +while [[ $# -gt 0 ]]; do + case "$1" in + --port) STRIX_PORT="$2"; shift 2 ;; + --frigate-url) STRIX_FRIGATE_URL="$2"; shift 2 ;; + --go2rtc-url) STRIX_GO2RTC_URL="$2"; shift 2 ;; + --log-level) STRIX_LOG_LEVEL="$2"; shift 2 ;; + --tag) STRIX_TAG="$2"; shift 2 ;; + --dir) STRIX_DIR="$2"; shift 2 ;; + *) shift ;; + esac +done + +# --------------------------------------------------------------------------- +# JSON helpers (same protocol as prepare.sh) +# --------------------------------------------------------------------------- +emit() { + local type="$1" + local msg="$2" + local data="${3:-}" + + msg="${msg//\\/\\\\}" + msg="${msg//\"/\\\"}" + + if [[ -n "$data" ]]; then + printf '{"type":"%s","msg":"%s","data":%s}\n' "$type" "$msg" "$data" + else + printf '{"type":"%s","msg":"%s"}\n' "$type" "$msg" + fi +} + +emit_done() { + local ok="$1" + shift + + if [[ "$ok" == "true" ]]; then + # Remaining args are key:value pairs for data + local data="{" + local first=true + while [[ $# -ge 2 ]]; do + local key="$1" val="$2"; shift 2 + val="${val//\\/\\\\}" + val="${val//\"/\\\"}" + [[ "$first" == true ]] && first=false || data="${data}," + data="${data}\"${key}\":\"${val}\"" + done + data="${data}}" + printf '{"type":"done","ok":true,"data":%s}\n' "$data" + exit 0 + else + local error="${1:-unknown}" + error="${error//\\/\\\\}" + error="${error//\"/\\\"}" + printf '{"type":"done","ok":false,"error":"%s"}\n' "$error" + exit 1 + fi +} + +# --------------------------------------------------------------------------- +# Detect LAN IP +# --------------------------------------------------------------------------- +detect_lan_ip() { + local ip="" + ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) + [[ -z "$ip" ]] && ip=$(hostname -I 2>/dev/null | awk '{print $1}') + [[ -z "$ip" ]] && ip="localhost" + echo "$ip" +} + +# --------------------------------------------------------------------------- +# Working directory +# --------------------------------------------------------------------------- +setup_dir() { + emit "check" "Checking working directory ${STRIX_DIR}" + + if [[ -d "$STRIX_DIR" ]]; then + emit "ok" "Directory exists: ${STRIX_DIR}" + else + emit "install" "Creating directory ${STRIX_DIR}" + if mkdir -p "$STRIX_DIR" 2>/dev/null; then + emit "ok" "Directory created: ${STRIX_DIR}" + else + emit "error" "Failed to create directory ${STRIX_DIR}" + emit_done "false" "Cannot create ${STRIX_DIR}" + fi + fi +} + +# --------------------------------------------------------------------------- +# Download docker-compose.yml +# --------------------------------------------------------------------------- +download_compose() { + emit "check" "Checking docker-compose.yml" + + if [[ -f "${STRIX_DIR}/docker-compose.yml" ]]; then + emit "ok" "docker-compose.yml already exists" + return + fi + + emit "install" "Downloading docker-compose.yml from GitHub" + + if curl -fsSL "$COMPOSE_URL" -o "${STRIX_DIR}/docker-compose.yml" 2>/dev/null; then + emit "ok" "docker-compose.yml downloaded" + else + emit "error" "Failed to download docker-compose.yml" + emit_done "false" "docker-compose.yml download failed" + fi +} + +# --------------------------------------------------------------------------- +# Generate .env +# --------------------------------------------------------------------------- +generate_env() { + emit "check" "Generating .env configuration" + + cat > "${STRIX_DIR}/.env" <> "${STRIX_DIR}/.env" + emit "ok" "Frigate URL: ${STRIX_FRIGATE_URL}" "{\"frigate_url\":\"${STRIX_FRIGATE_URL}\"}" + fi + + if [[ -n "$STRIX_GO2RTC_URL" ]]; then + echo "STRIX_GO2RTC_URL=${STRIX_GO2RTC_URL}" >> "${STRIX_DIR}/.env" + emit "ok" "go2rtc URL: ${STRIX_GO2RTC_URL}" "{\"go2rtc_url\":\"${STRIX_GO2RTC_URL}\"}" + fi + + if [[ -n "$STRIX_LOG_LEVEL" ]]; then + echo "STRIX_LOG_LEVEL=${STRIX_LOG_LEVEL}" >> "${STRIX_DIR}/.env" + emit "ok" "Log level: ${STRIX_LOG_LEVEL}" + fi + + emit "ok" ".env generated (port ${STRIX_PORT})" "{\"port\":\"${STRIX_PORT}\"}" +} + +# --------------------------------------------------------------------------- +# Pull image +# --------------------------------------------------------------------------- +pull_image() { + emit "check" "Pulling image ${IMAGE}:${STRIX_TAG}" + + if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" pull &>/dev/null; then + emit "ok" "Image pulled: ${IMAGE}:${STRIX_TAG}" + else + emit "error" "Failed to pull image ${IMAGE}:${STRIX_TAG}" + emit_done "false" "Image pull failed" + fi +} + +# --------------------------------------------------------------------------- +# Start container +# --------------------------------------------------------------------------- +start_container() { + # Check if strix container is already running + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^strix$'; then + emit "check" "Strix container is running, recreating" + if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d --force-recreate &>/dev/null; then + emit "ok" "Container recreated" + else + emit "error" "Failed to recreate container" + emit_done "false" "Container recreate failed" + fi + else + emit "install" "Starting Strix container" + if docker compose -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d &>/dev/null; then + emit "ok" "Container started" + else + emit "error" "Failed to start container" + emit_done "false" "Container start failed" + fi + fi +} + +# --------------------------------------------------------------------------- +# Healthcheck +# --------------------------------------------------------------------------- +healthcheck() { + emit "check" "Waiting for Strix to respond" + + local retries=15 + local i + for (( i = 1; i <= retries; i++ )); do + if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:${STRIX_PORT}/api/health" &>/dev/null; then + local version + version=$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown") + emit "ok" "Strix v${version} is running on port ${STRIX_PORT}" "{\"version\":\"${version}\",\"port\":\"${STRIX_PORT}\"}" + return 0 + fi + sleep 1 + done + + emit "error" "Healthcheck failed after ${retries} attempts" + emit_done "false" "Healthcheck failed" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + # 1. Working directory + setup_dir + + # 2. Download compose file (if not present) + download_compose + + # 3. Generate .env from parameters + generate_env + + # 4. Pull image + pull_image + + # 5. Start / recreate container + start_container + + # 6. Healthcheck + healthcheck + + # 7. Done -- include URL for navigator + local lan_ip + lan_ip=$(detect_lan_ip) + local url="http://${lan_ip}:${STRIX_PORT}" + + emit_done "true" "url" "$url" "version" "$(curl -sf --max-time 3 "http://localhost:${STRIX_PORT}/api" 2>/dev/null | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "unknown")" "port" "$STRIX_PORT" "ip" "$lan_ip" +} + +main