#!/usr/bin/env bash # ============================================================ # Fester — unified installer / lifecycle script # ============================================================ # Replaces the previous bootstrap.sh / install-fester.sh / # install-fester2.sh trio with a single dynamic entry point. # # Every install / run concern maps to a subcommand. Run with no # args (or `all`) to do the standard in-place dev setup: # # ./install.sh # = ./install.sh all # ./install.sh deps # venv + pip install (core or --dev) # ./install.sh config # write config.yaml (interactive / NONINTERACTIVE=1) # ./install.sh db # init SQLite # ./install.sh observability # write Prometheus + Grafana drop-ins # ./install.sh systemd # write /tmp/fester.service # ./install.sh deploy [BASE_DIR] # copy code to BASE_DIR (default: /usr/share/cockpit/fester) # ./install.sh run [--dev] [--port N] [--host H] [--mock-agent] # ./install.sh help # # Environment variables (all optional): # NONINTERACTIVE=1 skip all prompts, take defaults # FESTER_STORAGE_DIR default: ~/.fester # FESTER_DB_PATH default: $FESTER_STORAGE_DIR/fester.db # FESTER_CONFIG default: ./config.yaml # FESTER_HOST / FESTER_PORT default: 0.0.0.0 / 8181 # FESTER_MINIO_* / FESTER_PROM_URL / FESTER_FC_SSH_KEY — runtime service endpoints # ============================================================ set -euo pipefail # ------------------------------------------------------------------ # Resolve repo root + defaults # ------------------------------------------------------------------ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$REPO_ROOT" STORAGE_DIR="${FESTER_STORAGE_DIR:-$HOME/.fester}" DB_PATH="${FESTER_DB_PATH:-$STORAGE_DIR/fester.db}" CONFIG_PATH="${FESTER_CONFIG:-$REPO_ROOT/config.yaml}" VENV_DIR="$REPO_ROOT/.venv" # ------------------------------------------------------------------ # Logging helpers (no emoji on stderr noise; colour only if tty) # ------------------------------------------------------------------ if [[ -t 1 ]]; then C_BOLD="\033[1m"; C_GREEN="\033[32m"; C_YELLOW="\033[33m"; C_RED="\033[31m"; C_RESET="\033[0m" else C_BOLD=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_RESET="" fi log() { printf "%b\n" "${C_BOLD}[$(date +%H:%M:%S)]${C_RESET} $*"; } ok() { printf "%b\n" "${C_GREEN}ok${C_RESET} $*"; } warn() { printf "%b\n" "${C_YELLOW}warn${C_RESET} $*" >&2; } err() { printf "%b\n" "${C_RED}error${C_RESET} $*" >&2; } # ------------------------------------------------------------------ # Yes/no prompt with default (respects NONINTERACTIVE=1) # ------------------------------------------------------------------ ask() { local prompt="$1" default="${2:-y}" reply if [[ "${NONINTERACTIVE:-0}" == "1" ]]; then echo "$default" return fi local hint="y/n"; [[ "$default" == "y" ]] && hint="Y/n" || hint="y/N" read -rp "$prompt [$hint] " reply reply="${reply:-$default}" case "$reply" in y|Y|yes|YES) echo "y" ;; *) echo "n" ;; esac } ask_value() { local prompt="$1" default="$2" if [[ "${NONINTERACTIVE:-0}" == "1" ]]; then echo "$default" return fi read -rp "$prompt [$default]: " reply echo "${reply:-$default}" } # ------------------------------------------------------------------ # Subcommand: deps # ------------------------------------------------------------------ cmd_deps() { local dev=0 [[ "${1:-}" == "--dev" ]] && dev=1 log "Checking Python..." command -v python3 >/dev/null 2>&1 || { err "python3 not found (need 3.12+)"; exit 1; } local PY_VERSION PY_MAJOR PY_MINOR PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") PY_MAJOR=${PY_VERSION%%.*} PY_MINOR=${PY_VERSION#*.}; PY_MINOR=${PY_MINOR%%.*} if (( PY_MAJOR < 3 )) || (( PY_MAJOR == 3 && PY_MINOR < 12 )); then err "Python $PY_VERSION found, but 3.12+ required" exit 1 fi ok "Python $PY_VERSION" log "Setting up virtualenv at $VENV_DIR ..." if [[ ! -d "$VENV_DIR" ]]; then python3 -m venv "$VENV_DIR" ok "created venv" else ok "reusing existing venv" fi # shellcheck disable=SC1091 source "$VENV_DIR/bin/activate" python3 -m pip install --upgrade pip --quiet ok "pip upgraded" log "Installing Python dependencies..." local deps=( "fastapi>=0.128" "uvicorn[standard]>=0.30" "websockets>=12" "pydantic>=2" "aiohttp>=3.9" "minio>=7.2" "prometheus-client>=0.20" "PyYAML>=6" "requests>=2.31" "websocket-client>=1.7" ) pip install --quiet "${deps[@]}" ok "core deps installed" if (( dev )); then log "Installing dev tools (ruff, mypy, pytest, httpx)..." pip install --quiet ruff mypy pytest pytest-asyncio httpx ok "dev tools installed" fi log "Checking optional system tools..." set +e local tools=( "tmux:live action output viewer" "mosh:shell-into-node button" "ssh:shell-into-node fallback" "qemu-img:qcow2 workspace snapshots" "qemu-nbd:qcow2 mount for freeze" "rsync:workspace sync for freeze" "curl:firecracker API calls" ) for entry in "${tools[@]}"; do local tool="${entry%%:*}" purpose="${entry#*:}" if command -v "$tool" >/dev/null 2>&1; then ok "$tool — $purpose" else warn "$tool missing — $purpose (optional)" fi done set -e } # ------------------------------------------------------------------ # Subcommand: config # ------------------------------------------------------------------ cmd_config() { log "Setting up configuration at $CONFIG_PATH ..." if [[ -f "$CONFIG_PATH" ]]; then ok "config.yaml already exists — leaving untouched" return fi local CLUSTER_NAME LIBVIRT LXC DISTCC MINIO CLUSTER_NAME=$(ask_value "Cluster name" "fester-cluster") [[ "$(ask "Enable libvirt integration?" "y")" == "y" ]] && LIBVIRT=true || LIBVIRT=false [[ "$(ask "Enable LXC integration?" "y")" == "y" ]] && LXC=true || LXC=false [[ "$(ask "Enable distcc integration?" "y")" == "y" ]] && DISTCC=true || DISTCC=false [[ "$(ask "Enable MinIO cache backend?" "y")" == "y" ]] && MINIO=true || MINIO=false cat > "$CONFIG_PATH" <&1 || { warn "DB init issue — will be created on first backend run"; return; } ok "database ready" } # ------------------------------------------------------------------ # Subcommand: observability # ------------------------------------------------------------------ cmd_observability() { log "Writing Prometheus + Grafana drop-ins ..." local out_dir="${1:-$REPO_ROOT}" mkdir -p "$out_dir" cat > "$out_dir/add-to-prometheus-config.yml" <<'EOF' scrape_configs: - job_name: 'fester' static_configs: - targets: ['localhost:8181'] metrics_path: /metrics EOF ok "wrote $out_dir/add-to-prometheus-config.yml" # Grafana dashboard — wired to real Prometheus metric names exposed # by backend/metrics/prometheus.py: # fester_node_cpu, fester_node_temp, # fester_pipeline_actions_total, fester_cache_hits_total, # fester_scheduler_score cat > "$out_dir/add-to-grafana-config.json" <<'EOF' { "dashboard": { "title": "Fester Cluster Overview", "schemaVersion": 39, "version": 1, "time": { "from": "now-1h", "to": "now" }, "panels": [ { "type": "stat", "title": "Pipeline Actions (done)", "gridPos": { "x": 0, "y": 0, "w": 6, "h": 4 }, "targets": [{ "expr": "sum(fester_pipeline_actions_total{state=\"done\"})", "legendFormat": "done" }] }, { "type": "stat", "title": "Pipeline Actions (failed)", "gridPos": { "x": 6, "y": 0, "w": 6, "h": 4 }, "targets": [{ "expr": "sum(fester_pipeline_actions_total{state=\"failed\"})", "legendFormat": "failed" }] }, { "type": "stat", "title": "Cache Hits", "gridPos": { "x": 12, "y": 0, "w": 6, "h": 4 }, "targets": [{ "expr": "sum(fester_cache_hits_total)", "legendFormat": "hits" }] }, { "type": "stat", "title": "Nodes Tracked", "gridPos": { "x": 18, "y": 0, "w": 6, "h": 4 }, "targets": [{ "expr": "count(fester_node_cpu)", "legendFormat": "nodes" }] }, { "type": "timeseries", "title": "Per-node CPU Load", "gridPos": { "x": 0, "y": 4, "w": 12, "h": 8 }, "targets": [{ "expr": "fester_node_cpu", "legendFormat": "{{node}}" }] }, { "type": "timeseries", "title": "Per-node Temperature (C)", "gridPos": { "x": 12, "y": 4, "w": 12, "h": 8 }, "targets": [{ "expr": "fester_node_temp", "legendFormat": "{{node}}" }] }, { "type": "timeseries", "title": "Scheduler Score by Node / Target", "gridPos": { "x": 0, "y": 12, "w": 24, "h": 8 }, "targets": [{ "expr": "fester_scheduler_score", "legendFormat": "{{node}} / {{target}}" }] } ] } } EOF ok "wrote $out_dir/add-to-grafana-config.json" } # ------------------------------------------------------------------ # Subcommand: systemd # ------------------------------------------------------------------ cmd_systemd() { local base_dir="${1:-$REPO_ROOT}" log "Writing systemd unit for $base_dir ..." local unit_path="/tmp/fester.service" cat > "$unit_path" </dev/null; then : # created without sudo elif [[ "${EUID:-$(id -u)}" -eq 0 ]]; then mkdir -p "$base_dir" else warn "creating $base_dir requires root — invoking sudo" sudo mkdir -p "$base_dir" sudo chown -R "$USER":"$USER" "$base_dir" 2>/dev/null || true fi fi # Ensure we can write into base_dir. If we can't, bail with a clear # message instead of letting cp fail one file at a time. if [[ ! -w "$base_dir" ]]; then err "$base_dir is not writable by $USER — re-run with sudo or pick a different BASE_DIR" exit 1 fi mkdir -p "$base_dir"/{backend,ui,cockpit/fester-module,cli,docs,config} # Copy code (preserve structure) cp -r "$REPO_ROOT/backend/"* "$base_dir/backend/" 2>/dev/null || true cp -r "$REPO_ROOT/ui/"* "$base_dir/ui/" 2>/dev/null || true cp -r "$REPO_ROOT/cockpit/"* "$base_dir/cockpit/" 2>/dev/null || true cp "$REPO_ROOT/cli/fester.py" "$base_dir/cli/fester.py" cp "$REPO_ROOT/index.html" "$base_dir/" 2>/dev/null || true cp "$REPO_ROOT/style.css" "$base_dir/" 2>/dev/null || true cp "$REPO_ROOT/manifest.json" "$base_dir/" 2>/dev/null || true cp "$REPO_ROOT/CHEATSHEET.md" "$base_dir/docs/" 2>/dev/null || true cp "$REPO_ROOT/README.md" "$base_dir/docs/" 2>/dev/null || true chmod +x "$base_dir/cli/fester.py" ok "code deployed to $base_dir" # Carry config + observability drop-ins along [[ -f "$CONFIG_PATH" ]] && cp "$CONFIG_PATH" "$base_dir/config.yaml" cmd_observability "$base_dir" } # ------------------------------------------------------------------ # Subcommand: run (delegates to run.sh so we keep one launcher) # ------------------------------------------------------------------ cmd_run() { exec "$REPO_ROOT/run.sh" "$@" } # ------------------------------------------------------------------ # Subcommand: all (the default — does the standard in-place setup) # ------------------------------------------------------------------ cmd_all() { local dev=0 [[ "${1:-}" == "--dev" ]] && dev=1 log "Running full in-place setup (dev=$dev)" if (( dev )); then cmd_deps --dev; else cmd_deps; fi cmd_config cmd_db cmd_observability chmod +x "$REPO_ROOT/run.sh" "$REPO_ROOT/cli/fester.py" 2>/dev/null || true cat < [args] Subcommands: all venv + deps + config + db + observability (default) --dev also install ruff/mypy/pytest/httpx deps venv + pip install (core deps only) --dev also install dev tools config write config.yaml interactively (or NONINTERACTIVE=1) db init SQLite at \$FESTER_DB_PATH (default: ~/.fester/fester.db) observability [DIR] write Prometheus + Grafana drop-ins (default: repo root) systemd [BASE_DIR] write /tmp/fester.service pointed at BASE_DIR deploy [BASE_DIR] copy code to BASE_DIR (default: /usr/share/cockpit/fester) run [--dev] [--port N] [--host H] [--mock-agent] launch the FastAPI backend via run.sh help this message Environment: NONINTERACTIVE=1 skip prompts, take defaults FESTER_STORAGE_DIR default: ~/.fester FESTER_DB_PATH default: \$FESTER_STORAGE_DIR/fester.db FESTER_CONFIG default: ./config.yaml FESTER_HOST / FESTER_PORT default: 0.0.0.0 / 8181 Examples: $0 # standard in-place dev setup $0 all --dev # + dev tools NONINTERACTIVE=1 $0 all # CI mode $0 deploy /opt/fester # system-wide deploy + systemd unit $0 run --dev --port 9000 EOF } # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ sub="${1:-all}"; shift || true case "$sub" in all) cmd_all "$@" ;; deps) cmd_deps "$@" ;; config) cmd_config "$@" ;; db) cmd_db "$@" ;; observability) cmd_observability "$@" ;; systemd) cmd_systemd "$@" ;; deploy) cmd_deploy "$@" ;; run) cmd_run "$@" ;; help|-h|--help) cmd_help ;; *) err "unknown subcommand: $sub" cmd_help exit 1 ;; esac