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,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 };

View File

@@ -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<T>(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<UpgradeStatus>(STATUS_FILE);
}
function getProgress(): UpgradeProgress | null {
return readJsonFile<UpgradeProgress>(PROGRESS_FILE);
}
function getResult(): UpgradeResult | null {
return readJsonFile<UpgradeResult>(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,
};

View File

@@ -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(() => {});