diff --git a/backend/src/__tests__/fleet-update-hardening.test.ts b/backend/src/__tests__/fleet-update-hardening.test.ts index 13b11a7f..834d5454 100644 --- a/backend/src/__tests__/fleet-update-hardening.test.ts +++ b/backend/src/__tests__/fleet-update-hardening.test.ts @@ -73,6 +73,7 @@ function setTracker(over: Partial { FleetUpdateTrackerService.getInstance().set(proxyNodeId, { status: 'failed', startedAt: Date.now(), previousVersion: null, previousProcessStart: null, wasOffline: false, resolvedAt: Date.now(), error: 'boom', + operationKind: 'update', }); const second = await request(app) @@ -413,3 +415,170 @@ describe('GET /api/fleet/update-status/release-notes', () => { expect(res.body.htmlUrl).toBeNull(); }); }); + +describe('compose reapply status and concurrency', () => { + it('exposes canReapplyCompose for local when SelfUpdateService is available', async () => { + vi.spyOn(SelfUpdateService.getInstance(), 'isAvailable').mockReturnValue(true); + mockCompareTargetFetch(); + const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth); + expect(res.status).toBe(200); + const local = res.body.nodes.find((n: { type: string }) => n.type === 'local'); + expect(local.canReapplyCompose).toBe(true); + }); + + it('sets canReapplyCompose false for a remote without self-update capability', async () => { + mockMeta(ONLINE({ capabilities: ['stacks'] })); + mockCompareTargetFetch(); + const res = await request(app).get('/api/fleet/update-status').set('Authorization', adminAuth); + expect(res.status).toBe(200); + const remote = res.body.nodes.find((n: { nodeId: number }) => n.nodeId === proxyNodeId); + expect(remote.canReapplyCompose).toBe(false); + }); + + it('resolves a reapply tracker via startedAt change without requiring a version bump', async () => { + setTracker({ + operationKind: 'reapply_configuration', + previousVersion: '0.83.0', + previousProcessStart: 1, + startedAt: Date.now() - RECENT_MS, + }); + mockMeta(ONLINE({ version: '0.83.0', startedAt: 2 })); + mockCompareTargetFetch(); + expect(await getStatus()).toBe('completed'); + }); + + it('does not complete a reapply tracker via signal 4 when version is already current', async () => { + setTracker({ + operationKind: 'reapply_configuration', + previousVersion: '0.99.0', + previousProcessStart: 1, + startedAt: Date.now() - 20_000, + }); + // Node already at compare target; signal 4 would false-complete an update, + // but must not for reapply while startedAt is unchanged. + mockMeta(ONLINE({ version: '0.99.0', startedAt: 1 })); + mockCompareTargetFetch(); + expect(await getStatus()).toBe('updating'); + }); + + it('returns 409 when reapply is requested while an update tracker is in flight', async () => { + setTracker({ operationKind: 'update' }); + mockTarget(); + const res = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth); + expect(res.status).toBe(409); + expect(res.body?.error).toMatch(/already in progress/i); + }); + + it('dispatches remote reapply to /api/system/reapply-compose without updateBlocked gating', async () => { + mockTarget(); + mockMeta(ONLINE({ updateBlocked: true, imagePinKind: 'digest', imageChannel: 'community' })); + let reapplyUrl: string | null = null; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + try { + if (new URL(url).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { /* fall through */ } + reapplyUrl = url; + return new Response(JSON.stringify({ message: 'ok' }), { status: 202 }); + }); + + const res = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth); + + expect(res.status).toBe(202); + expect(reapplyUrl).toContain('/api/system/reapply-compose'); + expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.operationKind) + .toBe('reapply_configuration'); + }); + + it('reserves the tracker before remote dispatch so a concurrent reapply gets 409 without overwriting success', async () => { + mockTarget(); + // Hold meta so the first request sits in the dispatch set before the + // pollable tracker exists; the second must still 409 on that lock. + let releaseMeta!: (value: RemoteMeta) => void; + const metaHeld = new Promise((resolve) => { releaseMeta = resolve; }); + vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode').mockImplementation(async () => metaHeld); + + let releaseRemote!: (value: Response) => void; + const remoteHeld = new Promise((resolve) => { releaseRemote = resolve; }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + try { + if (new URL(url).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { /* fall through */ } + if (url.includes('/api/system/reapply-compose')) { + return remoteHeld; + } + return new Response('{}', { status: 200 }); + }); + + // Supertest is lazy until the thenable is consumed; start the request now. + const firstPromise = request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth) + .then((res) => res); + + await vi.waitFor(() => { + expect(NodeRegistry.getInstance().fetchMetaForNode).toHaveBeenCalled(); + }); + + const secondDuringMeta = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth); + expect(secondDuringMeta.status).toBe(409); + expect(secondDuringMeta.body?.error).toMatch(/already in progress/i); + + releaseMeta(ONLINE()); + + await vi.waitFor(() => { + expect(FleetUpdateTrackerService.getInstance().get(proxyNodeId)?.status).toBe('updating'); + }); + + const secondDuringPost = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth); + expect(secondDuringPost.status).toBe(409); + + releaseRemote(new Response(JSON.stringify({ message: 'ok' }), { status: 202 })); + const first = await firstPromise; + expect(first.status).toBe(202); + const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId); + expect(tracker?.status).toBe('updating'); + expect(tracker?.operationKind).toBe('reapply_configuration'); + expect(tracker?.previousVersion).toBe('0.83.0'); + expect(tracker?.previousProcessStart).toBe(1); + expect(tracker?.error).toBeUndefined(); + }); + + it('marks a reserved remote reapply as failed when the peer rejects, without leaving a false updating row for a second request', async () => { + mockTarget(); + mockMeta(ONLINE()); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + try { + if (new URL(url).hostname === 'api.github.com') { + return new Response(JSON.stringify({ tag_name: 'v0.99.0' }), { status: 200 }); + } + } catch { /* fall through */ } + return new Response(JSON.stringify({ + error: 'An image operation is already in progress.', + code: 'IMAGE_OPERATION_IN_FLIGHT', + }), { status: 409 }); + }); + + const res = await request(app) + .post(`/api/fleet/nodes/${proxyNodeId}/reapply-compose`) + .set('Authorization', adminAuth); + expect(res.status).toBe(502); + const tracker = FleetUpdateTrackerService.getInstance().get(proxyNodeId); + expect(tracker?.status).toBe('failed'); + expect(tracker?.error).toMatch(/already in progress/i); + }); +}); diff --git a/backend/src/__tests__/fleet-update-tracker-service.test.ts b/backend/src/__tests__/fleet-update-tracker-service.test.ts index c596f727..2c6a6645 100644 --- a/backend/src/__tests__/fleet-update-tracker-service.test.ts +++ b/backend/src/__tests__/fleet-update-tracker-service.test.ts @@ -14,6 +14,7 @@ function mk(over: Partial): UpdateTracker { previousVersion: null, previousProcessStart: null, wasOffline: false, + operationKind: 'update', ...over, }; } diff --git a/backend/src/__tests__/image-operation-service.test.ts b/backend/src/__tests__/image-operation-service.test.ts index 02847750..f91c2d82 100644 --- a/backend/src/__tests__/image-operation-service.test.ts +++ b/backend/src/__tests__/image-operation-service.test.ts @@ -270,4 +270,46 @@ describe('ImageOperationService', () => { expect(changed).not.toBe(baseline); }); + + it('executeClaimedComposeReapply transitions to recreating and watches helper exit before trigger', async () => { + const callOrder: string[] = []; + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + vi.spyOn(SelfUpdateService.getInstance(), 'onceHelperExit').mockImplementation(() => { + callOrder.push('watch'); + }); + vi.spyOn(SelfUpdateService.getInstance(), 'triggerComposeReapply').mockImplementation(async () => { + callOrder.push('trigger'); + }); + vi.spyOn(SelfUpdateService.getInstance(), 'getLastError').mockReturnValue(null); + + const service = ImageOperationService.getInstance(); + const claim = await service.claimComposeReapply(); + expect(claim).toEqual({ ok: true }); + const result = await service.executeClaimedComposeReapply(); + const current = await service.getCurrentOperation(); + + expect(result).toEqual({ ok: true }); + expect(current?.kind).toBe('compose_reapply'); + expect(current?.state).toBe('recreating'); + expect(callOrder).toEqual(['watch', 'trigger']); + }); + + it('reconcileOnStartup resolves compose_reapply via marker-only success without pin match', async () => { + const service = ImageOperationService.getInstance(); + vi.spyOn(SelfUpdateService.getInstance(), 'getResolvedComposeImageForUpdate').mockResolvedValue(null); + vi.spyOn(SelfUpdateService.getInstance(), 'getComposeServiceName').mockReturnValue('sencho'); + const claim = await service.claimComposeReapply(); + expect(claim).toEqual({ ok: true }); + const current = await service.getCurrentOperation(); + expect(current?.kind).toBe('compose_reapply'); + expect(current?.targetImageRef).toBeNull(); + + const markerPath = path.join(tmpDir, `image-op-success-${current!.operationId}.json`); + await fs.writeFile(markerPath, JSON.stringify({ ok: true, operationId: current!.operationId }), 'utf8'); + + await service.reconcileOnStartup(); + const resolved = await service.getCurrentOperation(); + expect(resolved?.state).toBe('succeeded'); + }); }); diff --git a/backend/src/__tests__/self-update-compose.test.ts b/backend/src/__tests__/self-update-compose.test.ts index e4096750..2f9409e9 100644 --- a/backend/src/__tests__/self-update-compose.test.ts +++ b/backend/src/__tests__/self-update-compose.test.ts @@ -21,6 +21,7 @@ import { } from '../helpers/selfUpdateCompose'; import { buildComposeReadArgs, + buildComposeConfigValidateArgs, buildSelfUpdateComposeCmd, buildSelfUpdateRunArgs, shQuote, @@ -302,3 +303,22 @@ describe('buildSelfUpdateRunArgs (repinWritable branch)', () => { expect(args).not.toContain('/opt/sencho:/opt/sencho:rw'); }); }); + +describe('buildComposeConfigValidateArgs', () => { + it('runs compose config in a throwaway helper with the working dir mounted read-only', () => { + const args = buildComposeConfigValidateArgs({ + workingDir: '/opt/sencho', + imageName: 'saelix/sencho:1.0.0', + configFiles: 'docker-compose.yml,/opt/sencho/override.yml', + hostBindMounts: [{ source: '/etc/sencho', destination: '/etc/sencho' }], + }); + expect(args).toContain('/opt/sencho:/opt/sencho:ro'); + expect(args).toContain('/var/run/docker.sock:/var/run/docker.sock'); + expect(args).toContain('/etc/sencho:/etc/sencho:ro'); + const cmd = args[args.length - 1]; + expect(cmd).toContain('docker compose'); + expect(cmd).toContain('config'); + expect(cmd).toContain(shQuote('docker-compose.yml')); + expect(cmd).toContain(shQuote('/opt/sencho/override.yml')); + }); +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index a97129e4..dc9c6091 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -62,6 +62,8 @@ import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license- import { LicenseService } from '../services/LicenseService'; const updateTracker = FleetUpdateTrackerService.getInstance(); +/** Sync lock for remote reapply while meta is fetched (before the pollable tracker exists). */ +const remoteReapplyDispatching = new Set(); const EARLY_FAIL_MS = 180 * 1000; // 3 minutes before declaring a probable pull failure // Shown in the Node Updates UI when a node's image is pinned in a way Fleet // cannot repin (digest or an unresolved value). Node-neutral so it reads the @@ -1114,6 +1116,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp let remoteImagePinKind: ImagePinKind | null = null; let remoteUpdateBlocked = false; let remoteImageChannel: 'community' | 'hardened' | 'unknown' | null = null; + let remoteCapabilities: string[] = []; if (node.type === 'local') { version = gatewayVersion; } else { @@ -1125,8 +1128,18 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp remoteImagePinKind = meta.imagePinKind; remoteUpdateBlocked = meta.updateBlocked; remoteImageChannel = meta.imageChannel; + remoteCapabilities = meta.capabilities ?? []; } + const isReapply = tracker?.operationKind === 'reapply_configuration'; + const earlyFailMsg = isReapply + ? (node.type === 'local' + ? 'Local reapply did not complete. The container may not have restarted; check Docker logs on the host.' + : 'Reapply may have failed. The node is still running and its process start time has not changed.') + : (node.type === 'local' + ? 'Local update did not complete. The container may not have restarted; check Docker logs on the host.' + : 'Update may have failed. The node is still running and its version has not changed.'); + if (tracker?.status === 'updating') { const elapsed = Date.now() - tracker.startedAt; @@ -1136,7 +1149,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp if (elapsed > UPDATE_TIMEOUT_MS) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'timed out after', Math.round(elapsed / 1000) + 's'); - resolveTerminal(node, tracker, 'timeout', UPDATE_TIMEOUT_MSG); + resolveTerminal(node, tracker, 'timeout', isReapply + ? 'Node did not come back online within 5 minutes after reapply.' + : UPDATE_TIMEOUT_MSG); } else if (node.type === 'remote') { if (remoteUpdateError) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'reported pull failure:', remoteUpdateError); @@ -1146,10 +1161,9 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp if (debug) console.debug('[Fleet:debug] Node', node.id, 'went offline (restarting)'); updateTracker.set(node.id, { ...tracker, wasOffline: true }); } - } else if (isValidVersion(version) && version !== tracker.previousVersion) { - // Signal 1: a valid, different version. A null/unparseable version - // from a transient /api/meta blip is NOT a version change, so it - // must not complete a still-running, same-process node here. + } else if (!isReapply && isValidVersion(version) && version !== tracker.previousVersion) { + // Signal 1: a valid, different version. Skipped for reapply because + // the authored image/version is not expected to change. if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 1 (version changed):', tracker.previousVersion, '->', version); updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed')); } else if ( @@ -1173,20 +1187,20 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 3 (offline then online, startedAt unavailable)'); updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed')); } else if ( + !isReapply && elapsed > 15_000 && isValidVersion(version) && gatewayValid && !semver.lt(version, compareVersion!) ) { // 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 and startedAt - // hasn't been observed to change yet. + // minimum processing time). Never used for reapply: an already + // current node would false-complete before the helper runs. if (debug) console.debug('[Fleet:debug] Node', node.id, 'completed via signal 4 (version >= compare target):', version, '>=', compareVersion); updateTracker.set(node.id, updateTracker.resolve(tracker, 'completed')); } else if (elapsed > EARLY_FAIL_MS) { if (debug) console.debug('[Fleet:debug] Node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's - no signals detected'); - resolveTerminal(node, tracker, 'failed', 'Update may have failed. The node is still running and its version has not changed.'); + resolveTerminal(node, tracker, 'failed', earlyFailMsg); } } else if (node.type === 'local') { // Local node has only two failure signals: an explicit pull/spawn @@ -1202,7 +1216,7 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp selfUpdate.clearLastError(); } else if (elapsed > EARLY_FAIL_MS) { if (debug) console.debug('[Fleet:debug] Local node', node.id, 'early fail after', Math.round(elapsed / 1000) + 's'); - resolveTerminal(node, tracker, 'failed', 'Local update did not complete. The container may not have restarted; check Docker logs on the host.'); + resolveTerminal(node, tracker, 'failed', earlyFailMsg); } } } @@ -1286,6 +1300,10 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp updateBlocked, updateBlockedReason, imageChannel, + operationKind: currentTracker?.operationKind ?? null, + canReapplyCompose: node.type === 'local' + ? SelfUpdateService.getInstance().isAvailable() + : remoteOnline && remoteCapabilities.includes('self-update'), }; }), ); @@ -1305,6 +1323,8 @@ fleetRouter.get('/update-status', authMiddleware, async (req: Request, res: Resp skipActive: false, skippedVersion: null, ...EMPTY_PIN_STATUS, + operationKind: null, + canReapplyCompose: false, }; }); @@ -1345,17 +1365,46 @@ fleetRouter.get('/update-status/release-notes', authMiddleware, async (req: Requ // repin a semver-pinned compose to that release. It is omitted otherwise (never // sent as null/invalid), and an older remote that predates this field simply // ignores the extra body key and behaves as before. -function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) { +function postSystemEndpoint( + target: { apiUrl: string; apiToken: string }, + endpoint: '/api/system/update' | '/api/system/reapply-compose', + body: Record = {}, +) { const headers: Record = { 'Content-Type': 'application/json' }; if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`; - return fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/update`, { + return fetch(`${target.apiUrl.replace(/\/$/, '')}${endpoint}`, { method: 'POST', headers, - body: JSON.stringify(targetVersion ? { targetVersion } : {}), + body: JSON.stringify(body), signal: AbortSignal.timeout(10000), }); } +function postSystemUpdate(target: { apiUrl: string; apiToken: string }, targetVersion?: string) { + return postSystemEndpoint(target, '/api/system/update', targetVersion ? { targetVersion } : {}); +} + +function postSystemReapplyCompose(target: { apiUrl: string; apiToken: string }) { + return postSystemEndpoint(target, '/api/system/reapply-compose'); +} + +/** Clear a terminal tracker row, or time out a stale in-flight one. Returns a + * conflict message when another update/reapply is still actively running. */ +function beginTrackerOperation(nodeId: number, conflictError: string): string | null { + const existing = updateTracker.get(nodeId); + if (existing?.status === 'updating') { + if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) { + updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG)); + } else { + return conflictError; + } + } + if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) { + updateTracker.delete(nodeId); + } + return null; +} + function parseRemoteUpdateFailure(payload: unknown): { error: string; code?: string } { if (!payload || typeof payload !== 'object') { return { error: 'Remote node rejected update request.' }; @@ -1435,18 +1484,10 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r const requestedTarget = parseRequestedTargetVersion(req, res); if (requestedTarget === null) return; // invalid supplied value; 400 already sent - const existing = updateTracker.get(nodeId); - if (existing?.status === 'updating') { - if (Date.now() - existing.startedAt > UPDATE_TIMEOUT_MS) { - updateTracker.set(nodeId, updateTracker.resolve(existing, 'timeout', UPDATE_TIMEOUT_MSG)); - } else { - res.status(409).json({ error: 'Update already in progress for this node.' }); - return; - } - } - // Clear terminal states to allow retry. - if (existing && (existing.status === 'timeout' || existing.status === 'failed' || existing.status === 'completed')) { - updateTracker.delete(nodeId); + const conflict = beginTrackerOperation(nodeId, 'Update already in progress for this node.'); + if (conflict) { + res.status(409).json({ error: conflict }); + return; } console.log('[Fleet] Update triggered for node', node.name, node.type); @@ -1548,6 +1589,140 @@ fleetRouter.post('/nodes/:nodeId/update', authMiddleware, async (req: Request, r } }); +fleetRouter.post('/nodes/:nodeId/reapply-compose', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + try { + const nodeId = parseIntParam(req, res, 'nodeId', 'node ID'); + if (nodeId === null) return; + const db = DatabaseService.getInstance(); + const node = db.getNode(nodeId); + if (!node) { + res.status(404).json({ error: 'Node not found' }); + return; + } + + const conflict = beginTrackerOperation(nodeId, 'An update or reapply is already in progress for this node.'); + if (conflict) { + res.status(409).json({ error: conflict }); + return; + } + + console.log('[Fleet] Compose reapply triggered for node', node.name, node.type); + + if (node.type === 'local') { + const selfUpdate = SelfUpdateService.getInstance(); + if (!selfUpdate.isAvailable()) { + res.status(503).json({ error: 'Compose reapply unavailable on the local node.' }); + return; + } + const claim = await ImageOperationService.getInstance().claimComposeReapply(); + if (!claim.ok) { + res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode }); + return; + } + updateTracker.set( + nodeId, + updateTracker.create('updating', getSenchoVersion(), null, undefined, undefined, 'reapply_configuration'), + ); + res.status(202).json({ message: 'Compose reapply initiated on local node. The server will restart shortly.' }); + setTimeout(() => { + ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => { + console.error('[ImageOperation] Unexpected compose reapply failure:', error); + }); + }, 500); + return; + } + + // Sync lock before any await so a concurrent reapply gets 409 without + // racing the remote POST. The pollable tracker is created only after meta + // is known (full process identity), immediately before dispatch. + if (remoteReapplyDispatching.has(nodeId)) { + res.status(409).json({ error: 'An update or reapply is already in progress for this node.' }); + return; + } + remoteReapplyDispatching.add(nodeId); + + const failOwnedTracker = ( + error: string, + code?: string, + previousVersion: string | null = null, + previousProcessStart: number | null = null, + ) => { + const current = updateTracker.get(nodeId); + if (current?.status !== 'updating' || current.operationKind !== 'reapply_configuration') return; + updateTracker.set( + nodeId, + updateTracker.create('failed', previousVersion, previousProcessStart, error, code, 'reapply_configuration'), + ); + }; + + try { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) { + const error = formatNoTargetError(node); + res.status(503).json({ error }); + return; + } + + const meta = await NodeRegistry.getInstance().fetchMetaForNode(node.id); + if (!meta.online) { + const error = 'Remote node is unreachable. Verify the node is running and the API URL is correct.'; + res.status(503).json({ error }); + return; + } + if (!meta.capabilities.includes('self-update')) { + const error = 'Remote node does not support compose reapply. It may need to be updated manually first.'; + res.status(503).json({ error }); + return; + } + // Digest pins and updateBlocked are intentional non-gates: reapply never + // repins the image, so blocked update rows remain eligible. + + // Reserve before the remote POST so a concurrent reapply still sees + // 'updating' after this request leaves the dispatch set in finally. + updateTracker.set( + nodeId, + updateTracker.create( + 'updating', + meta.version, + meta.startedAt, + undefined, + undefined, + 'reapply_configuration', + ), + ); + + const response = await postSystemReapplyCompose(target); + + if (!response.ok) { + const failure = parseRemoteUpdateFailure(await response.json().catch(() => null)); + failOwnedTracker(failure.error, failure.code, meta.version, meta.startedAt); + res.status(502).json(failure); + return; + } + + res.status(202).json({ message: `Compose reapply initiated on ${node.name}.` }); + } finally { + remoteReapplyDispatching.delete(nodeId); + } + } catch (error) { + console.error('[Fleet] Node compose reapply error:', error); + const errorMsg = getErrorMessage(error, 'Failed to trigger compose reapply.'); + const failedNodeId = parseInt(req.params.nodeId as string, 10); + if (!isNaN(failedNodeId)) { + remoteReapplyDispatching.delete(failedNodeId); + const current = updateTracker.get(failedNodeId); + if (current?.status === 'updating' && current.operationKind === 'reapply_configuration') { + updateTracker.set( + failedNodeId, + updateTracker.create('failed', null, null, errorMsg, undefined, 'reapply_configuration'), + ); + } + } + res.status(500).json({ error: 'Failed to trigger compose reapply.' }); + } +}); + fleetRouter.post('/update-all', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; try { diff --git a/backend/src/routes/license.ts b/backend/src/routes/license.ts index fd3e473b..e640c6c9 100644 --- a/backend/src/routes/license.ts +++ b/backend/src/routes/license.ts @@ -175,3 +175,23 @@ systemUpdateRouter.post('/update', async (req: Request, res: Response): Promise< }); }, 500); }); + +systemUpdateRouter.post('/reapply-compose', async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + const selfUpdate = SelfUpdateService.getInstance(); + if (!selfUpdate.isAvailable()) { + res.status(503).json({ error: 'Compose reapply unavailable. Sencho must be deployed via Docker Compose.' }); + return; + } + const claim = await ImageOperationService.getInstance().claimComposeReapply(); + if (!claim.ok) { + res.status(409).json({ error: 'An image operation is already in progress.', code: claim.failureCode }); + return; + } + res.status(202).json({ message: 'Compose reapply initiated. The server will restart shortly.' }); + setTimeout(() => { + ImageOperationService.getInstance().executeClaimedComposeReapply().catch(error => { + console.error('[ImageOperation] Unexpected compose reapply failure:', error); + }); + }, 500); +}); diff --git a/backend/src/services/FleetUpdateTrackerService.ts b/backend/src/services/FleetUpdateTrackerService.ts index 8848cb12..3b4bf7e2 100644 --- a/backend/src/services/FleetUpdateTrackerService.ts +++ b/backend/src/services/FleetUpdateTrackerService.ts @@ -1,3 +1,5 @@ +export type FleetOperationKind = 'update' | 'reapply_configuration'; + export interface UpdateTracker { status: 'updating' | 'completed' | 'timeout' | 'failed'; startedAt: number; @@ -11,6 +13,8 @@ export interface UpdateTracker { wasOffline: boolean; /** Timestamp when the tracker transitioned to a terminal state (completed/failed/timeout). */ resolvedAt?: number; + /** Distinguishes version updates from compose reapply so poll heuristics stay correct. */ + operationKind: FleetOperationKind; } export type TerminalStatus = 'completed' | 'failed' | 'timeout'; @@ -61,13 +65,15 @@ export class FleetUpdateTrackerService { return this.trackers.size; } - /** Create a new tracker with `startedAt=now` and resolvedAt set if terminal. */ + /** Create a new tracker with `startedAt=now` and resolvedAt set if terminal. + * `operationKind` defaults to `'update'` so existing call sites stay unchanged. */ public create( status: UpdateTracker['status'], previousVersion: string | null, previousProcessStart: number | null, error?: string, code?: string, + operationKind: FleetOperationKind = 'update', ): UpdateTracker { const now = Date.now(); return { @@ -78,6 +84,7 @@ export class FleetUpdateTrackerService { wasOffline: false, error, code, + operationKind, resolvedAt: status !== 'updating' ? now : undefined, }; } diff --git a/backend/src/services/ImageOperationService.ts b/backend/src/services/ImageOperationService.ts index e0702f76..e2e442c8 100644 --- a/backend/src/services/ImageOperationService.ts +++ b/backend/src/services/ImageOperationService.ts @@ -8,7 +8,7 @@ import type { ImagePinKind } from '../helpers/selfUpdateCompose'; import type { LocalRegistryAccess } from './hardenedEntitlementTypes'; import { getAuthToken, httpRequest } from './registry-api'; -export type ImageOperationKind = 'switch' | 'update' | 'community_update'; +export type ImageOperationKind = 'switch' | 'update' | 'community_update' | 'compose_reapply'; export type ImageOperationState = 'pending_pull' | 'pulling' | 'patching' | 'recreating' | 'succeeded' | 'failed'; type FailureCode = 'self_update_unavailable' | 'entitlement_denied' | 'preflight_mismatch' | 'compose_unavailable' | 'registry_access_unavailable' | 'update_failed' | 'interrupted_by_restart'; @@ -146,32 +146,12 @@ export class ImageOperationService { public async claimCommunityUpdate(options?: { targetVersion?: string }): Promise< { ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' } > { - const selfUpdate = SelfUpdateService.getInstance(); - const resolved = await selfUpdate.getResolvedComposeImageForUpdate(); - const operation = this.newOperation( - 'community_update', - resolved?.imageRef ?? null, - options?.targetVersion ?? null, - resolved?.filePath ?? null, - selfUpdate.getComposeServiceName(), - ); - if (!await this.tryClaim(operation)) { - return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }; - } - // Disk non-terminal state is the concurrency lock; clear the in-memory mutex - // so a later claim can observe the persisted pending operation. - this.claimed = false; - return { ok: true }; + return this.claimComposeOperation('community_update', options?.targetVersion ?? null); } public async executeClaimedCommunityUpdate(options?: { targetVersion?: string }): Promise<{ ok: boolean; failureCode?: string }> { - const operation = await this.getCurrentOperation(); - if (!operation || operation.kind !== 'community_update') { - return { ok: false, failureCode: 'update_failed' }; - } - if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) { - return { ok: false, failureCode: 'update_failed' }; - } + const operation = await this.getActiveClaimedOperation('community_update'); + if (!operation) return { ok: false, failureCode: 'update_failed' }; const selfUpdate = SelfUpdateService.getInstance(); try { operation.state = 'pulling'; @@ -207,6 +187,37 @@ export class ImageOperationService { return this.executeClaimedCommunityUpdate(options); } + public async claimComposeReapply(): Promise< + { ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' } + > { + return this.claimComposeOperation('compose_reapply', null); + } + + public async executeClaimedComposeReapply(): Promise<{ ok: boolean; failureCode?: string }> { + const operation = await this.getActiveClaimedOperation('compose_reapply'); + if (!operation) return { ok: false, failureCode: 'update_failed' }; + const selfUpdate = SelfUpdateService.getInstance(); + try { + // No pull/patch for reapply: jump straight to recreating. + operation.state = 'recreating'; + await this.persist(operation); + this.watchHelperExit(operation); + await selfUpdate.triggerComposeReapply({ + successMarkerFile: this.successMarkerFile(operation), + successMarkerContent: JSON.stringify({ ok: true, operationId: operation.operationId }), + }); + if (selfUpdate.getLastError()) { + await this.fail(operation, 'update_failed'); + return { ok: false, failureCode: 'update_failed' }; + } + return { ok: true }; + } catch (error) { + console.error('[ImageOperation] Compose reapply failed:', error); + await this.fail(operation, 'update_failed'); + return { ok: false, failureCode: 'update_failed' }; + } + } + public async getOperation(operationId: string): Promise { const filePath = this.operationFile(operationId); if (!filePath) return null; @@ -243,8 +254,9 @@ export class ImageOperationService { const markerPath = this.successMarkerFile(operation); for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) { const markerOk = await this.isSuccessMarkerForOperation(markerPath, operation.operationId); - if (operation.kind === 'community_update') { - // Community success is the marker alone; floating tags may not equal targetImageRef. + if (operation.kind === 'community_update' || operation.kind === 'compose_reapply') { + // Marker-only success: community updates may leave floating tags that do + // not equal targetImageRef, and reapply never sets a target image at all. if (markerOk) { operation.state = 'succeeded'; operation.resolvedAt = new Date().toISOString(); @@ -284,6 +296,35 @@ export class ImageOperationService { }); } + private async claimComposeOperation( + kind: 'community_update' | 'compose_reapply', + targetImageRef: string | null, + ): Promise<{ ok: true } | { ok: false; failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }> { + const selfUpdate = SelfUpdateService.getInstance(); + const resolved = await selfUpdate.getResolvedComposeImageForUpdate(); + const operation = this.newOperation( + kind, + resolved?.imageRef ?? null, + targetImageRef, + resolved?.filePath ?? null, + selfUpdate.getComposeServiceName(), + ); + if (!await this.tryClaim(operation)) { + return { ok: false, failureCode: 'IMAGE_OPERATION_IN_FLIGHT' }; + } + // Disk non-terminal state is the concurrency lock; clear the in-memory mutex + // so a later claim can observe the persisted pending operation. + this.claimed = false; + return { ok: true }; + } + + private async getActiveClaimedOperation(kind: ImageOperationKind): Promise { + const operation = await this.getCurrentOperation(); + if (!operation || operation.kind !== kind) return null; + if (!['pending_pull', 'pulling', 'patching', 'recreating'].includes(operation.state)) return null; + return operation; + } + private newOperation(kind: ImageOperationKind, previousImageRef: string | null, targetImageRef: string | null, composeFilePath: string | null, serviceName: string | null, preflightFingerprint?: string): ImageOperation { return { schemaVersion: 1, diff --git a/backend/src/services/SelfUpdateService.ts b/backend/src/services/SelfUpdateService.ts index b0568277..804f12a7 100644 --- a/backend/src/services/SelfUpdateService.ts +++ b/backend/src/services/SelfUpdateService.ts @@ -205,6 +205,27 @@ export function buildSelfUpdateRunArgs( ]; } +/** + * Build the argv for a throwaway helper that runs `docker compose … config` + * against the host compose project. Reuses the recreate helper's mount layout + * (socket + working dir + host binds) without mounting /app/data, since + * validation is read-only. Pure and exported for unit testing. + */ +export function buildComposeConfigValidateArgs( + ctx: Pick & { configFiles: string }, +): string[] { + const { workingDir, imageName, hostBindMounts, configFiles } = ctx; + const fFlags = configFiles.split(',').flatMap(f => { + const trimmed = f.trim(); + return trimmed ? ['-f', trimmed] : []; + }); + const composeCmd = ['docker compose', ...fFlags.map(shQuote), 'config'].join(' '); + return buildSelfUpdateRunArgs( + { workingDir, imageName, dataDirHost: null, hostBindMounts }, + composeCmd, + ); +} + class SelfUpdateService { private static instance: SelfUpdateService; private canSelfUpdate = false; @@ -527,6 +548,46 @@ class SelfUpdateService { this.spawnHelper(env, composeCopy, options?.successMarkerFile, options?.successMarkerContent); } + /** + * Recreate the Sencho service from the exact current on-disk Compose project + * without pulling or rewriting the image reference. Used by Fleet "Reapply + * configuration". Validates the authored compose via a throwaway helper + * before the last-breath recreate so invalid config fails before shutdown. + */ + async triggerComposeReapply(options?: { + successMarkerFile?: string; + successMarkerContent?: string; + }): Promise { + if (!this.composeContext) return; + const env = this.buildEnv(); + this.lastUpdateError = null; + this.pendingHelperExitError = undefined; + + try { fs.unlinkSync(UPDATE_ERROR_FILE); } catch { /* absent is the steady state */ } + try { fs.unlinkSync(STAGED_PATCH_FILE); } catch { /* absent is the steady state */ } + + const { workingDir, configFiles, imageName, hostBindMounts } = this.composeContext; + console.log('[SelfUpdate] Validating compose configuration before reapply...'); + try { + await execFileAsync( + 'docker', + buildComposeConfigValidateArgs({ workingDir, imageName, hostBindMounts, configFiles }), + { env, timeout: 60_000, maxBuffer: 10 * 1024 * 1024 }, + ); + } catch (error) { + const stderr = (error as { stderr?: Buffer | string })?.stderr?.toString().trim(); + const stdout = (error as { stdout?: Buffer | string })?.stdout?.toString().trim(); + this.lastUpdateError = + stderr || stdout || (error as Error).message || 'Compose configuration validation failed.'; + console.error('[SelfUpdate] Compose reapply validation failed:', this.lastUpdateError); + return; + } + + // No pull and no compose rewrite: the authored image ref is authoritative. + // Skip dangling-image prune (nothing was pulled). + this.spawnHelper(env, undefined, options?.successMarkerFile, options?.successMarkerContent, false); + } + /** * Spawn the "last breath" helper container that recreates Sencho (and, when a * repin is staged, copies the rewritten compose file onto the host first). @@ -538,6 +599,7 @@ class SelfUpdateService { composeCopy?: ComposeCopy, successMarkerFile?: string, successMarkerContent?: string, + pruneOnUpdateOverride?: boolean, ): void { if (!this.composeContext) return; const { workingDir, configFiles, serviceName, imageName, dataDirHost, hostBindMounts } = this.composeContext; @@ -551,8 +613,9 @@ class SelfUpdateService { // Opt-out (default ON): after a clean recreate, prune the dangling image // layers the pull orphaned. Read fresh so this node honors its own setting. const stderrTmp = '/tmp/_sencho_err'; - const pruneOnUpdate = - DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'; + const pruneOnUpdate = pruneOnUpdateOverride ?? ( + DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1' + ); const composeCmd = buildSelfUpdateComposeCmd( fFlags, serviceName, diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 8da12ea7..4ecdb21b 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -42,6 +42,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /system/networks/delete': 'Deleted networks', 'POST /system/networks': 'Created network', 'POST /system/console-token': 'Generated console token', + 'POST /system/reapply-compose': 'Triggered compose reapply', // Node management 'POST /nodes': 'Added node', @@ -91,6 +92,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'DELETE /fleet/snapshots': 'Deleted fleet backup', 'POST /fleet/snapshots/*/restore': 'Restored fleet backup', 'POST /fleet/nodes/*/update': 'Triggered fleet node update', + 'POST /fleet/nodes/*/reapply-compose': 'Triggered fleet node compose reapply', 'POST /fleet/update-all': 'Triggered fleet-wide update', 'POST /fleet/role/reanchor': 'Re-anchored fleet replica', 'POST /fleet/role/demote': 'Demoted fleet replica to control', diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 0f1260a7..a0a11be4 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -28,7 +28,7 @@ The top card on the left holds the stack's identity and primary controls. The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Take down** when running, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack. - If a stack is the Sencho instance you are currently signed into, its deploy/delete actions are protected: clicking any of them opens a **Sencho instance protected** dialog instead of running the action. Update Sencho from **Fleet → Node Update**, or move Sencho's own compose project outside `COMPOSE_DIR` to manage it as a normal stack. + If a stack is the Sencho instance you are currently signed into, stop, take down, delete, update, and rollback stay protected: clicking them opens a **Sencho instance protected** dialog. Eligible admins can apply on-disk Compose changes with **Save & Reapply** in this editor (same recreate procedure as Fleet Node Updates), or open **Fleet → Node Updates**. To manage the stack as a normal stack, move Sencho's compose project outside `COMPOSE_DIR`. | Button | Behavior | @@ -144,19 +144,20 @@ The `.env` editor renders a teal banner above the textarea reminding you that va ### Save options -As soon as the compose editor opens, the toolbar shows a split button. The primary action is **Save & Deploy**; the dropdown chevron reveals two more. +As soon as the compose editor opens, the toolbar shows a split button. The primary action is **Save & Deploy** for ordinary stacks. On Sencho's own Compose-managed stack, eligible admins see **Save & Reapply** instead. The dropdown chevron reveals two more actions. | Action | Effect | |--------|--------| | **Save & Deploy** | Writes the file to disk, then runs `docker compose up -d` to apply changes. | -| **Save Only** | Writes the file to disk without restarting any containers. Changes take effect on the next deploy. | +| **Save & Reapply** | Shown only for admins on the Compose-managed Sencho self-stack when reapply is available. Writes the file, then confirms and recreates Sencho from the current on-disk Compose project without selecting a newer image. Same procedure as Fleet **Reapply configuration**. | +| **Save Only** | Writes the file to disk without restarting any containers. Changes take effect on the next deploy or reapply. | | **Discard Changes** | Reverts the active file (compose or env) to the last saved version. Unsaved edits in that file are lost. | The same controls apply to the `compose.yaml` and `.env` editors. ## Diff preview before save -When **Diff preview before save** is enabled in **Settings → Infrastructure → Stacks**, clicking **Save & Deploy** or **Save Only** opens a side-by-side diff modal before anything is written to disk. The left pane is the on-disk content; the right pane is your unsaved edits with additions highlighted in green. The footer reads `ON DISK → UNSAVED` so the panes are unambiguous. +When **Diff preview before save** is enabled in **Settings → Infrastructure → Stacks**, clicking **Save & Deploy**, **Save & Reapply**, or **Save Only** opens a side-by-side diff modal before anything is written to disk. The left pane is the on-disk content; the right pane is your unsaved edits with additions highlighted in green. The footer reads `ON DISK → UNSAVED` so the panes are unambiguous. Diff preview modal showing side-by-side YAML diff with the unsaved version on the right and an ON DISK to UNSAVED legend in the footer @@ -164,7 +165,7 @@ When **Diff preview before save** is enabled in **Settings → Infrastructure Review the diff, then: -- Click the primary button (**Save & deploy** when triggered from **Save & Deploy**, or **Save** when triggered from **Save Only**) to confirm and write the changes. +- Click the primary button (**Save & deploy** or **Save & reapply** when triggered from that primary action, or **Save** when triggered from **Save Only**) to confirm and write the changes. - Click **Cancel** to return to the editor without saving. If there are no unsaved changes the modal is skipped and the save proceeds directly. The toggle is off by default and stored per browser, so each device remembers its own setting. @@ -177,7 +178,7 @@ On a narrow screen the stack opens as a full-screen detail with **Health**, **Lo Mobile compose editor with a Cancel button, the compose.yaml label, a monospace text field showing the compose file, a small-edits note, and Save and Save and Deploy buttons -The mobile editor is a lightweight monospace text field rather than Monaco. Tap **compose** or **.env** at the top to choose the file. The **.env** toggle appears only when the stack has an env file, and the file picker is locked while you have unsaved edits so switching files cannot drop them. The footer carries the same **Save** and **Save & Deploy** actions, and every protection is shared with desktop: the diff preview, save-conflict handling, and the unsaved-changes prompt all behave the same way. **Cancel** leaves the editor and asks before discarding unsaved edits. +The mobile editor is a lightweight monospace text field rather than Monaco. Tap **compose** or **.env** at the top to choose the file. The **.env** toggle appears only when the stack has an env file, and the file picker is locked while you have unsaved edits so switching files cannot drop them. The footer carries the same **Save** and **Save & Deploy** (or **Save & Reapply** when eligible) actions, and every protection is shared with desktop: the diff preview, save-conflict handling, and the unsaved-changes prompt all behave the same way. **Cancel** leaves the editor and asks before discarding unsaved edits. A note at the bottom of the editor is a reminder that mobile editing is meant for small corrections such as bumping an image tag or fixing a value. For large compose rewrites, open the stack on a desktop. Editing requires the `stack:edit` permission. @@ -252,7 +253,7 @@ Sencho tries `/bin/bash` first and transparently falls back to `/bin/sh` if bash The editor blocks a silent loss of in-progress edits. Click **Cancel** to return to the original node with your edits intact; click **Discard** to abandon them and proceed to the other node. - That stack is running the Sencho instance you are signed into, and its deploy/delete actions are blocked to prevent locking yourself out. Update Sencho from **Fleet → Node Update** instead. To manage the stack normally, move its compose project to a directory outside `COMPOSE_DIR`. + That stack is running the Sencho instance you are signed into. Stop, take down, delete, update, and rollback stay blocked. Eligible admins can apply Compose edits with **Save & Reapply** in the editor, or use **Fleet → Node Updates**. To manage the stack normally, move its compose project to a directory outside `COMPOSE_DIR`. The stats WebSocket failed to open, or closed unexpectedly. This usually means the Docker daemon on the node is unreachable. Container status, action buttons, and logs continue to work; only live CPU / memory / network rates pause. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index d93dabb3..61856b69 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -280,9 +280,9 @@ The table lists every registered node, filtered by the search box at the top. Co | **Type** | `local` or `remote` outline pill | | **Current** | The node's reported Sencho version, in mono. Reads `unknown` if the node has not reported (offline, unreachable, or never connected). | | **Latest** | The newest published Sencho release. Highlighted when newer than Current. | -| **Status** | Either an `Up to date` success badge, an `Update` button (per-row), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. | +| **Status** | Either an `Up to date` success badge, an `Update` button when a newer release is available, an icon-only **Reapply configuration** control (tooltip) for Compose-managed nodes (including up-to-date rows), an in-progress / failed badge with retry and dismiss controls, or a `Skipped` badge when the version has been deferred. | -The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. +The latest-version label is resolved from the GitHub Releases API (with a Docker Hub fallback) and cached for 30 minutes. **Recheck** flushes the cache and re-resolves immediately. See [Remote Updates · Reapply configuration](/features/remote-updates#reapply-configuration) for what reapply does and when to use it. ### Skipping a version diff --git a/docs/features/remote-updates.mdx b/docs/features/remote-updates.mdx index c1de64d4..221b9d0a 100644 --- a/docs/features/remote-updates.mdx +++ b/docs/features/remote-updates.mdx @@ -52,6 +52,21 @@ When an update is available, this same card gains a warning **Update available** The gateway switches to a fast 5-second polling loop while any node is in the `Updating` state, so the badge advances in near real time without waiting for the next 30-second fleet refresh. +## Reapply configuration + +When a node is already on the current Sencho release, **Update** is hidden, but Compose-managed nodes still expose a **Reapply configuration** icon control (tooltip on hover) in the Node updates sheet. Use this after you change the node's on-disk Compose project (environment variables, mounts, ports, labels, limits, healthcheck, networks, or socket-proxy settings) and need Sencho to recreate itself from that project without selecting a newer release. + +Reapply: + +- Uses the same Compose project context as a version update (multiple Compose files, `env_file`, configs, secrets, and bind mounts). +- Preserves the image reference declared in Compose. Sencho does not resolve a newer release or rewrite the pin. +- Works for digest-pinned installs, because no repin step runs. +- Requires the admin role. + +Confirming a local reapply opens a dialog that states the node will recreate from its current Compose configuration, the dashboard may briefly disconnect, no newer version is selected, and the configured image reference is not rewritten. Confirming a remote reapply uses the same required acknowledgement (recreate from current Compose, no newer version, no image rewrite) before the request is sent; Fleet then shows a **Reapplying** badge until the node restarts or the operation fails. + +Eligible admins can also run the same procedure from the Compose editor: on Sencho's own stack the primary save action becomes **Save & Reapply**, which saves the file first, then opens the same confirmation and fleet reapply path. + ## Updating the local (gateway) node Updating the gateway is special because the dashboard is hosted by the very container that is about to restart. Clicking **Update** on the local row, or **Update to vX.Y.Z** on the Local card, opens a confirmation dialog (kicker **LOCAL · UPDATE**, title **Update local node**, with **Cancel** and **Update & restart** buttons) before anything happens on disk. The body text depends on how the compose file pins the image: for a semver pin it names the exact rewrite (for example, "This install pins `saelix/sencho:0.94.1`. Updating rewrites it to `saelix/sencho:0.95.0`..."); for a floating tag it reads more generally ("Pulls Sencho v0.95.0 and restarts the server..."). Both variants end with the same note that the dashboard briefly disconnects and reconnects automatically. diff --git a/frontend/src/components/ComposeDiffPreviewDialog.tsx b/frontend/src/components/ComposeDiffPreviewDialog.tsx index b1cefbc5..8f0f03a2 100644 --- a/frontend/src/components/ComposeDiffPreviewDialog.tsx +++ b/frontend/src/components/ComposeDiffPreviewDialog.tsx @@ -3,6 +3,7 @@ import { DiffEditor } from '@/lib/monacoLoader'; import { Loader2 } from 'lucide-react'; import { Modal, ModalHeader, ModalFooter } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; +import type { ComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel'; export interface ComposeDiffPreviewDialogProps { open: boolean; @@ -12,7 +13,7 @@ export interface ComposeDiffPreviewDialogProps { language: 'yaml' | 'ini'; original: string; modified: string; - actionLabel: 'Save' | 'Save & deploy'; + actionLabel: ComposeDiffActionLabel; confirming: boolean; isDarkMode: boolean; onConfirm: () => void | Promise; diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 24aa9a43..53a1a1d1 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -23,6 +23,9 @@ import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; import { useNotifications } from './EditorLayout/hooks/useNotifications'; import { useContainerStats } from './EditorLayout/hooks/useContainerStats'; import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu'; +import { useActiveNodeReapplyEligibility } from './EditorLayout/hooks/useActiveNodeReapplyEligibility'; +import { resolveCanSaveAndReapply } from './EditorLayout/resolveCanSaveAndReapply'; +import { useComposeReapplyAction } from './FleetView/hooks/useComposeReapplyAction'; import { NodeSwitcher } from './NodeSwitcher'; import { GlobalCommandPalette, @@ -186,6 +189,12 @@ export default function EditorLayout() { createDialogOpen, setCreateDialogOpen, } = overlayState; + const { canReapply: canReapplyCompose } = useActiveNodeReapplyEligibility(activeNode?.id); + const composeReapply = useComposeReapplyAction(); + const isSelfStackSelected = selectedFile ? stackSelfFlags[selectedFile] === true : false; + // Ordinary stacks keep Save & Deploy even when the node supports compose reapply. + const canSaveAndReapply = resolveCanSaveAndReapply(isAdmin, canReapplyCompose, isSelfStackSelected); + // Which mode the create dialog opens on (always empty after import tab removal). const [createDialogInitialMode, setCreateDialogInitialMode] = useState('empty'); const [adoptDialogOpen, setAdoptDialogOpen] = useState(false); @@ -292,6 +301,8 @@ export default function EditorLayout() { canOfferVolumeRemoval, onDeletedOpenStack: () => onDeletedOpenStackRef.current(), removeNotificationsForStack, + isAdmin, + canReapplyCompose, }); // Wire the ref now that stackActions is available @@ -692,7 +703,8 @@ export default function EditorLayout() { requestDeleteStack={stackActions.requestDeleteStack} requestTakeDownStack={stackActions.requestTakeDownStack} showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false} - isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false} + isSelfStack={isSelfStackSelected} + canSaveAndReapply={canSaveAndReapply} recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined} onRefreshState={async () => { if (!selectedFile) return; @@ -1050,6 +1062,8 @@ export default function EditorLayout() { gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} canSelfUpdate={hasCapability('self-update')} + composeReapply={composeReapply} + canSaveAndReapply={canSaveAndReapply} canOfferVolumeRemoval={canOfferVolumeRemoval} onOpenFleetNodeUpdates={() => { if (isMobile) { diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 36dc7f61..777a83c0 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -207,6 +207,8 @@ export interface EditorViewProps { showTakeDown: boolean; /** True when this stack is the running Sencho instance on the active node. */ isSelfStack?: boolean; + /** Admin + node reapply eligibility + self-stack: show Save & Reapply instead of Save & Deploy. */ + canSaveAndReapply?: boolean; // Recovery surface for a failed/stalled operation on this stack (undefined // when the last op succeeded or none has run). onRefreshState re-syncs @@ -300,6 +302,7 @@ export function EditorView(props: EditorViewProps) { requestTakeDownStack, showTakeDown, isSelfStack, + canSaveAndReapply = false, recoveryResult, onRefreshState, onDismissRecovery, @@ -609,7 +612,7 @@ export function EditorView(props: EditorViewProps) {
diff --git a/frontend/src/components/EditorLayout/MobileComposeEditor.tsx b/frontend/src/components/EditorLayout/MobileComposeEditor.tsx index 8ded336e..812d1162 100644 --- a/frontend/src/components/EditorLayout/MobileComposeEditor.tsx +++ b/frontend/src/components/EditorLayout/MobileComposeEditor.tsx @@ -27,6 +27,7 @@ interface MobileComposeEditorProps { canEdit: boolean; requestSave: () => void; requestSaveAndDeploy: (e: React.MouseEvent) => void; + canSaveAndReapply?: boolean; onClose: () => void; hasUnsavedChanges: () => boolean; } @@ -54,6 +55,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) { canEdit, requestSave, requestSaveAndDeploy, + canSaveAndReapply = false, onClose, hasUnsavedChanges, } = props; @@ -195,7 +197,7 @@ export function MobileComposeEditor(props: MobileComposeEditorProps) { className="h-11 flex-1 rounded-lg" > - Save & Deploy + {canSaveAndReapply ? 'Save & Reapply' : 'Save & Deploy'}
)} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 2a53b8d8..1c4b81d4 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -75,6 +75,7 @@ export function MobileStackDetail(props: EditorViewProps) { requestTakeDownStack, showTakeDown, isSelfStack = false, + canSaveAndReapply = false, onMobileBack, onCloseEditor, hasUnsavedChanges, @@ -115,6 +116,7 @@ export function MobileStackDetail(props: EditorViewProps) { canEdit={canEditStack} requestSave={requestSave} requestSaveAndDeploy={requestSaveAndDeploy} + canSaveAndReapply={canSaveAndReapply} onClose={onCloseEditor} hasUnsavedChanges={hasUnsavedChanges} /> diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index e0e8cc1c..69be9161 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -4,6 +4,8 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog'; import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog'; +import { LocalUpdateConfirmDialog } from '../FleetView/LocalUpdateConfirmDialog'; +import { ReconnectingOverlay } from '../FleetView/ReconnectingOverlay'; import { DeleteStackDialog } from './DeleteStackDialog'; import { TakeDownStackDialog } from './TakeDownStackDialog'; import { UnsavedChangesDialog } from './UnsavedChangesDialog'; @@ -12,9 +14,11 @@ import { GitSourcePanel } from '../stack/GitSourcePanel'; import { LogViewer } from '../LogViewer'; import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet'; import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog'; +import { resolveComposeDiffActionLabel } from '@/components/resolveComposeDiffActionLabel'; import type { OverlayState } from './hooks/useOverlayState'; import type { StackActionsHook } from './hooks/useStackActions'; import type { PermissionAction } from '@/context/AuthContext'; +import type { useComposeReapplyAction } from '../FleetView/hooks/useComposeReapplyAction'; interface ShellOverlaysProps { overlayState: OverlayState; @@ -27,6 +31,8 @@ interface ShellOverlaysProps { gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; canSelfUpdate: boolean; + composeReapply: ReturnType; + canSaveAndReapply: boolean; canOfferVolumeRemoval: boolean; onOpenFleetNodeUpdates: () => void; } @@ -42,6 +48,8 @@ export function ShellOverlays({ gitSourceOpen, setGitSourceOpen, canSelfUpdate, + composeReapply, + canSaveAndReapply, canOfferVolumeRemoval, onOpenFleetNodeUpdates, }: ShellOverlaysProps) { @@ -57,6 +65,7 @@ export function ShellOverlays({ preDeployAdvisory, missingExternalNetworks, setMissingExternalNetworks, selfStackProtectedOpen, setSelfStackProtectedOpen, + composeReapplyCapture, setComposeReapplyCapture, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } = overlayState; @@ -85,6 +94,32 @@ export function ShellOverlays({ onOpenFleetUpdates={onOpenFleetNodeUpdates} /> + { + if (!open) setComposeReapplyCapture(null); + }} + onConfirm={() => { + const capture = composeReapplyCapture; + setComposeReapplyCapture(null); + if (!capture || composeReapply.dispatching) return; + void composeReapply.runReapply({ + nodeId: capture.nodeId, + type: capture.nodeType, + name: capture.nodeName, + }); + }} + /> + + {composeReapply.reconnecting && ( + + )} + { diff --git a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx index 0317d52f..71bbc6c1 100644 --- a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx @@ -164,6 +164,17 @@ describe('EditorView single edit gate', () => { expect(lastReadOnly).toBe(false); }); + it('shows Save & Reapply when the self-stack is eligible for compose reapply', () => { + render(); + expect(screen.getByRole('button', { name: 'Save & Reapply' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save & Deploy' })).not.toBeInTheDocument(); + }); + it('disables the env file selector when hasUnsavedChanges is true', () => { render( { + it('is true only when admin, node-eligible, and self-stack', () => { + expect(resolveCanSaveAndReapply(true, true, true)).toBe(true); + }); + + it('is false for ordinary stacks even when admin and node-eligible', () => { + expect(resolveCanSaveAndReapply(true, true, false)).toBe(false); + }); + + it('is false when not admin or not node-eligible', () => { + expect(resolveCanSaveAndReapply(false, true, true)).toBe(false); + expect(resolveCanSaveAndReapply(true, false, true)).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/__tests__/useActiveNodeReapplyEligibility.test.tsx b/frontend/src/components/EditorLayout/hooks/__tests__/useActiveNodeReapplyEligibility.test.tsx new file mode 100644 index 00000000..4f9382a9 --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/__tests__/useActiveNodeReapplyEligibility.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, waitFor, act } from '@testing-library/react'; +import { useActiveNodeReapplyEligibility } from '../useActiveNodeReapplyEligibility'; + +const apiFetchMock = vi.fn(); + +vi.mock('@/lib/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('useActiveNodeReapplyEligibility', () => { + beforeEach(() => { + apiFetchMock.mockReset(); + }); + + it('derives canReapply only when owned result matches active node and value is true', async () => { + apiFetchMock.mockResolvedValue(okJson({ + nodes: [{ nodeId: 1, canReapplyCompose: true }], + })); + const { result } = renderHook(() => useActiveNodeReapplyEligibility(1)); + expect(result.current.canReapply).toBe(false); + await waitFor(() => expect(result.current.canReapply).toBe(true)); + }); + + it('ignores a late response for a previous node after switching', async () => { + let resolveA!: (value: Response) => void; + const pendingA = new Promise((resolve) => { resolveA = resolve; }); + apiFetchMock + .mockImplementationOnce(() => pendingA) + .mockResolvedValueOnce(okJson({ + nodes: [{ nodeId: 2, canReapplyCompose: false }], + })); + + const { result, rerender } = renderHook( + ({ id }: { id: number | null }) => useActiveNodeReapplyEligibility(id), + { initialProps: { id: 1 as number | null } }, + ); + + rerender({ id: 2 }); + await waitFor(() => expect(apiFetchMock).toHaveBeenCalledTimes(2)); + expect(result.current.canReapply).toBe(false); + + await act(async () => { + resolveA(okJson({ + nodes: [{ nodeId: 1, canReapplyCompose: true }], + })); + }); + + expect(result.current.canReapply).toBe(false); + expect(result.current.owned?.nodeId === 2 || result.current.owned === null || result.current.owned.nodeId === 2).toBe(true); + }); + + it('stays ineligible when the row is missing canReapplyCompose', async () => { + apiFetchMock.mockResolvedValue(okJson({ + nodes: [{ nodeId: 1 }], + })); + const { result } = renderHook(() => useActiveNodeReapplyEligibility(1)); + await waitFor(() => expect(result.current.owned).not.toBeNull()); + expect(result.current.canReapply).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useActiveNodeReapplyEligibility.ts b/frontend/src/components/EditorLayout/hooks/useActiveNodeReapplyEligibility.ts new file mode 100644 index 00000000..e3a9c52c --- /dev/null +++ b/frontend/src/components/EditorLayout/hooks/useActiveNodeReapplyEligibility.ts @@ -0,0 +1,64 @@ +import { useState, useEffect, useRef } from 'react'; +import { apiFetch } from '@/lib/api'; +import type { NodeUpdateStatus } from '@/components/FleetView/types'; + +type OwnedEligibility = { nodeId: number; value: boolean }; + +/** + * Authoritative canReapplyCompose for the active node from /fleet/update-status. + * Result is keyed by nodeId so a late response for a previous node cannot enable + * Save & Reapply after the operator switches nodes. Derived canReapply is only + * true when the owned result's nodeId matches the current activeNodeId. + */ +export function useActiveNodeReapplyEligibility(activeNodeId: number | null | undefined) { + const [owned, setOwned] = useState(null); + const generationRef = useRef(0); + + useEffect(() => { + if (activeNodeId == null) { + generationRef.current += 1; + setOwned(null); + return; + } + + const generation = ++generationRef.current; + let cancelled = false; + + (async () => { + try { + const res = await apiFetch('/fleet/update-status', { localOnly: true }); + if (cancelled || generation !== generationRef.current) return; + if (!res.ok) { + setOwned({ nodeId: activeNodeId, value: false }); + return; + } + const data = await res.json(); + if (cancelled || generation !== generationRef.current) return; + const nodes: NodeUpdateStatus[] = data.nodes ?? []; + const row = nodes.find(n => n.nodeId === activeNodeId); + setOwned({ + nodeId: activeNodeId, + value: row?.canReapplyCompose === true, + }); + } catch (error) { + if (cancelled || generation !== generationRef.current) return; + console.warn('[Editor] Failed to load reapply eligibility:', error); + setOwned({ nodeId: activeNodeId, value: false }); + } + })(); + + return () => { + cancelled = true; + }; + }, [activeNodeId]); + + // Synchronous ownership check: after a node switch, a stale owned row for the + // previous node must not enable reapply on the new active node. + const canReapply = + activeNodeId != null + && owned !== null + && owned.nodeId === activeNodeId + && owned.value === true; + + return { canReapply, owned }; +} diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 86aeea97..1c6fba31 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -166,6 +166,14 @@ export function useOverlayState() { const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []); const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []); + /** Captured when Save & Reapply opens confirm; cleared on cancel, confirm, or ownership drift. */ + const [composeReapplyCapture, setComposeReapplyCapture] = useState<{ + nodeId: number; + nodeType: 'local' | 'remote'; + nodeName: string; + stackFile: string; + } | null>(null); + const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [diffPreview, setDiffPreview] = useState(null); @@ -187,6 +195,7 @@ export function useOverlayState() { preDeployAdvisory, setPreDeployAdvisory, missingExternalNetworks, setMissingExternalNetworks, selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected, + composeReapplyCapture, setComposeReapplyCapture, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } as const; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 8eefa8ba..0fdeabcc 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -106,6 +106,8 @@ function makeOverlay(over: Partial = {}): OverlayState { preDeployAdvisory: null, setPreDeployAdvisory: vi.fn(), openSelfStackProtected: vi.fn(), + setComposeReapplyCapture: vi.fn(), + composeReapplyCapture: null, setDiffPreview: vi.fn(), stackToDelete: null, closeDeleteDialog: vi.fn(), @@ -131,6 +133,8 @@ function setup(over: { setActiveNode?: Parameters[0]['setActiveNode']; onDeletedOpenStack?: () => void; removeNotificationsForStack?: (nodeId: number, stackName: string) => void; + isAdmin?: boolean; + canReapplyCompose?: boolean; } = {}) { const editorState = makeEditorState(over.editorState); const stackListState = makeStackListState(over.stackList); @@ -150,7 +154,7 @@ function setup(over: { stackListState, navState, overlayState, - activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters[0]['activeNode']), + activeNode: over.activeNode ?? ({ id: 1, name: 'Local', type: 'local' } as Parameters[0]['activeNode']), setActiveNode, nodes: [], runWithLog, @@ -160,6 +164,8 @@ function setup(over: { canEditStack: over.canEditStack ?? (() => true), onDeletedOpenStack, removeNotificationsForStack, + isAdmin: over.isAdmin ?? false, + canReapplyCompose: over.canReapplyCompose ?? false, }), ); return { result, editorState, stackListState, overlayState, navState, setActiveNode, onDeletedOpenStack, removeNotificationsForStack }; @@ -1127,6 +1133,152 @@ describe('useStackActions.getStackMenuVisibility', () => { expect(stackListState.setStackAction).not.toHaveBeenCalled(); }); + it('opens reapply capture for eligible admin Save & Deploy on self-stack without posting deploy', async () => { + vi.mocked(apiFetch).mockReset(); + const { result, overlayState } = setup({ + isAdmin: true, + canReapplyCompose: true, + activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters[0]['activeNode'], + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); }); + expect(overlayState.setComposeReapplyCapture).toHaveBeenCalledWith({ + nodeId: 7, + nodeType: 'local', + nodeName: 'Gateway', + stackFile: 'sencho.yml', + }); + expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('does not open reapply capture for ordinary stacks when canReapplyCompose is true', async () => { + // Node eligibility alone must not retarget ordinary stacks; isSelfStackFile gates capture. + vi.mocked(apiFetch).mockReset(); + vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ hasIssues: false }), { status: 200 })); + const { result, overlayState, stackListState } = setup({ + isAdmin: true, + canReapplyCompose: true, + activeNode: { id: 7, name: 'Gateway', type: 'local' } as Parameters[0]['activeNode'], + stackList: { + selectedFile: 'web.yml', + stackSelfFlags: { 'web.yml': false }, + }, + }); + await act(async () => { + await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); + }); + expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled(); + expect(overlayState.openSelfStackProtected).not.toHaveBeenCalled(); + expect(stackListState.setStackAction).toHaveBeenCalled(); + }); + + it('opens protected dialog for self-stack deploy when reapply is not eligible', async () => { + const { result, overlayState } = setup({ + isAdmin: true, + canReapplyCompose: false, + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled(); + }); + + it('opens protected dialog for non-admin even when canReapplyCompose is true', async () => { + const { result, overlayState } = setup({ + isAdmin: false, + canReapplyCompose: true, + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(overlayState.setComposeReapplyCapture).not.toHaveBeenCalled(); + }); + + it('cancels open reapply capture when the active node changes', async () => { + const setComposeReapplyCapture = vi.fn(); + const activeNodeA = { id: 1, name: 'A', type: 'local' as const }; + const { rerender } = renderHook( + ({ activeNode }: { activeNode: typeof activeNodeA }) => + useStackActions({ + editorState: makeEditorState(), + stackListState: makeStackListState({ + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }), + navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState, + overlayState: makeOverlay({ + composeReapplyCapture: { + nodeId: 1, + nodeType: 'local', + nodeName: 'A', + stackFile: 'sencho.yml', + }, + setComposeReapplyCapture, + }), + activeNode: activeNode as unknown as Parameters[0]['activeNode'], + setActiveNode: vi.fn(), + nodes: [], + runWithLog, + getLastDeployOutputLine: () => undefined, + diffPreviewEnabled: false, + canEditStack: () => true, + onDeletedOpenStack: vi.fn(), + isAdmin: true, + canReapplyCompose: true, + }), + { initialProps: { activeNode: activeNodeA } }, + ); + rerender({ activeNode: { id: 2, name: 'B', type: 'local' as const } }); + expect(setComposeReapplyCapture).toHaveBeenCalledWith(null); + }); + + it('cancels open reapply capture when the selected stack changes', async () => { + const setComposeReapplyCapture = vi.fn(); + const { rerender } = renderHook( + ({ selectedFile }: { selectedFile: string }) => + useStackActions({ + editorState: makeEditorState(), + stackListState: makeStackListState({ + selectedFile, + stackSelfFlags: { 'sencho.yml': true, 'other.yml': true }, + }), + navState: { activeView: 'editor', setActiveView: vi.fn() } as unknown as NavState, + overlayState: makeOverlay({ + composeReapplyCapture: { + nodeId: 1, + nodeType: 'local', + nodeName: 'A', + stackFile: 'sencho.yml', + }, + setComposeReapplyCapture, + }), + activeNode: { id: 1, name: 'A', type: 'local' } as unknown as Parameters[0]['activeNode'], + setActiveNode: vi.fn(), + nodes: [], + runWithLog, + getLastDeployOutputLine: () => undefined, + diffPreviewEnabled: false, + canEditStack: () => true, + onDeletedOpenStack: vi.fn(), + isAdmin: true, + canReapplyCompose: true, + }), + { initialProps: { selectedFile: 'sencho.yml' } }, + ); + rerender({ selectedFile: 'other.yml' }); + expect(setComposeReapplyCapture).toHaveBeenCalledWith(null); + }); + it('opens the self-stack modal instead of calling rollback on a protected stack', async () => { vi.mocked(apiFetch).mockReset(); const { result, overlayState, stackListState } = setup({ diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 1d6922b7..f945c407 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -182,6 +182,10 @@ interface UseStackActionsOptions { * Optional so unit tests that do not exercise delete can omit it. */ removeNotificationsForStack?: (nodeId: number, stackName: string) => void; + /** Admin role: required together with canReapplyCompose for Save & Reapply. */ + isAdmin?: boolean; + /** Authoritative canReapplyCompose === true for the active node. */ + canReapplyCompose?: boolean; } const isRecord = (value: unknown): value is Record => @@ -406,6 +410,8 @@ export function useStackActions(options: UseStackActionsOptions) { canOfferVolumeRemoval = false, onDeletedOpenStack, removeNotificationsForStack, + isAdmin = false, + canReapplyCompose = false, } = options; const pendingStackLoadRef = useRef(null); @@ -445,6 +451,24 @@ export function useStackActions(options: UseStackActionsOptions) { containersRef.current = editorState.containers; }); + // Cancel an open Save & Reapply confirmation if the active node or selected + // stack diverges from the capture (never retarget a pending confirm). + useEffect(() => { + const capture = overlayState.composeReapplyCapture; + if (!capture) return; + if ( + activeNode?.id !== capture.nodeId + || stackListState.selectedFile !== capture.stackFile + ) { + overlayState.setComposeReapplyCapture(null); + } + }, [ + activeNode?.id, + stackListState.selectedFile, + overlayState.composeReapplyCapture, + overlayState.setComposeReapplyCapture, + ]); + useEffect(() => { return () => { if (checkUpdatesIntervalRef.current !== null) { @@ -1371,8 +1395,22 @@ export function useStackActions(options: UseStackActionsOptions) { deployPendingRef.current ) return; - if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; + const stackFile = stackListState.selectedFile; + if (isSelfStackFile(stackFile)) { + if (isAdmin && canReapplyCompose && activeNode) { + overlayState.setComposeReapplyCapture({ + nodeId: activeNode.id, + nodeType: activeNode.type === 'local' ? 'local' : 'remote', + nodeName: activeNode.name, + stackFile, + }); + return; + } + overlayState.openSelfStackProtected(); + return; + } + const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); // Snapshot the node once so the advisory fetch and the deploy stay bound to // it even if the active node changes while the advisory dialog is open. diff --git a/frontend/src/components/EditorLayout/resolveCanSaveAndReapply.ts b/frontend/src/components/EditorLayout/resolveCanSaveAndReapply.ts new file mode 100644 index 00000000..05df951b --- /dev/null +++ b/frontend/src/components/EditorLayout/resolveCanSaveAndReapply.ts @@ -0,0 +1,8 @@ +/** Toolbar/diff eligibility: admin + node reapply + selected file is self-stack. */ +export function resolveCanSaveAndReapply( + isAdmin: boolean, + canReapplyCompose: boolean, + isSelfStack: boolean, +): boolean { + return isAdmin && canReapplyCompose && isSelfStack; +} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 8fafa571..38487f35 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -74,9 +74,13 @@ export function FleetView({ const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); const overview = useFleetOverview({ prefs, updatePrefs, updateStatuses: updateStatus.updateStatuses }); - // The local node's status backs the confirm dialog copy (pin + target ref). - const localUpdateConfirmStatus = updateStatus.localUpdateConfirm !== null - ? updateStatus.updateStatuses.find(s => s.nodeId === updateStatus.localUpdateConfirm) + // Confirm dialogs: local update uses pin/target copy; reapply covers local + // and remote nodes with mode-specific wording. Prefer reapply when both set. + const confirmMode = updateStatus.reapplyConfirm !== null ? 'reapply' as const : 'update' as const; + const confirmNodeId = updateStatus.reapplyConfirm ?? updateStatus.localUpdateConfirm; + const confirmOpen = confirmNodeId !== null; + const confirmStatus = confirmNodeId !== null + ? updateStatus.updateStatuses.find(s => s.nodeId === confirmNodeId) : undefined; const topology = useTopologyPreferences(); const { exporting, exportDossier } = useFleetDossierExport(); @@ -340,7 +344,10 @@ export function FleetView({ {updateStatus.reconnecting && ( - + )} { if (!open) updateStatus.setLocalUpdateConfirm(null); }} - onConfirm={updateStatus.confirmLocalUpdate} - imagePinKind={localUpdateConfirmStatus?.imagePinKind} - composeImageRef={localUpdateConfirmStatus?.composeImageRef} - targetImageRef={localUpdateConfirmStatus?.targetImageRef} - targetVersion={localUpdateConfirmStatus?.latestVersion} + open={confirmOpen} + mode={confirmMode} + nodeType={ + confirmMode === 'reapply' + ? (updateStatus.reapplyConfirmTarget?.type ?? 'local') + : (confirmStatus?.type ?? 'local') + } + onOpenChange={(open) => { + if (!open) { + updateStatus.setLocalUpdateConfirm(null); + updateStatus.setReapplyConfirm(null); + } + }} + onConfirm={confirmMode === 'reapply' + ? updateStatus.confirmReapply + : updateStatus.confirmLocalUpdate} + imagePinKind={confirmStatus?.imagePinKind} + composeImageRef={confirmStatus?.composeImageRef} + targetImageRef={confirmStatus?.targetImageRef} + targetVersion={confirmStatus?.latestVersion} /> {NodeActionModals} diff --git a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx index aa851a65..dddf12e0 100644 --- a/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx +++ b/frontend/src/components/FleetView/LocalUpdateConfirmDialog.tsx @@ -1,4 +1,5 @@ -import { Download } from 'lucide-react'; +import { Download, RefreshCw } from 'lucide-react'; +import type { ReactNode } from 'react'; import { ConfirmModal } from '@/components/ui/modal'; import { formatVersion } from '@/lib/version'; import type { ImagePinKind } from './types'; @@ -7,6 +8,9 @@ interface LocalUpdateConfirmDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: () => void; + mode?: 'update' | 'reapply'; + /** Distinguishes local vs remote reapply copy. Ignored for update mode. */ + nodeType?: 'local' | 'remote'; imagePinKind?: ImagePinKind | null; composeImageRef?: string | null; targetImageRef?: string | null; @@ -14,33 +18,72 @@ interface LocalUpdateConfirmDialogProps { } export function LocalUpdateConfirmDialog({ - open, onOpenChange, onConfirm, imagePinKind, composeImageRef, targetImageRef, targetVersion, + open, onOpenChange, onConfirm, mode = 'update', nodeType = 'local', + imagePinKind, composeImageRef, targetImageRef, targetVersion, }: LocalUpdateConfirmDialogProps) { + const isReapply = mode === 'reapply'; + const isRemoteReapply = isReapply && nodeType === 'remote'; const versionLabel = formatVersion(targetVersion) ?? 'the latest release'; + + let kicker = 'LOCAL · UPDATE'; + if (isRemoteReapply) kicker = 'REMOTE · REAPPLY'; + else if (isReapply) kicker = 'LOCAL · REAPPLY'; + + let body: ReactNode; + if (isRemoteReapply) { + body = ( +

+ Recreates this remote Sencho service from its current Compose configuration. + No newer Sencho version is selected, and Sencho will not rewrite the + configured image reference. The node will restart; Fleet tracks reconnection. +

+ ); + } else if (isReapply) { + body = ( +

+ Recreates this Sencho service from its current Compose configuration. + No newer Sencho version is selected, and Sencho will not rewrite the + configured image reference. The dashboard may briefly disconnect and + reconnects automatically when the restart completes. +

+ ); + } else if (imagePinKind === 'semver' && composeImageRef && targetImageRef) { + body = ( +

+ This install pins {composeImageRef}. Updating rewrites it to{' '} + {targetImageRef} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes. +

+ ); + } else { + body = ( +

+ Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes. +

+ ); + } + return ( - - Update & restart - + isReapply ? ( + <> + + Reapply & restart + + ) : ( + <> + + Update & restart + + ) } onConfirm={onConfirm} > - {imagePinKind === 'semver' && composeImageRef && targetImageRef ? ( -

- This install pins {composeImageRef}. Updating rewrites it to{' '} - {targetImageRef} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes. -

- ) : ( -

- Pulls Sencho {versionLabel} and restarts the server. The dashboard briefly disconnects and reconnects automatically when the update completes. -

- )} + {body}
); } diff --git a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx index ae28b110..839fa168 100644 --- a/frontend/src/components/FleetView/NodeUpdatesSheet.tsx +++ b/frontend/src/components/FleetView/NodeUpdatesSheet.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { MarkdownContent } from '@/components/ui/MarkdownContent'; import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion, isValidVersion } from '@/lib/version'; @@ -29,6 +30,7 @@ interface NodeUpdatesSheetProps { initialTab?: 'nodes' | 'changelog'; fetchUpdateStatus: () => Promise; triggerNodeUpdate: (nodeId: number) => void; + triggerNodeReapply: (nodeId: number) => void; retryNodeUpdate: (nodeId: number) => void; dismissNodeUpdate: (nodeId: number) => void; triggerUpdateAll: () => Promise; @@ -37,7 +39,7 @@ interface NodeUpdatesSheetProps { export function NodeUpdatesSheet({ open, onOpenChange, checkingUpdates, updateStatuses, updatingNodeId, isAdmin, initialTab = 'nodes', - fetchUpdateStatus, triggerNodeUpdate, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, + fetchUpdateStatus, triggerNodeUpdate, triggerNodeReapply, retryNodeUpdate, dismissNodeUpdate, triggerUpdateAll, }: NodeUpdatesSheetProps) { const [search, setSearch] = useState(''); const [recheckingUpdates, setRecheckingUpdates] = useState(false); @@ -410,12 +412,17 @@ export function NodeUpdatesSheet({ retryNodeUpdate(s.nodeId) : undefined} + operationKind={s.operationKind} + onRetry={isAdmin ? () => ( + s.operationKind === 'reapply_configuration' + ? triggerNodeReapply(s.nodeId) + : retryNodeUpdate(s.nodeId) + ) : undefined} onDismiss={isAdmin ? () => dismissNodeUpdate(s.nodeId) : undefined} /> )} {!s.updateStatus && !s.updateAvailable && !s.skipActive && ( - + Up to date )} @@ -456,6 +463,41 @@ export function NodeUpdatesSheet({ )} )} + {isAdmin && !s.updateStatus && s.canReapplyCompose && ( + + + + + + + {updatingNodeId === s.nodeId ? 'Reapplying…' : 'Reapply configuration'} + + + + )} + {isAdmin && !s.updateStatus && s.canReapplyCompose === false && ( + + Reapply unavailable + + )} {showSkip(s) && ( diff --git a/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx b/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx index c5d7c7bb..a55f0ef0 100644 --- a/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx +++ b/frontend/src/components/FleetView/__tests__/LocalUpdateConfirmDialog.test.tsx @@ -46,4 +46,38 @@ describe('LocalUpdateConfirmDialog', () => { expect(screen.getByText(/Pulls Sencho v0\.94\.0/i)).toBeInTheDocument(); expect(screen.queryByText(/rewrites it to/i)).not.toBeInTheDocument(); }); + + it('explains local reapply without a version change or image rewrite', () => { + render( + , + ); + expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument(); + expect(screen.getByText(/current Compose configuration/i)).toBeInTheDocument(); + expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument(); + expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument(); + expect(screen.getByText(/briefly disconnect/i)).toBeInTheDocument(); + }); + + it('explains remote reapply with REMOTE kicker and restart acknowledgement', () => { + render( + , + ); + expect(screen.getByRole('heading', { name: /Reapply configuration/i })).toBeInTheDocument(); + expect(screen.getByText(/Recreates this remote Sencho service/i)).toBeInTheDocument(); + expect(screen.getByText(/No newer Sencho version is selected/i)).toBeInTheDocument(); + expect(screen.getByText(/will not rewrite the configured image reference/i)).toBeInTheDocument(); + expect(screen.getByText(/The node will restart/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx index 4b25deea..04c9654a 100644 --- a/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeUpdatesSheet.test.tsx @@ -27,6 +27,7 @@ function baseProps(overrides: Partial {}), triggerNodeUpdate: vi.fn(), + triggerNodeReapply: vi.fn(), retryNodeUpdate: vi.fn(), dismissNodeUpdate: vi.fn(), triggerUpdateAll: vi.fn(async () => {}), @@ -363,4 +364,26 @@ describe('NodeUpdatesSheet', () => { expect(screen.queryByLabelText('Retry update')).not.toBeInTheDocument(); expect(screen.queryByLabelText('Dismiss')).not.toBeInTheDocument(); }); + + it('shows an icon-only Reapply control on an up-to-date Compose-managed node', () => { + const triggerNodeReapply = vi.fn(); + const statuses: NodeUpdateStatus[] = [ + { + nodeId: 1, + name: 'Local', + type: 'local', + version: '1.1.0', + latestVersion: '1.1.0', + updateAvailable: false, + updateStatus: null, + canReapplyCompose: true, + }, + ]; + render(); + expect(screen.queryByRole('button', { name: /Update$/ })).not.toBeInTheDocument(); + const reapply = screen.getByRole('button', { name: 'Reapply configuration' }); + expect(reapply).not.toHaveTextContent(/Reapply configuration/); + fireEvent.click(reapply); + expect(triggerNodeReapply).toHaveBeenCalledWith(1); + }); }); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useComposeReapplyAction.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useComposeReapplyAction.test.tsx new file mode 100644 index 00000000..e4ee4bcb --- /dev/null +++ b/frontend/src/components/FleetView/hooks/__tests__/useComposeReapplyAction.test.tsx @@ -0,0 +1,126 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useComposeReapplyAction } from '../useComposeReapplyAction'; + +const apiFetchMock = vi.fn(); +const toastSuccess = vi.fn(); +const toastError = vi.fn(); + +vi.mock('@/lib/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + success: (...a: unknown[]) => toastSuccess(...a), + error: (...a: unknown[]) => toastError(...a), + }, +})); + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('useComposeReapplyAction', () => { + beforeEach(() => { + apiFetchMock.mockReset(); + toastSuccess.mockReset(); + toastError.mockReset(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('openConfirm does not POST until confirmReapply', async () => { + const { result } = renderHook(() => useComposeReapplyAction()); + act(() => { + result.current.openConfirm({ nodeId: 2, type: 'remote', name: 'Edge' }); + }); + expect(result.current.confirmTarget?.nodeId).toBe(2); + expect(apiFetchMock).not.toHaveBeenCalled(); + + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + await act(async () => { await result.current.confirmReapply(); }); + expect(apiFetchMock).toHaveBeenCalledWith( + '/fleet/nodes/2/reapply-compose', + expect.objectContaining({ method: 'POST', localOnly: true }), + ); + expect(toastSuccess).toHaveBeenCalled(); + expect(result.current.confirmTarget).toBeNull(); + }); + + it('cancelConfirm clears without POST', () => { + const { result } = renderHook(() => useComposeReapplyAction()); + act(() => { + result.current.openConfirm({ nodeId: 1, type: 'local', name: 'Local' }); + result.current.cancelConfirm(); + }); + expect(result.current.confirmTarget).toBeNull(); + expect(apiFetchMock).not.toHaveBeenCalled(); + }); + + it('starts local reconnect after a successful local POST', async () => { + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 42 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ))); + const { result } = renderHook(() => useComposeReapplyAction()); + await act(async () => { + await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' }); + }); + expect(result.current.reconnecting).toBe(true); + expect(result.current.preUpdateStartedAt).toBe(42); + }); + + it('clears reconnect when tracker reports local failure', async () => { + vi.useFakeTimers(); + apiFetchMock + .mockResolvedValueOnce(okJson({ message: 'ok' })) + .mockResolvedValue(okJson({ + nodes: [{ + type: 'local', + updateStatus: 'failed', + operationKind: 'reapply_configuration', + error: 'Compose config invalid', + }], + })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ))); + const { result } = renderHook(() => useComposeReapplyAction()); + await act(async () => { + await result.current.runReapply({ nodeId: 1, type: 'local', name: 'Local' }); + }); + expect(result.current.reconnecting).toBe(true); + + await act(async () => { await vi.advanceTimersByTimeAsync(3000); }); + expect(result.current.reconnecting).toBe(false); + expect(toastError).toHaveBeenCalledWith('Compose config invalid'); + }); + + it('ignores a second confirm while dispatch is pending', async () => { + let release!: (value: Response) => void; + const held = new Promise((resolve) => { release = resolve; }); + apiFetchMock.mockImplementation(() => held); + const { result } = renderHook(() => useComposeReapplyAction()); + + const first = act(async () => { + await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' }); + }); + await act(async () => { + await result.current.runReapply({ nodeId: 2, type: 'remote', name: 'Edge' }); + }); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + release(okJson({ message: 'ok' })); + await first; + }); +}); diff --git a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx index ce7eb7e7..60971f18 100644 --- a/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx +++ b/frontend/src/components/FleetView/hooks/__tests__/useFleetUpdateStatus.test.tsx @@ -173,6 +173,54 @@ describe('useFleetUpdateStatus', () => { expect(apiFetchMock).not.toHaveBeenCalled(); }); + it('triggerNodeReapply on a remote node opens confirm and does not POST until confirmed', async () => { + apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + apiFetchMock.mockClear(); + + await act(async () => { await result.current.triggerNodeReapply(2); }); + + expect(result.current.reapplyConfirm).toBe(2); + expect(apiFetchMock).not.toHaveBeenCalled(); + + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + await act(async () => { await result.current.confirmReapply(); }); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/fleet/nodes/2/reapply-compose', + expect.objectContaining({ method: 'POST', localOnly: true }), + ); + expect(toastSuccess).toHaveBeenCalledWith(expect.stringContaining('Edge')); + expect(result.current.reapplyConfirm).toBeNull(); + }); + + it('triggerNodeReapply on a local node opens confirm then starts local reconnect flow', async () => { + apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES })); + const { result } = renderHook(() => useFleetUpdateStatus()); + await act(async () => { await result.current.fetchUpdateStatus(); }); + apiFetchMock.mockClear(); + + await act(async () => { await result.current.triggerNodeReapply(1); }); + expect(result.current.reapplyConfirm).toBe(1); + expect(apiFetchMock).not.toHaveBeenCalled(); + + apiFetchMock.mockResolvedValue(okJson({ message: 'ok' })); + vi.stubGlobal('fetch', vi.fn(() => Promise.resolve( + new Response(JSON.stringify({ startedAt: 1000 }), { status: 200, headers: { 'Content-Type': 'application/json' } }), + ))); + + await act(async () => { await result.current.confirmReapply(); }); + + expect(apiFetchMock).toHaveBeenCalledWith( + '/fleet/nodes/1/reapply-compose', + expect.objectContaining({ method: 'POST', localOnly: true }), + ); + expect(result.current.reconnecting).toBe(true); + expect(result.current.reconnectMode).toBe('reapply'); + vi.unstubAllGlobals(); + }); + it('confirmLocalUpdate forwards targetVersion when latestVersion is valid', async () => { apiFetchMock.mockResolvedValue(okJson({ nodes: STATUSES })); const { result } = renderHook(() => useFleetUpdateStatus()); diff --git a/frontend/src/components/FleetView/hooks/useComposeReapplyAction.ts b/frontend/src/components/FleetView/hooks/useComposeReapplyAction.ts new file mode 100644 index 00000000..4b510f4a --- /dev/null +++ b/frontend/src/components/FleetView/hooks/useComposeReapplyAction.ts @@ -0,0 +1,140 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import type { NodeUpdateStatus } from '../types'; + +export type ComposeReapplyTarget = { + nodeId: number; + type: 'local' | 'remote'; + name: string; +}; + +function parseReapplyError(err: Record, fallback: string): string { + const nested = err?.data as Record | undefined; + const message = err?.message ?? err?.error ?? nested?.error; + return typeof message === 'string' && message ? message : fallback; +} + +async function readBootStartedAt(): Promise { + try { + const healthRes = await fetch('/api/health'); + if (!healthRes.ok) return null; + const data = await healthRes.json(); + return typeof data?.startedAt === 'number' ? data.startedAt : null; + } catch { + return null; + } +} + +export type UseComposeReapplyActionOptions = { + /** Refresh fleet statuses after a successful remote dispatch. */ + onRemoteSuccess?: () => void; +}; + +/** + * Shared confirm → dispatch → reconnect workflow for compose reapply. + * Used by Fleet Node Updates and the Compose editor Save & Reapply path. + */ +export function useComposeReapplyAction(options: UseComposeReapplyActionOptions = {}) { + const { onRemoteSuccess } = options; + const onRemoteSuccessRef = useRef(onRemoteSuccess); + onRemoteSuccessRef.current = onRemoteSuccess; + + const [confirmTarget, setConfirmTarget] = useState(null); + const [busyNodeId, setBusyNodeId] = useState(null); + const [reconnecting, setReconnecting] = useState(false); + const [preUpdateStartedAt, setPreUpdateStartedAt] = useState(null); + const dispatchingRef = useRef(false); + + const openConfirm = useCallback((target: ComposeReapplyTarget) => { + setConfirmTarget(target); + }, []); + + const cancelConfirm = useCallback(() => { + setConfirmTarget(null); + }, []); + + const runReapply = useCallback(async (target: ComposeReapplyTarget) => { + if (dispatchingRef.current) return; + + dispatchingRef.current = true; + setBusyNodeId(target.nodeId); + const path = `/fleet/nodes/${target.nodeId}/reapply-compose`; + const init = { method: 'POST', localOnly: true } as const; + + try { + if (target.type === 'local') { + const bootBefore = await readBootStartedAt(); + const res = await apiFetch(path, init); + if (res.ok) { + setPreUpdateStartedAt(bootBefore); + setReconnecting(true); + } else { + const err = await res.json().catch(() => ({})); + toast.error(parseReapplyError(err, 'Failed to trigger local compose reapply.')); + } + return; + } + + const res = await apiFetch(path, init); + if (res.ok) { + toast.success(`Compose reapply initiated on ${target.name}.`); + onRemoteSuccessRef.current?.(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(parseReapplyError(err, 'Failed to trigger compose reapply.')); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + dispatchingRef.current = false; + setBusyNodeId(null); + } + }, []); + + const confirmReapply = useCallback(async () => { + const target = confirmTarget; + setConfirmTarget(null); + if (!target) return; + await runReapply(target); + }, [confirmTarget, runReapply]); + + // While reconnecting, poll fleet update-status so a validation/helper failure + // before restart dismisses the overlay instead of waiting for the timeout. + useEffect(() => { + if (!reconnecting) return; + const poll = setInterval(async () => { + try { + const res = await apiFetch('/fleet/update-status', { localOnly: true }); + if (!res.ok) return; + const data = await res.json(); + const nodes: NodeUpdateStatus[] = data.nodes ?? []; + const local = nodes.find(s => s.type === 'local'); + if (local && (local.updateStatus === 'failed' || local.updateStatus === 'timeout')) { + setReconnecting(false); + setPreUpdateStartedAt(null); + toast.error(local.error || 'Local compose reapply failed. The server did not restart.'); + onRemoteSuccessRef.current?.(); + } + } catch (error) { + console.warn('[ComposeReapply] Reconnect status poll failed:', error); + } + }, 3000); + return () => clearInterval(poll); + }, [reconnecting]); + + return { + confirmTarget, + openConfirm, + cancelConfirm, + confirmReapply, + runReapply, + busyNodeId, + dispatching: busyNodeId !== null, + reconnecting, + preUpdateStartedAt, + reconnectMode: 'reapply' as const, + setReconnecting, + setPreUpdateStartedAt, + }; +} diff --git a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts index 4ad4ca1b..a1779a8c 100644 --- a/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts +++ b/frontend/src/components/FleetView/hooks/useFleetUpdateStatus.ts @@ -3,6 +3,7 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { isValidVersion } from '@/lib/version'; import { PINNED_UPDATE_BLOCKED_FALLBACK, type NodeUpdateStatus } from '../types'; +import { useComposeReapplyAction } from './useComposeReapplyAction'; /** POST body for an update trigger: forward the target release when it is a * valid version so the receiving node can repin a semver pin to it; omit @@ -28,12 +29,25 @@ function toastIfUpdateBlocked(status: NodeUpdateStatus | undefined): boolean { return true; } +async function readBootStartedAt(): Promise { + try { + const healthRes = await fetch('/api/health'); + if (!healthRes.ok) return null; + const data = await healthRes.json(); + return typeof data?.startedAt === 'number' ? data.startedAt : null; + } catch { + // Fall back to offline-then-online detection in the reconnect overlay. + return null; + } +} + export function useFleetUpdateStatus() { const [updateStatuses, setUpdateStatuses] = useState([]); const [updatingNodeId, setUpdatingNodeId] = useState(null); const [reconnecting, setReconnecting] = useState(false); const [preUpdateStartedAt, setPreUpdateStartedAt] = useState(null); const [localUpdateConfirm, setLocalUpdateConfirm] = useState(null); + const [reconnectMode, setReconnectMode] = useState<'update' | 'reapply'>('update'); const [showUpdateModal, setShowUpdateModal] = useState(false); const [checkingUpdates, setCheckingUpdates] = useState(false); @@ -65,6 +79,69 @@ export function useFleetUpdateStatus() { } }, []); + const reapplyAction = useComposeReapplyAction({ onRemoteSuccess: fetchUpdateStatus }); + const { + openConfirm: openReapplyConfirm, + cancelConfirm: cancelReapplyConfirm, + confirmReapply, + confirmTarget: reapplyConfirmTarget, + busyNodeId: reapplyBusyNodeId, + reconnecting: reapplyReconnecting, + preUpdateStartedAt: reapplyPreStartedAt, + } = reapplyAction; + + const postRemoteAction = useCallback(async ( + nodeId: number, + path: string, + init: RequestInit & { localOnly: true }, + successMsg: string, + failFallback: string, + ) => { + setUpdatingNodeId(nodeId); + try { + const res = await apiFetch(path, init); + if (res.ok) { + toast.success(successMsg); + fetchUpdateStatus(); + } else { + const err = await res.json().catch(() => ({})); + toast.error(parseUpdateError(err, failFallback)); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setUpdatingNodeId(null); + } + }, [fetchUpdateStatus]); + + const startLocalRestart = useCallback(async ( + nodeId: number, + path: string, + init: RequestInit & { localOnly: true }, + mode: 'update' | 'reapply', + failFallback: string, + ) => { + setUpdatingNodeId(nodeId); + try { + // Capture pre-restart boot timestamp so the overlay can detect a real + // restart vs a false "online" response from the still-running process. + const bootBefore = await readBootStartedAt(); + const res = await apiFetch(path, init); + if (res.ok) { + setReconnectMode(mode); + setPreUpdateStartedAt(bootBefore); + setReconnecting(true); + } else { + const err = await res.json().catch(() => ({})); + toast.error(parseUpdateError(err, failFallback)); + } + } catch (e: unknown) { + toast.error((e as Error)?.message || 'Something went wrong.'); + } finally { + setUpdatingNodeId(null); + } + }, []); + const triggerNodeUpdate = useCallback(async (nodeId: number) => { const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); // A pin we cannot repin (digest/unknown) has no update action; the button @@ -75,22 +152,14 @@ export function useFleetUpdateStatus() { return; } - setUpdatingNodeId(nodeId); - try { - const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status)); - if (res.ok) { - toast.success(`Update initiated on ${status?.name ?? 'node'}.`); - fetchUpdateStatus(); - } else { - const err = await res.json().catch(() => ({})); - toast.error(parseUpdateError(err, 'Failed to trigger update.')); - } - } catch (e: unknown) { - toast.error((e as Error)?.message || 'Something went wrong.'); - } finally { - setUpdatingNodeId(null); - } - }, [fetchUpdateStatus]); + await postRemoteAction( + nodeId, + `/fleet/nodes/${nodeId}/update`, + updateRequestInit(status), + `Update initiated on ${status?.name ?? 'node'}.`, + 'Failed to trigger update.', + ); + }, [postRemoteAction]); const confirmLocalUpdate = useCallback(async () => { const nodeId = localUpdateConfirm; @@ -99,35 +168,27 @@ export function useFleetUpdateStatus() { const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); if (toastIfUpdateBlocked(status)) return; - setUpdatingNodeId(nodeId); - try { - // Capture pre-update boot timestamp so the overlay can detect a real restart - // vs a false "online" response from the still-running old process mid-pull. - let bootBefore: number | null = null; - try { - const healthRes = await fetch('/api/health'); - if (healthRes.ok) { - const data = await healthRes.json(); - if (typeof data?.startedAt === 'number') bootBefore = data.startedAt; - } - } catch { /* fall back to offline-then-online detection */ } + await startLocalRestart( + nodeId, + `/fleet/nodes/${nodeId}/update`, + updateRequestInit(status), + 'update', + 'Failed to trigger local update.', + ); + }, [localUpdateConfirm, startLocalRestart]); - const res = await apiFetch(`/fleet/nodes/${nodeId}/update`, updateRequestInit(status)); - if (res.ok) { - setPreUpdateStartedAt(bootBefore); - setReconnecting(true); - } else { - // A blocked pin returns 409 fast (before any 202), so the overlay - // never starts here; surface the reason through the toast path. - const err = await res.json().catch(() => ({})); - toast.error(parseUpdateError(err, 'Failed to trigger local update.')); - } - } catch (e: unknown) { - toast.error((e as Error)?.message || 'Something went wrong.'); - } finally { - setUpdatingNodeId(null); + const triggerNodeReapply = useCallback((nodeId: number) => { + const status = updateStatusesRef.current.find(s => s.nodeId === nodeId); + if (!status) { + toast.error('Node status is unavailable. Recheck updates and try again.'); + return; } - }, [localUpdateConfirm]); + openReapplyConfirm({ + nodeId, + type: status.type === 'local' ? 'local' : 'remote', + name: status.name, + }); + }, [openReapplyConfirm]); const triggerUpdateAll = useCallback(async () => { try { @@ -176,13 +237,7 @@ export function useFleetUpdateStatus() { setCheckingUpdates(false); }, [fetchUpdateStatus]); - // While the reconnect overlay is up, poll the local node's update status. - // A pull/patch failure leaves the old gateway alive (no restart), so the - // overlay's health poll would sit for the full 5-minute timeout. Detecting - // the resolved `failed` status here dismisses the overlay fast and surfaces - // the error, instead of leaving the operator on the spinner. A genuine - // restart makes this endpoint unreachable (caught, keeps polling) and the - // overlay's own health poll reloads the page on success. + // Version-update reconnect failure poll (reapply uses useComposeReapplyAction). useEffect(() => { if (!reconnecting) return; const poll = setInterval(async () => { @@ -199,27 +254,37 @@ export function useFleetUpdateStatus() { toast.error(local.error || 'Local update failed. The server did not restart.'); } } catch (error) { - // Expected while the process restarts; the overlay's health poll - // drives the reload on success. console.warn('[Fleet] Reconnect status poll failed:', error); } }, 3000); return () => clearInterval(poll); }, [reconnecting]); + const reapplyConfirm = reapplyConfirmTarget?.nodeId ?? null; + const setReapplyConfirm = useCallback((nodeId: number | null) => { + if (nodeId === null) cancelReapplyConfirm(); + }, [cancelReapplyConfirm]); + return { updateStatuses, - updatingNodeId, - reconnecting, - preUpdateStartedAt, + updatingNodeId: updatingNodeId ?? reapplyBusyNodeId, + // Prefer reapply reconnect when active so overlay mode stays correct. + reconnecting: reconnecting || reapplyReconnecting, + preUpdateStartedAt: reapplyReconnecting ? reapplyPreStartedAt : preUpdateStartedAt, + reconnectMode: reapplyReconnecting ? 'reapply' as const : reconnectMode, localUpdateConfirm, + reapplyConfirm, + reapplyConfirmTarget, showUpdateModal, checkingUpdates, setShowUpdateModal, setLocalUpdateConfirm, + setReapplyConfirm, fetchUpdateStatus, triggerNodeUpdate, confirmLocalUpdate, + triggerNodeReapply, + confirmReapply, triggerUpdateAll, dismissNodeUpdate, retryNodeUpdate, diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index 08a1437b..c733a44f 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -61,6 +61,10 @@ export interface NodeUpdateStatus { updateBlockedReason?: string | null; /** Coarse image channel from meta/update-status. Hardened digests still POST. */ imageChannel?: 'community' | 'hardened' | 'unknown' | null; + /** Active fleet self-management operation, when a tracker is present. */ + operationKind?: 'update' | 'reapply_configuration' | null; + /** True when this Compose-managed node can reapply its on-disk configuration. */ + canReapplyCompose?: boolean; } export type ViewMode = 'grid' | 'topology'; diff --git a/frontend/src/components/__tests__/ComposeDiffPreviewDialog.test.tsx b/frontend/src/components/__tests__/ComposeDiffPreviewDialog.test.tsx new file mode 100644 index 00000000..1e1c24e3 --- /dev/null +++ b/frontend/src/components/__tests__/ComposeDiffPreviewDialog.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ComposeDiffPreviewDialog } from '../ComposeDiffPreviewDialog'; +import { resolveComposeDiffActionLabel } from '../resolveComposeDiffActionLabel'; + +vi.mock('@/lib/monacoLoader', () => ({ + DiffEditor: () =>
, +})); + +describe('resolveComposeDiffActionLabel', () => { + it('returns Save for save-only mode', () => { + expect(resolveComposeDiffActionLabel('save', false)).toBe('Save'); + expect(resolveComposeDiffActionLabel('save', true)).toBe('Save'); + }); + + it('returns Save when mode is undefined', () => { + expect(resolveComposeDiffActionLabel(undefined, true)).toBe('Save'); + }); + + it('returns Save & deploy for ordinary save-and-deploy', () => { + expect(resolveComposeDiffActionLabel('save-and-deploy', false)).toBe('Save & deploy'); + }); + + it('returns Save & reapply when self-stack reapply is eligible', () => { + expect(resolveComposeDiffActionLabel('save-and-deploy', true)).toBe('Save & reapply'); + }); +}); + +describe('ComposeDiffPreviewDialog', () => { + it('renders the Save & reapply confirm CTA', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Save & reapply' })).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/resolveComposeDiffActionLabel.ts b/frontend/src/components/resolveComposeDiffActionLabel.ts new file mode 100644 index 00000000..c8627643 --- /dev/null +++ b/frontend/src/components/resolveComposeDiffActionLabel.ts @@ -0,0 +1,11 @@ +export type ComposeDiffActionLabel = 'Save' | 'Save & deploy' | 'Save & reapply'; + +/** Maps diff preview mode + self-stack eligibility to the confirm CTA label. */ +export function resolveComposeDiffActionLabel( + mode: 'save' | 'save-and-deploy' | undefined, + canSaveAndReapply: boolean, +): ComposeDiffActionLabel { + if (mode !== 'save-and-deploy') return 'Save'; + if (canSaveAndReapply) return 'Save & reapply'; + return 'Save & deploy'; +} diff --git a/frontend/src/components/stack/SelfStackProtectedDialog.tsx b/frontend/src/components/stack/SelfStackProtectedDialog.tsx index 6bd44f79..2340a94c 100644 --- a/frontend/src/components/stack/SelfStackProtectedDialog.tsx +++ b/frontend/src/components/stack/SelfStackProtectedDialog.tsx @@ -40,8 +40,11 @@ export function SelfStackProtectedDialog({ }} >

- This stack is the running Sencho instance. Use Fleet -> Node Update to update Sencho. - To manage it as a normal stack, move Sencho's compose project outside COMPOSE_DIR. + This stack is the running Sencho instance. Destructive lifecycle actions + stay protected. Eligible admins can use Save & Reapply in the Compose + editor, or Fleet -> Node Updates, to recreate Sencho from its current + Compose configuration. To manage it as a normal stack, move Sencho's + compose project outside COMPOSE_DIR.

);