From a64e41492d3ecefd62d7beae433750c234d0ea8d Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 16 Apr 2026 16:50:40 +0000 Subject: [PATCH] Replace monolithic install.sh with modular script architecture - Remove old single-file installer - Add worker scripts with JSON event streaming protocol: - scripts/prepare.sh: system prep, Docker/Compose installation - scripts/strix.sh: deploy Strix standalone via Docker Compose - scripts/strix-frigate.sh: deploy Strix + Frigate with HW autodetect - scripts/proxmox-lxc-create.sh: create Ubuntu LXC on Proxmox - Add scripts/install.sh: animated frontend with owl display - Update docker-compose.frigate.yml: host networking, internal API port, expanded device comments, GPU image hints --- docker-compose.frigate.yml | 35 +- install.sh | 894 ---------------------------------- scripts/install.sh | 262 ++++++++++ scripts/prepare.sh | 400 +++++++++++++++ scripts/proxmox-lxc-create.sh | 606 +++++++++++++++++++++++ scripts/strix-frigate.sh | 428 ++++++++++++++++ scripts/strix.sh | 274 +++++++++++ 7 files changed, 1994 insertions(+), 905 deletions(-) delete mode 100755 install.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/docker-compose.frigate.yml b/docker-compose.frigate.yml index 3d497a4..33f04cf 100644 --- a/docker-compose.frigate.yml +++ b/docker-compose.frigate.yml @@ -1,5 +1,14 @@ # Strix + Frigate -# Usage: docker compose -f docker-compose.frigate.yml up -d +# Usage: +# curl -O https://raw.githubusercontent.com/eduard256/Strix/main/docker-compose.frigate.yml +# mkdir -p frigate/config frigate/storage +# docker compose -f docker-compose.frigate.yml up -d +# +# Strix UI: http://YOUR_IP:4567 +# Frigate UI: http://YOUR_IP:8971 +# Frigate API: http://YOUR_IP:5000 (internal, no auth) +# go2rtc UI: http://YOUR_IP:1984 (built into Frigate) +# RTSP restream: rtsp://YOUR_IP:8554 services: strix: @@ -9,22 +18,31 @@ services: restart: unless-stopped environment: STRIX_LISTEN: ":4567" - STRIX_FRIGATE_URL: "http://localhost:8971" + STRIX_FRIGATE_URL: "http://localhost:5000" + # STRIX_GO2RTC_URL: "http://localhost:1984" # STRIX_LOG_LEVEL: debug depends_on: - - frigate + frigate: + condition: service_started frigate: container_name: frigate image: ghcr.io/blakeblackshear/frigate:stable privileged: true + network_mode: host restart: unless-stopped stop_grace_period: 30s shm_size: "512mb" + # Uncomment devices for your hardware: # devices: - # - /dev/bus/usb:/dev/bus/usb # USB Coral - # - /dev/apex_0:/dev/apex_0 # PCIe Coral - # - /dev/dri/renderD128:/dev/dri/renderD128 # Intel/AMD GPU + # - /dev/bus/usb:/dev/bus/usb # USB Coral + # - /dev/apex_0:/dev/apex_0 # PCIe Coral + # - /dev/dri:/dev/dri # Intel/AMD GPU + # - /dev/accel:/dev/accel # Intel NPU + # - /dev/video11:/dev/video11 # Raspberry Pi 4 + # For Nvidia GPU use image: ghcr.io/blakeblackshear/frigate:stable-tensorrt + # For AMD GPU use image: ghcr.io/blakeblackshear/frigate:stable-rocm + # For Rockchip use image: ghcr.io/blakeblackshear/frigate:stable-rk volumes: - /etc/localtime:/etc/localtime:ro - ./frigate/config:/config @@ -33,10 +51,5 @@ services: target: /tmp/cache tmpfs: size: 1000000000 - ports: - - "8971:8971" - - "8554:8554" - - "8555:8555/tcp" - - "8555:8555/udp" environment: FRIGATE_RTSP_PASSWORD: "password" diff --git a/install.sh b/install.sh deleted file mode 100755 index 8d17107..0000000 --- a/install.sh +++ /dev/null @@ -1,894 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# Strix Installer -# Universal installer for any Linux distribution. -# Installs Docker (via get.docker.com), Docker Compose, detects Frigate/go2rtc, -# and deploys Strix via docker compose. -# -# Usage: -# curl -fsSL https://raw.githubusercontent.com/eduard256/Strix/main/install.sh | sudo bash -# sudo bash install.sh [OPTIONS] -# -# Options: -# --no-logo Suppress ASCII logo (useful when called from another script) -# --no-color Disable colored output -# --yes, -y Non-interactive mode, accept all defaults -# --version TAG Set image tag without prompt (latest/dev/1.0.9) -# --verbose, -v Show detailed output from docker commands -# -# Exit codes: -# 0 = success -# 1 = docker installation failed -# 2 = docker compose not available -# 3 = image pull failed -# 4 = container failed to start -# 5 = healthcheck failed -# ============================================================================= - -set -euo pipefail - -# --------------------------------------------------------------------------- -# Globals -# --------------------------------------------------------------------------- -STRIX_DIR="/opt/strix" -STRIX_PORT="4567" -IMAGE="eduard256/strix" -LOG_FILE="${STRIX_DIR}/install.log" -DIALOG_TIMEOUT=10 - -# Flags (overridden by CLI args) -SHOW_LOGO=true -USE_COLOR=true -INTERACTIVE=true -VERBOSE=false -TAG="" - -# Result variables (printed at the end for parent scripts) -INSTALL_MODE="" # install | update -FRIGATE_STATUS="none" # found | set | none -GO2RTC_STATUS="none" # found | set | none -FINAL_VERSION="" - -# --------------------------------------------------------------------------- -# Parse CLI arguments -# --------------------------------------------------------------------------- -while [[ $# -gt 0 ]]; do - case "$1" in - --no-logo) SHOW_LOGO=false; shift ;; - --no-color) USE_COLOR=false; shift ;; - --yes|-y) INTERACTIVE=false; shift ;; - --verbose|-v) VERBOSE=true; shift ;; - --version) TAG="$2"; shift 2 ;; - *) shift ;; - esac -done - -# --------------------------------------------------------------------------- -# Color setup -# --------------------------------------------------------------------------- -setup_colors() { - if [[ "$USE_COLOR" == false ]] || [[ "${NO_COLOR:-}" != "" ]] || [[ ! -t 1 ]]; then - USE_COLOR=false - C_RESET="" C_BOLD="" C_DIM="" - C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_MAGENTA="" C_WHITE="" - return - fi - - local colors - colors=$(tput colors 2>/dev/null || echo 0) - if [[ "$colors" -lt 8 ]]; then - USE_COLOR=false - C_RESET="" C_BOLD="" C_DIM="" - C_RED="" C_GREEN="" C_YELLOW="" C_CYAN="" C_MAGENTA="" C_WHITE="" - return - fi - - C_RESET="\033[0m" - C_BOLD="\033[1m" - C_DIM="\033[2m" - C_RED="\033[31m" - C_GREEN="\033[32m" - C_YELLOW="\033[33m" - C_CYAN="\033[36m" - C_MAGENTA="\033[35m" - C_WHITE="\033[97m" -} - -# --------------------------------------------------------------------------- -# Terminal width helpers -# --------------------------------------------------------------------------- -term_width() { - local w - w=$(tput cols 2>/dev/null || echo 80) - [[ "$w" -lt 40 ]] && w=40 - echo "$w" -} - -# Print text centered in terminal -center() { - local text="$1" - local w - w=$(term_width) - # Strip ANSI codes for length calculation - local stripped - stripped=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g') - local len=${#stripped} - local pad=$(( (w - len) / 2 )) - [[ "$pad" -lt 0 ]] && pad=0 - printf "%*s%b\n" "$pad" "" "$text" -} - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- -mkdir -p "$STRIX_DIR" 2>/dev/null || true - -log_raw() { - local ts - ts=$(date '+%H:%M:%S') - echo -e "$ts $1" >> "$LOG_FILE" 2>/dev/null || true -} - -log() { - log_raw "$1" - if [[ "$VERBOSE" == true ]]; then - local ts - ts=$(date '+%H:%M:%S') - echo -e " ${C_DIM}${ts} $1${C_RESET}" - fi -} - -# Status line helpers -status_ok() { echo -e " ${C_GREEN}${C_BOLD}[OK]${C_RESET} $1"; log "[OK] $1"; } -status_warn() { echo -e " ${C_YELLOW}${C_BOLD}[!!]${C_RESET} $1"; log "[!!] $1"; } -status_fail() { echo -e " ${C_RED}${C_BOLD}[XX]${C_RESET} $1"; log "[XX] $1"; } -status_info() { echo -e " ${C_CYAN}${C_BOLD}[..]${C_RESET} $1"; log "[..] $1"; } -status_skip() { echo -e " ${C_DIM}[--]${C_RESET} $1"; log "[--] $1"; } - -# --------------------------------------------------------------------------- -# ASCII art -# --------------------------------------------------------------------------- -show_logo() { - [[ "$SHOW_LOGO" == false ]] && return - - echo "" - - # Block STRIX title - center "${C_MAGENTA}${C_BOLD}███████╗████████╗██████╗ ██╗██╗ ██╗${C_RESET}" - center "${C_MAGENTA}${C_BOLD}██╔════╝╚══██╔══╝██╔══██╗██║╚██╗██╔╝${C_RESET}" - center "${C_MAGENTA}${C_BOLD}███████╗ ██║ ██████╔╝██║ ╚███╔╝${C_RESET}" - center "${C_MAGENTA}${C_BOLD}╚════██║ ██║ ██╔══██╗██║ ██╔██╗${C_RESET}" - center "${C_MAGENTA}${C_BOLD}███████║ ██║ ██║ ██║██║██╔╝ ██╗${C_RESET}" - center "${C_MAGENTA}${C_BOLD}╚══════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═╝${C_RESET}" - - echo "" - - # Owl - center "${C_CYAN} __________-------____ ____-------__________${C_RESET}" - center "${C_CYAN} \\------____-------___--__---------__--___-------____------/${C_RESET}" - center "${C_CYAN} \\//////// / / / / / \\ _-------_ / \\ \\ \\ \\ \\ \\\\\\\\\\\\\\\\/${C_RESET}" - center "${C_CYAN} \\////-/-/------/_/_| /___ ___\\ |_\\_\\------\\-\\-\\\\\\\\/${C_RESET}" - center "${C_CYAN} --//// / / / //|| ${C_YELLOW}(O)${C_CYAN}\\ /${C_YELLOW}(O)${C_CYAN} ||\\\\ \\ \\ \\ \\\\\\\\--${C_RESET}" - center "${C_CYAN} ---__/ // /| \\_ /V\\ _/ |\\ \\\\ \\__---${C_RESET}" - center "${C_CYAN} -// / /\\_ ------- _/\\ \\ \\\\-${C_RESET}" - center "${C_CYAN} \\_/_/ /\\---------/\\ \\_\\_/${C_RESET}" - center "${C_CYAN} ----\\ | /----${C_RESET}" - center "${C_CYAN} | -|- |${C_RESET}" - center "${C_CYAN} / | \\${C_RESET}" - center "${C_CYAN} ---- ----${C_RESET}" - - echo "" - center "${C_WHITE}${C_BOLD}Smart IP Camera Stream Finder${C_RESET}" - center "${C_DIM}────────────────────────────────────────${C_RESET}" - echo "" -} - -show_owl_small() { - echo -e " ${C_CYAN} ,___,${C_RESET}" - echo -e " ${C_CYAN} /(6 6)\\_${C_RESET}" - echo -e " ${C_CYAN} /\\\` ' \`'\\\\_${C_RESET}" - echo -e " ${C_CYAN} \\\\\\\\_''''|\\\\\\\\${C_RESET}" - echo -e " ${C_CYAN} )\\\\\\\\\\\\''//||/${C_RESET}" - echo -e " ${C_CYAN} ._,--/////\"\"------${C_RESET}" -} - -# --------------------------------------------------------------------------- -# Dialog boxes -# --------------------------------------------------------------------------- - -# Draw a box around content. -# Usage: draw_box "Title" "line1" "line2" ... -draw_box() { - local title="$1"; shift - local w - w=$(term_width) - local box_w=$(( w - 4 )) - [[ "$box_w" -gt 60 ]] && box_w=60 - - local top_line="" - local bot_line="" - local i - - # Build horizontal lines - for (( i = 0; i < box_w - 2; i++ )); do - top_line="${top_line}─" - bot_line="${bot_line}─" - done - - # Center the box - local pad=$(( (w - box_w) / 2 )) - [[ "$pad" -lt 0 ]] && pad=0 - local sp - sp=$(printf "%*s" "$pad" "") - - echo "" - echo -e "${sp}${C_CYAN}┌─ ${C_WHITE}${C_BOLD}${title}${C_RESET}${C_CYAN} ${top_line:$(( ${#title} + 3 ))}┐${C_RESET}" - echo -e "${sp}${C_CYAN}│$(printf "%*s" $(( box_w - 2 )) "")│${C_RESET}" - - for line in "$@"; do - local stripped - stripped=$(echo -e "$line" | sed 's/\x1b\[[0-9;]*m//g') - local line_len=${#stripped} - local right_pad=$(( box_w - 2 - line_len )) - [[ "$right_pad" -lt 0 ]] && right_pad=0 - echo -e "${sp}${C_CYAN}│${C_RESET} ${line}$(printf "%*s" "$right_pad" "")${C_CYAN}│${C_RESET}" - done - - echo -e "${sp}${C_CYAN}│$(printf "%*s" $(( box_w - 2 )) "")│${C_RESET}" - echo -e "${sp}${C_CYAN}└${bot_line}┘${C_RESET}" - echo "" -} - -# Prompt with timeout. Returns user input or default. -# Usage: timed_prompt "prompt text" "default" timeout_seconds -timed_prompt() { - local prompt_text="$1" - local default="$2" - local timeout="$3" - local result="" - - if [[ "$INTERACTIVE" == false ]]; then - echo "$default" - return - fi - - # Show countdown hint - echo -ne " ${C_YELLOW}${prompt_text}${C_RESET} ${C_DIM}(${timeout}s -> ${default})${C_RESET}: " - - if read -r -t "$timeout" result 2>/dev/null; then - if [[ -z "$result" ]]; then - echo "$default" - else - echo "$result" - fi - else - echo "" # newline after timeout - echo "$default" - fi -} - -# --------------------------------------------------------------------------- -# System detection -# --------------------------------------------------------------------------- -detect_system() { - log "Detecting OS from /etc/os-release" - - if [[ -f /etc/os-release ]]; then - # shellcheck disable=SC1091 - . /etc/os-release - OS_ID="${ID:-unknown}" - OS_VERSION="${VERSION_ID:-unknown}" - OS_NAME="${PRETTY_NAME:-${ID} ${VERSION_ID}}" - else - OS_ID="unknown" - OS_VERSION="unknown" - OS_NAME="Unknown Linux" - fi - - ARCH=$(uname -m) - case "$ARCH" in - x86_64) ARCH_LABEL="amd64" ;; - aarch64) ARCH_LABEL="arm64" ;; - armv7l) ARCH_LABEL="armv7" ;; - *) ARCH_LABEL="$ARCH" ;; - esac - - log "Detected: ${OS_ID} ${OS_VERSION} (${ARCH_LABEL})" - status_ok "System: ${C_WHITE}${C_BOLD}${OS_NAME}${C_RESET} (${ARCH_LABEL})" -} - -# --------------------------------------------------------------------------- -# Ensure root -# --------------------------------------------------------------------------- -ensure_root() { - if [[ "$(id -u)" -ne 0 ]]; then - status_info "Root privileges required, re-running with sudo..." - exec sudo "$0" "$@" - fi -} - -# --------------------------------------------------------------------------- -# Install curl if missing -# --------------------------------------------------------------------------- -ensure_curl() { - if command -v curl &>/dev/null; then - log "curl found" - return - fi - - status_info "Installing curl..." - log "curl not found, installing" - - case "$OS_ID" in - ubuntu|debian|raspbian|linuxmint|pop) - apt-get update -qq && apt-get install -y -qq curl ;; - centos|rhel|rocky|almalinux|ol) - yum install -y -q curl ;; - fedora) - dnf install -y -q curl ;; - alpine) - apk add --no-cache curl ;; - arch|manjaro) - pacman -Sy --noconfirm curl ;; - opensuse*|sles) - zypper install -y curl ;; - *) - status_fail "Cannot install curl: unknown package manager for ${OS_ID}" - status_fail "Please install curl manually and re-run the script" - exit 1 ;; - esac - - if command -v curl &>/dev/null; then - status_ok "curl installed" - else - status_fail "Failed to install curl" - exit 1 - fi -} - -# --------------------------------------------------------------------------- -# Docker installation -# --------------------------------------------------------------------------- -check_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") - status_ok "Docker ${C_WHITE}${C_BOLD}${ver}${C_RESET}" - log "Docker found: ${ver}" - return 0 - fi - return 1 -} - -install_docker() { - status_info "Installing Docker via ${C_WHITE}get.docker.com${C_RESET}..." - log "Downloading and running get.docker.com" - - if [[ "$VERBOSE" == true ]]; then - curl -fsSL https://get.docker.com | sh - else - curl -fsSL https://get.docker.com | sh &>/dev/null - fi - - # Enable and start docker - if command -v systemctl &>/dev/null; then - systemctl enable docker &>/dev/null || true - systemctl start docker &>/dev/null || true - fi - - if check_docker; then - return 0 - else - status_fail "Docker installation failed" - log "Docker installation failed" - exit 1 - fi -} - -# --------------------------------------------------------------------------- -# Docker Compose check -# --------------------------------------------------------------------------- -check_compose() { - # Check plugin first (docker compose v2) - if docker compose version &>/dev/null; then - local ver - ver=$(docker compose version --short 2>/dev/null || echo "unknown") - status_ok "Docker Compose ${C_WHITE}${C_BOLD}${ver}${C_RESET}" - log "Docker Compose found: ${ver}" - COMPOSE_CMD="docker compose" - return 0 - fi - - # Check 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") - status_ok "Docker Compose ${C_WHITE}${C_BOLD}${ver}${C_RESET} (standalone)" - log "Docker Compose standalone found: ${ver}" - COMPOSE_CMD="docker-compose" - return 0 - fi - - return 1 -} - -install_compose() { - status_info "Installing Docker Compose plugin..." - log "Installing Docker Compose plugin" - - # Docker Compose V2 is typically bundled with Docker now. - # Try installing the plugin package. - case "$OS_ID" in - ubuntu|debian|raspbian|linuxmint|pop) - apt-get update -qq && apt-get install -y -qq docker-compose-plugin 2>/dev/null ;; - centos|rhel|rocky|almalinux|ol|fedora) - yum install -y -q docker-compose-plugin 2>/dev/null || dnf install -y -q docker-compose-plugin 2>/dev/null ;; - *) - # Fallback: download binary - local compose_ver="v2.29.1" - local compose_arch - case "$ARCH_LABEL" in - amd64) compose_arch="x86_64" ;; - arm64) compose_arch="aarch64" ;; - *) compose_arch="$ARCH" ;; - esac - mkdir -p /usr/local/lib/docker/cli-plugins - 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 - chmod +x /usr/local/lib/docker/cli-plugins/docker-compose - ;; - esac - - if check_compose; then - return 0 - else - status_fail "Docker Compose installation failed" - log "Docker Compose installation failed" - exit 2 - fi -} - -# --------------------------------------------------------------------------- -# Check if Strix is already installed -# --------------------------------------------------------------------------- -check_existing() { - if [[ -f "${STRIX_DIR}/docker-compose.yml" ]]; then - if $COMPOSE_CMD -f "${STRIX_DIR}/docker-compose.yml" ps --format '{{.State}}' 2>/dev/null | grep -qi "running"; then - INSTALL_MODE="update" - status_info "Strix is already running -- ${C_WHITE}${C_BOLD}update mode${C_RESET}" - log "Existing Strix installation found, switching to update mode" - return 0 - fi - # compose file exists but not running - INSTALL_MODE="update" - status_info "Strix config found but not running -- ${C_WHITE}${C_BOLD}update mode${C_RESET}" - log "Existing Strix config found (not running), switching to update mode" - return 0 - fi - - INSTALL_MODE="install" - log "No existing Strix installation found" - return 1 -} - -# --------------------------------------------------------------------------- -# Version selection dialog -# --------------------------------------------------------------------------- -select_version() { - # If already set via --version flag - if [[ -n "$TAG" ]]; then - FINAL_VERSION="$TAG" - status_ok "Version: ${C_WHITE}${C_BOLD}${TAG}${C_RESET} (from --version flag)" - log "Version set via flag: ${TAG}" - return - fi - - if [[ "$INTERACTIVE" == false ]]; then - FINAL_VERSION="latest" - status_ok "Version: ${C_WHITE}${C_BOLD}latest${C_RESET} (non-interactive default)" - log "Version defaulted to latest (non-interactive)" - return - fi - - draw_box "Select Version" \ - " ${C_GREEN}${C_BOLD}[1]${C_RESET} latest ${C_DIM}(recommended)${C_RESET}" \ - " ${C_YELLOW}${C_BOLD}[2]${C_RESET} dev ${C_DIM}(development)${C_RESET}" \ - " ${C_CYAN}${C_BOLD}[3]${C_RESET} custom tag ${C_DIM}(e.g. 1.0.9)${C_RESET}" - - local choice - choice=$(timed_prompt "Choice [1/2/3]" "1" "$DIALOG_TIMEOUT") - - case "$choice" in - 1|"") FINAL_VERSION="latest" ;; - 2) FINAL_VERSION="dev" ;; - 3) - echo -ne " ${C_YELLOW}Enter tag: ${C_RESET}" - local custom_tag - if read -r -t "$DIALOG_TIMEOUT" custom_tag 2>/dev/null && [[ -n "$custom_tag" ]]; then - FINAL_VERSION="$custom_tag" - else - FINAL_VERSION="latest" - echo "" - fi - ;; - *) FINAL_VERSION="latest" ;; - esac - - status_ok "Version: ${C_WHITE}${C_BOLD}${FINAL_VERSION}${C_RESET}" - log "Version selected: ${FINAL_VERSION}" -} - -# --------------------------------------------------------------------------- -# Service detection (Frigate / go2rtc) -# --------------------------------------------------------------------------- -probe_frigate() { - log "Probing Frigate at localhost:5000" - - if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:5000/api/config" &>/dev/null; then - FRIGATE_STATUS="found" - FRIGATE_URL="http://localhost:5000" - status_ok "Frigate: ${C_WHITE}${C_BOLD}localhost:5000${C_RESET}" - log "Frigate found at localhost:5000" - return - fi - - log "Frigate not found locally" - - if [[ "$INTERACTIVE" == false ]]; then - FRIGATE_STATUS="none" - FRIGATE_URL="" - status_skip "Frigate: not found (skipped)" - return - fi - - echo "" - show_owl_small - draw_box "Frigate Not Found" \ - " Frigate was not detected on this machine." \ - " Enter Frigate URL or leave empty to skip." \ - "" \ - " ${C_DIM}Example: http://192.168.1.100:5000${C_RESET}" - - local input - input=$(timed_prompt "Frigate URL" "" "$DIALOG_TIMEOUT") - - if [[ -n "$input" ]]; then - FRIGATE_STATUS="set" - FRIGATE_URL="$input" - status_ok "Frigate: ${C_WHITE}${C_BOLD}${input}${C_RESET} (manual)" - log "Frigate URL set manually: ${input}" - else - FRIGATE_STATUS="none" - FRIGATE_URL="" - status_skip "Frigate: not configured" - log "Frigate skipped" - fi -} - -probe_go2rtc() { - log "Probing go2rtc at localhost:1984 and localhost:11984" - - if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:1984/api" &>/dev/null; then - GO2RTC_STATUS="found" - GO2RTC_URL="http://localhost:1984" - status_ok "go2rtc: ${C_WHITE}${C_BOLD}localhost:1984${C_RESET}" - log "go2rtc found at localhost:1984" - return - fi - - if curl -sf --connect-timeout 2 --max-time 3 "http://localhost:11984/api" &>/dev/null; then - GO2RTC_STATUS="found" - GO2RTC_URL="http://localhost:11984" - status_ok "go2rtc: ${C_WHITE}${C_BOLD}localhost:11984${C_RESET}" - log "go2rtc found at localhost:11984" - return - fi - - log "go2rtc not found locally" - - if [[ "$INTERACTIVE" == false ]]; then - GO2RTC_STATUS="none" - GO2RTC_URL="" - status_skip "go2rtc: not found (skipped)" - return - fi - - echo "" - show_owl_small - draw_box "go2rtc Not Found" \ - " go2rtc was not detected on this machine." \ - " Enter go2rtc URL or leave empty to skip." \ - "" \ - " ${C_DIM}Example: http://192.168.1.100:1984${C_RESET}" - - local input - input=$(timed_prompt "go2rtc URL" "" "$DIALOG_TIMEOUT") - - if [[ -n "$input" ]]; then - GO2RTC_STATUS="set" - GO2RTC_URL="$input" - status_ok "go2rtc: ${C_WHITE}${C_BOLD}${input}${C_RESET} (manual)" - log "go2rtc URL set manually: ${input}" - else - GO2RTC_STATUS="none" - GO2RTC_URL="" - status_skip "go2rtc: not configured" - log "go2rtc skipped" - fi -} - -# --------------------------------------------------------------------------- -# Generate config files -# --------------------------------------------------------------------------- -generate_env() { - log "Generating ${STRIX_DIR}/.env" - - cat > "${STRIX_DIR}/.env" <> "${STRIX_DIR}/.env" - fi - - if [[ -n "${GO2RTC_URL:-}" ]]; then - echo "STRIX_GO2RTC_URL=${GO2RTC_URL}" >> "${STRIX_DIR}/.env" - fi - - log "Generated .env: TAG=${FINAL_VERSION}, FRIGATE=${FRIGATE_URL:-}, GO2RTC=${GO2RTC_URL:-}" -} - -generate_compose() { - log "Generating ${STRIX_DIR}/docker-compose.yml" - - local env_section="" - if [[ -n "${FRIGATE_URL:-}" ]] || [[ -n "${GO2RTC_URL:-}" ]]; then - env_section=" environment:" - [[ -n "${FRIGATE_URL:-}" ]] && env_section="${env_section} - - STRIX_FRIGATE_URL=\${STRIX_FRIGATE_URL}" - [[ -n "${GO2RTC_URL:-}" ]] && env_section="${env_section} - - STRIX_GO2RTC_URL=\${STRIX_GO2RTC_URL}" - fi - - cat > "${STRIX_DIR}/docker-compose.yml" <&1 | tail -1 - fi - - if [[ $? -eq 0 ]]; then - status_ok "Image pulled: ${C_WHITE}${C_BOLD}${IMAGE}:${FINAL_VERSION}${C_RESET}" - log "Image pulled successfully" - else - status_fail "Failed to pull image ${IMAGE}:${FINAL_VERSION}" - log "Image pull failed" - exit 3 - fi -} - -start_container() { - log "Starting container" - - if [[ "$INSTALL_MODE" == "update" ]]; then - status_info "Recreating container..." - $COMPOSE_CMD -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d --force-recreate 2>&1 | \ - if [[ "$VERBOSE" == true ]]; then cat; else tail -1; fi - else - status_info "Starting container..." - $COMPOSE_CMD -f "${STRIX_DIR}/docker-compose.yml" --env-file "${STRIX_DIR}/.env" up -d 2>&1 | \ - if [[ "$VERBOSE" == true ]]; then cat; else tail -1; fi - fi - - if [[ $? -ne 0 ]]; then - status_fail "Container failed to start" - log "Container failed to start" - exit 4 - fi - - log "Container started" -} - -healthcheck() { - status_info "Running healthcheck..." - log "Waiting for healthcheck on localhost:${STRIX_PORT}" - - local retries=10 - 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") - status_ok "Strix is running ${C_WHITE}${C_BOLD}v${version}${C_RESET} on port ${C_WHITE}${C_BOLD}${STRIX_PORT}${C_RESET}" - log "Healthcheck passed, version: ${version}" - return 0 - fi - sleep 1 - done - - status_fail "Healthcheck failed after ${retries} attempts" - log "Healthcheck failed" - exit 5 -} - -# --------------------------------------------------------------------------- -# Detect LAN IP address -# --------------------------------------------------------------------------- -detect_lan_ip() { - local ip="" - - # Method 1: ip route (most reliable on modern Linux) - ip=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K\S+' | head -1) - - # Method 2: hostname -I - if [[ -z "$ip" ]]; then - ip=$(hostname -I 2>/dev/null | awk '{print $1}') - fi - - # Method 3: ifconfig fallback - if [[ -z "$ip" ]]; then - ip=$(ifconfig 2>/dev/null | grep -oP 'inet \K[0-9.]+' | grep -v '127.0.0.1' | head -1) - fi - - # Fallback to localhost - if [[ -z "$ip" ]]; then - ip="localhost" - fi - - echo "$ip" -} - -# --------------------------------------------------------------------------- -# Summary -# --------------------------------------------------------------------------- -show_summary() { - echo "" - local w - w=$(term_width) - local line="" - local box_w=$(( w - 4 )) - [[ "$box_w" -gt 60 ]] && box_w=60 - for (( i = 0; i < box_w - 2; i++ )); do line="${line}─"; done - local pad=$(( (w - box_w) / 2 )) - [[ "$pad" -lt 0 ]] && pad=0 - local sp - sp=$(printf "%*s" "$pad" "") - - local lan_ip - lan_ip=$(detect_lan_ip) - local open_url="http://${lan_ip}:${STRIX_PORT}" - - echo -e "${sp}${C_GREEN}┌─ ${C_WHITE}${C_BOLD}Complete${C_RESET} ${C_GREEN}${line:11}┐${C_RESET}" - echo -e "${sp}${C_GREEN}│$(printf "%*s" $(( box_w - 2 )) "")│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Mode: ${C_WHITE}${C_BOLD}${INSTALL_MODE}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#INSTALL_MODE} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Version: ${C_WHITE}${C_BOLD}${FINAL_VERSION}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#FINAL_VERSION} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Port: ${C_WHITE}${C_BOLD}${STRIX_PORT}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#STRIX_PORT} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Frigate: ${C_WHITE}${FRIGATE_STATUS}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#FRIGATE_STATUS} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} go2rtc: ${C_WHITE}${GO2RTC_STATUS}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#GO2RTC_STATUS} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Config: ${C_DIM}${STRIX_DIR}/${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#STRIX_DIR} - 1 )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} Log: ${C_DIM}${LOG_FILE}${C_RESET}$(printf "%*s" $(( box_w - 16 - ${#LOG_FILE} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│$(printf "%*s" $(( box_w - 2 )) "")│${C_RESET}" - echo -e "${sp}${C_GREEN}│${C_RESET} ${C_CYAN}Open: ${C_WHITE}${C_BOLD}${open_url}${C_RESET}$(printf "%*s" $(( box_w - 9 - ${#open_url} )) "")${C_GREEN}│${C_RESET}" - echo -e "${sp}${C_GREEN}│$(printf "%*s" $(( box_w - 2 )) "")│${C_RESET}" - echo -e "${sp}${C_GREEN}└${line}┘${C_RESET}" - echo "" - - # Machine-readable output for parent scripts (always last line) - echo "STRIX_RESULT=OK MODE=${INSTALL_MODE} VERSION=${FINAL_VERSION} FRIGATE=${FRIGATE_STATUS} GO2RTC=${GO2RTC_STATUS}" -} - -# --------------------------------------------------------------------------- -# Verbose log tail (shown on error) -# --------------------------------------------------------------------------- -show_error_log() { - if [[ -f "$LOG_FILE" ]]; then - echo "" - echo -e " ${C_RED}${C_BOLD}── Last log entries ──${C_RESET}" - tail -20 "$LOG_FILE" | while IFS= read -r line; do - echo -e " ${C_RED}${line}${C_RESET}" - done - echo -e " ${C_RED}${C_BOLD}──────────────────────${C_RESET}" - echo "" - echo -e " Full log: ${C_WHITE}${LOG_FILE}${C_RESET}" - fi -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- -main() { - # Trap errors to show log - trap 'show_error_log' ERR - - # Initialize - setup_colors - ensure_root "$@" - - # Show logo - show_logo - - # Detect system - detect_system - - # Ensure curl is available - ensure_curl - - # Docker - if ! check_docker; then - install_docker - fi - - # Docker Compose - if ! check_compose; then - install_compose - fi - - # Version selection - select_version - - # Check existing installation - check_existing || true - - # Detect services (only for fresh install) - if [[ "$INSTALL_MODE" == "install" ]]; then - probe_frigate - probe_go2rtc - else - # For updates, load existing env - if [[ -f "${STRIX_DIR}/.env" ]]; then - # shellcheck disable=SC1091 - source "${STRIX_DIR}/.env" 2>/dev/null || true - FRIGATE_URL="${STRIX_FRIGATE_URL:-}" - GO2RTC_URL="${STRIX_GO2RTC_URL:-}" - [[ -n "$FRIGATE_URL" ]] && FRIGATE_STATUS="set" - [[ -n "$GO2RTC_URL" ]] && GO2RTC_STATUS="set" - fi - fi - - echo "" - echo -e " ${C_DIM}────────────────────────────────────────${C_RESET}" - echo "" - - # Generate configs - generate_env - generate_compose - - # Deploy - pull_image - start_container - healthcheck - - # Done - show_summary -} - -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