#!/usr/bin/env bash # Linux install environment preflight — structured checks with stable IDs and JSON output. # Use before or during install when services are not yet up (unlike ./ods-preflight.sh). # # Usage: # ./scripts/linux-install-preflight.sh # human-readable report # ./scripts/linux-install-preflight.sh --json # JSON on stdout # ./scripts/linux-install-preflight.sh --json-file /tmp/report.json # ./scripts/linux-install-preflight.sh --strict # exit 1 if any warn or fail # # Also reachable as: ./ods-preflight.sh --install-env [same args] set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" OUTPUT_MODE="human" JSON_FILE="" STRICT=false ODS_ROOT="${ODS_ROOT:-$ROOT_DIR}" MIN_DISK_GB_FREE="${MIN_DISK_GB_FREE:-15}" while [[ $# -gt 0 ]]; do case "$1" in --json) OUTPUT_MODE="json" shift ;; --json-file) JSON_FILE="${2:-}" OUTPUT_MODE="json" shift 2 ;; --strict) STRICT=true shift ;; --ods-root) ODS_ROOT="${2:-}" shift 2 ;; --min-disk-gb) MIN_DISK_GB_FREE="${2:-}" shift 2 ;; -h|--help) sed -n '1,20p' "$0" | tail -n +2 exit 0 ;; *) echo "Unknown option: $1" >&2 exit 2 ;; esac done CHECKS_JSONL="$(mktemp)" trap 'rm -f "$CHECKS_JSONL"' EXIT append_check() { # id, status, message, remediation — safe for special characters via env export LP_ID="$1" LP_STATUS="$2" LP_MSG="$3" LP_FIX="${4:-}" python3 -c ' import json, os print(json.dumps({ "id": os.environ["LP_ID"], "status": os.environ["LP_STATUS"], "message": os.environ["LP_MSG"], "remediation": os.environ.get("LP_FIX", ""), })) ' >>"$CHECKS_JSONL" } docker_cli_looks_like_podman() { local docker_version="" docker_version="$(docker --version 2>/dev/null || true)" if printf '%s\n' "$docker_version" | grep -qi 'podman'; then return 0 fi # podman-docker commonly installs a docker-compatible shim. ODS # currently relies on Docker Engine semantics, so the shim must fail loud. local docker_path="" docker_path="$(command -v docker 2>/dev/null || true)" if [[ -n "$docker_path" ]] && command -v readlink >/dev/null 2>&1; then local docker_real="" docker_real="$(readlink -f "$docker_path" 2>/dev/null || true)" case "$(basename "$docker_real")" in podman|podman-docker) return 0 ;; esac fi return 1 } # --- Distro fingerprint (from /etc/os-release) --- DISTRO_ID="" DISTRO_VERSION_ID="" DISTRO_PRETTY="" DISTRO_LIKE="" if [[ -f /etc/os-release ]]; then # shellcheck source=/dev/null source /etc/os-release DISTRO_ID="${ID:-}" DISTRO_VERSION_ID="${VERSION_ID:-}" DISTRO_PRETTY="${PRETTY_NAME:-}" DISTRO_LIKE="${ID_LIKE:-}" append_check "DISTRO_INFO" "pass" \ "Linux distro: ${PRETTY_NAME:-unknown} (ID=${ID:-?}, VERSION_ID=${VERSION_ID:-?})" \ "" else append_check "DISTRO_INFO" "fail" \ "/etc/os-release not found — installer expects a Linux environment" \ "Run on a supported Linux distribution or use the platform-specific installer." fi KERNEL="$(uname -r 2>/dev/null || echo unknown)" append_check "KERNEL_INFO" "pass" "Kernel: $KERNEL" "" # --- curl (used by service preflight and many scripts) --- if command -v curl >/dev/null 2>&1; then append_check "CURL_INSTALLED" "pass" "curl is available" "" else append_check "CURL_INSTALLED" "warn" \ "curl not found — installer and health checks expect it" \ "Install curl (e.g. apt install curl / dnf install curl) and re-run." fi # --- Docker CLI --- DOCKER_IS_PODMAN=false if ! command -v docker >/dev/null 2>&1; then append_check "DOCKER_INSTALLED" "fail" \ "Docker CLI not found in PATH" \ "Install Docker Engine and ensure your user can run docker (see LINUX-TROUBLESHOOTING-GUIDE.md#docker_installed)." else DV="$(docker --version 2>/dev/null | head -1 || true)" append_check "DOCKER_INSTALLED" "pass" "Docker CLI: ${DV:-present}" "" if docker_cli_looks_like_podman; then DOCKER_IS_PODMAN=true append_check "DOCKER_ENGINE" "fail" \ "docker resolves to Podman compatibility mode, not Docker Engine" \ "Install Docker Engine and Docker Compose v2. Podman is not a supported ODS runtime yet; remove podman-docker or put Docker Engine first in PATH." else append_check "DOCKER_ENGINE" "pass" "Docker CLI appears to target Docker Engine" "" fi fi # --- Docker daemon --- DOCKER_INFO_OK=false if [[ "$DOCKER_IS_PODMAN" == true ]]; then append_check "DOCKER_DAEMON" "fail" \ "Skipped Docker daemon probe because docker resolves to Podman" \ "Install Docker Engine; ODS does not currently support Podman as the container runtime." elif command -v docker >/dev/null 2>&1; then if docker info >/dev/null 2>&1; then DOCKER_INFO_OK=true append_check "DOCKER_DAEMON" "pass" "Docker daemon is reachable" "" else append_check "DOCKER_DAEMON" "fail" \ "Docker daemon not running or not accessible" \ "Start the service (e.g. sudo systemctl start docker) or log in to Docker Desktop; add your user to the docker group if permission denied (see LINUX-TROUBLESHOOTING-GUIDE.md#docker_daemon)." fi else append_check "DOCKER_DAEMON" "fail" "Skipped — Docker CLI missing" "" fi # --- Docker Compose v2 / v1 --- if [[ "$DOCKER_IS_PODMAN" == true ]]; then append_check "COMPOSE_CLI" "fail" \ "Skipped Compose probe because docker resolves to Podman" \ "Install Docker Engine with the Docker Compose v2 plugin. podman compose is not a supported substitute for ODS installs yet." elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then CV="$(docker compose version 2>/dev/null | head -1 || true)" append_check "COMPOSE_CLI" "pass" "Compose: $CV" "" elif command -v docker-compose >/dev/null 2>&1; then CV="$(docker-compose version --short 2>/dev/null || docker-compose version 2>/dev/null | head -1)" append_check "COMPOSE_CLI" "pass" "docker-compose (legacy): $CV" "" else append_check "COMPOSE_CLI" "fail" \ "Neither 'docker compose' nor 'docker-compose' is available" \ "Install Docker Compose v2 plugin or docker-compose; see LINUX-TROUBLESHOOTING-GUIDE.md#compose_cli." fi # --- NVIDIA Docker runtime (only if nvidia-smi works) --- if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi --query-gpu=name --format=csv,noheader >/dev/null 2>&1; then if [[ "$DOCKER_INFO_OK" == true ]]; then if docker info 2>/dev/null | grep -qi nvidia; then append_check "NVIDIA_CONTAINER_RUNTIME" "pass" "NVIDIA Container Toolkit / runtime visible to Docker" "" else append_check "NVIDIA_CONTAINER_RUNTIME" "warn" \ "GPU visible to nvidia-smi but Docker does not report NVIDIA runtime" \ "Install/configure nvidia-container-toolkit so GPU containers work (see LINUX-TROUBLESHOOTING-GUIDE.md#nvidia_container_runtime)." fi else append_check "NVIDIA_CONTAINER_RUNTIME" "warn" \ "nvidia-smi works but Docker daemon check failed — could not verify NVIDIA runtime" \ "Fix Docker daemon access first, then install nvidia-container-toolkit if needed." fi else append_check "NVIDIA_CONTAINER_RUNTIME" "pass" "No NVIDIA GPU detected via nvidia-smi — check skipped" "" fi # --- Free disk space for ODS root --- if [[ -d "$ODS_ROOT" ]]; then # POSIX-friendly: df -P, parse available KB if DFOUT="$(df -Pk "$ODS_ROOT" 2>/dev/null | tail -1)"; then AVAIL_KB="$(echo "$DFOUT" | awk '{print $4}')" if [[ "$AVAIL_KB" =~ ^[0-9]+$ ]]; then AVAIL_GB=$((AVAIL_KB / 1048576)) if [[ "$AVAIL_GB" -ge "$MIN_DISK_GB_FREE" ]]; then append_check "DISK_SPACE" "pass" \ "Free space on $ODS_ROOT: ~${AVAIL_GB}GB (min ${MIN_DISK_GB_FREE}GB)" "" else append_check "DISK_SPACE" "warn" \ "Low free space on $ODS_ROOT: ~${AVAIL_GB}GB (recommended ≥${MIN_DISK_GB_FREE}GB free)" \ "Free disk space or set ODS_ROOT to a volume with more room; see LINUX-TROUBLESHOOTING-GUIDE.md#disk_space." fi else append_check "DISK_SPACE" "warn" "Could not parse free space for $ODS_ROOT" "" fi else append_check "DISK_SPACE" "warn" "df failed for $ODS_ROOT" "" fi else append_check "DISK_SPACE" "warn" "ODS_ROOT does not exist yet: $ODS_ROOT" \ "Create the directory or run from the extracted ods tree." fi # --- cgroup v2 (optional signal) --- if [[ -f /sys/fs/cgroup/cgroup.controllers ]]; then append_check "CGROUP_V2" "pass" "cgroup v2 detected (/sys/fs/cgroup/cgroup.controllers present)" "" else append_check "CGROUP_V2" "warn" \ "cgroup v2 not detected — some Docker/rootless setups may differ" \ "Usually fine on modern distros; if Docker fails oddly, see LINUX-TROUBLESHOOTING-GUIDE.md#cgroups." fi # --- jq (installer often installs it; nice to have) --- if command -v jq >/dev/null 2>&1; then append_check "JQ_INSTALLED" "pass" "jq available for JSON tooling" "" else append_check "JQ_INSTALLED" "warn" \ "jq not found — installer may install it; some scripts expect it" \ "Install jq for smoother tooling (see INSTALL-TROUBLESHOOTING.md)." fi # --- Host firewall (UFW / firewalld) --- # The dashboard host agent binds to the Docker bridge gateway (default # 172.17.0.1:7710) and compose containers reach it via the host INPUT chain. # Default-DROP firewalls block that traffic. Warn only — never fail. if command -v ufw >/dev/null 2>&1 && systemctl is-active --quiet ufw 2>/dev/null; then append_check "FIREWALL_CHECK" "warn" \ "UFW active — may block container→host:7710 traffic" \ "Installer will auto-add a scoped rule for the actual ods-network subnet after compose starts." elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then append_check "FIREWALL_CHECK" "warn" \ "firewalld active — may block container→host:7710 traffic" \ "Installer will auto-add a scoped rule for the actual ods-network subnet after compose starts." else append_check "FIREWALL_CHECK" "pass" \ "No restrictive host firewall detected" "" fi # --- Compose files present (expected when run from repo tree) --- if [[ -f "$ROOT_DIR/docker-compose.base.yml" ]] || [[ -f "$ROOT_DIR/docker-compose.yml" ]]; then append_check "COMPOSE_FILES" "pass" "Compose files present under ods root" "" else append_check "COMPOSE_FILES" "warn" \ "No docker-compose.base.yml or docker-compose.yml in $ROOT_DIR" \ "Run this script from the extracted ODS source tree (ods/)." fi emit_report() { python3 - "$CHECKS_JSONL" "$ROOT_DIR" "$KERNEL" "$DISTRO_ID" "$DISTRO_VERSION_ID" "$DISTRO_PRETTY" "$DISTRO_LIKE" "$ODS_ROOT" "$MIN_DISK_GB_FREE" <<'PY' import json import sys from datetime import datetime, timezone checks_path = sys.argv[1] root_dir = sys.argv[2] kernel = sys.argv[3] distro_id = sys.argv[4] distro_vid = sys.argv[5] distro_pretty = sys.argv[6] distro_like = sys.argv[7] ods_root = sys.argv[8] min_disk = sys.argv[9] checks = [] with open(checks_path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue checks.append(json.loads(line)) fail_n = sum(1 for c in checks if c["status"] == "fail") warn_n = sum(1 for c in checks if c["status"] == "warn") pass_n = sum(1 for c in checks if c["status"] == "pass") exit_ok = fail_n == 0 report = { "schema_version": "1", "kind": "linux-install-preflight", "generated_at": datetime.now(timezone.utc).isoformat(), "distro": { "id": distro_id, "version_id": distro_vid, "pretty_name": distro_pretty, "id_like": distro_like, }, "kernel": kernel, "ods_root": ods_root, "min_disk_gb_free": min_disk, "checks": checks, "summary": { "pass": pass_n, "warn": warn_n, "fail": fail_n, "exit_ok": exit_ok, }, } print(json.dumps(report, indent=2)) PY } REPORT_JSON="$(emit_report)" EXIT_CODE=0 echo "$REPORT_JSON" | python3 -c ' import json,sys r=json.load(sys.stdin) s=r["summary"] sys.exit(0 if s["exit_ok"] else 1) ' || EXIT_CODE=1 if [[ "$STRICT" == true ]]; then echo "$REPORT_JSON" | python3 -c ' import json,sys r=json.load(sys.stdin) s=r["summary"] sys.exit(0 if s["fail"]==0 and s["warn"]==0 else 1) ' || EXIT_CODE=1 fi if [[ "$OUTPUT_MODE" == "json" ]]; then echo "$REPORT_JSON" if [[ -n "$JSON_FILE" ]]; then printf '%s\n' "$REPORT_JSON" >"$JSON_FILE" fi exit "$EXIT_CODE" fi # --- Human output --- RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' echo -e "${BOLD}${CYAN}Linux install preflight${NC} (structured checks)" echo "ODS root: $ODS_ROOT" echo "" while IFS= read -r line; do [[ -z "$line" ]] && continue mapfile -t _trip < <(python3 -c 'import json,sys; o=json.loads(sys.argv[1]); print(o["id"]); print(o["status"]); print(o["message"])' "$line") id="${_trip[0]}" st="${_trip[1]}" msg="${_trip[2]}" case "$st" in pass) sig="${GREEN}PASS${NC}" ;; warn) sig="${YELLOW}WARN${NC}" ;; fail) sig="${RED}FAIL${NC}" ;; *) sig="$st" ;; esac echo -e "[$sig] ${BOLD}${id}${NC}: $msg" done <"$CHECKS_JSONL" echo "" echo -e "${BOLD}Summary${NC}" echo "$REPORT_JSON" | python3 -c ' import json,sys r=json.load(sys.stdin) s=r["summary"] print(" pass:", s["pass"], " warn:", s["warn"], " fail:", s["fail"]) print(" exit_ok:", "true" if s["exit_ok"] else "false") ' if [[ "$EXIT_CODE" -eq 0 ]]; then echo -e "${GREEN}Preflight OK (no failures).${NC}" else echo -e "${RED}Preflight failed — see FAIL checks and LINUX-TROUBLESHOOTING-GUIDE.md${NC}" fi if [[ -n "$JSON_FILE" ]]; then printf '%s\n' "$REPORT_JSON" >"$JSON_FILE" echo "JSON written to: $JSON_FILE" fi exit "$EXIT_CODE"