From da3e43fcf7474c1d8caebe092a331fb333587494 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Tue, 3 Mar 2026 18:00:15 -0700 Subject: [PATCH] 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 --- .gitignore | 4 +- admin/src/pages/SettingsPage.tsx | 605 +++++++++++++++++++- admin/src/types/api.ts | 49 ++ api/src/modules/upgrade/upgrade.routes.ts | 69 +++ api/src/modules/upgrade/upgrade.service.ts | 182 ++++++ api/src/server.ts | 6 + config.sh | 59 ++ data/upgrade/.gitkeep | 0 docker-compose.yml | 1 + scripts/systemd/changemaker-upgrade.path | 10 + scripts/systemd/changemaker-upgrade.service | 13 + scripts/upgrade-check.sh | 105 ++++ scripts/upgrade-watcher.sh | 88 +++ scripts/upgrade.sh | 65 +++ 14 files changed, 1238 insertions(+), 18 deletions(-) create mode 100644 api/src/modules/upgrade/upgrade.routes.ts create mode 100644 api/src/modules/upgrade/upgrade.service.ts create mode 100644 data/upgrade/.gitkeep create mode 100644 scripts/systemd/changemaker-upgrade.path create mode 100644 scripts/systemd/changemaker-upgrade.service create mode 100755 scripts/upgrade-check.sh create mode 100755 scripts/upgrade-watcher.sh diff --git a/.gitignore b/.gitignore index 31a98ad5..f89afe65 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,9 @@ node_modules/ /influence/app/public/uploadsdata/ # NAR data directory (large voter registry files) -/data/ +/data/* +!/data/upgrade/ +/data/upgrade/*.json # Media files (managed by Docker volumes, not git) /media/ diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index a3fc25af..1047a93e 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import { useOutletContext, useLocation } from 'react-router-dom'; import { Typography, @@ -19,6 +19,12 @@ import { Collapse, Row, Col, + Slider, + Progress, + Steps, + Modal, + Checkbox, + Timeline, message, Spin, } from 'antd'; @@ -39,13 +45,21 @@ import { MailOutlined, RetweetOutlined, PhoneOutlined, + CloudSyncOutlined, + SyncOutlined, + BranchesOutlined, + HistoryOutlined, + RocketOutlined, + ExclamationCircleOutlined, + LoadingOutlined, + ReloadOutlined, } from '@ant-design/icons'; import { useSettingsStore } from '@/stores/settings.store'; import { api } from '@/lib/api'; import type { AppOutletContext } from '@/components/AppLayout'; -import type { SmtpTestResult, SmtpSendTestResult } from '@/types/api'; +import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult } from '@/types/api'; -const { Text } = Typography; +const { Text, Paragraph } = Typography; export default function SettingsPage() { const { setPageHeader } = useOutletContext(); @@ -204,8 +218,8 @@ export default function SettingsPage() { - - + + @@ -220,18 +234,6 @@ export default function SettingsPage() { -
- Header Gradient Preview -
)} @@ -652,6 +654,12 @@ export default function SettingsPage() { ), }, + { + key: 'system', + label: 'System', + icon: , + children: , + }, ]; return ( @@ -666,6 +674,433 @@ export default function SettingsPage() { ); } +// ============================================================================= +// System Upgrade Tab +// ============================================================================= + +const UPGRADE_PHASES = [ + 'Pre-flight Checks', + 'Backup', + 'Code Update', + 'Container Rebuild', + 'Service Restart', + 'Verification', +]; + +function SystemUpgradeTab() { + const [status, setStatus] = useState(null); + const [progress, setProgress] = useState(null); + const [result, setResult] = useState(null); + const [running, setRunning] = useState(false); + const [checking, setChecking] = useState(false); + const [upgrading, setUpgrading] = useState(false); + const [apiOffline, setApiOffline] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [skipBackup, setSkipBackup] = useState(false); + const [pullServices, setPullServices] = useState(false); + const pollRef = useRef | null>(null); + const checkStartRef = useRef(null); + + const fetchStatus = useCallback(async () => { + try { + const { data } = await api.get('/upgrade/status'); + setStatus(data.status); + setProgress(data.progress); + setResult(data.result); + setRunning(data.running); + setApiOffline(false); + return data; + } catch { + setApiOffline(true); + return null; + } + }, []); + + // Initial fetch on mount + useEffect(() => { + fetchStatus().then((data) => { + // If already running when we load, start polling + if (data?.running) { + setUpgrading(true); + startUpgradePoll(); + } + }); + return () => stopPoll(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const stopPoll = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + const startCheckPoll = () => { + stopPoll(); + checkStartRef.current = status?.checkedAt || null; + pollRef.current = setInterval(async () => { + const data = await fetchStatus(); + if (!data) return; + // Check complete when checkedAt changes + if (data.status && data.status.checkedAt !== checkStartRef.current) { + setChecking(false); + stopPoll(); + message.success('Update check complete'); + } + }, 2000); + // Auto-stop after 30s + setTimeout(() => { + if (checking) { + setChecking(false); + stopPoll(); + } + }, 30000); + }; + + const startUpgradePoll = () => { + stopPoll(); + pollRef.current = setInterval(async () => { + const data = await fetchStatus(); + // If API is offline, show restarting message (expected during upgrade) + if (!data) return; + // Upgrade complete when result appears or not running anymore + if (data.result && !data.running) { + setUpgrading(false); + stopPoll(); + if (data.result.success) { + message.success('Upgrade completed successfully'); + } else { + message.error('Upgrade failed'); + } + } + }, 3000); + }; + + const handleCheckForUpdates = async () => { + setChecking(true); + try { + await api.post('/upgrade/check'); + startCheckPoll(); + } catch { + setChecking(false); + message.error('Failed to trigger update check'); + } + }; + + const handleStartUpgrade = async () => { + setConfirmOpen(false); + setUpgrading(true); + setResult(null); + try { + await api.post('/upgrade/start', { skipBackup, pullServices }); + startUpgradePoll(); + } catch { + setUpgrading(false); + message.error('Failed to trigger upgrade'); + } + }; + + const handleClearResult = async () => { + try { + await api.post('/upgrade/clear-result'); + setResult(null); + } catch { + message.error('Failed to clear result'); + } + }; + + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleString(); + } catch { + return dateStr; + } + }; + + const formatDuration = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; + }; + + const formatRelativeTime = (dateStr: string) => { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + }; + + const isUpgrading = upgrading || running; + + return ( +
+ {/* Current Version */} + Current Version} + style={{ marginBottom: 16 }} + > + {status ? ( + + + {status.branch} + + + {status.currentCommit} + + + {status.currentMessage} + + + {status.currentDate ? formatDate(status.currentDate) : '-'} + + + {status.checkedAt ? ( + {formatRelativeTime(status.checkedAt)} + ) : ( + Never + )} + + + ) : ( + + No version information available. Click "Check for Updates" to fetch the current status. + + )} + + + {/* Available Updates */} + {status && status.commitsBehind > 0 && ( + + + Available Updates + {status.commitsBehind} commit{status.commitsBehind !== 1 ? 's' : ''} behind + + } + style={{ marginBottom: 16 }} + > +
+ ({ + color: 'blue', + children: ( +
+ + {entry.hash} + {entry.author} + +
{entry.message}
+
+ ), + }))} + /> +
+
+ )} + + {status && status.commitsBehind === 0 && status.checkedAt && !status.error && ( + + )} + + {status?.error && ( + + )} + + {/* Actions */} + + + + + + {/* Upgrade Progress */} + {isUpgrading && ( + Upgrade in Progress} + style={{ marginBottom: 16 }} + > + {apiOffline ? ( + } + style={{ marginBottom: 12 }} + /> + ) : progress ? ( + <> + ({ + title: name, + status: progress.phase - 1 > i ? 'finish' : progress.phase - 1 === i ? 'process' : 'wait', + }))} + style={{ marginBottom: 16 }} + /> + + + {progress.message} + + + ) : ( +
+ + + Waiting for upgrade to start... + +
+ )} +
+ )} + + {/* Last Result */} + {result && !isUpgrading && ( + Last Upgrade Result} + extra={ + + } + style={{ marginBottom: 16 }} + > + + + + {result.previousCommit} + + + {result.newCommit} + + + {result.commitCount} + + + {formatDuration(result.durationSeconds)} + + + {formatDate(result.completedAt)} + + + {result.warnings.length > 0 && ( + + {result.warnings.map((w, i) =>
  • {w}
  • )} + + } + showIcon + style={{ marginTop: 12 }} + /> + )} +
    + )} + + {/* Systemd Setup Info */} + Setup} + style={{ marginBottom: 16, opacity: 0.8 }} + > + + The upgrade system requires a systemd path watcher on the host. This is normally + installed by ./config.sh during initial setup. To install or + reinstall manually: + + + {`cd ~/changemaker.lite && sudo cp scripts/systemd/changemaker-upgrade.* /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-upgrade.path`} + + + + {/* Upgrade Confirmation Modal */} + + + Confirm System Upgrade + + } + open={confirmOpen} + onOk={handleStartUpgrade} + onCancel={() => setConfirmOpen(false)} + okText="Start Upgrade" + okButtonProps={{ danger: true }} + > + + This will pull the latest code, rebuild containers, and restart all services. + The admin panel will be briefly unavailable during the restart. + + {status && ( + + {status.commitsBehind} commit{status.commitsBehind !== 1 ? 's' : ''} will + be applied from {status.currentCommit} to {status.remoteCommit}. + + )} + + + setPullServices(e.target.checked)} + > + Pull third-party images (PostgreSQL, Redis, etc.) + + setSkipBackup(e.target.checked)} + > + Skip backup (not recommended) + + + +
    + ); +} + function Swatch({ label, color }: { label: string; color: string }) { return (
    @@ -683,3 +1118,139 @@ function Swatch({ label, color }: { label: string; color: string }) {
    ); } + +/** Parse "linear-gradient(135deg, #005a9c 0%, #007acc 100%)" into parts */ +function parseGradient(val: string): { angle: number; color1: string; color2: string } { + const defaults = { angle: 135, color1: '#005a9c', color2: '#007acc' }; + if (!val || !val.includes('linear-gradient')) return defaults; + const inner = val.replace(/^linear-gradient\(/, '').replace(/\)$/, ''); + // Match angle + const angleMatch = inner.match(/^(\d+)deg/); + const angle = angleMatch ? parseInt(angleMatch[1]!, 10) : defaults.angle; + // Match colors (hex, rgb, named) + const colorMatches = inner.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/g); + return { + angle, + color1: colorMatches?.[0] ?? defaults.color1, + color2: colorMatches?.[1] ?? colorMatches?.[0] ?? defaults.color2, + }; +} + +function buildGradient(angle: number, color1: string, color2: string): string { + return `linear-gradient(${angle}deg, ${color1} 0%, ${color2} 100%)`; +} + +/** Custom form control — accepts value/onChange from Form.Item */ +function GradientPicker({ value, onChange }: { value?: string; onChange?: (val: string) => void }) { + const parsed = parseGradient(value || ''); + const [angle, setAngle] = useState(parsed.angle); + const [color1, setColor1] = useState(parsed.color1); + const [color2, setColor2] = useState(parsed.color2); + + // Sync from external value changes (e.g., form reset) + useEffect(() => { + const p = parseGradient(value || ''); + setAngle(p.angle); + setColor1(p.color1); + setColor2(p.color2); + }, [value]); + + const emit = useCallback( + (a: number, c1: string, c2: string) => { + onChange?.(buildGradient(a, c1, c2)); + }, + [onChange], + ); + + const gradient = buildGradient(angle, color1, color2); + + return ( +
    + {/* Live preview */} +
    + Header Gradient Preview +
    + + {/* Color pickers row */} +
    +
    + Start Color + { + setColor1(hex); + emit(angle, hex, color2); + }} + /> +
    +
    + End Color + { + setColor2(hex); + emit(angle, color1, hex); + }} + /> +
    + +
    + + {/* Angle slider */} +
    + Angle: {angle}° + { + setAngle(val); + emit(val, color1, color2); + }} + tooltip={{ formatter: (v) => `${v}°` }} + styles={{ track: { background: gradient } }} + /> +
    + + {/* Raw CSS output (editable) */} + { + const raw = e.target.value; + onChange?.(raw); + const p = parseGradient(raw); + setAngle(p.angle); + setColor1(p.color1); + setColor2(p.color2); + }} + style={{ fontFamily: 'monospace', fontSize: 12 }} + /> +
    + ); +} diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index 7e874c17..cfe49947 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -2854,3 +2854,52 @@ export interface PollDetailResponse extends SchedulingPoll { }>; } +// --- System Upgrade --- + +export interface UpgradeChangelogEntry { + hash: string; + message: string; + date: string; + author: string; +} + +export interface UpgradeStatus { + branch: string; + currentCommit: string; + currentCommitFull: string; + currentMessage: string; + currentDate: string; + remoteCommit: string | null; + remoteCommitFull?: string | null; + commitsBehind: number; + changelog: UpgradeChangelogEntry[]; + checkedAt: string; + error: string | null; +} + +export interface UpgradeProgress { + phase: number; + phaseName: string; + percentage: number; + message: string; + lastUpdate: string; +} + +export interface UpgradeResult { + success: boolean; + message: string; + previousCommit: string; + newCommit: string; + commitCount: number; + durationSeconds: number; + warnings: string[]; + completedAt: string; +} + +export interface UpgradeStatusResponse { + status: UpgradeStatus | null; + progress: UpgradeProgress | null; + result: UpgradeResult | null; + running: boolean; +} + diff --git a/api/src/modules/upgrade/upgrade.routes.ts b/api/src/modules/upgrade/upgrade.routes.ts new file mode 100644 index 00000000..c2a2234b --- /dev/null +++ b/api/src/modules/upgrade/upgrade.routes.ts @@ -0,0 +1,69 @@ +import { Router } from 'express'; +import { authenticate } from '../../middleware/auth.middleware'; +import { requireRole } from '../../middleware/rbac.middleware'; +import { upgradeService } from './upgrade.service'; + +const router = Router(); + +// All routes require SUPER_ADMIN +router.use(authenticate, requireRole('SUPER_ADMIN')); + +/** + * GET /api/upgrade/status + * Returns combined status: version info, progress (if running), and last result. + */ +router.get('/status', (_req, res) => { + const status = upgradeService.getStatus(); + const progress = upgradeService.getProgress(); + const result = upgradeService.getResult(); + const running = upgradeService.isRunning(); + + res.json({ status, progress: running ? progress : null, result, running }); +}); + +/** + * POST /api/upgrade/check + * Triggers an update check (writes trigger.json for systemd watcher). + */ +router.post('/check', (req, res) => { + try { + const userEmail = req.user?.email || 'unknown'; + upgradeService.triggerCheck(userEmail); + res.json({ message: 'Update check triggered' }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to trigger check'; + res.status(409).json({ error: { message, code: 'UPGRADE_BUSY' } }); + } +}); + +/** + * POST /api/upgrade/start + * Triggers an upgrade (writes trigger.json for systemd watcher). + * Body: { skipBackup?: boolean, pullServices?: boolean, dryRun?: boolean } + */ +router.post('/start', (req, res) => { + try { + const userEmail = req.user?.email || 'unknown'; + const { skipBackup, pullServices, dryRun } = req.body as { + skipBackup?: boolean; + pullServices?: boolean; + dryRun?: boolean; + }; + upgradeService.triggerUpgrade(userEmail, { skipBackup, pullServices, dryRun }); + res.json({ message: 'Upgrade triggered' }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to trigger upgrade'; + res.status(409).json({ error: { message, code: 'UPGRADE_BUSY' } }); + } +}); + +/** + * POST /api/upgrade/clear-result + * Removes the last upgrade result file. + */ +router.post('/clear-result', (_req, res) => { + upgradeService.clearResult(); + res.json({ message: 'Result cleared' }); +}); + +export { router as upgradeRouter }; diff --git a/api/src/modules/upgrade/upgrade.service.ts b/api/src/modules/upgrade/upgrade.service.ts new file mode 100644 index 00000000..fe906690 --- /dev/null +++ b/api/src/modules/upgrade/upgrade.service.ts @@ -0,0 +1,182 @@ +import fs from 'fs'; +import path from 'path'; +import { logger } from '../../utils/logger'; + +/** + * Upgrade service — reads/writes JSON files from the shared upgrade directory. + * The API container writes trigger files; the host systemd watcher reads them + * and runs upgrade scripts. Status/progress/result files are written by the + * host scripts and read by this service. + */ + +const UPGRADE_DIR = path.resolve('/app/upgrade'); +const STATUS_FILE = path.join(UPGRADE_DIR, 'status.json'); +const PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json'); +const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json'); +const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.json'); + +// Stale threshold: if progress hasn't been updated in this many ms, assume crashed +const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes + +interface UpgradeStatus { + branch: string; + currentCommit: string; + currentCommitFull: string; + currentMessage: string; + currentDate: string; + remoteCommit: string | null; + remoteCommitFull?: string | null; + commitsBehind: number; + changelog: Array<{ + hash: string; + message: string; + date: string; + author: string; + }>; + checkedAt: string; + error: string | null; +} + +interface UpgradeProgress { + phase: number; + phaseName: string; + percentage: number; + message: string; + lastUpdate: string; +} + +interface UpgradeResult { + success: boolean; + message: string; + previousCommit: string; + newCommit: string; + commitCount: number; + durationSeconds: number; + warnings: string[]; + completedAt: string; +} + +interface TriggerPayload { + action: 'check' | 'upgrade'; + branch?: string; + skipBackup?: boolean; + pullServices?: boolean; + dryRun?: boolean; + triggeredAt: string; + triggeredBy: string; +} + +function readJsonFile(filePath: string): T | null { + try { + if (!fs.existsSync(filePath)) return null; + const raw = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(raw) as T; + } catch (err) { + logger.warn(`Failed to read ${path.basename(filePath)}:`, err); + return null; + } +} + +function writeJsonFile(filePath: string, data: unknown): void { + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (err) { + logger.error(`Failed to write ${path.basename(filePath)}:`, err); + throw err; + } +} + +function getStatus(): UpgradeStatus | null { + return readJsonFile(STATUS_FILE); +} + +function getProgress(): UpgradeProgress | null { + return readJsonFile(PROGRESS_FILE); +} + +function getResult(): UpgradeResult | null { + return readJsonFile(RESULT_FILE); +} + +function isRunning(): boolean { + const progress = getProgress(); + if (!progress) return false; + + // Check if progress is stale (script probably crashed) + const lastUpdate = new Date(progress.lastUpdate).getTime(); + const age = Date.now() - lastUpdate; + if (age > STALE_THRESHOLD_MS) { + logger.warn(`Upgrade progress is stale (${Math.round(age / 60000)}min old), assuming crashed`); + return false; + } + + return true; +} + +function triggerCheck(triggeredBy: string, branch?: string): void { + if (isRunning()) { + throw new Error('An upgrade is already in progress'); + } + + const payload: TriggerPayload = { + action: 'check', + triggeredAt: new Date().toISOString(), + triggeredBy, + }; + if (branch) payload.branch = branch; + + writeJsonFile(TRIGGER_FILE, payload); + logger.info(`Update check triggered by ${triggeredBy}`); +} + +function triggerUpgrade( + triggeredBy: string, + options: { skipBackup?: boolean; pullServices?: boolean; dryRun?: boolean; branch?: string } = {}, +): void { + if (isRunning()) { + throw new Error('An upgrade is already in progress'); + } + + const payload: TriggerPayload = { + action: 'upgrade', + triggeredAt: new Date().toISOString(), + triggeredBy, + ...options, + }; + + writeJsonFile(TRIGGER_FILE, payload); + logger.info(`Upgrade triggered by ${triggeredBy} (options: ${JSON.stringify(options)})`); +} + +function clearResult(): void { + try { + if (fs.existsSync(RESULT_FILE)) { + fs.unlinkSync(RESULT_FILE); + } + } catch (err) { + logger.warn('Failed to clear result file:', err); + } +} + +function clearStaleProgress(): void { + try { + if (fs.existsSync(PROGRESS_FILE) && !isRunning()) { + fs.unlinkSync(PROGRESS_FILE); + logger.info('Cleaned up stale upgrade progress file'); + } + } catch { + // Ignore + } +} + +export const upgradeService = { + getStatus, + getProgress, + getResult, + isRunning, + triggerCheck, + triggerUpgrade, + clearResult, + clearStaleProgress, +}; diff --git a/api/src/server.ts b/api/src/server.ts index 94311192..d6ddeb58 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -53,6 +53,7 @@ import { narImportRouter } from './modules/map/locations/nar-import.routes'; import { areaImportRouter } from './modules/map/locations/area-import.routes'; import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes'; import { observabilityRouter } from './modules/observability/observability.routes'; +import { upgradeRouter } from './modules/upgrade/upgrade.routes'; import { dashboardRouter } from './modules/dashboard/dashboard.routes'; import { initEncryption } from './utils/crypto'; import { emailService } from './services/email.service'; @@ -104,6 +105,7 @@ import { socialRouter } from './modules/social/social.routes'; import { errorReportRouter } from './modules/reports/error-report.routes'; import { sseService } from './modules/social/sse.service'; import { presenceService } from './modules/social/presence.service'; +import { upgradeService } from './modules/upgrade/upgrade.service'; const app = express(); @@ -233,6 +235,7 @@ app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel ma app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required) app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required) app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN) +app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN) app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles) app.use('/api/donation-pages', donationPagesPublicRouter); // Public donation pages (no auth) app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription) @@ -366,6 +369,9 @@ async function start() { sseService.startHeartbeat(); setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min + // Clean up stale upgrade progress on startup + upgradeService.clearStaleProgress(); + // Setup Rocket.Chat notification channels (non-blocking) rocketchatWebhookService.setupChannels().catch(() => {}); diff --git a/config.sh b/config.sh index 8cc7326b..6269e95b 100755 --- a/config.sh +++ b/config.sh @@ -1004,6 +1004,63 @@ fix_container_permissions() { fi } +# ============================================================================= +# Upgrade Watcher (systemd) +# ============================================================================= + +install_upgrade_watcher() { + header "System Upgrade Watcher" + + info "The upgrade watcher lets you trigger upgrades from the admin Settings page." + info "It installs a systemd path watcher that monitors for trigger files." + echo "" + + # Ensure upgrade IPC directory exists + mkdir -p "$SCRIPT_DIR/data/upgrade" + + local unit_src="$SCRIPT_DIR/scripts/systemd" + if [[ ! -f "$unit_src/changemaker-upgrade.path" ]] || [[ ! -f "$unit_src/changemaker-upgrade.service" ]]; then + warn "Systemd unit templates not found in scripts/systemd/ — skipping" + UPGRADE_WATCHER="skipped" + return + fi + + if ! command -v systemctl &>/dev/null; then + warn "systemctl not found — skipping (not a systemd host?)" + UPGRADE_WATCHER="skipped" + return + fi + + if prompt_yes_no "Install the upgrade watcher (requires sudo)?"; then + # Generate units with correct paths substituted + local tmp_path tmp_service + tmp_path=$(mktemp) + tmp_service=$(mktemp) + + sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" "$unit_src/changemaker-upgrade.path" > "$tmp_path" + sed -e "s|__PROJECT_DIR__|$SCRIPT_DIR|g" -e "s|__USER__|$(whoami)|g" "$unit_src/changemaker-upgrade.service" > "$tmp_service" + + if sudo cp "$tmp_path" /etc/systemd/system/changemaker-upgrade.path \ + && sudo cp "$tmp_service" /etc/systemd/system/changemaker-upgrade.service \ + && sudo systemctl daemon-reload \ + && sudo systemctl enable --now changemaker-upgrade.path; then + success "Upgrade watcher installed and enabled" + UPGRADE_WATCHER="yes" + else + warn "Failed to install systemd units (sudo may have failed)" + warn "Install manually later:" + echo -e " ${CYAN}sudo cp scripts/systemd/changemaker-upgrade.* /etc/systemd/system/${NC}" + echo -e " ${CYAN}sudo systemctl daemon-reload && sudo systemctl enable --now changemaker-upgrade.path${NC}" + UPGRADE_WATCHER="manual" + fi + + rm -f "$tmp_path" "$tmp_service" + else + info "Skipped. You can install it later from Settings > System." + UPGRADE_WATCHER="skipped" + fi +} + # ============================================================================= # Summary & Next Steps # ============================================================================= @@ -1025,6 +1082,7 @@ print_summary() { echo -e " ${BOLD}Docs Comments:${NC} ${DOCS_COMMENTS_ENABLED:-no}" echo -e " ${BOLD}Bunker Ops:${NC} ${BUNKER_OPS_ENABLED:-no}" echo -e " ${BOLD}Pangolin:${NC} ${PANGOLIN_CONFIGURED:-no}" + echo -e " ${BOLD}Upgrade watcher:${NC} ${UPGRADE_WATCHER:-skipped}" echo -e " ${BOLD}Secrets:${NC} 21 auto-generated" echo "" echo -e " ${DIM}Config file: $ENV_FILE${NC}" @@ -1083,6 +1141,7 @@ main() { generate_nginx_configs generate_services_yaml fix_container_permissions + install_upgrade_watcher print_summary print_next_steps diff --git a/data/upgrade/.gitkeep b/data/upgrade/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docker-compose.yml b/docker-compose.yml index d9a12189..fdc0bc90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,7 @@ services: - ./assets/uploads:/app/uploads - ./mkdocs:/mkdocs:rw - ./data:/data:ro + - ./data/upgrade:/app/upgrade:rw - ./configs:/app/configs:ro deploy: resources: diff --git a/scripts/systemd/changemaker-upgrade.path b/scripts/systemd/changemaker-upgrade.path new file mode 100644 index 00000000..796455bc --- /dev/null +++ b/scripts/systemd/changemaker-upgrade.path @@ -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 diff --git a/scripts/systemd/changemaker-upgrade.service b/scripts/systemd/changemaker-upgrade.service new file mode 100644 index 00000000..116fefc7 --- /dev/null +++ b/scripts/systemd/changemaker-upgrade.service @@ -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 diff --git a/scripts/upgrade-check.sh b/scripts/upgrade-check.sh new file mode 100755 index 00000000..2fa021cd --- /dev/null +++ b/scripts/upgrade-check.sh @@ -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" </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" <&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 diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 3913eaf7..dce364c9 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -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" < "$RESULT_FILE" </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}"