FossilRepo

Add omnibus installer: bare metal + Docker, interactive + unattended install.sh -- single script that deploys fossilrepo on any Linux box. Two modes: --docker (compose) or --bare-metal (systemd services). Interactive TUI when no flags given, unattended with --yes. Supports YAML config files. Tested on Amazon Linux 2023 ARM64. Handles: Python 3.12, PostgreSQL 15/16, Redis, Fossil (from source), Caddy (auto-SSL), Litestream (S3 backup), uv, gunicorn, celery.

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

Keyboard Shortcuts

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