Add browser-based system upgrade UI with file-based IPC

API container writes trigger files to a shared volume (data/upgrade/),
and a systemd path watcher on the host detects them and runs the
upgrade scripts. This avoids giving the container Docker socket access.

- Add upgrade-check.sh (git fetch + compare + write status.json)
- Add upgrade-watcher.sh (systemd bridge, dispatches check/upgrade)
- Add systemd path/service units with placeholder substitution
- Modify upgrade.sh with --api-mode flag (progress.json + result.json)
- Add API upgrade module (service + routes, SUPER_ADMIN only)
- Add System tab to Settings page with version info, changelog,
  progress steps, and upgrade confirmation modal
- Add upgrade watcher installation to config.sh wizard
- Add data/upgrade/ shared volume to api service in docker-compose

Bunker Admin
This commit is contained in:
2026-03-03 18:00:15 -07:00
parent 576dea2f98
commit da3e43fcf7
14 changed files with 1238 additions and 18 deletions

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Watch for Changemaker Lite upgrade triggers
Documentation=https://docs.cmlite.org/docs/admin/services/
[Path]
PathExists=__PROJECT_DIR__/data/upgrade/trigger.json
MakeDirectory=yes
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Changemaker Lite upgrade dispatcher
Documentation=https://docs.cmlite.org/docs/admin/services/
[Service]
Type=oneshot
User=__USER__
Group=__USER__
WorkingDirectory=__PROJECT_DIR__
ExecStart=__PROJECT_DIR__/scripts/upgrade-watcher.sh
TimeoutStartSec=900
StandardOutput=journal
StandardError=journal

105
scripts/upgrade-check.sh Executable file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite V2 — Upgrade Check Script
# Checks for available updates and writes status to data/upgrade/status.json.
# Safe to run via cron or on-demand via file trigger.
# Usage: ./scripts/upgrade-check.sh [--branch BRANCH]
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
STATUS_FILE="${UPGRADE_DIR}/status.json"
BRANCH=""
# --- Parse Arguments ---
while [[ $# -gt 0 ]]; do
case "$1" in
--branch) BRANCH="$2"; shift 2 ;;
*) shift ;;
esac
done
cd "$PROJECT_DIR"
mkdir -p "$UPGRADE_DIR"
# Determine branch
if [[ -z "$BRANCH" ]]; then
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
fi
# Write an error status and exit
write_error() {
local msg="$1"
cat > "$STATUS_FILE" <<EOF
{
"branch": "${BRANCH}",
"currentCommit": "$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")",
"currentCommitFull": "$(git rev-parse HEAD 2>/dev/null || echo "unknown")",
"currentMessage": "$(git log -1 --format='%s' HEAD 2>/dev/null | sed 's/"/\\"/g' || echo "")",
"currentDate": "$(git log -1 --format='%aI' HEAD 2>/dev/null || echo "")",
"remoteCommit": null,
"commitsBehind": 0,
"changelog": [],
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": "${msg}"
}
EOF
exit 1
}
# Fetch latest from remote
if ! timeout 30 git fetch origin "$BRANCH" 2>/dev/null; then
write_error "Failed to reach git remote"
fi
# Gather info
CURRENT_COMMIT="$(git rev-parse HEAD)"
CURRENT_SHORT="$(git rev-parse --short HEAD)"
CURRENT_MSG="$(git log -1 --format='%s' HEAD | sed 's/"/\\"/g')"
CURRENT_DATE="$(git log -1 --format='%aI' HEAD)"
REMOTE_COMMIT="$(git rev-parse "origin/${BRANCH}" 2>/dev/null || echo "")"
REMOTE_SHORT="$(git rev-parse --short "origin/${BRANCH}" 2>/dev/null || echo "")"
if [[ -z "$REMOTE_COMMIT" ]]; then
write_error "Remote branch origin/${BRANCH} not found"
fi
# Count commits behind
COMMITS_BEHIND=0
if [[ "$CURRENT_COMMIT" != "$REMOTE_COMMIT" ]]; then
COMMITS_BEHIND="$(git rev-list --count HEAD..origin/"${BRANCH}" 2>/dev/null || echo "0")"
fi
# Build changelog (last 30 commits we're behind)
CHANGELOG="[]"
if [[ "$COMMITS_BEHIND" -gt 0 ]]; then
CHANGELOG="$(git log --oneline --format='{"hash":"%h","message":"%s","date":"%aI","author":"%an"}' HEAD..origin/"${BRANCH}" 2>/dev/null | head -30 | while IFS= read -r line; do
# Escape any double quotes in the message that aren't already escaped
echo "$line"
done | paste -sd ',' | sed 's/^/[/' | sed 's/$/]/')"
# Fallback if jq-less approach fails
if [[ -z "$CHANGELOG" ]] || [[ "$CHANGELOG" == "[]" ]]; then
CHANGELOG="[]"
fi
fi
# Write status
cat > "$STATUS_FILE" <<EOF
{
"branch": "${BRANCH}",
"currentCommit": "${CURRENT_SHORT}",
"currentCommitFull": "${CURRENT_COMMIT}",
"currentMessage": "${CURRENT_MSG}",
"currentDate": "${CURRENT_DATE}",
"remoteCommit": "${REMOTE_SHORT}",
"remoteCommitFull": "${REMOTE_COMMIT}",
"commitsBehind": ${COMMITS_BEHIND},
"changelog": ${CHANGELOG},
"checkedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"error": null
}
EOF
echo "Update check complete: ${COMMITS_BEHIND} commit(s) behind on ${BRANCH}"

88
scripts/upgrade-watcher.sh Executable file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# =============================================================================
# Changemaker Lite V2 — Upgrade Watcher (systemd bridge)
# Called by systemd path unit when data/upgrade/trigger.json is created.
# Reads the trigger, dispatches to the appropriate script, cleans up.
# =============================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
TRIGGER_FILE="${UPGRADE_DIR}/trigger.json"
LOG_DIR="${PROJECT_DIR}/logs"
mkdir -p "$LOG_DIR"
log() {
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "${LOG_DIR}/upgrade-watcher.log"
}
# Bail if no trigger file
if [[ ! -f "$TRIGGER_FILE" ]]; then
log "No trigger file found, exiting."
exit 0
fi
# Read trigger (minimal JSON parsing with grep/sed — no jq dependency)
TRIGGER_CONTENT="$(cat "$TRIGGER_FILE")"
ACTION="$(echo "$TRIGGER_CONTENT" | grep -o '"action"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"action"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//')"
if [[ -z "$ACTION" ]]; then
log "ERROR: Could not parse action from trigger file"
rm -f "$TRIGGER_FILE"
exit 1
fi
log "Received trigger: action=${ACTION}"
# Remove trigger immediately to prevent re-execution
rm -f "$TRIGGER_FILE"
case "$ACTION" in
check)
log "Running update check..."
# Extract optional branch
BRANCH="$(echo "$TRIGGER_CONTENT" | grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//' || true)"
ARGS=()
if [[ -n "$BRANCH" ]]; then
ARGS+=(--branch "$BRANCH")
fi
"$SCRIPT_DIR/upgrade-check.sh" "${ARGS[@]}" 2>&1 | tee -a "${LOG_DIR}/upgrade-watcher.log"
log "Update check complete."
;;
upgrade)
log "Running upgrade..."
# Parse options from trigger
ARGS=(--api-mode)
SKIP_BACKUP="$(echo "$TRIGGER_CONTENT" | grep -o '"skipBackup"[[:space:]]*:[[:space:]]*true' || true)"
if [[ -n "$SKIP_BACKUP" ]]; then
ARGS+=(--skip-backup --force)
fi
PULL_SERVICES="$(echo "$TRIGGER_CONTENT" | grep -o '"pullServices"[[:space:]]*:[[:space:]]*true' || true)"
if [[ -n "$PULL_SERVICES" ]]; then
ARGS+=(--pull-services)
fi
DRY_RUN="$(echo "$TRIGGER_CONTENT" | grep -o '"dryRun"[[:space:]]*:[[:space:]]*true' || true)"
if [[ -n "$DRY_RUN" ]]; then
ARGS+=(--dry-run)
fi
BRANCH="$(echo "$TRIGGER_CONTENT" | grep -o '"branch"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"//' | sed 's/".*//' || true)"
if [[ -n "$BRANCH" ]]; then
ARGS+=(--branch "$BRANCH")
fi
"$SCRIPT_DIR/upgrade.sh" "${ARGS[@]}" 2>&1 | tee -a "${LOG_DIR}/upgrade-watcher.log"
log "Upgrade complete."
;;
*)
log "ERROR: Unknown action '${ACTION}'"
exit 1
;;
esac

View File

@@ -46,6 +46,7 @@ DRY_RUN=false
FORCE=false
BRANCH=""
ROLLBACK=false
API_MODE=false
# --- Colors (respects NO_COLOR convention) ---
if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then
@@ -73,6 +74,52 @@ phase() {
echo ""
}
# --- API mode: JSON progress/result writing ---
UPGRADE_DIR="${PROJECT_DIR}/data/upgrade"
PROGRESS_FILE="${UPGRADE_DIR}/progress.json"
RESULT_FILE="${UPGRADE_DIR}/result.json"
write_progress() {
[[ "$API_MODE" != "true" ]] && return
local phase_num="$1" phase_name="$2" pct="$3" msg="$4"
mkdir -p "$UPGRADE_DIR"
cat > "$PROGRESS_FILE" <<PEOF
{
"phase": ${phase_num},
"phaseName": "${phase_name}",
"percentage": ${pct},
"message": "$(echo "$msg" | sed 's/"/\\"/g')",
"lastUpdate": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
PEOF
}
write_result() {
[[ "$API_MODE" != "true" ]] && return
local success="$1" msg="$2"
local duration_secs=$((SECONDS - START_TIME))
local warnings_json="${3:-[]}"
mkdir -p "$UPGRADE_DIR"
cat > "$RESULT_FILE" <<REOF
{
"success": ${success},
"message": "$(echo "$msg" | sed 's/"/\\"/g')",
"previousCommit": "${PRE_UPGRADE_SHORT:-unknown}",
"newCommit": "$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")",
"commitCount": ${COMMIT_COUNT:-0},
"durationSeconds": ${duration_secs},
"warnings": ${warnings_json},
"completedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
REOF
# Clean up progress file
rm -f "$PROGRESS_FILE"
# Update status.json with new commit info
if [[ -x "$SCRIPT_DIR/upgrade-check.sh" ]]; then
"$SCRIPT_DIR/upgrade-check.sh" 2>/dev/null || true
fi
}
elapsed() {
local secs=$((SECONDS - START_TIME))
printf '%dm %ds' $((secs / 60)) $((secs % 60))
@@ -213,6 +260,7 @@ on_failure() {
release_lock
if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then
error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)"
write_result "false" "Upgrade failed at line ${BASH_LINENO[0]} (exit code ${exit_code})"
print_rollback_help
info "Log file: $LOG_FILE"
fi
@@ -235,6 +283,7 @@ Options:
--force Continue past non-critical warnings
--branch BRANCH Git branch to pull (default: current branch)
--rollback Rollback to pre-upgrade commit
--api-mode Write progress/result JSON for admin UI
--help Show this help message
Examples:
@@ -254,6 +303,7 @@ while [[ $# -gt 0 ]]; do
--force) FORCE=true; shift ;;
--branch) BRANCH="$2"; shift 2 ;;
--rollback) ROLLBACK=true; shift ;;
--api-mode) API_MODE=true; shift ;;
--help|-h) show_help ;;
*) error "Unknown option: $1"; echo "Run with --help for usage."; exit 1 ;;
esac
@@ -350,6 +400,7 @@ fi
# =============================================================================
phase "1" "Pre-flight Checks"
write_progress 1 "Pre-flight Checks" 5 "Verifying system requirements..."
# Docker
if command -v docker &>/dev/null; then
@@ -454,6 +505,7 @@ fi
# =============================================================================
phase "2" "Backup"
write_progress 2 "Backup" 15 "Creating backup..."
if [[ "$SKIP_BACKUP" == "true" ]]; then
warn "Backup skipped (--skip-backup --force)"
@@ -512,6 +564,7 @@ fi
# =============================================================================
phase "3" "Code Update"
write_progress 3 "Code Update" 30 "Pulling latest code..."
if [[ "$DRY_RUN" == "true" ]]; then
info "[DRY RUN] Would fetch and show incoming changes:"
@@ -640,6 +693,7 @@ fi
# =============================================================================
phase "4" "Container Rebuild"
write_progress 4 "Container Rebuild" 50 "Rebuilding containers..."
# Always rebuild source-built containers
info "Rebuilding source containers: $SOURCE_CONTAINERS"
@@ -683,6 +737,7 @@ fi
# =============================================================================
phase "5" "Service Restart"
write_progress 5 "Service Restart" 70 "Restarting services..."
# Stop application containers
info "Stopping application containers..."
@@ -773,6 +828,7 @@ fi
# =============================================================================
phase "6" "Post-Upgrade Verification"
write_progress 6 "Verification" 90 "Running health checks..."
VERIFY_FAILED=false
@@ -846,6 +902,15 @@ fi
ELAPSED="$(elapsed)"
FINAL_COMMIT="$(git rev-parse --short HEAD)"
# Collect warnings for API mode result
UPGRADE_WARNINGS="[]"
if [[ "$VERIFY_FAILED" == "true" ]]; then
UPGRADE_WARNINGS='["Some health checks failed after upgrade — services may still be starting"]'
fi
write_progress 6 "Verification" 100 "Upgrade complete!"
write_result "true" "Upgraded ${PRE_UPGRADE_SHORT}${FINAL_COMMIT} (${COMMIT_COUNT} commits)" "$UPGRADE_WARNINGS"
echo ""
echo -e "${BOLD}${GREEN}══════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${GREEN} Upgrade Complete${NC}"