FossilRepo

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

Keyboard Shortcuts

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