fester/install.sh

476 lines
16 KiB
Bash
Executable File

#!/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" <<EOF
cluster:
name: $CLUSTER_NAME
master:
name: fester-master
role: control
nodes:
- name: localhost
host: 127.0.0.1
max_jobs: 4
integrations:
libvirt: $LIBVIRT
lxc: $LXC
distcc: $DISTCC
cache:
backend: $([ "$MINIO" == "true" ] && echo "minio" || echo "local")
endpoint: localhost:9000
observability:
prometheus: true
grafana: true
scheduler:
mode: weighted-thermal-aware
projects: []
EOF
ok "wrote $CONFIG_PATH"
}
# ------------------------------------------------------------------
# Subcommand: db
# ------------------------------------------------------------------
cmd_db() {
log "Initializing SQLite database at $DB_PATH ..."
mkdir -p "$(dirname "$DB_PATH")"
mkdir -p "$STORAGE_DIR/cas" "$STORAGE_DIR/snapshots" "$STORAGE_DIR/cache"
FESTER_DB_PATH="$DB_PATH" python3 -c "
import sys
sys.path.insert(0, '$REPO_ROOT')
from backend.storage.sqlite_db import Storage
s = Storage()
tables = s._conn.execute('SELECT name FROM sqlite_master WHERE type=\"table\"').fetchall()
print(f' database ready at {s.path}')
print(f' tables: {len(tables)}')
" 2>&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" <<EOF
[Unit]
Description=Fester Distributed Build System
After=network.target
[Service]
Type=simple
WorkingDirectory=$base_dir
ExecStart=$VENV_DIR/bin/python -m uvicorn backend.main:app --host 0.0.0.0 --port 8181
Restart=on-failure
User=$USER
Environment=PYTHONPATH=$base_dir
Environment=FESTER_CONFIG=$base_dir/config.yaml
Environment=FESTER_DB_PATH=$DB_PATH
[Install]
WantedBy=multi-user.target
EOF
ok "unit written to $unit_path"
cat <<EOF
Install with:
sudo mv $unit_path /etc/systemd/system/
sudo systemctl enable --now fester
EOF
}
# ------------------------------------------------------------------
# Subcommand: deploy
# ------------------------------------------------------------------
cmd_deploy() {
local base_dir="${1:-/usr/share/cockpit/fester}"
log "Deploying code to $base_dir ..."
# Try to create the target dir without sudo first. Only escalate if
# the parent isn't writable.
if [[ ! -d "$base_dir" ]]; then
if mkdir -p "$base_dir" 2>/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 <<EOF
${C_BOLD}Setup complete.${C_RESET}
Next:
./install.sh run # start backend on :8181
./install.sh run --dev # auto-reload dev mode
source .venv/bin/activate && fester health
UI: http://localhost:8181
API: http://localhost:8181/docs
EOF
}
# ------------------------------------------------------------------
# Help
# ------------------------------------------------------------------
cmd_help() {
cat <<EOF
Fester installer — unified entry point.
Usage: $0 <subcommand> [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