claude-home/server-configs/sync-configs.sh
Cal Corum cd614e753a CLAUDE: Add server-configs version control system
Introduces centralized configuration management for home lab:
- sync-configs.sh script for pull/push/diff/deploy operations
- hosts.yml inventory tracking 9 hosts (Proxmox, VMs, LXCs, cloud)
- Docker Compose files from all active hosts (sanitized)
- Proxmox VM and LXC configurations for backup reference
- .env.example files for services requiring secrets

All hardcoded secrets replaced with ${VAR} references.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 16:13:28 -06:00

452 lines
13 KiB
Bash
Executable File

#!/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") <command> [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 <host> <service> 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 "$@"