550 lines
No EOL
21 KiB
Bash
550 lines
No EOL
21 KiB
Bash
#!/bin/bash
|
|
|
|
# ComposeSync - Automated Docker Compose Update Agent
|
|
# This script downloads and applies updates to docker-compose.yml files from remote sources
|
|
|
|
set -euo pipefail
|
|
|
|
# Source the configuration parser (if available)
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
if [ -f "$SCRIPT_DIR/config-parser.sh" ]; then
|
|
source "$SCRIPT_DIR/config-parser.sh"
|
|
else
|
|
# Fallback for .env-only usage
|
|
CONFIG_DIR="$SCRIPT_DIR"
|
|
ENV_FILE="$CONFIG_DIR/.env"
|
|
|
|
# Simple load_config function for .env-only
|
|
load_config() {
|
|
# Try multiple .env locations
|
|
local env_locations=(
|
|
"$ENV_FILE" # Default location
|
|
"$(dirname "${BASH_SOURCE[0]}")/.env" # Same directory as script
|
|
".env" # Current directory
|
|
)
|
|
|
|
for env_file in "${env_locations[@]}"; do
|
|
if [ -f "$env_file" ]; then
|
|
log "Loading .env configuration from $env_file"
|
|
# Use set -a to automatically export variables
|
|
set -a
|
|
source "$env_file"
|
|
set +a
|
|
return 0
|
|
fi
|
|
done
|
|
|
|
log "ERROR: No configuration file found at any .env locations"
|
|
return 1
|
|
}
|
|
|
|
# Simple log function if not defined
|
|
if ! declare -F log >/dev/null; then
|
|
log() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
|
|
}
|
|
fi
|
|
fi
|
|
|
|
# Default values
|
|
BASE_DIR=${COMPOSESYNC_BASE_DIR:-"$SCRIPT_DIR/stacks"}
|
|
KEEP_VERSIONS=${KEEP_VERSIONS:-10}
|
|
UPDATE_INTERVAL=${UPDATE_INTERVAL_SECONDS:-3600}
|
|
UPDATE_MODE=${UPDATE_MODE:-"notify_and_apply"}
|
|
DRY_RUN=${DRY_RUN:-"false"}
|
|
STACKS=${STACKS:-1}
|
|
|
|
# Load configuration (supports both .env and TOML)
|
|
if ! load_config; then
|
|
echo "ERROR: Failed to load configuration"
|
|
exit 1
|
|
fi
|
|
|
|
# Function to log messages
|
|
log() {
|
|
local prefix=""
|
|
if [ "$DRY_RUN" = "true" ]; then
|
|
prefix="[DRY-RUN] "
|
|
fi
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ${prefix}$1"
|
|
}
|
|
|
|
# Function to acquire lock
|
|
acquire_lock() {
|
|
local lock_file="$1"
|
|
local lock_timeout=300 # 5 minutes timeout
|
|
|
|
# Try to create the lock file
|
|
if mkdir "$lock_file" 2>/dev/null; then
|
|
# Successfully acquired lock
|
|
return 0
|
|
else
|
|
# Check if lock is stale
|
|
if [ -d "$lock_file" ]; then
|
|
local lock_age
|
|
# Cross-platform lock age checking
|
|
if command -v stat >/dev/null 2>&1; then
|
|
# Linux: stat -c %Y
|
|
if stat -c %Y "$lock_file" >/dev/null 2>&1; then
|
|
lock_age=$(($(date +%s) - $(stat -c %Y "$lock_file")))
|
|
# BSD/macOS: stat -f %m
|
|
elif stat -f %m "$lock_file" >/dev/null 2>&1; then
|
|
lock_age=$(($(date +%s) - $(stat -f %m "$lock_file")))
|
|
else
|
|
# Fallback: use ls -ld
|
|
lock_age=$(($(date +%s) - $(ls -ld "$lock_file" | awk '{print $6, $7, $8}' | xargs -I {} date -d "{}" +%s 2>/dev/null || echo 0)))
|
|
fi
|
|
else
|
|
# Fallback: assume lock is stale if we can't determine age
|
|
lock_age=$((lock_timeout + 1))
|
|
fi
|
|
|
|
if [ $lock_age -gt $lock_timeout ]; then
|
|
log "Found stale lock (age: ${lock_age}s), removing..."
|
|
rm -rf "$lock_file"
|
|
if mkdir "$lock_file" 2>/dev/null; then
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Function to release lock
|
|
release_lock() {
|
|
local lock_file="$1"
|
|
rm -rf "$lock_file"
|
|
}
|
|
|
|
# Function to download a file
|
|
download_file() {
|
|
local url=$1
|
|
local output=$2
|
|
local tool=$3
|
|
local subpath=$4
|
|
local git_ref=$5
|
|
local compose_filename=$6 # New parameter for configurable compose file name
|
|
|
|
log "Downloading $url to $output"
|
|
|
|
case $tool in
|
|
"wget")
|
|
if ! wget -q -O "$output" "$url"; then
|
|
log "ERROR: Failed to download $url"
|
|
return 1
|
|
fi
|
|
# Verify file is not empty
|
|
if [ ! -s "$output" ]; then
|
|
log "ERROR: Downloaded file $output is empty"
|
|
return 1
|
|
fi
|
|
;;
|
|
"git")
|
|
if [ ! -d "${output}.git" ]; then
|
|
if ! git clone --quiet "$url" "${output}.git"; then
|
|
log "ERROR: Failed to clone $url"
|
|
return 1
|
|
fi
|
|
else
|
|
if ! (cd "${output}.git" && git fetch --quiet); then
|
|
log "ERROR: Failed to fetch from $url"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Checkout specific branch/tag if specified
|
|
if [ -n "$git_ref" ]; then
|
|
if ! (cd "${output}.git" && git checkout --quiet "$git_ref"); then
|
|
log "ERROR: Failed to checkout $git_ref"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
if [ -n "$subpath" ]; then
|
|
if [ ! -f "${output}.git/$subpath" ]; then
|
|
log "ERROR: Subpath $subpath not found in repository"
|
|
return 1
|
|
fi
|
|
cp "${output}.git/$subpath" "$output"
|
|
else
|
|
# Use configurable compose file name, default to docker-compose.yml
|
|
local compose_file_name=${compose_filename:-"docker-compose.yml"}
|
|
if [ ! -f "${output}.git/$compose_file_name" ]; then
|
|
log "ERROR: $compose_file_name not found in repository root"
|
|
return 1
|
|
fi
|
|
cp "${output}.git/$compose_file_name" "$output"
|
|
fi
|
|
;;
|
|
*)
|
|
log "ERROR: Unsupported tool $tool"
|
|
return 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Function to get version identifier
|
|
get_version_id() {
|
|
local path=$1
|
|
local tool=$2
|
|
|
|
case $tool in
|
|
"git")
|
|
if [ -d "${path}.git" ]; then
|
|
git -C "${path}.git" rev-parse --short HEAD
|
|
else
|
|
date +%Y%m%d%H%M%S
|
|
fi
|
|
;;
|
|
*)
|
|
date +%Y%m%d%H%M%S
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Function to clean up old backup directories
|
|
cleanup_old_backups() {
|
|
local path=$1
|
|
local keep_versions=$2
|
|
|
|
log "Cleaning up old backup directories (keeping $keep_versions)"
|
|
find "$path/backups" -maxdepth 1 -type d -name "backup-*" 2>/dev/null | sort -r | tail -n +$((keep_versions + 1)) | xargs -r rm -rf
|
|
}
|
|
|
|
# Function to send webhook notification
|
|
send_webhook_notification() {
|
|
local event="$1"
|
|
local stack_name="$2"
|
|
local message="$3"
|
|
local version_id="$4"
|
|
local diff="$5"
|
|
local git_context="$6" # New parameter for Git context
|
|
|
|
if [ -z "$NOTIFICATION_WEBHOOK_URL" ]; then
|
|
return
|
|
fi
|
|
|
|
# Escape newlines and quotes in diff for JSON
|
|
local diff_json
|
|
if [ -n "$diff" ]; then
|
|
diff_json=$(echo "$diff" | head -50 | sed 's/\\/\\\\/g; s/\"/\\\"/g; s/$/\\n/' | tr -d '\r' | tr -d '\000')
|
|
else
|
|
diff_json=""
|
|
fi
|
|
|
|
# Escape git context for JSON
|
|
local git_context_json=""
|
|
if [ -n "$git_context" ]; then
|
|
git_context_json=$(echo "$git_context" | sed 's/\\/\\\\/g; s/\"/\\\"/g')
|
|
fi
|
|
|
|
local payload="{\n"
|
|
payload+=" \"event\": \"$event\",\n"
|
|
payload+=" \"stack_name\": \"$stack_name\",\n"
|
|
payload+=" \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\n"
|
|
payload+=" \"message\": \"$message\",\n"
|
|
payload+=" \"version_id\": \"$version_id\",\n"
|
|
payload+=" \"diff\": \"$diff_json\",\n"
|
|
payload+=" \"git_context\": \"$git_context_json\"\n"
|
|
payload+="}"
|
|
|
|
curl -s -X POST -H "Content-Type: application/json" -d "$payload" "$NOTIFICATION_WEBHOOK_URL" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
# Function to get Git context for notifications
|
|
get_git_context() {
|
|
local path=$1
|
|
local url=$2
|
|
local git_ref=$3
|
|
|
|
if [ ! -d "${path}.git" ]; then
|
|
return
|
|
fi
|
|
|
|
local context=""
|
|
|
|
# Get commit hash and message
|
|
if git -C "${path}.git" rev-parse HEAD >/dev/null 2>&1; then
|
|
local commit_hash=$(git -C "${path}.git" rev-parse --short HEAD)
|
|
local commit_msg=$(git -C "${path}.git" log -1 --pretty=format:"%s" 2>/dev/null | head -c 100)
|
|
context="Commit: $commit_hash - $commit_msg"
|
|
|
|
# Add branch/tag info if available
|
|
if [ -n "$git_ref" ]; then
|
|
context="$context (Ref: $git_ref)"
|
|
fi
|
|
|
|
# Add repository URL (sanitized)
|
|
local repo_name=$(basename "$url" .git)
|
|
context="$context | Repo: $repo_name"
|
|
fi
|
|
|
|
echo "$context"
|
|
}
|
|
|
|
# Function to process a stack
|
|
process_stack() {
|
|
local stack_num=$1
|
|
local name_var="STACK_${stack_num}_NAME"
|
|
local url_var="STACK_${stack_num}_URL"
|
|
local path_var="STACK_${stack_num}_PATH"
|
|
local tool_var="STACK_${stack_num}_TOOL"
|
|
local interval_var="STACK_${stack_num}_INTERVAL"
|
|
local keep_versions_var="STACK_${stack_num}_KEEP_VERSIONS"
|
|
local git_subpath_var="STACK_${stack_num}_GIT_SUBPATH"
|
|
local git_ref_var="STACK_${stack_num}_GIT_REF"
|
|
local compose_filename_var="STACK_${stack_num}_COMPOSE_FILENAME"
|
|
|
|
local name="${!name_var:-}"
|
|
local url="${!url_var:-}"
|
|
local path="${!path_var:-}"
|
|
local tool="${!tool_var:-}"
|
|
local interval="${!interval_var:-$UPDATE_INTERVAL}"
|
|
local keep_versions="${!keep_versions_var:-$KEEP_VERSIONS}"
|
|
local git_subpath="${!git_subpath_var:-}"
|
|
local git_ref="${!git_ref_var:-}"
|
|
local compose_filename="${!compose_filename_var:-}"
|
|
|
|
if [ -z "$name" ] || [ -z "$url" ] || [ -z "$path" ] || [ -z "$tool" ]; then
|
|
log "Error: Missing required configuration for stack $stack_num"
|
|
return 1
|
|
fi
|
|
|
|
# Create lock file path
|
|
local lock_file="$path/.lock"
|
|
|
|
# Try to acquire lock
|
|
if ! acquire_lock "$lock_file"; then
|
|
log "Warning: Could not acquire lock for stack $name, skipping..."
|
|
return 1
|
|
fi
|
|
|
|
# Ensure lock is released on exit
|
|
trap 'release_lock "'"$lock_file"'"' EXIT
|
|
|
|
log "Processing stack $name"
|
|
|
|
# Create stack directory and backups directory if they don't exist
|
|
mkdir -p "$path"
|
|
mkdir -p "$path/backups"
|
|
|
|
# Download main compose file
|
|
local compose_file="$path/docker-compose.yml"
|
|
if ! download_file "$url" "$compose_file" "$tool" "$git_subpath" "$git_ref" "$compose_filename"; then
|
|
log "ERROR: Failed to download main compose file for stack $name, skipping..."
|
|
return 1
|
|
fi
|
|
|
|
# Get version identifier
|
|
local version_id=$(get_version_id "$path" "$tool")
|
|
log "Version ID: $version_id"
|
|
|
|
# Get Git context for notifications
|
|
local git_context=""
|
|
if [ "$tool" = "git" ]; then
|
|
git_context=$(get_git_context "$path" "$url" "$git_ref")
|
|
if [ -n "$git_context" ]; then
|
|
log "Git context: $git_context"
|
|
fi
|
|
fi
|
|
|
|
# Create backup directory for this update
|
|
local backup_dir="$path/backups/backup-$(date +%Y%m%d%H%M%S)"
|
|
if [ "$DRY_RUN" != "true" ]; then
|
|
mkdir -p "$backup_dir"
|
|
fi
|
|
|
|
# Download extra files if configured
|
|
# Support custom ordering via STACK_N_EXTRA_FILES_ORDER (comma-separated list of numbers)
|
|
local extra_file_order_var="STACK_${stack_num}_EXTRA_FILES_ORDER"
|
|
local extra_file_order=${!extra_file_order_var}
|
|
local extra_files=()
|
|
if [ -n "$extra_file_order" ]; then
|
|
# Custom order specified
|
|
IFS=',' read -ra order_arr <<< "$extra_file_order"
|
|
for idx in "${order_arr[@]}"; do
|
|
idx=$(echo "$idx" | xargs) # trim whitespace
|
|
local extra_file_var="STACK_${stack_num}_EXTRA_FILES_${idx}"
|
|
local extra_file_url=${!extra_file_var}
|
|
if [ -n "$extra_file_url" ]; then
|
|
local extra_filename=$(basename "$extra_file_url")
|
|
local extra_file_path="$path/$extra_filename"
|
|
log "Downloading extra file $idx (custom order): $extra_filename"
|
|
if ! download_file "$extra_file_url" "$extra_file_path" "wget" "" "" ""; then
|
|
log "ERROR: Failed to download extra file $extra_filename, skipping..."
|
|
continue
|
|
fi
|
|
extra_files+=("$extra_file_path")
|
|
# Version the extra file
|
|
local versioned_extra_file="$path/compose-${version_id}-${extra_filename}.bak"
|
|
if [ "$DRY_RUN" != "true" ]; then
|
|
cp "$extra_file_path" "$versioned_extra_file"
|
|
cp "$extra_file_path" "$backup_dir/$extra_filename"
|
|
else
|
|
log "Would create versioned file: $versioned_extra_file"
|
|
log "Would backup file to: $backup_dir/$extra_filename"
|
|
fi
|
|
fi
|
|
done
|
|
else
|
|
# Default: process in numeric order starting from 1
|
|
local extra_file_num=1
|
|
while true; do
|
|
local extra_file_var="STACK_${stack_num}_EXTRA_FILES_${extra_file_num}"
|
|
local extra_file_url=${!extra_file_var}
|
|
if [ -z "$extra_file_url" ]; then
|
|
break
|
|
fi
|
|
local extra_filename=$(basename "$extra_file_url")
|
|
local extra_file_path="$path/$extra_filename"
|
|
log "Downloading extra file $extra_file_num: $extra_filename"
|
|
if ! download_file "$extra_file_url" "$extra_file_path" "wget" "" "" ""; then
|
|
log "ERROR: Failed to download extra file $extra_filename, skipping..."
|
|
((extra_file_num++))
|
|
continue
|
|
fi
|
|
extra_files+=("$extra_file_path")
|
|
# Version the extra file
|
|
local versioned_extra_file="$path/compose-${version_id}-${extra_filename}.bak"
|
|
if [ "$DRY_RUN" != "true" ]; then
|
|
cp "$extra_file_path" "$versioned_extra_file"
|
|
cp "$extra_file_path" "$backup_dir/$extra_filename"
|
|
else
|
|
log "Would create versioned file: $versioned_extra_file"
|
|
log "Would backup file to: $backup_dir/$extra_filename"
|
|
fi
|
|
((extra_file_num++))
|
|
done
|
|
fi
|
|
|
|
# Create versioned copy of main compose file
|
|
local versioned_file="$path/compose-${version_id}.yml.bak"
|
|
if [ "$DRY_RUN" != "true" ]; then
|
|
cp "$compose_file" "$versioned_file"
|
|
# Backup the main compose file
|
|
cp "$compose_file" "$backup_dir/docker-compose.yml"
|
|
# Backup the override file if it exists
|
|
if [ -f "$path/docker-compose.override.yml" ]; then
|
|
cp "$path/docker-compose.override.yml" "$backup_dir/docker-compose.override.yml"
|
|
fi
|
|
else
|
|
log "Would create versioned file: $versioned_file"
|
|
log "Would backup main compose file to: $backup_dir/docker-compose.yml"
|
|
if [ -f "$path/docker-compose.override.yml" ]; then
|
|
log "Would backup override file to: $backup_dir/docker-compose.override.yml"
|
|
fi
|
|
fi
|
|
|
|
# Clean up old versions and backups
|
|
if [ "$DRY_RUN" != "true" ]; then
|
|
log "Cleaning up old versions (keeping $keep_versions)"
|
|
find "$path" -name "compose-*.yml.bak" -type f 2>/dev/null | sort -r | tail -n +$((keep_versions + 1)) | xargs -r rm
|
|
cleanup_old_backups "$path" "$keep_versions"
|
|
else
|
|
log "Would clean up old versions (keeping $keep_versions)"
|
|
log "Would clean up old backup directories (keeping $keep_versions)"
|
|
fi
|
|
|
|
# Check for changes
|
|
if [ -f "$compose_file" ] && [ -f "$versioned_file" ]; then
|
|
if ! diff -q "$compose_file" "$versioned_file" >/dev/null; then
|
|
log "Changes detected in $name"
|
|
# Generate diff (truncated to 50 lines)
|
|
local diff_output
|
|
diff_output=$(diff -u "$versioned_file" "$compose_file" | head -50)
|
|
if [ $(diff -u "$versioned_file" "$compose_file" | wc -l) -gt 50 ]; then
|
|
diff_output="$diff_output\n(diff truncated, showing first 50 lines)"
|
|
fi
|
|
if [ "$UPDATE_MODE" = "notify_and_apply" ]; then
|
|
if [ "$DRY_RUN" = "true" ]; then
|
|
log "DRY-RUN: Would apply changes to $name"
|
|
|
|
# Build docker compose command with all files (safer array-based approach)
|
|
local compose_cmd_array=("docker" "compose" "-f" "$compose_file")
|
|
for extra_file in "${extra_files[@]}"; do
|
|
compose_cmd_array+=("-f" "$extra_file")
|
|
done
|
|
if [ -f "$path/docker-compose.override.yml" ]; then
|
|
compose_cmd_array+=("-f" "$path/docker-compose.override.yml")
|
|
fi
|
|
compose_cmd_array+=("up" "-d")
|
|
|
|
log "DRY-RUN: Would run: ${compose_cmd_array[*]}"
|
|
log "DRY-RUN: Changes that would be applied:"
|
|
echo "$diff_output"
|
|
send_webhook_notification "update_success" "$name" "[DRY-RUN] Would apply changes to $name" "$version_id" "$diff_output" "$git_context"
|
|
else
|
|
log "Applying changes to $name"
|
|
|
|
# Build docker compose command with all files (safer array-based approach)
|
|
local compose_cmd_array=("docker" "compose" "-f" "$compose_file")
|
|
for extra_file in "${extra_files[@]}"; do
|
|
compose_cmd_array+=("-f" "$extra_file")
|
|
done
|
|
if [ -f "$path/docker-compose.override.yml" ]; then
|
|
compose_cmd_array+=("-f" "$path/docker-compose.override.yml")
|
|
fi
|
|
compose_cmd_array+=("up" "-d")
|
|
|
|
log "Running: ${compose_cmd_array[*]}"
|
|
if "${compose_cmd_array[@]}"; then
|
|
log "Successfully updated stack $name"
|
|
send_webhook_notification "update_success" "$name" "Successfully updated stack $name" "$version_id" "$diff_output" "$git_context"
|
|
else
|
|
log "ERROR: Failed to update stack $name, attempting rollback..."
|
|
send_webhook_notification "update_failure" "$name" "Failed to update stack $name, rolled back to previous version" "$version_id" "$diff_output" "$git_context"
|
|
|
|
# Rollback: restore from backup
|
|
if [ -f "$backup_dir/docker-compose.yml" ]; then
|
|
cp "$backup_dir/docker-compose.yml" "$compose_file"
|
|
log "Restored main compose file from backup"
|
|
fi
|
|
|
|
# Restore extra files
|
|
for extra_file in "${extra_files[@]}"; do
|
|
local extra_filename=$(basename "$extra_file")
|
|
if [ -f "$backup_dir/$extra_filename" ]; then
|
|
cp "$backup_dir/$extra_filename" "$extra_file"
|
|
log "Restored $extra_filename from backup"
|
|
fi
|
|
done
|
|
|
|
# Restore override file if it existed
|
|
if [ -f "$backup_dir/docker-compose.override.yml" ]; then
|
|
cp "$backup_dir/docker-compose.override.yml" "$path/docker-compose.override.yml"
|
|
log "Restored override file from backup"
|
|
fi
|
|
|
|
# Try to restart with rolled back configuration
|
|
log "Attempting to restart stack with rolled back configuration..."
|
|
if "${compose_cmd_array[@]}"; then
|
|
log "Successfully rolled back stack $name"
|
|
send_webhook_notification "rollback_success" "$name" "Successfully rolled back stack $name to previous version" "$version_id" "" "$git_context"
|
|
else
|
|
log "CRITICAL: Failed to rollback stack $name - manual intervention required"
|
|
send_webhook_notification "rollback_failure" "$name" "CRITICAL: Failed to rollback stack $name - manual intervention required" "$version_id" "" "$git_context"
|
|
fi
|
|
fi
|
|
fi
|
|
else
|
|
log "Changes detected but not applied (notify_only mode)"
|
|
send_webhook_notification "update_success" "$name" "Changes detected in $name (notify_only mode)" "$version_id" "$diff_output" "$git_context"
|
|
fi
|
|
else
|
|
log "No changes detected in $name"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# Main loop
|
|
while true; do
|
|
log "Starting update check"
|
|
|
|
for ((i=1; i<=STACKS; i++)); do
|
|
process_stack $i
|
|
done
|
|
|
|
log "Update check complete. Sleeping for $UPDATE_INTERVAL seconds"
|
|
sleep $UPDATE_INTERVAL
|
|
done |