FossilRepo

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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button