Files
Varaverk/git_pull_execute.sh
T
Gmer4Lfe 934c0b56be Make a pull that updates the puller take effect on that pull
git installs the new file by rename, so the running bash finishes on the inode
it started with and every step after the sync used the previous version.
2026-08-08 13:21:14 -04:00

456 lines
21 KiB
Bash
Executable File

#!/bin/bash
# ==============================================================================================
# ================================= Git Pull & Execute =========================================
# ==============================================================================================
#
# PURPOSE
# ─────────────────────────────────────────────────────────────────────────────
# Pulls the latest scripts from the Gitea repository via SSH. Lives at the repo
# root — sources load_config.sh from the same directory.
#
# ==============================================================================================
# OPERATIONAL MODEL
# ==============================================================================================
#
# 1. detect_hosts() — identifies which server is running the pull (MY_ID)
# 2. Configure sparse checkout — exclude other servers' credential files
# 3. Pull or clone latest scripts from Gitea
# 4. Set executable permissions on all .sh files
#
# SPARSE CHECKOUT
# Each server only receives its own host conf — never peer credentials:
# HOST1 pulls: master.conf + host1.conf + all scripts
# HOST1 skips: host2.conf, host3.conf etc.
# Adding a new server: create host3.conf in the repo — all existing servers
# automatically exclude it on next pull; new server gets only its own conf.
#
# GITEA LOCATION DETECTION
# Detected at runtime — works through fallback:
# Gitea local → connects via local IP
# Gitea remote → connects via Tailscale IP
# Both fail → falls back to GITEA_DOMAIN if configured
#
# ==============================================================================================
# DESIGN PRINCIPLES
# ==============================================================================================
#
# Credentials Stay Separated
# Sparse checkout is configured per-server on every pull, not just at clone
# time. Ensures newly added host confs are automatically excluded on all
# existing servers without any manual intervention.
#
# Runtime Location Detection
# Gitea's IP is never hardcoded — the script probes whether Gitea is local
# or remote on every run. Handles Gitea container restarts, migrations, and
# Tailscale address changes automatically.
#
# ==============================================================================================
# OPERATIONAL SAFEGUARDS
# ==============================================================================================
#
# Root check — git operations on /boot require root
# acquire_lock — prevents concurrent pulls
# detect_hosts() — MY_ID required to build correct sparse checkout rules
# Docker check — Gitea container status probed before any SSH attempt
# Self re-exec — a pull that replaces this file restarts the run once, so the steps after
# the sync are the pulled ones rather than the ones already in memory
#
# The re-exec is the non-obvious one. This script lives in the repo it pulls, and git installs
# an updated file by rename — the running bash holds the old inode and finishes the run on the
# old logic. Nothing looks wrong; the change simply takes effect one run late. Bounded by
# VV_PULL_REEXECED so it can restart at most once, skipped entirely when the checksum is
# unchanged, and the lock is released by hand first because exec does not fire EXIT traps.
#
# ==============================================================================================
# CONFIGURATION
# ==============================================================================================
#
# master.conf
#
# GITEA_CONTAINER — Docker container name for Gitea
# GITEA_REPO_PATH — repo path on Gitea (e.g. Varaverk/varaverk.git)
# GITEA_DOMAIN — public domain fallback (optional)
# TARGET_DIR — local path to clone/pull into
# GITEA_SSH_KEY — SSH key for Gitea authentication
# SSH_PORT — Gitea SSH port (often 221 or 222)
#
# ==============================================================================================
# RUNTIME MODES
# ==============================================================================================
#
# git_pull_execute.sh
# Normal pull.
#
# git_pull_execute.sh --dry-run
# Preview without making changes.
#
# git_pull_execute.sh --log
# Verbose output.
#
# git_pull_execute.sh --status
# Show config and exit.
#
# ==============================================================================================
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# This script lives inside the repo it pulls, so a pull can replace it mid-run. Recorded before
# anything happens; compared again once the pull lands. See the re-exec block after the sync.
_VV_SELF="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")"
_VV_SELF_ARGS=("$@")
_VV_SELF_SUM="$(md5sum "$_VV_SELF" 2>/dev/null | cut -d' ' -f1)"
# Root level script — load_config.sh is in the same directory
source "$SCRIPT_DIR/load_config.sh"
parse_args "$@"
# ==============================================================================================
# ━━━ Setup ━━━
# ==============================================================================================
if [[ "$EUID" -ne 0 ]]; then
error "Must be run as root"
exit 1
fi
acquire_lock
if ! command -v docker &>/dev/null; then
error "Docker command not found"
exit 1
fi
# detect_hosts() sets MY_ID — needed for sparse checkout configuration
detect_hosts
# ==============================================================================================
# ━━━ Locate Gitea ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_CONTAINERS Locate Gitea ━━━"
if docker ps --format "{{.Names}}" 2>/dev/null | grep -q "^${GITEA_CONTAINER}$"; then
# Gitea is running on this server — use local IP
GITEA_IP=$(hostname -I | awk '{print $1}')
log "Gitea running locally — connecting via $GITEA_IP"
else
# Gitea not running locally — find it on the remote server via Tailscale
log "Gitea not running locally — checking remote server"
GITEA_IP=$(resolve_tailscale_ip "${REMOTE_SERVER_NAME}")
if [[ -n "$GITEA_IP" ]]; then
echo " Gitea on $REMOTE_SERVER_NAME — connecting via Tailscale $GITEA_IP"
elif [[ -n "${GITEA_DOMAIN:-}" ]]; then
warn "Tailscale resolution failed — falling back to $GITEA_DOMAIN"
GITEA_IP="$GITEA_DOMAIN"
else
error "Cannot find Gitea — local: not running, Tailscale: failed, domain: not configured"
notify "Git pull failed on $(hostname) — cannot locate Gitea container" "Git Sync" "alert"
exit 1
fi
fi
REPO_SSH="git@${GITEA_IP}:${GITEA_REPO_PATH}"
require_var REPO_SSH
require_var TARGET_DIR
require_var GITEA_SSH_KEY
require_var SSH_PORT
# ==============================================================================================
# ━━━ Status ━━━
# ==============================================================================================
if [[ "$SHOW_STATUS" == true ]]; then
echo ""
echo "━━━━━ $ICON_SUMMARY STATUS ━━━━━"
echo "$ICON_NET Repo: $REPO_SSH"
echo "$ICON_GEAR Target: $TARGET_DIR"
echo "$ICON_GEAR SSH Key: $GITEA_SSH_KEY"
echo "$ICON_GEAR SSH Port: $SSH_PORT"
echo "$ICON_HOST My ID: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_HOST Remote ID: $REMOTE_ID ($REMOTE_SERVER_NAME)"
echo "$ICON_NOTIFY Notify: unRAID=${NOTIFY_UNRAID:-false} Discord=$([[ -n "${MY_DISCORD_WEBHOOK:-}" ]] && echo enabled || echo disabled)"
echo "$ICON_GEAR Dry Run: $DRY_RUN"
echo "━━━━━━━━━━━━━━━━━━━━━━━"
exit 0
fi
[[ "$DRY_RUN" == true ]] && warn "DRY RUN — no changes will be made"
# ==============================================================================================
# ━━━ Sparse Checkout Configuration ━━━
# ==============================================================================================
# Build the list of host*.conf files that belong to OTHER servers.
# This server pulls everything EXCEPT those files.
# MY_ID is set by detect_hosts() — e.g. "HOST1"
configure_sparse_checkout() {
local repo_dir="$1"
log "Configuring sparse checkout for $MY_ID..."
# Enable sparse checkout
git -C "$repo_dir" config core.sparseCheckout true 2>/dev/null
# Build exclusion list — all host*.conf files except MY_ID's
local sparse_file="$repo_dir/.git/info/sparse-checkout"
mkdir -p "$(dirname "$sparse_file")"
# Start with: pull everything
echo "/*" > "$sparse_file"
# Exclude each other server's conf file
# Find all host*.conf files present in the repo
local excluded=0
for conf_file in "$repo_dir"/host*.conf; do
[[ -f "$conf_file" ]] || continue
local conf_name
conf_name=$(basename "$conf_file")
# Determine which HOST ID owns this conf by grepping its hostname var
# Pattern: HOST1="unRAID-..." or HOST2="unRAID-..."
local conf_host_id
conf_host_id=$(grep -m1 -oP '^\s+HOST[0-9]+(?==)' "$conf_file" 2>/dev/null | tr -d ' ')
if [[ -z "$conf_host_id" ]]; then
log "Cannot determine HOST ID for $conf_name — including in pull (safe default)"
continue
fi
if [[ "$conf_host_id" != "$MY_ID" ]]; then
echo "!$conf_name" >> "$sparse_file"
log "Sparse checkout: excluding $conf_name (belongs to $conf_host_id)"
((excluded++))
else
log "Sparse checkout: including $conf_name (belongs to $MY_ID — this server)"
fi
done
if [[ "$excluded" -gt 0 ]]; then
echo " Sparse checkout: excluding $excluded peer conf file(s) — credentials protected"
else
log "Sparse checkout: no peer conf files to exclude (single server or first run)"
fi
}
# ==============================================================================================
# ━━━ Git Sync ━━━
# ==============================================================================================
echo ""
echo "━━━ $ICON_SYNC Git Sync ━━━"
echo "$ICON_NET Repo: $REPO_SSH"
echo "$ICON_GEAR Target: $TARGET_DIR"
echo ""
START=$(date +%s)
SYNC_SUCCESS=false
if [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would sync $REPO_SSH$TARGET_DIR"
warn "DRY RUN — would configure sparse checkout for $MY_ID"
warn "DRY RUN — would exclude peer host*.conf files"
SYNC_SUCCESS=true
else
mkdir -p "$TARGET_DIR"
git config --global --add safe.directory "$TARGET_DIR"
cd "$TARGET_DIR" || { error "Cannot cd into $TARGET_DIR"; exit 1; }
if [[ -d ".git" ]]; then
# ── Existing repository ──────────────────────────────────────────────
echo " Existing repository — updating"
# Configure sparse checkout BEFORE pull
# Uses conf files already present from last pull to determine exclusions
configure_sparse_checkout "$TARGET_DIR"
echo " Pulling latest changes..."
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" git pull --ff-only; then
echo " Git pull successful"
SYNC_SUCCESS=true
else
# ff-only fails when local commits or tracked changes exist that can't
# fast-forward. Fail loudly — never silently destroy local work.
error "Git pull failed — local changes conflict with remote (will not force-reset)"
notify "Git pull failed on $(hostname) — local changes conflict, manual resolve needed" "Git Sync" "alert"
exit 1
fi
else
# ── Fresh clone ──────────────────────────────────────────────────────
echo " No repository found — cloning"
# Clone first — need the repo to exist before configuring sparse checkout
if GIT_SSH_COMMAND="ssh -i $GITEA_SSH_KEY -p $SSH_PORT" git clone "$REPO_SSH" .; then
echo " Clone successful"
# Configure sparse checkout after clone
# Now all host*.conf files are present — can detect exclusions
configure_sparse_checkout "$TARGET_DIR"
# Apply sparse checkout — removes excluded files from working tree
echo " Applying sparse checkout..."
git read-tree -mu HEAD
echo " Sparse checkout applied — peer credentials removed from working tree"
SYNC_SUCCESS=true
else
error "Clone failed"
notify "Git clone failed on $(hostname) — check Gitea connectivity" "Git Sync" "alert"
exit 1
fi
fi
# ── Re-exec if the pull replaced this script ─────────────────────────────
#
# git installs an updated file by rename, so the running bash keeps reading the inode it
# started with. That is the benign half — no torn parse, no garbage — but it means every
# step below this line runs the version that was on disk when the run began, not the one
# just pulled. Observed 2026-08-08: a pull carrying a fix to the conf-upgrade call executed
# the old call anyway, and the fix only took effect on the following run.
#
# Re-exec makes the pull self-applying. Guarded three ways:
# VV_PULL_REEXECED — set before exec, so the new image can never re-exec again. One
# restart per run, whatever the checksums say.
# checksum compare — no change means no restart, so the common case costs one md5sum.
# lock released — exec keeps the PID but does NOT fire the EXIT trap, so the lock file
# would survive into the new image, which would then find a live PID
# holding its own name and exit 1 under strict mode. Dropped by hand
# first; the new image re-acquires it immediately.
if [[ -z "${VV_PULL_REEXECED:-}" ]]; then
_vv_self_now="$(md5sum "$_VV_SELF" 2>/dev/null | cut -d' ' -f1)"
if [[ -n "$_vv_self_now" && -n "$_VV_SELF_SUM" && "$_vv_self_now" != "$_VV_SELF_SUM" ]]; then
echo " Pull updated this script — restarting so the rest of the run uses it"
_release_all_locks
export VV_PULL_REEXECED=1
exec bash "$_VV_SELF" "${_VV_SELF_ARGS[@]}"
fi
fi
# ── Permissions ──────────────────────────────────────────────────────────
echo ""
echo "━━━ $ICON_GEAR Permissions ━━━"
log "Setting executable permissions on all .sh files..."
find "$TARGET_DIR" -type f -name "*.sh" -exec chmod +x {} \;
echo " Permissions set on .sh files"
# ── Flash mode: sync Plugin/ to /boot/ so the webUI picks up updates ─────
# In flash mode SCRIPTS_DIR is in appdata — Plugin/ lives in the repo there
# but Unraid serves PHP from /boot/. Sync after every pull to keep them in step.
_BOOT_DIR=$(platform_webui_install_path)
if [[ "$TARGET_DIR" != "$_BOOT_DIR" ]]; then
echo ""
echo "━━━ $ICON_SYNC Flash mode: sync Plugin/ → /boot/ ━━━"
if rsync -a --delete "$TARGET_DIR/Plugin/" "$_BOOT_DIR/Plugin/" 2>/dev/null; then
echo " Plugin/ synced to /boot/ ✅"
else
warn "Plugin/ sync to /boot/ failed — webUI may be stale until next pull"
fi
fi
fi
END=$(date +%s)
# ==============================================================================================
# ━━━ Conf Upgrade ━━━
# ==============================================================================================
# Merges new conf structure into the live conf files after every pull.
# New keys → added with template defaults (user fills in once).
# Removed keys → dropped. Existing values → always preserved.
# Silent when already up to date — no overhead on unchanged pulls.
echo ""
echo "━━━ $ICON_GEAR Conf Upgrade ━━━"
UPGRADE_SCRIPT="$TARGET_DIR/Deployment/conf_upgrade.sh"
CONF_DIR="$TARGET_DIR/Configurations"
DEPLOY_DIR="$TARGET_DIR/Deployment"
if [[ ! -f "$UPGRADE_SCRIPT" ]]; then
log "conf_upgrade.sh not found — skipping (pre-deployment-folder repo)"
elif [[ "$DRY_RUN" == true ]]; then
warn "DRY RUN — would upgrade master.conf and ${MY_ID,,}.conf"
elif [[ "$SYNC_SUCCESS" == true ]]; then
_DRY=""
# master.conf
if [[ -f "$DEPLOY_DIR/master.conf.template" && -f "$CONF_DIR/master.conf" ]]; then
bash "$UPGRADE_SCRIPT" \
--template "$DEPLOY_DIR/master.conf.template" \
--target "$CONF_DIR/master.conf" \
--backup $_DRY
else
warn "master.conf.template or master.conf not found — skipping"
fi
# This server's host conf only — sparse checkout ensures we have it
HOST_CONF="$CONF_DIR/${MY_ID,,}.conf"
if [[ -f "$DEPLOY_DIR/host.conf.template" && -f "$HOST_CONF" ]]; then
# The template is slot-generic; conf_upgrade resolves HOSTN/hostn to MY_ID and refuses
# if that disagrees with the target's own keys. This used to be a local sed that only
# replaced HOSTN_, which left bare HOSTN in comments — enough to trip conf_upgrade's
# own guard, so the host conf silently never upgraded — and left lowercase hostn alone,
# which would have installed an unreferenced hostn-appdata rsync profile.
bash "$UPGRADE_SCRIPT" \
--template "$DEPLOY_DIR/host.conf.template" \
--target "$HOST_CONF" \
--host-slot "$MY_ID" \
--backup $_DRY
else
warn "${MY_ID,,}.conf or host.conf.template not found — skipping"
fi
fi
# ==============================================================================================
# ━━━ AI Index Refresh ━━━
# ==============================================================================================
#
# A pull is the only thing that changes tracked files on a server — prod never edits them — so
# it is the only moment the AI index can go stale. A timer would be the wrong shape: it would
# do nothing 23 times a day and still drift from the pull that matters.
#
# Staleness is invisible in the answers themselves. The index keeps returning the old text with
# full confidence and correct-looking citations, so a day of drift means the assistant quoting
# code that no longer exists. That is why this runs here rather than being left to a human.
#
# Three gates, any of which skips it: the pull must have succeeded, AI_INDEX_ON_PULL must be
# true, and AI_ENABLED must be true. ai_index.sh also refuses on its own unless AI_ENABLED is
# exactly "true", so a node with AI off never pays for this even if the flags disagree.
#
# Never fatal. Indexing is an enhancement; a git pull must not be reported as failed because an
# embedding call timed out.
if [[ "$DRY_RUN" == true ]]; then
[[ "${AI_INDEX_ON_PULL:-false}" == "true" ]] && warn "DRY RUN — would refresh the AI index"
elif [[ "$SYNC_SUCCESS" == true \
&& "${AI_INDEX_ON_PULL:-false}" == "true" \
&& "${AI_ENABLED:-false}" == "true" ]]; then
_AI_INDEX="$TARGET_DIR/AI/ai_index.sh"
if [[ -f "$_AI_INDEX" ]]; then
log "Refreshing AI index (incremental — unchanged files are skipped)..."
if bash "$_AI_INDEX" >/dev/null 2>&1; then
echo " AI index refreshed"
else
warn "AI index refresh failed — answers may cite outdated code until it is rerun"
fi
else
warn "AI_INDEX_ON_PULL is true but $_AI_INDEX not found — skipping"
fi
fi
# ==============================================================================================
# ━━━ Summary ━━━
# ==============================================================================================
echo ""
echo "━━━━━ $ICON_SUMMARY GIT SYNC SUMMARY ━━━━━"
echo "$ICON_NET Repo: $REPO_SSH"
echo "$ICON_GEAR Target: $TARGET_DIR"
echo "$ICON_HOST Identity: $MY_ID ($LOCAL_SERVER_NAME)"
echo "$ICON_LOCK Excluded: peer host*.conf files"
echo "$ICON_TIME Duration: $(format_duration $((END - START)))"
if [[ "$DRY_RUN" == true ]]; then
echo "$ICON_WARN Status: DRY RUN — no changes made"
elif [[ "$SYNC_SUCCESS" == true ]]; then
echo "$ICON_DONE Status: $ICON_SUCCESS DONE"
notify "Repository synced successfully on $(hostname)" "Git Sync" "normal"
else
echo "$ICON_ERROR Status: $ICON_ERROR FAILED"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"