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>
452 lines
13 KiB
Bash
Executable File
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 "$@"
|