Publish deployment and backup scripts
This commit is contained in:
514
common.sh
Normal file
514
common.sh
Normal file
@@ -0,0 +1,514 @@
|
||||
# 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
|
||||
}
|
||||
Reference in New Issue
Block a user