FossilRepo
Add omnibus installer: bare metal + Docker, interactive + unattended install.sh -- single script that deploys fossilrepo on any Linux box. Two modes: --docker (compose) or --bare-metal (systemd services). Interactive TUI when no flags given, unattended with --yes. Supports YAML config files. Tested on Amazon Linux 2023 ARM64. Handles: Python 3.12, PostgreSQL 15/16, Redis, Fossil (from source), Caddy (auto-SSL), Litestream (S3 backup), uv, gunicorn, celery.
Commit
79e3d9da43f8f557668e9e188ac5a841f74788dd8a1df25358bd491a47bb77ca
Parent
8032e48b1ba8263…
1 file changed
+2333
+2333
| --- a/install.sh | ||
| +++ b/install.sh | ||
| @@ -0,0 +1,2333 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# ============================================================================ | |
| 3 | +# fossilrepo -- Omnibus Installer | |
| 4 | +# | |
| 5 | +# Installs fossilrepo on any Linux box. Supports two modes: | |
| 6 | +# - Docker: full stack via docker compose (recommended) | |
| 7 | +# - Bare Metal: native install with systemd services | |
| 8 | +# | |
| 9 | +# Usage: | |
| 10 | +# curl -sSL https://get.fossilrepo.dev | bash | |
| 11 | +# -- or -- | |
| 12 | +# ./install.sh --docker --domain fossil.example.com --ssl | |
| 13 | +# | |
| 14 | +# https://github.com/ConflictHQ/fossilrepo | |
| 15 | +# MIT License | |
| 16 | +# ============================================================================ | |
| 17 | + | |
| 18 | +set -euo pipefail | |
| 19 | + | |
| 20 | +# Ensure HOME is set (SSM/cloud-init may not set it) | |
| 21 | +export HOME="${HOME:-/root}" | |
| 22 | + | |
| 23 | +# ============================================================================ | |
| 24 | +# Section 1: Constants + Version Pins | |
| 25 | +# ============================================================================ | |
| 26 | + | |
| 27 | +readonly INSTALLER_VERSION="1.0.0" | |
| 28 | +readonly FOSSILREPO_VERSION="0.1.0" | |
| 29 | + | |
| 30 | +readonly FOSSIL_VERSION="2.24" | |
| 31 | +readonly LITESTREAM_VERSION="0.3.13" | |
| 32 | +readonly CADDY_VERSION="2.9" | |
| 33 | +readonly PYTHON_VERSION="3.12" | |
| 34 | +readonly POSTGRES_VERSION="16" | |
| 35 | +readonly REDIS_VERSION="7" | |
| 36 | + | |
| 37 | +readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git" | |
| 38 | +readonly DEFAULT_PREFIX="/opt/fossilrepo" | |
| 39 | +readonly DATA_DIR="/data" | |
| 40 | +readonly LOG_DIR="/var/log/fossilrepo" | |
| 41 | +readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download" | |
| 42 | +readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download" | |
| 43 | + | |
| 44 | +# Globals -- set by arg parser, interactive TUI, or defaults | |
| 45 | +OPT_MODE="" # docker | bare-metal | |
| 46 | +OPT_DOMAIN="localhost" | |
| 47 | +OPT_SSL="false" | |
| 48 | +OPT_PREFIX="$DEFAULT_PREFIX" | |
| 49 | +OPT_PORT="8000" | |
| 50 | +OPT_DB_NAME="fossilrepo" | |
| 51 | +OPT_DB_USER="dbadmin" | |
| 52 | +OPT_DB_PASSWORD="" | |
| 53 | +OPT_ADMIN_USER="admin" | |
| 54 | +OPT_ADMIN_EMAIL="" | |
| 55 | +OPT_ADMIN_PASSWORD="" | |
| 56 | +OPT_S3_BUCKET="" | |
| 57 | +OPT_S3_REGION="" | |
| 58 | +OPT_S3_ENDPOINT="" | |
| 59 | +OPT_S3_ACCESS_KEY="" | |
| 60 | +OPT_S3_SECRET_KEY="" | |
| 61 | +OPT_CONFIG_FILE="" | |
| 62 | +OPT_YES="false" | |
| 63 | +OPT_VERBOSE="false" | |
| 64 | + | |
| 65 | +# Detected at runtime | |
| 66 | +OS_ID="" | |
| 67 | +OS_VERSION="" | |
| 68 | +OS_ARCH="" | |
| 69 | +PKG_MANAGER="" | |
| 70 | + | |
| 71 | +# Generated secrets | |
| 72 | +GEN_SECRET_KEY="" | |
| 73 | +GEN_DB_PASSWORD="" | |
| 74 | +GEN_ADMIN_PASSWORD="" | |
| 75 | + | |
| 76 | +# Color codes -- set by _color_init | |
| 77 | +_C_RESET="" | |
| 78 | +_C_RED="" | |
| 79 | +_C_GREEN="" | |
| 80 | +_C_YELLOW="" | |
| 81 | +_C_BLUE="" | |
| 82 | +_C_CYAN="" | |
| 83 | +_C_BOLD="" | |
| 84 | + | |
| 85 | +# ============================================================================ | |
| 86 | +# Section 2: Logging | |
| 87 | +# ============================================================================ | |
| 88 | + | |
| 89 | +_supports_color() { | |
| 90 | + # Check if stdout is a terminal and TERM is not dumb | |
| 91 | + [[ -t 1 ]] && [[ "${TERM:-dumb}" != "dumb" ]] && return 0 | |
| 92 | + # Also support NO_COLOR convention | |
| 93 | + [[ -z "${NO_COLOR:-}" ]] || return 1 | |
| 94 | + return 1 | |
| 95 | +} | |
| 96 | + | |
| 97 | +_color_init() { | |
| 98 | + if _supports_color; then | |
| 99 | + _C_RESET='\033[0m' | |
| 100 | + _C_RED='\033[0;31m' | |
| 101 | + _C_GREEN='\033[0;32m' | |
| 102 | + _C_YELLOW='\033[0;33m' | |
| 103 | + _C_BLUE='\033[0;34m' | |
| 104 | + _C_CYAN='\033[0;36m' | |
| 105 | + _C_BOLD='\033[1m' | |
| 106 | + fi | |
| 107 | +} | |
| 108 | + | |
| 109 | +log_info() { printf "${_C_BLUE}[INFO]${_C_RESET} %s\n" "$*"; } | |
| 110 | +log_ok() { printf "${_C_GREEN}[ OK]${_C_RESET} %s\n" "$*"; } | |
| 111 | +log_warn() { printf "${_C_YELLOW}[WARN]${_C_RESET} %s\n" "$*" >&2; } | |
| 112 | +log_error() { printf "${_C_RED}[ ERR]${_C_RESET} %s\n" "$*" >&2; } | |
| 113 | +log_step() { printf "\n${_C_CYAN}${_C_BOLD}==> %s${_C_RESET}\n" "$*"; } | |
| 114 | + | |
| 115 | +die() { | |
| 116 | + log_error "$@" | |
| 117 | + exit 1 | |
| 118 | +} | |
| 119 | + | |
| 120 | +verbose() { | |
| 121 | + [[ "$OPT_VERBOSE" == "true" ]] && log_info "$@" | |
| 122 | + return 0 | |
| 123 | +} | |
| 124 | + | |
| 125 | +# ============================================================================ | |
| 126 | +# Section 3: Utilities | |
| 127 | +# ============================================================================ | |
| 128 | + | |
| 129 | +generate_password() { | |
| 130 | + # 32-char alphanumeric password -- no special chars to avoid escaping issues | |
| 131 | + local length="${1:-32}" | |
| 132 | + if command -v openssl &>/dev/null; then | |
| 133 | + openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" | |
| 134 | + elif [[ -r /dev/urandom ]]; then | |
| 135 | + tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$length" | |
| 136 | + else | |
| 137 | + die "Cannot generate random password: no openssl or /dev/urandom" | |
| 138 | + fi | |
| 139 | +} | |
| 140 | + | |
| 141 | +generate_secret_key() { | |
| 142 | + # 50-char Django secret key | |
| 143 | + if command -v openssl &>/dev/null; then | |
| 144 | + openssl rand -base64 72 | tr -dc 'a-zA-Z0-9!@#$%^&*(-_=+)' | head -c 50 | |
| 145 | + elif [[ -r /dev/urandom ]]; then | |
| 146 | + tr -dc 'a-zA-Z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c 50 | |
| 147 | + else | |
| 148 | + die "Cannot generate secret key: no openssl or /dev/urandom" | |
| 149 | + fi | |
| 150 | +} | |
| 151 | + | |
| 152 | +command_exists() { | |
| 153 | + command -v "$1" &>/dev/null | |
| 154 | +} | |
| 155 | + | |
| 156 | +version_gte() { | |
| 157 | + # Returns 0 if $1 >= $2 (dot-separated version comparison) | |
| 158 | + local IFS=. | |
| 159 | + local i ver1=($1) ver2=($2) | |
| 160 | + for ((i = 0; i < ${#ver2[@]}; i++)); do | |
| 161 | + local v1="${ver1[i]:-0}" | |
| 162 | + local v2="${ver2[i]:-0}" | |
| 163 | + if ((v1 > v2)); then return 0; fi | |
| 164 | + if ((v1 < v2)); then return 1; fi | |
| 165 | + done | |
| 166 | + return 0 | |
| 167 | +} | |
| 168 | + | |
| 169 | +require_root() { | |
| 170 | + if [[ $EUID -ne 0 ]]; then | |
| 171 | + die "This installer must be run as root. Use: sudo ./install.sh" | |
| 172 | + fi | |
| 173 | +} | |
| 174 | + | |
| 175 | +confirm() { | |
| 176 | + if [[ "$OPT_YES" == "true" ]]; then | |
| 177 | + return 0 | |
| 178 | + fi | |
| 179 | + local prompt="${1:-Continue?}" | |
| 180 | + local reply | |
| 181 | + printf "${_C_BOLD}%s [y/N] ${_C_RESET}" "$prompt" | |
| 182 | + read -r reply | |
| 183 | + case "$reply" in | |
| 184 | + [yY][eE][sS]|[yY]) return 0 ;; | |
| 185 | + *) die "Aborted by user." ;; | |
| 186 | + esac | |
| 187 | +} | |
| 188 | + | |
| 189 | +backup_file() { | |
| 190 | + local file="$1" | |
| 191 | + if [[ -f "$file" ]]; then | |
| 192 | + local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" | |
| 193 | + cp "$file" "$backup" | |
| 194 | + verbose "Backed up $file -> $backup" | |
| 195 | + fi | |
| 196 | +} | |
| 197 | + | |
| 198 | +write_file() { | |
| 199 | + # Write content to a file, creating parent dirs and backing up existing files. | |
| 200 | + # Usage: write_file <path> <content> [mode] | |
| 201 | + local path="$1" | |
| 202 | + local content="$2" | |
| 203 | + local mode="${3:-0644}" | |
| 204 | + | |
| 205 | + mkdir -p "$(dirname "$path")" | |
| 206 | + backup_file "$path" | |
| 207 | + printf '%s\n' "$content" > "$path" | |
| 208 | + chmod "$mode" "$path" | |
| 209 | + verbose "Wrote $path (mode $mode)" | |
| 210 | +} | |
| 211 | + | |
| 212 | +retry_command() { | |
| 213 | + # Retry a command up to N times with a delay between attempts | |
| 214 | + local max_attempts="${1:-3}" | |
| 215 | + local delay="${2:-5}" | |
| 216 | + shift 2 | |
| 217 | + local attempt=1 | |
| 218 | + while [[ $attempt -le $max_attempts ]]; do | |
| 219 | + if "$@"; then | |
| 220 | + return 0 | |
| 221 | + fi | |
| 222 | + log_warn "Command failed (attempt $attempt/$max_attempts): $*" | |
| 223 | + ((attempt++)) | |
| 224 | + sleep "$delay" | |
| 225 | + done | |
| 226 | + return 1 | |
| 227 | +} | |
| 228 | + | |
| 229 | +# ============================================================================ | |
| 230 | +# Section 4: OS Detection | |
| 231 | +# ============================================================================ | |
| 232 | + | |
| 233 | +detect_os() { | |
| 234 | + log_step "Detecting operating system" | |
| 235 | + | |
| 236 | + if [[ ! -f /etc/os-release ]]; then | |
| 237 | + die "Cannot detect OS: /etc/os-release not found. This installer requires a modern Linux distribution." | |
| 238 | + fi | |
| 239 | + | |
| 240 | + # shellcheck source=/dev/null | |
| 241 | + . /etc/os-release | |
| 242 | + | |
| 243 | + OS_ID="${ID:-unknown}" | |
| 244 | + OS_VERSION="${VERSION_ID:-0}" | |
| 245 | + | |
| 246 | + case "$OS_ID" in | |
| 247 | + debian) | |
| 248 | + PKG_MANAGER="apt" | |
| 249 | + ;; | |
| 250 | + ubuntu) | |
| 251 | + PKG_MANAGER="apt" | |
| 252 | + ;; | |
| 253 | + rhel|centos|rocky|almalinux) | |
| 254 | + OS_ID="rhel" | |
| 255 | + PKG_MANAGER="dnf" | |
| 256 | + ;; | |
| 257 | + amzn) | |
| 258 | + PKG_MANAGER="dnf" | |
| 259 | + ;; | |
| 260 | + fedora) | |
| 261 | + OS_ID="rhel" | |
| 262 | + PKG_MANAGER="dnf" | |
| 263 | + ;; | |
| 264 | + alpine) | |
| 265 | + PKG_MANAGER="apk" | |
| 266 | + log_warn "Alpine Linux detected. Only Docker mode is supported on Alpine." | |
| 267 | + if [[ "$OPT_MODE" == "bare-metal" ]]; then | |
| 268 | + die "Bare metal installation is not supported on Alpine Linux." | |
| 269 | + fi | |
| 270 | + OPT_MODE="docker" | |
| 271 | + ;; | |
| 272 | + *) | |
| 273 | + die "Unsupported OS: $OS_ID. Supported: debian, ubuntu, rhel/centos/rocky/alma, fedora, amzn, alpine." | |
| 274 | + ;; | |
| 275 | + esac | |
| 276 | + | |
| 277 | + # Detect architecture | |
| 278 | + local machine | |
| 279 | + machine="$(uname -m)" | |
| 280 | + case "$machine" in | |
| 281 | + x86_64|amd64) OS_ARCH="amd64" ;; | |
| 282 | + aarch64|arm64) OS_ARCH="arm64" ;; | |
| 283 | + *) die "Unsupported architecture: $machine. Supported: amd64, arm64." ;; | |
| 284 | + esac | |
| 285 | + | |
| 286 | + log_ok "OS: $OS_ID $OS_VERSION ($OS_ARCH), package manager: $PKG_MANAGER" | |
| 287 | +} | |
| 288 | + | |
| 289 | +# ============================================================================ | |
| 290 | +# Section 5: YAML Config Parser | |
| 291 | +# ============================================================================ | |
| 292 | + | |
| 293 | +parse_config_file() { | |
| 294 | + local config_file="$1" | |
| 295 | + | |
| 296 | + if [[ ! -f "$config_file" ]]; then | |
| 297 | + die "Config file not found: $config_file" | |
| 298 | + fi | |
| 299 | + | |
| 300 | + log_info "Loading config from $config_file" | |
| 301 | + | |
| 302 | + local current_section="" | |
| 303 | + local line key value | |
| 304 | + | |
| 305 | + while IFS= read -r line || [[ -n "$line" ]]; do | |
| 306 | + # Strip comments and trailing whitespace | |
| 307 | + line="${line%%#*}" | |
| 308 | + line="${line%"${line##*[![:space:]]}"}" | |
| 309 | + | |
| 310 | + # Skip empty lines | |
| 311 | + [[ -z "$line" ]] && continue | |
| 312 | + | |
| 313 | + # Detect section (key followed by colon, no value, next lines indented) | |
| 314 | + if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*$ ]]; then | |
| 315 | + current_section="${BASH_REMATCH[1]}" | |
| 316 | + continue | |
| 317 | + fi | |
| 318 | + | |
| 319 | + # Indented key:value under a section | |
| 320 | + if [[ "$line" =~ ^[[:space:]]+([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*(.+)$ ]]; then | |
| 321 | + key="${BASH_REMATCH[1]}" | |
| 322 | + value="${BASH_REMATCH[2]}" | |
| 323 | + # Strip quotes | |
| 324 | + value="${value%\"}" | |
| 325 | + value="${value#\"}" | |
| 326 | + value="${value%\'}" | |
| 327 | + value="${value#\'}" | |
| 328 | + _set_config_value "${current_section}_${key}" "$value" | |
| 329 | + continue | |
| 330 | + fi | |
| 331 | + | |
| 332 | + # Top-level key: value | |
| 333 | + if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*(.+)$ ]]; then | |
| 334 | + current_section="" | |
| 335 | + key="${BASH_REMATCH[1]}" | |
| 336 | + value="${BASH_REMATCH[2]}" | |
| 337 | + value="${value%\"}" | |
| 338 | + value="${value#\"}" | |
| 339 | + value="${value%\'}" | |
| 340 | + value="${value#\'}" | |
| 341 | + _set_config_value "$key" "$value" | |
| 342 | + continue | |
| 343 | + fi | |
| 344 | + done < "$config_file" | |
| 345 | +} | |
| 346 | + | |
| 347 | +_set_config_value() { | |
| 348 | + local key="$1" | |
| 349 | + local value="$2" | |
| 350 | + | |
| 351 | + # Normalize key: lowercase, dashes to underscores | |
| 352 | + key="${key,,}" | |
| 353 | + key="${key//-/_}" | |
| 354 | + | |
| 355 | + case "$key" in | |
| 356 | + mode) OPT_MODE="$value" ;; | |
| 357 | + domain) OPT_DOMAIN="$value" ;; | |
| 358 | + ssl) OPT_SSL="$value" ;; | |
| 359 | + prefix) OPT_PREFIX="$value" ;; | |
| 360 | + port) OPT_PORT="$value" ;; | |
| 361 | + db_name) OPT_DB_NAME="$value" ;; | |
| 362 | + db_user) OPT_DB_USER="$value" ;; | |
| 363 | + db_password) OPT_DB_PASSWORD="$value" ;; | |
| 364 | + admin_user) OPT_ADMIN_USER="$value" ;; | |
| 365 | + admin_email) OPT_ADMIN_EMAIL="$value" ;; | |
| 366 | + admin_password) OPT_ADMIN_PASSWORD="$value" ;; | |
| 367 | + s3_bucket) OPT_S3_BUCKET="$value" ;; | |
| 368 | + s3_region) OPT_S3_REGION="$value" ;; | |
| 369 | + s3_endpoint) OPT_S3_ENDPOINT="$value" ;; | |
| 370 | + s3_access_key) OPT_S3_ACCESS_KEY="$value" ;; | |
| 371 | + s3_secret_key) OPT_S3_SECRET_KEY="$value" ;; | |
| 372 | + *) verbose "Ignoring unknown config key: $key" ;; | |
| 373 | + esac | |
| 374 | +} | |
| 375 | + | |
| 376 | +# ============================================================================ | |
| 377 | +# Section 6: Arg Parser | |
| 378 | +# ============================================================================ | |
| 379 | + | |
| 380 | +show_help() { | |
| 381 | + cat <<'HELPTEXT' | |
| 382 | +fossilrepo installer -- deploy a full Fossil forge in one command. | |
| 383 | + | |
| 384 | +USAGE | |
| 385 | + install.sh [OPTIONS] | |
| 386 | + install.sh --docker --domain fossil.example.com --ssl | |
| 387 | + install.sh --bare-metal --domain fossil.example.com --config fossilrepo.yml | |
| 388 | + install.sh # interactive mode | |
| 389 | + | |
| 390 | +INSTALLATION MODE | |
| 391 | + --docker Docker Compose deployment (recommended) | |
| 392 | + --bare-metal Native install with systemd services | |
| 393 | + | |
| 394 | +NETWORK | |
| 395 | + --domain <fqdn> Domain name (default: localhost) | |
| 396 | + --ssl Enable automatic HTTPS via Caddy/Let's Encrypt | |
| 397 | + --port <port> Application port (default: 8000) | |
| 398 | + | |
| 399 | +DATABASE | |
| 400 | + --db-password <pass> PostgreSQL password (auto-generated if omitted) | |
| 401 | + | |
| 402 | +ADMIN ACCOUNT | |
| 403 | + --admin-user <name> Admin username (default: admin) | |
| 404 | + --admin-email <email> Admin email address | |
| 405 | + --admin-password <pass> Admin password (auto-generated if omitted) | |
| 406 | + | |
| 407 | +S3 BACKUP (LITESTREAM) | |
| 408 | + --s3-bucket <name> S3 bucket for Litestream replication | |
| 409 | + --s3-region <region> AWS region (default: us-east-1) | |
| 410 | + --s3-endpoint <url> S3-compatible endpoint (for MinIO etc.) | |
| 411 | + --s3-access-key <key> AWS access key ID | |
| 412 | + --s3-secret-key <key> AWS secret access key | |
| 413 | + | |
| 414 | +GENERAL | |
| 415 | + --prefix <path> Install prefix (default: /opt/fossilrepo) | |
| 416 | + --config <file> Load options from YAML config file | |
| 417 | + --yes Skip all confirmation prompts | |
| 418 | + --verbose Enable verbose output | |
| 419 | + -h, --help Show this help and exit | |
| 420 | + --version Show version and exit | |
| 421 | + | |
| 422 | +EXAMPLES | |
| 423 | + # Interactive guided install | |
| 424 | + sudo ./install.sh | |
| 425 | + | |
| 426 | + # Docker with auto-SSL | |
| 427 | + sudo ./install.sh --docker --domain fossil.example.com --ssl --yes | |
| 428 | + | |
| 429 | + # Bare metal with config file | |
| 430 | + sudo ./install.sh --bare-metal --config /etc/fossilrepo/install.yml | |
| 431 | + | |
| 432 | + # Docker on localhost for testing | |
| 433 | + sudo ./install.sh --docker --yes | |
| 434 | +HELPTEXT | |
| 435 | +} | |
| 436 | + | |
| 437 | +parse_args() { | |
| 438 | + while [[ $# -gt 0 ]]; do | |
| 439 | + case "$1" in | |
| 440 | + --docker) | |
| 441 | + OPT_MODE="docker" | |
| 442 | + shift | |
| 443 | + ;; | |
| 444 | + --bare-metal) | |
| 445 | + OPT_MODE="bare-metal" | |
| 446 | + shift | |
| 447 | + ;; | |
| 448 | + --domain) | |
| 449 | + [[ -z "${2:-}" ]] && die "--domain requires a value" | |
| 450 | + OPT_DOMAIN="$2" | |
| 451 | + shift 2 | |
| 452 | + ;; | |
| 453 | + --ssl) | |
| 454 | + OPT_SSL="true" | |
| 455 | + shift | |
| 456 | + ;; | |
| 457 | + --prefix) | |
| 458 | + [[ -z "${2:-}" ]] && die "--prefix requires a value" | |
| 459 | + OPT_PREFIX="$2" | |
| 460 | + shift 2 | |
| 461 | + ;; | |
| 462 | + --port) | |
| 463 | + [[ -z "${2:-}" ]] && die "--port requires a value" | |
| 464 | + OPT_PORT="$2" | |
| 465 | + shift 2 | |
| 466 | + ;; | |
| 467 | + --db-password) | |
| 468 | + [[ -z "${2:-}" ]] && die "--db-password requires a value" | |
| 469 | + OPT_DB_PASSWORD="$2" | |
| 470 | + shift 2 | |
| 471 | + ;; | |
| 472 | + --admin-user) | |
| 473 | + [[ -z "${2:-}" ]] && die "--admin-user requires a value" | |
| 474 | + OPT_ADMIN_USER="$2" | |
| 475 | + shift 2 | |
| 476 | + ;; | |
| 477 | + --admin-email) | |
| 478 | + [[ -z "${2:-}" ]] && die "--admin-email requires a value" | |
| 479 | + OPT_ADMIN_EMAIL="$2" | |
| 480 | + shift 2 | |
| 481 | + ;; | |
| 482 | + --admin-password) | |
| 483 | + [[ -z "${2:-}" ]] && die "--admin-password requires a value" | |
| 484 | + OPT_ADMIN_PASSWORD="$2" | |
| 485 | + shift 2 | |
| 486 | + ;; | |
| 487 | + --s3-bucket) | |
| 488 | + [[ -z "${2:-}" ]] && die "--s3-bucket requires a value" | |
| 489 | + OPT_S3_BUCKET="$2" | |
| 490 | + shift 2 | |
| 491 | + ;; | |
| 492 | + --s3-region) | |
| 493 | + [[ -z "${2:-}" ]] && die "--s3-region requires a value" | |
| 494 | + OPT_S3_REGION="$2" | |
| 495 | + shift 2 | |
| 496 | + ;; | |
| 497 | + --s3-endpoint) | |
| 498 | + [[ -z "${2:-}" ]] && die "--s3-endpoint requires a value" | |
| 499 | + OPT_S3_ENDPOINT="$2" | |
| 500 | + shift 2 | |
| 501 | + ;; | |
| 502 | + --s3-access-key) | |
| 503 | + [[ -z "${2:-}" ]] && die "--s3-access-key requires a value" | |
| 504 | + OPT_S3_ACCESS_KEY="$2" | |
| 505 | + shift 2 | |
| 506 | + ;; | |
| 507 | + --s3-secret-key) | |
| 508 | + [[ -z "${2:-}" ]] && die "--s3-secret-key requires a value" | |
| 509 | + OPT_S3_SECRET_KEY="$2" | |
| 510 | + shift 2 | |
| 511 | + ;; | |
| 512 | + --config) | |
| 513 | + [[ -z "${2:-}" ]] && die "--config requires a value" | |
| 514 | + OPT_CONFIG_FILE="$2" | |
| 515 | + shift 2 | |
| 516 | + ;; | |
| 517 | + --yes|-y) | |
| 518 | + OPT_YES="true" | |
| 519 | + shift | |
| 520 | + ;; | |
| 521 | + --verbose|-v) | |
| 522 | + OPT_VERBOSE="true" | |
| 523 | + shift | |
| 524 | + ;; | |
| 525 | + -h|--help) | |
| 526 | + show_help | |
| 527 | + exit 0 | |
| 528 | + ;; | |
| 529 | + --version) | |
| 530 | + echo "fossilrepo installer v${INSTALLER_VERSION} (fossilrepo v${FOSSILREPO_VERSION})" | |
| 531 | + exit 0 | |
| 532 | + ;; | |
| 533 | + *) | |
| 534 | + die "Unknown option: $1 (use --help for usage)" | |
| 535 | + ;; | |
| 536 | + esac | |
| 537 | + done | |
| 538 | + | |
| 539 | + # Load config file if specified (CLI args take precedence -- already set above) | |
| 540 | + if [[ -n "$OPT_CONFIG_FILE" ]]; then | |
| 541 | + # Save CLI-set values | |
| 542 | + local saved_mode="$OPT_MODE" | |
| 543 | + local saved_domain="$OPT_DOMAIN" | |
| 544 | + local saved_ssl="$OPT_SSL" | |
| 545 | + local saved_prefix="$OPT_PREFIX" | |
| 546 | + local saved_port="$OPT_PORT" | |
| 547 | + local saved_db_password="$OPT_DB_PASSWORD" | |
| 548 | + local saved_admin_user="$OPT_ADMIN_USER" | |
| 549 | + local saved_admin_email="$OPT_ADMIN_EMAIL" | |
| 550 | + local saved_admin_password="$OPT_ADMIN_PASSWORD" | |
| 551 | + local saved_s3_bucket="$OPT_S3_BUCKET" | |
| 552 | + local saved_s3_region="$OPT_S3_REGION" | |
| 553 | + | |
| 554 | + parse_config_file "$OPT_CONFIG_FILE" | |
| 555 | + | |
| 556 | + # Restore CLI overrides (non-default values take precedence) | |
| 557 | + [[ -n "$saved_mode" ]] && OPT_MODE="$saved_mode" | |
| 558 | + [[ "$saved_domain" != "localhost" ]] && OPT_DOMAIN="$saved_domain" | |
| 559 | + [[ "$saved_ssl" == "true" ]] && OPT_SSL="$saved_ssl" | |
| 560 | + [[ "$saved_prefix" != "$DEFAULT_PREFIX" ]] && OPT_PREFIX="$saved_prefix" | |
| 561 | + [[ "$saved_port" != "8000" ]] && OPT_PORT="$saved_port" | |
| 562 | + [[ -n "$saved_db_password" ]] && OPT_DB_PASSWORD="$saved_db_password" | |
| 563 | + [[ "$saved_admin_user" != "admin" ]] && OPT_ADMIN_USER="$saved_admin_user" | |
| 564 | + [[ -n "$saved_admin_email" ]] && OPT_ADMIN_EMAIL="$saved_admin_email" | |
| 565 | + [[ -n "$saved_admin_password" ]] && OPT_ADMIN_PASSWORD="$saved_admin_password" | |
| 566 | + [[ -n "$saved_s3_bucket" ]] && OPT_S3_BUCKET="$saved_s3_bucket" | |
| 567 | + [[ -n "$saved_s3_region" ]] && OPT_S3_REGION="$saved_s3_region" | |
| 568 | + fi | |
| 569 | +} | |
| 570 | + | |
| 571 | +# ============================================================================ | |
| 572 | +# Section 7: Interactive TUI | |
| 573 | +# ============================================================================ | |
| 574 | + | |
| 575 | +_print_banner() { | |
| 576 | + printf "\n" | |
| 577 | + printf "${_C_CYAN}${_C_BOLD}" | |
| 578 | + cat <<'BANNER' | |
| 579 | + __ _ __ | |
| 580 | + / _|___ ___ ___ (_) |_ __ ___ _ __ ___ | |
| 581 | + | |_/ _ \/ __/ __|| | | '__/ _ \ '_ \ / _ \ | |
| 582 | + | _| (_) \__ \__ \| | | | | __/ |_) | (_) | | |
| 583 | + |_| \___/|___/___/|_|_|_| \___| .__/ \___/ | |
| 584 | + |_| | |
| 585 | +BANNER | |
| 586 | + printf "${_C_RESET}" | |
| 587 | + printf " ${_C_BOLD}Omnibus Installer v${INSTALLER_VERSION}${_C_RESET}\n" | |
| 588 | + printf " Self-hosted Fossil forge -- one command, full stack.\n\n" | |
| 589 | +} | |
| 590 | + | |
| 591 | +_prompt() { | |
| 592 | + # Usage: _prompt "Prompt text" "default" VARNAME | |
| 593 | + local prompt="$1" | |
| 594 | + local default="$2" | |
| 595 | + local varname="$3" | |
| 596 | + local reply | |
| 597 | + | |
| 598 | + if [[ -n "$default" ]]; then | |
| 599 | + printf " ${_C_BOLD}%s${_C_RESET} [%s]: " "$prompt" "$default" | |
| 600 | + else | |
| 601 | + printf " ${_C_BOLD}%s${_C_RESET}: " "$prompt" | |
| 602 | + fi | |
| 603 | + read -r reply | |
| 604 | + reply="${reply:-$default}" | |
| 605 | + eval "$varname=\"\$reply\"" | |
| 606 | +} | |
| 607 | + | |
| 608 | +_prompt_password() { | |
| 609 | + local prompt="$1" | |
| 610 | + local varname="$2" | |
| 611 | + local reply | |
| 612 | + | |
| 613 | + printf " ${_C_BOLD}%s${_C_RESET} (leave blank to auto-generate): " "$prompt" | |
| 614 | + read -rs reply | |
| 615 | + printf "\n" | |
| 616 | + eval "$varname=\"\$reply\"" | |
| 617 | +} | |
| 618 | + | |
| 619 | +_prompt_choice() { | |
| 620 | + # Usage: _prompt_choice "Prompt" "1" VARNAME "Option 1" "Option 2" | |
| 621 | + local prompt="$1" | |
| 622 | + local default="$2" | |
| 623 | + local varname="$3" | |
| 624 | + shift 3 | |
| 625 | + local -a options=("$@") | |
| 626 | + local i reply | |
| 627 | + | |
| 628 | + printf "\n ${_C_BOLD}%s${_C_RESET}\n" "$prompt" | |
| 629 | + for i in "${!options[@]}"; do | |
| 630 | + printf " %d) %s\n" "$((i + 1))" "${options[$i]}" | |
| 631 | + done | |
| 632 | + printf " Choice [%s]: " "$default" | |
| 633 | + read -r reply | |
| 634 | + reply="${reply:-$default}" | |
| 635 | + | |
| 636 | + if [[ "$reply" =~ ^[0-9]+$ ]] && ((reply >= 1 && reply <= ${#options[@]})); then | |
| 637 | + eval "$varname=\"\$reply\"" | |
| 638 | + else | |
| 639 | + eval "$varname=\"\$default\"" | |
| 640 | + fi | |
| 641 | +} | |
| 642 | + | |
| 643 | +_prompt_yesno() { | |
| 644 | + local prompt="$1" | |
| 645 | + local default="$2" | |
| 646 | + local varname="$3" | |
| 647 | + local reply hint | |
| 648 | + | |
| 649 | + if [[ "$default" == "y" ]]; then hint="Y/n"; else hint="y/N"; fi | |
| 650 | + printf " ${_C_BOLD}%s${_C_RESET} [%s]: " "$prompt" "$hint" | |
| 651 | + read -r reply | |
| 652 | + reply="${reply:-$default}" | |
| 653 | + case "$reply" in | |
| 654 | + [yY][eE][sS]|[yY]) eval "$varname=true" ;; | |
| 655 | + *) eval "$varname=false" ;; | |
| 656 | + esac | |
| 657 | +} | |
| 658 | + | |
| 659 | +run_interactive() { | |
| 660 | + _print_banner | |
| 661 | + | |
| 662 | + printf " ${_C_BLUE}Welcome to the fossilrepo installer.${_C_RESET}\n" | |
| 663 | + printf " This will guide you through setting up a self-hosted Fossil forge.\n\n" | |
| 664 | + | |
| 665 | + # Mode | |
| 666 | + local mode_choice | |
| 667 | + _prompt_choice "Installation mode" "1" mode_choice \ | |
| 668 | + "Docker (recommended -- everything runs in containers)" \ | |
| 669 | + "Bare Metal (native install with systemd services)" | |
| 670 | + case "$mode_choice" in | |
| 671 | + 1) OPT_MODE="docker" ;; | |
| 672 | + 2) OPT_MODE="bare-metal" ;; | |
| 673 | + esac | |
| 674 | + | |
| 675 | + # Domain | |
| 676 | + _prompt "Domain name" "localhost" OPT_DOMAIN | |
| 677 | + | |
| 678 | + # SSL -- skip if localhost | |
| 679 | + if [[ "$OPT_DOMAIN" != "localhost" && "$OPT_DOMAIN" != "127.0.0.1" ]]; then | |
| 680 | + _prompt_yesno "Enable automatic HTTPS (Let's Encrypt)" "y" OPT_SSL | |
| 681 | + else | |
| 682 | + OPT_SSL="false" | |
| 683 | + log_info "SSL skipped for localhost." | |
| 684 | + fi | |
| 685 | + | |
| 686 | + # S3 backup | |
| 687 | + local want_s3 | |
| 688 | + _prompt_yesno "Configure S3 backup (Litestream replication)" "n" want_s3 | |
| 689 | + if [[ "$want_s3" == "true" ]]; then | |
| 690 | + _prompt "S3 bucket name" "" OPT_S3_BUCKET | |
| 691 | + _prompt "S3 region" "us-east-1" OPT_S3_REGION | |
| 692 | + _prompt "S3 endpoint (leave blank for AWS)" "" OPT_S3_ENDPOINT | |
| 693 | + _prompt "AWS Access Key ID" "" OPT_S3_ACCESS_KEY | |
| 694 | + _prompt_password "AWS Secret Access Key" OPT_S3_SECRET_KEY | |
| 695 | + fi | |
| 696 | + | |
| 697 | + # Admin credentials | |
| 698 | + printf "\n ${_C_BOLD}Admin Account${_C_RESET}\n" | |
| 699 | + _prompt "Admin username" "admin" OPT_ADMIN_USER | |
| 700 | + _prompt "Admin email" "admin@${OPT_DOMAIN}" OPT_ADMIN_EMAIL | |
| 701 | + _prompt_password "Admin password" OPT_ADMIN_PASSWORD | |
| 702 | + | |
| 703 | + # Summary | |
| 704 | + printf "\n" | |
| 705 | + printf " ${_C_CYAN}${_C_BOLD}Installation Summary${_C_RESET}\n" | |
| 706 | + printf " %-20s %s\n" "Mode:" "$OPT_MODE" | |
| 707 | + printf " %-20s %s\n" "Domain:" "$OPT_DOMAIN" | |
| 708 | + printf " %-20s %s\n" "SSL:" "$OPT_SSL" | |
| 709 | + printf " %-20s %s\n" "Admin user:" "$OPT_ADMIN_USER" | |
| 710 | + printf " %-20s %s\n" "Admin email:" "$OPT_ADMIN_EMAIL" | |
| 711 | + printf " %-20s %s\n" "Admin password:" "$(if [[ -n "$OPT_ADMIN_PASSWORD" ]]; then echo '(set)'; else echo '(auto-generate)'; fi)" | |
| 712 | + if [[ -n "$OPT_S3_BUCKET" ]]; then | |
| 713 | + printf " %-20s %s\n" "S3 bucket:" "$OPT_S3_BUCKET" | |
| 714 | + printf " %-20s %s\n" "S3 region:" "${OPT_S3_REGION:-us-east-1}" | |
| 715 | + else | |
| 716 | + printf " %-20s %s\n" "S3 backup:" "disabled" | |
| 717 | + fi | |
| 718 | + printf " %-20s %s\n" "Install prefix:" "$OPT_PREFIX" | |
| 719 | + printf "\n" | |
| 720 | +} | |
| 721 | + | |
| 722 | +# ============================================================================ | |
| 723 | +# Section 8: Dependency Management | |
| 724 | +# ============================================================================ | |
| 725 | + | |
| 726 | +check_docker_deps() { | |
| 727 | + log_step "Checking Docker dependencies" | |
| 728 | + | |
| 729 | + local missing=() | |
| 730 | + | |
| 731 | + if ! command_exists docker; then | |
| 732 | + missing+=("docker") | |
| 733 | + fi | |
| 734 | + | |
| 735 | + # Check for docker compose v2 (plugin) | |
| 736 | + if command_exists docker; then | |
| 737 | + if ! docker compose version &>/dev/null; then | |
| 738 | + missing+=("docker-compose-plugin") | |
| 739 | + fi | |
| 740 | + fi | |
| 741 | + | |
| 742 | + if [[ ${#missing[@]} -gt 0 ]]; then | |
| 743 | + log_warn "Missing Docker dependencies: ${missing[*]}" | |
| 744 | + return 1 | |
| 745 | + fi | |
| 746 | + | |
| 747 | + local compose_ver | |
| 748 | + compose_ver="$(docker compose version --short 2>/dev/null || echo "0")" | |
| 749 | + log_ok "Docker $(docker --version | awk '{print $3}' | tr -d ',') + Compose $compose_ver" | |
| 750 | + return 0 | |
| 751 | +} | |
| 752 | + | |
| 753 | +check_bare_metal_deps() { | |
| 754 | + log_step "Checking bare metal dependencies" | |
| 755 | + | |
| 756 | + local missing=() | |
| 757 | + | |
| 758 | + command_exists git || missing+=("git") | |
| 759 | + command_exists curl || missing+=("curl") | |
| 760 | + | |
| 761 | + # Python 3.12+ | |
| 762 | + if command_exists python3; then | |
| 763 | + local pyver | |
| 764 | + pyver="$(python3 --version 2>&1 | awk '{print $2}')" | |
| 765 | + if ! version_gte "$pyver" "$PYTHON_VERSION"; then | |
| 766 | + missing+=("python${PYTHON_VERSION}") | |
| 767 | + fi | |
| 768 | + else | |
| 769 | + missing+=("python${PYTHON_VERSION}") | |
| 770 | + fi | |
| 771 | + | |
| 772 | + # PostgreSQL | |
| 773 | + if command_exists psql; then | |
| 774 | + local pgver | |
| 775 | + pgver="$(psql --version | awk '{print $3}' | cut -d. -f1)" | |
| 776 | + if ! version_gte "$pgver" "$POSTGRES_VERSION"; then | |
| 777 | + missing+=("postgresql-${POSTGRES_VERSION}") | |
| 778 | + fi | |
| 779 | + else | |
| 780 | + missing+=("postgresql-${POSTGRES_VERSION}") | |
| 781 | + fi | |
| 782 | + | |
| 783 | + command_exists redis-server || missing+=("redis") | |
| 784 | + | |
| 785 | + # These are installed from source/binary -- check separately | |
| 786 | + command_exists fossil || missing+=("fossil") | |
| 787 | + command_exists caddy || missing+=("caddy") | |
| 788 | + | |
| 789 | + if [[ ${#missing[@]} -gt 0 ]]; then | |
| 790 | + log_warn "Missing dependencies: ${missing[*]}" | |
| 791 | + return 1 | |
| 792 | + fi | |
| 793 | + | |
| 794 | + log_ok "All bare metal dependencies present" | |
| 795 | + return 0 | |
| 796 | +} | |
| 797 | + | |
| 798 | +install_docker_engine() { | |
| 799 | + log_info "Installing Docker Engine..." | |
| 800 | + | |
| 801 | + case "$PKG_MANAGER" in | |
| 802 | + apt) | |
| 803 | + # Docker official GPG key and repo | |
| 804 | + apt-get update -qq | |
| 805 | + apt-get install -y -qq ca-certificates curl gnupg lsb-release | |
| 806 | + | |
| 807 | + install -m 0755 -d /etc/apt/keyrings | |
| 808 | + if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then | |
| 809 | + curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \ | |
| 810 | + gpg --dearmor -o /etc/apt/keyrings/docker.gpg | |
| 811 | + chmod a+r /etc/apt/keyrings/docker.gpg | |
| 812 | + fi | |
| 813 | + | |
| 814 | + local codename | |
| 815 | + codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" | |
| 816 | + cat > /etc/apt/sources.list.d/docker.list <<EOF | |
| 817 | +deb [arch=${OS_ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable | |
| 818 | +EOF | |
| 819 | + | |
| 820 | + apt-get update -qq | |
| 821 | + apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin | |
| 822 | + ;; | |
| 823 | + dnf) | |
| 824 | + dnf install -y -q dnf-plugins-core | |
| 825 | + dnf config-manager --add-repo "https://download.docker.com/linux/${OS_ID}/docker-ce.repo" 2>/dev/null || \ | |
| 826 | + dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" | |
| 827 | + dnf install -y -q docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin | |
| 828 | + ;; | |
| 829 | + apk) | |
| 830 | + apk add --no-cache docker docker-cli-compose | |
| 831 | + ;; | |
| 832 | + esac | |
| 833 | + | |
| 834 | + systemctl enable --now docker | |
| 835 | + log_ok "Docker installed and running" | |
| 836 | +} | |
| 837 | + | |
| 838 | +install_deps_debian() { | |
| 839 | + log_step "Installing system packages (Debian/Ubuntu)" | |
| 840 | + | |
| 841 | + apt-get update -qq | |
| 842 | + | |
| 843 | + # Build tools for Fossil compilation + runtime deps | |
| 844 | + apt-get install -y -qq \ | |
| 845 | + build-essential \ | |
| 846 | + ca-certificates \ | |
| 847 | + curl \ | |
| 848 | + git \ | |
| 849 | + gnupg \ | |
| 850 | + lsb-release \ | |
| 851 | + zlib1g-dev \ | |
| 852 | + libssl-dev \ | |
| 853 | + tcl \ | |
| 854 | + openssh-server \ | |
| 855 | + sudo \ | |
| 856 | + logrotate | |
| 857 | + | |
| 858 | + # Python 3.12 | |
| 859 | + if ! command_exists python3 || ! version_gte "$(python3 --version 2>&1 | awk '{print $2}')" "$PYTHON_VERSION"; then | |
| 860 | + log_info "Installing Python ${PYTHON_VERSION}..." | |
| 861 | + apt-get install -y -qq software-properties-common | |
| 862 | + add-apt-repository -y ppa:deadsnakes/ppa 2>/dev/null || true | |
| 863 | + apt-get update -qq | |
| 864 | + apt-get install -y -qq "python${PYTHON_VERSION}" "python${PYTHON_VERSION}-venv" "python${PYTHON_VERSION}-dev" || \ | |
| 865 | + apt-get install -y -qq python3 python3-venv python3-dev | |
| 866 | + fi | |
| 867 | + | |
| 868 | + # PostgreSQL 16 | |
| 869 | + if ! command_exists psql || ! version_gte "$(psql --version | awk '{print $3}' | cut -d. -f1)" "$POSTGRES_VERSION"; then | |
| 870 | + log_info "Installing PostgreSQL ${POSTGRES_VERSION}..." | |
| 871 | + if [[ ! -f /etc/apt/sources.list.d/pgdg.list ]]; then | |
| 872 | + curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | \ | |
| 873 | + gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg | |
| 874 | + echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ | |
| 875 | + > /etc/apt/sources.list.d/pgdg.list | |
| 876 | + apt-get update -qq | |
| 877 | + fi | |
| 878 | + apt-get install -y -qq "postgresql-${POSTGRES_VERSION}" "postgresql-client-${POSTGRES_VERSION}" | |
| 879 | + fi | |
| 880 | + | |
| 881 | + # Redis | |
| 882 | + if ! command_exists redis-server; then | |
| 883 | + log_info "Installing Redis..." | |
| 884 | + apt-get install -y -qq redis-server | |
| 885 | + fi | |
| 886 | + | |
| 887 | + log_ok "System packages installed" | |
| 888 | +} | |
| 889 | + | |
| 890 | +install_deps_rhel() { | |
| 891 | + log_step "Installing system packages (RHEL/CentOS/Fedora/Amazon Linux)" | |
| 892 | + | |
| 893 | + dnf install -y -q epel-release 2>/dev/null || true | |
| 894 | + dnf groupinstall -y -q "Development Tools" 2>/dev/null || \ | |
| 895 | + dnf install -y -q gcc gcc-c++ make | |
| 896 | + | |
| 897 | + # AL2023 ships curl-minimal which conflicts with curl; skip if any curl works | |
| 898 | + local curl_pkg="" | |
| 899 | + command_exists curl || curl_pkg="curl" | |
| 900 | + | |
| 901 | + dnf install -y -q \ | |
| 902 | + ca-certificates \ | |
| 903 | + $curl_pkg \ | |
| 904 | + git \ | |
| 905 | + zlib-devel \ | |
| 906 | + openssl-devel \ | |
| 907 | + tcl \ | |
| 908 | + openssh-server \ | |
| 909 | + sudo \ | |
| 910 | + logrotate | |
| 911 | + | |
| 912 | + # Python 3.12 | |
| 913 | + if ! command_exists python3 || ! version_gte "$(python3 --version 2>&1 | awk '{print $2}')" "$PYTHON_VERSION"; then | |
| 914 | + log_info "Installing Python ${PYTHON_VERSION}..." | |
| 915 | + dnf install -y -q "python${PYTHON_VERSION//.}" "python${PYTHON_VERSION//.}-devel" 2>/dev/null || \ | |
| 916 | + dnf install -y -q python3 python3-devel | |
| 917 | + fi | |
| 918 | + | |
| 919 | + # PostgreSQL | |
| 920 | + if ! command_exists psql; then | |
| 921 | + log_info "Installing PostgreSQL..." | |
| 922 | + # Try PG16 from PGDG repo first, fall back to distro version (PG15 on AL2023) | |
| 923 | + if dnf install -y -q "https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-${OS_ARCH}/pgdg-redhat-repo-latest.noarch.rpm" 2>/dev/null; then | |
| 924 | + dnf install -y -q "postgresql${POSTGRES_VERSION}-server" "postgresql${POSTGRES_VERSION}" 2>/dev/null || \ | |
| 925 | + dnf install -y -q postgresql15-server postgresql15 | |
| 926 | + else | |
| 927 | + dnf install -y -q postgresql15-server postgresql15 2>/dev/null || \ | |
| 928 | + dnf install -y -q postgresql-server postgresql | |
| 929 | + fi | |
| 930 | + fi | |
| 931 | + | |
| 932 | + # Redis | |
| 933 | + if ! command_exists redis-server && ! command_exists redis6-server; then | |
| 934 | + log_info "Installing Redis..." | |
| 935 | + dnf install -y -q redis 2>/dev/null || dnf install -y -q redis6 | |
| 936 | + fi | |
| 937 | + | |
| 938 | + log_ok "System packages installed" | |
| 939 | +} | |
| 940 | + | |
| 941 | +install_fossil_from_source() { | |
| 942 | + # Matches Dockerfile lines 11-22 exactly | |
| 943 | + if command_exists fossil; then | |
| 944 | + local current_ver | |
| 945 | + current_ver="$(fossil version | grep -oP 'version \K[0-9]+\.[0-9]+' | head -1)" | |
| 946 | + if version_gte "${current_ver:-0}" "$FOSSIL_VERSION"; then | |
| 947 | + log_ok "Fossil $current_ver already installed (>= $FOSSIL_VERSION)" | |
| 948 | + return 0 | |
| 949 | + fi | |
| 950 | + fi | |
| 951 | + | |
| 952 | + log_info "Building Fossil ${FOSSIL_VERSION} from source..." | |
| 953 | + local build_dir | |
| 954 | + build_dir="$(mktemp -d)" | |
| 955 | + | |
| 956 | + ( | |
| 957 | + cd "$build_dir" | |
| 958 | + curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \ | |
| 959 | + -o fossil.tar.gz | |
| 960 | + tar xzf fossil.tar.gz | |
| 961 | + cd "fossil-src-${FOSSIL_VERSION}" | |
| 962 | + ./configure --prefix=/usr/local --with-openssl=auto --json | |
| 963 | + make -j"$(nproc)" | |
| 964 | + make install | |
| 965 | + ) | |
| 966 | + | |
| 967 | + rm -rf "$build_dir" | |
| 968 | + | |
| 969 | + if ! command_exists fossil; then | |
| 970 | + die "Fossil build failed -- binary not found at /usr/local/bin/fossil" | |
| 971 | + fi | |
| 972 | + | |
| 973 | + log_ok "Fossil $(fossil version | grep -oP 'version \K[0-9]+\.[0-9]+') installed" | |
| 974 | +} | |
| 975 | + | |
| 976 | +install_caddy_binary() { | |
| 977 | + if command_exists caddy; then | |
| 978 | + local current_ver | |
| 979 | + current_ver="$(caddy version 2>/dev/null | awk '{print $1}' | tr -d 'v')" | |
| 980 | + if version_gte "${current_ver:-0}" "$CADDY_VERSION"; then | |
| 981 | + log_ok "Caddy $current_ver already installed (>= $CADDY_VERSION)" | |
| 982 | + return 0 | |
| 983 | + fi | |
| 984 | + fi | |
| 985 | + | |
| 986 | + log_info "Installing Caddy ${CADDY_VERSION}..." | |
| 987 | + | |
| 988 | + local caddy_arch="$OS_ARCH" | |
| 989 | + local caddy_url="${CADDY_DOWNLOAD_BASE}?os=linux&arch=${caddy_arch}" | |
| 990 | + | |
| 991 | + curl -sSL "$caddy_url" -o /usr/local/bin/caddy | |
| 992 | + chmod +x /usr/local/bin/caddy | |
| 993 | + | |
| 994 | + if ! /usr/local/bin/caddy version &>/dev/null; then | |
| 995 | + die "Caddy binary download failed or is not executable" | |
| 996 | + fi | |
| 997 | + | |
| 998 | + # Allow Caddy to bind to privileged ports without root | |
| 999 | + if command_exists setcap; then | |
| 1000 | + setcap 'cap_net_bind_service=+ep' /usr/local/bin/caddy 2>/dev/null || true | |
| 1001 | + fi | |
| 1002 | + | |
| 1003 | + log_ok "Caddy $(/usr/local/bin/caddy version | awk '{print $1}') installed" | |
| 1004 | +} | |
| 1005 | + | |
| 1006 | +install_litestream_binary() { | |
| 1007 | + if [[ -z "$OPT_S3_BUCKET" ]]; then | |
| 1008 | + verbose "Skipping Litestream install (no S3 bucket configured)" | |
| 1009 | + return 0 | |
| 1010 | + fi | |
| 1011 | + | |
| 1012 | + if command_exists litestream; then | |
| 1013 | + local current_ver | |
| 1014 | + current_ver="$(litestream version 2>/dev/null | tr -d 'v')" | |
| 1015 | + if version_gte "${current_ver:-0}" "$LITESTREAM_VERSION"; then | |
| 1016 | + log_ok "Litestream $current_ver already installed (>= $LITESTREAM_VERSION)" | |
| 1017 | + return 0 | |
| 1018 | + fi | |
| 1019 | + fi | |
| 1020 | + | |
| 1021 | + log_info "Installing Litestream ${LITESTREAM_VERSION}..." | |
| 1022 | + | |
| 1023 | + local ls_arch | |
| 1024 | + case "$OS_ARCH" in | |
| 1025 | + amd64) ls_arch="amd64" ;; | |
| 1026 | + arm64) ls_arch="arm64" ;; | |
| 1027 | + esac | |
| 1028 | + | |
| 1029 | + local ls_url="${LITESTREAM_DOWNLOAD_BASE}/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-${ls_arch}.tar.gz" | |
| 1030 | + local tmp_dir | |
| 1031 | + tmp_dir="$(mktemp -d)" | |
| 1032 | + | |
| 1033 | + curl -sSL "$ls_url" -o "${tmp_dir}/litestream.tar.gz" | |
| 1034 | + tar xzf "${tmp_dir}/litestream.tar.gz" -C "${tmp_dir}" | |
| 1035 | + install -m 0755 "${tmp_dir}/litestream" /usr/local/bin/litestream | |
| 1036 | + rm -rf "$tmp_dir" | |
| 1037 | + | |
| 1038 | + if ! command_exists litestream; then | |
| 1039 | + die "Litestream install failed" | |
| 1040 | + fi | |
| 1041 | + | |
| 1042 | + log_ok "Litestream $(litestream version) installed" | |
| 1043 | +} | |
| 1044 | + | |
| 1045 | +install_uv() { | |
| 1046 | + if command_exists uv; then | |
| 1047 | + log_ok "uv already installed" | |
| 1048 | + return 0 | |
| 1049 | + fi | |
| 1050 | + | |
| 1051 | + log_info "Installing uv (Python package manager)..." | |
| 1052 | + export HOME="${HOME:-/root}" | |
| 1053 | + curl -LsSf https://astral.sh/uv/install.sh | sh | |
| 1054 | + # Copy to /usr/local/bin so all users can access it (symlink fails because /root is 700) | |
| 1055 | + cp -f "${HOME}/.local/bin/uv" /usr/local/bin/uv 2>/dev/null || true | |
| 1056 | + cp -f "${HOME}/.local/bin/uvx" /usr/local/bin/uvx 2>/dev/null || true | |
| 1057 | + chmod +x /usr/local/bin/uv /usr/local/bin/uvx 2>/dev/null || true | |
| 1058 | + export PATH="/usr/local/bin:${HOME}/.local/bin:$PATH" | |
| 1059 | + | |
| 1060 | + if ! command_exists uv; then | |
| 1061 | + # Fallback: pip install | |
| 1062 | + pip3 install uv 2>/dev/null || pip install uv 2>/dev/null || \ | |
| 1063 | + die "Failed to install uv" | |
| 1064 | + fi | |
| 1065 | + | |
| 1066 | + log_ok "uv installed" | |
| 1067 | +} | |
| 1068 | + | |
| 1069 | +check_and_install_deps() { | |
| 1070 | + if [[ "$OPT_MODE" == "docker" ]]; then | |
| 1071 | + if ! check_docker_deps; then | |
| 1072 | + confirm "Docker not found. Install Docker Engine?" | |
| 1073 | + install_docker_engine | |
| 1074 | + fi | |
| 1075 | + else | |
| 1076 | + # Bare metal: install OS packages, then build/download remaining deps | |
| 1077 | + case "$PKG_MANAGER" in | |
| 1078 | + apt) install_deps_debian ;; | |
| 1079 | + dnf) install_deps_rhel ;; | |
| 1080 | + *) die "Unsupported package manager: $PKG_MANAGER" ;; | |
| 1081 | + esac | |
| 1082 | + | |
| 1083 | + install_fossil_from_source | |
| 1084 | + install_caddy_binary | |
| 1085 | + install_litestream_binary | |
| 1086 | + install_uv | |
| 1087 | + fi | |
| 1088 | +} | |
| 1089 | + | |
| 1090 | +# ============================================================================ | |
| 1091 | +# Section 9: Docker Mode | |
| 1092 | +# ============================================================================ | |
| 1093 | + | |
| 1094 | +generate_env_file() { | |
| 1095 | + log_info "Generating .env file..." | |
| 1096 | + | |
| 1097 | + local proto="http" | |
| 1098 | + [[ "$OPT_SSL" == "true" ]] && proto="https" | |
| 1099 | + local base_url="${proto}://${OPT_DOMAIN}" | |
| 1100 | + | |
| 1101 | + local db_host="postgres" | |
| 1102 | + local redis_host="redis" | |
| 1103 | + local email_host="localhost" | |
| 1104 | + | |
| 1105 | + local use_s3="false" | |
| 1106 | + [[ -n "$OPT_S3_BUCKET" ]] && use_s3="true" | |
| 1107 | + | |
| 1108 | + local env_content | |
| 1109 | + env_content="# fossilrepo -- generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 1110 | +# Mode: ${OPT_MODE} | |
| 1111 | + | |
| 1112 | +# --- Security --- | |
| 1113 | +DJANGO_SECRET_KEY=${GEN_SECRET_KEY} | |
| 1114 | +DJANGO_DEBUG=false | |
| 1115 | +DJANGO_ALLOWED_HOSTS=${OPT_DOMAIN},localhost,127.0.0.1 | |
| 1116 | + | |
| 1117 | +# --- Database --- | |
| 1118 | +POSTGRES_DB=${OPT_DB_NAME} | |
| 1119 | +POSTGRES_USER=${OPT_DB_USER} | |
| 1120 | +POSTGRES_PASSWORD=${GEN_DB_PASSWORD} | |
| 1121 | +POSTGRES_HOST=${db_host} | |
| 1122 | +POSTGRES_PORT=5432 | |
| 1123 | + | |
| 1124 | +# --- Redis / Celery --- | |
| 1125 | +REDIS_URL=redis://${redis_host}:6379/1 | |
| 1126 | +CELERY_BROKER=redis://${redis_host}:6379/0 | |
| 1127 | + | |
| 1128 | +# --- Email --- | |
| 1129 | +EMAIL_HOST=${email_host} | |
| 1130 | +EMAIL_PORT=587 | |
| 1131 | +DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend | |
| 1132 | +FROM_EMAIL=no-reply@${OPT_DOMAIN} | |
| 1133 | + | |
| 1134 | +# --- S3 / Media --- | |
| 1135 | +USE_S3=${use_s3} | |
| 1136 | +AWS_ACCESS_KEY_ID=${OPT_S3_ACCESS_KEY} | |
| 1137 | +AWS_SECRET_ACCESS_KEY=${OPT_S3_SECRET_KEY} | |
| 1138 | +AWS_STORAGE_BUCKET_NAME=${OPT_S3_BUCKET} | |
| 1139 | +AWS_S3_ENDPOINT_URL=${OPT_S3_ENDPOINT} | |
| 1140 | + | |
| 1141 | +# --- CORS / CSRF --- | |
| 1142 | +CORS_ALLOWED_ORIGINS=${base_url} | |
| 1143 | +CSRF_TRUSTED_ORIGINS=${base_url} | |
| 1144 | + | |
| 1145 | +# --- Sentry --- | |
| 1146 | +SENTRY_DSN= | |
| 1147 | + | |
| 1148 | +# --- Litestream S3 Replication --- | |
| 1149 | +FOSSILREPO_S3_BUCKET=${OPT_S3_BUCKET} | |
| 1150 | +FOSSILREPO_S3_REGION=${OPT_S3_REGION:-us-east-1} | |
| 1151 | +FOSSILREPO_S3_ENDPOINT=${OPT_S3_ENDPOINT}" | |
| 1152 | + | |
| 1153 | + write_file "${OPT_PREFIX}/.env" "$env_content" "0600" | |
| 1154 | +} | |
| 1155 | + | |
| 1156 | +generate_docker_compose() { | |
| 1157 | + log_info "Generating docker-compose.yml..." | |
| 1158 | + | |
| 1159 | + local litestream_service="" | |
| 1160 | + local litestream_depends="" | |
| 1161 | + if [[ -n "$OPT_S3_BUCKET" ]]; then | |
| 1162 | + litestream_depends=" | |
| 1163 | + litestream: | |
| 1164 | + condition: service_started" | |
| 1165 | + litestream_service=" | |
| 1166 | + litestream: | |
| 1167 | + image: litestream/litestream:${LITESTREAM_VERSION} | |
| 1168 | + volumes: | |
| 1169 | + - fossil-repos:/data/repos | |
| 1170 | + - ./litestream.yml:/etc/litestream.yml:ro | |
| 1171 | + env_file: .env | |
| 1172 | + command: litestream replicate -config /etc/litestream.yml | |
| 1173 | + restart: unless-stopped" | |
| 1174 | + fi | |
| 1175 | + | |
| 1176 | + local compose_content | |
| 1177 | + compose_content="# fossilrepo -- production docker-compose | |
| 1178 | +# Generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 1179 | + | |
| 1180 | +services: | |
| 1181 | + app: | |
| 1182 | + build: | |
| 1183 | + context: ./src | |
| 1184 | + dockerfile: Dockerfile | |
| 1185 | + env_file: .env | |
| 1186 | + environment: | |
| 1187 | + DJANGO_DEBUG: \"false\" | |
| 1188 | + POSTGRES_HOST: postgres | |
| 1189 | + REDIS_URL: redis://redis:6379/1 | |
| 1190 | + CELERY_BROKER: redis://redis:6379/0 | |
| 1191 | + ports: | |
| 1192 | + - \"${OPT_PORT}:8000\" | |
| 1193 | + - \"2222:2222\" | |
| 1194 | + volumes: | |
| 1195 | + - fossil-repos:/data/repos | |
| 1196 | + - fossil-ssh:/data/ssh | |
| 1197 | + - static-files:/app/assets | |
| 1198 | + depends_on: | |
| 1199 | + postgres: | |
| 1200 | + condition: service_healthy | |
| 1201 | + redis: | |
| 1202 | + condition: service_healthy | |
| 1203 | + restart: unless-stopped | |
| 1204 | + healthcheck: | |
| 1205 | + test: [\"CMD-SHELL\", \"curl -f http://localhost:8000/health/ || exit 1\"] | |
| 1206 | + interval: 30s | |
| 1207 | + timeout: 10s | |
| 1208 | + retries: 3 | |
| 1209 | + start_period: 40s | |
| 1210 | + | |
| 1211 | + celery-worker: | |
| 1212 | + build: | |
| 1213 | + context: ./src | |
| 1214 | + dockerfile: Dockerfile | |
| 1215 | + command: celery -A config.celery worker -l info -Q celery | |
| 1216 | + env_file: .env | |
| 1217 | + environment: | |
| 1218 | + POSTGRES_HOST: postgres | |
| 1219 | + REDIS_URL: redis://redis:6379/1 | |
| 1220 | + CELERY_BROKER: redis://redis:6379/0 | |
| 1221 | + volumes: | |
| 1222 | + - fossil-repos:/data/repos | |
| 1223 | + depends_on: | |
| 1224 | + postgres: | |
| 1225 | + condition: service_healthy | |
| 1226 | + redis: | |
| 1227 | + condition: service_healthy | |
| 1228 | + restart: unless-stopped | |
| 1229 | + | |
| 1230 | + celery-beat: | |
| 1231 | + build: | |
| 1232 | + context: ./src | |
| 1233 | + dockerfile: Dockerfile | |
| 1234 | + command: celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler | |
| 1235 | + env_file: .env | |
| 1236 | + environment: | |
| 1237 | + POSTGRES_HOST: postgres | |
| 1238 | + REDIS_URL: redis://redis:6379/1 | |
| 1239 | + CELERY_BROKER: redis://redis:6379/0 | |
| 1240 | + depends_on: | |
| 1241 | + postgres: | |
| 1242 | + condition: service_healthy | |
| 1243 | + redis: | |
| 1244 | + condition: service_healthy | |
| 1245 | + restart: unless-stopped | |
| 1246 | + | |
| 1247 | + postgres: | |
| 1248 | + image: postgres:${POSTGRES_VERSION}-alpine | |
| 1249 | + environment: | |
| 1250 | + POSTGRES_DB: ${OPT_DB_NAME} | |
| 1251 | + POSTGRES_USER: ${OPT_DB_USER} | |
| 1252 | + POSTGRES_PASSWORD: ${GEN_DB_PASSWORD} | |
| 1253 | + volumes: | |
| 1254 | + - pgdata:/var/lib/postgresql/data | |
| 1255 | + healthcheck: | |
| 1256 | + test: [\"CMD-SHELL\", \"pg_isready -U ${OPT_DB_USER} -d ${OPT_DB_NAME}\"] | |
| 1257 | + interval: 5s | |
| 1258 | + timeout: 5s | |
| 1259 | + retries: 5 | |
| 1260 | + restart: unless-stopped | |
| 1261 | + | |
| 1262 | + redis: | |
| 1263 | + image: redis:${REDIS_VERSION}-alpine | |
| 1264 | + volumes: | |
| 1265 | + - redisdata:/data | |
| 1266 | + command: redis-server --appendonly yes | |
| 1267 | + healthcheck: | |
| 1268 | + test: [\"CMD\", \"redis-cli\", \"ping\"] | |
| 1269 | + interval: 5s | |
| 1270 | + timeout: 5s | |
| 1271 | + retries: 5 | |
| 1272 | + restart: unless-stopped | |
| 1273 | + | |
| 1274 | + caddy: | |
| 1275 | + image: caddy:2-alpine | |
| 1276 | + ports: | |
| 1277 | + - \"80:80\" | |
| 1278 | + - \"443:443\" | |
| 1279 | + - \"443:443/udp\" | |
| 1280 | + volumes: | |
| 1281 | + - ./Caddyfile:/etc/caddy/Caddyfile:ro | |
| 1282 | + - caddy-data:/data | |
| 1283 | + - caddy-config:/config | |
| 1284 | + - static-files:/srv/static:ro | |
| 1285 | + depends_on: | |
| 1286 | + app: | |
| 1287 | + condition: service_healthy | |
| 1288 | + restart: unless-stopped | |
| 1289 | +${litestream_service} | |
| 1290 | +volumes: | |
| 1291 | + pgdata: | |
| 1292 | + redisdata: | |
| 1293 | + fossil-repos: | |
| 1294 | + fossil-ssh: | |
| 1295 | + static-files: | |
| 1296 | + caddy-data: | |
| 1297 | + caddy-config:" | |
| 1298 | + | |
| 1299 | + write_file "${OPT_PREFIX}/docker-compose.yml" "$compose_content" | |
| 1300 | +} | |
| 1301 | + | |
| 1302 | +generate_caddyfile() { | |
| 1303 | + log_info "Generating Caddyfile..." | |
| 1304 | + | |
| 1305 | + local caddy_content | |
| 1306 | + | |
| 1307 | + if [[ "$OPT_SSL" == "true" && "$OPT_DOMAIN" != "localhost" ]]; then | |
| 1308 | + caddy_content="# fossilrepo Caddy config -- auto HTTPS | |
| 1309 | +# Generated by installer | |
| 1310 | + | |
| 1311 | +# Root domain -- Django app | |
| 1312 | +${OPT_DOMAIN} { | |
| 1313 | + encode gzip | |
| 1314 | + | |
| 1315 | + # Static files served by Caddy | |
| 1316 | + handle_path /static/* { | |
| 1317 | + root * /srv/static | |
| 1318 | + file_server | |
| 1319 | + } | |
| 1320 | + | |
| 1321 | + # Everything else to Django/gunicorn | |
| 1322 | + reverse_proxy app:8000 | |
| 1323 | +} | |
| 1324 | + | |
| 1325 | +# Wildcard subdomain routing -- repo subdomains to Django | |
| 1326 | +*.${OPT_DOMAIN} { | |
| 1327 | + tls { | |
| 1328 | + dns | |
| 1329 | + } | |
| 1330 | + | |
| 1331 | + encode gzip | |
| 1332 | + reverse_proxy app:8000 | |
| 1333 | +}" | |
| 1334 | + else | |
| 1335 | + # No SSL / localhost | |
| 1336 | + local listen_addr | |
| 1337 | + if [[ "$OPT_DOMAIN" == "localhost" || "$OPT_DOMAIN" == "127.0.0.1" ]]; then | |
| 1338 | + listen_addr=":80" | |
| 1339 | + else | |
| 1340 | + listen_addr="${OPT_DOMAIN}:80" | |
| 1341 | + fi | |
| 1342 | + | |
| 1343 | + caddy_content="# fossilrepo Caddy config -- HTTP only | |
| 1344 | +# Generated by installer | |
| 1345 | + | |
| 1346 | +{ | |
| 1347 | + auto_https off | |
| 1348 | +} | |
| 1349 | + | |
| 1350 | +${listen_addr} { | |
| 1351 | + encode gzip | |
| 1352 | + | |
| 1353 | + handle_path /static/* { | |
| 1354 | + root * /srv/static | |
| 1355 | + file_server | |
| 1356 | + } | |
| 1357 | + | |
| 1358 | + reverse_proxy app:8000 | |
| 1359 | +}" | |
| 1360 | + fi | |
| 1361 | + | |
| 1362 | + write_file "${OPT_PREFIX}/Caddyfile" "$caddy_content" | |
| 1363 | +} | |
| 1364 | + | |
| 1365 | +generate_litestream_config() { | |
| 1366 | + if [[ -z "$OPT_S3_BUCKET" ]]; then | |
| 1367 | + return 0 | |
| 1368 | + fi | |
| 1369 | + | |
| 1370 | + log_info "Generating litestream.yml..." | |
| 1371 | + | |
| 1372 | + local ls_content | |
| 1373 | + ls_content="# Litestream replication -- continuous .fossil backup to S3 | |
| 1374 | +# Generated by installer | |
| 1375 | + | |
| 1376 | +dbs: | |
| 1377 | + - path: /data/repos/*.fossil | |
| 1378 | + replicas: | |
| 1379 | + - type: s3 | |
| 1380 | + bucket: ${OPT_S3_BUCKET} | |
| 1381 | + endpoint: ${OPT_S3_ENDPOINT} | |
| 1382 | + region: ${OPT_S3_REGION:-us-east-1} | |
| 1383 | + access-key-id: \${AWS_ACCESS_KEY_ID} | |
| 1384 | + secret-access-key: \${AWS_SECRET_ACCESS_KEY}" | |
| 1385 | + | |
| 1386 | + write_file "${OPT_PREFIX}/litestream.yml" "$ls_content" | |
| 1387 | +} | |
| 1388 | + | |
| 1389 | +setup_docker_systemd() { | |
| 1390 | + log_info "Creating systemd service for auto-start..." | |
| 1391 | + | |
| 1392 | + local unit_content | |
| 1393 | + unit_content="[Unit] | |
| 1394 | +Description=fossilrepo (Docker Compose) | |
| 1395 | +Requires=docker.service | |
| 1396 | +After=docker.service | |
| 1397 | + | |
| 1398 | +[Service] | |
| 1399 | +Type=oneshot | |
| 1400 | +RemainAfterExit=yes | |
| 1401 | +WorkingDirectory=${OPT_PREFIX} | |
| 1402 | +ExecStart=/usr/bin/docker compose up -d --remove-orphans | |
| 1403 | +ExecStop=/usr/bin/docker compose down | |
| 1404 | +TimeoutStartSec=300 | |
| 1405 | + | |
| 1406 | +[Install] | |
| 1407 | +WantedBy=multi-user.target" | |
| 1408 | + | |
| 1409 | + write_file "/etc/systemd/system/fossilrepo.service" "$unit_content" | |
| 1410 | + systemctl daemon-reload | |
| 1411 | + systemctl enable fossilrepo.service | |
| 1412 | + log_ok "systemd service enabled (fossilrepo.service)" | |
| 1413 | +} | |
| 1414 | + | |
| 1415 | +install_docker() { | |
| 1416 | + log_step "Installing fossilrepo (Docker mode)" | |
| 1417 | + | |
| 1418 | + mkdir -p "${OPT_PREFIX}/src" | |
| 1419 | + | |
| 1420 | + # Clone the repo | |
| 1421 | + if [[ -d "${OPT_PREFIX}/src/.git" ]]; then | |
| 1422 | + log_info "Updating existing repo..." | |
| 1423 | + git -C "${OPT_PREFIX}/src" pull --ff-only || true | |
| 1424 | + else | |
| 1425 | + log_info "Cloning fossilrepo..." | |
| 1426 | + git clone "$REPO_URL" "${OPT_PREFIX}/src" | |
| 1427 | + fi | |
| 1428 | + | |
| 1429 | + # Generate all config files | |
| 1430 | + generate_env_file | |
| 1431 | + generate_docker_compose | |
| 1432 | + generate_caddyfile | |
| 1433 | + generate_litestream_config | |
| 1434 | + | |
| 1435 | + # Build and start | |
| 1436 | + log_info "Building Docker images (this may take a few minutes)..." | |
| 1437 | + cd "$OPT_PREFIX" | |
| 1438 | + docker compose build | |
| 1439 | + | |
| 1440 | + log_info "Starting services..." | |
| 1441 | + docker compose up -d | |
| 1442 | + | |
| 1443 | + # Wait for postgres to be healthy | |
| 1444 | + log_info "Waiting for PostgreSQL to be ready..." | |
| 1445 | + local attempts=0 | |
| 1446 | + while ! docker compose exec -T postgres pg_isready -U "$OPT_DB_USER" -d "$OPT_DB_NAME" &>/dev/null; do | |
| 1447 | + ((attempts++)) | |
| 1448 | + if ((attempts > 30)); then | |
| 1449 | + die "PostgreSQL did not become ready within 150 seconds" | |
| 1450 | + fi | |
| 1451 | + sleep 5 | |
| 1452 | + done | |
| 1453 | + log_ok "PostgreSQL is ready" | |
| 1454 | + | |
| 1455 | + # Run Django setup | |
| 1456 | + log_info "Running database migrations..." | |
| 1457 | + docker compose exec -T app python manage.py migrate --noinput | |
| 1458 | + | |
| 1459 | + log_info "Collecting static files..." | |
| 1460 | + docker compose exec -T app python manage.py collectstatic --noinput | |
| 1461 | + | |
| 1462 | + # Create admin user | |
| 1463 | + log_info "Creating admin user..." | |
| 1464 | + docker compose exec -T app python manage.py shell -c " | |
| 1465 | +from django.contrib.auth import get_user_model | |
| 1466 | +User = get_user_model() | |
| 1467 | +if not User.objects.filter(username='${OPT_ADMIN_USER}').exists(): | |
| 1468 | + user = User.objects.create_superuser( | |
| 1469 | + username='${OPT_ADMIN_USER}', | |
| 1470 | + email='${OPT_ADMIN_EMAIL}', | |
| 1471 | + password='${GEN_ADMIN_PASSWORD}', | |
| 1472 | + ) | |
| 1473 | + print(f'Admin user created: {user.username}') | |
| 1474 | +else: | |
| 1475 | + print('Admin user already exists') | |
| 1476 | +" | |
| 1477 | + | |
| 1478 | + # Create data directories inside the app container | |
| 1479 | + docker compose exec -T app mkdir -p /data/repos /data/trash /data/ssh | |
| 1480 | + | |
| 1481 | + setup_docker_systemd | |
| 1482 | + | |
| 1483 | + log_ok "Docker installation complete" | |
| 1484 | +} | |
| 1485 | + | |
| 1486 | +# ============================================================================ | |
| 1487 | +# Section 10: Bare Metal Mode | |
| 1488 | +# ============================================================================ | |
| 1489 | + | |
| 1490 | +create_system_user() { | |
| 1491 | + log_info "Creating fossilrepo system user..." | |
| 1492 | + | |
| 1493 | + if id fossilrepo &>/dev/null; then | |
| 1494 | + verbose "User fossilrepo already exists" | |
| 1495 | + else | |
| 1496 | + useradd -r -m -d /home/fossilrepo -s /bin/bash fossilrepo | |
| 1497 | + fi | |
| 1498 | + | |
| 1499 | + # Data directories | |
| 1500 | + mkdir -p "${DATA_DIR}/repos" "${DATA_DIR}/trash" "${DATA_DIR}/ssh" "${DATA_DIR}/git-mirrors" "${DATA_DIR}/ssh-keys" | |
| 1501 | + mkdir -p "$LOG_DIR" | |
| 1502 | + mkdir -p "${OPT_PREFIX}" | |
| 1503 | + chown -R fossilrepo:fossilrepo "${DATA_DIR}" | |
| 1504 | + chown -R fossilrepo:fossilrepo "$LOG_DIR" | |
| 1505 | + | |
| 1506 | + log_ok "System user and directories created" | |
| 1507 | +} | |
| 1508 | + | |
| 1509 | +clone_repo() { | |
| 1510 | + # Configure SSH for GitHub if deploy key exists | |
| 1511 | + if [[ -f /root/.ssh/deploy_key ]]; then | |
| 1512 | + export GIT_SSH_COMMAND="ssh -i /root/.ssh/deploy_key -o StrictHostKeyChecking=no" | |
| 1513 | + # Use SSH URL for private repos | |
| 1514 | + local repo_url="${REPO_URL/https:\/\/github.com\//[email protected]:}" | |
| 1515 | + else | |
| 1516 | + local repo_url="$REPO_URL" | |
| 1517 | + fi | |
| 1518 | + | |
| 1519 | + if [[ -d "${OPT_PREFIX}/.git" ]]; then | |
| 1520 | + log_info "Updating existing repo..." | |
| 1521 | + git config --global --add safe.directory "$OPT_PREFIX" 2>/dev/null || true | |
| 1522 | + git -C "$OPT_PREFIX" pull --ff-only || true | |
| 1523 | + elif [[ -d "$OPT_PREFIX" ]]; then | |
| 1524 | + log_warn "${OPT_PREFIX} exists but is not a git repo. Backing up and cloning fresh..." | |
| 1525 | + mv "$OPT_PREFIX" "${OPT_PREFIX}.bak.$(date +%s)" | |
| 1526 | + git clone "$repo_url" "$OPT_PREFIX" | |
| 1527 | + else | |
| 1528 | + log_info "Cloning fossilrepo to ${OPT_PREFIX}..." | |
| 1529 | + git clone "$repo_url" "$OPT_PREFIX" | |
| 1530 | + fi | |
| 1531 | + chown -R fossilrepo:fossilrepo "$OPT_PREFIX" | |
| 1532 | + log_ok "Repository cloned" | |
| 1533 | +} | |
| 1534 | + | |
| 1535 | +setup_python_venv() { | |
| 1536 | + log_info "Setting up Python virtual environment..." | |
| 1537 | + | |
| 1538 | + local venv_dir="${OPT_PREFIX}/.venv" | |
| 1539 | + | |
| 1540 | + # Resolve uv path (sudo resets PATH) | |
| 1541 | + local uv_bin | |
| 1542 | + uv_bin="$(command -v uv 2>/dev/null || echo /usr/local/bin/uv)" | |
| 1543 | + | |
| 1544 | + # Use uv to create venv and install deps | |
| 1545 | + if [[ -x "$uv_bin" ]]; then | |
| 1546 | + sudo -u fossilrepo "$uv_bin" venv "$venv_dir" --python "python${PYTHON_VERSION}" 2>/dev/null || \ | |
| 1547 | + sudo -u fossilrepo "$uv_bin" venv "$venv_dir" --clear 2>/dev/null || \ | |
| 1548 | + sudo -u fossilrepo "$uv_bin" venv "$venv_dir" | |
| 1549 | + sudo -u fossilrepo bash -c "cd '${OPT_PREFIX}' && source '${venv_dir}/bin/activate' && '${uv_bin}' pip install -r pyproject.toml" | |
| 1550 | + else | |
| 1551 | + sudo -u fossilrepo "python${PYTHON_VERSION}" -m venv "$venv_dir" 2>/dev/null || \ | |
| 1552 | + sudo -u fossilrepo python3 -m venv "$venv_dir" | |
| 1553 | + sudo -u fossilrepo bash -c "source '${venv_dir}/bin/activate' && pip install --upgrade pip && pip install -r '${OPT_PREFIX}/pyproject.toml'" | |
| 1554 | + fi | |
| 1555 | + | |
| 1556 | + log_ok "Python environment configured" | |
| 1557 | +} | |
| 1558 | + | |
| 1559 | +setup_postgres() { | |
| 1560 | + log_info "Configuring PostgreSQL..." | |
| 1561 | + | |
| 1562 | + # Ensure PostgreSQL service is running | |
| 1563 | + local pg_service | |
| 1564 | + pg_service="postgresql" | |
| 1565 | + if systemctl list-unit-files "postgresql-${POSTGRES_VERSION}.service" &>/dev/null; then | |
| 1566 | + pg_service="postgresql-${POSTGRES_VERSION}" | |
| 1567 | + fi | |
| 1568 | + | |
| 1569 | + # Initialize cluster if needed (RHEL) | |
| 1570 | + if [[ "$PKG_MANAGER" == "dnf" ]]; then | |
| 1571 | + local pg_setup="/usr/pgsql-${POSTGRES_VERSION}/bin/postgresql-${POSTGRES_VERSION}-setup" | |
| 1572 | + if [[ -x "$pg_setup" ]]; then | |
| 1573 | + "$pg_setup" initdb 2>/dev/null || true | |
| 1574 | + fi | |
| 1575 | + fi | |
| 1576 | + | |
| 1577 | + systemctl enable --now "$pg_service" | |
| 1578 | + | |
| 1579 | + # Wait for PostgreSQL to accept connections | |
| 1580 | + local attempts=0 | |
| 1581 | + while ! sudo -u postgres pg_isready -q 2>/dev/null; do | |
| 1582 | + ((attempts++)) | |
| 1583 | + if ((attempts > 20)); then | |
| 1584 | + die "PostgreSQL did not start within 100 seconds" | |
| 1585 | + fi | |
| 1586 | + sleep 5 | |
| 1587 | + done | |
| 1588 | + | |
| 1589 | + # Ensure peer auth for postgres user (previous runs may have broken it) | |
| 1590 | + local pg_hba_candidates=("/var/lib/pgsql/data/pg_hba.conf" "/etc/postgresql/*/main/pg_hba.conf") | |
| 1591 | + local pg_hba="" | |
| 1592 | + for f in ${pg_hba_candidates[@]}; do | |
| 1593 | + [[ -f "$f" ]] && pg_hba="$f" && break | |
| 1594 | + done | |
| 1595 | + | |
| 1596 | + if [[ -n "$pg_hba" ]]; then | |
| 1597 | + # Ensure postgres user can connect via peer (unix socket) | |
| 1598 | + if ! grep -q "^local.*all.*postgres.*peer" "$pg_hba"; then | |
| 1599 | + sed -i '1i local all postgres peer' "$pg_hba" | |
| 1600 | + systemctl reload "$pg_service" 2>/dev/null || true | |
| 1601 | + sleep 2 | |
| 1602 | + fi | |
| 1603 | + fi | |
| 1604 | + | |
| 1605 | + # Create user and database (idempotent) | |
| 1606 | + sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname = '${OPT_DB_USER}'" | \ | |
| 1607 | + grep -q 1 || \ | |
| 1608 | + sudo -u postgres psql -c "CREATE USER ${OPT_DB_USER} WITH PASSWORD '${GEN_DB_PASSWORD}';" | |
| 1609 | + | |
| 1610 | + # Update password in case it changed | |
| 1611 | + sudo -u postgres psql -c "ALTER USER ${OPT_DB_USER} WITH PASSWORD '${GEN_DB_PASSWORD}';" | |
| 1612 | + | |
| 1613 | + sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = '${OPT_DB_NAME}'" | \ | |
| 1614 | + grep -q 1 || \ | |
| 1615 | + sudo -u postgres psql -c "CREATE DATABASE ${OPT_DB_NAME} OWNER ${OPT_DB_USER};" | |
| 1616 | + | |
| 1617 | + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${OPT_DB_NAME} TO ${OPT_DB_USER};" | |
| 1618 | + sudo -u postgres psql -d "${OPT_DB_NAME}" -c "GRANT ALL ON SCHEMA public TO ${OPT_DB_USER};" | |
| 1619 | + sudo -u postgres psql -d "${OPT_DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${OPT_DB_USER};" | |
| 1620 | + | |
| 1621 | + # Fix pg_hba.conf to allow md5 auth for app connections via TCP (127.0.0.1) | |
| 1622 | + pg_hba="${pg_hba:-$(sudo -u postgres psql -t -c 'SHOW hba_file' 2>/dev/null | tr -d '[:space:]')}" | |
| 1623 | + | |
| 1624 | + if [[ -f "$pg_hba" ]]; then | |
| 1625 | + # Ensure md5 auth for 127.0.0.1 connections | |
| 1626 | + if ! grep -q "host.*${OPT_DB_NAME}.*${OPT_DB_USER}.*127.0.0.1/32.*md5" "$pg_hba" && \ | |
| 1627 | + ! grep -q "host.*${OPT_DB_NAME}.*${OPT_DB_USER}.*127.0.0.1/32.*scram-sha-256" "$pg_hba"; then | |
| 1628 | + backup_file "$pg_hba" | |
| 1629 | + # Insert before the first 'host' line to take priority | |
| 1630 | + sed -i "/^# IPv4 local connections/a host ${OPT_DB_NAME} ${OPT_DB_USER} 127.0.0.1/32 md5" "$pg_hba" 2>/dev/null || \ | |
| 1631 | + echo "host ${OPT_DB_NAME} ${OPT_DB_USER} 127.0.0.1/32 md5" >> "$pg_hba" | |
| 1632 | + systemctl reload "$pg_service" | |
| 1633 | + verbose "Added md5 auth rule to pg_hba.conf" | |
| 1634 | + fi | |
| 1635 | + fi | |
| 1636 | + | |
| 1637 | + # Verify connection works | |
| 1638 | + if ! PGPASSWORD="$GEN_DB_PASSWORD" psql -h 127.0.0.1 -U "$OPT_DB_USER" -d "$OPT_DB_NAME" -c "SELECT 1" &>/dev/null; then | |
| 1639 | + log_warn "PostgreSQL connection test failed -- you may need to manually adjust pg_hba.conf" | |
| 1640 | + else | |
| 1641 | + log_ok "PostgreSQL configured and connection verified" | |
| 1642 | + fi | |
| 1643 | +} | |
| 1644 | + | |
| 1645 | +setup_redis() { | |
| 1646 | + log_info "Configuring Redis..." | |
| 1647 | + systemctl enable --now redis-server 2>/dev/null || systemctl enable --now redis6 2>/dev/null || systemctl enable --now redis 2>/dev/null | |
| 1648 | + # Verify Redis is responding | |
| 1649 | + local attempts=0 | |
| 1650 | + local redis_cli="redis-cli" | |
| 1651 | + command_exists redis-cli || redis_cli="redis6-cli" | |
| 1652 | + while ! $redis_cli ping &>/dev/null; do | |
| 1653 | + ((attempts++)) | |
| 1654 | + if ((attempts > 10)); then | |
| 1655 | + die "Redis did not start within 50 seconds" | |
| 1656 | + fi | |
| 1657 | + sleep 5 | |
| 1658 | + done | |
| 1659 | + log_ok "Redis running" | |
| 1660 | +} | |
| 1661 | + | |
| 1662 | +generate_bare_metal_env() { | |
| 1663 | + log_info "Generating .env file..." | |
| 1664 | + | |
| 1665 | + local proto="http" | |
| 1666 | + [[ "$OPT_SSL" == "true" ]] && proto="https" | |
| 1667 | + local base_url="${proto}://${OPT_DOMAIN}" | |
| 1668 | + | |
| 1669 | + local use_s3="false" | |
| 1670 | + [[ -n "$OPT_S3_BUCKET" ]] && use_s3="true" | |
| 1671 | + | |
| 1672 | + local env_content | |
| 1673 | + env_content="# fossilrepo -- generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 1674 | +# Mode: bare-metal | |
| 1675 | + | |
| 1676 | +# --- Security --- | |
| 1677 | +DJANGO_SECRET_KEY=${GEN_SECRET_KEY} | |
| 1678 | +DJANGO_DEBUG=false | |
| 1679 | +DJANGO_ALLOWED_HOSTS=${OPT_DOMAIN},localhost,127.0.0.1 | |
| 1680 | +DJANGO_SETTINGS_MODULE=config.settings | |
| 1681 | + | |
| 1682 | +# --- Database --- | |
| 1683 | +POSTGRES_DB=${OPT_DB_NAME} | |
| 1684 | +POSTGRES_USER=${OPT_DB_USER} | |
| 1685 | +POSTGRES_PASSWORD=${GEN_DB_PASSWORD} | |
| 1686 | +POSTGRES_HOST=127.0.0.1 | |
| 1687 | +POSTGRES_PORT=5432 | |
| 1688 | + | |
| 1689 | +# --- Redis / Celery --- | |
| 1690 | +REDIS_URL=redis://127.0.0.1:6379/1 | |
| 1691 | +CELERY_BROKER=redis://127.0.0.1:6379/0 | |
| 1692 | + | |
| 1693 | +# --- Email --- | |
| 1694 | +EMAIL_HOST=localhost | |
| 1695 | +EMAIL_PORT=587 | |
| 1696 | +DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend | |
| 1697 | +FROM_EMAIL=no-reply@${OPT_DOMAIN} | |
| 1698 | + | |
| 1699 | +# --- S3 / Media --- | |
| 1700 | +USE_S3=${use_s3} | |
| 1701 | +AWS_ACCESS_KEY_ID=${OPT_S3_ACCESS_KEY} | |
| 1702 | +AWS_SECRET_ACCESS_KEY=${OPT_S3_SECRET_KEY} | |
| 1703 | +AWS_STORAGE_BUCKET_NAME=${OPT_S3_BUCKET} | |
| 1704 | +AWS_S3_ENDPOINT_URL=${OPT_S3_ENDPOINT} | |
| 1705 | + | |
| 1706 | +# --- CORS / CSRF --- | |
| 1707 | +CORS_ALLOWED_ORIGINS=${base_url} | |
| 1708 | +CSRF_TRUSTED_ORIGINS=${base_url} | |
| 1709 | + | |
| 1710 | +# --- Sentry --- | |
| 1711 | +SENTRY_DSN= | |
| 1712 | + | |
| 1713 | +# --- Litestream S3 Replication --- | |
| 1714 | +FOSSILREPO_S3_BUCKET=${OPT_S3_BUCKET} | |
| 1715 | +FOSSILREPO_S3_REGION=${OPT_S3_REGION:-us-east-1} | |
| 1716 | +FOSSILREPO_S3_ENDPOINT=${OPT_S3_ENDPOINT}" | |
| 1717 | + | |
| 1718 | + write_file "${OPT_PREFIX}/.env" "$env_content" "0600" | |
| 1719 | + chown fossilrepo:fossilrepo "${OPT_PREFIX}/.env" | |
| 1720 | +} | |
| 1721 | + | |
| 1722 | +run_django_setup() { | |
| 1723 | + log_info "Running Django setup..." | |
| 1724 | + | |
| 1725 | + local venv_activate="${OPT_PREFIX}/.venv/bin/activate" | |
| 1726 | + local env_file="${OPT_PREFIX}/.env" | |
| 1727 | + | |
| 1728 | + # Migrate | |
| 1729 | + log_info "Running database migrations..." | |
| 1730 | + sudo -u fossilrepo bash -c " | |
| 1731 | + set -a; source '${env_file}'; set +a | |
| 1732 | + source '${venv_activate}' | |
| 1733 | + cd '${OPT_PREFIX}' | |
| 1734 | + python manage.py migrate --noinput | |
| 1735 | + " | |
| 1736 | + | |
| 1737 | + # Collect static | |
| 1738 | + log_info "Collecting static files..." | |
| 1739 | + sudo -u fossilrepo bash -c " | |
| 1740 | + set -a; source '${env_file}'; set +a | |
| 1741 | + source '${venv_activate}' | |
| 1742 | + cd '${OPT_PREFIX}' | |
| 1743 | + python manage.py collectstatic --noinput | |
| 1744 | + " | |
| 1745 | + | |
| 1746 | + # Create admin user | |
| 1747 | + log_info "Creating admin user..." | |
| 1748 | + sudo -u fossilrepo bash -c " | |
| 1749 | + set -a; source '${env_file}'; set +a | |
| 1750 | + source '${venv_activate}' | |
| 1751 | + cd '${OPT_PREFIX}' | |
| 1752 | + python manage.py shell -c \" | |
| 1753 | +from django.contrib.auth import get_user_model | |
| 1754 | +User = get_user_model() | |
| 1755 | +if not User.objects.filter(username='${OPT_ADMIN_USER}').exists(): | |
| 1756 | + user = User.objects.create_superuser( | |
| 1757 | + username='${OPT_ADMIN_USER}', | |
| 1758 | + email='${OPT_ADMIN_EMAIL}', | |
| 1759 | + password='${GEN_ADMIN_PASSWORD}', | |
| 1760 | + ) | |
| 1761 | + print(f'Admin user created: {user.username}') | |
| 1762 | +else: | |
| 1763 | + print('Admin user already exists') | |
| 1764 | +\" | |
| 1765 | + " | |
| 1766 | + | |
| 1767 | + log_ok "Django setup complete" | |
| 1768 | +} | |
| 1769 | + | |
| 1770 | +setup_caddy_bare_metal() { | |
| 1771 | + log_info "Configuring Caddy..." | |
| 1772 | + | |
| 1773 | + mkdir -p /etc/caddy | |
| 1774 | + local caddy_content | |
| 1775 | + | |
| 1776 | + if [[ "$OPT_SSL" == "true" && "$OPT_DOMAIN" != "localhost" ]]; then | |
| 1777 | + caddy_content="# fossilrepo Caddy config -- auto HTTPS (bare metal) | |
| 1778 | +# Generated by installer | |
| 1779 | + | |
| 1780 | +${OPT_DOMAIN} { | |
| 1781 | + encode gzip | |
| 1782 | + | |
| 1783 | + handle_path /static/* { | |
| 1784 | + root * ${OPT_PREFIX}/assets | |
| 1785 | + file_server | |
| 1786 | + } | |
| 1787 | + | |
| 1788 | + reverse_proxy 127.0.0.1:8000 | |
| 1789 | +} | |
| 1790 | + | |
| 1791 | +" | |
| 1792 | + else | |
| 1793 | + caddy_content="# fossilrepo Caddy config -- HTTP (bare metal) | |
| 1794 | +# Generated by installer | |
| 1795 | + | |
| 1796 | +{ | |
| 1797 | + auto_https off | |
| 1798 | +} | |
| 1799 | + | |
| 1800 | +:80 { | |
| 1801 | + encode gzip | |
| 1802 | + | |
| 1803 | + handle_path /static/* { | |
| 1804 | + root * ${OPT_PREFIX}/assets | |
| 1805 | + file_server | |
| 1806 | + } | |
| 1807 | + | |
| 1808 | + reverse_proxy 127.0.0.1:8000 | |
| 1809 | +}" | |
| 1810 | + fi | |
| 1811 | + | |
| 1812 | + write_file "/etc/caddy/Caddyfile" "$caddy_content" | |
| 1813 | + | |
| 1814 | + # Caddy systemd unit | |
| 1815 | + local caddy_bin | |
| 1816 | + caddy_bin="$(command -v caddy)" | |
| 1817 | + | |
| 1818 | + local caddy_unit | |
| 1819 | + caddy_unit="[Unit] | |
| 1820 | +Description=Caddy web server (fossilrepo) | |
| 1821 | +After=network-online.target | |
| 1822 | +Wants=network-online.target | |
| 1823 | + | |
| 1824 | +[Service] | |
| 1825 | +Type=notify | |
| 1826 | +User=caddy | |
| 1827 | +Group=caddy | |
| 1828 | +ExecStart=${caddy_bin} run --config /etc/caddy/Caddyfile --adapter caddyfile | |
| 1829 | +ExecReload=${caddy_bin} reload --config /etc/caddy/Caddyfile --adapter caddyfile | |
| 1830 | +TimeoutStopSec=5s | |
| 1831 | +LimitNOFILE=1048576 | |
| 1832 | +LimitNPROC=512 | |
| 1833 | + | |
| 1834 | +[Install] | |
| 1835 | +WantedBy=multi-user.target" | |
| 1836 | + | |
| 1837 | + # Create caddy user if it doesn't exist | |
| 1838 | + if ! id caddy &>/dev/null; then | |
| 1839 | + useradd -r -m -d /var/lib/caddy -s /usr/sbin/nologin caddy | |
| 1840 | + fi | |
| 1841 | + mkdir -p /var/lib/caddy/.local/share/caddy | |
| 1842 | + chown -R caddy:caddy /var/lib/caddy | |
| 1843 | + | |
| 1844 | + write_file "/etc/systemd/system/caddy.service" "$caddy_unit" | |
| 1845 | + log_ok "Caddy configured" | |
| 1846 | +} | |
| 1847 | + | |
| 1848 | +create_systemd_services() { | |
| 1849 | + log_info "Creating systemd service units..." | |
| 1850 | + | |
| 1851 | + local venv_activate="${OPT_PREFIX}/.venv/bin/activate" | |
| 1852 | + local env_file="${OPT_PREFIX}/.env" | |
| 1853 | + | |
| 1854 | + # --- gunicorn (fossilrepo-web) --- | |
| 1855 | + local gunicorn_unit | |
| 1856 | + gunicorn_unit="[Unit] | |
| 1857 | +Description=fossilrepo web (gunicorn) | |
| 1858 | +After=network.target postgresql.service redis.service | |
| 1859 | +Requires=postgresql.service | |
| 1860 | + | |
| 1861 | +[Service] | |
| 1862 | +Type=notify | |
| 1863 | +User=fossilrepo | |
| 1864 | +Group=fossilrepo | |
| 1865 | +WorkingDirectory=${OPT_PREFIX} | |
| 1866 | +EnvironmentFile=${env_file} | |
| 1867 | +ExecStart=${OPT_PREFIX}/.venv/bin/gunicorn config.wsgi:application \\ | |
| 1868 | + --bind 127.0.0.1:8000 \\ | |
| 1869 | + --workers 3 \\ | |
| 1870 | + --timeout 120 \\ | |
| 1871 | + --access-logfile ${LOG_DIR}/gunicorn-access.log \\ | |
| 1872 | + --error-logfile ${LOG_DIR}/gunicorn-error.log | |
| 1873 | +ExecReload=/bin/kill -s HUP \$MAINPID | |
| 1874 | +Restart=on-failure | |
| 1875 | +RestartSec=10 | |
| 1876 | +KillMode=mixed | |
| 1877 | +StandardOutput=append:${LOG_DIR}/web.log | |
| 1878 | +StandardError=append:${LOG_DIR}/web.log | |
| 1879 | + | |
| 1880 | +[Install] | |
| 1881 | +WantedBy=multi-user.target" | |
| 1882 | + | |
| 1883 | + write_file "/etc/systemd/system/fossilrepo-web.service" "$gunicorn_unit" | |
| 1884 | + | |
| 1885 | + # --- celery worker --- | |
| 1886 | + local celery_worker_unit | |
| 1887 | + celery_worker_unit="[Unit] | |
| 1888 | +Description=fossilrepo Celery worker | |
| 1889 | +After=network.target postgresql.service redis.service | |
| 1890 | +Requires=redis.service | |
| 1891 | + | |
| 1892 | +[Service] | |
| 1893 | +Type=forking | |
| 1894 | +User=fossilrepo | |
| 1895 | +Group=fossilrepo | |
| 1896 | +WorkingDirectory=${OPT_PREFIX} | |
| 1897 | +EnvironmentFile=${env_file} | |
| 1898 | +ExecStart=${OPT_PREFIX}/.venv/bin/celery -A config.celery worker \\ | |
| 1899 | + -l info \\ | |
| 1900 | + -Q celery \\ | |
| 1901 | + --detach \\ | |
| 1902 | + --pidfile=${OPT_PREFIX}/celery-worker.pid \\ | |
| 1903 | + --logfile=${LOG_DIR}/celery-worker.log | |
| 1904 | +ExecStop=/bin/kill -s TERM \$(cat ${OPT_PREFIX}/celery-worker.pid) | |
| 1905 | +PIDFile=${OPT_PREFIX}/celery-worker.pid | |
| 1906 | +Restart=on-failure | |
| 1907 | +RestartSec=10 | |
| 1908 | + | |
| 1909 | +[Install] | |
| 1910 | +WantedBy=multi-user.target" | |
| 1911 | + | |
| 1912 | + write_file "/etc/systemd/system/fossilrepo-celery-worker.service" "$celery_worker_unit" | |
| 1913 | + | |
| 1914 | + # --- celery beat --- | |
| 1915 | + local celery_beat_unit | |
| 1916 | + celery_beat_unit="[Unit] | |
| 1917 | +Description=fossilrepo Celery beat scheduler | |
| 1918 | +After=network.target postgresql.service redis.service | |
| 1919 | +Requires=redis.service | |
| 1920 | + | |
| 1921 | +[Service] | |
| 1922 | +Type=forking | |
| 1923 | +User=fossilrepo | |
| 1924 | +Group=fossilrepo | |
| 1925 | +WorkingDirectory=${OPT_PREFIX} | |
| 1926 | +EnvironmentFile=${env_file} | |
| 1927 | +ExecStart=${OPT_PREFIX}/.venv/bin/celery -A config.celery beat \\ | |
| 1928 | + -l info \\ | |
| 1929 | + --scheduler django_celery_beat.schedulers:DatabaseScheduler \\ | |
| 1930 | + --detach \\ | |
| 1931 | + --pidfile=${OPT_PREFIX}/celery-beat.pid \\ | |
| 1932 | + --logfile=${LOG_DIR}/celery-beat.log | |
| 1933 | +ExecStop=/bin/kill -s TERM \$(cat ${OPT_PREFIX}/celery-beat.pid) | |
| 1934 | +PIDFile=${OPT_PREFIX}/celery-beat.pid | |
| 1935 | +Restart=on-failure | |
| 1936 | +RestartSec=10 | |
| 1937 | + | |
| 1938 | +[Install] | |
| 1939 | +WantedBy=multi-user.target" | |
| 1940 | + | |
| 1941 | + write_file "/etc/systemd/system/fossilrepo-celery-beat.service" "$celery_beat_unit" | |
| 1942 | + | |
| 1943 | + # --- litestream (optional) --- | |
| 1944 | + if [[ -n "$OPT_S3_BUCKET" ]]; then | |
| 1945 | + local litestream_unit | |
| 1946 | + litestream_unit="[Unit] | |
| 1947 | +Description=fossilrepo Litestream replication | |
| 1948 | +After=network.target | |
| 1949 | + | |
| 1950 | +[Service] | |
| 1951 | +Type=simple | |
| 1952 | +User=fossilrepo | |
| 1953 | +Group=fossilrepo | |
| 1954 | +EnvironmentFile=${env_file} | |
| 1955 | +ExecStart=/usr/local/bin/litestream replicate -config ${OPT_PREFIX}/litestream.yml | |
| 1956 | +Restart=on-failure | |
| 1957 | +RestartSec=10 | |
| 1958 | +StandardOutput=append:${LOG_DIR}/litestream.log | |
| 1959 | +StandardError=append:${LOG_DIR}/litestream.log | |
| 1960 | + | |
| 1961 | +[Install] | |
| 1962 | +WantedBy=multi-user.target" | |
| 1963 | + | |
| 1964 | + # Generate litestream config for bare metal | |
| 1965 | + local ls_content | |
| 1966 | + ls_content="# Litestream replication -- continuous .fossil backup to S3 | |
| 1967 | +# Generated by installer | |
| 1968 | + | |
| 1969 | +dbs: | |
| 1970 | + - path: ${DATA_DIR}/repos/*.fossil | |
| 1971 | + replicas: | |
| 1972 | + - type: s3 | |
| 1973 | + bucket: ${OPT_S3_BUCKET} | |
| 1974 | + endpoint: ${OPT_S3_ENDPOINT} | |
| 1975 | + region: ${OPT_S3_REGION:-us-east-1} | |
| 1976 | + access-key-id: \${AWS_ACCESS_KEY_ID} | |
| 1977 | + secret-access-key: \${AWS_SECRET_ACCESS_KEY}" | |
| 1978 | + | |
| 1979 | + write_file "${OPT_PREFIX}/litestream.yml" "$ls_content" | |
| 1980 | + chown fossilrepo:fossilrepo "${OPT_PREFIX}/litestream.yml" | |
| 1981 | + write_file "/etc/systemd/system/fossilrepo-litestream.service" "$litestream_unit" | |
| 1982 | + fi | |
| 1983 | + | |
| 1984 | + # Reload systemd and enable all services | |
| 1985 | + systemctl daemon-reload | |
| 1986 | + systemctl enable fossilrepo-web.service | |
| 1987 | + systemctl enable fossilrepo-celery-worker.service | |
| 1988 | + systemctl enable fossilrepo-celery-beat.service | |
| 1989 | + systemctl enable caddy.service | |
| 1990 | + [[ -n "$OPT_S3_BUCKET" ]] && systemctl enable fossilrepo-litestream.service | |
| 1991 | + | |
| 1992 | + # Start services | |
| 1993 | + systemctl start caddy.service | |
| 1994 | + systemctl start fossilrepo-web.service | |
| 1995 | + systemctl start fossilrepo-celery-worker.service | |
| 1996 | + systemctl start fossilrepo-celery-beat.service | |
| 1997 | + [[ -n "$OPT_S3_BUCKET" ]] && systemctl start fossilrepo-litestream.service | |
| 1998 | + | |
| 1999 | + log_ok "All systemd services created and started" | |
| 2000 | +} | |
| 2001 | + | |
| 2002 | +setup_logrotate() { | |
| 2003 | + log_info "Configuring log rotation..." | |
| 2004 | + | |
| 2005 | + local logrotate_content | |
| 2006 | + logrotate_content="${LOG_DIR}/*.log { | |
| 2007 | + daily | |
| 2008 | + missingok | |
| 2009 | + rotate 14 | |
| 2010 | + compress | |
| 2011 | + delaycompress | |
| 2012 | + notifempty | |
| 2013 | + create 0640 fossilrepo fossilrepo | |
| 2014 | + sharedscripts | |
| 2015 | + postrotate | |
| 2016 | + systemctl reload fossilrepo-web.service 2>/dev/null || true | |
| 2017 | + endscript | |
| 2018 | +}" | |
| 2019 | + | |
| 2020 | + write_file "/etc/logrotate.d/fossilrepo" "$logrotate_content" | |
| 2021 | + log_ok "Log rotation configured (14 days, compressed)" | |
| 2022 | +} | |
| 2023 | + | |
| 2024 | +install_bare_metal() { | |
| 2025 | + log_step "Installing fossilrepo (Bare Metal mode)" | |
| 2026 | + | |
| 2027 | + create_system_user | |
| 2028 | + clone_repo | |
| 2029 | + setup_python_venv | |
| 2030 | + setup_postgres | |
| 2031 | + setup_redis | |
| 2032 | + generate_bare_metal_env | |
| 2033 | + run_django_setup | |
| 2034 | + setup_caddy_bare_metal | |
| 2035 | + create_systemd_services | |
| 2036 | + setup_logrotate | |
| 2037 | + | |
| 2038 | + log_ok "Bare metal installation complete" | |
| 2039 | +} | |
| 2040 | + | |
| 2041 | +# ============================================================================ | |
| 2042 | +# Section 11: Uninstall Generator | |
| 2043 | +# ============================================================================ | |
| 2044 | + | |
| 2045 | +generate_uninstall_script() { | |
| 2046 | + log_info "Generating uninstall script..." | |
| 2047 | + | |
| 2048 | + local uninstall_content | |
| 2049 | + uninstall_content="#!/usr/bin/env bash | |
| 2050 | +# fossilrepo uninstaller | |
| 2051 | +# Generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 2052 | +# Mode: ${OPT_MODE} | |
| 2053 | + | |
| 2054 | +set -euo pipefail | |
| 2055 | + | |
| 2056 | +echo 'fossilrepo uninstaller' | |
| 2057 | +echo '======================' | |
| 2058 | +echo '' | |
| 2059 | +echo 'This will remove all fossilrepo services, files, and data.' | |
| 2060 | +echo 'PostgreSQL data and Fossil repositories will be DELETED.' | |
| 2061 | +echo '' | |
| 2062 | +read -p 'Are you sure? Type YES to confirm: ' confirm | |
| 2063 | +[[ \"\$confirm\" == \"YES\" ]] || { echo 'Aborted.'; exit 1; }" | |
| 2064 | + | |
| 2065 | + if [[ "$OPT_MODE" == "docker" ]]; then | |
| 2066 | + uninstall_content+=" | |
| 2067 | + | |
| 2068 | +echo 'Stopping Docker services...' | |
| 2069 | +cd '${OPT_PREFIX}' | |
| 2070 | +docker compose down -v 2>/dev/null || true | |
| 2071 | + | |
| 2072 | +echo 'Removing systemd service...' | |
| 2073 | +systemctl stop fossilrepo.service 2>/dev/null || true | |
| 2074 | +systemctl disable fossilrepo.service 2>/dev/null || true | |
| 2075 | +rm -f /etc/systemd/system/fossilrepo.service | |
| 2076 | +systemctl daemon-reload | |
| 2077 | + | |
| 2078 | +echo 'Removing install directory...' | |
| 2079 | +rm -rf '${OPT_PREFIX}' | |
| 2080 | + | |
| 2081 | +echo 'Done. Docker images may still be cached -- run \"docker system prune\" to reclaim space.'" | |
| 2082 | + else | |
| 2083 | + uninstall_content+=" | |
| 2084 | + | |
| 2085 | +echo 'Stopping services...' | |
| 2086 | +systemctl stop fossilrepo-web.service 2>/dev/null || true | |
| 2087 | +systemctl stop fossilrepo-celery-worker.service 2>/dev/null || true | |
| 2088 | +systemctl stop fossilrepo-celery-beat.service 2>/dev/null || true | |
| 2089 | +systemctl stop fossilrepo-litestream.service 2>/dev/null || true | |
| 2090 | +systemctl stop caddy.service 2>/dev/null || true | |
| 2091 | + | |
| 2092 | +echo 'Disabling services...' | |
| 2093 | +systemctl disable fossilrepo-web.service 2>/dev/null || true | |
| 2094 | +systemctl disable fossilrepo-celery-worker.service 2>/dev/null || true | |
| 2095 | +systemctl disable fossilrepo-celery-beat.service 2>/dev/null || true | |
| 2096 | +systemctl disable fossilrepo-litestream.service 2>/dev/null || true | |
| 2097 | + | |
| 2098 | +echo 'Removing systemd units...' | |
| 2099 | +rm -f /etc/systemd/system/fossilrepo-web.service | |
| 2100 | +rm -f /etc/systemd/system/fossilrepo-celery-worker.service | |
| 2101 | +rm -f /etc/systemd/system/fossilrepo-celery-beat.service | |
| 2102 | +rm -f /etc/systemd/system/fossilrepo-litestream.service | |
| 2103 | +rm -f /etc/systemd/system/caddy.service | |
| 2104 | +systemctl daemon-reload | |
| 2105 | + | |
| 2106 | +echo 'Removing PostgreSQL database and user...' | |
| 2107 | +sudo -u postgres psql -c \"DROP DATABASE IF EXISTS ${OPT_DB_NAME};\" 2>/dev/null || true | |
| 2108 | +sudo -u postgres psql -c \"DROP USER IF EXISTS ${OPT_DB_USER};\" 2>/dev/null || true | |
| 2109 | + | |
| 2110 | +echo 'Removing Caddy config...' | |
| 2111 | +rm -f /etc/caddy/Caddyfile | |
| 2112 | + | |
| 2113 | +echo 'Removing logrotate config...' | |
| 2114 | +rm -f /etc/logrotate.d/fossilrepo | |
| 2115 | + | |
| 2116 | +echo 'Removing data directories...' | |
| 2117 | +rm -rf '${DATA_DIR}/repos' | |
| 2118 | +rm -rf '${DATA_DIR}/trash' | |
| 2119 | +rm -rf '${DATA_DIR}/ssh' | |
| 2120 | +rm -rf '${DATA_DIR}/git-mirrors' | |
| 2121 | +rm -rf '${DATA_DIR}/ssh-keys' | |
| 2122 | +rm -rf '${LOG_DIR}' | |
| 2123 | + | |
| 2124 | +echo 'Removing install directory...' | |
| 2125 | +rm -rf '${OPT_PREFIX}' | |
| 2126 | + | |
| 2127 | +echo 'Removing system user...' | |
| 2128 | +userdel -r fossilrepo 2>/dev/null || true | |
| 2129 | + | |
| 2130 | +echo 'Done. System packages (PostgreSQL, Redis, Fossil, Caddy) were NOT removed.'" | |
| 2131 | + fi | |
| 2132 | + | |
| 2133 | + uninstall_content+=" | |
| 2134 | + | |
| 2135 | +echo '' | |
| 2136 | +echo 'fossilrepo has been uninstalled.'" | |
| 2137 | + | |
| 2138 | + write_file "${OPT_PREFIX}/uninstall.sh" "$uninstall_content" "0755" | |
| 2139 | + log_ok "Uninstall script: ${OPT_PREFIX}/uninstall.sh" | |
| 2140 | +} | |
| 2141 | + | |
| 2142 | +# ============================================================================ | |
| 2143 | +# Section 12: Post-Install Summary | |
| 2144 | +# ============================================================================ | |
| 2145 | + | |
| 2146 | +show_summary() { | |
| 2147 | + local proto="http" | |
| 2148 | + [[ "$OPT_SSL" == "true" ]] && proto="https" | |
| 2149 | + local base_url="${proto}://${OPT_DOMAIN}" | |
| 2150 | + | |
| 2151 | + if [[ "$OPT_DOMAIN" == "localhost" && "$OPT_PORT" != "80" && "$OPT_PORT" != "443" ]]; then | |
| 2152 | + base_url="${proto}://localhost:${OPT_PORT}" | |
| 2153 | + fi | |
| 2154 | + | |
| 2155 | + local box_width=64 | |
| 2156 | + local border | |
| 2157 | + border="$(printf '%*s' $box_width '' | tr ' ' '=')" | |
| 2158 | + | |
| 2159 | + printf "\n" | |
| 2160 | + printf "${_C_GREEN}${_C_BOLD}" | |
| 2161 | + printf " %s\n" "$border" | |
| 2162 | + printf " %-${box_width}s\n" " fossilrepo installation complete" | |
| 2163 | + printf " %s\n" "$border" | |
| 2164 | + printf "${_C_RESET}" | |
| 2165 | + printf "\n" | |
| 2166 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Web UI:" "${base_url}" | |
| 2167 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Django Admin:" "${base_url}/admin/" | |
| 2168 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Health Check:" "${base_url}/health/" | |
| 2169 | + printf "\n" | |
| 2170 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin username:" "$OPT_ADMIN_USER" | |
| 2171 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin email:" "$OPT_ADMIN_EMAIL" | |
| 2172 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin password:" "$GEN_ADMIN_PASSWORD" | |
| 2173 | + printf "\n" | |
| 2174 | + | |
| 2175 | + if [[ "$OPT_DOMAIN" != "localhost" ]]; then | |
| 2176 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "SSH clone:" "ssh://fossil@${OPT_DOMAIN}:2222/<repo>" | |
| 2177 | + fi | |
| 2178 | + | |
| 2179 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Install mode:" "$OPT_MODE" | |
| 2180 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Install prefix:" "$OPT_PREFIX" | |
| 2181 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Config file:" "${OPT_PREFIX}/.env" | |
| 2182 | + printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Uninstall:" "${OPT_PREFIX}/uninstall.sh" | |
| 2183 | + printf "\n" | |
| 2184 | + | |
| 2185 | + if [[ "$OPT_MODE" == "docker" ]]; then | |
| 2186 | + printf " ${_C_BOLD}Useful commands:${_C_RESET}\n" | |
| 2187 | + printf " cd %s\n" "$OPT_PREFIX" | |
| 2188 | + printf " docker compose logs -f # tail all logs\n" | |
| 2189 | + printf " docker compose logs -f app # tail app logs\n" | |
| 2190 | + printf " docker compose exec app bash # shell into app container\n" | |
| 2191 | + printf " docker compose restart # restart all services\n" | |
| 2192 | + printf " docker compose down # stop all services\n" | |
| 2193 | + else | |
| 2194 | + printf " ${_C_BOLD}Useful commands:${_C_RESET}\n" | |
| 2195 | + printf " systemctl status fossilrepo-web # check web status\n" | |
| 2196 | + printf " journalctl -u fossilrepo-web -f # tail web logs\n" | |
| 2197 | + printf " journalctl -u fossilrepo-celery-worker -f # tail worker logs\n" | |
| 2198 | + printf " systemctl restart fossilrepo-web # restart web\n" | |
| 2199 | + printf " tail -f %s/*.log # tail log files\n" "$LOG_DIR" | |
| 2200 | + fi | |
| 2201 | + | |
| 2202 | + printf "\n" | |
| 2203 | + | |
| 2204 | + if [[ -n "$OPT_S3_BUCKET" ]]; then | |
| 2205 | + printf " ${_C_BOLD}Litestream backup:${_C_RESET} s3://%s\n" "$OPT_S3_BUCKET" | |
| 2206 | + fi | |
| 2207 | + | |
| 2208 | + printf "${_C_YELLOW} IMPORTANT: Save the admin password above -- it will not be shown again.${_C_RESET}\n" | |
| 2209 | + printf "\n" | |
| 2210 | + | |
| 2211 | + # Write credentials to a restricted file for reference | |
| 2212 | + local creds_content | |
| 2213 | + creds_content="# fossilrepo credentials -- generated $(date -u +%Y-%m-%dT%H:%M:%SZ) | |
| 2214 | +# KEEP THIS FILE SECURE -- delete after recording credentials elsewhere | |
| 2215 | + | |
| 2216 | +ADMIN_USER=${OPT_ADMIN_USER} | |
| 2217 | +ADMIN_EMAIL=${OPT_ADMIN_EMAIL} | |
| 2218 | +ADMIN_PASSWORD=${GEN_ADMIN_PASSWORD} | |
| 2219 | +DB_PASSWORD=${GEN_DB_PASSWORD} | |
| 2220 | +DJANGO_SECRET_KEY=${GEN_SECRET_KEY}" | |
| 2221 | + | |
| 2222 | + write_file "${OPT_PREFIX}/.credentials" "$creds_content" "0600" | |
| 2223 | + if [[ "$OPT_MODE" == "bare-metal" ]]; then | |
| 2224 | + chown fossilrepo:fossilrepo "${OPT_PREFIX}/.credentials" | |
| 2225 | + fi | |
| 2226 | + printf " Credentials also saved to: ${_C_BOLD}%s/.credentials${_C_RESET} (mode 0600)\n" "$OPT_PREFIX" | |
| 2227 | + printf "\n" | |
| 2228 | +} | |
| 2229 | + | |
| 2230 | +# ============================================================================ | |
| 2231 | +# Section 13: Validation + Secret Generation + Main Dispatcher | |
| 2232 | +# ============================================================================ | |
| 2233 | + | |
| 2234 | +validate_options() { | |
| 2235 | + # Validate mode | |
| 2236 | + case "$OPT_MODE" in | |
| 2237 | + docker|bare-metal) ;; | |
| 2238 | + *) die "Invalid mode: '$OPT_MODE'. Must be 'docker' or 'bare-metal'." ;; | |
| 2239 | + esac | |
| 2240 | + | |
| 2241 | + # Validate port | |
| 2242 | + if ! [[ "$OPT_PORT" =~ ^[0-9]+$ ]] || ((OPT_PORT < 1 || OPT_PORT > 65535)); then | |
| 2243 | + die "Invalid port: $OPT_PORT" | |
| 2244 | + fi | |
| 2245 | + | |
| 2246 | + # Validate domain (basic check) | |
| 2247 | + if [[ -z "$OPT_DOMAIN" ]]; then | |
| 2248 | + die "Domain must not be empty" | |
| 2249 | + fi | |
| 2250 | + | |
| 2251 | + # Default admin email | |
| 2252 | + if [[ -z "$OPT_ADMIN_EMAIL" ]]; then | |
| 2253 | + OPT_ADMIN_EMAIL="${OPT_ADMIN_USER}@${OPT_DOMAIN}" | |
| 2254 | + fi | |
| 2255 | + | |
| 2256 | + # S3: if bucket is set, region should be too | |
| 2257 | + if [[ -n "$OPT_S3_BUCKET" && -z "$OPT_S3_REGION" ]]; then | |
| 2258 | + OPT_S3_REGION="us-east-1" | |
| 2259 | + fi | |
| 2260 | + | |
| 2261 | + # Warn about SSL on localhost | |
| 2262 | + if [[ "$OPT_SSL" == "true" && ( "$OPT_DOMAIN" == "localhost" || "$OPT_DOMAIN" == "127.0.0.1" ) ]]; then | |
| 2263 | + log_warn "SSL is enabled but domain is '$OPT_DOMAIN' -- Let's Encrypt will not work. Disabling SSL." | |
| 2264 | + OPT_SSL="false" | |
| 2265 | + fi | |
| 2266 | +} | |
| 2267 | + | |
| 2268 | +auto_generate_secrets() { | |
| 2269 | + # Generate any secrets not provided by the user | |
| 2270 | + GEN_SECRET_KEY="$(generate_secret_key)" | |
| 2271 | + verbose "Generated Django secret key" | |
| 2272 | + | |
| 2273 | + if [[ -n "$OPT_DB_PASSWORD" ]]; then | |
| 2274 | + GEN_DB_PASSWORD="$OPT_DB_PASSWORD" | |
| 2275 | + else | |
| 2276 | + GEN_DB_PASSWORD="$(generate_password 32)" | |
| 2277 | + verbose "Generated database password" | |
| 2278 | + fi | |
| 2279 | + | |
| 2280 | + if [[ -n "$OPT_ADMIN_PASSWORD" ]]; then | |
| 2281 | + GEN_ADMIN_PASSWORD="$OPT_ADMIN_PASSWORD" | |
| 2282 | + else | |
| 2283 | + GEN_ADMIN_PASSWORD="$(generate_password 24)" | |
| 2284 | + verbose "Generated admin password" | |
| 2285 | + fi | |
| 2286 | +} | |
| 2287 | + | |
| 2288 | +show_config_summary() { | |
| 2289 | + printf "\n" | |
| 2290 | + log_step "Configuration" | |
| 2291 | + printf " %-24s %s\n" "Mode:" "$OPT_MODE" | |
| 2292 | + printf " %-24s %s\n" "Domain:" "$OPT_DOMAIN" | |
| 2293 | + printf " %-24s %s\n" "SSL:" "$OPT_SSL" | |
| 2294 | + printf " %-24s %s\n" "Port:" "$OPT_PORT" | |
| 2295 | + printf " %-24s %s\n" "Install prefix:" "$OPT_PREFIX" | |
| 2296 | + printf " %-24s %s\n" "Database:" "${OPT_DB_NAME} (user: ${OPT_DB_USER})" | |
| 2297 | + printf " %-24s %s\n" "Admin:" "${OPT_ADMIN_USER} <${OPT_ADMIN_EMAIL}>" | |
| 2298 | + if [[ -n "$OPT_S3_BUCKET" ]]; then | |
| 2299 | + printf " %-24s %s (region: %s)\n" "S3 backup:" "$OPT_S3_BUCKET" "${OPT_S3_REGION:-us-east-1}" | |
| 2300 | + else | |
| 2301 | + printf " %-24s %s\n" "S3 backup:" "disabled" | |
| 2302 | + fi | |
| 2303 | + printf "\n" | |
| 2304 | +} | |
| 2305 | + | |
| 2306 | +main() { | |
| 2307 | + _color_init | |
| 2308 | + parse_args "$@" | |
| 2309 | + require_root | |
| 2310 | + detect_os | |
| 2311 | + | |
| 2312 | + # Run interactive TUI if mode is not set | |
| 2313 | + if [[ -z "$OPT_MODE" ]]; then | |
| 2314 | + run_interactive | |
| 2315 | + fi | |
| 2316 | + | |
| 2317 | + validate_options | |
| 2318 | + auto_generate_secrets | |
| 2319 | + show_config_summary | |
| 2320 | + confirm "Begin installation?" | |
| 2321 | + check_and_install_deps | |
| 2322 | + | |
| 2323 | + if [[ "$OPT_MODE" == "docker" ]]; then | |
| 2324 | + install_docker | |
| 2325 | + else | |
| 2326 | + install_bare_metal | |
| 2327 | + fi | |
| 2328 | + | |
| 2329 | + generate_uninstall_script | |
| 2330 | + show_summary | |
| 2331 | +} | |
| 2332 | + | |
| 2333 | +main "$@" |
| --- a/install.sh | |
| +++ b/install.sh | |
| @@ -0,0 +1,2333 @@ | |
| --- a/install.sh | |
| +++ b/install.sh | |
| @@ -0,0 +1,2333 @@ | |
| 1 | #!/usr/bin/env bash |
| 2 | # ============================================================================ |
| 3 | # fossilrepo -- Omnibus Installer |
| 4 | # |
| 5 | # Installs fossilrepo on any Linux box. Supports two modes: |
| 6 | # - Docker: full stack via docker compose (recommended) |
| 7 | # - Bare Metal: native install with systemd services |
| 8 | # |
| 9 | # Usage: |
| 10 | # curl -sSL https://get.fossilrepo.dev | bash |
| 11 | # -- or -- |
| 12 | # ./install.sh --docker --domain fossil.example.com --ssl |
| 13 | # |
| 14 | # https://github.com/ConflictHQ/fossilrepo |
| 15 | # MIT License |
| 16 | # ============================================================================ |
| 17 | |
| 18 | set -euo pipefail |
| 19 | |
| 20 | # Ensure HOME is set (SSM/cloud-init may not set it) |
| 21 | export HOME="${HOME:-/root}" |
| 22 | |
| 23 | # ============================================================================ |
| 24 | # Section 1: Constants + Version Pins |
| 25 | # ============================================================================ |
| 26 | |
| 27 | readonly INSTALLER_VERSION="1.0.0" |
| 28 | readonly FOSSILREPO_VERSION="0.1.0" |
| 29 | |
| 30 | readonly FOSSIL_VERSION="2.24" |
| 31 | readonly LITESTREAM_VERSION="0.3.13" |
| 32 | readonly CADDY_VERSION="2.9" |
| 33 | readonly PYTHON_VERSION="3.12" |
| 34 | readonly POSTGRES_VERSION="16" |
| 35 | readonly REDIS_VERSION="7" |
| 36 | |
| 37 | readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git" |
| 38 | readonly DEFAULT_PREFIX="/opt/fossilrepo" |
| 39 | readonly DATA_DIR="/data" |
| 40 | readonly LOG_DIR="/var/log/fossilrepo" |
| 41 | readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download" |
| 42 | readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download" |
| 43 | |
| 44 | # Globals -- set by arg parser, interactive TUI, or defaults |
| 45 | OPT_MODE="" # docker | bare-metal |
| 46 | OPT_DOMAIN="localhost" |
| 47 | OPT_SSL="false" |
| 48 | OPT_PREFIX="$DEFAULT_PREFIX" |
| 49 | OPT_PORT="8000" |
| 50 | OPT_DB_NAME="fossilrepo" |
| 51 | OPT_DB_USER="dbadmin" |
| 52 | OPT_DB_PASSWORD="" |
| 53 | OPT_ADMIN_USER="admin" |
| 54 | OPT_ADMIN_EMAIL="" |
| 55 | OPT_ADMIN_PASSWORD="" |
| 56 | OPT_S3_BUCKET="" |
| 57 | OPT_S3_REGION="" |
| 58 | OPT_S3_ENDPOINT="" |
| 59 | OPT_S3_ACCESS_KEY="" |
| 60 | OPT_S3_SECRET_KEY="" |
| 61 | OPT_CONFIG_FILE="" |
| 62 | OPT_YES="false" |
| 63 | OPT_VERBOSE="false" |
| 64 | |
| 65 | # Detected at runtime |
| 66 | OS_ID="" |
| 67 | OS_VERSION="" |
| 68 | OS_ARCH="" |
| 69 | PKG_MANAGER="" |
| 70 | |
| 71 | # Generated secrets |
| 72 | GEN_SECRET_KEY="" |
| 73 | GEN_DB_PASSWORD="" |
| 74 | GEN_ADMIN_PASSWORD="" |
| 75 | |
| 76 | # Color codes -- set by _color_init |
| 77 | _C_RESET="" |
| 78 | _C_RED="" |
| 79 | _C_GREEN="" |
| 80 | _C_YELLOW="" |
| 81 | _C_BLUE="" |
| 82 | _C_CYAN="" |
| 83 | _C_BOLD="" |
| 84 | |
| 85 | # ============================================================================ |
| 86 | # Section 2: Logging |
| 87 | # ============================================================================ |
| 88 | |
| 89 | _supports_color() { |
| 90 | # Check if stdout is a terminal and TERM is not dumb |
| 91 | [[ -t 1 ]] && [[ "${TERM:-dumb}" != "dumb" ]] && return 0 |
| 92 | # Also support NO_COLOR convention |
| 93 | [[ -z "${NO_COLOR:-}" ]] || return 1 |
| 94 | return 1 |
| 95 | } |
| 96 | |
| 97 | _color_init() { |
| 98 | if _supports_color; then |
| 99 | _C_RESET='\033[0m' |
| 100 | _C_RED='\033[0;31m' |
| 101 | _C_GREEN='\033[0;32m' |
| 102 | _C_YELLOW='\033[0;33m' |
| 103 | _C_BLUE='\033[0;34m' |
| 104 | _C_CYAN='\033[0;36m' |
| 105 | _C_BOLD='\033[1m' |
| 106 | fi |
| 107 | } |
| 108 | |
| 109 | log_info() { printf "${_C_BLUE}[INFO]${_C_RESET} %s\n" "$*"; } |
| 110 | log_ok() { printf "${_C_GREEN}[ OK]${_C_RESET} %s\n" "$*"; } |
| 111 | log_warn() { printf "${_C_YELLOW}[WARN]${_C_RESET} %s\n" "$*" >&2; } |
| 112 | log_error() { printf "${_C_RED}[ ERR]${_C_RESET} %s\n" "$*" >&2; } |
| 113 | log_step() { printf "\n${_C_CYAN}${_C_BOLD}==> %s${_C_RESET}\n" "$*"; } |
| 114 | |
| 115 | die() { |
| 116 | log_error "$@" |
| 117 | exit 1 |
| 118 | } |
| 119 | |
| 120 | verbose() { |
| 121 | [[ "$OPT_VERBOSE" == "true" ]] && log_info "$@" |
| 122 | return 0 |
| 123 | } |
| 124 | |
| 125 | # ============================================================================ |
| 126 | # Section 3: Utilities |
| 127 | # ============================================================================ |
| 128 | |
| 129 | generate_password() { |
| 130 | # 32-char alphanumeric password -- no special chars to avoid escaping issues |
| 131 | local length="${1:-32}" |
| 132 | if command -v openssl &>/dev/null; then |
| 133 | openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c "$length" |
| 134 | elif [[ -r /dev/urandom ]]; then |
| 135 | tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$length" |
| 136 | else |
| 137 | die "Cannot generate random password: no openssl or /dev/urandom" |
| 138 | fi |
| 139 | } |
| 140 | |
| 141 | generate_secret_key() { |
| 142 | # 50-char Django secret key |
| 143 | if command -v openssl &>/dev/null; then |
| 144 | openssl rand -base64 72 | tr -dc 'a-zA-Z0-9!@#$%^&*(-_=+)' | head -c 50 |
| 145 | elif [[ -r /dev/urandom ]]; then |
| 146 | tr -dc 'a-zA-Z0-9!@#$%^&*(-_=+)' < /dev/urandom | head -c 50 |
| 147 | else |
| 148 | die "Cannot generate secret key: no openssl or /dev/urandom" |
| 149 | fi |
| 150 | } |
| 151 | |
| 152 | command_exists() { |
| 153 | command -v "$1" &>/dev/null |
| 154 | } |
| 155 | |
| 156 | version_gte() { |
| 157 | # Returns 0 if $1 >= $2 (dot-separated version comparison) |
| 158 | local IFS=. |
| 159 | local i ver1=($1) ver2=($2) |
| 160 | for ((i = 0; i < ${#ver2[@]}; i++)); do |
| 161 | local v1="${ver1[i]:-0}" |
| 162 | local v2="${ver2[i]:-0}" |
| 163 | if ((v1 > v2)); then return 0; fi |
| 164 | if ((v1 < v2)); then return 1; fi |
| 165 | done |
| 166 | return 0 |
| 167 | } |
| 168 | |
| 169 | require_root() { |
| 170 | if [[ $EUID -ne 0 ]]; then |
| 171 | die "This installer must be run as root. Use: sudo ./install.sh" |
| 172 | fi |
| 173 | } |
| 174 | |
| 175 | confirm() { |
| 176 | if [[ "$OPT_YES" == "true" ]]; then |
| 177 | return 0 |
| 178 | fi |
| 179 | local prompt="${1:-Continue?}" |
| 180 | local reply |
| 181 | printf "${_C_BOLD}%s [y/N] ${_C_RESET}" "$prompt" |
| 182 | read -r reply |
| 183 | case "$reply" in |
| 184 | [yY][eE][sS]|[yY]) return 0 ;; |
| 185 | *) die "Aborted by user." ;; |
| 186 | esac |
| 187 | } |
| 188 | |
| 189 | backup_file() { |
| 190 | local file="$1" |
| 191 | if [[ -f "$file" ]]; then |
| 192 | local backup="${file}.bak.$(date +%Y%m%d%H%M%S)" |
| 193 | cp "$file" "$backup" |
| 194 | verbose "Backed up $file -> $backup" |
| 195 | fi |
| 196 | } |
| 197 | |
| 198 | write_file() { |
| 199 | # Write content to a file, creating parent dirs and backing up existing files. |
| 200 | # Usage: write_file <path> <content> [mode] |
| 201 | local path="$1" |
| 202 | local content="$2" |
| 203 | local mode="${3:-0644}" |
| 204 | |
| 205 | mkdir -p "$(dirname "$path")" |
| 206 | backup_file "$path" |
| 207 | printf '%s\n' "$content" > "$path" |
| 208 | chmod "$mode" "$path" |
| 209 | verbose "Wrote $path (mode $mode)" |
| 210 | } |
| 211 | |
| 212 | retry_command() { |
| 213 | # Retry a command up to N times with a delay between attempts |
| 214 | local max_attempts="${1:-3}" |
| 215 | local delay="${2:-5}" |
| 216 | shift 2 |
| 217 | local attempt=1 |
| 218 | while [[ $attempt -le $max_attempts ]]; do |
| 219 | if "$@"; then |
| 220 | return 0 |
| 221 | fi |
| 222 | log_warn "Command failed (attempt $attempt/$max_attempts): $*" |
| 223 | ((attempt++)) |
| 224 | sleep "$delay" |
| 225 | done |
| 226 | return 1 |
| 227 | } |
| 228 | |
| 229 | # ============================================================================ |
| 230 | # Section 4: OS Detection |
| 231 | # ============================================================================ |
| 232 | |
| 233 | detect_os() { |
| 234 | log_step "Detecting operating system" |
| 235 | |
| 236 | if [[ ! -f /etc/os-release ]]; then |
| 237 | die "Cannot detect OS: /etc/os-release not found. This installer requires a modern Linux distribution." |
| 238 | fi |
| 239 | |
| 240 | # shellcheck source=/dev/null |
| 241 | . /etc/os-release |
| 242 | |
| 243 | OS_ID="${ID:-unknown}" |
| 244 | OS_VERSION="${VERSION_ID:-0}" |
| 245 | |
| 246 | case "$OS_ID" in |
| 247 | debian) |
| 248 | PKG_MANAGER="apt" |
| 249 | ;; |
| 250 | ubuntu) |
| 251 | PKG_MANAGER="apt" |
| 252 | ;; |
| 253 | rhel|centos|rocky|almalinux) |
| 254 | OS_ID="rhel" |
| 255 | PKG_MANAGER="dnf" |
| 256 | ;; |
| 257 | amzn) |
| 258 | PKG_MANAGER="dnf" |
| 259 | ;; |
| 260 | fedora) |
| 261 | OS_ID="rhel" |
| 262 | PKG_MANAGER="dnf" |
| 263 | ;; |
| 264 | alpine) |
| 265 | PKG_MANAGER="apk" |
| 266 | log_warn "Alpine Linux detected. Only Docker mode is supported on Alpine." |
| 267 | if [[ "$OPT_MODE" == "bare-metal" ]]; then |
| 268 | die "Bare metal installation is not supported on Alpine Linux." |
| 269 | fi |
| 270 | OPT_MODE="docker" |
| 271 | ;; |
| 272 | *) |
| 273 | die "Unsupported OS: $OS_ID. Supported: debian, ubuntu, rhel/centos/rocky/alma, fedora, amzn, alpine." |
| 274 | ;; |
| 275 | esac |
| 276 | |
| 277 | # Detect architecture |
| 278 | local machine |
| 279 | machine="$(uname -m)" |
| 280 | case "$machine" in |
| 281 | x86_64|amd64) OS_ARCH="amd64" ;; |
| 282 | aarch64|arm64) OS_ARCH="arm64" ;; |
| 283 | *) die "Unsupported architecture: $machine. Supported: amd64, arm64." ;; |
| 284 | esac |
| 285 | |
| 286 | log_ok "OS: $OS_ID $OS_VERSION ($OS_ARCH), package manager: $PKG_MANAGER" |
| 287 | } |
| 288 | |
| 289 | # ============================================================================ |
| 290 | # Section 5: YAML Config Parser |
| 291 | # ============================================================================ |
| 292 | |
| 293 | parse_config_file() { |
| 294 | local config_file="$1" |
| 295 | |
| 296 | if [[ ! -f "$config_file" ]]; then |
| 297 | die "Config file not found: $config_file" |
| 298 | fi |
| 299 | |
| 300 | log_info "Loading config from $config_file" |
| 301 | |
| 302 | local current_section="" |
| 303 | local line key value |
| 304 | |
| 305 | while IFS= read -r line || [[ -n "$line" ]]; do |
| 306 | # Strip comments and trailing whitespace |
| 307 | line="${line%%#*}" |
| 308 | line="${line%"${line##*[![:space:]]}"}" |
| 309 | |
| 310 | # Skip empty lines |
| 311 | [[ -z "$line" ]] && continue |
| 312 | |
| 313 | # Detect section (key followed by colon, no value, next lines indented) |
| 314 | if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*$ ]]; then |
| 315 | current_section="${BASH_REMATCH[1]}" |
| 316 | continue |
| 317 | fi |
| 318 | |
| 319 | # Indented key:value under a section |
| 320 | if [[ "$line" =~ ^[[:space:]]+([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*(.+)$ ]]; then |
| 321 | key="${BASH_REMATCH[1]}" |
| 322 | value="${BASH_REMATCH[2]}" |
| 323 | # Strip quotes |
| 324 | value="${value%\"}" |
| 325 | value="${value#\"}" |
| 326 | value="${value%\'}" |
| 327 | value="${value#\'}" |
| 328 | _set_config_value "${current_section}_${key}" "$value" |
| 329 | continue |
| 330 | fi |
| 331 | |
| 332 | # Top-level key: value |
| 333 | if [[ "$line" =~ ^([a-zA-Z_][a-zA-Z0-9_-]*):[[:space:]]*(.+)$ ]]; then |
| 334 | current_section="" |
| 335 | key="${BASH_REMATCH[1]}" |
| 336 | value="${BASH_REMATCH[2]}" |
| 337 | value="${value%\"}" |
| 338 | value="${value#\"}" |
| 339 | value="${value%\'}" |
| 340 | value="${value#\'}" |
| 341 | _set_config_value "$key" "$value" |
| 342 | continue |
| 343 | fi |
| 344 | done < "$config_file" |
| 345 | } |
| 346 | |
| 347 | _set_config_value() { |
| 348 | local key="$1" |
| 349 | local value="$2" |
| 350 | |
| 351 | # Normalize key: lowercase, dashes to underscores |
| 352 | key="${key,,}" |
| 353 | key="${key//-/_}" |
| 354 | |
| 355 | case "$key" in |
| 356 | mode) OPT_MODE="$value" ;; |
| 357 | domain) OPT_DOMAIN="$value" ;; |
| 358 | ssl) OPT_SSL="$value" ;; |
| 359 | prefix) OPT_PREFIX="$value" ;; |
| 360 | port) OPT_PORT="$value" ;; |
| 361 | db_name) OPT_DB_NAME="$value" ;; |
| 362 | db_user) OPT_DB_USER="$value" ;; |
| 363 | db_password) OPT_DB_PASSWORD="$value" ;; |
| 364 | admin_user) OPT_ADMIN_USER="$value" ;; |
| 365 | admin_email) OPT_ADMIN_EMAIL="$value" ;; |
| 366 | admin_password) OPT_ADMIN_PASSWORD="$value" ;; |
| 367 | s3_bucket) OPT_S3_BUCKET="$value" ;; |
| 368 | s3_region) OPT_S3_REGION="$value" ;; |
| 369 | s3_endpoint) OPT_S3_ENDPOINT="$value" ;; |
| 370 | s3_access_key) OPT_S3_ACCESS_KEY="$value" ;; |
| 371 | s3_secret_key) OPT_S3_SECRET_KEY="$value" ;; |
| 372 | *) verbose "Ignoring unknown config key: $key" ;; |
| 373 | esac |
| 374 | } |
| 375 | |
| 376 | # ============================================================================ |
| 377 | # Section 6: Arg Parser |
| 378 | # ============================================================================ |
| 379 | |
| 380 | show_help() { |
| 381 | cat <<'HELPTEXT' |
| 382 | fossilrepo installer -- deploy a full Fossil forge in one command. |
| 383 | |
| 384 | USAGE |
| 385 | install.sh [OPTIONS] |
| 386 | install.sh --docker --domain fossil.example.com --ssl |
| 387 | install.sh --bare-metal --domain fossil.example.com --config fossilrepo.yml |
| 388 | install.sh # interactive mode |
| 389 | |
| 390 | INSTALLATION MODE |
| 391 | --docker Docker Compose deployment (recommended) |
| 392 | --bare-metal Native install with systemd services |
| 393 | |
| 394 | NETWORK |
| 395 | --domain <fqdn> Domain name (default: localhost) |
| 396 | --ssl Enable automatic HTTPS via Caddy/Let's Encrypt |
| 397 | --port <port> Application port (default: 8000) |
| 398 | |
| 399 | DATABASE |
| 400 | --db-password <pass> PostgreSQL password (auto-generated if omitted) |
| 401 | |
| 402 | ADMIN ACCOUNT |
| 403 | --admin-user <name> Admin username (default: admin) |
| 404 | --admin-email <email> Admin email address |
| 405 | --admin-password <pass> Admin password (auto-generated if omitted) |
| 406 | |
| 407 | S3 BACKUP (LITESTREAM) |
| 408 | --s3-bucket <name> S3 bucket for Litestream replication |
| 409 | --s3-region <region> AWS region (default: us-east-1) |
| 410 | --s3-endpoint <url> S3-compatible endpoint (for MinIO etc.) |
| 411 | --s3-access-key <key> AWS access key ID |
| 412 | --s3-secret-key <key> AWS secret access key |
| 413 | |
| 414 | GENERAL |
| 415 | --prefix <path> Install prefix (default: /opt/fossilrepo) |
| 416 | --config <file> Load options from YAML config file |
| 417 | --yes Skip all confirmation prompts |
| 418 | --verbose Enable verbose output |
| 419 | -h, --help Show this help and exit |
| 420 | --version Show version and exit |
| 421 | |
| 422 | EXAMPLES |
| 423 | # Interactive guided install |
| 424 | sudo ./install.sh |
| 425 | |
| 426 | # Docker with auto-SSL |
| 427 | sudo ./install.sh --docker --domain fossil.example.com --ssl --yes |
| 428 | |
| 429 | # Bare metal with config file |
| 430 | sudo ./install.sh --bare-metal --config /etc/fossilrepo/install.yml |
| 431 | |
| 432 | # Docker on localhost for testing |
| 433 | sudo ./install.sh --docker --yes |
| 434 | HELPTEXT |
| 435 | } |
| 436 | |
| 437 | parse_args() { |
| 438 | while [[ $# -gt 0 ]]; do |
| 439 | case "$1" in |
| 440 | --docker) |
| 441 | OPT_MODE="docker" |
| 442 | shift |
| 443 | ;; |
| 444 | --bare-metal) |
| 445 | OPT_MODE="bare-metal" |
| 446 | shift |
| 447 | ;; |
| 448 | --domain) |
| 449 | [[ -z "${2:-}" ]] && die "--domain requires a value" |
| 450 | OPT_DOMAIN="$2" |
| 451 | shift 2 |
| 452 | ;; |
| 453 | --ssl) |
| 454 | OPT_SSL="true" |
| 455 | shift |
| 456 | ;; |
| 457 | --prefix) |
| 458 | [[ -z "${2:-}" ]] && die "--prefix requires a value" |
| 459 | OPT_PREFIX="$2" |
| 460 | shift 2 |
| 461 | ;; |
| 462 | --port) |
| 463 | [[ -z "${2:-}" ]] && die "--port requires a value" |
| 464 | OPT_PORT="$2" |
| 465 | shift 2 |
| 466 | ;; |
| 467 | --db-password) |
| 468 | [[ -z "${2:-}" ]] && die "--db-password requires a value" |
| 469 | OPT_DB_PASSWORD="$2" |
| 470 | shift 2 |
| 471 | ;; |
| 472 | --admin-user) |
| 473 | [[ -z "${2:-}" ]] && die "--admin-user requires a value" |
| 474 | OPT_ADMIN_USER="$2" |
| 475 | shift 2 |
| 476 | ;; |
| 477 | --admin-email) |
| 478 | [[ -z "${2:-}" ]] && die "--admin-email requires a value" |
| 479 | OPT_ADMIN_EMAIL="$2" |
| 480 | shift 2 |
| 481 | ;; |
| 482 | --admin-password) |
| 483 | [[ -z "${2:-}" ]] && die "--admin-password requires a value" |
| 484 | OPT_ADMIN_PASSWORD="$2" |
| 485 | shift 2 |
| 486 | ;; |
| 487 | --s3-bucket) |
| 488 | [[ -z "${2:-}" ]] && die "--s3-bucket requires a value" |
| 489 | OPT_S3_BUCKET="$2" |
| 490 | shift 2 |
| 491 | ;; |
| 492 | --s3-region) |
| 493 | [[ -z "${2:-}" ]] && die "--s3-region requires a value" |
| 494 | OPT_S3_REGION="$2" |
| 495 | shift 2 |
| 496 | ;; |
| 497 | --s3-endpoint) |
| 498 | [[ -z "${2:-}" ]] && die "--s3-endpoint requires a value" |
| 499 | OPT_S3_ENDPOINT="$2" |
| 500 | shift 2 |
| 501 | ;; |
| 502 | --s3-access-key) |
| 503 | [[ -z "${2:-}" ]] && die "--s3-access-key requires a value" |
| 504 | OPT_S3_ACCESS_KEY="$2" |
| 505 | shift 2 |
| 506 | ;; |
| 507 | --s3-secret-key) |
| 508 | [[ -z "${2:-}" ]] && die "--s3-secret-key requires a value" |
| 509 | OPT_S3_SECRET_KEY="$2" |
| 510 | shift 2 |
| 511 | ;; |
| 512 | --config) |
| 513 | [[ -z "${2:-}" ]] && die "--config requires a value" |
| 514 | OPT_CONFIG_FILE="$2" |
| 515 | shift 2 |
| 516 | ;; |
| 517 | --yes|-y) |
| 518 | OPT_YES="true" |
| 519 | shift |
| 520 | ;; |
| 521 | --verbose|-v) |
| 522 | OPT_VERBOSE="true" |
| 523 | shift |
| 524 | ;; |
| 525 | -h|--help) |
| 526 | show_help |
| 527 | exit 0 |
| 528 | ;; |
| 529 | --version) |
| 530 | echo "fossilrepo installer v${INSTALLER_VERSION} (fossilrepo v${FOSSILREPO_VERSION})" |
| 531 | exit 0 |
| 532 | ;; |
| 533 | *) |
| 534 | die "Unknown option: $1 (use --help for usage)" |
| 535 | ;; |
| 536 | esac |
| 537 | done |
| 538 | |
| 539 | # Load config file if specified (CLI args take precedence -- already set above) |
| 540 | if [[ -n "$OPT_CONFIG_FILE" ]]; then |
| 541 | # Save CLI-set values |
| 542 | local saved_mode="$OPT_MODE" |
| 543 | local saved_domain="$OPT_DOMAIN" |
| 544 | local saved_ssl="$OPT_SSL" |
| 545 | local saved_prefix="$OPT_PREFIX" |
| 546 | local saved_port="$OPT_PORT" |
| 547 | local saved_db_password="$OPT_DB_PASSWORD" |
| 548 | local saved_admin_user="$OPT_ADMIN_USER" |
| 549 | local saved_admin_email="$OPT_ADMIN_EMAIL" |
| 550 | local saved_admin_password="$OPT_ADMIN_PASSWORD" |
| 551 | local saved_s3_bucket="$OPT_S3_BUCKET" |
| 552 | local saved_s3_region="$OPT_S3_REGION" |
| 553 | |
| 554 | parse_config_file "$OPT_CONFIG_FILE" |
| 555 | |
| 556 | # Restore CLI overrides (non-default values take precedence) |
| 557 | [[ -n "$saved_mode" ]] && OPT_MODE="$saved_mode" |
| 558 | [[ "$saved_domain" != "localhost" ]] && OPT_DOMAIN="$saved_domain" |
| 559 | [[ "$saved_ssl" == "true" ]] && OPT_SSL="$saved_ssl" |
| 560 | [[ "$saved_prefix" != "$DEFAULT_PREFIX" ]] && OPT_PREFIX="$saved_prefix" |
| 561 | [[ "$saved_port" != "8000" ]] && OPT_PORT="$saved_port" |
| 562 | [[ -n "$saved_db_password" ]] && OPT_DB_PASSWORD="$saved_db_password" |
| 563 | [[ "$saved_admin_user" != "admin" ]] && OPT_ADMIN_USER="$saved_admin_user" |
| 564 | [[ -n "$saved_admin_email" ]] && OPT_ADMIN_EMAIL="$saved_admin_email" |
| 565 | [[ -n "$saved_admin_password" ]] && OPT_ADMIN_PASSWORD="$saved_admin_password" |
| 566 | [[ -n "$saved_s3_bucket" ]] && OPT_S3_BUCKET="$saved_s3_bucket" |
| 567 | [[ -n "$saved_s3_region" ]] && OPT_S3_REGION="$saved_s3_region" |
| 568 | fi |
| 569 | } |
| 570 | |
| 571 | # ============================================================================ |
| 572 | # Section 7: Interactive TUI |
| 573 | # ============================================================================ |
| 574 | |
| 575 | _print_banner() { |
| 576 | printf "\n" |
| 577 | printf "${_C_CYAN}${_C_BOLD}" |
| 578 | cat <<'BANNER' |
| 579 | __ _ __ |
| 580 | / _|___ ___ ___ (_) |_ __ ___ _ __ ___ |
| 581 | | |_/ _ \/ __/ __|| | | '__/ _ \ '_ \ / _ \ |
| 582 | | _| (_) \__ \__ \| | | | | __/ |_) | (_) | |
| 583 | |_| \___/|___/___/|_|_|_| \___| .__/ \___/ |
| 584 | |_| |
| 585 | BANNER |
| 586 | printf "${_C_RESET}" |
| 587 | printf " ${_C_BOLD}Omnibus Installer v${INSTALLER_VERSION}${_C_RESET}\n" |
| 588 | printf " Self-hosted Fossil forge -- one command, full stack.\n\n" |
| 589 | } |
| 590 | |
| 591 | _prompt() { |
| 592 | # Usage: _prompt "Prompt text" "default" VARNAME |
| 593 | local prompt="$1" |
| 594 | local default="$2" |
| 595 | local varname="$3" |
| 596 | local reply |
| 597 | |
| 598 | if [[ -n "$default" ]]; then |
| 599 | printf " ${_C_BOLD}%s${_C_RESET} [%s]: " "$prompt" "$default" |
| 600 | else |
| 601 | printf " ${_C_BOLD}%s${_C_RESET}: " "$prompt" |
| 602 | fi |
| 603 | read -r reply |
| 604 | reply="${reply:-$default}" |
| 605 | eval "$varname=\"\$reply\"" |
| 606 | } |
| 607 | |
| 608 | _prompt_password() { |
| 609 | local prompt="$1" |
| 610 | local varname="$2" |
| 611 | local reply |
| 612 | |
| 613 | printf " ${_C_BOLD}%s${_C_RESET} (leave blank to auto-generate): " "$prompt" |
| 614 | read -rs reply |
| 615 | printf "\n" |
| 616 | eval "$varname=\"\$reply\"" |
| 617 | } |
| 618 | |
| 619 | _prompt_choice() { |
| 620 | # Usage: _prompt_choice "Prompt" "1" VARNAME "Option 1" "Option 2" |
| 621 | local prompt="$1" |
| 622 | local default="$2" |
| 623 | local varname="$3" |
| 624 | shift 3 |
| 625 | local -a options=("$@") |
| 626 | local i reply |
| 627 | |
| 628 | printf "\n ${_C_BOLD}%s${_C_RESET}\n" "$prompt" |
| 629 | for i in "${!options[@]}"; do |
| 630 | printf " %d) %s\n" "$((i + 1))" "${options[$i]}" |
| 631 | done |
| 632 | printf " Choice [%s]: " "$default" |
| 633 | read -r reply |
| 634 | reply="${reply:-$default}" |
| 635 | |
| 636 | if [[ "$reply" =~ ^[0-9]+$ ]] && ((reply >= 1 && reply <= ${#options[@]})); then |
| 637 | eval "$varname=\"\$reply\"" |
| 638 | else |
| 639 | eval "$varname=\"\$default\"" |
| 640 | fi |
| 641 | } |
| 642 | |
| 643 | _prompt_yesno() { |
| 644 | local prompt="$1" |
| 645 | local default="$2" |
| 646 | local varname="$3" |
| 647 | local reply hint |
| 648 | |
| 649 | if [[ "$default" == "y" ]]; then hint="Y/n"; else hint="y/N"; fi |
| 650 | printf " ${_C_BOLD}%s${_C_RESET} [%s]: " "$prompt" "$hint" |
| 651 | read -r reply |
| 652 | reply="${reply:-$default}" |
| 653 | case "$reply" in |
| 654 | [yY][eE][sS]|[yY]) eval "$varname=true" ;; |
| 655 | *) eval "$varname=false" ;; |
| 656 | esac |
| 657 | } |
| 658 | |
| 659 | run_interactive() { |
| 660 | _print_banner |
| 661 | |
| 662 | printf " ${_C_BLUE}Welcome to the fossilrepo installer.${_C_RESET}\n" |
| 663 | printf " This will guide you through setting up a self-hosted Fossil forge.\n\n" |
| 664 | |
| 665 | # Mode |
| 666 | local mode_choice |
| 667 | _prompt_choice "Installation mode" "1" mode_choice \ |
| 668 | "Docker (recommended -- everything runs in containers)" \ |
| 669 | "Bare Metal (native install with systemd services)" |
| 670 | case "$mode_choice" in |
| 671 | 1) OPT_MODE="docker" ;; |
| 672 | 2) OPT_MODE="bare-metal" ;; |
| 673 | esac |
| 674 | |
| 675 | # Domain |
| 676 | _prompt "Domain name" "localhost" OPT_DOMAIN |
| 677 | |
| 678 | # SSL -- skip if localhost |
| 679 | if [[ "$OPT_DOMAIN" != "localhost" && "$OPT_DOMAIN" != "127.0.0.1" ]]; then |
| 680 | _prompt_yesno "Enable automatic HTTPS (Let's Encrypt)" "y" OPT_SSL |
| 681 | else |
| 682 | OPT_SSL="false" |
| 683 | log_info "SSL skipped for localhost." |
| 684 | fi |
| 685 | |
| 686 | # S3 backup |
| 687 | local want_s3 |
| 688 | _prompt_yesno "Configure S3 backup (Litestream replication)" "n" want_s3 |
| 689 | if [[ "$want_s3" == "true" ]]; then |
| 690 | _prompt "S3 bucket name" "" OPT_S3_BUCKET |
| 691 | _prompt "S3 region" "us-east-1" OPT_S3_REGION |
| 692 | _prompt "S3 endpoint (leave blank for AWS)" "" OPT_S3_ENDPOINT |
| 693 | _prompt "AWS Access Key ID" "" OPT_S3_ACCESS_KEY |
| 694 | _prompt_password "AWS Secret Access Key" OPT_S3_SECRET_KEY |
| 695 | fi |
| 696 | |
| 697 | # Admin credentials |
| 698 | printf "\n ${_C_BOLD}Admin Account${_C_RESET}\n" |
| 699 | _prompt "Admin username" "admin" OPT_ADMIN_USER |
| 700 | _prompt "Admin email" "admin@${OPT_DOMAIN}" OPT_ADMIN_EMAIL |
| 701 | _prompt_password "Admin password" OPT_ADMIN_PASSWORD |
| 702 | |
| 703 | # Summary |
| 704 | printf "\n" |
| 705 | printf " ${_C_CYAN}${_C_BOLD}Installation Summary${_C_RESET}\n" |
| 706 | printf " %-20s %s\n" "Mode:" "$OPT_MODE" |
| 707 | printf " %-20s %s\n" "Domain:" "$OPT_DOMAIN" |
| 708 | printf " %-20s %s\n" "SSL:" "$OPT_SSL" |
| 709 | printf " %-20s %s\n" "Admin user:" "$OPT_ADMIN_USER" |
| 710 | printf " %-20s %s\n" "Admin email:" "$OPT_ADMIN_EMAIL" |
| 711 | printf " %-20s %s\n" "Admin password:" "$(if [[ -n "$OPT_ADMIN_PASSWORD" ]]; then echo '(set)'; else echo '(auto-generate)'; fi)" |
| 712 | if [[ -n "$OPT_S3_BUCKET" ]]; then |
| 713 | printf " %-20s %s\n" "S3 bucket:" "$OPT_S3_BUCKET" |
| 714 | printf " %-20s %s\n" "S3 region:" "${OPT_S3_REGION:-us-east-1}" |
| 715 | else |
| 716 | printf " %-20s %s\n" "S3 backup:" "disabled" |
| 717 | fi |
| 718 | printf " %-20s %s\n" "Install prefix:" "$OPT_PREFIX" |
| 719 | printf "\n" |
| 720 | } |
| 721 | |
| 722 | # ============================================================================ |
| 723 | # Section 8: Dependency Management |
| 724 | # ============================================================================ |
| 725 | |
| 726 | check_docker_deps() { |
| 727 | log_step "Checking Docker dependencies" |
| 728 | |
| 729 | local missing=() |
| 730 | |
| 731 | if ! command_exists docker; then |
| 732 | missing+=("docker") |
| 733 | fi |
| 734 | |
| 735 | # Check for docker compose v2 (plugin) |
| 736 | if command_exists docker; then |
| 737 | if ! docker compose version &>/dev/null; then |
| 738 | missing+=("docker-compose-plugin") |
| 739 | fi |
| 740 | fi |
| 741 | |
| 742 | if [[ ${#missing[@]} -gt 0 ]]; then |
| 743 | log_warn "Missing Docker dependencies: ${missing[*]}" |
| 744 | return 1 |
| 745 | fi |
| 746 | |
| 747 | local compose_ver |
| 748 | compose_ver="$(docker compose version --short 2>/dev/null || echo "0")" |
| 749 | log_ok "Docker $(docker --version | awk '{print $3}' | tr -d ',') + Compose $compose_ver" |
| 750 | return 0 |
| 751 | } |
| 752 | |
| 753 | check_bare_metal_deps() { |
| 754 | log_step "Checking bare metal dependencies" |
| 755 | |
| 756 | local missing=() |
| 757 | |
| 758 | command_exists git || missing+=("git") |
| 759 | command_exists curl || missing+=("curl") |
| 760 | |
| 761 | # Python 3.12+ |
| 762 | if command_exists python3; then |
| 763 | local pyver |
| 764 | pyver="$(python3 --version 2>&1 | awk '{print $2}')" |
| 765 | if ! version_gte "$pyver" "$PYTHON_VERSION"; then |
| 766 | missing+=("python${PYTHON_VERSION}") |
| 767 | fi |
| 768 | else |
| 769 | missing+=("python${PYTHON_VERSION}") |
| 770 | fi |
| 771 | |
| 772 | # PostgreSQL |
| 773 | if command_exists psql; then |
| 774 | local pgver |
| 775 | pgver="$(psql --version | awk '{print $3}' | cut -d. -f1)" |
| 776 | if ! version_gte "$pgver" "$POSTGRES_VERSION"; then |
| 777 | missing+=("postgresql-${POSTGRES_VERSION}") |
| 778 | fi |
| 779 | else |
| 780 | missing+=("postgresql-${POSTGRES_VERSION}") |
| 781 | fi |
| 782 | |
| 783 | command_exists redis-server || missing+=("redis") |
| 784 | |
| 785 | # These are installed from source/binary -- check separately |
| 786 | command_exists fossil || missing+=("fossil") |
| 787 | command_exists caddy || missing+=("caddy") |
| 788 | |
| 789 | if [[ ${#missing[@]} -gt 0 ]]; then |
| 790 | log_warn "Missing dependencies: ${missing[*]}" |
| 791 | return 1 |
| 792 | fi |
| 793 | |
| 794 | log_ok "All bare metal dependencies present" |
| 795 | return 0 |
| 796 | } |
| 797 | |
| 798 | install_docker_engine() { |
| 799 | log_info "Installing Docker Engine..." |
| 800 | |
| 801 | case "$PKG_MANAGER" in |
| 802 | apt) |
| 803 | # Docker official GPG key and repo |
| 804 | apt-get update -qq |
| 805 | apt-get install -y -qq ca-certificates curl gnupg lsb-release |
| 806 | |
| 807 | install -m 0755 -d /etc/apt/keyrings |
| 808 | if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then |
| 809 | curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \ |
| 810 | gpg --dearmor -o /etc/apt/keyrings/docker.gpg |
| 811 | chmod a+r /etc/apt/keyrings/docker.gpg |
| 812 | fi |
| 813 | |
| 814 | local codename |
| 815 | codename="$(. /etc/os-release && echo "$VERSION_CODENAME")" |
| 816 | cat > /etc/apt/sources.list.d/docker.list <<EOF |
| 817 | deb [arch=${OS_ARCH} signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/${OS_ID} ${codename} stable |
| 818 | EOF |
| 819 | |
| 820 | apt-get update -qq |
| 821 | apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
| 822 | ;; |
| 823 | dnf) |
| 824 | dnf install -y -q dnf-plugins-core |
| 825 | dnf config-manager --add-repo "https://download.docker.com/linux/${OS_ID}/docker-ce.repo" 2>/dev/null || \ |
| 826 | dnf config-manager --add-repo "https://download.docker.com/linux/centos/docker-ce.repo" |
| 827 | dnf install -y -q docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin |
| 828 | ;; |
| 829 | apk) |
| 830 | apk add --no-cache docker docker-cli-compose |
| 831 | ;; |
| 832 | esac |
| 833 | |
| 834 | systemctl enable --now docker |
| 835 | log_ok "Docker installed and running" |
| 836 | } |
| 837 | |
| 838 | install_deps_debian() { |
| 839 | log_step "Installing system packages (Debian/Ubuntu)" |
| 840 | |
| 841 | apt-get update -qq |
| 842 | |
| 843 | # Build tools for Fossil compilation + runtime deps |
| 844 | apt-get install -y -qq \ |
| 845 | build-essential \ |
| 846 | ca-certificates \ |
| 847 | curl \ |
| 848 | git \ |
| 849 | gnupg \ |
| 850 | lsb-release \ |
| 851 | zlib1g-dev \ |
| 852 | libssl-dev \ |
| 853 | tcl \ |
| 854 | openssh-server \ |
| 855 | sudo \ |
| 856 | logrotate |
| 857 | |
| 858 | # Python 3.12 |
| 859 | if ! command_exists python3 || ! version_gte "$(python3 --version 2>&1 | awk '{print $2}')" "$PYTHON_VERSION"; then |
| 860 | log_info "Installing Python ${PYTHON_VERSION}..." |
| 861 | apt-get install -y -qq software-properties-common |
| 862 | add-apt-repository -y ppa:deadsnakes/ppa 2>/dev/null || true |
| 863 | apt-get update -qq |
| 864 | apt-get install -y -qq "python${PYTHON_VERSION}" "python${PYTHON_VERSION}-venv" "python${PYTHON_VERSION}-dev" || \ |
| 865 | apt-get install -y -qq python3 python3-venv python3-dev |
| 866 | fi |
| 867 | |
| 868 | # PostgreSQL 16 |
| 869 | if ! command_exists psql || ! version_gte "$(psql --version | awk '{print $3}' | cut -d. -f1)" "$POSTGRES_VERSION"; then |
| 870 | log_info "Installing PostgreSQL ${POSTGRES_VERSION}..." |
| 871 | if [[ ! -f /etc/apt/sources.list.d/pgdg.list ]]; then |
| 872 | curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | \ |
| 873 | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg |
| 874 | echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ |
| 875 | > /etc/apt/sources.list.d/pgdg.list |
| 876 | apt-get update -qq |
| 877 | fi |
| 878 | apt-get install -y -qq "postgresql-${POSTGRES_VERSION}" "postgresql-client-${POSTGRES_VERSION}" |
| 879 | fi |
| 880 | |
| 881 | # Redis |
| 882 | if ! command_exists redis-server; then |
| 883 | log_info "Installing Redis..." |
| 884 | apt-get install -y -qq redis-server |
| 885 | fi |
| 886 | |
| 887 | log_ok "System packages installed" |
| 888 | } |
| 889 | |
| 890 | install_deps_rhel() { |
| 891 | log_step "Installing system packages (RHEL/CentOS/Fedora/Amazon Linux)" |
| 892 | |
| 893 | dnf install -y -q epel-release 2>/dev/null || true |
| 894 | dnf groupinstall -y -q "Development Tools" 2>/dev/null || \ |
| 895 | dnf install -y -q gcc gcc-c++ make |
| 896 | |
| 897 | # AL2023 ships curl-minimal which conflicts with curl; skip if any curl works |
| 898 | local curl_pkg="" |
| 899 | command_exists curl || curl_pkg="curl" |
| 900 | |
| 901 | dnf install -y -q \ |
| 902 | ca-certificates \ |
| 903 | $curl_pkg \ |
| 904 | git \ |
| 905 | zlib-devel \ |
| 906 | openssl-devel \ |
| 907 | tcl \ |
| 908 | openssh-server \ |
| 909 | sudo \ |
| 910 | logrotate |
| 911 | |
| 912 | # Python 3.12 |
| 913 | if ! command_exists python3 || ! version_gte "$(python3 --version 2>&1 | awk '{print $2}')" "$PYTHON_VERSION"; then |
| 914 | log_info "Installing Python ${PYTHON_VERSION}..." |
| 915 | dnf install -y -q "python${PYTHON_VERSION//.}" "python${PYTHON_VERSION//.}-devel" 2>/dev/null || \ |
| 916 | dnf install -y -q python3 python3-devel |
| 917 | fi |
| 918 | |
| 919 | # PostgreSQL |
| 920 | if ! command_exists psql; then |
| 921 | log_info "Installing PostgreSQL..." |
| 922 | # Try PG16 from PGDG repo first, fall back to distro version (PG15 on AL2023) |
| 923 | if dnf install -y -q "https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-${OS_ARCH}/pgdg-redhat-repo-latest.noarch.rpm" 2>/dev/null; then |
| 924 | dnf install -y -q "postgresql${POSTGRES_VERSION}-server" "postgresql${POSTGRES_VERSION}" 2>/dev/null || \ |
| 925 | dnf install -y -q postgresql15-server postgresql15 |
| 926 | else |
| 927 | dnf install -y -q postgresql15-server postgresql15 2>/dev/null || \ |
| 928 | dnf install -y -q postgresql-server postgresql |
| 929 | fi |
| 930 | fi |
| 931 | |
| 932 | # Redis |
| 933 | if ! command_exists redis-server && ! command_exists redis6-server; then |
| 934 | log_info "Installing Redis..." |
| 935 | dnf install -y -q redis 2>/dev/null || dnf install -y -q redis6 |
| 936 | fi |
| 937 | |
| 938 | log_ok "System packages installed" |
| 939 | } |
| 940 | |
| 941 | install_fossil_from_source() { |
| 942 | # Matches Dockerfile lines 11-22 exactly |
| 943 | if command_exists fossil; then |
| 944 | local current_ver |
| 945 | current_ver="$(fossil version | grep -oP 'version \K[0-9]+\.[0-9]+' | head -1)" |
| 946 | if version_gte "${current_ver:-0}" "$FOSSIL_VERSION"; then |
| 947 | log_ok "Fossil $current_ver already installed (>= $FOSSIL_VERSION)" |
| 948 | return 0 |
| 949 | fi |
| 950 | fi |
| 951 | |
| 952 | log_info "Building Fossil ${FOSSIL_VERSION} from source..." |
| 953 | local build_dir |
| 954 | build_dir="$(mktemp -d)" |
| 955 | |
| 956 | ( |
| 957 | cd "$build_dir" |
| 958 | curl -sSL "https://fossil-scm.org/home/tarball/version-${FOSSIL_VERSION}/fossil-src-${FOSSIL_VERSION}.tar.gz" \ |
| 959 | -o fossil.tar.gz |
| 960 | tar xzf fossil.tar.gz |
| 961 | cd "fossil-src-${FOSSIL_VERSION}" |
| 962 | ./configure --prefix=/usr/local --with-openssl=auto --json |
| 963 | make -j"$(nproc)" |
| 964 | make install |
| 965 | ) |
| 966 | |
| 967 | rm -rf "$build_dir" |
| 968 | |
| 969 | if ! command_exists fossil; then |
| 970 | die "Fossil build failed -- binary not found at /usr/local/bin/fossil" |
| 971 | fi |
| 972 | |
| 973 | log_ok "Fossil $(fossil version | grep -oP 'version \K[0-9]+\.[0-9]+') installed" |
| 974 | } |
| 975 | |
| 976 | install_caddy_binary() { |
| 977 | if command_exists caddy; then |
| 978 | local current_ver |
| 979 | current_ver="$(caddy version 2>/dev/null | awk '{print $1}' | tr -d 'v')" |
| 980 | if version_gte "${current_ver:-0}" "$CADDY_VERSION"; then |
| 981 | log_ok "Caddy $current_ver already installed (>= $CADDY_VERSION)" |
| 982 | return 0 |
| 983 | fi |
| 984 | fi |
| 985 | |
| 986 | log_info "Installing Caddy ${CADDY_VERSION}..." |
| 987 | |
| 988 | local caddy_arch="$OS_ARCH" |
| 989 | local caddy_url="${CADDY_DOWNLOAD_BASE}?os=linux&arch=${caddy_arch}" |
| 990 | |
| 991 | curl -sSL "$caddy_url" -o /usr/local/bin/caddy |
| 992 | chmod +x /usr/local/bin/caddy |
| 993 | |
| 994 | if ! /usr/local/bin/caddy version &>/dev/null; then |
| 995 | die "Caddy binary download failed or is not executable" |
| 996 | fi |
| 997 | |
| 998 | # Allow Caddy to bind to privileged ports without root |
| 999 | if command_exists setcap; then |
| 1000 | setcap 'cap_net_bind_service=+ep' /usr/local/bin/caddy 2>/dev/null || true |
| 1001 | fi |
| 1002 | |
| 1003 | log_ok "Caddy $(/usr/local/bin/caddy version | awk '{print $1}') installed" |
| 1004 | } |
| 1005 | |
| 1006 | install_litestream_binary() { |
| 1007 | if [[ -z "$OPT_S3_BUCKET" ]]; then |
| 1008 | verbose "Skipping Litestream install (no S3 bucket configured)" |
| 1009 | return 0 |
| 1010 | fi |
| 1011 | |
| 1012 | if command_exists litestream; then |
| 1013 | local current_ver |
| 1014 | current_ver="$(litestream version 2>/dev/null | tr -d 'v')" |
| 1015 | if version_gte "${current_ver:-0}" "$LITESTREAM_VERSION"; then |
| 1016 | log_ok "Litestream $current_ver already installed (>= $LITESTREAM_VERSION)" |
| 1017 | return 0 |
| 1018 | fi |
| 1019 | fi |
| 1020 | |
| 1021 | log_info "Installing Litestream ${LITESTREAM_VERSION}..." |
| 1022 | |
| 1023 | local ls_arch |
| 1024 | case "$OS_ARCH" in |
| 1025 | amd64) ls_arch="amd64" ;; |
| 1026 | arm64) ls_arch="arm64" ;; |
| 1027 | esac |
| 1028 | |
| 1029 | local ls_url="${LITESTREAM_DOWNLOAD_BASE}/v${LITESTREAM_VERSION}/litestream-v${LITESTREAM_VERSION}-linux-${ls_arch}.tar.gz" |
| 1030 | local tmp_dir |
| 1031 | tmp_dir="$(mktemp -d)" |
| 1032 | |
| 1033 | curl -sSL "$ls_url" -o "${tmp_dir}/litestream.tar.gz" |
| 1034 | tar xzf "${tmp_dir}/litestream.tar.gz" -C "${tmp_dir}" |
| 1035 | install -m 0755 "${tmp_dir}/litestream" /usr/local/bin/litestream |
| 1036 | rm -rf "$tmp_dir" |
| 1037 | |
| 1038 | if ! command_exists litestream; then |
| 1039 | die "Litestream install failed" |
| 1040 | fi |
| 1041 | |
| 1042 | log_ok "Litestream $(litestream version) installed" |
| 1043 | } |
| 1044 | |
| 1045 | install_uv() { |
| 1046 | if command_exists uv; then |
| 1047 | log_ok "uv already installed" |
| 1048 | return 0 |
| 1049 | fi |
| 1050 | |
| 1051 | log_info "Installing uv (Python package manager)..." |
| 1052 | export HOME="${HOME:-/root}" |
| 1053 | curl -LsSf https://astral.sh/uv/install.sh | sh |
| 1054 | # Copy to /usr/local/bin so all users can access it (symlink fails because /root is 700) |
| 1055 | cp -f "${HOME}/.local/bin/uv" /usr/local/bin/uv 2>/dev/null || true |
| 1056 | cp -f "${HOME}/.local/bin/uvx" /usr/local/bin/uvx 2>/dev/null || true |
| 1057 | chmod +x /usr/local/bin/uv /usr/local/bin/uvx 2>/dev/null || true |
| 1058 | export PATH="/usr/local/bin:${HOME}/.local/bin:$PATH" |
| 1059 | |
| 1060 | if ! command_exists uv; then |
| 1061 | # Fallback: pip install |
| 1062 | pip3 install uv 2>/dev/null || pip install uv 2>/dev/null || \ |
| 1063 | die "Failed to install uv" |
| 1064 | fi |
| 1065 | |
| 1066 | log_ok "uv installed" |
| 1067 | } |
| 1068 | |
| 1069 | check_and_install_deps() { |
| 1070 | if [[ "$OPT_MODE" == "docker" ]]; then |
| 1071 | if ! check_docker_deps; then |
| 1072 | confirm "Docker not found. Install Docker Engine?" |
| 1073 | install_docker_engine |
| 1074 | fi |
| 1075 | else |
| 1076 | # Bare metal: install OS packages, then build/download remaining deps |
| 1077 | case "$PKG_MANAGER" in |
| 1078 | apt) install_deps_debian ;; |
| 1079 | dnf) install_deps_rhel ;; |
| 1080 | *) die "Unsupported package manager: $PKG_MANAGER" ;; |
| 1081 | esac |
| 1082 | |
| 1083 | install_fossil_from_source |
| 1084 | install_caddy_binary |
| 1085 | install_litestream_binary |
| 1086 | install_uv |
| 1087 | fi |
| 1088 | } |
| 1089 | |
| 1090 | # ============================================================================ |
| 1091 | # Section 9: Docker Mode |
| 1092 | # ============================================================================ |
| 1093 | |
| 1094 | generate_env_file() { |
| 1095 | log_info "Generating .env file..." |
| 1096 | |
| 1097 | local proto="http" |
| 1098 | [[ "$OPT_SSL" == "true" ]] && proto="https" |
| 1099 | local base_url="${proto}://${OPT_DOMAIN}" |
| 1100 | |
| 1101 | local db_host="postgres" |
| 1102 | local redis_host="redis" |
| 1103 | local email_host="localhost" |
| 1104 | |
| 1105 | local use_s3="false" |
| 1106 | [[ -n "$OPT_S3_BUCKET" ]] && use_s3="true" |
| 1107 | |
| 1108 | local env_content |
| 1109 | env_content="# fossilrepo -- generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 1110 | # Mode: ${OPT_MODE} |
| 1111 | |
| 1112 | # --- Security --- |
| 1113 | DJANGO_SECRET_KEY=${GEN_SECRET_KEY} |
| 1114 | DJANGO_DEBUG=false |
| 1115 | DJANGO_ALLOWED_HOSTS=${OPT_DOMAIN},localhost,127.0.0.1 |
| 1116 | |
| 1117 | # --- Database --- |
| 1118 | POSTGRES_DB=${OPT_DB_NAME} |
| 1119 | POSTGRES_USER=${OPT_DB_USER} |
| 1120 | POSTGRES_PASSWORD=${GEN_DB_PASSWORD} |
| 1121 | POSTGRES_HOST=${db_host} |
| 1122 | POSTGRES_PORT=5432 |
| 1123 | |
| 1124 | # --- Redis / Celery --- |
| 1125 | REDIS_URL=redis://${redis_host}:6379/1 |
| 1126 | CELERY_BROKER=redis://${redis_host}:6379/0 |
| 1127 | |
| 1128 | # --- Email --- |
| 1129 | EMAIL_HOST=${email_host} |
| 1130 | EMAIL_PORT=587 |
| 1131 | DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend |
| 1132 | FROM_EMAIL=no-reply@${OPT_DOMAIN} |
| 1133 | |
| 1134 | # --- S3 / Media --- |
| 1135 | USE_S3=${use_s3} |
| 1136 | AWS_ACCESS_KEY_ID=${OPT_S3_ACCESS_KEY} |
| 1137 | AWS_SECRET_ACCESS_KEY=${OPT_S3_SECRET_KEY} |
| 1138 | AWS_STORAGE_BUCKET_NAME=${OPT_S3_BUCKET} |
| 1139 | AWS_S3_ENDPOINT_URL=${OPT_S3_ENDPOINT} |
| 1140 | |
| 1141 | # --- CORS / CSRF --- |
| 1142 | CORS_ALLOWED_ORIGINS=${base_url} |
| 1143 | CSRF_TRUSTED_ORIGINS=${base_url} |
| 1144 | |
| 1145 | # --- Sentry --- |
| 1146 | SENTRY_DSN= |
| 1147 | |
| 1148 | # --- Litestream S3 Replication --- |
| 1149 | FOSSILREPO_S3_BUCKET=${OPT_S3_BUCKET} |
| 1150 | FOSSILREPO_S3_REGION=${OPT_S3_REGION:-us-east-1} |
| 1151 | FOSSILREPO_S3_ENDPOINT=${OPT_S3_ENDPOINT}" |
| 1152 | |
| 1153 | write_file "${OPT_PREFIX}/.env" "$env_content" "0600" |
| 1154 | } |
| 1155 | |
| 1156 | generate_docker_compose() { |
| 1157 | log_info "Generating docker-compose.yml..." |
| 1158 | |
| 1159 | local litestream_service="" |
| 1160 | local litestream_depends="" |
| 1161 | if [[ -n "$OPT_S3_BUCKET" ]]; then |
| 1162 | litestream_depends=" |
| 1163 | litestream: |
| 1164 | condition: service_started" |
| 1165 | litestream_service=" |
| 1166 | litestream: |
| 1167 | image: litestream/litestream:${LITESTREAM_VERSION} |
| 1168 | volumes: |
| 1169 | - fossil-repos:/data/repos |
| 1170 | - ./litestream.yml:/etc/litestream.yml:ro |
| 1171 | env_file: .env |
| 1172 | command: litestream replicate -config /etc/litestream.yml |
| 1173 | restart: unless-stopped" |
| 1174 | fi |
| 1175 | |
| 1176 | local compose_content |
| 1177 | compose_content="# fossilrepo -- production docker-compose |
| 1178 | # Generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 1179 | |
| 1180 | services: |
| 1181 | app: |
| 1182 | build: |
| 1183 | context: ./src |
| 1184 | dockerfile: Dockerfile |
| 1185 | env_file: .env |
| 1186 | environment: |
| 1187 | DJANGO_DEBUG: \"false\" |
| 1188 | POSTGRES_HOST: postgres |
| 1189 | REDIS_URL: redis://redis:6379/1 |
| 1190 | CELERY_BROKER: redis://redis:6379/0 |
| 1191 | ports: |
| 1192 | - \"${OPT_PORT}:8000\" |
| 1193 | - \"2222:2222\" |
| 1194 | volumes: |
| 1195 | - fossil-repos:/data/repos |
| 1196 | - fossil-ssh:/data/ssh |
| 1197 | - static-files:/app/assets |
| 1198 | depends_on: |
| 1199 | postgres: |
| 1200 | condition: service_healthy |
| 1201 | redis: |
| 1202 | condition: service_healthy |
| 1203 | restart: unless-stopped |
| 1204 | healthcheck: |
| 1205 | test: [\"CMD-SHELL\", \"curl -f http://localhost:8000/health/ || exit 1\"] |
| 1206 | interval: 30s |
| 1207 | timeout: 10s |
| 1208 | retries: 3 |
| 1209 | start_period: 40s |
| 1210 | |
| 1211 | celery-worker: |
| 1212 | build: |
| 1213 | context: ./src |
| 1214 | dockerfile: Dockerfile |
| 1215 | command: celery -A config.celery worker -l info -Q celery |
| 1216 | env_file: .env |
| 1217 | environment: |
| 1218 | POSTGRES_HOST: postgres |
| 1219 | REDIS_URL: redis://redis:6379/1 |
| 1220 | CELERY_BROKER: redis://redis:6379/0 |
| 1221 | volumes: |
| 1222 | - fossil-repos:/data/repos |
| 1223 | depends_on: |
| 1224 | postgres: |
| 1225 | condition: service_healthy |
| 1226 | redis: |
| 1227 | condition: service_healthy |
| 1228 | restart: unless-stopped |
| 1229 | |
| 1230 | celery-beat: |
| 1231 | build: |
| 1232 | context: ./src |
| 1233 | dockerfile: Dockerfile |
| 1234 | command: celery -A config.celery beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler |
| 1235 | env_file: .env |
| 1236 | environment: |
| 1237 | POSTGRES_HOST: postgres |
| 1238 | REDIS_URL: redis://redis:6379/1 |
| 1239 | CELERY_BROKER: redis://redis:6379/0 |
| 1240 | depends_on: |
| 1241 | postgres: |
| 1242 | condition: service_healthy |
| 1243 | redis: |
| 1244 | condition: service_healthy |
| 1245 | restart: unless-stopped |
| 1246 | |
| 1247 | postgres: |
| 1248 | image: postgres:${POSTGRES_VERSION}-alpine |
| 1249 | environment: |
| 1250 | POSTGRES_DB: ${OPT_DB_NAME} |
| 1251 | POSTGRES_USER: ${OPT_DB_USER} |
| 1252 | POSTGRES_PASSWORD: ${GEN_DB_PASSWORD} |
| 1253 | volumes: |
| 1254 | - pgdata:/var/lib/postgresql/data |
| 1255 | healthcheck: |
| 1256 | test: [\"CMD-SHELL\", \"pg_isready -U ${OPT_DB_USER} -d ${OPT_DB_NAME}\"] |
| 1257 | interval: 5s |
| 1258 | timeout: 5s |
| 1259 | retries: 5 |
| 1260 | restart: unless-stopped |
| 1261 | |
| 1262 | redis: |
| 1263 | image: redis:${REDIS_VERSION}-alpine |
| 1264 | volumes: |
| 1265 | - redisdata:/data |
| 1266 | command: redis-server --appendonly yes |
| 1267 | healthcheck: |
| 1268 | test: [\"CMD\", \"redis-cli\", \"ping\"] |
| 1269 | interval: 5s |
| 1270 | timeout: 5s |
| 1271 | retries: 5 |
| 1272 | restart: unless-stopped |
| 1273 | |
| 1274 | caddy: |
| 1275 | image: caddy:2-alpine |
| 1276 | ports: |
| 1277 | - \"80:80\" |
| 1278 | - \"443:443\" |
| 1279 | - \"443:443/udp\" |
| 1280 | volumes: |
| 1281 | - ./Caddyfile:/etc/caddy/Caddyfile:ro |
| 1282 | - caddy-data:/data |
| 1283 | - caddy-config:/config |
| 1284 | - static-files:/srv/static:ro |
| 1285 | depends_on: |
| 1286 | app: |
| 1287 | condition: service_healthy |
| 1288 | restart: unless-stopped |
| 1289 | ${litestream_service} |
| 1290 | volumes: |
| 1291 | pgdata: |
| 1292 | redisdata: |
| 1293 | fossil-repos: |
| 1294 | fossil-ssh: |
| 1295 | static-files: |
| 1296 | caddy-data: |
| 1297 | caddy-config:" |
| 1298 | |
| 1299 | write_file "${OPT_PREFIX}/docker-compose.yml" "$compose_content" |
| 1300 | } |
| 1301 | |
| 1302 | generate_caddyfile() { |
| 1303 | log_info "Generating Caddyfile..." |
| 1304 | |
| 1305 | local caddy_content |
| 1306 | |
| 1307 | if [[ "$OPT_SSL" == "true" && "$OPT_DOMAIN" != "localhost" ]]; then |
| 1308 | caddy_content="# fossilrepo Caddy config -- auto HTTPS |
| 1309 | # Generated by installer |
| 1310 | |
| 1311 | # Root domain -- Django app |
| 1312 | ${OPT_DOMAIN} { |
| 1313 | encode gzip |
| 1314 | |
| 1315 | # Static files served by Caddy |
| 1316 | handle_path /static/* { |
| 1317 | root * /srv/static |
| 1318 | file_server |
| 1319 | } |
| 1320 | |
| 1321 | # Everything else to Django/gunicorn |
| 1322 | reverse_proxy app:8000 |
| 1323 | } |
| 1324 | |
| 1325 | # Wildcard subdomain routing -- repo subdomains to Django |
| 1326 | *.${OPT_DOMAIN} { |
| 1327 | tls { |
| 1328 | dns |
| 1329 | } |
| 1330 | |
| 1331 | encode gzip |
| 1332 | reverse_proxy app:8000 |
| 1333 | }" |
| 1334 | else |
| 1335 | # No SSL / localhost |
| 1336 | local listen_addr |
| 1337 | if [[ "$OPT_DOMAIN" == "localhost" || "$OPT_DOMAIN" == "127.0.0.1" ]]; then |
| 1338 | listen_addr=":80" |
| 1339 | else |
| 1340 | listen_addr="${OPT_DOMAIN}:80" |
| 1341 | fi |
| 1342 | |
| 1343 | caddy_content="# fossilrepo Caddy config -- HTTP only |
| 1344 | # Generated by installer |
| 1345 | |
| 1346 | { |
| 1347 | auto_https off |
| 1348 | } |
| 1349 | |
| 1350 | ${listen_addr} { |
| 1351 | encode gzip |
| 1352 | |
| 1353 | handle_path /static/* { |
| 1354 | root * /srv/static |
| 1355 | file_server |
| 1356 | } |
| 1357 | |
| 1358 | reverse_proxy app:8000 |
| 1359 | }" |
| 1360 | fi |
| 1361 | |
| 1362 | write_file "${OPT_PREFIX}/Caddyfile" "$caddy_content" |
| 1363 | } |
| 1364 | |
| 1365 | generate_litestream_config() { |
| 1366 | if [[ -z "$OPT_S3_BUCKET" ]]; then |
| 1367 | return 0 |
| 1368 | fi |
| 1369 | |
| 1370 | log_info "Generating litestream.yml..." |
| 1371 | |
| 1372 | local ls_content |
| 1373 | ls_content="# Litestream replication -- continuous .fossil backup to S3 |
| 1374 | # Generated by installer |
| 1375 | |
| 1376 | dbs: |
| 1377 | - path: /data/repos/*.fossil |
| 1378 | replicas: |
| 1379 | - type: s3 |
| 1380 | bucket: ${OPT_S3_BUCKET} |
| 1381 | endpoint: ${OPT_S3_ENDPOINT} |
| 1382 | region: ${OPT_S3_REGION:-us-east-1} |
| 1383 | access-key-id: \${AWS_ACCESS_KEY_ID} |
| 1384 | secret-access-key: \${AWS_SECRET_ACCESS_KEY}" |
| 1385 | |
| 1386 | write_file "${OPT_PREFIX}/litestream.yml" "$ls_content" |
| 1387 | } |
| 1388 | |
| 1389 | setup_docker_systemd() { |
| 1390 | log_info "Creating systemd service for auto-start..." |
| 1391 | |
| 1392 | local unit_content |
| 1393 | unit_content="[Unit] |
| 1394 | Description=fossilrepo (Docker Compose) |
| 1395 | Requires=docker.service |
| 1396 | After=docker.service |
| 1397 | |
| 1398 | [Service] |
| 1399 | Type=oneshot |
| 1400 | RemainAfterExit=yes |
| 1401 | WorkingDirectory=${OPT_PREFIX} |
| 1402 | ExecStart=/usr/bin/docker compose up -d --remove-orphans |
| 1403 | ExecStop=/usr/bin/docker compose down |
| 1404 | TimeoutStartSec=300 |
| 1405 | |
| 1406 | [Install] |
| 1407 | WantedBy=multi-user.target" |
| 1408 | |
| 1409 | write_file "/etc/systemd/system/fossilrepo.service" "$unit_content" |
| 1410 | systemctl daemon-reload |
| 1411 | systemctl enable fossilrepo.service |
| 1412 | log_ok "systemd service enabled (fossilrepo.service)" |
| 1413 | } |
| 1414 | |
| 1415 | install_docker() { |
| 1416 | log_step "Installing fossilrepo (Docker mode)" |
| 1417 | |
| 1418 | mkdir -p "${OPT_PREFIX}/src" |
| 1419 | |
| 1420 | # Clone the repo |
| 1421 | if [[ -d "${OPT_PREFIX}/src/.git" ]]; then |
| 1422 | log_info "Updating existing repo..." |
| 1423 | git -C "${OPT_PREFIX}/src" pull --ff-only || true |
| 1424 | else |
| 1425 | log_info "Cloning fossilrepo..." |
| 1426 | git clone "$REPO_URL" "${OPT_PREFIX}/src" |
| 1427 | fi |
| 1428 | |
| 1429 | # Generate all config files |
| 1430 | generate_env_file |
| 1431 | generate_docker_compose |
| 1432 | generate_caddyfile |
| 1433 | generate_litestream_config |
| 1434 | |
| 1435 | # Build and start |
| 1436 | log_info "Building Docker images (this may take a few minutes)..." |
| 1437 | cd "$OPT_PREFIX" |
| 1438 | docker compose build |
| 1439 | |
| 1440 | log_info "Starting services..." |
| 1441 | docker compose up -d |
| 1442 | |
| 1443 | # Wait for postgres to be healthy |
| 1444 | log_info "Waiting for PostgreSQL to be ready..." |
| 1445 | local attempts=0 |
| 1446 | while ! docker compose exec -T postgres pg_isready -U "$OPT_DB_USER" -d "$OPT_DB_NAME" &>/dev/null; do |
| 1447 | ((attempts++)) |
| 1448 | if ((attempts > 30)); then |
| 1449 | die "PostgreSQL did not become ready within 150 seconds" |
| 1450 | fi |
| 1451 | sleep 5 |
| 1452 | done |
| 1453 | log_ok "PostgreSQL is ready" |
| 1454 | |
| 1455 | # Run Django setup |
| 1456 | log_info "Running database migrations..." |
| 1457 | docker compose exec -T app python manage.py migrate --noinput |
| 1458 | |
| 1459 | log_info "Collecting static files..." |
| 1460 | docker compose exec -T app python manage.py collectstatic --noinput |
| 1461 | |
| 1462 | # Create admin user |
| 1463 | log_info "Creating admin user..." |
| 1464 | docker compose exec -T app python manage.py shell -c " |
| 1465 | from django.contrib.auth import get_user_model |
| 1466 | User = get_user_model() |
| 1467 | if not User.objects.filter(username='${OPT_ADMIN_USER}').exists(): |
| 1468 | user = User.objects.create_superuser( |
| 1469 | username='${OPT_ADMIN_USER}', |
| 1470 | email='${OPT_ADMIN_EMAIL}', |
| 1471 | password='${GEN_ADMIN_PASSWORD}', |
| 1472 | ) |
| 1473 | print(f'Admin user created: {user.username}') |
| 1474 | else: |
| 1475 | print('Admin user already exists') |
| 1476 | " |
| 1477 | |
| 1478 | # Create data directories inside the app container |
| 1479 | docker compose exec -T app mkdir -p /data/repos /data/trash /data/ssh |
| 1480 | |
| 1481 | setup_docker_systemd |
| 1482 | |
| 1483 | log_ok "Docker installation complete" |
| 1484 | } |
| 1485 | |
| 1486 | # ============================================================================ |
| 1487 | # Section 10: Bare Metal Mode |
| 1488 | # ============================================================================ |
| 1489 | |
| 1490 | create_system_user() { |
| 1491 | log_info "Creating fossilrepo system user..." |
| 1492 | |
| 1493 | if id fossilrepo &>/dev/null; then |
| 1494 | verbose "User fossilrepo already exists" |
| 1495 | else |
| 1496 | useradd -r -m -d /home/fossilrepo -s /bin/bash fossilrepo |
| 1497 | fi |
| 1498 | |
| 1499 | # Data directories |
| 1500 | mkdir -p "${DATA_DIR}/repos" "${DATA_DIR}/trash" "${DATA_DIR}/ssh" "${DATA_DIR}/git-mirrors" "${DATA_DIR}/ssh-keys" |
| 1501 | mkdir -p "$LOG_DIR" |
| 1502 | mkdir -p "${OPT_PREFIX}" |
| 1503 | chown -R fossilrepo:fossilrepo "${DATA_DIR}" |
| 1504 | chown -R fossilrepo:fossilrepo "$LOG_DIR" |
| 1505 | |
| 1506 | log_ok "System user and directories created" |
| 1507 | } |
| 1508 | |
| 1509 | clone_repo() { |
| 1510 | # Configure SSH for GitHub if deploy key exists |
| 1511 | if [[ -f /root/.ssh/deploy_key ]]; then |
| 1512 | export GIT_SSH_COMMAND="ssh -i /root/.ssh/deploy_key -o StrictHostKeyChecking=no" |
| 1513 | # Use SSH URL for private repos |
| 1514 | local repo_url="${REPO_URL/https:\/\/github.com\//[email protected]:}" |
| 1515 | else |
| 1516 | local repo_url="$REPO_URL" |
| 1517 | fi |
| 1518 | |
| 1519 | if [[ -d "${OPT_PREFIX}/.git" ]]; then |
| 1520 | log_info "Updating existing repo..." |
| 1521 | git config --global --add safe.directory "$OPT_PREFIX" 2>/dev/null || true |
| 1522 | git -C "$OPT_PREFIX" pull --ff-only || true |
| 1523 | elif [[ -d "$OPT_PREFIX" ]]; then |
| 1524 | log_warn "${OPT_PREFIX} exists but is not a git repo. Backing up and cloning fresh..." |
| 1525 | mv "$OPT_PREFIX" "${OPT_PREFIX}.bak.$(date +%s)" |
| 1526 | git clone "$repo_url" "$OPT_PREFIX" |
| 1527 | else |
| 1528 | log_info "Cloning fossilrepo to ${OPT_PREFIX}..." |
| 1529 | git clone "$repo_url" "$OPT_PREFIX" |
| 1530 | fi |
| 1531 | chown -R fossilrepo:fossilrepo "$OPT_PREFIX" |
| 1532 | log_ok "Repository cloned" |
| 1533 | } |
| 1534 | |
| 1535 | setup_python_venv() { |
| 1536 | log_info "Setting up Python virtual environment..." |
| 1537 | |
| 1538 | local venv_dir="${OPT_PREFIX}/.venv" |
| 1539 | |
| 1540 | # Resolve uv path (sudo resets PATH) |
| 1541 | local uv_bin |
| 1542 | uv_bin="$(command -v uv 2>/dev/null || echo /usr/local/bin/uv)" |
| 1543 | |
| 1544 | # Use uv to create venv and install deps |
| 1545 | if [[ -x "$uv_bin" ]]; then |
| 1546 | sudo -u fossilrepo "$uv_bin" venv "$venv_dir" --python "python${PYTHON_VERSION}" 2>/dev/null || \ |
| 1547 | sudo -u fossilrepo "$uv_bin" venv "$venv_dir" --clear 2>/dev/null || \ |
| 1548 | sudo -u fossilrepo "$uv_bin" venv "$venv_dir" |
| 1549 | sudo -u fossilrepo bash -c "cd '${OPT_PREFIX}' && source '${venv_dir}/bin/activate' && '${uv_bin}' pip install -r pyproject.toml" |
| 1550 | else |
| 1551 | sudo -u fossilrepo "python${PYTHON_VERSION}" -m venv "$venv_dir" 2>/dev/null || \ |
| 1552 | sudo -u fossilrepo python3 -m venv "$venv_dir" |
| 1553 | sudo -u fossilrepo bash -c "source '${venv_dir}/bin/activate' && pip install --upgrade pip && pip install -r '${OPT_PREFIX}/pyproject.toml'" |
| 1554 | fi |
| 1555 | |
| 1556 | log_ok "Python environment configured" |
| 1557 | } |
| 1558 | |
| 1559 | setup_postgres() { |
| 1560 | log_info "Configuring PostgreSQL..." |
| 1561 | |
| 1562 | # Ensure PostgreSQL service is running |
| 1563 | local pg_service |
| 1564 | pg_service="postgresql" |
| 1565 | if systemctl list-unit-files "postgresql-${POSTGRES_VERSION}.service" &>/dev/null; then |
| 1566 | pg_service="postgresql-${POSTGRES_VERSION}" |
| 1567 | fi |
| 1568 | |
| 1569 | # Initialize cluster if needed (RHEL) |
| 1570 | if [[ "$PKG_MANAGER" == "dnf" ]]; then |
| 1571 | local pg_setup="/usr/pgsql-${POSTGRES_VERSION}/bin/postgresql-${POSTGRES_VERSION}-setup" |
| 1572 | if [[ -x "$pg_setup" ]]; then |
| 1573 | "$pg_setup" initdb 2>/dev/null || true |
| 1574 | fi |
| 1575 | fi |
| 1576 | |
| 1577 | systemctl enable --now "$pg_service" |
| 1578 | |
| 1579 | # Wait for PostgreSQL to accept connections |
| 1580 | local attempts=0 |
| 1581 | while ! sudo -u postgres pg_isready -q 2>/dev/null; do |
| 1582 | ((attempts++)) |
| 1583 | if ((attempts > 20)); then |
| 1584 | die "PostgreSQL did not start within 100 seconds" |
| 1585 | fi |
| 1586 | sleep 5 |
| 1587 | done |
| 1588 | |
| 1589 | # Ensure peer auth for postgres user (previous runs may have broken it) |
| 1590 | local pg_hba_candidates=("/var/lib/pgsql/data/pg_hba.conf" "/etc/postgresql/*/main/pg_hba.conf") |
| 1591 | local pg_hba="" |
| 1592 | for f in ${pg_hba_candidates[@]}; do |
| 1593 | [[ -f "$f" ]] && pg_hba="$f" && break |
| 1594 | done |
| 1595 | |
| 1596 | if [[ -n "$pg_hba" ]]; then |
| 1597 | # Ensure postgres user can connect via peer (unix socket) |
| 1598 | if ! grep -q "^local.*all.*postgres.*peer" "$pg_hba"; then |
| 1599 | sed -i '1i local all postgres peer' "$pg_hba" |
| 1600 | systemctl reload "$pg_service" 2>/dev/null || true |
| 1601 | sleep 2 |
| 1602 | fi |
| 1603 | fi |
| 1604 | |
| 1605 | # Create user and database (idempotent) |
| 1606 | sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname = '${OPT_DB_USER}'" | \ |
| 1607 | grep -q 1 || \ |
| 1608 | sudo -u postgres psql -c "CREATE USER ${OPT_DB_USER} WITH PASSWORD '${GEN_DB_PASSWORD}';" |
| 1609 | |
| 1610 | # Update password in case it changed |
| 1611 | sudo -u postgres psql -c "ALTER USER ${OPT_DB_USER} WITH PASSWORD '${GEN_DB_PASSWORD}';" |
| 1612 | |
| 1613 | sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname = '${OPT_DB_NAME}'" | \ |
| 1614 | grep -q 1 || \ |
| 1615 | sudo -u postgres psql -c "CREATE DATABASE ${OPT_DB_NAME} OWNER ${OPT_DB_USER};" |
| 1616 | |
| 1617 | sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE ${OPT_DB_NAME} TO ${OPT_DB_USER};" |
| 1618 | sudo -u postgres psql -d "${OPT_DB_NAME}" -c "GRANT ALL ON SCHEMA public TO ${OPT_DB_USER};" |
| 1619 | sudo -u postgres psql -d "${OPT_DB_NAME}" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${OPT_DB_USER};" |
| 1620 | |
| 1621 | # Fix pg_hba.conf to allow md5 auth for app connections via TCP (127.0.0.1) |
| 1622 | pg_hba="${pg_hba:-$(sudo -u postgres psql -t -c 'SHOW hba_file' 2>/dev/null | tr -d '[:space:]')}" |
| 1623 | |
| 1624 | if [[ -f "$pg_hba" ]]; then |
| 1625 | # Ensure md5 auth for 127.0.0.1 connections |
| 1626 | if ! grep -q "host.*${OPT_DB_NAME}.*${OPT_DB_USER}.*127.0.0.1/32.*md5" "$pg_hba" && \ |
| 1627 | ! grep -q "host.*${OPT_DB_NAME}.*${OPT_DB_USER}.*127.0.0.1/32.*scram-sha-256" "$pg_hba"; then |
| 1628 | backup_file "$pg_hba" |
| 1629 | # Insert before the first 'host' line to take priority |
| 1630 | sed -i "/^# IPv4 local connections/a host ${OPT_DB_NAME} ${OPT_DB_USER} 127.0.0.1/32 md5" "$pg_hba" 2>/dev/null || \ |
| 1631 | echo "host ${OPT_DB_NAME} ${OPT_DB_USER} 127.0.0.1/32 md5" >> "$pg_hba" |
| 1632 | systemctl reload "$pg_service" |
| 1633 | verbose "Added md5 auth rule to pg_hba.conf" |
| 1634 | fi |
| 1635 | fi |
| 1636 | |
| 1637 | # Verify connection works |
| 1638 | if ! PGPASSWORD="$GEN_DB_PASSWORD" psql -h 127.0.0.1 -U "$OPT_DB_USER" -d "$OPT_DB_NAME" -c "SELECT 1" &>/dev/null; then |
| 1639 | log_warn "PostgreSQL connection test failed -- you may need to manually adjust pg_hba.conf" |
| 1640 | else |
| 1641 | log_ok "PostgreSQL configured and connection verified" |
| 1642 | fi |
| 1643 | } |
| 1644 | |
| 1645 | setup_redis() { |
| 1646 | log_info "Configuring Redis..." |
| 1647 | systemctl enable --now redis-server 2>/dev/null || systemctl enable --now redis6 2>/dev/null || systemctl enable --now redis 2>/dev/null |
| 1648 | # Verify Redis is responding |
| 1649 | local attempts=0 |
| 1650 | local redis_cli="redis-cli" |
| 1651 | command_exists redis-cli || redis_cli="redis6-cli" |
| 1652 | while ! $redis_cli ping &>/dev/null; do |
| 1653 | ((attempts++)) |
| 1654 | if ((attempts > 10)); then |
| 1655 | die "Redis did not start within 50 seconds" |
| 1656 | fi |
| 1657 | sleep 5 |
| 1658 | done |
| 1659 | log_ok "Redis running" |
| 1660 | } |
| 1661 | |
| 1662 | generate_bare_metal_env() { |
| 1663 | log_info "Generating .env file..." |
| 1664 | |
| 1665 | local proto="http" |
| 1666 | [[ "$OPT_SSL" == "true" ]] && proto="https" |
| 1667 | local base_url="${proto}://${OPT_DOMAIN}" |
| 1668 | |
| 1669 | local use_s3="false" |
| 1670 | [[ -n "$OPT_S3_BUCKET" ]] && use_s3="true" |
| 1671 | |
| 1672 | local env_content |
| 1673 | env_content="# fossilrepo -- generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 1674 | # Mode: bare-metal |
| 1675 | |
| 1676 | # --- Security --- |
| 1677 | DJANGO_SECRET_KEY=${GEN_SECRET_KEY} |
| 1678 | DJANGO_DEBUG=false |
| 1679 | DJANGO_ALLOWED_HOSTS=${OPT_DOMAIN},localhost,127.0.0.1 |
| 1680 | DJANGO_SETTINGS_MODULE=config.settings |
| 1681 | |
| 1682 | # --- Database --- |
| 1683 | POSTGRES_DB=${OPT_DB_NAME} |
| 1684 | POSTGRES_USER=${OPT_DB_USER} |
| 1685 | POSTGRES_PASSWORD=${GEN_DB_PASSWORD} |
| 1686 | POSTGRES_HOST=127.0.0.1 |
| 1687 | POSTGRES_PORT=5432 |
| 1688 | |
| 1689 | # --- Redis / Celery --- |
| 1690 | REDIS_URL=redis://127.0.0.1:6379/1 |
| 1691 | CELERY_BROKER=redis://127.0.0.1:6379/0 |
| 1692 | |
| 1693 | # --- Email --- |
| 1694 | EMAIL_HOST=localhost |
| 1695 | EMAIL_PORT=587 |
| 1696 | DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend |
| 1697 | FROM_EMAIL=no-reply@${OPT_DOMAIN} |
| 1698 | |
| 1699 | # --- S3 / Media --- |
| 1700 | USE_S3=${use_s3} |
| 1701 | AWS_ACCESS_KEY_ID=${OPT_S3_ACCESS_KEY} |
| 1702 | AWS_SECRET_ACCESS_KEY=${OPT_S3_SECRET_KEY} |
| 1703 | AWS_STORAGE_BUCKET_NAME=${OPT_S3_BUCKET} |
| 1704 | AWS_S3_ENDPOINT_URL=${OPT_S3_ENDPOINT} |
| 1705 | |
| 1706 | # --- CORS / CSRF --- |
| 1707 | CORS_ALLOWED_ORIGINS=${base_url} |
| 1708 | CSRF_TRUSTED_ORIGINS=${base_url} |
| 1709 | |
| 1710 | # --- Sentry --- |
| 1711 | SENTRY_DSN= |
| 1712 | |
| 1713 | # --- Litestream S3 Replication --- |
| 1714 | FOSSILREPO_S3_BUCKET=${OPT_S3_BUCKET} |
| 1715 | FOSSILREPO_S3_REGION=${OPT_S3_REGION:-us-east-1} |
| 1716 | FOSSILREPO_S3_ENDPOINT=${OPT_S3_ENDPOINT}" |
| 1717 | |
| 1718 | write_file "${OPT_PREFIX}/.env" "$env_content" "0600" |
| 1719 | chown fossilrepo:fossilrepo "${OPT_PREFIX}/.env" |
| 1720 | } |
| 1721 | |
| 1722 | run_django_setup() { |
| 1723 | log_info "Running Django setup..." |
| 1724 | |
| 1725 | local venv_activate="${OPT_PREFIX}/.venv/bin/activate" |
| 1726 | local env_file="${OPT_PREFIX}/.env" |
| 1727 | |
| 1728 | # Migrate |
| 1729 | log_info "Running database migrations..." |
| 1730 | sudo -u fossilrepo bash -c " |
| 1731 | set -a; source '${env_file}'; set +a |
| 1732 | source '${venv_activate}' |
| 1733 | cd '${OPT_PREFIX}' |
| 1734 | python manage.py migrate --noinput |
| 1735 | " |
| 1736 | |
| 1737 | # Collect static |
| 1738 | log_info "Collecting static files..." |
| 1739 | sudo -u fossilrepo bash -c " |
| 1740 | set -a; source '${env_file}'; set +a |
| 1741 | source '${venv_activate}' |
| 1742 | cd '${OPT_PREFIX}' |
| 1743 | python manage.py collectstatic --noinput |
| 1744 | " |
| 1745 | |
| 1746 | # Create admin user |
| 1747 | log_info "Creating admin user..." |
| 1748 | sudo -u fossilrepo bash -c " |
| 1749 | set -a; source '${env_file}'; set +a |
| 1750 | source '${venv_activate}' |
| 1751 | cd '${OPT_PREFIX}' |
| 1752 | python manage.py shell -c \" |
| 1753 | from django.contrib.auth import get_user_model |
| 1754 | User = get_user_model() |
| 1755 | if not User.objects.filter(username='${OPT_ADMIN_USER}').exists(): |
| 1756 | user = User.objects.create_superuser( |
| 1757 | username='${OPT_ADMIN_USER}', |
| 1758 | email='${OPT_ADMIN_EMAIL}', |
| 1759 | password='${GEN_ADMIN_PASSWORD}', |
| 1760 | ) |
| 1761 | print(f'Admin user created: {user.username}') |
| 1762 | else: |
| 1763 | print('Admin user already exists') |
| 1764 | \" |
| 1765 | " |
| 1766 | |
| 1767 | log_ok "Django setup complete" |
| 1768 | } |
| 1769 | |
| 1770 | setup_caddy_bare_metal() { |
| 1771 | log_info "Configuring Caddy..." |
| 1772 | |
| 1773 | mkdir -p /etc/caddy |
| 1774 | local caddy_content |
| 1775 | |
| 1776 | if [[ "$OPT_SSL" == "true" && "$OPT_DOMAIN" != "localhost" ]]; then |
| 1777 | caddy_content="# fossilrepo Caddy config -- auto HTTPS (bare metal) |
| 1778 | # Generated by installer |
| 1779 | |
| 1780 | ${OPT_DOMAIN} { |
| 1781 | encode gzip |
| 1782 | |
| 1783 | handle_path /static/* { |
| 1784 | root * ${OPT_PREFIX}/assets |
| 1785 | file_server |
| 1786 | } |
| 1787 | |
| 1788 | reverse_proxy 127.0.0.1:8000 |
| 1789 | } |
| 1790 | |
| 1791 | " |
| 1792 | else |
| 1793 | caddy_content="# fossilrepo Caddy config -- HTTP (bare metal) |
| 1794 | # Generated by installer |
| 1795 | |
| 1796 | { |
| 1797 | auto_https off |
| 1798 | } |
| 1799 | |
| 1800 | :80 { |
| 1801 | encode gzip |
| 1802 | |
| 1803 | handle_path /static/* { |
| 1804 | root * ${OPT_PREFIX}/assets |
| 1805 | file_server |
| 1806 | } |
| 1807 | |
| 1808 | reverse_proxy 127.0.0.1:8000 |
| 1809 | }" |
| 1810 | fi |
| 1811 | |
| 1812 | write_file "/etc/caddy/Caddyfile" "$caddy_content" |
| 1813 | |
| 1814 | # Caddy systemd unit |
| 1815 | local caddy_bin |
| 1816 | caddy_bin="$(command -v caddy)" |
| 1817 | |
| 1818 | local caddy_unit |
| 1819 | caddy_unit="[Unit] |
| 1820 | Description=Caddy web server (fossilrepo) |
| 1821 | After=network-online.target |
| 1822 | Wants=network-online.target |
| 1823 | |
| 1824 | [Service] |
| 1825 | Type=notify |
| 1826 | User=caddy |
| 1827 | Group=caddy |
| 1828 | ExecStart=${caddy_bin} run --config /etc/caddy/Caddyfile --adapter caddyfile |
| 1829 | ExecReload=${caddy_bin} reload --config /etc/caddy/Caddyfile --adapter caddyfile |
| 1830 | TimeoutStopSec=5s |
| 1831 | LimitNOFILE=1048576 |
| 1832 | LimitNPROC=512 |
| 1833 | |
| 1834 | [Install] |
| 1835 | WantedBy=multi-user.target" |
| 1836 | |
| 1837 | # Create caddy user if it doesn't exist |
| 1838 | if ! id caddy &>/dev/null; then |
| 1839 | useradd -r -m -d /var/lib/caddy -s /usr/sbin/nologin caddy |
| 1840 | fi |
| 1841 | mkdir -p /var/lib/caddy/.local/share/caddy |
| 1842 | chown -R caddy:caddy /var/lib/caddy |
| 1843 | |
| 1844 | write_file "/etc/systemd/system/caddy.service" "$caddy_unit" |
| 1845 | log_ok "Caddy configured" |
| 1846 | } |
| 1847 | |
| 1848 | create_systemd_services() { |
| 1849 | log_info "Creating systemd service units..." |
| 1850 | |
| 1851 | local venv_activate="${OPT_PREFIX}/.venv/bin/activate" |
| 1852 | local env_file="${OPT_PREFIX}/.env" |
| 1853 | |
| 1854 | # --- gunicorn (fossilrepo-web) --- |
| 1855 | local gunicorn_unit |
| 1856 | gunicorn_unit="[Unit] |
| 1857 | Description=fossilrepo web (gunicorn) |
| 1858 | After=network.target postgresql.service redis.service |
| 1859 | Requires=postgresql.service |
| 1860 | |
| 1861 | [Service] |
| 1862 | Type=notify |
| 1863 | User=fossilrepo |
| 1864 | Group=fossilrepo |
| 1865 | WorkingDirectory=${OPT_PREFIX} |
| 1866 | EnvironmentFile=${env_file} |
| 1867 | ExecStart=${OPT_PREFIX}/.venv/bin/gunicorn config.wsgi:application \\ |
| 1868 | --bind 127.0.0.1:8000 \\ |
| 1869 | --workers 3 \\ |
| 1870 | --timeout 120 \\ |
| 1871 | --access-logfile ${LOG_DIR}/gunicorn-access.log \\ |
| 1872 | --error-logfile ${LOG_DIR}/gunicorn-error.log |
| 1873 | ExecReload=/bin/kill -s HUP \$MAINPID |
| 1874 | Restart=on-failure |
| 1875 | RestartSec=10 |
| 1876 | KillMode=mixed |
| 1877 | StandardOutput=append:${LOG_DIR}/web.log |
| 1878 | StandardError=append:${LOG_DIR}/web.log |
| 1879 | |
| 1880 | [Install] |
| 1881 | WantedBy=multi-user.target" |
| 1882 | |
| 1883 | write_file "/etc/systemd/system/fossilrepo-web.service" "$gunicorn_unit" |
| 1884 | |
| 1885 | # --- celery worker --- |
| 1886 | local celery_worker_unit |
| 1887 | celery_worker_unit="[Unit] |
| 1888 | Description=fossilrepo Celery worker |
| 1889 | After=network.target postgresql.service redis.service |
| 1890 | Requires=redis.service |
| 1891 | |
| 1892 | [Service] |
| 1893 | Type=forking |
| 1894 | User=fossilrepo |
| 1895 | Group=fossilrepo |
| 1896 | WorkingDirectory=${OPT_PREFIX} |
| 1897 | EnvironmentFile=${env_file} |
| 1898 | ExecStart=${OPT_PREFIX}/.venv/bin/celery -A config.celery worker \\ |
| 1899 | -l info \\ |
| 1900 | -Q celery \\ |
| 1901 | --detach \\ |
| 1902 | --pidfile=${OPT_PREFIX}/celery-worker.pid \\ |
| 1903 | --logfile=${LOG_DIR}/celery-worker.log |
| 1904 | ExecStop=/bin/kill -s TERM \$(cat ${OPT_PREFIX}/celery-worker.pid) |
| 1905 | PIDFile=${OPT_PREFIX}/celery-worker.pid |
| 1906 | Restart=on-failure |
| 1907 | RestartSec=10 |
| 1908 | |
| 1909 | [Install] |
| 1910 | WantedBy=multi-user.target" |
| 1911 | |
| 1912 | write_file "/etc/systemd/system/fossilrepo-celery-worker.service" "$celery_worker_unit" |
| 1913 | |
| 1914 | # --- celery beat --- |
| 1915 | local celery_beat_unit |
| 1916 | celery_beat_unit="[Unit] |
| 1917 | Description=fossilrepo Celery beat scheduler |
| 1918 | After=network.target postgresql.service redis.service |
| 1919 | Requires=redis.service |
| 1920 | |
| 1921 | [Service] |
| 1922 | Type=forking |
| 1923 | User=fossilrepo |
| 1924 | Group=fossilrepo |
| 1925 | WorkingDirectory=${OPT_PREFIX} |
| 1926 | EnvironmentFile=${env_file} |
| 1927 | ExecStart=${OPT_PREFIX}/.venv/bin/celery -A config.celery beat \\ |
| 1928 | -l info \\ |
| 1929 | --scheduler django_celery_beat.schedulers:DatabaseScheduler \\ |
| 1930 | --detach \\ |
| 1931 | --pidfile=${OPT_PREFIX}/celery-beat.pid \\ |
| 1932 | --logfile=${LOG_DIR}/celery-beat.log |
| 1933 | ExecStop=/bin/kill -s TERM \$(cat ${OPT_PREFIX}/celery-beat.pid) |
| 1934 | PIDFile=${OPT_PREFIX}/celery-beat.pid |
| 1935 | Restart=on-failure |
| 1936 | RestartSec=10 |
| 1937 | |
| 1938 | [Install] |
| 1939 | WantedBy=multi-user.target" |
| 1940 | |
| 1941 | write_file "/etc/systemd/system/fossilrepo-celery-beat.service" "$celery_beat_unit" |
| 1942 | |
| 1943 | # --- litestream (optional) --- |
| 1944 | if [[ -n "$OPT_S3_BUCKET" ]]; then |
| 1945 | local litestream_unit |
| 1946 | litestream_unit="[Unit] |
| 1947 | Description=fossilrepo Litestream replication |
| 1948 | After=network.target |
| 1949 | |
| 1950 | [Service] |
| 1951 | Type=simple |
| 1952 | User=fossilrepo |
| 1953 | Group=fossilrepo |
| 1954 | EnvironmentFile=${env_file} |
| 1955 | ExecStart=/usr/local/bin/litestream replicate -config ${OPT_PREFIX}/litestream.yml |
| 1956 | Restart=on-failure |
| 1957 | RestartSec=10 |
| 1958 | StandardOutput=append:${LOG_DIR}/litestream.log |
| 1959 | StandardError=append:${LOG_DIR}/litestream.log |
| 1960 | |
| 1961 | [Install] |
| 1962 | WantedBy=multi-user.target" |
| 1963 | |
| 1964 | # Generate litestream config for bare metal |
| 1965 | local ls_content |
| 1966 | ls_content="# Litestream replication -- continuous .fossil backup to S3 |
| 1967 | # Generated by installer |
| 1968 | |
| 1969 | dbs: |
| 1970 | - path: ${DATA_DIR}/repos/*.fossil |
| 1971 | replicas: |
| 1972 | - type: s3 |
| 1973 | bucket: ${OPT_S3_BUCKET} |
| 1974 | endpoint: ${OPT_S3_ENDPOINT} |
| 1975 | region: ${OPT_S3_REGION:-us-east-1} |
| 1976 | access-key-id: \${AWS_ACCESS_KEY_ID} |
| 1977 | secret-access-key: \${AWS_SECRET_ACCESS_KEY}" |
| 1978 | |
| 1979 | write_file "${OPT_PREFIX}/litestream.yml" "$ls_content" |
| 1980 | chown fossilrepo:fossilrepo "${OPT_PREFIX}/litestream.yml" |
| 1981 | write_file "/etc/systemd/system/fossilrepo-litestream.service" "$litestream_unit" |
| 1982 | fi |
| 1983 | |
| 1984 | # Reload systemd and enable all services |
| 1985 | systemctl daemon-reload |
| 1986 | systemctl enable fossilrepo-web.service |
| 1987 | systemctl enable fossilrepo-celery-worker.service |
| 1988 | systemctl enable fossilrepo-celery-beat.service |
| 1989 | systemctl enable caddy.service |
| 1990 | [[ -n "$OPT_S3_BUCKET" ]] && systemctl enable fossilrepo-litestream.service |
| 1991 | |
| 1992 | # Start services |
| 1993 | systemctl start caddy.service |
| 1994 | systemctl start fossilrepo-web.service |
| 1995 | systemctl start fossilrepo-celery-worker.service |
| 1996 | systemctl start fossilrepo-celery-beat.service |
| 1997 | [[ -n "$OPT_S3_BUCKET" ]] && systemctl start fossilrepo-litestream.service |
| 1998 | |
| 1999 | log_ok "All systemd services created and started" |
| 2000 | } |
| 2001 | |
| 2002 | setup_logrotate() { |
| 2003 | log_info "Configuring log rotation..." |
| 2004 | |
| 2005 | local logrotate_content |
| 2006 | logrotate_content="${LOG_DIR}/*.log { |
| 2007 | daily |
| 2008 | missingok |
| 2009 | rotate 14 |
| 2010 | compress |
| 2011 | delaycompress |
| 2012 | notifempty |
| 2013 | create 0640 fossilrepo fossilrepo |
| 2014 | sharedscripts |
| 2015 | postrotate |
| 2016 | systemctl reload fossilrepo-web.service 2>/dev/null || true |
| 2017 | endscript |
| 2018 | }" |
| 2019 | |
| 2020 | write_file "/etc/logrotate.d/fossilrepo" "$logrotate_content" |
| 2021 | log_ok "Log rotation configured (14 days, compressed)" |
| 2022 | } |
| 2023 | |
| 2024 | install_bare_metal() { |
| 2025 | log_step "Installing fossilrepo (Bare Metal mode)" |
| 2026 | |
| 2027 | create_system_user |
| 2028 | clone_repo |
| 2029 | setup_python_venv |
| 2030 | setup_postgres |
| 2031 | setup_redis |
| 2032 | generate_bare_metal_env |
| 2033 | run_django_setup |
| 2034 | setup_caddy_bare_metal |
| 2035 | create_systemd_services |
| 2036 | setup_logrotate |
| 2037 | |
| 2038 | log_ok "Bare metal installation complete" |
| 2039 | } |
| 2040 | |
| 2041 | # ============================================================================ |
| 2042 | # Section 11: Uninstall Generator |
| 2043 | # ============================================================================ |
| 2044 | |
| 2045 | generate_uninstall_script() { |
| 2046 | log_info "Generating uninstall script..." |
| 2047 | |
| 2048 | local uninstall_content |
| 2049 | uninstall_content="#!/usr/bin/env bash |
| 2050 | # fossilrepo uninstaller |
| 2051 | # Generated by installer on $(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 2052 | # Mode: ${OPT_MODE} |
| 2053 | |
| 2054 | set -euo pipefail |
| 2055 | |
| 2056 | echo 'fossilrepo uninstaller' |
| 2057 | echo '======================' |
| 2058 | echo '' |
| 2059 | echo 'This will remove all fossilrepo services, files, and data.' |
| 2060 | echo 'PostgreSQL data and Fossil repositories will be DELETED.' |
| 2061 | echo '' |
| 2062 | read -p 'Are you sure? Type YES to confirm: ' confirm |
| 2063 | [[ \"\$confirm\" == \"YES\" ]] || { echo 'Aborted.'; exit 1; }" |
| 2064 | |
| 2065 | if [[ "$OPT_MODE" == "docker" ]]; then |
| 2066 | uninstall_content+=" |
| 2067 | |
| 2068 | echo 'Stopping Docker services...' |
| 2069 | cd '${OPT_PREFIX}' |
| 2070 | docker compose down -v 2>/dev/null || true |
| 2071 | |
| 2072 | echo 'Removing systemd service...' |
| 2073 | systemctl stop fossilrepo.service 2>/dev/null || true |
| 2074 | systemctl disable fossilrepo.service 2>/dev/null || true |
| 2075 | rm -f /etc/systemd/system/fossilrepo.service |
| 2076 | systemctl daemon-reload |
| 2077 | |
| 2078 | echo 'Removing install directory...' |
| 2079 | rm -rf '${OPT_PREFIX}' |
| 2080 | |
| 2081 | echo 'Done. Docker images may still be cached -- run \"docker system prune\" to reclaim space.'" |
| 2082 | else |
| 2083 | uninstall_content+=" |
| 2084 | |
| 2085 | echo 'Stopping services...' |
| 2086 | systemctl stop fossilrepo-web.service 2>/dev/null || true |
| 2087 | systemctl stop fossilrepo-celery-worker.service 2>/dev/null || true |
| 2088 | systemctl stop fossilrepo-celery-beat.service 2>/dev/null || true |
| 2089 | systemctl stop fossilrepo-litestream.service 2>/dev/null || true |
| 2090 | systemctl stop caddy.service 2>/dev/null || true |
| 2091 | |
| 2092 | echo 'Disabling services...' |
| 2093 | systemctl disable fossilrepo-web.service 2>/dev/null || true |
| 2094 | systemctl disable fossilrepo-celery-worker.service 2>/dev/null || true |
| 2095 | systemctl disable fossilrepo-celery-beat.service 2>/dev/null || true |
| 2096 | systemctl disable fossilrepo-litestream.service 2>/dev/null || true |
| 2097 | |
| 2098 | echo 'Removing systemd units...' |
| 2099 | rm -f /etc/systemd/system/fossilrepo-web.service |
| 2100 | rm -f /etc/systemd/system/fossilrepo-celery-worker.service |
| 2101 | rm -f /etc/systemd/system/fossilrepo-celery-beat.service |
| 2102 | rm -f /etc/systemd/system/fossilrepo-litestream.service |
| 2103 | rm -f /etc/systemd/system/caddy.service |
| 2104 | systemctl daemon-reload |
| 2105 | |
| 2106 | echo 'Removing PostgreSQL database and user...' |
| 2107 | sudo -u postgres psql -c \"DROP DATABASE IF EXISTS ${OPT_DB_NAME};\" 2>/dev/null || true |
| 2108 | sudo -u postgres psql -c \"DROP USER IF EXISTS ${OPT_DB_USER};\" 2>/dev/null || true |
| 2109 | |
| 2110 | echo 'Removing Caddy config...' |
| 2111 | rm -f /etc/caddy/Caddyfile |
| 2112 | |
| 2113 | echo 'Removing logrotate config...' |
| 2114 | rm -f /etc/logrotate.d/fossilrepo |
| 2115 | |
| 2116 | echo 'Removing data directories...' |
| 2117 | rm -rf '${DATA_DIR}/repos' |
| 2118 | rm -rf '${DATA_DIR}/trash' |
| 2119 | rm -rf '${DATA_DIR}/ssh' |
| 2120 | rm -rf '${DATA_DIR}/git-mirrors' |
| 2121 | rm -rf '${DATA_DIR}/ssh-keys' |
| 2122 | rm -rf '${LOG_DIR}' |
| 2123 | |
| 2124 | echo 'Removing install directory...' |
| 2125 | rm -rf '${OPT_PREFIX}' |
| 2126 | |
| 2127 | echo 'Removing system user...' |
| 2128 | userdel -r fossilrepo 2>/dev/null || true |
| 2129 | |
| 2130 | echo 'Done. System packages (PostgreSQL, Redis, Fossil, Caddy) were NOT removed.'" |
| 2131 | fi |
| 2132 | |
| 2133 | uninstall_content+=" |
| 2134 | |
| 2135 | echo '' |
| 2136 | echo 'fossilrepo has been uninstalled.'" |
| 2137 | |
| 2138 | write_file "${OPT_PREFIX}/uninstall.sh" "$uninstall_content" "0755" |
| 2139 | log_ok "Uninstall script: ${OPT_PREFIX}/uninstall.sh" |
| 2140 | } |
| 2141 | |
| 2142 | # ============================================================================ |
| 2143 | # Section 12: Post-Install Summary |
| 2144 | # ============================================================================ |
| 2145 | |
| 2146 | show_summary() { |
| 2147 | local proto="http" |
| 2148 | [[ "$OPT_SSL" == "true" ]] && proto="https" |
| 2149 | local base_url="${proto}://${OPT_DOMAIN}" |
| 2150 | |
| 2151 | if [[ "$OPT_DOMAIN" == "localhost" && "$OPT_PORT" != "80" && "$OPT_PORT" != "443" ]]; then |
| 2152 | base_url="${proto}://localhost:${OPT_PORT}" |
| 2153 | fi |
| 2154 | |
| 2155 | local box_width=64 |
| 2156 | local border |
| 2157 | border="$(printf '%*s' $box_width '' | tr ' ' '=')" |
| 2158 | |
| 2159 | printf "\n" |
| 2160 | printf "${_C_GREEN}${_C_BOLD}" |
| 2161 | printf " %s\n" "$border" |
| 2162 | printf " %-${box_width}s\n" " fossilrepo installation complete" |
| 2163 | printf " %s\n" "$border" |
| 2164 | printf "${_C_RESET}" |
| 2165 | printf "\n" |
| 2166 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Web UI:" "${base_url}" |
| 2167 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Django Admin:" "${base_url}/admin/" |
| 2168 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Health Check:" "${base_url}/health/" |
| 2169 | printf "\n" |
| 2170 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin username:" "$OPT_ADMIN_USER" |
| 2171 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin email:" "$OPT_ADMIN_EMAIL" |
| 2172 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Admin password:" "$GEN_ADMIN_PASSWORD" |
| 2173 | printf "\n" |
| 2174 | |
| 2175 | if [[ "$OPT_DOMAIN" != "localhost" ]]; then |
| 2176 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "SSH clone:" "ssh://fossil@${OPT_DOMAIN}:2222/<repo>" |
| 2177 | fi |
| 2178 | |
| 2179 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Install mode:" "$OPT_MODE" |
| 2180 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Install prefix:" "$OPT_PREFIX" |
| 2181 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Config file:" "${OPT_PREFIX}/.env" |
| 2182 | printf " ${_C_BOLD}%-24s${_C_RESET} %s\n" "Uninstall:" "${OPT_PREFIX}/uninstall.sh" |
| 2183 | printf "\n" |
| 2184 | |
| 2185 | if [[ "$OPT_MODE" == "docker" ]]; then |
| 2186 | printf " ${_C_BOLD}Useful commands:${_C_RESET}\n" |
| 2187 | printf " cd %s\n" "$OPT_PREFIX" |
| 2188 | printf " docker compose logs -f # tail all logs\n" |
| 2189 | printf " docker compose logs -f app # tail app logs\n" |
| 2190 | printf " docker compose exec app bash # shell into app container\n" |
| 2191 | printf " docker compose restart # restart all services\n" |
| 2192 | printf " docker compose down # stop all services\n" |
| 2193 | else |
| 2194 | printf " ${_C_BOLD}Useful commands:${_C_RESET}\n" |
| 2195 | printf " systemctl status fossilrepo-web # check web status\n" |
| 2196 | printf " journalctl -u fossilrepo-web -f # tail web logs\n" |
| 2197 | printf " journalctl -u fossilrepo-celery-worker -f # tail worker logs\n" |
| 2198 | printf " systemctl restart fossilrepo-web # restart web\n" |
| 2199 | printf " tail -f %s/*.log # tail log files\n" "$LOG_DIR" |
| 2200 | fi |
| 2201 | |
| 2202 | printf "\n" |
| 2203 | |
| 2204 | if [[ -n "$OPT_S3_BUCKET" ]]; then |
| 2205 | printf " ${_C_BOLD}Litestream backup:${_C_RESET} s3://%s\n" "$OPT_S3_BUCKET" |
| 2206 | fi |
| 2207 | |
| 2208 | printf "${_C_YELLOW} IMPORTANT: Save the admin password above -- it will not be shown again.${_C_RESET}\n" |
| 2209 | printf "\n" |
| 2210 | |
| 2211 | # Write credentials to a restricted file for reference |
| 2212 | local creds_content |
| 2213 | creds_content="# fossilrepo credentials -- generated $(date -u +%Y-%m-%dT%H:%M:%SZ) |
| 2214 | # KEEP THIS FILE SECURE -- delete after recording credentials elsewhere |
| 2215 | |
| 2216 | ADMIN_USER=${OPT_ADMIN_USER} |
| 2217 | ADMIN_EMAIL=${OPT_ADMIN_EMAIL} |
| 2218 | ADMIN_PASSWORD=${GEN_ADMIN_PASSWORD} |
| 2219 | DB_PASSWORD=${GEN_DB_PASSWORD} |
| 2220 | DJANGO_SECRET_KEY=${GEN_SECRET_KEY}" |
| 2221 | |
| 2222 | write_file "${OPT_PREFIX}/.credentials" "$creds_content" "0600" |
| 2223 | if [[ "$OPT_MODE" == "bare-metal" ]]; then |
| 2224 | chown fossilrepo:fossilrepo "${OPT_PREFIX}/.credentials" |
| 2225 | fi |
| 2226 | printf " Credentials also saved to: ${_C_BOLD}%s/.credentials${_C_RESET} (mode 0600)\n" "$OPT_PREFIX" |
| 2227 | printf "\n" |
| 2228 | } |
| 2229 | |
| 2230 | # ============================================================================ |
| 2231 | # Section 13: Validation + Secret Generation + Main Dispatcher |
| 2232 | # ============================================================================ |
| 2233 | |
| 2234 | validate_options() { |
| 2235 | # Validate mode |
| 2236 | case "$OPT_MODE" in |
| 2237 | docker|bare-metal) ;; |
| 2238 | *) die "Invalid mode: '$OPT_MODE'. Must be 'docker' or 'bare-metal'." ;; |
| 2239 | esac |
| 2240 | |
| 2241 | # Validate port |
| 2242 | if ! [[ "$OPT_PORT" =~ ^[0-9]+$ ]] || ((OPT_PORT < 1 || OPT_PORT > 65535)); then |
| 2243 | die "Invalid port: $OPT_PORT" |
| 2244 | fi |
| 2245 | |
| 2246 | # Validate domain (basic check) |
| 2247 | if [[ -z "$OPT_DOMAIN" ]]; then |
| 2248 | die "Domain must not be empty" |
| 2249 | fi |
| 2250 | |
| 2251 | # Default admin email |
| 2252 | if [[ -z "$OPT_ADMIN_EMAIL" ]]; then |
| 2253 | OPT_ADMIN_EMAIL="${OPT_ADMIN_USER}@${OPT_DOMAIN}" |
| 2254 | fi |
| 2255 | |
| 2256 | # S3: if bucket is set, region should be too |
| 2257 | if [[ -n "$OPT_S3_BUCKET" && -z "$OPT_S3_REGION" ]]; then |
| 2258 | OPT_S3_REGION="us-east-1" |
| 2259 | fi |
| 2260 | |
| 2261 | # Warn about SSL on localhost |
| 2262 | if [[ "$OPT_SSL" == "true" && ( "$OPT_DOMAIN" == "localhost" || "$OPT_DOMAIN" == "127.0.0.1" ) ]]; then |
| 2263 | log_warn "SSL is enabled but domain is '$OPT_DOMAIN' -- Let's Encrypt will not work. Disabling SSL." |
| 2264 | OPT_SSL="false" |
| 2265 | fi |
| 2266 | } |
| 2267 | |
| 2268 | auto_generate_secrets() { |
| 2269 | # Generate any secrets not provided by the user |
| 2270 | GEN_SECRET_KEY="$(generate_secret_key)" |
| 2271 | verbose "Generated Django secret key" |
| 2272 | |
| 2273 | if [[ -n "$OPT_DB_PASSWORD" ]]; then |
| 2274 | GEN_DB_PASSWORD="$OPT_DB_PASSWORD" |
| 2275 | else |
| 2276 | GEN_DB_PASSWORD="$(generate_password 32)" |
| 2277 | verbose "Generated database password" |
| 2278 | fi |
| 2279 | |
| 2280 | if [[ -n "$OPT_ADMIN_PASSWORD" ]]; then |
| 2281 | GEN_ADMIN_PASSWORD="$OPT_ADMIN_PASSWORD" |
| 2282 | else |
| 2283 | GEN_ADMIN_PASSWORD="$(generate_password 24)" |
| 2284 | verbose "Generated admin password" |
| 2285 | fi |
| 2286 | } |
| 2287 | |
| 2288 | show_config_summary() { |
| 2289 | printf "\n" |
| 2290 | log_step "Configuration" |
| 2291 | printf " %-24s %s\n" "Mode:" "$OPT_MODE" |
| 2292 | printf " %-24s %s\n" "Domain:" "$OPT_DOMAIN" |
| 2293 | printf " %-24s %s\n" "SSL:" "$OPT_SSL" |
| 2294 | printf " %-24s %s\n" "Port:" "$OPT_PORT" |
| 2295 | printf " %-24s %s\n" "Install prefix:" "$OPT_PREFIX" |
| 2296 | printf " %-24s %s\n" "Database:" "${OPT_DB_NAME} (user: ${OPT_DB_USER})" |
| 2297 | printf " %-24s %s\n" "Admin:" "${OPT_ADMIN_USER} <${OPT_ADMIN_EMAIL}>" |
| 2298 | if [[ -n "$OPT_S3_BUCKET" ]]; then |
| 2299 | printf " %-24s %s (region: %s)\n" "S3 backup:" "$OPT_S3_BUCKET" "${OPT_S3_REGION:-us-east-1}" |
| 2300 | else |
| 2301 | printf " %-24s %s\n" "S3 backup:" "disabled" |
| 2302 | fi |
| 2303 | printf "\n" |
| 2304 | } |
| 2305 | |
| 2306 | main() { |
| 2307 | _color_init |
| 2308 | parse_args "$@" |
| 2309 | require_root |
| 2310 | detect_os |
| 2311 | |
| 2312 | # Run interactive TUI if mode is not set |
| 2313 | if [[ -z "$OPT_MODE" ]]; then |
| 2314 | run_interactive |
| 2315 | fi |
| 2316 | |
| 2317 | validate_options |
| 2318 | auto_generate_secrets |
| 2319 | show_config_summary |
| 2320 | confirm "Begin installation?" |
| 2321 | check_and_install_deps |
| 2322 | |
| 2323 | if [[ "$OPT_MODE" == "docker" ]]; then |
| 2324 | install_docker |
| 2325 | else |
| 2326 | install_bare_metal |
| 2327 | fi |
| 2328 | |
| 2329 | generate_uninstall_script |
| 2330 | show_summary |
| 2331 | } |
| 2332 | |
| 2333 | main "$@" |