#!/bin/bash # Home Lab Configuration Sync Script # Syncs Docker Compose and VM configs between local git repo and remote hosts set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" HOSTS_FILE="$SCRIPT_DIR/hosts.yml" # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # Helper functions log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } log_success() { echo -e "${GREEN}[OK]${NC} $1"; } log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } log_error() { echo -e "${RED}[ERROR]${NC} $1"; } usage() { cat << EOF Usage: $(basename "$0") [host] [service] Commands: pull [host] Pull configs from remote hosts to local repo push [host] Push configs from local repo to remote hosts (no restart) diff [host] Show differences between local and remote configs deploy Push config and restart specific service status Show sync status for all hosts list List all configured hosts and services Examples: $(basename "$0") pull # Pull from all hosts $(basename "$0") pull ubuntu-manticore # Pull from specific host $(basename "$0") diff discord-bots # Show diffs for host $(basename "$0") deploy sba-bots paper-dynasty # Deploy and restart service EOF exit 1 } # Parse hosts.yml using simple bash (no yq dependency) get_hosts() { grep -E "^ [a-z]" "$HOSTS_FILE" | grep -v "^ #" | sed 's/://g' | awk '{print $1}' } get_host_property() { local host="$1" local property="$2" # Simple YAML parsing - works for our flat structure sed -n "/^ $host:/,/^ [a-z]/p" "$HOSTS_FILE" | grep " $property:" | head -1 | sed 's/.*: *//' | tr -d '"' } get_host_type() { get_host_property "$1" "type" } get_ssh_alias() { get_host_property "$1" "ssh_alias" } get_docker_path() { local host="$1" sed -n "/^ $host:/,/^ [a-z]/p" "$HOSTS_FILE" | grep "docker-compose:" | head -1 | sed 's/.*: *//' | tr -d '"' } # Check if host is reachable check_host() { local host="$1" local host_type host_type=$(get_host_type "$host") if [[ "$host_type" == "local" ]]; then return 0 fi local ssh_alias ssh_alias=$(get_ssh_alias "$host") if ssh -o ConnectTimeout=3 -o BatchMode=yes "$ssh_alias" "echo ok" &>/dev/null; then return 0 else return 1 fi } # Pull configs from a single host pull_host() { local host="$1" local host_type host_type=$(get_host_type "$host") local local_dir="$SCRIPT_DIR/$host" log_info "Pulling configs from $host..." if [[ "$host_type" == "local" ]]; then log_warn "Skipping local host $host (no pull needed)" return 0 fi if ! check_host "$host"; then log_error "Cannot connect to $host" return 1 fi local ssh_alias ssh_alias=$(get_ssh_alias "$host") if [[ "$host_type" == "proxmox" ]]; then # Pull LXC configs mkdir -p "$local_dir/lxc" log_info " Pulling LXC configs..." rsync -av --delete "$ssh_alias:/etc/pve/nodes/proxmox/lxc/" "$local_dir/lxc/" 2>/dev/null || true # Pull QEMU configs mkdir -p "$local_dir/qemu" log_info " Pulling QEMU/VM configs..." rsync -av --delete "$ssh_alias:/etc/pve/nodes/proxmox/qemu-server/" "$local_dir/qemu/" 2>/dev/null || true elif [[ "$host_type" == "docker" ]]; then local remote_path remote_path=$(get_docker_path "$host") if [[ -z "$remote_path" ]]; then log_warn " No docker-compose path configured for $host" return 0 fi mkdir -p "$local_dir/docker-compose" log_info " Pulling Docker Compose configs from $remote_path..." # Find and sync ONLY docker-compose files (not application data) ssh "$ssh_alias" "find $remote_path -maxdepth 2 \( -name 'docker-compose*.yml' -o -name 'compose.yml' \) 2>/dev/null" | while read -r compose_file; do local service_dir service_dir=$(dirname "$compose_file") local service_name service_name=$(basename "$service_dir") mkdir -p "$local_dir/docker-compose/$service_name" # ONLY sync compose files and .env.example - nothing else! # Explicitly copy just the files we want, not directories scp "$ssh_alias:$service_dir/docker-compose*.yml" "$local_dir/docker-compose/$service_name/" 2>/dev/null || true scp "$ssh_alias:$service_dir/compose.yml" "$local_dir/docker-compose/$service_name/" 2>/dev/null || true scp "$ssh_alias:$service_dir/.env.example" "$local_dir/docker-compose/$service_name/" 2>/dev/null || true done fi log_success "Pulled configs from $host" } # Push configs to a single host push_host() { local host="$1" local host_type host_type=$(get_host_type "$host") local local_dir="$SCRIPT_DIR/$host" log_info "Pushing configs to $host..." if [[ "$host_type" == "local" ]]; then log_warn "Skipping local host $host (no push needed)" return 0 fi if [[ ! -d "$local_dir" ]]; then log_error "No local configs found for $host" return 1 fi if ! check_host "$host"; then log_error "Cannot connect to $host" return 1 fi local ssh_alias ssh_alias=$(get_ssh_alias "$host") if [[ "$host_type" == "proxmox" ]]; then log_warn "Pushing Proxmox configs requires manual review - skipping for safety" log_info " Use 'diff $host' to review changes first" return 0 elif [[ "$host_type" == "docker" ]]; then local remote_path remote_path=$(get_docker_path "$host") if [[ ! -d "$local_dir/docker-compose" ]]; then log_warn " No docker-compose configs to push for $host" return 0 fi for service_dir in "$local_dir/docker-compose"/*/; do local service_name service_name=$(basename "$service_dir") local remote_service_path="$remote_path/$service_name" log_info " Pushing $service_name..." rsync -av --dry-run \ --include='docker-compose*.yml' \ --include='compose.yml' \ --include='*.conf' \ --exclude='*' \ "$service_dir" "$ssh_alias:$remote_service_path/" 2>/dev/null # Actual push (remove --dry-run for real push) rsync -av \ --include='docker-compose*.yml' \ --include='compose.yml' \ --include='*.conf' \ --exclude='*' \ "$service_dir" "$ssh_alias:$remote_service_path/" 2>/dev/null || true done fi log_success "Pushed configs to $host (services NOT restarted)" } # Show diff between local and remote diff_host() { local host="$1" local host_type host_type=$(get_host_type "$host") local local_dir="$SCRIPT_DIR/$host" log_info "Comparing configs for $host..." if [[ "$host_type" == "local" ]]; then log_warn "Skipping local host $host" return 0 fi if ! check_host "$host"; then log_error "Cannot connect to $host" return 1 fi local ssh_alias ssh_alias=$(get_ssh_alias "$host") local temp_dir temp_dir=$(mktemp -d) # Pull current remote state to temp if [[ "$host_type" == "proxmox" ]]; then mkdir -p "$temp_dir/lxc" "$temp_dir/qemu" rsync -a "$ssh_alias:/etc/pve/nodes/proxmox/lxc/" "$temp_dir/lxc/" 2>/dev/null || true rsync -a "$ssh_alias:/etc/pve/nodes/proxmox/qemu-server/" "$temp_dir/qemu/" 2>/dev/null || true echo "" echo "=== LXC Config Differences ===" diff -rq "$local_dir/lxc" "$temp_dir/lxc" 2>/dev/null || true echo "" echo "=== QEMU Config Differences ===" diff -rq "$local_dir/qemu" "$temp_dir/qemu" 2>/dev/null || true elif [[ "$host_type" == "docker" ]]; then local remote_path remote_path=$(get_docker_path "$host") for service_dir in "$local_dir/docker-compose"/*/; do local service_name service_name=$(basename "$service_dir") local remote_service_path="$remote_path/$service_name" mkdir -p "$temp_dir/$service_name" rsync -a \ --include='docker-compose*.yml' \ --include='compose.yml' \ --exclude='*' \ "$ssh_alias:$remote_service_path/" "$temp_dir/$service_name/" 2>/dev/null || true echo "" echo "=== $service_name ===" diff -u "$temp_dir/$service_name/docker-compose.yml" "$service_dir/docker-compose.yml" 2>/dev/null || echo "(no differences or file missing)" done fi rm -rf "$temp_dir" } # Deploy a specific service (push + restart) deploy_service() { local host="$1" local service="$2" local host_type host_type=$(get_host_type "$host") if [[ "$host_type" != "docker" ]]; then log_error "Deploy only works for docker hosts" return 1 fi local ssh_alias ssh_alias=$(get_ssh_alias "$host") local remote_path remote_path=$(get_docker_path "$host") local local_dir="$SCRIPT_DIR/$host/docker-compose/$service" local remote_service_path="$remote_path/$service" if [[ ! -d "$local_dir" ]]; then log_error "No local config found for $service on $host" return 1 fi if ! check_host "$host"; then log_error "Cannot connect to $host" return 1 fi log_info "Deploying $service to $host..." # Push the config rsync -av \ --include='docker-compose*.yml' \ --include='compose.yml' \ --include='*.conf' \ --exclude='*' \ "$local_dir/" "$ssh_alias:$remote_service_path/" # Restart the service log_info "Restarting $service..." ssh "$ssh_alias" "cd $remote_service_path && docker compose down && docker compose up -d" log_success "Deployed and restarted $service on $host" } # Show status of all hosts show_status() { echo "" echo "Home Lab Configuration Status" echo "==============================" echo "" for host in $(get_hosts); do local host_type host_type=$(get_host_type "$host") local status_icon if [[ "$host_type" == "local" ]]; then status_icon="${GREEN}●${NC}" status_text="local" elif check_host "$host"; then status_icon="${GREEN}●${NC}" status_text="online" else status_icon="${RED}●${NC}" status_text="offline" fi local local_dir="$SCRIPT_DIR/$host" local config_count=0 if [[ -d "$local_dir" ]]; then config_count=$(find "$local_dir" -name "*.yml" -o -name "*.conf" 2>/dev/null | wc -l) fi printf " %b %-20s %-10s %-8s %d configs tracked\n" "$status_icon" "$host" "($host_type)" "$status_text" "$config_count" done echo "" } # List all hosts and services list_hosts() { echo "" echo "Configured Hosts and Services" echo "==============================" for host in $(get_hosts); do local host_type host_type=$(get_host_type "$host") local description description=$(get_host_property "$host" "description") echo "" echo -e "${BLUE}$host${NC} ($host_type)" echo " $description" local local_dir="$SCRIPT_DIR/$host/docker-compose" if [[ -d "$local_dir" ]]; then echo " Services:" for service in "$local_dir"/*/; do if [[ -d "$service" ]]; then echo " - $(basename "$service")" fi done fi done echo "" } # Main command dispatcher main() { if [[ $# -lt 1 ]]; then usage fi local command="$1" shift case "$command" in pull) if [[ $# -ge 1 ]]; then pull_host "$1" else for host in $(get_hosts); do pull_host "$host" || true done fi ;; push) if [[ $# -ge 1 ]]; then push_host "$1" else for host in $(get_hosts); do push_host "$host" || true done fi ;; diff) if [[ $# -ge 1 ]]; then diff_host "$1" else for host in $(get_hosts); do diff_host "$host" || true done fi ;; deploy) if [[ $# -lt 2 ]]; then log_error "Deploy requires host and service arguments" usage fi deploy_service "$1" "$2" ;; status) show_status ;; list) list_hosts ;; *) log_error "Unknown command: $command" usage ;; esac } main "$@"