Files
deployment-scripts/common.sh

515 lines
19 KiB
Bash

# common.sh — Shared functions sourced by all deploy and backup scripts
# This file should NOT be executed directly.
#
# Usage: source "${SCRIPT_DIR}/common.sh"
# ──────────────────────────── VARIABLES ───────────────────────────────────────
ACME_EMAIL="${ACME_EMAIL:-admin@an2.io}"
TRAEFIK_DIR="/opt/traefik"
TRAEFIK_DYNAMIC_DIR="/opt/traefik/dynamic"
DOCKER_NETWORK="traefik-public"
# ──────────────────────────── COLOURS / LOGGING ───────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[ OK ]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
fatal() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; }
# ──────────────────────────── ARGUMENT PARSING ────────────────────────────────
parse_args() {
ARG_DOMAIN=""
ARG_REMOVE="0"
ARG_PURGE="0"
ARG_YES="0"
ARG_COLD="0"
ARG_DIR=""
ARG_EXTRA=()
while [[ $# -gt 0 ]]; do
case "$1" in
--domain)
[[ $# -ge 2 ]] || fatal "--domain requires a value"
ARG_DOMAIN="$2"; shift 2 ;;
--dir)
[[ $# -ge 2 ]] || fatal "--dir requires a value"
ARG_DIR="$2"; shift 2 ;;
--remove) ARG_REMOVE="1"; shift ;;
--purge) ARG_PURGE="1"; shift ;;
--yes) ARG_YES="1"; shift ;;
--cold) ARG_COLD="1"; shift ;;
*) ARG_EXTRA+=("$1"); shift ;;
esac
done
}
# ──────────────────────────── REQUIRE ROOT ────────────────────────────────────
require_root() {
[[ $EUID -eq 0 ]] || fatal "This script must be run as root (or via sudo)."
}
# ──────────────────────────── OS DETECTION ────────────────────────────────────
detect_os() {
if [[ -f /etc/os-release ]]; then
# shellcheck source=/dev/null
source /etc/os-release
OS_ID="${ID,,}"
OS_VERSION="${VERSION_ID:-}"
OS_NAME="${PRETTY_NAME}"
else
fatal "Cannot detect OS — /etc/os-release not found."
fi
case "$OS_ID" in
fedora|rhel|centos|rocky|alma|amzn)
DISTRO_FAMILY="fedora" ;;
debian|ubuntu|pop|linuxmint|raspbian|kali)
DISTRO_FAMILY="debian" ;;
*)
fatal "Unsupported distro: $OS_ID ($OS_NAME). Supported: Fedora/RHEL family, Debian/Ubuntu family." ;;
esac
ok "Detected OS: $OS_NAME (family: $DISTRO_FAMILY)"
}
# ──────────────────────────── INSTALL PREREQUISITES ───────────────────────────
install_prerequisites() {
case "$DISTRO_FAMILY" in
debian)
info "Installing prerequisites (Debian/Ubuntu)..."
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
apt-transport-https ca-certificates curl gnupg lsb-release \
openssl jq net-tools > /dev/null 2>&1
if ! command -v docker &>/dev/null; then
info "Installing Docker Engine..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/${OS_ID}/gpg" | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null
chmod a+r /etc/apt/keyrings/docker.gpg
local codename
codename="$(. /etc/os-release && echo "${VERSION_CODENAME:-$(lsb_release -cs)}")"
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/${OS_ID} ${codename} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1
fi
ok "Prerequisites installed (Debian/Ubuntu)."
;;
fedora)
info "Installing prerequisites (Fedora/RHEL)..."
dnf install -y -q \
ca-certificates curl gnupg2 openssl jq net-tools > /dev/null 2>&1
if ! command -v docker &>/dev/null; then
info "Installing Docker Engine..."
dnf -y install dnf-plugins-core > /dev/null 2>&1
dnf config-manager --add-repo \
https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null || \
dnf config-manager --add-repo \
https://download.docker.com/linux/centos/docker-ce.repo 2>/dev/null
dnf install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin > /dev/null 2>&1
fi
ok "Prerequisites installed (Fedora/RHEL)."
;;
esac
systemctl enable --now docker > /dev/null 2>&1
ok "Docker $(docker --version | awk '{print $3}') is running."
if ! docker compose version &>/dev/null; then
fatal "docker compose v2 plugin not found. Please install docker-compose-plugin."
fi
ok "Docker Compose $(docker compose version --short) available."
}
# ──────────────────────────── DOCKER NETWORK ──────────────────────────────────
ensure_docker_network() {
docker network inspect "$DOCKER_NETWORK" &>/dev/null || \
docker network create "$DOCKER_NETWORK"
ok "Docker network '$DOCKER_NETWORK' ready."
}
# ──────────────────────────── TRAEFIK ─────────────────────────────────────────
# Deploys shared Traefik at /opt/traefik/ if not already present.
# Uses file provider (not Docker socket) — Docker Engine 29+ has API
# negotiation bugs with Traefik's Go client, and the docs warn about
# Docker socket security.
ensure_traefik() {
# Already running — nothing to do
if [[ -f "${TRAEFIK_DIR}/docker-compose.yml" ]]; then
if docker ps --format '{{.Names}}' | grep -q '^traefik$'; then
ok "Traefik is already running."
return 0
fi
info "Traefik compose exists but not running. Starting..."
(cd "$TRAEFIK_DIR" && docker compose up -d)
ok "Traefik started."
return 0
fi
# Full deploy
info "Deploying Traefik (file provider, Let's Encrypt)..."
# Check port 443
if ss -tlnp 2>/dev/null | grep -q ':443 '; then
local proc
proc="$(ss -tlnp 2>/dev/null | grep ':443 ')"
if ! echo "$proc" | grep -q 'traefik'; then
echo "$proc" | sed 's/^/ /'
fatal "Port 443 is in use by another process. Free it before deploying Traefik."
fi
fi
# Warn if port 80 is busy (LE HTTP challenge needs it)
if ss -tlnp 2>/dev/null | grep -q ':80 '; then
warn "Port 80 is in use. Let's Encrypt HTTP challenge requires port 80."
fi
mkdir -p "$TRAEFIK_DIR" "$TRAEFIK_DYNAMIC_DIR"
touch "${TRAEFIK_DIR}/acme.json"
chmod 600 "${TRAEFIK_DIR}/acme.json"
cat > "${TRAEFIK_DIR}/docker-compose.yml" <<EOF
# Traefik reverse proxy — shared by all services
# Static config via CLI flags (per official Traefik docs)
# Dynamic routing: one .yml per service in ./dynamic/, hot-reloaded
# NO Docker socket — secure, avoids Docker Engine 29+ API bugs
services:
traefik:
image: traefik:v3.4
container_name: traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
mem_limit: 1G
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "80:80"
- "443:443"
command:
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.transport.respondingTimeouts.readTimeout=600s"
- "--entrypoints.websecure.transport.respondingTimeouts.writeTimeout=600s"
- "--entrypoints.websecure.transport.respondingTimeouts.idleTimeout=600s"
- "--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL}"
- "--certificatesresolvers.letsencrypt.acme.storage=/acme.json"
- "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"
- "--providers.file.directory=/dynamic"
- "--providers.file.watch=true"
- "--log.level=WARN"
volumes:
- ./acme.json:/acme.json
- ./dynamic:/dynamic:ro
networks:
- traefik-public
logging:
driver: json-file
options:
max-size: "50m"
max-file: "5"
networks:
traefik-public:
external: true
EOF
(cd "$TRAEFIK_DIR" && docker compose up -d)
install_systemd_unit "traefik-docker" "$TRAEFIK_DIR"
ok "Traefik deployed at $TRAEFIK_DIR."
}
# ──────────────────────────── FIREWALL ────────────────────────────────────────
configure_firewall() {
local extra_ports=("$@")
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "active"; then
ufw allow 80/tcp > /dev/null 2>&1
ufw allow 443/tcp > /dev/null 2>&1
for port in "${extra_ports[@]}"; do
ufw allow "$port" > /dev/null 2>&1
done
ok "UFW rules added (80, 443${extra_ports:+, ${extra_ports[*]}})."
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-service=http > /dev/null 2>&1
firewall-cmd --permanent --add-service=https > /dev/null 2>&1
for port in "${extra_ports[@]}"; do
firewall-cmd --permanent --add-port="$port" > /dev/null 2>&1
done
firewall-cmd --reload > /dev/null 2>&1
ok "Firewalld rules added (http, https${extra_ports:+, ${extra_ports[*]}})."
else
warn "No active firewall detected. Ensure ports 80, 443${extra_ports:+, ${extra_ports[*]}} are open."
fi
}
# ──────────────────────────── TRAEFIK DYNAMIC CONFIG ──────────────────────────
write_traefik_dynamic_config() {
local config_name="$1"
local domain="$2"
local backend_url="$3"
info "Writing Traefik dynamic config: ${config_name}.yml"
cat > "${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml" <<EOF
http:
routers:
${config_name}:
rule: "Host(\`${domain}\`)"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
service: ${config_name}
middlewares:
- ${config_name}-headers
services:
${config_name}:
loadBalancer:
servers:
- url: "${backend_url}"
middlewares:
${config_name}-headers:
headers:
customRequestHeaders:
X-Forwarded-Proto: "https"
stsSeconds: 63072000
stsIncludeSubdomains: true
stsPreload: true
forceSTSHeader: true
frameDeny: true
contentTypeNosniff: true
browserXssFilter: true
EOF
ok "Traefik dynamic config: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
}
remove_traefik_dynamic_config() {
local config_name="$1"
local config_file="${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
if [[ -f "$config_file" ]]; then
rm -f "$config_file"
ok "Removed Traefik dynamic config: ${config_file}"
fi
}
# ──────────────────────────── BACKUP / RESTORE ────────────────────────────────
check_and_restore_backup() {
local service_name="$1"
local backup_file=""
# Search order: scp'd backups near the script, then server-side locations
local search_paths=(
"${SCRIPT_DIR}/backups/backup-${service_name}.tar.gz"
"${SCRIPT_DIR}/backup-${service_name}.tar.gz"
"/root/backup-${service_name}.tar.gz"
)
for path in "${search_paths[@]}"; do
if [[ -f "$path" ]]; then
backup_file="$path"
break
fi
done
# Check /opt/backup/ for timestamped backups (most recent first)
if [[ -z "$backup_file" ]]; then
local latest
latest="$(ls -t "/opt/backup/backup-${service_name}-"*.tar.gz 2>/dev/null | head -1)" || true
if [[ -n "$latest" ]]; then
backup_file="$latest"
fi
fi
if [[ -z "$backup_file" ]]; then
return 1
fi
local size
size="$(du -h "$backup_file" | cut -f1)"
info "Found backup: $backup_file ($size)"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Restore from this backup? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Skipping backup restore."
return 1
fi
fi
info "Restoring backup..."
tar xf "$backup_file" -C /
ok "Backup restored from $backup_file"
return 0
}
# ──────────────────────────── HEALTH CHECK ────────────────────────────────────
wait_for_healthy() {
local container_name="$1"
local health_url="$2"
local timeout="${3:-120}"
info "Waiting for ${container_name} to become healthy (up to ${timeout}s)..."
local elapsed=0
while (( elapsed < timeout )); do
# Try curl first, fall back to wget (Alpine images lack curl)
if docker exec "$container_name" curl -sf "$health_url" > /dev/null 2>&1 || \
docker exec "$container_name" wget -q --spider "$health_url" 2>/dev/null; then
ok "${container_name} is healthy."
return 0
fi
sleep 5
(( elapsed += 5 ))
done
warn "${container_name} did not become healthy within ${timeout}s. Check: docker logs ${container_name}"
return 1
}
# ──────────────────────────── PASSWORD GENERATION ─────────────────────────────
generate_password() {
openssl rand -base64 32 | tr -d '/+=' | head -c 32
}
# ──────────────────────────── SYSTEMD UNITS ───────────────────────────────────
install_systemd_unit() {
local unit_name="$1"
local working_directory="$2"
cat > "/etc/systemd/system/${unit_name}.service" <<EOF
[Unit]
Description=${unit_name} Docker Compose Stack
Requires=docker.service
After=docker.service traefik-docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${working_directory}
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${unit_name}.service" > /dev/null 2>&1
ok "Systemd unit installed: ${unit_name}.service"
}
remove_systemd_unit() {
local unit_name="$1"
local unit_file="/etc/systemd/system/${unit_name}.service"
if [[ -f "$unit_file" ]]; then
systemctl stop "${unit_name}.service" > /dev/null 2>&1 || true
systemctl disable "${unit_name}.service" > /dev/null 2>&1 || true
rm -f "$unit_file"
systemctl daemon-reload
ok "Removed systemd unit: ${unit_name}.service"
fi
}
# ──────────────────────────── REMOVAL ─────────────────────────────────────────
do_remove() {
local service_name="$1"
local config_name="$2"
local unit_name="$3"
local base_dir="$4"
local backup_script="${SCRIPT_DIR}/backup-${service_name}.sh"
echo ""
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo -e "${YELLOW} Removing ${service_name}${NC}"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" == "1" ]]; then
echo -e " Mode: ${RED}Purge (delete all data)${NC}"
else
echo -e " Mode: Safe (keep data)"
fi
echo -e " Data: ${base_dir}"
[[ -n "$config_name" ]] && \
echo -e " Traefik: ${TRAEFIK_DYNAMIC_DIR}/${config_name}.yml"
echo -e " Systemd: ${unit_name}.service"
echo -e "${YELLOW}══════════════════════════════════════════════${NC}"
echo ""
# Suggest backup before purge
if [[ "$ARG_PURGE" == "1" ]] && [[ -d "$base_dir" ]]; then
if [[ -f "$backup_script" ]]; then
warn "Back up first? ${backup_script}"
fi
echo ""
fi
# Stop containers
if [[ -d "$base_dir" ]] && [[ -f "${base_dir}/docker-compose.yml" ]]; then
(cd "$base_dir" && docker compose down) || true
ok "Containers stopped."
fi
# Remove traefik dynamic config
[[ -n "$config_name" ]] && remove_traefik_dynamic_config "$config_name"
# Remove systemd unit
remove_systemd_unit "$unit_name"
# Purge data if requested
if [[ "$ARG_PURGE" == "1" ]]; then
echo ""
warn "This will permanently delete: $base_dir"
if [[ "$ARG_YES" != "1" ]]; then
read -rp "Are you sure? [y/N] " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Purge cancelled."
return 0
fi
fi
rm -rf "$base_dir"
ok "Purged: $base_dir"
fi
# Summary
echo ""
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo -e "${GREEN} ${service_name} removed${NC}"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
if [[ "$ARG_PURGE" != "1" ]]; then
echo -e " Data preserved at ${base_dir}"
echo -e " To purge: $0 --remove --purge"
fi
echo -e " To redeploy: $0"
echo -e "${GREEN}══════════════════════════════════════════════${NC}"
echo ""
return 0
}