476 lines
16 KiB
Bash
Executable File
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
|