mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
fix(fleet): harden remote node updates with admin enforcement, expiry fix, and diagnostics (#542)
- Fix completed-entry auto-expiry using resolvedAt instead of startedAt - Add admin role enforcement to update trigger and update-all endpoints - Add missing error field in rejected-promise fallback for update-status - Fix rejected promises in update-all losing node names - Harden frontend recheck button with try/catch/finally error handling - Align frontend isValidVersion with stricter regex validation - Add diagnostic logging gated behind developer_mode - Extract resolveTracker helper to centralize terminal state transitions - Add 15 new fleet test cases covering auth, tier gating, input validation, and admin roles - Document admin requirement and troubleshooting in fleet-view docs
This commit is contained in:
@@ -60,6 +60,21 @@ describe('Fleet endpoints require authentication', () => {
|
|||||||
const res = await request(app).post('/api/fleet/nodes/1/update');
|
const res = await request(app).post('/api/fleet/nodes/1/update');
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/update-all returns 401 without auth', async () => {
|
||||||
|
const res = await request(app).post('/api/fleet/update-all');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/nodes/1/update-status returns 401 without auth', async () => {
|
||||||
|
const res = await request(app).delete('/api/fleet/nodes/1/update-status');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/update-status returns 401 without auth', async () => {
|
||||||
|
const res = await request(app).delete('/api/fleet/update-status');
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Input Validation ───
|
// ─── Input Validation ───
|
||||||
@@ -159,6 +174,125 @@ describe('Fleet tier gating', () => {
|
|||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/nodes/1/update returns 403 on free tier', async () => {
|
||||||
|
mockTier('community');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/nodes/1/update')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/update-all returns 403 on free tier', async () => {
|
||||||
|
mockTier('community');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/update-all')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/nodes/1/update-status returns 403 on free tier', async () => {
|
||||||
|
mockTier('community');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/fleet/nodes/1/update-status')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/update-status returns 403 on free tier', async () => {
|
||||||
|
mockTier('community');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/fleet/update-status')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Update Endpoint Input Validation ───
|
||||||
|
|
||||||
|
describe('Fleet update input validation', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('POST /api/fleet/nodes/abc/update returns 400 for NaN nodeId', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/nodes/abc/update')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/invalid node id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/nodes/99999/update returns 404 for missing node', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/nodes/99999/update')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/nodes/abc/update-status returns 400 for NaN nodeId', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/fleet/nodes/abc/update-status')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/invalid node id/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/fleet/nodes/99999/update-status returns 404 for missing node', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.delete('/api/fleet/nodes/99999/update-status')
|
||||||
|
.set('Authorization', authHeader);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Admin Role Enforcement ───
|
||||||
|
|
||||||
|
describe('Fleet update admin enforcement', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
let viewerHeader: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Create a viewer user and generate a token for them
|
||||||
|
const { DatabaseService } = await import('../services/DatabaseService');
|
||||||
|
const db = DatabaseService.getInstance();
|
||||||
|
const bcrypt = await import('bcrypt');
|
||||||
|
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||||
|
try {
|
||||||
|
db.addUser({ username: 'testviewer', password_hash: viewerHash, role: 'viewer' });
|
||||||
|
} catch {
|
||||||
|
// User may already exist from a prior run
|
||||||
|
}
|
||||||
|
const viewerToken = jwt.sign({ username: 'testviewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||||
|
viewerHeader = `Bearer ${viewerToken}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/nodes/1/update returns 403 for viewer', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/nodes/1/update')
|
||||||
|
.set('Authorization', viewerHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/fleet/update-all returns 403 for viewer', async () => {
|
||||||
|
mockTier('paid');
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/fleet/update-all')
|
||||||
|
.set('Authorization', viewerHeader);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(res.body.code).toBe('ADMIN_REQUIRED');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Snapshot CRUD ───
|
// ─── Snapshot CRUD ───
|
||||||
|
|||||||
+59
-24
@@ -1277,6 +1277,8 @@ interface UpdateTracker {
|
|||||||
previousProcessStart: number | null;
|
previousProcessStart: number | null;
|
||||||
/** True when the node became unreachable at least once during the update window. */
|
/** True when the node became unreachable at least once during the update window. */
|
||||||
wasOffline: boolean;
|
wasOffline: boolean;
|
||||||
|
/** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */
|
||||||
|
resolvedAt?: number;
|
||||||
}
|
}
|
||||||
const updateTracker = new Map<number, UpdateTracker>();
|
const updateTracker = new Map<number, UpdateTracker>();
|
||||||
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
@@ -1353,12 +1355,16 @@ async function getLatestVersion(forceRefresh = false): Promise<string | null> {
|
|||||||
async function getCompareTarget(gatewayVersion: string | null) {
|
async function getCompareTarget(gatewayVersion: string | null) {
|
||||||
const latestVersion = await getLatestVersion();
|
const latestVersion = await getLatestVersion();
|
||||||
const latestValid = latestVersion !== null && isValidVersion(latestVersion);
|
const latestValid = latestVersion !== null && isValidVersion(latestVersion);
|
||||||
return {
|
const result = {
|
||||||
latestVersion,
|
latestVersion,
|
||||||
latestValid,
|
latestValid,
|
||||||
compareVersion: latestValid ? latestVersion : gatewayVersion,
|
compareVersion: latestValid ? latestVersion : gatewayVersion,
|
||||||
compareValid: latestValid || isValidVersion(gatewayVersion),
|
compareValid: latestValid || isValidVersion(gatewayVersion),
|
||||||
};
|
};
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.debug('[Fleet:debug] Compare target resolved:', { gatewayVersion, latestVersion, using: result.compareVersion, valid: result.compareValid });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTracker(
|
function createTracker(
|
||||||
@@ -1367,7 +1373,16 @@ function createTracker(
|
|||||||
previousProcessStart: number | null,
|
previousProcessStart: number | null,
|
||||||
error?: string,
|
error?: string,
|
||||||
): UpdateTracker {
|
): UpdateTracker {
|
||||||
return { status, startedAt: Date.now(), previousVersion, previousProcessStart, wasOffline: false, error };
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
status, startedAt: now, previousVersion, previousProcessStart, wasOffline: false, error,
|
||||||
|
resolvedAt: status !== 'updating' ? now : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transition a tracker to a terminal state, setting resolvedAt automatically. */
|
||||||
|
function resolveTracker(tracker: UpdateTracker, status: 'completed' | 'failed' | 'timeout', error?: string): UpdateTracker {
|
||||||
|
return { ...tracker, status, resolvedAt: Date.now(), error };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FleetNodeOverview {
|
interface FleetNodeOverview {
|
||||||
@@ -1528,6 +1543,7 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
const gatewayValid = isValidVersion(gatewayVersion);
|
const gatewayValid = isValidVersion(gatewayVersion);
|
||||||
|
|
||||||
const { latestVersion, latestValid, compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
const { latestVersion, latestValid, compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
||||||
|
const debug = isDebugEnabled();
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
nodes.map(async (node) => {
|
nodes.map(async (node) => {
|
||||||
@@ -1551,31 +1567,41 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
if (tracker?.status === 'updating') {
|
if (tracker?.status === 'updating') {
|
||||||
const elapsed = Date.now() - tracker.startedAt;
|
const elapsed = Date.now() - tracker.startedAt;
|
||||||
|
|
||||||
|
if (debug) {
|
||||||
|
console.debug('[Fleet:debug] Polling update status for node', node.id, node.name, '- elapsed:', Math.round(elapsed / 1000) + 's', 'version:', version, 'wasOffline:', tracker.wasOffline, 'remoteOnline:', remoteOnline);
|
||||||
|
}
|
||||||
|
|
||||||
if (elapsed > UPDATE_TIMEOUT_MS) {
|
if (elapsed > UPDATE_TIMEOUT_MS) {
|
||||||
// Final timeout (5 min)
|
// Final timeout (5 min)
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'timeout', error: UPDATE_TIMEOUT_MSG });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's');
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||||
} else if (node.type === 'remote') {
|
} else if (node.type === 'remote') {
|
||||||
if (remoteUpdateError) {
|
if (remoteUpdateError) {
|
||||||
// Remote reported a pull failure via /api/meta
|
// Remote reported a pull failure via /api/meta
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'failed', error: remoteUpdateError });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError);
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'failed', remoteUpdateError));
|
||||||
} else if (!remoteOnline) {
|
} else if (!remoteOnline) {
|
||||||
// Node is unreachable (restarting); record that it went offline
|
// Node is unreachable (restarting); record that it went offline
|
||||||
if (!tracker.wasOffline) {
|
if (!tracker.wasOffline) {
|
||||||
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)');
|
||||||
updateTracker.set(node.id, { ...tracker, wasOffline: true });
|
updateTracker.set(node.id, { ...tracker, wasOffline: true });
|
||||||
}
|
}
|
||||||
} else if (version !== tracker.previousVersion) {
|
} else if (version !== tracker.previousVersion) {
|
||||||
// Signal 1: Version changed (or version now resolvable after being unknown)
|
// Signal 1: Version changed (or version now resolvable after being unknown)
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'completed' });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version);
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
|
||||||
} else if (
|
} else if (
|
||||||
remoteStartedAt !== null &&
|
remoteStartedAt !== null &&
|
||||||
tracker.previousProcessStart !== null &&
|
tracker.previousProcessStart !== null &&
|
||||||
remoteStartedAt !== tracker.previousProcessStart
|
remoteStartedAt !== tracker.previousProcessStart
|
||||||
) {
|
) {
|
||||||
// Signal 2: Process restarted (startedAt changed)
|
// Signal 2: Process restarted (startedAt changed)
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'completed' });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 2 (process restarted):', tracker.previousProcessStart, '->', remoteStartedAt);
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
|
||||||
} else if (tracker.wasOffline && remoteOnline) {
|
} else if (tracker.wasOffline && remoteOnline) {
|
||||||
// Signal 3: Node went offline and is back online (container was recreated)
|
// Signal 3: Node went offline and is back online (container was recreated)
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'completed' });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online)');
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
|
||||||
} else if (
|
} else if (
|
||||||
elapsed > 15_000 &&
|
elapsed > 15_000 &&
|
||||||
isValidVersion(version) &&
|
isValidVersion(version) &&
|
||||||
@@ -1585,14 +1611,12 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
// Signal 4: Remote is now at or above gateway version (after minimum processing time).
|
// Signal 4: Remote is now at or above gateway version (after minimum processing time).
|
||||||
// Catches fast restarts where the 5s polling interval misses the offline window
|
// Catches fast restarts where the 5s polling interval misses the offline window
|
||||||
// and startedAt hasn't been observed to change yet.
|
// and startedAt hasn't been observed to change yet.
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'completed' });
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 4 (version >= compare target):', version, '>=', compareVersion);
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'completed'));
|
||||||
} else if (elapsed > EARLY_FAIL_MS) {
|
} else if (elapsed > EARLY_FAIL_MS) {
|
||||||
// Heuristic: node never went offline and nothing changed after 3 min
|
// Heuristic: node never went offline and nothing changed after 3 min
|
||||||
updateTracker.set(node.id, {
|
if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected');
|
||||||
...tracker,
|
updateTracker.set(node.id, resolveTracker(tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.'));
|
||||||
status: 'failed',
|
|
||||||
error: 'Update may have failed. The node is still running and its version has not changed.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else if (node.type === 'local') {
|
} else if (node.type === 'local') {
|
||||||
// Local node has only two failure signals: an explicit pull/spawn error,
|
// Local node has only two failure signals: an explicit pull/spawn error,
|
||||||
@@ -1602,21 +1626,19 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
const selfUpdate = SelfUpdateService.getInstance();
|
const selfUpdate = SelfUpdateService.getInstance();
|
||||||
const localError = selfUpdate.getLastError();
|
const localError = selfUpdate.getLastError();
|
||||||
if (localError) {
|
if (localError) {
|
||||||
updateTracker.set(node.id, { ...tracker, status: 'failed', error: localError });
|
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'update failed:', localError);
|
||||||
|
updateTracker.set(node.id, resolveTracker(tracker, 'failed', localError));
|
||||||
selfUpdate.clearLastError();
|
selfUpdate.clearLastError();
|
||||||
} else if (elapsed > EARLY_FAIL_MS) {
|
} else if (elapsed > EARLY_FAIL_MS) {
|
||||||
// Helper container likely failed silently. Surface failure before the 5 min timeout.
|
// Helper container likely failed silently. Surface failure before the 5 min timeout.
|
||||||
updateTracker.set(node.id, {
|
if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's');
|
||||||
...tracker,
|
updateTracker.set(node.id, resolveTracker(tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'));
|
||||||
status: 'failed',
|
|
||||||
error: 'Local update did not complete. The container may not have restarted; check Docker logs on the host.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-expire completed entries after 60 seconds so nodes return to "Up to date"
|
// Auto-expire completed entries 60s after they resolved so the badge is visible
|
||||||
if (tracker?.status === 'completed' && Date.now() - tracker.startedAt > 60_000) {
|
if (tracker?.status === 'completed' && tracker.resolvedAt && Date.now() - tracker.resolvedAt > 60_000) {
|
||||||
updateTracker.delete(node.id);
|
updateTracker.delete(node.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1652,6 +1674,7 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
latestVersion: latestValid ? latestVersion : gatewayVersion,
|
latestVersion: latestValid ? latestVersion : gatewayVersion,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
updateStatus: null,
|
updateStatus: null,
|
||||||
|
error: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1669,6 +1692,7 @@ app.get('/api/fleet/update-status', authMiddleware, async (_req: Request, res: R
|
|||||||
// Trigger update on a specific node
|
// Trigger update on a specific node
|
||||||
app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requirePaid(req, res)) return;
|
if (!requirePaid(req, res)) return;
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const nodeId = parseInt(req.params.nodeId as string, 10);
|
const nodeId = parseInt(req.params.nodeId as string, 10);
|
||||||
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
|
if (isNaN(nodeId)) { res.status(400).json({ error: 'Invalid node ID' }); return; }
|
||||||
@@ -1682,7 +1706,7 @@ app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request,
|
|||||||
const existing = updateTracker.get(nodeId);
|
const existing = updateTracker.get(nodeId);
|
||||||
if (existing?.status === 'updating') {
|
if (existing?.status === 'updating') {
|
||||||
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
|
if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) {
|
||||||
updateTracker.set(nodeId, { ...existing, status: 'timeout', error: UPDATE_TIMEOUT_MSG });
|
updateTracker.set(nodeId, resolveTracker(existing, 'timeout', UPDATE_TIMEOUT_MSG));
|
||||||
} else {
|
} else {
|
||||||
res.status(409).json({ error: 'Update already in progress for this node.' });
|
res.status(409).json({ error: 'Update already in progress for this node.' });
|
||||||
return;
|
return;
|
||||||
@@ -1694,6 +1718,9 @@ app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request,
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Fleet] Update triggered for node', node.name, node.type);
|
console.log('[Fleet] Update triggered for node', node.name, node.type);
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.debug('[Fleet:debug] Update trigger details:', { nodeId, name: node.name, type: node.type, hasUrl: !!node.api_url, hasToken: !!node.api_token });
|
||||||
|
}
|
||||||
|
|
||||||
if (node.type === 'local') {
|
if (node.type === 'local') {
|
||||||
if (!SelfUpdateService.getInstance().isAvailable()) {
|
if (!SelfUpdateService.getInstance().isAvailable()) {
|
||||||
@@ -1713,6 +1740,9 @@ app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request,
|
|||||||
|
|
||||||
// Check remote availability and capabilities
|
// Check remote availability and capabilities
|
||||||
const meta = await fetchRemoteMeta(node.api_url, node.api_token);
|
const meta = await fetchRemoteMeta(node.api_url, node.api_token);
|
||||||
|
if (isDebugEnabled()) {
|
||||||
|
console.debug('[Fleet:debug] Remote meta for update:', { nodeId, online: meta.online, version: meta.version, capabilities: meta.capabilities, startedAt: meta.startedAt });
|
||||||
|
}
|
||||||
if (!meta.online) {
|
if (!meta.online) {
|
||||||
res.status(503).json({ error: 'Remote node is unreachable. Verify the node is running and the API URL is correct.' });
|
res.status(503).json({ error: 'Remote node is unreachable. Verify the node is running and the API URL is correct.' });
|
||||||
return;
|
return;
|
||||||
@@ -1756,13 +1786,16 @@ app.post('/api/fleet/nodes/:nodeId/update', authMiddleware, async (req: Request,
|
|||||||
// Trigger update on all outdated nodes
|
// Trigger update on all outdated nodes
|
||||||
app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requirePaid(req, res)) return;
|
if (!requirePaid(req, res)) return;
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
try {
|
try {
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const nodes = db.getNodes();
|
const nodes = db.getNodes();
|
||||||
const gatewayVersion = getSenchoVersion();
|
const gatewayVersion = getSenchoVersion();
|
||||||
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
const { compareVersion, compareValid } = await getCompareTarget(gatewayVersion);
|
||||||
|
|
||||||
|
const debug = isDebugEnabled();
|
||||||
console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered');
|
console.log('[Fleet] Update-all triggered,', nodes.length, 'nodes registered');
|
||||||
|
if (debug) console.debug('[Fleet:debug] Update-all compare target:', { gatewayVersion, compareVersion, compareValid });
|
||||||
|
|
||||||
// Filter to eligible candidates, then trigger all in parallel
|
// Filter to eligible candidates, then trigger all in parallel
|
||||||
const candidates = nodes.filter(node => {
|
const candidates = nodes.filter(node => {
|
||||||
@@ -1802,11 +1835,13 @@ app.post('/api/fleet/update-all', authMiddleware, async (req: Request, res: Resp
|
|||||||
|
|
||||||
const updating: string[] = [];
|
const updating: string[] = [];
|
||||||
const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name);
|
const skipped = nodes.filter(n => !candidates.includes(n)).map(n => n.name);
|
||||||
for (const r of results) {
|
for (let i = 0; i < results.length; i++) {
|
||||||
const val = r.status === 'fulfilled' ? r.value : { name: 'unknown', triggered: false };
|
const r = results[i];
|
||||||
|
const val = r.status === 'fulfilled' ? r.value : { name: candidates[i].name, triggered: false };
|
||||||
(val.triggered ? updating : skipped).push(val.name);
|
(val.triggered ? updating : skipped).push(val.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (debug) console.debug('[Fleet:debug] Update-all results:', { updating, skippedCount: skipped.length, candidateCount: candidates.length });
|
||||||
res.status(202).json({ updating, skipped });
|
res.status(202).json({ updating, skipped });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Fleet] Update all error:', error);
|
console.error('[Fleet] Update all error:', error);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { promisify } from 'util';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import DockerController from './DockerController';
|
import DockerController from './DockerController';
|
||||||
import { disableCapability } from './CapabilityRegistry';
|
import { disableCapability } from './CapabilityRegistry';
|
||||||
|
import { isDebugEnabled } from '../utils/debug';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -148,12 +149,16 @@ class SelfUpdateService {
|
|||||||
|
|
||||||
// Async pull: a sync execFileSync blocks the event loop, which lets the frontend
|
// Async pull: a sync execFileSync blocks the event loop, which lets the frontend
|
||||||
// overlay see a false "online" response between the pull finishing and the restart.
|
// overlay see a false "online" response between the pull finishing and the restart.
|
||||||
|
const debug = isDebugEnabled();
|
||||||
|
const pullStart = Date.now();
|
||||||
console.log(`[SelfUpdate] Pulling latest image: ${imageName}...`);
|
console.log(`[SelfUpdate] Pulling latest image: ${imageName}...`);
|
||||||
|
if (debug) console.debug('[SelfUpdate:debug] Pull context:', { workingDir, configFiles, serviceName, dataDirHost, mountCount: hostBindMounts.length });
|
||||||
try {
|
try {
|
||||||
await execFileAsync('docker', ['pull', imageName], {
|
await execFileAsync('docker', ['pull', imageName], {
|
||||||
env,
|
env,
|
||||||
timeout: 300_000, // 5 min max for pull
|
timeout: 300_000, // 5 min max for pull
|
||||||
});
|
});
|
||||||
|
if (debug) console.debug('[SelfUpdate:debug] Pull completed in', Math.round((Date.now() - pullStart) / 1000) + 's');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
|
const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim();
|
||||||
this.lastUpdateError = stderr || (error as Error).message;
|
this.lastUpdateError = stderr || (error as Error).message;
|
||||||
|
|||||||
@@ -147,10 +147,14 @@ The modal shows:
|
|||||||
- **Recheck** button to refresh the latest version from GitHub and re-scan for available updates
|
- **Recheck** button to refresh the latest version from GitHub and re-scan for available updates
|
||||||
- **Update All** button to trigger updates on all remote nodes that have a pending update
|
- **Update All** button to trigger updates on all remote nodes that have a pending update
|
||||||
|
|
||||||
When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change.
|
When you click **Update** on a remote node, Sencho sends the update command to the remote instance. The remote pulls the latest Docker image, then spawns a short-lived helper container that performs the compose recreate. The node restarts with the new version, and the status badge transitions from "Updating" to "Updated" once the gateway detects the version change. The "Updated" badge remains visible for 60 seconds before the node returns to "Up to date".
|
||||||
|
|
||||||
If you update the local node, a confirmation dialog appears first, then a reconnection overlay shows while your primary instance restarts.
|
If you update the local node, a confirmation dialog appears first, then a reconnection overlay shows while your primary instance restarts.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Triggering updates (both individual and bulk) requires admin privileges. Users with viewer or operator roles can see update status but cannot initiate updates.
|
||||||
|
</Note>
|
||||||
|
|
||||||
**How self-update works:** Each Sencho instance reads its own Docker Compose labels to determine the image name, compose file path, and service name. It pulls the latest image directly, then spawns a helper container that mounts the compose directory from the host and runs `docker compose up --force-recreate`. This approach works regardless of the container's own volume mounts.
|
**How self-update works:** Each Sencho instance reads its own Docker Compose labels to determine the image name, compose file path, and service name. It pulls the latest image directly, then spawns a helper container that mounts the compose directory from the host and runs `docker compose up --force-recreate`. This approach works regardless of the container's own volume mounts.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -205,3 +209,7 @@ This means the gateway could not connect to the remote node's `/api/meta` endpoi
|
|||||||
- The API URL configured for this node is correct and reachable from the gateway
|
- The API URL configured for this node is correct and reachable from the gateway
|
||||||
- The network allows traffic between the gateway and remote node on the configured port
|
- The network allows traffic between the gateway and remote node on the configured port
|
||||||
- The remote node's Sencho container is healthy (`docker ps` should show it as running)
|
- The remote node's Sencho container is healthy (`docker ps` should show it as running)
|
||||||
|
|
||||||
|
### Update button returns "Admin access required"
|
||||||
|
|
||||||
|
Fleet update operations (individual updates, Update All) require an admin account. If you see a 403 error when clicking **Update**, ask your Sencho administrator to either grant you admin privileges or perform the update on your behalf.
|
||||||
|
|||||||
@@ -1398,9 +1398,14 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
disabled={recheckingUpdates}
|
disabled={recheckingUpdates}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRecheckingUpdates(true);
|
setRecheckingUpdates(true);
|
||||||
await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
try {
|
||||||
await fetchUpdateStatus();
|
await apiFetch('/fleet/update-status?recheck=true', { method: 'DELETE', localOnly: true });
|
||||||
setRecheckingUpdates(false);
|
await fetchUpdateStatus();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Fleet] Recheck failed:', err);
|
||||||
|
} finally {
|
||||||
|
setRecheckingUpdates(false);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
<RefreshCw className={`w-3 h-3 mr-1.5 ${recheckingUpdates ? 'animate-spin' : ''}`} strokeWidth={1.5} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Returns true when the string is a displayable version (not a placeholder or missing). */
|
/** Returns true when the string is a displayable semver version (not a placeholder or missing). */
|
||||||
export function isValidVersion(v: string | null | undefined): v is string {
|
export function isValidVersion(v: string | null | undefined): v is string {
|
||||||
return !!v && v !== 'unknown' && v !== '0.0.0-dev';
|
return !!v && v !== 'unknown' && v !== '0.0.0-dev' && /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a version string for display, returning null for invalid/missing values. */
|
/** Format a version string for display, returning null for invalid/missing values. */
|
||||||
|
|||||||
Reference in New Issue
Block a user