feat: add version checking and install manifest tracking
Add minimum version validation for all dependencies across local and remote machines (jq>=1.6, curl>=7.70, git>=2.30, docker>=20.0, compose>=2.0, shellcheck>=0.8, gh>=2.0). Setup scripts now record every install action to .manifests/<host>.manifest files, enabling full rollback via setup/cleanup.sh. teardown_all.sh gains --cleanup flag to chain prerequisite removal after phase teardowns. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
175
lib/common.sh
175
lib/common.sh
@@ -352,3 +352,178 @@ wait_for_ssh() {
|
||||
log_error "Timeout waiting for SSH to ${host_key} after ${max_secs}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version checking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Compare two semver-like version strings (major.minor or major.minor.patch).
|
||||
# Returns 0 if $1 >= $2, 1 otherwise.
|
||||
# Works by comparing each numeric component left to right.
|
||||
_version_gte() {
|
||||
local ver="$1" min="$2"
|
||||
|
||||
# Split on dots into arrays
|
||||
local IFS='.'
|
||||
# shellcheck disable=SC2206
|
||||
local -a v=($ver)
|
||||
# shellcheck disable=SC2206
|
||||
local -a m=($min)
|
||||
|
||||
local max_parts=${#m[@]}
|
||||
local i
|
||||
for ((i = 0; i < max_parts; i++)); do
|
||||
local vp="${v[$i]:-0}"
|
||||
local mp="${m[$i]:-0}"
|
||||
if (( vp > mp )); then return 0; fi
|
||||
if (( vp < mp )); then return 1; fi
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# Extract the first semver-like string (X.Y or X.Y.Z) from arbitrary output.
|
||||
# Handles common patterns like "jq-1.7.1", "Docker version 24.0.7", "v2.29.1", etc.
|
||||
_extract_version() {
|
||||
local raw="$1"
|
||||
# Match the first occurrence of digits.digits (optionally .digits more)
|
||||
if [[ "$raw" =~ ([0-9]+\.[0-9]+(\.[0-9]+)*) ]]; then
|
||||
printf '%s' "${BASH_REMATCH[1]}"
|
||||
else
|
||||
printf ''
|
||||
fi
|
||||
}
|
||||
|
||||
# Check that a local command meets a minimum version.
|
||||
# Usage: check_min_version "docker" "docker --version" "20.0"
|
||||
# tool_name: display name for log messages
|
||||
# version_cmd: command to run (must output version somewhere in its output)
|
||||
# min_version: minimum required version (e.g. "1.6", "20.0.0")
|
||||
# Returns 0 if version >= min, 1 otherwise.
|
||||
check_min_version() {
|
||||
local tool_name="$1" version_cmd="$2" min_version="$3"
|
||||
local raw_output
|
||||
raw_output=$(eval "$version_cmd" 2>&1) || {
|
||||
log_error "$tool_name: failed to run '$version_cmd'"
|
||||
return 1
|
||||
}
|
||||
local actual
|
||||
actual=$(_extract_version "$raw_output")
|
||||
if [[ -z "$actual" ]]; then
|
||||
log_error "$tool_name: could not parse version from: $raw_output"
|
||||
return 1
|
||||
fi
|
||||
if _version_gte "$actual" "$min_version"; then
|
||||
log_success "$tool_name $actual (>= $min_version)"
|
||||
return 0
|
||||
else
|
||||
log_error "$tool_name $actual is below minimum $min_version"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Same as check_min_version but runs the command on a remote host via SSH.
|
||||
# Usage: check_remote_min_version "UNRAID" "docker" "docker --version" "20.0"
|
||||
check_remote_min_version() {
|
||||
local host_key="$1" tool_name="$2" version_cmd="$3" min_version="$4"
|
||||
local raw_output
|
||||
raw_output=$(ssh_exec "$host_key" "$version_cmd" 2>&1) || {
|
||||
log_error "$tool_name on ${host_key}: failed to run '$version_cmd'"
|
||||
return 1
|
||||
}
|
||||
local actual
|
||||
actual=$(_extract_version "$raw_output")
|
||||
if [[ -z "$actual" ]]; then
|
||||
log_error "$tool_name on ${host_key}: could not parse version from: $raw_output"
|
||||
return 1
|
||||
fi
|
||||
if _version_gte "$actual" "$min_version"; then
|
||||
log_success "$tool_name $actual on ${host_key} (>= $min_version)"
|
||||
return 0
|
||||
else
|
||||
log_error "$tool_name $actual on ${host_key} is below minimum $min_version"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install manifest — tracks what each setup script installs for rollback
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest files live in $PROJECT_ROOT/.manifests/<host>.manifest
|
||||
# Each line: TYPE|TARGET|DETAILS
|
||||
# Types:
|
||||
# brew_pkg — Homebrew package on macOS (TARGET=package_name)
|
||||
# dnf_pkg — DNF package on Fedora (TARGET=package_name)
|
||||
# static_bin — Static binary installed to a path (TARGET=path)
|
||||
# docker_group — User added to docker group (TARGET=username)
|
||||
# systemd_svc — Systemd service enabled (TARGET=service_name)
|
||||
# xcode_cli — Xcode CLI Tools installed (TARGET=xcode-select)
|
||||
# directory — Directory created (TARGET=path)
|
||||
|
||||
_manifest_dir() {
|
||||
printf '%s/.manifests' "$(_project_root)"
|
||||
}
|
||||
|
||||
# Initialize manifest directory and file for a host.
|
||||
# Usage: manifest_init "macbook"
|
||||
manifest_init() {
|
||||
local host="$1"
|
||||
local dir
|
||||
dir="$(_manifest_dir)"
|
||||
mkdir -p "$dir"
|
||||
local file="${dir}/${host}.manifest"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
printf '# Install manifest for %s — created %s\n' "$host" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Record an action in the manifest. Skips duplicates.
|
||||
# Usage: manifest_record "macbook" "brew_pkg" "jq"
|
||||
# manifest_record "unraid" "static_bin" "/usr/local/bin/jq"
|
||||
manifest_record() {
|
||||
local host="$1" action_type="$2" target="$3" details="${4:-}"
|
||||
local file
|
||||
file="$(_manifest_dir)/${host}.manifest"
|
||||
|
||||
# Ensure manifest file exists
|
||||
manifest_init "$host"
|
||||
|
||||
local entry="${action_type}|${target}|${details}"
|
||||
|
||||
# Skip if already recorded
|
||||
if grep -qF "$entry" "$file" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$entry" >> "$file"
|
||||
}
|
||||
|
||||
# Check if a manifest file exists and has entries (beyond the header).
|
||||
# Usage: manifest_exists "macbook"
|
||||
manifest_exists() {
|
||||
local host="$1"
|
||||
local file
|
||||
file="$(_manifest_dir)/${host}.manifest"
|
||||
[[ -f "$file" ]] && grep -qv '^#' "$file" 2>/dev/null
|
||||
}
|
||||
|
||||
# Read all entries from a manifest (skipping comments).
|
||||
# Usage: manifest_entries "macbook"
|
||||
# Outputs lines to stdout: TYPE|TARGET|DETAILS
|
||||
manifest_entries() {
|
||||
local host="$1"
|
||||
local file
|
||||
file="$(_manifest_dir)/${host}.manifest"
|
||||
if [[ ! -f "$file" ]]; then
|
||||
return 0
|
||||
fi
|
||||
grep -v '^#' "$file" | grep -v '^$' || true
|
||||
}
|
||||
|
||||
# Remove the manifest file for a host (after successful cleanup).
|
||||
# Usage: manifest_clear "macbook"
|
||||
manifest_clear() {
|
||||
local host="$1"
|
||||
local file
|
||||
file="$(_manifest_dir)/${host}.manifest"
|
||||
rm -f "$file"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user