#!/usr/bin/env bash # deployment-node.sh - To be run on the execution node (SOLARIA, PIHA, VPS) # This script pulls the latest changes and ensures services are running. set -e # Configuration REPO_PATH="${HOME}/homelab-codex-ws" # Env-overridable so the script can be exercised against a scratch runtime dir # without writing into the node's real /opt/homelab. RUNTIME_PATH="${RUNTIME_PATH:-/opt/homelab}" HOSTNAME=$(hostname | tr '[:lower:]' '[:upper:]') CURRENT_OS_HOST=$(hostname) echo "--- Starting Deployment on ${HOSTNAME} ---" # 1. Update Repository if [ ! -d "$REPO_PATH" ]; then echo "Error: Repository not found at $REPO_PATH" exit 1 fi cd "$REPO_PATH" echo "Pulling latest changes..." git pull # Resolve host directory: search hosts/*/host.yaml for os_hostname == $(hostname) HOST_LOGICAL=$(python3 -c " import yaml, os, sys current = '${CURRENT_OS_HOST}' hosts_dir = os.path.join('${REPO_PATH}', 'hosts') for entry in sorted(os.listdir(hosts_dir)): host_yaml = os.path.join(hosts_dir, entry, 'host.yaml') if not os.path.isfile(host_yaml): continue try: with open(host_yaml) as f: data = yaml.safe_load(f) if data and data.get('os_hostname') == current: print(entry) sys.exit(0) except Exception: pass ") if [ -n "$HOST_LOGICAL" ]; then HOST_DIR="${REPO_PATH}/hosts/${HOST_LOGICAL}" else HOST_LOGICAL_FB="$(echo "$CURRENT_OS_HOST" | tr '[:upper:]' '[:lower:]')" echo "WARN: no os_hostname match for ${CURRENT_OS_HOST}, falling back to lowercase hostname dir" >&2 HOST_DIR="${REPO_PATH}/hosts/${HOST_LOGICAL_FB}" fi if [ ! -d "$HOST_DIR" ]; then echo "Error: No host directory found for ${CURRENT_OS_HOST} (tried ${HOST_DIR})" >&2 exit 1 fi # 2. Identify Services SERVICES=() if [ -f "${HOST_DIR}/services.txt" ]; then mapfile -t SERVICES < <(grep -v '^\s*#' "${HOST_DIR}/services.txt" | grep -v '^\s*$') elif [ -f "${HOST_DIR}/services.yaml" ]; then SERVICES=($(python3 -c " import yaml, sys try: with open('${HOST_DIR}/services.yaml', 'r') as f: data = yaml.safe_load(f) if data and 'services' in data: if isinstance(data['services'], dict): print(' '.join(data['services'].keys())) elif isinstance(data['services'], list): print(' '.join(data['services'])) except Exception as e: print(f'Error parsing YAML: {e}', file=sys.stderr) sys.exit(1) ")) fi if [ ${#SERVICES[@]} -eq 0 ]; then echo "No services found for ${HOSTNAME}. Skipping service deployment." exit 0 fi # 3. Deploy Services # # The per-service compose invocation lives in deploy-service.sh, shared with the # agent-driven single-service redeploy (services/deploy-runner/). Both callers # MUST assemble the -f arguments identically: docker compose derives the project # name from the first -f file's directory, and a mismatch plus --remove-orphans # is what wiped the control-plane on VPS on 2026-06-25. Services with their own # deploy-local.sh are skipped by deploy-service.sh (exit 3). DEPLOY_SERVICE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deploy-service.sh" for service in "${SERVICES[@]}"; do TARGET_DIR="${RUNTIME_PATH}/services/${service}" mkdir -p "$TARGET_DIR" rc=0 "$DEPLOY_SERVICE" \ --repo "$REPO_PATH" \ --host-dir "$HOST_DIR" \ --service "$service" \ --build-if-needed \ --remove-orphans || rc=$? case "$rc" in 0) ;; # deployed 3) ;; # owns its deploy path — already reported, skip 2) echo "Warning: skipping ${service} (validation failed, see above)" ;; *) exit "$rc" ;; # real deploy failure — preserve set -e semantics esac done echo "--- Deployment Complete ---"