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:
69
api/src/modules/upgrade/upgrade.routes.ts
Normal file
69
api/src/modules/upgrade/upgrade.routes.ts
Normal 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 };
|
||||
182
api/src/modules/upgrade/upgrade.service.ts
Normal file
182
api/src/modules/upgrade/upgrade.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user