From 9922d8e765a7fbd917cec1473153fa462a46d0cd Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 09:42:14 -0400 Subject: [PATCH 01/31] feat(rbac): make stack-scoped grants node-specific (#1727) * feat(rbac): make stack-scoped grants node-specific Qualify stack role assignments as (nodeId, stackName), migrate legacy rows to the default node, and forward bound multi-action evidence on Proxy/Pilot hops so scoped users keep least-privilege remote access without shipping the full grant table. * fix: mirror scoped-stack-auth-evidence capability to frontend, sanitize node id in role assignment log Backend added the scoped-stack-auth-evidence capability without the matching frontend entry, failing the capability parity test. The role assignment log also interpolated the node id without sanitizeForLog, unlike the rest of the line. * fix(rbac): honor node-wide scopes and fix proxied DELETE cleanup Node-scoped grants now authorize that role's stack actions on the same node in the backend resolver, frontend can(), and remote evidence. Proxied DELETE cleanup uses the gate-stashed route because pathRewrite mutates req.path before proxyRes. Add proxy integration coverage and drop the stale scoped-permissions screenshot. * fix(rbac): preserve node-qualified grants during repair --- .../blueprints-remote-deploy.test.ts | 10 +- backend/src/__tests__/blueprints.test.ts | 47 +- .../__tests__/permissions-stack-rbac.test.ts | 236 ++++++++++ .../proxy-scoped-stack-evidence.test.ts | 327 +++++++++++++ .../role-assignments-node-qualified.test.ts | 430 ++++++++++++++++++ .../scoped-stack-auth-evidence.test.ts | 127 ++++++ ...stack-delete-cascades-mesh-opt-out.test.ts | 9 +- backend/src/__tests__/stackRouteAuth.test.ts | 155 +++++++ backend/src/__tests__/users-rbac.test.ts | 174 +++++-- .../src/helpers/assertStackExistsOnNode.ts | 81 ++++ backend/src/helpers/stackRouteAuth.ts | 224 +++++++++ backend/src/middleware/auth.ts | 25 +- backend/src/middleware/permissions.ts | 81 +++- backend/src/proxy/remoteNodeProxy.ts | 96 +++- backend/src/routes/permissions.ts | 4 +- backend/src/routes/users.ts | 74 ++- backend/src/services/BlueprintService.ts | 1 + backend/src/services/CapabilityRegistry.ts | 10 + backend/src/services/DatabaseService.ts | 158 ++++++- .../services/DeployedStackDeletionService.ts | 2 +- backend/src/services/license-headers.ts | 10 + backend/src/types/express.ts | 17 + docs/features/rbac.mdx | 21 +- docs/images/rbac/scoped-permissions.png | Bin 31916 -> 0 bytes frontend/src/components/EditorLayout.tsx | 3 +- .../components/EditorLayout/EditorView.tsx | 10 +- .../EditorLayout/MobileStackDetail.tsx | 4 +- .../components/EditorLayout/ShellOverlays.tsx | 6 +- .../EditorLayout/editor-view-blocks.tsx | 4 +- .../hooks/useSidebarContextMenu.ts | 8 +- .../components/networking/NetworkingView.tsx | 2 +- .../src/components/settings/UsersSection.tsx | 120 ++++- .../src/components/stack/EnvironmentPanel.tsx | 2 +- frontend/src/context/AuthContext.tsx | 23 +- frontend/src/lib/__tests__/resolveCan.test.ts | 79 ++++ frontend/src/lib/capabilities.ts | 2 + frontend/src/lib/resolveCan.ts | 48 ++ 37 files changed, 2487 insertions(+), 143 deletions(-) create mode 100644 backend/src/__tests__/permissions-stack-rbac.test.ts create mode 100644 backend/src/__tests__/proxy-scoped-stack-evidence.test.ts create mode 100644 backend/src/__tests__/role-assignments-node-qualified.test.ts create mode 100644 backend/src/__tests__/scoped-stack-auth-evidence.test.ts create mode 100644 backend/src/__tests__/stackRouteAuth.test.ts create mode 100644 backend/src/helpers/assertStackExistsOnNode.ts create mode 100644 backend/src/helpers/stackRouteAuth.ts delete mode 100644 docs/images/rbac/scoped-permissions.png create mode 100644 frontend/src/lib/__tests__/resolveCan.test.ts create mode 100644 frontend/src/lib/resolveCan.ts diff --git a/backend/src/__tests__/blueprints-remote-deploy.test.ts b/backend/src/__tests__/blueprints-remote-deploy.test.ts index 9bf91e72..534a536e 100644 --- a/backend/src/__tests__/blueprints-remote-deploy.test.ts +++ b/backend/src/__tests__/blueprints-remote-deploy.test.ts @@ -263,7 +263,7 @@ describe('BlueprintService remote deploy', () => { expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed'); }); - it('does not clear hub role assignments when withdrawing a remote deployment', async () => { + it('clears hub role assignments for the withdrawn remote stack tuple', async () => { const bcrypt = await import('bcrypt'); const db = DatabaseService.getInstance(); const node = seedRemoteNode(); @@ -282,20 +282,20 @@ describe('BlueprintService remote deploy', () => { username: `remote-wd-rbac-${counter}`, password_hash: hash, role: 'viewer', }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name, node_id: node.id, }); vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } }); const delSpy = vi.spyOn(axios, 'delete'); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj); expect(result.status).toBe('withdrawn'); expect(delSpy).not.toHaveBeenCalled(); - expect(rbacSpy).not.toHaveBeenCalled(); + expect(rbacSpy).toHaveBeenCalledWith(node.id, bpObj.name); expect(db.getAllRoleAssignments(userId) - .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true); + .some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name && a.node_id === node.id)).toBe(false); db.deleteUser(userId); }); diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts index 261a0e22..dffbafc8 100644 --- a/backend/src/__tests__/blueprints.test.ts +++ b/backend/src/__tests__/blueprints.test.ts @@ -493,10 +493,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const userId = db.addUser({ username: `bp-rbac-${counter}`, password_hash: hash, role: 'viewer' }); const otherNodeId = seedNode(); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bp.name, node_id: nodeId, }); db.addRoleAssignment({ - user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', + user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: nodeId, }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId), @@ -552,33 +552,42 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments', const { bp, node, nodeId, userId, deleteStackSpy, db } = await arrangeLocalWithdraw(); const fsErr = Object.assign(new Error('permission denied'), { code: 'EACCES' }); deleteStackSpy.mockRejectedValue(fsErr); - const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource'); + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack'); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(rbacSpy).not.toHaveBeenCalled(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(rbacSpy).not.toHaveBeenCalled(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + db.deleteUser(userId); + } }); it('fails withdraw and keeps the deployment when role-assignment cleanup throws', async () => { const { bp, node, nodeId, userId, db } = await arrangeLocalWithdraw(); - vi.spyOn(db, 'deleteRoleAssignmentsByResource') + const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByStack') .mockImplementation(() => { throw new Error('simulated rbac cleanup failure'); }); const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); + try { + const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node); - expect(outcome.status).toBe('failed'); - expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); - expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); - expect(errorSpy.mock.calls.some((args) => - typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), - )).toBe(true); - expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); - db.deleteUser(userId); + expect(outcome.status).toBe('failed'); + expect(db.getDeployment(bp.id, nodeId)).toBeDefined(); + expect(db.getDeployment(bp.id, nodeId)?.status).toBe('failed'); + expect(errorSpy.mock.calls.some((args) => + typeof args[0] === 'string' && args[0].includes('Secondary DB cleanup failed'), + )).toBe(true); + expect(hasAssignment(userId, 'stack', bp.name)).toBe(true); + } finally { + rbacSpy.mockRestore(); + errorSpy.mockRestore(); + db.deleteUser(userId); + } }); }); diff --git a/backend/src/__tests__/permissions-stack-rbac.test.ts b/backend/src/__tests__/permissions-stack-rbac.test.ts new file mode 100644 index 00000000..33d5bae9 --- /dev/null +++ b/backend/src/__tests__/permissions-stack-rbac.test.ts @@ -0,0 +1,236 @@ +/** + * Unit tests for checkPermission evidence + scopedActionsForStack with + * node-qualified stack grants. Uses mocked Request objects where possible. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { Request } from 'express'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { + checkPermission, + scopedActionsForStack, + type PermissionAction, +} from '../middleware/permissions'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let viewerId: number; +let defaultNodeId: number; +let otherNodeId: number; + +function mockReq(partial: { + userId: number; + role: 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + username?: string; + nodeId?: number; + proxyTier?: 'paid' | 'community'; + scopedStackEvidence?: { + stackName: string; + actions: ReadonlySet; + }; +}): Request { + return { + user: { + username: partial.username ?? 'test-user', + role: partial.role, + userId: partial.userId, + }, + nodeId: partial.nodeId ?? defaultNodeId, + proxyTier: partial.proxyTier ?? 'paid', + scopedStackEvidence: partial.scopedStackEvidence, + } as Request; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + defaultNodeId = db.getDefaultNode()!.id!; + otherNodeId = db.addNode({ + name: 'perm-other-node', + type: 'remote', + api_url: 'http://192.168.1.60:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'perm-viewer', password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + const db = DatabaseService.getInstance(); + db.deleteRoleAssignmentsByUser(viewerId); + db.deleteUser(viewerId); + db.deleteNode(otherNodeId); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scopedActionsForStack', () => { + it('includes edit and deploy for a node-admin stack assignment', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'actions-stack', + node_id: defaultNodeId, + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'actions-stack'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).toContain('stack:read'); + expect(actions).toContain('stack:delete'); + expect(actions).toContain('stack:create'); + expect(actions).not.toContain('node:manage'); + expect(actions).not.toContain('system:users'); + expect(actions.every((a) => a.startsWith('stack:'))).toBe(true); + + expect(scopedActionsForStack(viewerId, otherNodeId, 'actions-stack')).toEqual([]); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'actions-stack'); + }); +}); + +describe('checkPermission with node-scoped stack grants', () => { + it('viewer + scoped deploy grant succeeds for stack:deploy when req.nodeId matches', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'deploy-me', + node_id: defaultNodeId, + }); + + const req = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:read', 'stack', 'deploy-me')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'deploy-me')).toBe(false); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:deploy', 'stack', 'deploy-me')).toBe(false); + + db.deleteRoleAssignmentsByStack(defaultNodeId, 'deploy-me'); + }); + + it('node-scoped Node Admin authorizes stack actions on that node only', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const sameNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: defaultNodeId }); + expect(checkPermission(sameNode, 'stack:edit', 'stack', 'any-stack')).toBe(true); + expect(checkPermission(sameNode, 'stack:deploy', 'stack', 'other-stack')).toBe(true); + expect(checkPermission(sameNode, 'node:manage', 'node', String(defaultNodeId))).toBe(true); + + const wrongNode = mockReq({ userId: viewerId, role: 'viewer', nodeId: otherNodeId }); + expect(checkPermission(wrongNode, 'stack:edit', 'stack', 'any-stack')).toBe(false); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); + +describe('scopedActionsForStack with node-scoped grants', () => { + it('includes stack:* actions from a node-wide grant on the same node', () => { + const db = DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(defaultNodeId), + }); + + const actions = scopedActionsForStack(viewerId, defaultNodeId, 'fleet-wide'); + expect(actions).toContain('stack:edit'); + expect(actions).toContain('stack:deploy'); + expect(actions).not.toContain('node:manage'); + expect(scopedActionsForStack(viewerId, otherNodeId, 'fleet-wide')).toEqual([]); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(defaultNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); + +describe('checkPermission scopedStackEvidence', () => { + it('allows when action is a member of the evidenced set for the same stack', () => { + // Use actions the global viewer role does not already grant so the + // evidence path is what authorizes (not ROLE_PERMISSIONS.viewer). + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'stack:edit']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(true); + }); + + it('denies when the action is absent from the evidenced set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:read']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:edit', 'stack', 'evidenced')).toBe(false); + }); + + it('denies when the stack name does not match evidence', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy']), + }, + }); + expect(checkPermission(req, 'stack:deploy', 'stack', 'other-stack')).toBe(false); + }); + + it('ignores evidence for unscoped checks (no resourceType/resourceId)', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users']), + }, + }); + expect(checkPermission(req, 'stack:deploy')).toBe(false); + expect(checkPermission(req, 'system:users')).toBe(false); + }); + + it('does not honor non-stack actions even when present in the evidence set', () => { + const req = mockReq({ + userId: 0, + role: 'viewer', + scopedStackEvidence: { + stackName: 'evidenced', + actions: new Set(['stack:deploy', 'system:users', 'node:manage']), + }, + }); + expect(checkPermission(req, 'system:users', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'node:manage', 'stack', 'evidenced')).toBe(false); + expect(checkPermission(req, 'stack:deploy', 'stack', 'evidenced')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts new file mode 100644 index 00000000..06d3dc27 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-stack-evidence.test.ts @@ -0,0 +1,327 @@ +/** + * Orchestrated hub → remote proxy coverage for scoped stack elevation and + * DELETE tuple cleanup. Exercises createRemoteProxyMiddleware through + * live loopback remotes (not helper-only unit tests). + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let viewerId: number; +let evidenceServer: http.Server; +let noEvidenceServer: http.Server; +let failDeleteServer: http.Server; +let wrongNodeServer: http.Server; +let evidenceNodeId: number; +let noEvidenceNodeId: number; +let failDeleteNodeId: number; +let wrongNodeId: number; + +interface CapturedHop { + method: string; + url: string; + stackNameHeader: string | undefined; + stackActionsHeader: string | undefined; +} + +const evidenceHops: CapturedHop[] = []; +const failDeleteHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + stackNameHeader: req.headers[PROXY_SCOPED_STACK_NAME_HEADER] as string | undefined, + stackActionsHeader: req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER] as string | undefined, + }); +} + +function evidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, evidenceHops); + if (req.method === 'DELETE') { + res.writeHead(204); + res.end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function noEvidenceRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +function failDeleteRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'], + })); + return; + } + captureHop(req, failDeleteHops); + if (req.method === 'DELETE') { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream delete failed' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ username: 'scoped-proxy-viewer', password_hash: hash, role: 'viewer' }); + const viewer = db.getUserByUsername('scoped-proxy-viewer')!; + viewerBearer = jwt.sign( + { username: 'scoped-proxy-viewer', role: 'viewer', tv: viewer.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + evidenceServer = evidenceRemote(); + noEvidenceServer = noEvidenceRemote(); + failDeleteServer = failDeleteRemote(); + wrongNodeServer = evidenceRemote(); + const evidencePort = await listen(evidenceServer); + const noEvidencePort = await listen(noEvidenceServer); + const failDeletePort = await listen(failDeleteServer); + const wrongNodePort = await listen(wrongNodeServer); + + evidenceNodeId = db.addNode({ + name: 'evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${evidencePort}`, + api_token: 'evidence-token', + }); + noEvidenceNodeId = db.addNode({ + name: 'no-evidence-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${noEvidencePort}`, + api_token: 'no-evidence-token', + }); + failDeleteNodeId = db.addNode({ + name: 'fail-delete-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${failDeletePort}`, + api_token: 'fail-delete-token', + }); + wrongNodeId = db.addNode({ + name: 'wrong-node-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${wrongNodePort}`, + api_token: 'wrong-node-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => evidenceServer.close(() => resolve())); + await new Promise((resolve) => noEvidenceServer.close(() => resolve())); + await new Promise((resolve) => failDeleteServer.close(() => resolve())); + await new Promise((resolve) => wrongNodeServer.close(() => resolve())); + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('remote proxy scoped stack evidence and DELETE cleanup', () => { + it('elevates a matching stack grant and forwards bound evidence headers', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/shared-name/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('shared-name'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).not.toContain('node:manage'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies the same stack name on a node without a grant', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'shared-name', + node_id: evidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/shared-name/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(wrongNodeId)); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByStack(evidenceNodeId, 'shared-name'); + }); + + it('denies scoped elevation when the remote lacks scoped-stack-auth-evidence', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'deployer', + resource_type: 'stack', + resource_id: 'needs-evidence', + node_id: noEvidenceNodeId, + }); + + const res = await request(app) + .post('/api/stacks/needs-evidence/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(noEvidenceNodeId)); + + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/scoped stack authorization/i); + + db.deleteRoleAssignmentsByStack(noEvidenceNodeId, 'needs-evidence'); + }); + + it('clears the hub grant tuple after a successful remote DELETE', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'doomed', + node_id: evidenceNodeId, + }); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(1); + + const res = await request(app) + .delete('/api/stacks/doomed') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(204); + expect( + db.getRoleAssignments(viewerId, 'stack', 'doomed', evidenceNodeId), + ).toHaveLength(0); + }); + + it('preserves the hub grant tuple when remote DELETE is non-2xx', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'stack', + resource_id: 'keep-me', + node_id: failDeleteNodeId, + }); + + const res = await request(app) + .delete('/api/stacks/keep-me') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(failDeleteNodeId)); + + expect(res.status).toBe(500); + expect( + db.getRoleAssignments(viewerId, 'stack', 'keep-me', failDeleteNodeId), + ).toHaveLength(1); + + db.deleteRoleAssignmentsByStack(failDeleteNodeId, 'keep-me'); + }); + + it('builds evidence from a node-scoped grant on the target node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(evidenceNodeId), + }); + evidenceHops.length = 0; + + const res = await request(app) + .post('/api/stacks/node-wide-stack/deploy') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(evidenceNodeId)); + + expect(res.status).toBe(200); + const hop = evidenceHops.find((h) => h.url?.includes('/stacks/node-wide-stack/deploy')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('node-wide-stack'); + expect(hop!.stackActionsHeader).toContain('stack:deploy'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + const assignments = db.getAllRoleAssignments(viewerId).filter( + (a) => a.resource_type === 'node' && a.resource_id === String(evidenceNodeId), + ); + for (const a of assignments) db.deleteRoleAssignment(a.id!); + }); +}); diff --git a/backend/src/__tests__/role-assignments-node-qualified.test.ts b/backend/src/__tests__/role-assignments-node-qualified.test.ts new file mode 100644 index 00000000..4fdd7cf6 --- /dev/null +++ b/backend/src/__tests__/role-assignments-node-qualified.test.ts @@ -0,0 +1,430 @@ +/** + * migrateRoleAssignmentsNodeQualified: legacy rebuild, default remap, + * no-default omit, sqlite_master idempotency probe, unique indexes, + * deleteNode stack-grant cleanup, preserved ids/timestamps. + */ +import path from 'path'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; + +function resetDatabaseSingleton(): void { + const holder = DatabaseService as unknown as { instance?: DatabaseService }; + const existing = holder.instance; + if (existing) { + try { + existing.getDb().close(); + } catch { + // already closed + } + holder.instance = undefined; + } +} + +type IndexRow = { name: string; sql: string | null }; +type AssignmentRow = { + id: number; + user_id: number; + role: string; + resource_type: string; + resource_id: string; + node_id: number | null; + created_at: number; +}; + +function roleAssignmentsTableSql(raw: Database.Database): string { + return ( + (raw.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'", + ).get() as { sql: string } | undefined)?.sql ?? '' + ); +} + +function roleAssignmentIndexes(raw: Database.Database): Map { + const rows = raw.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'", + ).all() as IndexRow[]; + return new Map(rows.map((r) => [r.name, r.sql ?? ''])); +} + +/** Rewrite role_assignments to the pre-node_id schema and seed legacy rows. */ +function seedLegacyRoleAssignments( + dbPath: string, + seed: { + userId: number; + stackRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + nodeRows: Array<{ id: number; role: string; resource_id: string; created_at: number }>; + }, +): void { + const raw = new Database(dbPath); + try { + raw.exec('PRAGMA foreign_keys = OFF'); + raw.exec('DROP TABLE IF EXISTS role_assignments'); + raw.exec('DROP TABLE IF EXISTS role_assignments_new'); + raw.exec(` + CREATE TABLE role_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource + ON role_assignments(resource_type, resource_id); + `); + const insert = raw.prepare(` + INSERT INTO role_assignments (id, user_id, role, resource_type, resource_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const row of seed.stackRows) { + insert.run(row.id, seed.userId, row.role, 'stack', row.resource_id, row.created_at); + } + for (const row of seed.nodeRows) { + insert.run(row.id, seed.userId, row.role, 'node', row.resource_id, row.created_at); + } + } finally { + raw.close(); + } +} + +function removeRoleAssignmentsCheck(raw: Database.Database): void { + raw.exec(` + CREATE TABLE role_assignments_without_check ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + INSERT INTO role_assignments_without_check + SELECT * FROM role_assignments; + DROP TABLE role_assignments; + ALTER TABLE role_assignments_without_check RENAME TO role_assignments; + CREATE INDEX idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); +} + +function expectFinalSchema(raw: Database.Database): void { + const tableSql = roleAssignmentsTableSql(raw); + expect(tableSql).toContain("resource_type = 'stack' AND node_id IS NOT NULL"); + expect(tableSql).toContain("resource_type = 'node' AND node_id IS NULL"); + + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique).toMatch(/user_id/i); + expect(stackUnique).toMatch(/role/i); + expect(stackUnique).toMatch(/resource_type/i); + expect(stackUnique).toMatch(/resource_id/i); + expect(stackUnique).toMatch(/node_id/i); + expect(stackUnique).toMatch(/WHERE\s+resource_type\s*=\s*'stack'/i); + + expect(nodeUnique).toMatch(/user_id/i); + expect(nodeUnique).toMatch(/role/i); + expect(nodeUnique).toMatch(/resource_type/i); + expect(nodeUnique).toMatch(/resource_id/i); + expect(nodeUnique).toMatch(/WHERE\s+resource_type\s*=\s*'node'/i); + const nodeCols = nodeUnique.replace(/WHERE[\s\S]*/i, ''); + expect(nodeCols).not.toMatch(/node_id/); +} + +describe('migrateRoleAssignmentsNodeQualified', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await setupTestDb(); + }); + + afterEach(() => { + resetDatabaseSingleton(); + cleanupTestDb(tmpDir); + }); + + it('remaps legacy stack rows to the default node and preserves ids/timestamps', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-remote', + type: 'remote', + api_url: 'http://192.168.1.50:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-remap', password_hash: hash, role: 'viewer' }); + + const stackCreatedAt = 1_700_000_000_001; + const nodeCreatedAt = 1_700_000_000_002; + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 41, role: 'deployer', resource_id: 'web', created_at: stackCreatedAt }, + { id: 42, role: 'viewer', resource_id: 'api', created_at: stackCreatedAt + 1 }, + ], + nodeRows: [ + { id: 51, role: 'node-admin', resource_id: String(remoteNodeId), created_at: nodeCreatedAt }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const raw = migrated.getDb(); + expectFinalSchema(raw); + + const rows = raw.prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + expect(rows).toHaveLength(3); + + const stackWeb = rows.find((r) => r.id === 41)!; + expect(stackWeb.resource_type).toBe('stack'); + expect(stackWeb.resource_id).toBe('web'); + expect(stackWeb.node_id).toBe(defaultNodeId); + expect(stackWeb.created_at).toBe(stackCreatedAt); + expect(stackWeb.role).toBe('deployer'); + + const stackApi = rows.find((r) => r.id === 42)!; + expect(stackApi.node_id).toBe(defaultNodeId); + expect(stackApi.created_at).toBe(stackCreatedAt + 1); + + const nodeGrant = rows.find((r) => r.id === 51)!; + expect(nodeGrant.resource_type).toBe('node'); + expect(nodeGrant.node_id).toBeNull(); + expect(nodeGrant.created_at).toBe(nodeCreatedAt); + }); + + it('omits legacy stack rows when no default node exists', async () => { + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'mig-no-default-remote', + type: 'remote', + api_url: 'http://192.168.1.51:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-omit', password_hash: hash, role: 'viewer' }); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + expect(db.getDefaultNode()).toBeUndefined(); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 61, role: 'deployer', resource_id: 'orphan-stack', created_at: 99 }, + ], + nodeRows: [ + { id: 62, role: 'deployer', resource_id: String(remoteNodeId), created_at: 100 }, + ], + }); + + process.env.DATA_DIR = tmpDir; + const migrated = DatabaseService.getInstance(); + const rows = migrated.getDb().prepare( + 'SELECT * FROM role_assignments ORDER BY id', + ).all() as AssignmentRow[]; + + expect(rows).toHaveLength(1); + expect(rows[0].id).toBe(62); + expect(rows[0].resource_type).toBe('node'); + expect(rows[0].node_id).toBeNull(); + }); + + it('second init is idempotent via sqlite_master CHECK and partial-index WHERE probes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-idem', password_hash: hash, role: 'viewer' }); + + resetDatabaseSingleton(); + seedLegacyRoleAssignments(path.join(tmpDir, 'sencho.db'), { + userId, + stackRows: [ + { id: 71, role: 'deployer', resource_id: 'idem-stack', created_at: 200 }, + ], + nodeRows: [], + }); + + process.env.DATA_DIR = tmpDir; + const first = DatabaseService.getInstance(); + expectFinalSchema(first.getDb()); + const before = first.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(before.node_id).toBe(defaultNodeId); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const second = DatabaseService.getInstance(); + expectFinalSchema(second.getDb()); + const after = second.getDb().prepare( + 'SELECT id, node_id, created_at FROM role_assignments WHERE id = 71', + ).get() as { id: number; node_id: number; created_at: number }; + expect(after).toEqual(before); + + const stale = second.getDb().prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments_new'", + ).get(); + expect(stale).toBeUndefined(); + }); + + it('preserves node-qualified stack rows when repairing a missing index', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-remote', + type: 'remote', + api_url: 'http://192.168.1.54:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().exec('DROP INDEX idx_role_assignments_stack_unique'); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + + const rows = repaired.getAllRoleAssignments(userId); + expect(rows).toEqual(before); + }); + + it('preserves node-qualified rows without a default node when repairing the table check', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const remoteNodeId = db.addNode({ + name: 'mig-repair-no-default', + type: 'remote', + api_url: 'http://192.168.1.55:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-repair-check', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-stack', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'admin', resource_type: 'stack', + resource_id: 'remote-stack', node_id: remoteNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'viewer', resource_type: 'node', + resource_id: String(remoteNodeId), + }); + const before = db.getAllRoleAssignments(userId); + db.getDb().prepare('UPDATE nodes SET is_default = 0').run(); + removeRoleAssignmentsCheck(db.getDb()); + + resetDatabaseSingleton(); + process.env.DATA_DIR = tmpDir; + const repaired = DatabaseService.getInstance(); + expectFinalSchema(repaired.getDb()); + expect(repaired.getAllRoleAssignments(userId)).toEqual(before); + }); + + it('unique indexes use exact column sets including role', () => { + const raw = DatabaseService.getInstance().getDb(); + const indexes = roleAssignmentIndexes(raw); + const stackUnique = indexes.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUnique = indexes.get('idx_role_assignments_node_unique') ?? ''; + + expect(stackUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*,\s*node_id\s*\)/i, + ); + expect(nodeUnique.replace(/\s+/g, ' ')).toMatch( + /ON role_assignments\s*\(\s*user_id\s*,\s*role\s*,\s*resource_type\s*,\s*resource_id\s*\)/i, + ); + }); + + it('deleteNode clears stack grants by node_id and preserves other nodes', async () => { + const db = DatabaseService.getInstance(); + const defaultNodeId = db.getDefaultNode()!.id!; + const doomedId = db.addNode({ + name: 'mig-doomed', + type: 'remote', + api_url: 'http://192.168.1.52:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const survivorId = db.addNode({ + name: 'mig-survivor', + type: 'remote', + api_url: 'http://192.168.1.53:1852', + api_token: '', + compose_dir: '/tmp', + is_default: false, + }); + const hash = await bcrypt.hash('password123', 1); + const userId = db.addUser({ username: 'mig-delnode', password_hash: hash, role: 'viewer' }); + + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: doomedId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'shared-name', node_id: survivorId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'local-only', node_id: defaultNodeId, + }); + db.addRoleAssignment({ + user_id: userId, role: 'node-admin', resource_type: 'node', + resource_id: String(doomedId), + }); + + db.deleteNode(doomedId); + + const remaining = db.getAllRoleAssignments(userId); + expect(remaining.some((a) => a.node_id === doomedId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(doomedId))).toBe(false); + expect(remaining.some((a) => a.node_id === survivorId && a.resource_id === 'shared-name')).toBe(true); + expect(remaining.some((a) => a.node_id === defaultNodeId && a.resource_id === 'local-only')).toBe(true); + + db.deleteUser(userId); + db.deleteNode(survivorId); + }); +}); diff --git a/backend/src/__tests__/scoped-stack-auth-evidence.test.ts b/backend/src/__tests__/scoped-stack-auth-evidence.test.ts new file mode 100644 index 00000000..678db9d2 --- /dev/null +++ b/backend/src/__tests__/scoped-stack-auth-evidence.test.ts @@ -0,0 +1,127 @@ +/** + * Machine-auth scoped stack evidence: headers are trusted only under + * node_proxy / pilot_tunnel, and only when the name + actions pair is valid. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, + PROXY_ROLE_HEADER, +} from '../services/license-headers'; +import { checkPermission } from '../middleware/permissions'; + +let tmpDir: string; +let authMiddleware: typeof import('../middleware/auth').authMiddleware; + +function runAuth(req: Partial): Promise { + return new Promise((resolve, reject) => { + const fullReq = Object.assign( + { cookies: {} as Record, headers: {} as Record }, + req, + { + headers: { ...(req.headers ?? {}) }, + cookies: {}, + }, + ) as Request; + let settled = false; + const res = { + status: () => res, + json: (body: unknown) => { + if (!settled) { + settled = true; + reject(new Error(`authMiddleware rejected: ${JSON.stringify(body)}`)); + } + return res; + }, + } as unknown as Response; + const next: NextFunction = (err?: unknown) => { + if (settled) return; + settled = true; + if (err) reject(err); + else resolve(fullReq); + }; + void Promise.resolve(authMiddleware(fullReq, res, next)).catch(reject); + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ authMiddleware } = await import('../middleware/auth')); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +describe('scoped stack evidence under machine auth', () => { + it('attaches evidence for node_proxy when headers are valid', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,stack:deploy', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('web'); + expect(req.scopedStackEvidence?.actions.has('stack:edit')).toBe(true); + expect(req.scopedStackEvidence?.actions.has('stack:deploy')).toBe(true); + expect(checkPermission(req, 'stack:deploy', 'stack', 'web')).toBe(true); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(true); + }); + + it('attaches evidence for pilot_tunnel the same way', async () => { + const token = jwt.sign({ scope: 'pilot_tunnel' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'api', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:read', + }, + }); + expect(req.scopedStackEvidence?.stackName).toBe('api'); + expect(checkPermission(req, 'stack:read', 'stack', 'api')).toBe(true); + }); + + it('treats malformed actions as absent evidence', async () => { + const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_ROLE_HEADER]: 'viewer', + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:edit,not-real', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + expect(checkPermission(req, 'stack:edit', 'stack', 'web')).toBe(false); + }); + + it('ignores evidence headers on a user session JWT', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const { TEST_USERNAME } = await import('./helpers/setupTestDb'); + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME); + expect(user).toBeDefined(); + const token = jwt.sign( + { username: user!.username, role: user!.role, tv: user!.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const req = await runAuth({ + headers: { + authorization: `Bearer ${token}`, + [PROXY_SCOPED_STACK_NAME_HEADER]: 'web', + [PROXY_SCOPED_STACK_ACTIONS_HEADER]: 'stack:deploy,stack:edit', + }, + }); + expect(req.scopedStackEvidence).toBeUndefined(); + }); +}); diff --git a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts index e1ad3b0a..a188b776 100644 --- a/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts +++ b/backend/src/__tests__/stack-delete-cascades-mesh-opt-out.test.ts @@ -142,8 +142,9 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( name: 'stack-del-rbac-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack' }); + const defaultNodeId = db.getDefaultNode()!.id; + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'api', node_id: defaultNodeId }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'other-stack', node_id: defaultNodeId }); db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId) }); const res = await request(app) @@ -152,8 +153,8 @@ describe('DELETE /api/stacks/:stackName clears stack-scoped role assignments', ( expect(res.status).toBe(200); const remaining = db.getAllRoleAssignments(userId); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api')).toBe(false); - expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack')).toBe(true); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'api' && a.node_id === defaultNodeId)).toBe(false); + expect(remaining.some((a) => a.resource_type === 'stack' && a.resource_id === 'other-stack' && a.node_id === defaultNodeId)).toBe(true); expect(remaining.some((a) => a.resource_type === 'node' && a.resource_id === String(otherNodeId))).toBe(true); db.deleteUser(userId); diff --git a/backend/src/__tests__/stackRouteAuth.test.ts b/backend/src/__tests__/stackRouteAuth.test.ts new file mode 100644 index 00000000..4c115647 --- /dev/null +++ b/backend/src/__tests__/stackRouteAuth.test.ts @@ -0,0 +1,155 @@ +/** + * Pure classifyStackApiPath coverage for hub stack RBAC gating. + */ +import { describe, it, expect } from 'vitest'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, + parseScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import type { PermissionAction } from '../middleware/permissions'; + +describe('classifyStackApiPath', () => { + describe('named-stack families', () => { + it('maps read routes to stack:read', () => { + expect(classifyStackApiPath('GET', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('GET', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/drift/recheck')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + + it('maps edit routes to stack:edit', () => { + expect(classifyStackApiPath('PUT', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/env')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('PUT', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + expect(classifyStackApiPath('DELETE', '/stacks/web/git-source')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + + it('maps deploy routes and service lifecycle ops to stack:deploy', () => { + expect(classifyStackApiPath('POST', '/stacks/web/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/update')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/restart')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + }); + + it('maps stack DELETE to stack:delete', () => { + expect(classifyStackApiPath('DELETE', '/stacks/web')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:delete', + }); + }); + + it('treats git-source/apply primary as stack:edit', () => { + expect(classifyStackApiPath('POST', '/stacks/web/git-source/apply')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:edit', + }); + }); + }); + + describe('static exclusions', () => { + it('classifies collection and create paths as static', () => { + expect(classifyStackApiPath('GET', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/statuses')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/stacks/discovery')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/scan')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/import/move')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/bulk')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('POST', '/stacks/from-git')).toEqual({ kind: 'static' }); + }); + + it('classifies non-/stacks paths as static', () => { + expect(classifyStackApiPath('GET', '/nodes')).toEqual({ kind: 'static' }); + expect(classifyStackApiPath('GET', '/users')).toEqual({ kind: 'static' }); + }); + }); + + describe('encoding and trailing slashes', () => { + it('decodes percent-encoded stack names', () => { + expect(classifyStackApiPath('GET', '/stacks/my%2Dstack')).toEqual({ + kind: 'named-stack', stackName: 'my-stack', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web%5Fprod/deploy')).toEqual({ + kind: 'named-stack', stackName: 'web_prod', action: 'stack:deploy', + }); + }); + + it('strips trailing slashes before matching', () => { + expect(classifyStackApiPath('GET', '/stacks/web/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + expect(classifyStackApiPath('POST', '/stacks/web/deploy/')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:deploy', + }); + expect(classifyStackApiPath('GET', '/stacks/statuses/')).toEqual({ kind: 'static' }); + }); + + it('ignores query strings', () => { + expect(classifyStackApiPath('GET', '/stacks/web?nodeId=1')).toEqual({ + kind: 'named-stack', stackName: 'web', action: 'stack:read', + }); + }); + }); + + describe('fail-closed unknown-named', () => { + it('returns unknown-named for unrecognized /stacks//... suffixes', () => { + expect(classifyStackApiPath('GET', '/stacks/web/weird')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('POST', '/stacks/web/not-a-real-action')).toEqual({ + kind: 'unknown-named', + }); + expect(classifyStackApiPath('POST', '/stacks/web/services/api/recovery')).toEqual({ + kind: 'unknown-named', + }); + }); + + it('returns unknown-named for invalid stack name segments', () => { + expect(classifyStackApiPath('GET', '/stacks/bad name')).toEqual({ kind: 'unknown-named' }); + expect(classifyStackApiPath('GET', '/stacks/%2E%2E')).toEqual({ kind: 'unknown-named' }); + }); + }); +}); + +describe('scoped stack actions header encode/decode', () => { + it('round-trips a PermissionAction set', () => { + const actions: PermissionAction[] = ['stack:edit', 'stack:deploy', 'stack:read']; + const encoded = formatScopedStackActionsHeader(actions); + expect(parseScopedStackActionsHeader(encoded)).toEqual(actions); + }); + + it('returns null for malformed tokens', () => { + expect(parseScopedStackActionsHeader('stack:edit,not-a-real-action')).toBeNull(); + expect(parseScopedStackActionsHeader('')).toBeNull(); + expect(parseScopedStackActionsHeader(' ')).toBeNull(); + }); + + it('deduplicates while preserving first-seen order', () => { + expect(parseScopedStackActionsHeader('stack:edit,stack:deploy,stack:edit')).toEqual([ + 'stack:edit', + 'stack:deploy', + ]); + }); +}); diff --git a/backend/src/__tests__/users-rbac.test.ts b/backend/src/__tests__/users-rbac.test.ts index c5917576..8facd099 100644 --- a/backend/src/__tests__/users-rbac.test.ts +++ b/backend/src/__tests__/users-rbac.test.ts @@ -9,11 +9,22 @@ import bcrypt from 'bcrypt'; import crypto from 'crypto'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_PASSWORD, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { generateApiToken } from '../utils/apiTokenFormat'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; + +vi.mock('../helpers/assertStackExistsOnNode', () => ({ + assertStackExistsOnNode: vi.fn(async () => ({ ok: true as const })), +})); let tmpDir: string; let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +function defaultNodeId(): number { + const node = DatabaseService.getInstance().getDefaultNode(); + if (!node) throw new Error('test default node missing'); + return node.id; +} + /** Sign a JWT for a given user with optional token_version (tv). */ function authToken(username: string, role: string = 'admin', tv?: number): string { const payload: Record = { username, role }; @@ -383,20 +394,103 @@ describe('Scoped Role Assignments', () => { }); it('POST /api/users/:id/roles creates assignment (201)', async () => { + const nodeId = defaultNodeId(); const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: nodeId }); expect(res.status).toBe(201); expect(res.body.role).toBe('deployer'); expect(res.body.resource_type).toBe('stack'); + expect(res.body.node_id).toBe(nodeId); + }); + + it('POST /api/users/:id/roles rejects stack assignment without node_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'no-node-stack' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/node_id/i); + }); + + it('POST /api/users/:id/roles rejects when stack does not exist on node (400)', async () => { + vi.mocked(assertStackExistsOnNode).mockResolvedValueOnce({ + ok: false, + error: 'Stack not found on node', + }); + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'stack', + resource_id: 'missing-stack', + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not found/i); + }); + + it('POST /api/users/:id/roles rejects node_id qualifier on node assignments (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + node_id: defaultNodeId(), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/must not be set/i); + }); + + it('POST /api/users/:id/roles rejects nonexistent numeric node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: '999999', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Node not found/i); + }); + + it('POST /api/users/:id/roles creates node assignment without node_id (201)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: String(defaultNodeId()), + }); + expect(res.status).toBe(201); + expect(res.body.resource_type).toBe('node'); + expect(res.body.node_id).toBeNull(); + }); + + it('POST /api/users/:id/roles rejects a noncanonical node resource_id (400)', async () => { + const res = await request(app) + .post(`/api/users/${targetUserId}/roles`) + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + role: 'deployer', + resource_type: 'node', + resource_id: `0${defaultNodeId()}`, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/canonical/i); }); it('POST /api/users/:id/roles rejects duplicate (409)', async () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'test-stack', node_id: defaultNodeId() }); expect(res.status).toBe(409); }); @@ -420,7 +514,7 @@ describe('Scoped Role Assignments', () => { const res = await request(app) .post(`/api/users/${targetUserId}/roles`) .set('Authorization', `Bearer ${adminToken()}`) - .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack' }); + .send({ role: 'deployer', resource_type: 'stack', resource_id: 'community-stack', node_id: defaultNodeId() }); expect(res.status).toBe(403); expect(res.body.code).toBe('PAID_REQUIRED'); } finally { @@ -452,7 +546,8 @@ describe('GET /api/permissions/me', () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + const nodeId = defaultNodeId(); + db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack', node_id: nodeId }); const user = db.getUserById(id)!; const token = authToken('permcheck', 'viewer', user.token_version); @@ -461,7 +556,7 @@ describe('GET /api/permissions/me', () => { .set('Authorization', `Bearer ${token}`); expect(res.status).toBe(200); expect(res.body.globalRole).toBe('viewer'); - expect(res.body.scopedPermissions['stack:my-stack']).toBeDefined(); + expect(res.body.scopedPermissions[`stack:${nodeId}:my-stack`]).toBeDefined(); // Cleanup db.deleteRoleAssignmentsByUser(id); @@ -474,7 +569,13 @@ describe('GET /api/permissions/me', () => { const svc = LicenseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const id = db.addUser({ username: 'permcheck-community', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: id, role: 'deployer', resource_type: 'stack', resource_id: 'my-stack' }); + db.addRoleAssignment({ + user_id: id, + role: 'deployer', + resource_type: 'stack', + resource_id: 'my-stack', + node_id: defaultNodeId(), + }); const user = db.getUserById(id)!; const token = authToken('permcheck-community', 'viewer', user.token_version); @@ -689,53 +790,64 @@ describe('Atomic last-admin guard', () => { }); // ---- Orphaned Role Assignment Cleanup ---- +// Proxied remote stack DELETE clears hub grants only on 2xx in +// remoteNodeProxy (deleteRoleAssignmentsByStack). Non-2xx preserves rows. +// Orchestrated proxyRes coverage lives in proxy-scoped-stack-evidence.test.ts; +// these cases lock the DB helper isolation that the proxy calls. describe('Orphaned role assignment cleanup', () => { - it('deleting a node removes its role assignments', async () => { + it('deleting a node removes its node and stack role assignments', async () => { const db = DatabaseService.getInstance(); - // Create a test node - const nodeId = db.addNode({ name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', api_token: '', compose_dir: '/tmp', is_default: false }); - // Create a role assignment for this node + const nodeId = db.addNode({ + name: 'test-cleanup-node', type: 'remote', api_url: 'http://test:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'nodeorphan', password_hash: hash, role: 'viewer' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId), + }); + db.addRoleAssignment({ + user_id: userId, role: 'deployer', resource_type: 'stack', + resource_id: 'on-doomed', node_id: nodeId, + }); - // Verify assignment exists - const before = db.getAllRoleAssignments(userId); - expect(before.length).toBe(1); + expect(db.getAllRoleAssignments(userId)).toHaveLength(2); - // Delete the node db.deleteNode(nodeId); - // Assignments should be gone - const after = db.getAllRoleAssignments(userId); - expect(after.length).toBe(0); - - // Cleanup + expect(db.getAllRoleAssignments(userId)).toHaveLength(0); db.deleteUser(userId); }); - it('deleteRoleAssignmentsByResource removes only the matching resource tuple', async () => { + it('deleteRoleAssignmentsByStack clears only that node+stack tuple', async () => { const db = DatabaseService.getInstance(); const hash = await bcrypt.hash('password123', 1); const userId = db.addUser({ username: 'tupleorphan', password_hash: hash, role: 'viewer' }); - const nodeId = db.addNode({ - name: 'tuple-cleanup-node', type: 'remote', api_url: 'http://test:1852', + const nodeA = db.addNode({ + name: 'tuple-cleanup-node-a', type: 'remote', api_url: 'http://test-a:1852', api_token: '', compose_dir: '/tmp', is_default: false, }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'target-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack' }); - db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeId) }); + const nodeB = db.addNode({ + name: 'tuple-cleanup-node-b', type: 'remote', api_url: 'http://test-b:1852', + api_token: '', compose_dir: '/tmp', is_default: false, + }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'shared-name', node_id: nodeB }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: 'keep-stack', node_id: nodeA }); + db.addRoleAssignment({ user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(nodeA) }); - db.deleteRoleAssignmentsByResource('stack', 'target-stack'); + db.deleteRoleAssignmentsByStack(nodeA, 'shared-name'); const after = db.getAllRoleAssignments(userId); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'target-stack')).toBe(false); - expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack')).toBe(true); - expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeId))).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeA)).toBe(false); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'shared-name' && a.node_id === nodeB)).toBe(true); + expect(after.some((a) => a.resource_type === 'stack' && a.resource_id === 'keep-stack' && a.node_id === nodeA)).toBe(true); + expect(after.some((a) => a.resource_type === 'node' && a.resource_id === String(nodeA))).toBe(true); db.deleteUser(userId); - db.deleteNode(nodeId); + db.deleteNode(nodeA); + db.deleteNode(nodeB); }); }); diff --git a/backend/src/helpers/assertStackExistsOnNode.ts b/backend/src/helpers/assertStackExistsOnNode.ts new file mode 100644 index 00000000..6d4f0d0b --- /dev/null +++ b/backend/src/helpers/assertStackExistsOnNode.ts @@ -0,0 +1,81 @@ +import axios from 'axios'; +import { DatabaseService } from '../services/DatabaseService'; +import { FileSystemService } from '../services/FileSystemService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { PROXY_TIER_HEADER } from '../services/license-headers'; +import { LicenseService } from '../services/LicenseService'; +import { isValidStackName } from '../utils/validation'; +import { getErrorMessage } from '../utils/errors'; + +const REMOTE_STACKS_TIMEOUT_MS = 30_000; + +export type AssertStackExistsResult = + | { ok: true } + | { ok: false; error: string }; + +/** + * Verify that `stackName` exists on `nodeId` before inserting a stack-scoped + * role assignment. Local nodes use FileSystemService; remotes use a machine + * GET /api/stacks via NodeRegistry.getProxyTarget. + */ +export async function assertStackExistsOnNode( + nodeId: number, + stackName: string, +): Promise { + if (!isValidStackName(stackName)) { + return { ok: false, error: 'Invalid stack name' }; + } + + const node = DatabaseService.getInstance().getNode(nodeId); + if (!node) { + return { ok: false, error: 'Node not found' }; + } + + if (node.type === 'local') { + try { + const stacks = await FileSystemService.getInstance(nodeId).getStacks(); + if (!stacks.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Local stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on node' }; + } + } + + const target = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!target) { + return { ok: false, error: 'Remote node is unreachable' }; + } + + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers: Record = { + [PROXY_TIER_HEADER]: LicenseService.getInstance().getProxyHeaders().tier, + }; + if (target.apiToken) { + headers.Authorization = `Bearer ${target.apiToken}`; + } + + try { + const res = await axios.get(`${baseUrl}/api/stacks`, { + headers, + timeout: REMOTE_STACKS_TIMEOUT_MS, + validateStatus: () => true, + }); + if (res.status < 200 || res.status >= 300) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + if (!Array.isArray(res.data)) { + return { ok: false, error: 'Failed to verify stack on remote node' }; + } + const names = res.data.filter((n): n is string => typeof n === 'string'); + if (!names.includes(stackName)) { + return { ok: false, error: 'Stack not found on node' }; + } + return { ok: true }; + } catch (err) { + console.error('[assertStackExistsOnNode] Remote stack list failed:', getErrorMessage(err, 'unknown')); + return { ok: false, error: 'Failed to verify stack on remote node' }; + } +} diff --git a/backend/src/helpers/stackRouteAuth.ts b/backend/src/helpers/stackRouteAuth.ts new file mode 100644 index 00000000..76667df8 --- /dev/null +++ b/backend/src/helpers/stackRouteAuth.ts @@ -0,0 +1,224 @@ +import { isPermissionAction, type PermissionAction } from '../middleware/permissions'; +import { isValidStackName } from '../utils/validation'; + +export type StackRouteClassify = + | { kind: 'named-stack'; stackName: string; action: PermissionAction } + | { kind: 'static' } + | { kind: 'unknown-named' }; + +/** Static collection / create paths under /stacks (no stack-scoped resource). */ +const STATIC_STACK_PATHS = new Set([ + '/stacks', + '/stacks/', + '/stacks/statuses', + '/stacks/discovery', + '/stacks/import/scan', + '/stacks/import/move', + '/stacks/bulk', + '/stacks/from-git', +]); + +/** + * Exact relative suffixes under `/stacks/:name` mapped to the primary hub + * pre-check action. Service-name paths are matched separately via regex. + */ +type SuffixRule = { method: string; suffix: string; action: PermissionAction }; + +const EXACT_SUFFIX_RULES: readonly SuffixRule[] = [ + // Read + { method: 'GET', suffix: '', action: 'stack:read' }, + { method: 'GET', suffix: '/envs', action: 'stack:read' }, + { method: 'GET', suffix: '/env', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files', action: 'stack:read' }, + { method: 'GET', suffix: '/project-env-files/candidates', action: 'stack:read' }, + { method: 'GET', suffix: '/dossier', action: 'stack:read' }, + { method: 'GET', suffix: '/containers', action: 'stack:read' }, + { method: 'GET', suffix: '/services', action: 'stack:read' }, + { method: 'GET', suffix: '/drift', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight', action: 'stack:read' }, + { method: 'GET', suffix: '/missing-external-networks', action: 'stack:read' }, + { method: 'GET', suffix: '/preflight/acknowledgements', action: 'stack:read' }, + { method: 'GET', suffix: '/networking', action: 'stack:read' }, + { method: 'GET', suffix: '/storage', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-anatomy', action: 'stack:read' }, + { method: 'GET', suffix: '/effective-services', action: 'stack:read' }, + { method: 'GET', suffix: '/env-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/label-inventory', action: 'stack:read' }, + { method: 'GET', suffix: '/exposure', action: 'stack:read' }, + { method: 'GET', suffix: '/update-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/rollback-readiness', action: 'stack:read' }, + { method: 'GET', suffix: '/health-gate', action: 'stack:read' }, + { method: 'GET', suffix: '/update-preview', action: 'stack:read' }, + { method: 'GET', suffix: '/backup', action: 'stack:read' }, + { method: 'GET', suffix: '/scan-status', action: 'stack:read' }, + { method: 'GET', suffix: '/file-roots', action: 'stack:read' }, + { method: 'GET', suffix: '/files', action: 'stack:read' }, + { method: 'GET', suffix: '/files/content', action: 'stack:read' }, + { method: 'GET', suffix: '/files/download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/bulk-download', action: 'stack:read' }, + { method: 'GET', suffix: '/files/permissions', action: 'stack:read' }, + { method: 'GET', suffix: '/activity', action: 'stack:read' }, + { method: 'GET', suffix: '/git-source', action: 'stack:read' }, + + // Edit + { method: 'PUT', suffix: '', action: 'stack:edit' }, + { method: 'PUT', suffix: '/env', action: 'stack:edit' }, + { method: 'PUT', suffix: '/project-env-files', action: 'stack:edit' }, + { method: 'PUT', suffix: '/dossier', action: 'stack:edit' }, + { method: 'POST', suffix: '/drift/recheck', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/run', action: 'stack:read' }, + { method: 'POST', suffix: '/preflight/acknowledgements', action: 'stack:edit' }, + { method: 'PUT', suffix: '/exposure', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/upload', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/content', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/files', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/folder', action: 'stack:edit' }, + { method: 'PATCH', suffix: '/files/rename', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/copy', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-delete', action: 'stack:edit' }, + { method: 'POST', suffix: '/files/bulk-move', action: 'stack:edit' }, + { method: 'PUT', suffix: '/files/permissions', action: 'stack:edit' }, + { method: 'PUT', suffix: '/labels', action: 'stack:edit' }, + { method: 'PUT', suffix: '/git-source', action: 'stack:edit' }, + { method: 'DELETE', suffix: '/git-source', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/apply', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/webhook-pull', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/dismiss-pending', action: 'stack:edit' }, + { method: 'POST', suffix: '/git-source/browse', action: 'stack:edit' }, + + // Deploy + { method: 'POST', suffix: '/deploy', action: 'stack:deploy' }, + { method: 'POST', suffix: '/down', action: 'stack:deploy' }, + { method: 'POST', suffix: '/restart', action: 'stack:deploy' }, + { method: 'POST', suffix: '/stop', action: 'stack:deploy' }, + { method: 'POST', suffix: '/start', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update-preview', action: 'stack:deploy' }, + { method: 'POST', suffix: '/update', action: 'stack:deploy' }, + { method: 'POST', suffix: '/rollback', action: 'stack:deploy' }, + { method: 'POST', suffix: '/backup', action: 'stack:deploy' }, + + // Delete + { method: 'DELETE', suffix: '', action: 'stack:delete' }, +]; + +const EXACT_SUFFIX_INDEX = new Map( + EXACT_SUFFIX_RULES.map((r) => [`${r.method} ${r.suffix}`, r.action]), +); + +/** `/services/:serviceName/{restart|stop|start|update|restore|recovery}` */ +const SERVICE_SUFFIX_RE = + /^\/services\/[^/]+\/(restart|stop|start|update|restore|recovery)$/; + +/** `/preflight/acknowledgements/:id` */ +const PREFLIGHT_ACK_DELETE_RE = /^\/preflight\/acknowledgements\/[^/]+$/; + +function normalizePath(pathAfterApiStrip: string): string { + const withoutQuery = pathAfterApiStrip.split('?')[0] ?? pathAfterApiStrip; + if (withoutQuery.length > 1 && withoutQuery.endsWith('/')) { + return withoutQuery.slice(0, -1); + } + return withoutQuery; +} + +function decodeStackSegment(raw: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(raw); + } catch { + return null; + } + if (!isValidStackName(decoded)) return null; + return decoded; +} + +/** + * Classify a post-/api path for hub stack RBAC gating and evidence. + * Paths outside `/stacks` (and static `/stacks` collection routes) are + * `static`. Known named-stack families return the primary pre-check action. + * An unrecognized `/stacks//...` path fails closed as `unknown-named`. + */ +export function classifyStackApiPath(method: string, pathAfterApiStrip: string): StackRouteClassify { + const methodUpper = method.toUpperCase(); + const path = normalizePath(pathAfterApiStrip); + + if (!path.startsWith('/stacks')) { + return { kind: 'static' }; + } + + if (STATIC_STACK_PATHS.has(path) || (methodUpper === 'POST' && path === '/stacks')) { + return { kind: 'static' }; + } + + // Reserved first segments that look like names but are collection routes. + if ( + path === '/stacks/statuses' + || path === '/stacks/discovery' + || path.startsWith('/stacks/import/') + || path === '/stacks/bulk' + || path === '/stacks/from-git' + ) { + return { kind: 'static' }; + } + + const match = /^\/stacks\/([^/]+)(.*)$/.exec(path); + if (!match) { + return { kind: 'static' }; + } + + const stackName = decodeStackSegment(match[1]); + if (!stackName) { + return { kind: 'unknown-named' }; + } + + const suffix = match[2] ?? ''; + + const exact = EXACT_SUFFIX_INDEX.get(`${methodUpper} ${suffix}`); + if (exact) { + return { kind: 'named-stack', stackName, action: exact }; + } + + if (methodUpper === 'POST' && SERVICE_SUFFIX_RE.test(suffix)) { + const op = SERVICE_SUFFIX_RE.exec(suffix)?.[1]; + if (op === 'recovery') { + // recovery is GET-only in stacks.ts; POST recovery is unknown + return { kind: 'unknown-named' }; + } + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'GET' && /^\/services\/[^/]+\/recovery$/.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:deploy' }; + } + + if (methodUpper === 'DELETE' && PREFLIGHT_ACK_DELETE_RE.test(suffix)) { + return { kind: 'named-stack', stackName, action: 'stack:edit' }; + } + + return { kind: 'unknown-named' }; +} + +/** + * Parse the comma-separated scoped-actions header. Returns null when empty + * or when any token is not a known PermissionAction. + */ +export function parseScopedStackActionsHeader(value: string): PermissionAction[] | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parts = trimmed.split(',').map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return null; + const actions: PermissionAction[] = []; + const seen = new Set(); + for (const part of parts) { + if (!isPermissionAction(part)) return null; + if (seen.has(part)) continue; + seen.add(part); + actions.push(part); + } + return actions; +} + +/** Serialize PermissionAction values for the scoped-actions proxy header. */ +export function formatScopedStackActionsHeader(actions: Iterable): string { + return [...new Set(actions)].join(','); +} diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 86434dc2..c23da8ed 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,10 +8,20 @@ import { type ApiTokenScope, } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, + isDeploySourceHeader, +} from '../services/license-headers'; import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError'; import { isLicenseTier, normalizeTier } from '../services/license-normalize'; import { isDebugEnabled } from '../utils/debug'; +import { parseScopedStackActionsHeader } from '../helpers/stackRouteAuth'; +import { isValidStackName } from '../utils/validation'; import { COOKIE_NAME, MFA_PENDING_COOKIE_NAME, @@ -150,6 +160,19 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response req.deployContext = ctx; } + // Scoped stack auth evidence: only trust on this machine-auth path. + // Malformed or incomplete pairs are treated as absent (never as auth). + const scopedNameRaw = req.headers[PROXY_SCOPED_STACK_NAME_HEADER]; + const scopedActionsRaw = req.headers[PROXY_SCOPED_STACK_ACTIONS_HEADER]; + const scopedName = typeof scopedNameRaw === 'string' ? scopedNameRaw.trim() : ''; + const scopedActionsStr = typeof scopedActionsRaw === 'string' ? scopedActionsRaw : ''; + if (scopedName && isValidStackName(scopedName) && scopedActionsStr) { + const actions = parseScopedStackActionsHeader(scopedActionsStr); + if (actions && actions.length > 0) { + req.scopedStackEvidence = { stackName: scopedName, actions: new Set(actions) }; + } + } + next(); return; } diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 3c558205..1ccbe215 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -34,6 +34,50 @@ export const ROLE_PERMISSIONS: Record = { ], }; +/** Canonical PermissionAction set (admin matrix covers every action). */ +export const ALL_PERMISSION_ACTIONS: readonly PermissionAction[] = ROLE_PERMISSIONS.admin; + +export function isPermissionAction(value: string): value is PermissionAction { + return (ALL_PERMISSION_ACTIONS as readonly string[]).includes(value); +} + +/** + * Collect stack:* actions from role matrices. Used for remote evidence so + * node:/system: never leave the hub on a machine-auth hop. + */ +function addStackActionsFromRole( + actions: Set, + role: UserRole, +): void { + for (const action of ROLE_PERMISSIONS[role] ?? []) { + if (action.startsWith('stack:')) { + actions.add(action); + } + } +} + +/** + * Union of PermissionAction values conferred by the user's exact stack + * grant for (nodeId, stackName), plus any node-scoped grant on that node + * (node-wide roles cover every stack on the node). Used when the hub + * builds bound evidence for a remote hop. + */ +export function scopedActionsForStack( + userId: number, + nodeId: number, + stackName: string, +): PermissionAction[] { + const db = DatabaseService.getInstance(); + const actions = new Set(); + for (const assignment of db.getRoleAssignments(userId, 'stack', stackName, nodeId)) { + addStackActionsFromRole(actions, assignment.role); + } + for (const assignment of db.getRoleAssignments(userId, 'node', String(nodeId))) { + addStackActionsFromRole(actions, assignment.role); + } + return [...actions]; +} + /** Core permission resolver. Admin bypasses all checks; scoped assignments only apply on the paid tier. */ export function checkPermission( req: Request, @@ -51,14 +95,49 @@ export function checkPermission( if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true; if (!resourceType || !resourceId) return false; + + // Bound machine evidence from the hub (node_proxy / pilot_tunnel only). + // Authorizes exact stack + action members of the evidenced set without a + // local role_assignments row (remote userId is 0). + const evidence = req.scopedStackEvidence; + if ( + evidence + && resourceType === 'stack' + && resourceId === evidence.stackName + && action.startsWith('stack:') + && evidence.actions.has(action) + ) { + return true; + } + if (effectiveTier(req) !== 'paid') return false; - const assignments = DatabaseService.getInstance().getRoleAssignments(req.user.userId, resourceType, resourceId); + const db = DatabaseService.getInstance(); + const nodeId = resourceType === 'stack' ? req.nodeId : null; + const assignments = db.getRoleAssignments( + req.user.userId, + resourceType, + resourceId, + nodeId, + ); if (isDebugEnabled()) console.log('[RBAC:diag] Scoped assignments found:', assignments.length, 'for user:', req.user.userId); for (const assignment of assignments) { if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; } + // Node-scoped grants are node-wide: a Node Admin / Deployer / Admin on + // node N authorizes that role's stack actions for every stack on N. + if (resourceType === 'stack' && req.nodeId != null) { + const nodeAssignments = db.getRoleAssignments( + req.user.userId, + 'node', + String(req.nodeId), + ); + for (const assignment of nodeAssignments) { + if (ROLE_PERMISSIONS[assignment.role]?.includes(action)) return true; + } + } + return false; } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index abef7123..06f27a7b 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -1,17 +1,38 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { NodeRegistry } from '../services/NodeRegistry'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers'; +import { + PROXY_TIER_HEADER, + PROXY_ROLE_HEADER, + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, + PROXY_SCOPED_STACK_NAME_HEADER, + PROXY_SCOPED_STACK_ACTIONS_HEADER, +} from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities'; -import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, SERVICE_SCOPED_UPDATE_CAPABILITY, SERVICE_SCOPED_STACK_ALERT_CAPABILITY } from '../services/CapabilityRegistry'; +import { + STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, + SERVICE_SCOPED_UPDATE_CAPABILITY, + SERVICE_SCOPED_STACK_ALERT_CAPABILITY, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, +} from '../services/CapabilityRegistry'; import { getErrorMessage } from '../utils/errors'; import { DatabaseService } from '../services/DatabaseService'; import { redactSensitiveText } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; import { logDebugTiming, templatizeHydrationPath } from '../utils/requestTiming'; import { invalidateFleetUpdateCache, isFullStackUpdatePath, isUpdatePreviewPath } from '../helpers/fleetUpdateCache'; +import { + classifyStackApiPath, + formatScopedStackActionsHeader, +} from '../helpers/stackRouteAuth'; +import { + checkPermission, + ROLE_PERMISSIONS, + scopedActionsForStack, +} from '../middleware/permissions'; /** * Per-request hop timing for the critical hydration GETs, kept off the Request @@ -132,6 +153,17 @@ export function createRemoteProxyMiddleware(): RequestHandler { if (req.user?.username) { proxyReq.setHeader(PROXY_DEPLOY_ACTOR_HEADER, req.user.username); } + // Scoped stack evidence: always strip client-supplied values, then + // attach hub-built evidence when the gate stashed elevation for this hop. + proxyReq.removeHeader(PROXY_SCOPED_STACK_NAME_HEADER); + proxyReq.removeHeader(PROXY_SCOPED_STACK_ACTIONS_HEADER); + if (req.proxyScopedStackEvidence) { + proxyReq.setHeader(PROXY_SCOPED_STACK_NAME_HEADER, req.proxyScopedStackEvidence.stackName); + proxyReq.setHeader( + PROXY_SCOPED_STACK_ACTIONS_HEADER, + formatScopedStackActionsHeader(req.proxyScopedStackEvidence.actions), + ); + } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request @@ -188,6 +220,23 @@ export function createRemoteProxyMiddleware(): RequestHandler { ) { invalidateFleetUpdateCache(); } + // Successful remote stack DELETE: clear hub grants for this (node, stack) + // only. Failed / non-2xx responses must preserve assignments. Use the + // gate-stashed classification: pathRewrite mutates req.url before this + // callback, so re-running classifyStackApiPath(req.path) would miss. + if (req.method === 'DELETE' && status >= 200 && status < 300) { + const route = req.proxyNamedStackRoute; + if (route?.action === 'stack:delete') { + try { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(req.nodeId, route.stackName); + } catch (cleanupErr) { + console.warn( + '[Proxy] Failed to clear role assignments after remote stack delete:', + getErrorMessage(cleanupErr, 'unknown'), + ); + } + } + } }, error: (err, req, proxyRes) => { // Finalize the hop timing with an error outcome before the existing @@ -282,6 +331,49 @@ export function createRemoteProxyMiddleware(): RequestHandler { } } + // Named-stack hub pre-check + optional scoped-evidence elevation. + const classified = classifyStackApiPath(req.method, req.path); + if (classified.kind === 'unknown-named') { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if (classified.kind === 'named-stack') { + if (!checkPermission(req, classified.action, 'stack', classified.stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + // Stash before pathRewrite so proxyRes DELETE cleanup can see the route. + req.proxyNamedStackRoute = { + stackName: classified.stackName, + action: classified.action, + }; + const globalRole = req.user?.role; + const globalGrantsPrimary = + globalRole === 'admin' + || (globalRole != null && (ROLE_PERMISSIONS[globalRole]?.includes(classified.action) ?? false)); + if (!globalGrantsPrimary) { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return; + } + if (!req.user) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + const actions = scopedActionsForStack(req.user.userId, req.nodeId, classified.stackName); + req.proxyScopedStackEvidence = { + stackName: classified.stackName, + actions, + }; + } + } + // POST /alerts bodies are not on req.body for remote hops (JSON parsing // is skipped so the stream can be piped). Buffer once under the same // 100 KB cap as express.json(), gate on service_name, then rewrite diff --git a/backend/src/routes/permissions.ts b/backend/src/routes/permissions.ts index f2ef92a1..d229cb01 100644 --- a/backend/src/routes/permissions.ts +++ b/backend/src/routes/permissions.ts @@ -24,7 +24,9 @@ permissionsRouter.get('/me', authMiddleware, (req: Request, res: Response): void const scopedPermissions: Record = {}; if (effectiveTier(req) === 'paid') { for (const a of db.getAllRoleAssignments(req.user.userId)) { - const key = `${a.resource_type}:${a.resource_id}`; + const key = a.resource_type === 'stack' + ? `stack:${a.node_id}:${a.resource_id}` + : `node:${a.resource_id}`; const perms = ROLE_PERMISSIONS[a.role] || []; const existing = scopedPermissions[key] || []; scopedPermissions[key] = [...new Set([...existing, ...perms])]; diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 191e3a38..7321f3d9 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -10,6 +10,8 @@ import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; import { validateUsername } from '../helpers/validateUsername'; +import { assertStackExistsOnNode } from '../helpers/assertStackExistsOnNode'; +import { isValidStackName } from '../utils/validation'; const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.'; const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor']; @@ -247,13 +249,13 @@ usersRouter.get('/:id/roles', authMiddleware, (req: Request, res: Response): voi } }); -usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): void => { +usersRouter.post('/:id/roles', authMiddleware, async (req: Request, res: Response): Promise => { if (rejectApiTokenScope(req, res, USERS_SCOPE_MESSAGE)) return; if (!requireAdmin(req, res)) return; if (!requirePaid(req, res)) return; try { const userId = parseInt(req.params.id as string, 10); - const { role, resource_type, resource_id } = req.body; + const { role, resource_type, resource_id, node_id: rawNodeId } = req.body; if (!VALID_ASSIGNMENT_ROLES.includes(role)) { res.status(400).json({ error: 'Invalid role' }); @@ -274,10 +276,72 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo return; } + let nodeId: number | null = null; + + if (resource_type === 'stack') { + if (typeof rawNodeId !== 'number' || !Number.isInteger(rawNodeId)) { + res.status(400).json({ error: 'node_id is required for stack role assignments' }); + return; + } + if (!isValidStackName(resource_id)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + const exists = await assertStackExistsOnNode(rawNodeId, resource_id); + if (!exists.ok) { + res.status(400).json({ error: exists.error }); + return; + } + nodeId = rawNodeId; + } else { + // node resource: reject a stack-style node_id qualifier + if (rawNodeId !== undefined && rawNodeId !== null) { + res.status(400).json({ error: 'node_id must not be set for node role assignments' }); + return; + } + if (!/^\d+$/.test(resource_id)) { + res.status(400).json({ error: 'resource_id must be a numeric node id' }); + return; + } + const parsedNodeId = parseInt(resource_id, 10); + if (String(parsedNodeId) !== resource_id) { + res.status(400).json({ error: 'resource_id must be a canonical node id' }); + return; + } + if (!db.getNode(parsedNodeId)) { + res.status(400).json({ error: 'Node not found' }); + return; + } + } + try { - const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id }); - console.log('[Roles] Assigned', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(req.user!.username)); - res.status(201).json({ id, user_id: userId, role, resource_type, resource_id }); + const id = db.addRoleAssignment({ + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); + console.log( + '[Roles] Assigned', + sanitizeForLog(role), + 'on', + sanitizeForLog(resource_type), + sanitizeForLog(resource_id), + sanitizeForLog(nodeId != null ? `node ${nodeId}` : ''), + 'to user', + userId, + 'by:', + sanitizeForLog(req.user!.username), + ); + res.status(201).json({ + id, + user_id: userId, + role, + resource_type, + resource_id, + node_id: nodeId, + }); } catch (err: unknown) { if (isSqliteUniqueViolation(err)) { res.status(409).json({ error: 'This role assignment already exists' }); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index adddbee8..73f4b161 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -657,6 +657,7 @@ export class BlueprintService { ); } if (res.status === 200) { + DatabaseService.getInstance().deleteRoleAssignmentsByStack(node.id, blueprint.name); return { status: 'withdrawn' }; } if (res.status === 409) { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 1ef69b2f..3d385ce7 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -62,6 +62,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; /** @@ -103,6 +104,15 @@ export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +/** + * Remotes that consume hub-bound scoped stack auth evidence headers + * (`x-sencho-scoped-stack-name` / `x-sencho-scoped-stack-actions`) under + * machine auth. Hubs fail closed when scoped elevation is needed and the + * remote lacks this flag. + */ +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = + 'scoped-stack-auth-evidence' as const satisfies Capability; + /** Returns true when the string is a usable semver version. */ export function isValidVersion(v: string | null | undefined): v is string { return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5eea339a..dad728f6 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -508,6 +508,8 @@ export interface RoleAssignment { role: UserRole; resource_type: ResourceType; resource_id: string; + /** Required for stack scopes; null for node scopes. */ + node_id: number | null; created_at: number; } @@ -2245,11 +2247,109 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); `); - try { - this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)'); - } catch (e) { - console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message); - } + this.migrateRoleAssignmentsNodeQualified(); + } + + /** + * Rebuild role_assignments with Mesh-style stack identity (node_id, resource_id). + * Idempotent: probes sqlite_master for the final CHECK and both partial unique indexes. + */ + private migrateRoleAssignmentsNodeQualified(): void { + const tableSql = (this.db.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'role_assignments'" + ).get() as { sql: string } | undefined)?.sql ?? ''; + const indexRows = this.db.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'role_assignments'" + ).all() as Array<{ name: string; sql: string | null }>; + const hasNodeIdColumn = (this.db.prepare( + 'PRAGMA table_info(role_assignments)' + ).all() as Array<{ name: string }>).some((column) => column.name === 'node_id'); + const indexSqlByName = new Map(indexRows.map((r) => [r.name, r.sql ?? ''])); + const checkOk = + tableSql.includes("resource_type = 'stack' AND node_id IS NOT NULL") && + tableSql.includes("resource_type = 'node' AND node_id IS NULL"); + const stackUniqueSql = indexSqlByName.get('idx_role_assignments_stack_unique') ?? ''; + const nodeUniqueSql = indexSqlByName.get('idx_role_assignments_node_unique') ?? ''; + const stackUniqueOk = + stackUniqueSql.includes('user_id') && + stackUniqueSql.includes('role') && + stackUniqueSql.includes('resource_type') && + stackUniqueSql.includes('resource_id') && + stackUniqueSql.includes('node_id') && + /WHERE\s+resource_type\s*=\s*'stack'/i.test(stackUniqueSql); + const nodeUniqueOk = + nodeUniqueSql.includes('user_id') && + nodeUniqueSql.includes('role') && + nodeUniqueSql.includes('resource_type') && + nodeUniqueSql.includes('resource_id') && + /WHERE\s+resource_type\s*=\s*'node'/i.test(nodeUniqueSql) && + !/node_id/.test(nodeUniqueSql.replace(/WHERE[\s\S]*/i, '')); + if (checkOk && stackUniqueOk && nodeUniqueOk) return; + + this.db.exec('DROP TABLE IF EXISTS role_assignments_new'); + + // Do not call getDefaultNode(): NODE_COLUMNS may include columns not + // yet added when this migration runs early in the constructor chain. + const defaultNodeId = ( + this.db.prepare('SELECT id FROM nodes WHERE is_default = 1 LIMIT 1').get() as { id: number } | undefined + )?.id ?? null; + + this.db.transaction(() => { + this.db.exec(` + CREATE TABLE role_assignments_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + node_id INTEGER, + created_at INTEGER NOT NULL, + CHECK ( + (resource_type = 'stack' AND node_id IS NOT NULL) + OR (resource_type = 'node' AND node_id IS NULL) + ), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(node_id) REFERENCES nodes(id) ON DELETE CASCADE + ); + `); + + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, NULL, created_at + FROM role_assignments + WHERE resource_type = 'node'; + `); + + if (hasNodeIdColumn) { + this.db.exec(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, node_id, created_at + FROM role_assignments + WHERE resource_type = 'stack' AND node_id IS NOT NULL + `); + } else if (defaultNodeId !== null) { + this.db.prepare(` + INSERT INTO role_assignments_new (id, user_id, role, resource_type, resource_id, node_id, created_at) + SELECT id, user_id, role, resource_type, resource_id, ?, created_at + FROM role_assignments + WHERE resource_type = 'stack' + `).run(defaultNodeId); + } + // No default node: legacy stack rows are intentionally omitted (fail closed). + + this.db.exec(` + DROP TABLE role_assignments; + ALTER TABLE role_assignments_new RENAME TO role_assignments; + CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id); + CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_stack_unique + ON role_assignments(user_id, role, resource_type, resource_id, node_id) + WHERE resource_type = 'stack'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_node_unique + ON role_assignments(user_id, role, resource_type, resource_id) + WHERE resource_type = 'node'; + `); + })(); } private migrateNotificationRoutes(): void { @@ -4723,6 +4823,7 @@ export class DatabaseService { this.db.prepare('DELETE FROM service_update_recovery WHERE node_id = ?').run(id); this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id); this.deleteRoleAssignmentsByResource('node', String(id)); + this.deleteRoleAssignmentsByStackNode(id); this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id); this.db.prepare('DELETE FROM node_update_skips WHERE node_id = ?').run(id); this.db.prepare( @@ -5405,23 +5506,44 @@ export class DatabaseService { // --- Role Assignments --- - public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] { + public getRoleAssignments( + userId: number, + resourceType: ResourceType, + resourceId: string, + nodeId?: number | null, + ): RoleAssignment[] { + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return []; + return this.db.prepare( + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id = ?' + ).all(userId, resourceType, resourceId, nodeId) as RoleAssignment[]; + } return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?' + 'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ? AND node_id IS NULL' ).all(userId, resourceType, resourceId) as RoleAssignment[]; } public getAllRoleAssignments(userId: number): RoleAssignment[] { return this.db.prepare( - 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id' + 'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id, node_id' ).all(userId) as RoleAssignment[]; } - public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number { + public addRoleAssignment(assignment: { + user_id: number; + role: UserRole; + resource_type: ResourceType; + resource_id: string; + node_id?: number | null; + }): number { const now = Date.now(); + const nodeId = assignment.resource_type === 'stack' ? assignment.node_id ?? null : null; + if (assignment.resource_type === 'stack' && (nodeId === null || nodeId === undefined)) { + throw new Error('node_id is required for stack role assignments'); + } const result = this.db.prepare( - 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)' - ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now); + 'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, node_id, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, nodeId, now); return result.lastInsertRowid as number; } @@ -5441,6 +5563,20 @@ export class DatabaseService { this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId); } + /** Clear stack-scoped grants for one (nodeId, stackName) tuple. */ + public deleteRoleAssignmentsByStack(nodeId: number, stackName: string): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ? AND resource_id = ?" + ).run(nodeId, stackName); + } + + /** Clear all stack-scoped grants for a node (explicit cleanup; FK CASCADE is not enforced). */ + public deleteRoleAssignmentsByStackNode(nodeId: number): void { + this.db.prepare( + "DELETE FROM role_assignments WHERE resource_type = 'stack' AND node_id = ?" + ).run(nodeId); + } + // --- SSO Config --- public getSSOConfigs(): SSOConfig[] { diff --git a/backend/src/services/DeployedStackDeletionService.ts b/backend/src/services/DeployedStackDeletionService.ts index b9ca7b92..340a6294 100644 --- a/backend/src/services/DeployedStackDeletionService.ts +++ b/backend/src/services/DeployedStackDeletionService.ts @@ -359,7 +359,7 @@ export class DeployedStackDeletionService { try { db.clearStackUpdateStatus(nodeId, stackName); db.clearStackScanAttempts(nodeId, stackName); - db.deleteRoleAssignmentsByResource('stack', stackName); + db.deleteRoleAssignmentsByStack(nodeId, stackName); db.deleteGitSource(stackName); db.deleteStackDossier(nodeId, stackName); db.deleteStackDriftFindings(nodeId, stackName); diff --git a/backend/src/services/license-headers.ts b/backend/src/services/license-headers.ts index e70ead58..45b14fb7 100644 --- a/backend/src/services/license-headers.ts +++ b/backend/src/services/license-headers.ts @@ -27,6 +27,16 @@ export const PROXY_ROLE_HEADER = 'x-sencho-actor-role'; export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source'; export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor'; +/** + * Bound stack-scoped RBAC evidence for Proxy/Pilot hops. The hub strips any + * client-supplied values and, when scoped elevation is required, sets the + * exact stack name plus a comma-separated PermissionAction set conferred by + * that tuple's hub assignments. Remotes trust these only under node_proxy / + * pilot_tunnel machine auth. + */ +export const PROXY_SCOPED_STACK_NAME_HEADER = 'x-sencho-scoped-stack-name'; +export const PROXY_SCOPED_STACK_ACTIONS_HEADER = 'x-sencho-scoped-stack-actions'; + export const DEPLOY_SOURCES = [ 'manual', 'rollback', diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 6007ef5d..a21b0c5c 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -1,5 +1,6 @@ import type { UserRole, ApiTokenScope, ApiToken } from '../services/DatabaseService'; import type { LicenseTier } from '../services/license-types'; +import type { PermissionAction } from '../middleware/permissions'; // Extend Express Request type for user and node context. // This file is imported for its side effects only (ambient declaration). @@ -27,6 +28,22 @@ declare global { deployContext?: import('../services/network/missingExternalNetworksError').DeployInvocationContext; /** Verified JWT scope for machine credentials (`node_proxy` / `pilot_tunnel`). */ machineAuthScope?: 'node_proxy' | 'pilot_tunnel'; + /** + * Hub-bound stack-scoped action evidence, trusted only when set under + * machine auth (`node_proxy` / `pilot_tunnel`). Never set from browser sessions. + */ + scopedStackEvidence?: { stackName: string; actions: ReadonlySet }; + /** + * Hub-side pending evidence to attach on the outbound proxy hop when + * the caller's global role alone would not grant the primary action. + */ + proxyScopedStackEvidence?: { stackName: string; actions: readonly PermissionAction[] }; + /** + * Named-stack classification from the hub gate. Stashed because + * http-proxy pathRewrite mutates req.url before proxyRes, so + * re-classifying req.path there would miss DELETE cleanup. + */ + proxyNamedStackRoute?: { stackName: string; action: PermissionAction }; } } } diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index ae047376..bd978f68 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -115,27 +115,28 @@ Click **Update user** to save. Changing the role takes effect on the next API re Scoped permissions let you grant a user a higher role on a specific stack or node without elevating them globally. A Viewer can be granted Deployer on one stack; a Deployer can be granted Node Admin on one server. +**Stack scopes are node-specific.** The same stack name on two different nodes is two independent grants. Assigning a stack scope means choosing the node first, then picking a stack that exists on that node. Display form conceptually: stack name @ node name (for example `frontend @ prod`). + +**Node scopes are node-wide.** The resource is the node itself. There is no separate node qualifier on a node assignment row. Granting Node Admin (or Deployer, or Admin) on `staging-server` authorizes that role's stack and node operations for every stack on that node, without a separate per-stack grant. + The box appears below the user form whenever you are editing a user on Admiral. - - Edit User form for the viewer account with a Scoped Permissions box below. The box contains an existing assignment row (a Deployer badge with the text on Stack: bazarr and a destructive trash icon on the right) and a three-column add-scope row underneath (Role combobox set to Deployer, Resource Type combobox set to Stack, Resource combobox showing Select..., and a disabled Add button). - - -The add-scope form has three controls and an **Add** button: +The add-scope form has these controls and an **Add** button: | Control | Options | |---------|---------| | **Role** | Deployer, Node Admin, or Admin. The scoped role picker is narrower than the global role picker. Viewer and Auditor cannot be scoped (they are floor-only roles). | | **Resource Type** | `Stack` or `Node`. | -| **Resource** | The picker shows stacks (when type is `Stack`) or remote nodes (when type is `Node`) the gateway knows about. Resource names match what you see in the sidebar. | +| **Node** | Shown when Resource Type is `Stack`. Choose the node that hosts the stack before the stack picker unlocks. | +| **Stack** or **Node** | When type is `Stack`, the picker lists stacks on the selected node. When type is `Node`, the picker lists nodes the gateway knows about. Names match what you see in the sidebar. | -Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. +Click **Add** to save the assignment. Existing scopes render as a row with the role badge, the line `on : `, and for stacks the node name after an `@`, plus a trash icon for removal. Removing an assignment is instant; the user's effective permissions are recomputed on their next request. -Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` can deploy `frontend` but stays read-only on every other resource. Scopes never reduce the global role. +Scoped assignments are **additive only**. A Viewer with a scoped Deployer on `frontend` at a given node can deploy that stack on that node but stays read-only on every other resource. Scopes never reduce the global role. ### Example scenarios -- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack can deploy, restart, and stop only that stack. They cannot edit compose or delete it. +- A **Viewer** with a scoped **Deployer** assignment on the `frontend` stack at node `prod` can deploy, restart, and stop only that stack on `prod`. The same name on another node needs its own grant. They cannot edit compose or delete it. - A **Deployer** with a scoped **Node Admin** assignment on node `staging-server` can manage every stack and node operation on that server, while keeping plain Deployer rights on the rest of the fleet. - A **Node Admin** without any scoped assignments has full stack and node management across every node, but still cannot reach system settings, the user list, or the audit log. @@ -237,7 +238,7 @@ Entries include the acting user, IP address, HTTP method and path, response stat Check whether **Session policy > Keep active sessions alive** was turned off in **Settings > Users**. With it off, every session hits a strict, fixed 24-hour (or 30-day, with **Stay signed in**) ceiling regardless of activity. Turn it back on so an active session renews itself instead of hard-expiring, or have the user check **Stay signed in** at their next sign-in for a longer session between visits. - Two causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or name on the assignment does not match the request's resource. Re-open the user in the edit form and check the existing-scope row matches the stack name (case-sensitive) exactly. + Three causes. **One,** the assignment was created on Admiral but the license has since dropped to Community. The permission resolver only consults scoped assignments when the effective tier is Admiral; on Community the scope is ignored and the user falls back to their global role. **Two,** the resource type or stack name on the assignment does not match the request's resource (names are case-sensitive). **Three,** the stack grant is tied to a different node than the one the user is acting on: the same stack name on another node is a separate grant. Re-open the user in the edit form and confirm the existing-scope row shows the expected stack name at the expected node. The icon only appears for users with a finished TOTP enrollment. If the user started enrollment but never confirmed their first code, the enrollment is incomplete and the icon stays hidden. Ask the user to finish enrollment from their account settings, or, if they cannot, leave the row alone: there is nothing to reset. diff --git a/docs/images/rbac/scoped-permissions.png b/docs/images/rbac/scoped-permissions.png deleted file mode 100644 index 214a136f88fdb5b47ea215d23cff6e1ae02d45d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31916 zcmcG$1yq%7*Dfl8gn*=k^rAyj>1NTPgb0E(f^>ICFS=AfN(2!Fr5llw5CjqFl8{h9 zI;75AzTbDo|9@xiarW8g9LIQH5M0lC;=bp+<`paYww5yC7v&j%0uC3m1qls3PU`e6rTEaUSa_{@J%jv?saui>dUBv14cxV_hXE!N6?z z8Dnv!_{4Mij&UU~mHusVOcXAPk&#hsIm?}vjbCVXz$o`%*X!VhkW~8@%z@v18Sae@ zZ=CK-8#znuHP<#W=jZ3g6U*Z=bv)5rJa2MA#l&1DA`*cggcGMRb8>R3s;Vy2O?B{( zs*{tEwY$p8%U3Aa*}Wk_|BJdNTzvdkm+mLrcP%U|0s>CXZ`G!7+{%$WNsNt6B_;iP z*b5UJ!op9(Ds2X~&l4|DpD`WZ^HV_|{w{7eJ$GaY@#Vz0xDM^`;BNE}<&Di5Lx|PdJ zKmK!=WCNyxQY&)wX(q9t&*a}<_*0PRzY!~?{5^{{6;eF(A*oam=r6d;s(;_loFy9l zH>S9M-XJ{rpJV*zi?P_F@6|fJ_gMY<{=9c>yfV-|MMZ1-R+gyiLT5ZCHUVR~`*Qz# zzr9bHBBghil<9q~j`!C~oBzDYln+wV9{J}~H4vI*w30rXqo%>0rW>;#WMr4b+?UqI z$}vB-Z;{=SOqK~gJO2G4`0V6J3H=HL7On|twxqAyVs~=$s`req&F`7_V(Eg`F{4J- zpB~pQJ+r?|&XL4@_g$uQbG`q;2As=ejbo-NL+!MeQLc30_K)7EhChp`w~`E5o^sv! zIphD^ajMQ$N9Svz%$aD=$)S)<|84g)-upr4rw0?&_EAw$HY+^nyHc~w zOsm|&UfS~~`Ayv##aN1bOs20Q1KYOo_@}lUU30QXO-jecMh!@ehQ~BWYn>)q(>$S5p-m`(eJ4>g#L$?M^ zFmbL_DWZN%dyd}E6m}H+yF@U0)cCYtsy(?(!Bb~9%(Y9*7koGu)f$Gg&?I2k3fH=c zJ?%aqb(Kpmo!`6-zEg(tAxlk7b-ncd?9Z>J4$dbM-W$(u+UH1kC#3a0y`D;sUWrIQ zcz$zXt6yKn+Qgj78mg(q1V-ON;oj3%eL-pBgONAX;Q7lY$2T&~ex$^Af80h4m5|VR ze=+BVw6yfycgFhvtk{Cok`oieqKb|VX#(OH<2Zp%f`Zpr^zNz`> zUc7iQKR?fP_g%n{rikNg^LwZ1hHP=qXX2iA>l4+-d%wDj@bK`?PnKouMG3Cmu>Dq~ zz0j5TPX(Z6^5N4Cxp>K;?vva0Vh>;Qp~dLC`$bR7ExWXGrB5SioH%p}y5WYrzdUjK zZY;(1B6E0VHvL)UtF*H|3-O_C;Vu6!!tv2yzbGWy#$Jv*=B z`M1K36VJnou;_#X4qdX8U`+`Bz4g>g2&f~X_XPC{1CF+({P%ebE36!e)Y5q2`?b%R zA|iI*isZturyISMo3q3{xey2oSOxG4_VWRm^Cq~+7mBgMPE%CTVSfyp1CBPP8y%~= z5*cPIZEm$k6831EzP(p1Po$a7kHJptOoFeVIERoIzEiC42)hNhf+6S-BO57xgqKV5 z%Kv!1R_1h9civtzQ}{kC*;_KFR)4F*MN%>{GW`+*0a{5m^;KA{=Qf|8S%+SfQ|OL+ zp8krcXXov8qnZ+f@;1#5nQ(gfu}gtU=qh8}LtNj$@lOFl;DoSz9caG%UyIPcYSI5z zN&0_P&;DP%7>Xe_0aY24zGyP`2F|xewI&T7t56l8!C29a)U2JR-f#4}ZrXg(!=6iE zeEC;+e47r`a}|+fsB7|>VK_vvYf5R!c?`-_Q`u@~gZ$mxwr9`JyqUv^4Z9SRk?WJS zAC8tXx%EqY{_L*){&0SlpD2wGaGq&`t?b?CwGNAY>2v16ToeP;(GPGYu&s?h1O=8` ze2k&E-T+(T&)%<6quO`+Z|}ulyD9Fm@&ziZj7>jnOd1rz(%@6>--QJQC)>Sz&do+J0%FpcQ(cG20vn^)x#v3HEz9oZ~oKxoOiz!><@hzGmy*r&|kM=lhe+ zO1Q}iqQ2Wuo|)ITuGdZp`t7d3X-%eWe4Bx-!=4*p@i~I>*_k|QH)$_r1-4Pk+ia*5 z@SlFM{(6Q@&TE~QpVax@@3!JFe8*J&!NSTq?Yo>^L~W%DAl$y{D_?$?I zVVf@ZXTzKA!95B-+3LzUx_KHwyvp*6q10)t%sfZZw==9{S9DS@~0ib#GiH^P)A>+_B9k9NHJVNKJB6Sk)0u_i7we z34OwTj=Y_$w!dq1^6j;5?TjDy<%dv#e)OgV!%@HJq_W@Y&y@*=nqIU&VPC4q5>F)% zTEW+}dGT|85Oa7p)AxJy-(7MQtgIFRPh?CuL~T-}hFY5)T&1A!f_5;UBqgFj^%6a?v;3S zxM&M>$xj%K4KmNDsEgNwI9mMUx{jPX`B{L6HO^T2Wcd z?#SHNX04%3Ys<1~0Gq}uti_IN94F(55sR%1C$Iq~E&F;;j&`EE7q~iEHKwK-0#K+q zpH8W%JO!NE=Y=1L5vNw~S*}TV@o4iS4n&K`Gw7uJc0Y^EvPdBip=Q*AA0MwKcgSkTmcpkOF>~Y zY7VG(Eo{n+|L$M!_5;qZDaIasgB18vP%RW8z7ChK*fot5kn@?)3L!;vEv<-+YGK*g zK-KK1^bvFU-U6-NJd#Z-OVn@Ic0mPd^G~y-vG$kCap(;VEm`Jhk<2Iy_Z~C{%|EjK zOa-EM6X~Vmb{>l)i%#51h5NwXLhdEQ6*8I5PhA)$E4eBfQo_`>r3=Mm=+!O2I+NFn zR{6qJWLi~TPzyaMF{~_vHOmmVI|v9Wo6od4Pa%pluQqo<_E%ZF<*f(V3QwWUCyxOH zeeZu@(Uc^5`0o5@Ab9?Z)&Mm#EpBc3q=LhJ&*3``gj-3vlb3o)nUc8w#?I}JWrV4x-2?(&|lcVFhc->|toR&FU8g}Z5nt2L#DXN zT5YIc5?mp~I*SB^IC#Gak zFVW;5AkAyryxk*@$Mh9biAz9$BtK-8pBgy=RW3--(ow@D1-F!ZP8{0JDyD?uBZeV! zK~zaRF+u^$5q=pW)aDtpg3ly~<#qYrzsbk4LBEWHhli&QCk=)g9;$doX&yE>`)-a_ zLz?{Gtbd1({~R3t&Rc{p_n zRA7%yJw})2clZ4Pip~a~pTg>bRpSs&EQV1W^##u~=;&pS$sO`!_FFmO;o$^NqNAf# zZzV@D1l2*`-dXxw$=B%pJ7_uEJ2EPY5}+m&L*e&&uXPfo_Qz(=_G^Pq|GX?HC^4!{ zD5rwf3f=JF_w21m?R=$b=h>j6?H_PJ2MlNntz38B@EFxNv^>JNCAo!lb)@*NGn4}W zj&sxgzu(td^`t075G@Sod`EZDBA$^I)xhc8A)z#G}lV3lbGVZ1m z`$EyX#^242P>j>pU?!Nj7EYYeI;;Q}`C!9&x}jPtTO2@w#q_{!saC_x*cH|{dap^B z@?jV3v%bJ_537)ycac?t4htnM6~L(Zqfbw-OFZbK!n*C?-~fe6!t*CGa^$U%Y4GVD zpqq^^d6;}82I)c?RO8*o`AYF96bhyY9lSev@7^{4`8k-cEcD!QvgRfXxY#cqaL_wk z6#S@?cyZ(3TtMLs$BA8dYBJ|1mDL0esd1Gbw*~2vlau#;jdqnSe$GrIm~QZF|GxWs zc2+->UjFXO!or}6*G{P6&&BDP{9JSU9 zcXpn;;yACRturW771yD`$Rtw*8?nz zB&4yxh$o&`yU0$MsqQWEv7gl5Y+8(>B*}Crq_oJO++svW`zNemhw@EvT|A?k4gxrJ z=+)b0f**mTN|;}p>P~zCtNhB5bNvqr*lagGobG29NAVOPNHuGS`<%00rb2J{9$J%G z16Z#dZ|CxZ^1JP&5!rsHd!zA|#$~p`0S`1uudP4oYZU-XdIVm;4w1yCQGc5QVu;%js@j9*e>V$_+IK(1V()Fx%pe9B|kRzom* z3uY&udb8GxrIO(rB?{dY(6C?%BNIfb%%PNoNCuTL%R}13?pIi;?%Q-8v8qg|Q?VKS z{N5TS;kCvO%O(9Px4xOn=E`?Zr>_M)&;Wx@wvc(=1BQh z%*szOdPruEJvzpT-GPC5AXjEq)M@?lRW5#tt*tF!iGbzcx>vQb*ps?t>AgPjL@jQo zo^+;-UqGBlYL}Q(G}ous{SXQyTSxzxG|AVtgRmi6=xsT_EeuYcUGcK-=;!FZLYOD? z@GFb38wZOOah}NI5g-{7F;}y_XO3XtkJ}+;;e7#1olH{*YT`@ddbhr2xV*2tzgF_* z+M~$Sdg)a?TxG&#{P!J%%^fDjoaYku{nDr;SJB9kB#?F*Ks{R_m-ogLM*)ECd4o0r zrUGbjv0RAV$VQae)MHLv7dq`_xUT|s=*l$X5JLmx1EC#u7m+sqF)DGi5kmuKP+*rh zax5s|Ds{&`K_jxkwZ;o7+he=6O+bK>2UG4onSf%7Go%CmK;IsVB6lhC>)ueR2#Gy= zkK~Z>QxqkzUf3I{b6tQ<&n;|s7$kkXCbVy5z|mOV&O-8bw$_9(Ftc_68UgZx%7mRYT>8fHKGWssFh__g=poC_0kWKF^3FQ z^q2c5jQxN*tCPniL4R4p>z`P$F^%}I9NNFAwEtk#T2)_s`}S?q8C3*Z-v>GcjSL z>1k-hLHqO|rlFysposi;uV)Ia5oTNIDjvv@qM@eF;4_szL!$~D0X_{R%rBxXw&~B4 zs$tJ3@stb8J)_!pe03`~!S(wthakM$Ieug)N}7J{@bK{Y+i-vf^Py<)5j1K|r<*ki z^v*=MSh@xyT!jizMwM`eC(^!)ExeoF3JpW91~&Y~NAmv+|Nr{(Z?yEkM?(G&u>|h_ zB7^y_7t5kAoRpL_t}cCdZG30=uIcsL&s)B}RM#vL3^?2b9Lsj|rYEcnCDfOEDCf}h zpt*g6E)fS9gA=&BRv$DK`uS_K%|H%tl3kisJuodFGbc(~R9y6rc^_N>FN?sZoB^JU zttvy*bsow&4guBojC2v_8HwLbm9U>%RpWs_H1=Pw9xlFH%Lj-0+|4h|7W+NLb*?U; zH;s?ar0MWWp2EX25`@+FNEpcpgnlg~$^`v-TT5~M-flaQOqIjNiG#_c0LHe!bsd%pD#|#767o|eRV{UHlI8lXWPn%Eojoe*D z0a=%RIQt|AMBO;u*M4g+1D4-heP7t^y!y?hzH4_Rxi?+7Qpg_s3Nepv9zw zg}vYhY{lk|&m&;oX@r5iD}l~F9GB3*29`=kEPs7^@MAF$A=tx23N$xm|8m ze*ugc6fpnpB&Hx%67dT|VWZ}N?x1&^1H(xlbT}7!3E$!yprwu_JoON%3U$7&wIeh~ z0*}~NAI<6|Gb>M6W>Eu1upNGLHKhmuU3Wfk46pv-K?)4=hu}G)sbnBgU?ZFX87=c? zg?Yi@@>T$&8j7etTjBuTYyj$EwuT1E31SeQ?i6rSRa=p$iI=%)1#U0M!r*hqdfUbYn(r$R{bp{^A5MRkjXMOWSwl1SZD3#1 z57EHy-TU*iWA~*#CX#XsS|TxBpP%1eZ$wW;>o$5hKA3{83EXz2RS)Ts_VAlKlspD4 z0n|Wp@;!`u`?>gaI`j7ATYT&}K$yjFO96HRXmEIog-zVL_>^^E+Ix2T-Tg*b^KVT* z(34p$zfPlrlbfLPo(h>kW0Z9)91Y^5cuR4hZZ(u`>)&4I;)?Mri=iTxxT0qQoFX8N zpskOTz(!PdrV5nPXfcJW7qXD(CcOMpGwD=0-+pP~Bdt;>|1Y?n$W`C9@-Bd{Z31K| ziVeE2v^4Z2*fS&A8b6_0Sgp}Y0#Z3il0%BzZeMTBG{ zC5A!`j|>Bjex#M_12wyBT*Pqg7WTCkfpK6+`Z3~n9fb9X!Xj$gxwZsds!=K(m!Ft* zmG_2W6C#KR3rEw1bzJr=31Jf?3dh97S_0ZvP zNd}#W(&+iA9gB66mkD*mLDSE*`blPk)w{5;fI~#zcljsVgpFx>hK7p1U)BsTj`csD z8tIpsd~WoL?F@Lpg;2XDYLJHat9-VSe_a5hBXLZfF*r|}{_no%x#ir4sRP`Fhr2=+_1b)T#|2j_E5X``+_;FvMqziXRNH4&!?Tna&F zI3c!s*2LK0pxK+Yq4pb9MvNL?V275eR}qU4MdD^Jk0uBRsCG;%{!nQE^&()niY)Ew zu~Y}y63StW5IZFAc_Klq#1$kqHO{>V=eR6`dzpl-(YUGK(ay%yPqbFo$|_h~h&Tx}OnY;VfN<-TA7{=X#kZ`7NR0BkdN{mkTwvk3DH3 zw-6C_TQuhG@jFPN#-61so#lKZzvHLmoa@JLKNRg7D=WNdyf$}Hua}Kf*Pi*2ffpD1 z;|E_Ov30|9uGy%ok?arcUW-?`{H9+&LQ%;Tu|V(I!h@E~JhDjgGYx!8?F3F$IMzy_ z5u4c^Yu>V_7(-FXe-d{abEUQ!HRtUBYFE-TC0iMID%q*$^rHL;T5a3UwVOUwRQk2F zHg}{nZFSNJ(Z%h!)}uRgjW!WQ;`_^<7A~8Z<|;s}N3qZh4UHz06Cp1!MIJh19&pes z3I2>BB;X^F?Q?B#wmAhX+~ed;SDTZlEny-TsI$xWk!_j$PniBiXp!Ql+MvZ=wskL2%GizoOT+V@?|{j_A* zqvzjTK-1yzf zE$WsrEXuOEF)twhye?)r z6e)W9vNCxjCU?71)W@9F=~uTHjCFHzzp}@41>tX5O)j+w-d{V6Nf}x=Icqo+>T z^Gifz(c(AyqfGe&Vk!BA6T;%p=Bc_0=0CgHv-v~~9T#RgAu#zkd+VuG4`|{}5@<%A z5+q9hu>~+TRw?M@6{U%lG~uI)-Ct}|tcZFfk>MjEBC|mj@D-5mui;`!&M{&(?PC46 zC;b;6hvN~ZkNQ-MZ}NZKES&ZG_0}Qg3pZ6by_6r74bkcv4kq~!o+RWLqXU_J z4dL((X(>V87D&;RQf+zyMCWG*A9g70Wwhuh?w=@FDd_Nrz;)(~gbfE}fbtE*L#eBo z{y8uiC{niiQ*mTMvY}P}5(t;`U7flS$2gXDv>*CyaswaadUO^N$FbAcC}V&ih9?)6 zUO7v@3tZ4p|I;fzOSMkZF>O1ZnIDcf8aae$?}J2a9j3xs)yhpYaj9Jsn??JP9ZBp; z9&~tG%$O$y502NdWzG(nGYqjCyw?AKR#!i)L-UT$D+#TA_X{aF$a1EP)LIKV8B8|P zUSW`y4E(bTYWCjm+4FdjnX&i&B4Lq@Ij-LkejjBc>Z&BSI>?HAh41d`*P50kEcm8| z%^H5+^L(8~yoUECB7Pj~5y;-Rx31w3<-7$#wvwz}@0!hWrOTZBy?(6SYD?PNco{G- z*dHij^{(8^cze@w;OIAK4B!c=z9m>L(9XvErl~XJj{wqO2`(|Q6PRbU*@icdj;&io~x>D9xN^K?r7>cB=fSTiJ~A-xsOt^LbnLCfzKs zJ_}cFji32a&KQ zyO@^>gV*5XM)Pcftt)Ht=~Ab9+vZ&RR^R!-hjUh3TM*(=+<5=eE@W7en_sE_&!vlW zjUYa45JW&>ZfkF!HXR0LBMpcPAR!703c|y&QpL~%K1MgGJy`)u8$Z}rEHYs-T0Uz@ zB9`|d-7p*00R7eFLlG-519gms7LK8hkp>+7U53EY7s!3eMJg*>-UvOueA^Hxr%OF> zoW4qA5x=Bf#cdFZA0?}4Xmr?OZ87Jg^^V^EElAfm>&zgQ55G1zI0%Q@|1|+DKtX0Kcl0$8rvQ-VVHadLyz<-o38cSK zgU9Q;exPW>_)+09*A^Evu4-jv1&n76tn1+OvkT)01fo0REH@j!K}sH%FI^KjzS`M) znJFn1FkOJQcj(US7{yCcPDJMEnY>SK9Q^}fQ>OtAJ3A_ zalA1oNBj&w1shzGb7Yh6&Qm^9`VUuL<=>NBpPQQl;$tZ;s{t8JS`*PsqnWh>Dyb9z z_VG%aY2-6yPT|)M+~{G|@N&cY6i9%V4vfgVXc@%Z!b3M~!5o&fRIhzuHX&6AymTQi z_{fR)8Rd##2>$?1qz;8?@!Qr^CrUv5P3i}xtkHm@VIOlOzq>}kAep5yLl zbpuQ?&xMbLIBQg`EIEoWDlL_x1@hdOJFY5WaXTp;$ZF(xP{>i6-CSGrXf>&NI8+Ea zxrV>6*I~okKjv(y{fmoKbYa&Skr9DRL^%4yU`oPds&JldhD$8JWTkIOsbbadAANxU zLqDwI#v^G9Y5@)_1r}EX&qbTL=?9;mjhEIJ$ZKFLVN7}q-?{5*V%I6DE!Ii$OSYY# z!}eMnJ}1^-SxOP>!17i8J18n~TUu^~2X7p|JjYlsaC_sX`6SlH--U1ZwJuJWip09@ z?aM>a?>pVh=fxSC->8INTNR&LelH#b^cdMg%bfnrwJ6E=4qy zI-h>+fuA8x2i8Zs9Ru>v_qjpQihz z0Q4>k=W>1zV#laG=IqcLiIL{dPMp{xa9x32O>0r_An2gFZdidu((+ir$V|<}VKaAn z5;jhJ{`E4FJ{>k;-DhcQeZ&|B@gVD%eM^$)I2>%0Az_~P9YC7=RH`=v>!XT}2Z^+CwkavW}y z+k$x!nQivAOcXpm5?)We1u6`u}kP%hPi0N zdn_%Anr^{jB9GfyNpZ~)XxkQK1$WCwqmi5>LSHNPX7;Rv2aMfuQQ8&Tu^z=iW3QO% zMJ4uja>e6!9LORsDL;Noar}^$$p9w%PhT|?1n9;t3o90w1&jrj@4!r7n}w3L5T_#3 z3eo;}#j>>s>){^2_EbXuSUtD(t4k4|pl*?lUym*$*N}`$sAlUFFmj07WSTsReyDJv zoNWG4{pUriM2mHD_nh%wNp=}A5~Y1Hwa0X!kJ3yoKg!Z4!jQ+(oj)3?Q=uqMDhg*C zr0++`mG@s2X|w)S)yzE^ly&2VCGfVC0S>7Nw`sGjOut_GRk8g9xl8%09oIn9vQdHN zi|WSss`UOh=Pohn_3vJ^owGeQ57Rr8sw>s1HiZhI@%d7)RHj2tGsC9| zm#gmami-n(8F47m*?A4S;wZR96Te&5IxWn8rH>l0ym{F=#@a#g@`~u#Gi@OQY)RFz zzcvgNR|nisJnj(RJXYbA#IYAVBVm`vEyfGCQ{Jp$VtQefJhqah!WcEtHpWI8KQw-3 z6U|TI<%zQ|;zcT$K_lYSXOU=F{3cC{AUX4Tz?1jWZ?3o#q48MF%gR_`U&i8S_ zy43cKCo`Vw50}9Y@1Nb2=)^1(IHMQ7Q8M4TMF38LoOO~_4S##ivP>bU%MjM7cx{|6Rk8c!8=CRmwIZz zck_$NEP(;_=;JXj;KZhDQgt`KdcJK;B4iK^M4JMk>aN*zxtyC}J*;v)){6&N2h7z&%FY0Qki5Ib-s;X4$kt8HMJKQLG2?ZIN=eUM8H z_4%RGsnI*xR;u|`f&R#-4dPoip*!!6U(&A;(Fi+qQ=m@XsmFP{k_&8!J^!#w%TG=_ z`GsCTZDUgTN*PNWsYS0kxy21pl54%sD3w-2&5=yJLcD+2p1%n@PPvn-Ng($NT+QRk zR{T~Ye1M6(+6?-z{fcn^%>~$f&1mT}=)*u52|GlgwJ02;qyC;VA}nE=+meTzQN(R_@V{nRp|~RzkC1bOT!qj^#-6YK$Pt zlbMbs@B>Ch%#+t-gLlenlKMNfPhain8r2!Q9_4jFQrt>Yevxa+!(V@j?q_S}of=#Y zQ{p3+AiGQMbEkgU6+e%vFV}QSn+DZ(Pwb^`%K$6TMKwR>1V%*A>f-amSDsIERJ@hPBw?+Z$Gh5n zyUtYg5#Gn203;`>8%88H@CqM-pKd>{Dtv2q`U9S;?@pJur`2w=O=|Md>o(pF>_ACx zE@9G+$6Bv$O-ftS9)@`dFFqcjWS`*6ETpC!mYy)B!kx#TwWYbf>zH%5Yr3@M!G$o1 zWa4pI;zH(p#kzAxo|)eT59WQu?^aMBaPOT8Grz4lIt@&z;!!aBYH7B90lO?iei0Yl zM0xbG*1kVUALNVCytUNgdm_x-i#LIl>%FOYt629>II6BqP6R7`o2kNRb25%V^XI<~ zfl2h7I8DUQZ^Nx62sH}E*mU)6`$T~J?t=`a6lixeZs5Pt3IF{I+J|)W=J4qYEAZ5^ z%bm!g#YqHi=&{%{GY3s@na4c_D-(iUl4@Pi%Fx@t4)K4*IR3v(bNq*pin{Ul zX0yV)S3Diyp22&6`|dkhlB?Do5h_}0;?<6mGZ{i6!P}~EYw=>rKplfn##i(RDHdN= zR@U9!y|Q9UHUYjiHdfYbnc$o-{0IG}%>k*DrhRwci`VOZO*S=XtEX;j({i4uvf5n% zT`7Y==eaD#-znaD}{;( zIsvTfYoab_0mb;G+SSxyaBvg?{*SnPRgf~5?APeIpb!6>&;?8opFb>I;pS7x9jV{h zrI%{9pYWLLPFA()A3Iq*YWCe>5cfJMS5FJuS@zej_WrZ$oOxsJL#CTu@6+)xY|Y;@ z*=mOa0-T+L0$C`vpr6CBALvrqE;%h$yvm)CbjmVH5+2)q3-s#eboZUldd=s{xl%i~ z9u9TtH!oF>Iv@hG#%uY+9-i|lM0TvtI`gvqDa!@=p_i;+02Du0upWbAwPItl7YMFo zAi^fWga)>b&3=jB4ztatz)LlQ771pTls@pX`~sEfr>I7z@H*(Og(@>mzRuvy0==YO zOtl0gn+%}fYQgLda`DDw?Lato1XxE-PIl%72i27ml~(lDW@ii2RtNJ3D+lO7VEwo= z(DjrjV<4vO+cyP#%BSly+2_^`o8}_pLIBi*x*4 zHXg;|APlxf&3pRE*o0TE8CY=_Cz2SH4_l2<~(BXboLLkkM09=WvhC zJRI7F8NTTb_t{rNegXudjdwdunA3$VoOph_075CRU%$qs6tV6}VYQ{>_5&sVTZOec zt;#E{Z28?W0#pIG2a|nH4!6Miz7L*Y@CSkt!pLs-5GJw3CeWhsDR>S+y2*R_MFX+} zKdzHq%bKZi9PZ^im$dzoJR8h6TwgibSQGb6bGL(tEc%jH#)5u7>UW<5ZRgv!E|?OO1AyJb<>{9 z#Q$x5dbc{H+~&8#lc>MGdY-sr2{esLhLKafH7q5cXejj}juTe12=Pdohy7V%w3`*M z*|fE_0e&y0pVLWrEnc{;NK!CtnJA@nigq&V6rg&PZVPuVM14JT$vrs|6b}xP*B*`} z?A6VauS>kedQ*&gl!X4;@%H8mMVz1`>;0d@c*=?MQiybGUgS>jnNW6!P-*4)-~-DP zGEyza>3jAaC~;=xjnmavH_s21i)&}x-s@KXZX6JCq0lWEyOSs#W2^9!&baKk{D+{W z7fk5X-u$)|xF3rUR`Ho`la94gV9EsP7%gE6UOX6p*%d-Pb5(Yt!DmfZrS^Y7bR=c~ z?|ul;D)6eJUTf)^v#`*BNYV64ZuHqgrBh9@3n<29%`{xSNw#H!?+%`YHwriht6Aqv zH$7)d4B17TkO@@Fbqr!ndo`hE0=fW7+RiIjM$VWx4=O3{5^YP9y*{v)UEsZ!& z>6NsU$5nIdwcLcX28Bko@(`?3R(xr^5@pzy#b~B6_(JiTaNLf|;e7&fQ8I`;`fkId zxIhfij6pC@B#0E3T6aR~yFjBP72 z=SM_tIKcytFfhBtfXD*tuWJB0U;?h9?n2;WA$2rwbq|bd;3!fcju(H}3Htl*hF|Z% zj3>GGO&1tTFy2<=s^QCC`0;J0c2?t$imHZ&xI4BlIR*WjnfPztI&@#TT=Esm)zQ^) zp<_yH%g_I;XnC*qDfK7Q|FbtIdZ;1r+lpY1!+w7z=3bbI3VWrJjL6}{SOI_wSy-R| zr^u(YzCEJkLf!gq6U{;z@;5~4s+h#2N^Yk@YKH)0i2O&FliTQmQKg96+f_NoHs=oK`VCtcU5#H?Sq|cGp>| zr%`wW`SqdV!sze8UW$IDl|cZ3w|wWnC{#W{O=QO4je1(Zoj2jiiqx)5Iw>hyV4DtJ z>id48pd_<`PPE~g?}pL;#cLG*5be1C{mcKexb1%cwEyvov5?RDNZ=109UV0_H9fB< zEBe~j0E7KNr)r|3dwP1<$gI>zfAnY52q2ju9Vs||1%0w8#ukaNYtixXid4XZ_G77U zz5_(Jy?GAk6*7`v70?$_$HFL|guo@;c_oUM0O5o)rW3u4L5siFXoy>Z zsi5atWkMe{oE$~Qa#{YPRhAixwugs@g~f}%>1kYnBo5rGsji{P$dytkRA51bQy|8S z4!tcv&7Lj@tg&CtJv5wT!X;&`~v5 zEF)lj0N*_L9l_Cg%_=ty9aA%?aTo`HH50J&87he{vfbY#7Cip)4mH11P~ z;evtf4%)@S$F7mS32r1H96u;0(mTK3qu{KQeb>0r0O|n)z{Sb|X)s$|i$=pvP_-ao zMtSvWwa2Q?lsaT!*aLxYE-x<^)w+Fs5zI@bKKPvNMjUVd<$=rD8~fv`JKhJ%M-WZM z?^c&kdA;60h)*h2n!xj zS4gQB&7fadcE+v3wLn@x``+%T{;%lCZ;*0=bjATBnv!kO+<$yh`~i?hfbM|js+k5z zC87^Be4<4*z(J;YD*}B39$_w;HvO9mz?DRlj8k(#Fg0}$0U4VOP%P%!BH(VTYQ&IZ zeTKMH^Wi-H0?+d829JkGRX|vfsO|ZwRReeKJLsB;uCn=M@A!=C&|(N!ECS$xL^m?U zB{9?9g>)kL*=WFN*UR?-0QVD+Dn^R5(V7WtERIJ2Vr_R<2ERf2$XC!kDJ4aI^UBcGiNEb%iLmALB(NT;groj1y1)7_7-yq(XWz?MyDY93f8{H_;$PnDc|BU>B z`mnj_5>9@)4zYI*rOY?N_)xax(+5`_?On<@P-#l5c-LO45OW?&)g+s0gYyCZ0tfh@AJDp(am`gH!#ef0=?nBb@tE3gyL{Tp09Q$K^ zbP$qqT-bR=5@>~5?2ZgV_4V;e93qOr8ws4TjQP)(>$@LOF2k#qu3PxlwBEQ7OoYI` z-);MR?u&V+Z_~t6`3sk*Qo;IaWF1L{Yf2)fog2jc_Y)u}p+Icf5!1^zqvi4@o;z>I zKVgpcmzlSt7Lr1x%ft-VAlZ*|B?+0c%Cp|_*4RAcfjq{TrQW3wrd_PAqGIqW)Gb4_ zDE4;Z6B1deAev}}74#R9V!Vs<27KvbS zNLa>}A$DRi76cRJo4H5ds2~~&E@m}xX_^zAnw%=>dxYzazseW+8eqI25qsf$nL zaj~h$OnD9Rpt3XZj4!gv|M>1CM@1r$DPXAt%M|VBz#4~J04)P$MIz=0MI7>o>=X`F zS6pABt<-?tpc;sIt&M?k1FE$Cw-#8j4iPG}I-6;aAs-3CV8;a8tsIR<1hb@C$zaH= z*oRD6FhJRTcXMdcLEAm-Q0%_|IT>@6+ho%fLHXhyB+FjkEuBH{W00PnMKwm4QGXWB zxWI}W-ISc8bt4<}9r+k>#pu0X^_EB+M`l%LM;g992f9P43#%d!H=*iD%F&WX-!E|W z34nYZIxQ-yLfGoD4yzpVU0&Hx_7Jpy`3 zAFy&e?GUT7kr9g&5Zz}to9++DiO;4vPt$hxC_|U8dR91_Rmk0cCdj{P~`>5@FpPrLPJN|@wKP8$S!8_D?VR@S_ zwZ*th9{qL|Ot^XS$!kxMhN0Zi(4w8ucacdFSBup8V4(Q{UfR#DLG+JY>r(<{j=9j^ zl2rV61oQs`kolj4A0D9q&atqtSQTl&AEj7>?d1VeXb>Q@xPOJN3sLeJD`prG?!3{P zYXY3u91X373qG*`u3kr1ciP+1(lU@tNDYR5sC@oQebnv(Ta%4EJ+y!7m}yB!w84lD zn8!7ETLvy`0bDZ>O+L}t*6rG0wE!7cfT6730eLu}!~%3DP;qS`9?(B@ z#ttm4VfwsVWvgwHq9T`|%A@@PD>D;}PZ0batK@$;j?iT~QmvK9DJtSv5~|FWN}8mf zMfRhNnIr68W7uHwjW=%2Zp0fr6y@{OsT|I!N+okuvdcFIp0l$&&MM!0m-TJ7_-{c* zkEoa20s_euHLslvm3o{qC2^wCyz=CGCJ;BCG*5=i{{X{?qm&hVdI@fztI>}q^BLn( zoSa@arc&K_#qEvS#=hQb%+2>TIsWKzB_YX zS)5h23@OT*-MAc^_HN(rEf`l;=-^{{Qa|~7Gu>5 zYs&J&fzmw@jYrLm2P~T6FbTK+8qU9rP9FlF#5b$VdoviIUi~Qmjo>$DTJwYxo58_jxrL zj>-MhaAkCboJ`ggN=h%BkNU5)=&mKYA{Pf$s>ow;_6(RO-`wG5lUY!>ypMY5Ovtg+ zLPw}^2fEtW(?RQJpX9Y}Jxsi=9sz*+CH5!3D_8cuU&>Q879{mmrJdmgbHx-o<; z{uMYmEzJmsq+Zqh(ZU@uGv4PEwysL8GxMXsXYh5>R{4<_$q_a6yNk>YT_bc};wd?; zTXCs>MwIx_pN%ovt;DLAki1utak)q7U_4gHf&iCu%+}~>V{C2FkW;d6nwlu*-r8uD@zD#@dxr3eDb>vQSN{v&;t+#)0}=e_%; z@z;5=7k3+u9_Eb(TEq@r$TE8Ta?2xT$yI4~(9-&{3!PNYvMg(-VNu;yQAWlO;jkSy zC4VB_`spm6_*d_*|6Lv-SLd753HQ@6XZ_oqUr3Ce>}Q7o^E-%L=ni$}=+?sPDD!1~ z)ne4BV_##O@Tp>{i&AkG6(Q;Ud?dqa@+j&k`}@Z2y6ip4eL4mw>{2B+r45({8Cs)`B;>qy=;8R)1_nYFS%zP=V0orEt-!&Qu`@&Tc2r_Es5^4=+Avs z!V$ZGWBC<(V1l%qnP~9jc7{)*A|d;p6UzBX4N#8!nNUwANRo!;&E_@mX*;ba%NNKj zvsS8|UtGehg(3A`FZJuFN-CSm{*?ctwyzGV`tA0Wl9Z5cHYF-uf`CeQ2-4l42pdpA zx;q4BZ zXfB{5`jr(FIh|LHBG#Kr1eXt>mjv4MKFUX{d!yF3C7TzT1C6^_c0o_9CLMc06c+oE zTHs(z!UMTRBS@Ed!=>=Ga18kn(@&S-0{pAaeO1}8*xh5)7wC*9*3;*XMN6E=7$qpO zi09=iMKh(Y>JywOnHQzdInX>IXH%VCw-Y`M4rZ*rvdA+%u4KOly`X}sQU~X-$bGvz z2H~Emw(;%w(rPEQ$|af4Z3UX!Chm6)97}uD1dWthGVp9?L{@t-BDxu2k|1gt)?Ke?rfz?NVg*$qGf5HbVE?el5Lb>R-{# z!if*P53O~TE)nCq=muW1mx`OipW*@$B=&nw7hlRvq&chI*N|qlKUf!ekhf|2&{oc< zIuP<~E=}k(MQ%u~e)IT1_uHLy9QFP)XW$z->5EB~Ib^DTmy&}qA93)iXuLG+Ig@UU zYpO6|Fno0V z=BJQyHF(`=Ce4l86cTePR`pc3r>A}$%5d~*K2cq>D}g|#pRbwhQhC~)Dl&##tS*o; zRE$iOS?p-xkNDK_R8!%S%d(~S3I{kFfyVG=>C%4v*JgqA(sB-lm~v8Wl64Qw+5Lp` zH^fz^`(x#H<9D!te743RmkCMOQ*osXUe+2U>P^vox1qd_(I6YmqJ1FY^u4UTfpnFb zA+_TE%fkdesP6{I3O#Z9JC!Q9q{yXJ*|ooN&6Q1Yfi!o5{e}lDQQ8~pgs8-S28@vZm_cA*1aH{wyN;}&K9}4 zcwt)FtmaDE4&qNdEiA*nVWBNHi{-1L!b|rblnos=6H+qn^=H_H4yvgpEO)DaYA-TJ*iXkpF$?PSOqlVvP7`ba-5P+uGfqn&gkzyEHK}t zoY|M%MhZ<>Zy0OhmLE+YeP(R0+h5wN)L*TBoSlqYvs>PMfgpjlY*mQNQ?9lN|BXTG z!{9GwPtycFsoPDRp=h_eEKC3B`=>i4Yip$SOa4BKh2d&_uHn;9W-QQ&=A zDpUx@EH^j)$g-yE1WwiK2n)W<4f%jS3>6P2QjizP-hm7dN$`u+?PKHSx4oa-s+gx7 zSAL1t2=6n64&&}E?(z$+x z*|AE;`!}0S*AGNX24*vEGh;l_Tz>3g5Fa|-R&mT!BLNMKhx0KVhbE>baysrLjs%lS zDwfFSxD$&u!r@FJ|#K4c|??ll_dB#W689e5SYa*jlEtWU$pm zpGMXUeV-$h+!%U^iy~}o+0WlP_q&(&0=LE5>d(Hks(icvz=>*ZbC_mtiNKsr8P@I9WhG;~M(QEB!pG2_i=vG^|Jj_3$E;BPg0zB812 z?@p-qUW#)rL-dQGa*?b?G98WuvB$E zDaqQacoFlL7o?O4T*4(02mVSoX{+wslm(!~?Jf3GWfnW}&6-+-cw zcWj3666%2rSqi~)hS1F=$64bnrr9Iwn=`7pwEHjJQBTL zaY)ev4|{H31$Y@XT=YEH!s2IVY0h^g5&-CjCJMWpYNSy8b8ep zHD4}NbFX4>)V_9WA zx5#?%u`@>leFYmm8jVBxy<$mXNj8=!SRKv|nFt5)lbFV&Y$_rMGcVUoXk0fSoh7a4 z`ReRdUr({BNkmkLkcy`m3#Y{r`%y}`e;vPA;k zNsov%j4BPk)PoRw8rRSFK_T17%V+)1bY}kG>0HxP4LEMdSY2}p$Pjv(cS@ycuY+aY zK{V@O;p@$qTkc0Mn<@2c&)WsN#!~WE*NV^`X^Jk=z>jtM9qQxr3bs*Mh40Q=8eI>0Z=|_=`YxsV=zvK->wl!A*)h7D}Q?dqhx4Xje|cJJ!Kh z|D)UHz|_Z^I=5+6(s5?RPeHd>|KWygapUj9!oxnQfaW8t3G*hLFHiR`h}iGQobY7# z7uwD0(D~txRF7iZ{8cSrcGYh)_QDM{qu&ML(Wie@W`1S&M<=IGkZ4LGH0mFW90Z$x z85JxLc`IGrmh>O5Yo^orA3H#sJck(H|6R1xf6dAKztIr?P51uaWGLM!z?XvMLkWM# z$SmCip>2ma65wn8hNU7j9hCE-VICVB1FHsPHUQI-nWYA@C7@z}?Vg{TTS-9kF>-k1 zMu9?mq}i0havGzucc@qJrt)zf=#%{)Q=C^wWfHl-rtO(ty9P$yn8?Ul&%cIx06)QQjuz9S*-qCtPs@~d735X! z@jlC$e~1n=(mg)nh*s+)mr2BP>9!^1+YQw)El_NKr~mN`GDU=)#s)|HAzTzK38@@4 zD|Z2Dx;@;00QKio1vN@i!a>Edb`2_Ssd3 zMgmw7e?XfyfvNZ$fbQNGfg(Ec zp;_62j(SF&iwK|L+^2HgiIi)7+neFU8WWYRA?)JHmw~k@Qiw$xNkZ}gBLVJ|dan-{ z1*Bs^QzEc6qs4~qaBOkfuK(Xs())`WtS0EFM}UC<81>p&P`>yF;%Lo&hR+cd!vb=} z(c$)33+UFJHmm#jqVk>Om-U{yUtcpT9!BF^x+Sug0cPk4+!|0$zN@cNw?ZeuORINz z3Sv^x_&S7}nd-L8#j4`p&<0N#aQVlOk_k`H@lLdw;_ck&!L*|P*AJ#A%Q^lE!bpNF zZMb$ve|87N0W##jN%Xo0*eT@~N+}?D0gKjK;24DQ`z(VnV|Tz~mgo0mW74q1q6NU? zPoNV5xbhD85WV)-BD)Qn{G{$%18Y+eqT`*SuB4Q!^wF#VI+idsX`!KdP|O<#$_vMi z(Xu91gC9*T;*w%NNN!Y~a|L=w?%vuPlm!^q_W8;gr2IY;0S1SI=wPdr#9MUpXgTNg zQ@ChMFGoRD3b+?U>2+7L0jdO4lLasg0QUb`eVt>p$>9an*LrS9+irlz6s0~ipjTf*P%5mLou*g%t0ud%5x5N)|rO>V-L1iuc>5GI%Xb>OLxF#=i zzx(j^4oT_A+)v0$X5NaMu|P(b6X_6al11ORkH1WSc^z;;B?3LGAM$%8ZZCyCH0&Em z3d`lg#>_czis?4wKND&&5#*^J7c|8ud3|g=u%$o5L0_D&-8D4RX&(`6#0xk3_ z*ZNzXD$mKc1t)JW$;{mJ0vqk1ED=SvD8;q6IGep#asqqaH%yATWa_!4k(FwPBU@SSE|EM|q{T(CIqHTpb_1EAND`Vg&U zxx^|Oa#ms+*{MZvYBTR#b0dOwwwk3)78_SNL`LR0TIN$+CudJej$OdYV6Zl%4QJo; zGDLQ7fIZ<;`}j9JFU%ju#^Vl9^?42iQe?@Z!(Km;zFA04=at;;6`}wU4a4}*E|13Y zlNKFg(pJ{Nu=8S|svT6k=xU^ZXD??{ULb6caW&Ctk{(hWxkY>PfK}l9<07fyc!-YLC?Kb^1_k(Gd-n7A$Sy4#MS98OK5iF(ekTQv$6pmU!aC4 zckw7?KQGUq{en>`8}mDG{6bY`4Q3pRG9t zS1^PdEhtP5+C!KZiC09*1VzOX@a?hTM5#<=BJWUoK(#2{7Ux4M6o(J?qLXeX-3Q2) zGj6yr<)r`@Rkl!`QI;Q1Clb|6U`8-6_KZ{k+XVJN6kU_kcR^!i(0Xn6X%YAa&|9}f zkfApf5+1X98paY3JzCv}U|%3h2eH?~$5^WXcz!+_0w^>|9u9->?gTu~_k)&gC&=pF zEb-kS9DKx((WH@BxR&G|nXdFl#{$X`yPoR|-YdBY+lw>peXz!&^%!uBL{7wP(Y{n+)twEp+VY*ysd6%;Yi@zS zAaxGVbs!k3V3%luK%_q%i+1H?x4eJ8c|G_M;?hElNBn+V!k525HcH+8SJu_AAjx-E z7kBRxqT~S1Y&a0EYy)qKN(~-GaB~yc%6yNcm(-zbI(5y z$rN<)EPgV6MhwB-)j3*h^mLfFabGz}70if106XYNR{Eg;xO?T%eTbS8bL6FYRmNgV z8j5#yf+h;+e};z6ZD@O3QNj;Kh~9*6hNpJEsACws>FGQe0cDS(M1GRdO|2|`*7;Rh zf`zpHd`#L3@MY$)eK~HUoj-z@7}fw}CsYevw-9Zsa9H)`4!r~&AvufeHQgdDj8mK2 zY+*?;sCi~9Pk$gbDR>YXk_LE%a5@e<@nZ9B-y?g#b_=A11`0&VDy;nlpg}|O%4PrE z2pFQ{Z|7uKk*E__M??a4ChZ-$JtdfxOsQ$sgvANIBgQ;XJ7dlC6qkSK|+j#QQ30bF} zmDFZUw*qlLpD8I>yqMJ`xN&(H!zFf9pUv5gpBgtyU?aR=kl=V8#4QaLRq%9gKdgIf z-Mq^5<`LB>ZU@}^iaJmePju-xHq@;zC<}({99#~4mWmfIBk5Pwvti%Q%Eq=WV|y0t zh%UV&=Kst**zU$5@O;IVoWdxT_VD&u#(aYu>}60GrslBc>pbd1vzw_B5!rFJ-HdyO zKGitq&;0g9uz=2+;Q|1c8h6Ppa3drsx;{F)^Fstg;*k?xXXE<^}`q=HNs=zgf&wE*OUSrg5! zP=VoIqT7Sh5RQi-mnEp&lhV%>Y{+Qo&Q*!miy;Ba)6=1r-+hDzlI}aX;MdDwKU;?K zV95ezNI0E(@2v=c5DBQ*d8)U--h;%QQ!Q<#a=kzE#^y7GEYzw`p-6>Tix9dPyp-no zCMYN&ox~kDB%2zDl7Y1DpU@hBi~0ag{@dkkqwrqCbv)S9lpt$#T5hcKf+NN_}kDr}N$b`}*(oiPX@o z1VyIp-Y$hKTeQ#%vN{1XUQ83PzIAqJ%_bN=z0J&TapA!h^i*J;Z#@UcdD430$;Laa zJWMjy>7F((A9Ub*?Bb+Up~k%)P_(CJW%cTU4y(xf$Y2R_HlRRlI?i^5YRFL}xg4mw zCs4NN6sqNo!;ywg82f!E6!>igE)pim8u&aQq3_CXQWq(p5?|G5iltI&)5*fxUPTyN zlLK3ov)X(s^VKg<!{k^%{qveCK}9GA(u`g;T~= zgNwpj?RIn&S$gY(KX3yKQN{3E=7O5P)N?)rhTnW938djLkoAJj7t4f`Z!4NX;z_Sq zYP?tRLD0F0Vm+JR08MUXTVw=*h~qFei3*68;Gk{*s&sgprQm)5X8&^b7xz#rFDZPXoL9>>^uBB^8wn5GcT0 z181E$=G)G4C5MduRBKT2f5NJRGZ)qkdNP67LWbzWC>N}7+_TyXsaJVwd=`3NNL2eO zXGPqJhmWyn0q!iZs4Z&11(L=m!@KfdzI4Sz#23?vL9zGQ0lr=H(CK29a1lq+w}m$J z{hYwW+ZXO-u*|>LMhvQw8@*EW=AwJW%xfNos5Kor8y2~sp(`db^(+X)>~;rp6X;rE zJB7{W%|Dt+J;(0Z7aEZK@)D5cXC}nKG@tmhzT^7@IUi8OL_>D-G~r&h0WGX%bzPR=)-29;jJqo z-6Ztr@jaMQoOgU(>#$Iae!;HNynXZE(AouZ^8zKZ;-{db+{Q5Rn0n0ZKVns8+L-n|{3T#f<_UKg?NR6=mtS8O0r27GnPGA4@qy&-brKpbt5||4^SpF3ItCeb^2DJq_uvy#fwDwEP?--DT(Ak^h0X zF)=YwQQ65YDXFWg!)XmleRu|Yve+hS*iYoR$U|c9L#+#~K0F;28lK|>crKiT89n*u zb10#@Xrgao1P42$TIixDG;Dl~i0&obu+U$DboWB_nO-`7bKx+V2rW)o(YIQ8G7zVE z-LR~nYnEk6PfAPc+!E|`TpT*YRj}p?!LuEgfd^6WMn#r?MS!0$3?>Czh4gHh)}4n(1(dA&FfDFis((-R+>_7vJj;)Pxt zLEg2j;_q~9c(<)d^3P{5rT{l?uSube@hCi0XlN*o9~U@X!)`R=<_e#!O8IsB1wc%bxt<%i^57qyz7DBbu)9inY-Hin%4l4Mc}w0O zTj4JVu-t<_2~6BSuyKCfOkIYiK|~y5WCxH>cci2K9=&r7T2f>Vy%r393N6avx0C^j zdfgaGQ#&PfR96)t8LM>~P`jYOHHB<@kT0&l5rjoE^G+^?EgQ&K@~jK7adCw;WHJlb zgR)OPo7ug^^CyXV4Z0)HdbcAM3PIs*0owHXzly3AGl6*$sf%$s5j`aD!nN>$ z4iZgGy*3WX&i&bN^Z`ou2z1q=u5h!XRm+H3DcICA)NOn%Z4GPPIOdyEP5|iw<68SE zN4M*6fii;ubvt_26{za{py*KQ0fPzX2&bG{o_cgYzY2TO2UjY$d`a+3+ne`Gbr>{( zpAtRL;VqTR7XUM3P~&ir!0efzN9)r~{!l3xN^e`zEMIJZv#3TxObKT9AR#Z`_3_lW zXR6)I;yd%ETl(9eeitC8)S53svt8@}Xgx{#^I%u`yu2{Q4XmD|0IB6QUcc|#MR&2v z*b}|X&j-s{?ZdT;a2aH2B0v@gaHtRnxSoSBQ9%UI5CZ~d(Sg1sW2_?(({=h0dc5)7 z87iNFY@Vx1UZ7ci+h=GZVk<%G;n4Vu&PoTuKmWkBz zs3n(ZZ5;vLOK%bI?vl>}ZEar-E;7N?5vVR^zgZyApZCy>Vgl{~2Egl)(AQ`Gp0TGB zxla+Ow$TX}9N91=Y$8-aaU(ogj*I6xDP~HR|51;*hS=Wv#OMfmj1)UHtwD%`E|m)F z(nYDV2eqzp!q>e3{Z_APrBg#vgGjv8dhU1`B9i4g(uXIv50w($7O@rz^Vn~r0^6>g z0SJ+B?&5!v>H6oD$s0Hi;^3u%rg!5iUT-3HW7CBTM3>Mr1*Kr0D8jgIUmi6`E9(qF zzXg{$RM25~Z`0os@0-dIwSJC`GHh$nfzPEa$=>y1y7aul;*WPmmDkw7T4i;O`(6<# z&cIFN#7i+{#e)2#5);sGESo^@6T;nVl5*w1<7N8SvFr3J1FIH;c(LCgzNebhw{6ez zJVB90pg5&3xT`F21$L~l>wC{!{R)HCYw$VTnsxAqcolLM16_;tKQes?1x0eo9xPe_ z)M!(KU9*cU9BbFBCbrnOb9A4=tRcWhgN^a8-y}qVT)9=nMM|^i-d-Psw?~rB`sA}v z$4@-}b_@VnN4P8Cun9_1&JP`o8QAx^Ie#4j3Q@0ZFyu>K9tass=tQt~T~Uznhxx*4 z0&`34LwqLcAMML6N;^A{dHVqPjV&5P42_fucr4-YqG9mJsx{#HdVbP$UjDn>l!Zx! z!{&}&MlstX)H7oTsJ_vmKIb3;7pO9iDe(RAY$8qIJ339h@fj|Tmdx&{wCEjj+L4Uq1LW$( zaa>qHUcuP&3-;#(x%xK7ewoP0merYCJPY@wGadYkV}rF$W;?U?EbKrtgd|SpHO)v) zOI^z}q#qL~=SN;L2!5}Pwzdokb0eHvKvY!DXs`cJin*AXD;xaNWSG|LEhHg?1oELj z=5DKA&2+(}g7I(pu1vm*bG4PF*|zVQ2t<%SeIRYubLtr%5uur|ObTobx%yS33yb3FPhx4P5AHsoXJm6$y_q~Fw}yA47E;|+FDVdt_j3Yd zwjmliNOLjjA$_!n>RaE$M3GUy?vSE>DB>d2=S|t4XLEG(7t`V?|8Xm>^9&|c*uXuC zP@Hiq>2PM9RX!OEqdw&EjR)tkvRE;I=bbEij6u1mqd-k69`@9};AsayL6`Dd-nU9E z98D1khvjPSONhUfU~%~2W?_kH`SMPm&-uZ}WxvjSpz6k!Beurem~30mHlJgmFg?gjQQ>OSwUsnnU-SCZ@?9zjc@`D6w`Gz##Hy?P z9_dA75jsESlt^h&w~TMgcYnhmZ-}#Q@7q>BQ<(dhUk_SkOLHlR`xhOc5f%*}$!x~R z#;MDld4N|rXj!Dnn(_loUK5b9!(NJDuzi1x;Dx|K7-BrMxFUs^vXg&KQnt^%Nq{a5 zORk_VCi9E0*SH-gR_T+`XoaiZ0V%A<{WNqPt9>ZKHwzoM;F2R|vRb>W(DjyekF-2Z z{>p1rR=sdAnl%fBRvx0`3_HMRkNXQVtbaFJ395=AXjtYgr)9#m{*c~kz0qt5-zqL_ zs|UM546@~A@QFPXQL&6$+9hriG}zH{9=Htja(!c=b?EqQ!$Edx70=@Oqu#VX&BJVXKH>97(I#ydi{2&5Nx|cBCWe1BFH%L#{tY z#n2LQzLP+RqDE{Op=w(T859;PDhWWj(j`OFAken&dcY)xyddGg%(H+(Pn>yQiPP1E z_r+;vLXMm822=lC;}YP$MjApcL1bONNby&v+mGxRPCkd`8XW}C)qFF3{hV}8yJe(v zgXSfeM67~sWCHYvmmrC1_tdJ&0hin>BtNz2O|Wa@xEmarE)e4joTM|u`VM@^Q&5pW z-a7C%W1helE6Yii+O>6bpt)&07D;7K!mXat6=2L#Z4;BjlG9$lx46tAp^*X5hPt(+7S=%9wR z4MX#uf_ZoiBISt`cA&{xOmn?+pT!Hx;qZ?iAlf@-0KlByfO#ibxgWqD1CCTMD&`(5 z`9jTv?*nle<$K3uofOiW))ATEMdBW+(80dfmZr$BTDK01P!(2z{Pw4W>cQ2$8)U9W zxjR>T7^71@3pnNo&z)Uqrvj$GC3cyrg6SoICoh+_jpljV>*var;$De*-+|^nChWxN zCFxvZbA>!*=_@(Pyo)l5B($fW(s3=h^=V@XN*8Qo6Dmp)xQWpNx2%(t^5NYc!(amx z{@pj)9D30e*+1IXr9DG++kCaZ$rx5m)hI|du?Af**`901`kGS7FNbY%$h>F7WbsVs z=Gi4WVHRFg!DHO-e8tB#(LTEcBK_2v&*)@}iM*xj#Tusv>1b)Y>e4zZm`KWOyJl$h z%2z>tBc;zSrxEg0T`TBZ`d8kCu$9sdCLx%)(6FZ+wu5`+p4$&ijFMw7zfV7sD2Xw; zogrfF=|X}c(y_#O)_>=?n_H5ZlfKlCn4Hc8$<71(@=oT4ME`eLrC7_d;OXjq3GBO~ zcV5Uu40aqx#o+1khMOy9ELa51QR1R)rV-aOwS4OT8eVDV1fL-6jYt1Bq!A49r|mI4 ziB7*V{z9z@=-c8&ty)*r2j#zwl6W?N_Kaf4W|Jp_R}Zec@rr`o3pQTgreb9q9wN7Z z>i^wkjd7JUjXmUL`==2^554edy1~H@^DAh1E>lt_XJr-h>C}2cNKU!M$*Jp&m)n}m z|LWqT!PO0IY+_=fo5aGTDFbRBs#SEVte`Ey3qjf|Xd{f%F==CiOx}KGbt?IO=IOAP z{(KJ8KOJmItefG2g&eAj6F<_Rbt)WNXpYswfALxX>&T%wDPTja&uepIFQ;EQslN|0 z{@9q9UMR57V>=0uQG#i}wj%vaMR_abX2={M4$;4`Is$7B`ycCbCroAmuNe~) zuPz#Ih#)_ER27pI`)Ux2fPYldOy^gfVLX9x z=|Nr?7-wn+KL+GJ_4#v(AX^yx)zaQ$B6!jwput=bu7XDLS>>KRP(*+6Kbpo;ik|w` z`}fyPVA?cAc4A__ITr*sAlfJiWmZVUDJUsJ#kv;;g8u%W1+76DrTi_BA1Nv-UVj2H zBCz5~+2JLMQ?%Um^c3TRg#|IH@axIwbvY;tADh3V86z`uJU^>) { const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, ''); - return can('stack:edit', 'stack', stackName); + return can('stack:edit', 'stack', stackName, activeNode?.id); }, canOfferVolumeRemoval, onDeletedOpenStack: () => onDeletedOpenStackRef.current(), @@ -1059,6 +1059,7 @@ export default function EditorLayout() { can={can} selectedFile={selectedFile} stackName={stackName} + activeNodeId={activeNode?.id ?? null} gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} canSelfUpdate={hasCapability('self-update')} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 777a83c0..7f14b579 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -311,7 +311,7 @@ export function EditorView(props: EditorViewProps) { hasUnsavedChanges, } = props; const monacoEditorRef = useRef(null); - const canEditCompose = can('stack:edit', 'stack', stackName); + const canEditCompose = can('stack:edit', 'stack', stackName, activeNode?.id); // Dispose the underlying Monaco model when EditorView unmounts. The // @monaco-editor/react wrapper reuses a single model per editor instance @@ -358,7 +358,7 @@ export function EditorView(props: EditorViewProps) { const safeContent = content || ''; const safeEnvContent = envContent || ''; const isRunning = safeContainers.some(c => c.State === 'running'); - const canRead = can('stack:read', 'stack', stackName); + const canRead = can('stack:read', 'stack', stackName, activeNode?.id); useEffect(() => { if (activeTab === 'files' && !canRead) { @@ -469,7 +469,7 @@ export function EditorView(props: EditorViewProps) { result={recoveryResult} activeNode={activeNode} backupInfo={backupInfo} - canDeploy={can('stack:deploy', 'stack', stackName)} + canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)} onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })} onRestart={restartStack} onRollback={rollbackStack} @@ -663,7 +663,7 @@ export function EditorView(props: EditorViewProps) { {activeTab === 'files' && canRead ? ( setActiveTab('compose')} onNavigateToEnv={() => setActiveTab('env')} @@ -732,7 +732,7 @@ export function EditorView(props: EditorViewProps) { onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} applying={loadingAction === 'update'} - canEdit={can('stack:edit', 'stack', stackName)} + canEdit={can('stack:edit', 'stack', stackName, activeNode?.id)} notifications={notifications} requestedTab={props.requestedAnatomyTab} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 1c4b81d4..7614b0ef 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -93,7 +93,7 @@ export function MobileStackDetail(props: EditorViewProps) { const safeContainers = containers || []; const isMultiContainerLayout = safeContainers.length > 1 || effectiveServices.length > 1; const isRunning = safeContainers.some(c => c.State === 'running'); - const canEditStack = can('stack:edit', 'stack', stackName); + const canEditStack = can('stack:edit', 'stack', stackName, activeNode?.id); // The writable editor layer renders only for an editor; a stale editingCompose // while the user lacks stack:edit falls back to the read-only Compose segment. @@ -182,7 +182,7 @@ export function MobileStackDetail(props: EditorViewProps) { result={recoveryResult} activeNode={activeNode} backupInfo={backupInfo} - canDeploy={can('stack:deploy', 'stack', stackName)} + canDeploy={can('stack:deploy', 'stack', stackName, activeNode?.id)} onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })} onRestart={restartStack} onRollback={rollbackStack} diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 69be9161..61dd0afe 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -25,9 +25,10 @@ interface ShellOverlaysProps { stackActions: StackActionsHook; isDarkMode: boolean; isAdmin: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; selectedFile: string | null; stackName: string; + activeNodeId: number | null; gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; canSelfUpdate: boolean; @@ -45,6 +46,7 @@ export function ShellOverlays({ can, selectedFile, stackName, + activeNodeId, gitSourceOpen, setGitSourceOpen, canSelfUpdate, @@ -210,7 +212,7 @@ export function ShellOverlays({ open={gitSourceOpen} onOpenChange={setGitSourceOpen} stackName={stackName} - canEdit={can('stack:edit', 'stack', stackName)} + canEdit={can('stack:edit', 'stack', stackName, activeNodeId)} isDarkMode={isDarkMode} onSourceChanged={stackActions.refreshGitSourcePending} /> diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index d82fa62c..bfb2a97b 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -203,8 +203,8 @@ export function StackIdentityHeader({ backend permissions so a delete-only or deploy-only persona sees exactly what they can act on. */} {(() => { - const canDeploy = can('stack:deploy', 'stack', stackName); - const canDelete = can('stack:delete', 'stack', stackName); + const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id); + const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; const canMute = stackMuteActions?.canMute ?? false; diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index e9329318..11ac09a8 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -31,7 +31,7 @@ interface UseSidebarContextMenuOptions { stackActions: StackActionsHook; activeNode: Node | null | undefined; isAdmin: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; } export function useSidebarContextMenu({ @@ -61,9 +61,9 @@ export function useSidebarContextMenu({ canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null, isBusy: stackListState.isStackBusy(file), isAdmin, - canDelete: can('stack:delete', 'stack', sName), - canDeploy: can('stack:deploy', 'stack', sName), - canEditLabels: can('stack:edit', 'stack', sName), + canDelete: can('stack:delete', 'stack', sName, nodeId), + canDeploy: can('stack:deploy', 'stack', sName, nodeId), + canEditLabels: can('stack:edit', 'stack', sName, nodeId), // POST /api/labels (the inline "New label" entry) is guarded by the // unscoped requirePermission('stack:edit'); a user with only per-stack // scoped edit can toggle existing labels but cannot create new ones. diff --git a/frontend/src/components/networking/NetworkingView.tsx b/frontend/src/components/networking/NetworkingView.tsx index 7810ed39..ba80f605 100644 --- a/frontend/src/components/networking/NetworkingView.tsx +++ b/frontend/src/components/networking/NetworkingView.tsx @@ -342,7 +342,7 @@ export function NetworkingView({ headerActions }: NetworkingViewProps) { {topFindings.map((finding) => { const primary = finding.recommendedActions.find((action) => - isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack)), + isNetworkingActionVisible(action, isAdmin, (stack) => can('stack:edit', 'stack', stack, nodeId)), ); return ( diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 0dfbb992..d7597f38 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -36,6 +36,7 @@ interface RoleAssignmentItem { role: UserRole; resource_type: 'stack' | 'node'; resource_id: string; + node_id: number | null; created_at: number; } @@ -209,6 +210,11 @@ export function UsersSection() { setFormRole('viewer'); setEditingUser(null); setShowForm(false); + setRoleAssignments([]); + setScopeResourceType('stack'); + setScopeNodeId(''); + setScopeResourceId(''); + setAvailableStacks([]); }; const handleSave = async () => { @@ -313,16 +319,18 @@ export function UsersSection() { setFormConfirmPassword(''); setShowForm(true); fetchRoleAssignments(u.id); - fetchScopeResources(); + void fetchAvailableNodes(); }; // --- Scoped Role Assignments --- const [roleAssignments, setRoleAssignments] = useState([]); const [scopeResourceType, setScopeResourceType] = useState<'stack' | 'node'>('stack'); + const [scopeNodeId, setScopeNodeId] = useState(''); const [scopeResourceId, setScopeResourceId] = useState(''); const [scopeRole, setScopeRole] = useState('deployer'); const [availableStacks, setAvailableStacks] = useState([]); const [availableNodes, setAvailableNodes] = useState<{ id: number; name: string }[]>([]); + const [loadingStacks, setLoadingStacks] = useState(false); const [addingScope, setAddingScope] = useState(false); const fetchRoleAssignments = async (userId: number) => { @@ -333,16 +341,9 @@ export function UsersSection() { } catch { setRoleAssignments([]); } }; - const fetchScopeResources = async () => { + const fetchAvailableNodes = async () => { try { - const [stacksRes, nodesRes] = await Promise.all([ - apiFetch('/stacks', { localOnly: true }), - apiFetch('/nodes', { localOnly: true }), - ]); - if (stacksRes.ok) { - const data = await stacksRes.json(); - setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); - } + const nodesRes = await apiFetch('/nodes', { localOnly: true }); if (nodesRes.ok) { const data = await nodesRes.json(); setAvailableNodes(Array.isArray(data) ? data.map((n: { id: number; name: string }) => ({ id: n.id, name: n.name })) : []); @@ -350,14 +351,51 @@ export function UsersSection() { } catch { /* ignore */ } }; + const fetchStacksForNode = async (nodeIdStr: string) => { + if (!nodeIdStr) { + setAvailableStacks([]); + return; + } + const nodeId = parseInt(nodeIdStr, 10); + if (!Number.isInteger(nodeId)) { + setAvailableStacks([]); + return; + } + setLoadingStacks(true); + try { + const stacksRes = await apiFetch('/stacks', { nodeId }); + if (stacksRes.ok) { + const data = await stacksRes.json(); + setAvailableStacks(Array.isArray(data) ? data.filter((s: unknown): s is string => typeof s === 'string') : []); + } else { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } + } catch { + setAvailableStacks([]); + toast.error('Failed to load stacks for the selected node.'); + } finally { + setLoadingStacks(false); + } + }; + const addRoleAssignment = async () => { if (!editingUser || !scopeResourceId) return; + if (scopeResourceType === 'stack' && !scopeNodeId) return; setAddingScope(true); try { + const body: Record = { + role: scopeRole, + resource_type: scopeResourceType, + resource_id: scopeResourceId, + }; + if (scopeResourceType === 'stack') { + body.node_id = parseInt(scopeNodeId, 10); + } const res = await apiFetch(`/users/${editingUser.id}/roles`, { method: 'POST', localOnly: true, - body: JSON.stringify({ role: scopeRole, resource_type: scopeResourceType, resource_id: scopeResourceId }), + body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json(); @@ -476,21 +514,29 @@ export function UsersSection() { {roleAssignments.length > 0 && (
- {roleAssignments.map((a) => ( + {roleAssignments.map((a) => { + const nodeLabel = a.resource_type === 'stack' && a.node_id != null + ? (availableNodes.find((n) => n.id === a.node_id)?.name ?? `node ${a.node_id}`) + : null; + return (
{a.role} on {a.resource_type}: {a.resource_id} + {nodeLabel != null && ( + @ {nodeLabel} + )}
- ))} + ); + })}
)} -
+
{ setScopeResourceType(v as 'stack' | 'node'); setScopeResourceId(''); fetchScopeResources(); }} + onValueChange={(v) => { + setScopeResourceType(v as 'stack' | 'node'); + setScopeResourceId(''); + setScopeNodeId(''); + setAvailableStacks([]); + void fetchAvailableNodes(); + }} placeholder="Type..." className="h-8 text-xs w-[100px]" />
-
- + {scopeResourceType === 'stack' && ( +
+ + ({ value: String(n.id), label: n.name }))} + value={scopeNodeId} + onValueChange={(v) => { + setScopeNodeId(v); + setScopeResourceId(''); + void fetchStacksForNode(v); + }} + placeholder="Select node..." + className="h-8 text-xs w-[140px]" + /> +
+ )} +
+ ({ value: s, label: s })) @@ -527,11 +595,25 @@ export function UsersSection() { } value={scopeResourceId} onValueChange={setScopeResourceId} - placeholder="Select..." + placeholder={ + scopeResourceType === 'stack' + ? (loadingStacks ? 'Loading stacks...' : (!scopeNodeId ? 'Select a node first...' : 'Select stack...')) + : 'Select...' + } className="h-8 text-xs" + disabled={scopeResourceType === 'stack' && (!scopeNodeId || loadingStacks)} />
- diff --git a/frontend/src/components/stack/EnvironmentPanel.tsx b/frontend/src/components/stack/EnvironmentPanel.tsx index 78b4a32d..e33cbbc4 100644 --- a/frontend/src/components/stack/EnvironmentPanel.tsx +++ b/frontend/src/components/stack/EnvironmentPanel.tsx @@ -110,7 +110,7 @@ export default function EnvironmentPanel({ stackName }: { stackName: string }) { // Project env file selection const projectEnvCapable = hasCapability('project-env-files'); - const canEdit = can('stack:edit', 'stack', stackName); + const canEdit = can('stack:edit', 'stack', stackName, nodeId); const [projectEnvFiles, setProjectEnvFiles] = useState([]); const [candidates, setCandidates] = useState([]); const [savingProjectEnv, setSavingProjectEnv] = useState(false); diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 08ea7f46..b87e953b 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; import { markMilestone } from '@/lib/hydrationTiming'; +import { resolveCan } from '@/lib/resolveCan'; type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated'; @@ -33,7 +34,7 @@ interface AuthContextType { permissions: PermissionsData | null; permissionsStatus: PermissionsStatus; permissionsReady: boolean; - can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean; + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; login: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; ssoLdapLogin: (username: string, password: string, remember?: boolean) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>; submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>; @@ -128,20 +129,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized); }, []); - const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => { - if (!permissions) return false; - - if (permissions.globalRole === 'admin') return true; - - if (permissions.globalPermissions.includes(action)) return true; - - if (resourceType && resourceId) { - const key = `${resourceType}:${resourceId}`; - return permissions.scopedPermissions[key]?.includes(action) ?? false; - } - - return false; - }, [permissions]); + const can = useCallback(( + action: PermissionAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, + ): boolean => resolveCan(permissions, action, resourceType, resourceId, nodeId), [permissions]); const login = async (username: string, password: string, remember = false): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => { try { diff --git a/frontend/src/lib/__tests__/resolveCan.test.ts b/frontend/src/lib/__tests__/resolveCan.test.ts new file mode 100644 index 00000000..6b177746 --- /dev/null +++ b/frontend/src/lib/__tests__/resolveCan.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { resolveCan, type PermissionsSnapshot } from '../resolveCan'; + +const viewerBase: PermissionsSnapshot = { + globalRole: 'viewer', + globalPermissions: ['stack:read', 'node:read'], + scopedPermissions: {}, +}; + +describe('resolveCan', () => { + it('admin bypasses all checks', () => { + const perms: PermissionsSnapshot = { + globalRole: 'admin', + globalPermissions: [], + scopedPermissions: {}, + }; + expect(resolveCan(perms, 'system:users')).toBe(true); + expect(resolveCan(perms, 'stack:delete', 'stack', 'app', 1)).toBe(true); + }); + + it('grants from the global matrix without needing a resource', () => { + expect(resolveCan(viewerBase, 'stack:read')).toBe(true); + expect(resolveCan(viewerBase, 'stack:deploy')).toBe(false); + }); + + it('treats same stack name on different nodes as independent grants', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:read', 'stack:deploy'], + 'stack:2:frontend': ['stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete', 'node:read', 'node:manage'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 1)).toBe(false); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 2)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 2)).toBe(true); + }); + + it('fails closed for stack lookups when nodeId is missing', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'stack:1:frontend': ['stack:deploy'], + }, + }; + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend')).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', null)).toBe(false); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'frontend', 1)).toBe(true); + }); + + it('keeps node scopes keyed as node:id without a nodeId argument', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'node:manage', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'node', '7')).toBe(true); + expect(resolveCan(perms, 'node:manage', 'node', '8')).toBe(false); + }); + + it('node-scoped grants authorize stack actions on that node only', () => { + const perms: PermissionsSnapshot = { + ...viewerBase, + scopedPermissions: { + 'node:7': ['node:read', 'node:manage', 'stack:read', 'stack:edit', 'stack:deploy', 'stack:create', 'stack:delete'], + }, + }; + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 7)).toBe(true); + expect(resolveCan(perms, 'stack:deploy', 'stack', 'other', 7)).toBe(true); + expect(resolveCan(perms, 'stack:edit', 'stack', 'frontend', 8)).toBe(false); + }); + + it('returns false when permissions are null', () => { + expect(resolveCan(null, 'stack:read')).toBe(false); + }); +}); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 97c5f20d..87bcb9c9 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -40,6 +40,7 @@ export const CAPABILITIES = [ 'guided-external-network-preflight', 'service-scoped-update', 'service-scoped-stack-alert', + 'scoped-stack-auth-evidence', ] as const; export type Capability = (typeof CAPABILITIES)[number]; @@ -54,3 +55,4 @@ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability; export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability; export const SERVICE_SCOPED_STACK_ALERT_CAPABILITY = 'service-scoped-stack-alert' as const satisfies Capability; +export const SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY = 'scoped-stack-auth-evidence' as const satisfies Capability; diff --git a/frontend/src/lib/resolveCan.ts b/frontend/src/lib/resolveCan.ts new file mode 100644 index 00000000..667a4d74 --- /dev/null +++ b/frontend/src/lib/resolveCan.ts @@ -0,0 +1,48 @@ +/** Mirrors AuthContext PermissionAction / UserRole for the pure resolver (no circular import). */ +export type ResolveCanRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor'; + +export type ResolveCanAction = + | 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete' + | 'node:read' | 'node:manage' + | 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks' + | 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries'; + +export interface PermissionsSnapshot { + globalRole: ResolveCanRole; + globalPermissions: ResolveCanAction[]; + scopedPermissions: Record; +} + +/** + * Pure permission resolver for AuthContext.can and unit tests. + * Stack scopes are keyed `stack:${nodeId}:${stackName}`; missing nodeId + * fails closed for stack lookups after the global matrix is checked. + * Node scopes stay `node:${id}` and also authorize that role's stack + * actions for every stack on the node (node-wide semantics). + */ +export function resolveCan( + permissions: PermissionsSnapshot | null, + action: ResolveCanAction, + resourceType?: string, + resourceId?: string, + nodeId?: number | null, +): boolean { + if (!permissions) return false; + + if (permissions.globalRole === 'admin') return true; + + if (permissions.globalPermissions.includes(action)) return true; + + if (!resourceType || !resourceId) return false; + + if (resourceType === 'stack') { + if (nodeId === undefined || nodeId === null) return false; + const stackKey = `stack:${nodeId}:${resourceId}`; + if (permissions.scopedPermissions[stackKey]?.includes(action)) return true; + const nodeKey = `node:${nodeId}`; + return permissions.scopedPermissions[nodeKey]?.includes(action) ?? false; + } + + const key = `${resourceType}:${resourceId}`; + return permissions.scopedPermissions[key]?.includes(action) ?? false; +} From 8deea8af7ec9bc229f0b0e4c71c7494adc6d44d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:29:43 -0400 Subject: [PATCH 02/31] chore(deps-dev): bump @playwright/test in the all-npm-root group (#1730) Bumps the all-npm-root group with 1 update: [@playwright/test](https://github.com/microsoft/playwright). Updates `@playwright/test` from 1.61.1 to 1.62.0 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.0) --- updated-dependencies: - dependency-name: "@playwright/test" dependency-version: 1.62.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-root ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 30 +++++++++++++++--------------- package.json | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9ea38242..a2b08d01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.0.2", - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.0", "husky": "^9.1.7", "otplib": "^13.4.1" }, @@ -408,19 +408,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@scure/base": { @@ -964,35 +964,35 @@ "license": "ISC" }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/require-from-string": { diff --git a/package.json b/package.json index bbd84111..c9cc75ab 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@commitlint/cli": "^21.2.1", "@commitlint/config-conventional": "^21.0.2", - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.0", "husky": "^9.1.7", "otplib": "^13.4.1" } From 754d8af8f80be2d13c7b9fbdf879c7fd66078fcb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:29:46 -0400 Subject: [PATCH 03/31] chore(deps): bump the all-actions group across 1 directory with 2 updates (#1732) Bumps the all-actions group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale). Updates `docker/login-action` from 4.4.0 to 4.6.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...dbcb813823bdd20940b903addbd779551569679f) Updates `actions/stale` from 10.4.0 to 11.0.0 - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-actions - dependency-name: actions/stale dependency-version: 11.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: all-actions ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-dev.yml | 2 +- .github/workflows/docker-preview.yml | 2 +- .github/workflows/docker-publish.yml | 4 ++-- .github/workflows/stale.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 606edfc4..13e06eb5 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -72,7 +72,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io # repository_owner is a fixed value (studio-saelix) on every event diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 46f23b3f..4b1ff42f 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -89,7 +89,7 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ced58280..b5c15c43 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -61,13 +61,13 @@ jobs: run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT" - name: Log in to Docker Hub - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io # repository_owner resolves to a fixed value (studio-saelix) on every diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 915a0a7c..a749558b 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,7 +23,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: days-before-stale: 60 days-before-close: 14 From 55644cf87dbb6d91cd97e964787f2b58660e90c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:53:32 -0400 Subject: [PATCH 04/31] chore(deps): bump the all-npm-backend group in /backend with 9 updates (#1731) * chore(deps): bump the all-npm-backend group in /backend with 9 updates Bumps the all-npm-backend group in /backend with 9 updates: | Package | From | To | | --- | --- | --- | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `13.0.1` | `13.0.2` | | [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) | `8.6.0` | `8.6.1` | | [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git) | `1.38.10` | `1.40.0` | | [systeminformation](https://github.com/sebhildebrandt/systeminformation) | `5.33.0` | `5.33.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.1.2` | | [eslint](https://github.com/eslint/eslint) | `10.7.0` | `10.8.0` | | [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `7.0.2` | | [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1092.0` | `3.1097.0` | | [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1092.0` | `3.1097.0` | Updates `better-sqlite3` from 13.0.1 to 13.0.2 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v13.0.1...v13.0.2) Updates `express-rate-limit` from 8.6.0 to 8.6.1 - [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases) - [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.6.0...v8.6.1) Updates `isomorphic-git` from 1.38.10 to 1.40.0 - [Release notes](https://github.com/isomorphic-git/isomorphic-git/releases) - [Commits](https://github.com/isomorphic-git/isomorphic-git/compare/v1.38.10...v1.40.0) Updates `systeminformation` from 5.33.0 to 5.33.1 - [Release notes](https://github.com/sebhildebrandt/systeminformation/releases) - [Changelog](https://github.com/sebhildebrandt/systeminformation/blob/master/CHANGELOG.md) - [Commits](https://github.com/sebhildebrandt/systeminformation/compare/v5.33.0...v5.33.1) Updates `@types/node` from 26.1.1 to 26.1.2 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` from 10.7.0 to 10.8.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.7.0...v10.8.0) Updates `typescript` from 6.0.2 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) Updates `@aws-sdk/client-ecr` from 3.1092.0 to 3.1097.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1097.0/clients/client-ecr) Updates `@aws-sdk/client-s3` from 3.1092.0 to 3.1097.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1097.0/clients/client-s3) --- updated-dependencies: - dependency-name: better-sqlite3 dependency-version: 13.0.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: express-rate-limit dependency-version: 8.6.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: isomorphic-git dependency-version: 1.40.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: systeminformation dependency-version: 5.33.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: "@types/node" dependency-version: 26.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-backend - dependency-name: eslint dependency-version: 10.8.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-ecr" dependency-version: 3.1097.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend - dependency-name: "@aws-sdk/client-s3" dependency-version: 3.1097.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-backend ... Signed-off-by: dependabot[bot] * fix(deps): retain TypeScript 6 compatibility --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: SaelixCode --- backend/package-lock.json | 334 +++++++++++++++++++------------------- 1 file changed, 167 insertions(+), 167 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 7cc65f43..d77b2932 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { + "@aws-sdk/client-s3": "3.1097.0", "axios": "^1.15.0", "bcrypt": "^6.0.0", "better-sqlite3": "^13.0.1", @@ -74,15 +75,15 @@ } }, "node_modules/@aws-sdk/checksums": { - "version": "3.1000.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.19.tgz", - "integrity": "sha512-Hc4N100RdkuWshKBnhPzmpdftfi9mCLz+OHFELHM1QIgMH4QRUUWyWgfiebta/YX2Bd62wTcm3EqAP8TeXv0gA==", + "version": "3.1000.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.22.tgz", + "integrity": "sha512-YsSac72lcCOSjk5X4fMc20SjltkGUDjckB2vYZcEd/RpgB+huzeQwVZrOWxUvLVo+5D7X9sURgmAAPmErgCQ7w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -91,18 +92,18 @@ } }, "node_modules/@aws-sdk/client-ecr": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1092.0.tgz", - "integrity": "sha512-kovFwhQP08Hn4Aa5zY0EoNyrPYkU+chSIaDBWGV5i7Siq/Oq6C96LVJU2Aq1hK3+RsWQxnFC9GXPPEuDea2KRA==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ecr/-/client-ecr-3.1097.0.tgz", + "integrity": "sha512-3dxiPZ6Dt4hSDB9CQq8NVxEERPZs+s4t7oNIGA7Uyi8hBuQJetHoVLHglaOuIJ3+/2/GXG3wdHupgzpJAmR9jw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-node": "^3.972.71", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-node": "^3.972.74", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -111,21 +112,21 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1092.0.tgz", - "integrity": "sha512-NfcptdANQM1IgUT8QITKBN+PZPjshm5FyLKKjotEwscsDQGik4iDdLgwFYJSTlGoREv26Tf97WHpL7IZ3HF9nA==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1097.0.tgz", + "integrity": "sha512-iCBD95hrynpxiOzD301pUW9H3mxKcEfMErLqdg58WcIZnEqJuOd7JwcARsV3/y6OWj1t6j2tpS9lGt6X4OnPFw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/checksums": "^3.1000.19", - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-node": "^3.972.71", - "@aws-sdk/middleware-sdk-s3": "^3.972.65", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/checksums": "^3.1000.22", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-node": "^3.972.74", + "@aws-sdk/middleware-sdk-s3": "^3.972.68", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -134,17 +135,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.976.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.976.0.tgz", - "integrity": "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==", + "version": "3.977.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.2.tgz", + "integrity": "sha512-8sT/M5vDcagx5/iM0Bfx7f6i3mfVOQkA34+GTMwp0lIWZb6ma+bjkzDS/r9yqU2yTPBqqMBFPT3+d9kUuuNDJA==", "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.36", + "@aws-sdk/xml-builder": "^3.972.37", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.4", - "@smithy/signature-v4": "^5.6.5", + "@smithy/core": "^3.29.8", + "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -154,15 +155,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz", - "integrity": "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.63.tgz", + "integrity": "sha512-VSS9dftt7r7GiZ4gs8z0PNaMLVAaSj/MXVr6WQBtsrQQB9miJo7I6lQuJND1/ugFwK9x7OHCYZDkLSYh0FIZtA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -171,17 +172,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz", - "integrity": "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==", + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.65.tgz", + "integrity": "sha512-SH/ec7p1J0CfC28+ypH38IwGENd7tQEvTpmuRSlinthiGxKlwzJbXGXxIMAhn0/lpxnIxudNmCsw3Cy0PDRoAg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -190,23 +191,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz", - "integrity": "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.8.tgz", + "integrity": "sha512-alkQpDUHsjHGVXvlV0XFXpPfh9+aTMmN6UYRky0Qky8SbvdxoQdDHftT4uugq8XShP6WtDQW7bo5YQ0SfNSxRQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-login": "^3.972.67", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-login": "^3.972.70", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -215,16 +216,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz", - "integrity": "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.70.tgz", + "integrity": "sha512-JlUjK6bYJAxN9PkWWCI/TiOYEdvXNKq61x2DTaEKxRMxAOYNk2LX8m4wVtDFxTZwyXx7Tpmxb49dNprkW/uqXQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -233,21 +234,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.71", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz", - "integrity": "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.74.tgz", + "integrity": "sha512-V+7pzT0OzROL2uKcQ2+MpnfwKONvozYojmdn8RguAMX9o48gtSVvt+7aCkwWCH2thDXOnUPCN6qn4kiFDelZWA==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.60", - "@aws-sdk/credential-provider-http": "^3.972.62", - "@aws-sdk/credential-provider-ini": "^3.973.5", - "@aws-sdk/credential-provider-process": "^3.972.60", - "@aws-sdk/credential-provider-sso": "^3.973.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.66", + "@aws-sdk/credential-provider-env": "^3.972.63", + "@aws-sdk/credential-provider-http": "^3.972.65", + "@aws-sdk/credential-provider-ini": "^3.973.8", + "@aws-sdk/credential-provider-process": "^3.972.63", + "@aws-sdk/credential-provider-sso": "^3.973.7", + "@aws-sdk/credential-provider-web-identity": "^3.972.69", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/core": "^3.29.8", + "@smithy/credential-provider-imds": "^4.4.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -256,15 +257,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.60", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz", - "integrity": "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==", + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.63.tgz", + "integrity": "sha512-lPt2oGMcvP3uPhhxX5EquHrzBI/ZgJce+CHKcOGZl2ZQAXLLSxu7k/Cgo0HIktyi9dmDFljbOkj4XAnXD93YVQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", + "@aws-sdk/core": "^3.977.2", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -273,17 +274,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz", - "integrity": "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==", + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.7.tgz", + "integrity": "sha512-FR2b+7QNXP/q+eslVzrCjGKvso8Lcr/B18BvFyD2iLNhq42XSo+wnh8FfX6mtqgaVsL1vuB27uGXuY+xUTa7pg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", - "@aws-sdk/token-providers": "3.1092.0", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", + "@aws-sdk/token-providers": "3.1097.0", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -292,16 +293,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.66", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz", - "integrity": "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.69.tgz", + "integrity": "sha512-RWNTKGXRkzMJe8bgIAdlz9q0N97m7fThD9KOjBt2CSY+/xnIbrA1/Dnm/ZEz8ZeQ1Of5D+fLaPeoD3lGt6AU4Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -310,16 +311,16 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.972.65", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.65.tgz", - "integrity": "sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.68.tgz", + "integrity": "sha512-JA/LRxCSXQAsFHyIZzgncYdcTQTOL3ZS8R7EAeDwoSoWcNG8yxDAacrwFTZIyVSMvkogvlKHRvmrMU68UyQbkw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -328,18 +329,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.34", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz", - "integrity": "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==", + "version": "3.997.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.37.tgz", + "integrity": "sha512-vfDmA6APjX1LWxvt6/zcAmTCgRXCj35M+bC9Ujmy40QxYs9Fa9bE7oblOB3ODZ4mdN9R5osU0hTzoJjJlQqqTg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.42", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", - "@smithy/fetch-http-handler": "^5.6.6", - "@smithy/node-http-handler": "^4.9.6", + "@smithy/core": "^3.29.8", + "@smithy/fetch-http-handler": "^5.6.10", + "@smithy/node-http-handler": "^4.9.10", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -348,14 +349,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.41", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", - "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "version": "3.996.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", + "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", "license": "Apache-2.0", "optional": true, "dependencies": { "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.5", + "@smithy/signature-v4": "^5.6.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -364,16 +365,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1092.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz", - "integrity": "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==", + "version": "3.1097.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1097.0.tgz", + "integrity": "sha512-EIsdmy/f5IGc5r01RjKWNvrbBra6z0xudQM0D6Wf8DeGuPoRlubkLqr7VgWijFucO4kg0mtev9H3RX/ZOubUhg==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@aws-sdk/core": "^3.976.0", - "@aws-sdk/nested-clients": "^3.997.34", + "@aws-sdk/core": "^3.977.2", + "@aws-sdk/nested-clients": "^3.997.37", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -396,9 +397,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", - "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -530,9 +531,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1190,9 +1191,9 @@ } }, "node_modules/@smithy/core": { - "version": "3.29.7", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.7.tgz", - "integrity": "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", "license": "Apache-2.0", "optional": true, "dependencies": { @@ -1204,13 +1205,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.12", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz", - "integrity": "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.7", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1219,13 +1220,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.6", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.6.tgz", - "integrity": "sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw==", + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1234,13 +1235,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.6.tgz", - "integrity": "sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg==", + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.4", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1249,13 +1250,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.8.tgz", - "integrity": "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==", + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@smithy/core": "^3.29.7", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -1552,9 +1553,9 @@ } }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -2409,10 +2410,9 @@ } }, "node_modules/better-sqlite3": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.1.tgz", - "integrity": "sha512-LYpmOXdkpQYf4wmlxkdzW01XGlOXNIbjLg45yNkh0FQ4814VbK9PdOFmhZpYbej+EZtR/i3FDdhEG98HqZdgnA==", - "hasInstallScript": true, + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.2.tgz", + "integrity": "sha512-jW6oufeDhXZaiX9Lw5A+oerVClx4iFrI6uDj1zu7SqUAjak9vbJvA0NEcKLNxHiQHb6kYCoFzzXYV0YOauhV3g==", "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -3310,9 +3310,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -3322,7 +3322,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -3346,7 +3346,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -3607,9 +3607,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", - "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -4315,9 +4315,9 @@ "license": "ISC" }, "node_modules/isomorphic-git": { - "version": "1.38.10", - "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.10.tgz", - "integrity": "sha512-nJSSq7ypu97vM33rSdxXyPzSWksCberjKspzXhFEcBHdVyxdNkWByAzJ/AURV1jIlfv8pLq37lGdIEt7ORj17Q==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.40.0.tgz", + "integrity": "sha512-/CbnxwZqIm17y3c/z0INbkgEKSvFerXtO/NGgaRxZ8nvL3eoMtbjuAS7f4Pj7lZzj8HaultvDD1ClJTBVDl89g==", "license": "MIT", "dependencies": { "async-lock": "^1.4.1", @@ -4966,13 +4966,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6139,9 +6139,9 @@ } }, "node_modules/systeminformation": { - "version": "5.33.0", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.0.tgz", - "integrity": "sha512-0LYSL01CCbjVeJG7iXI8fUCFU76zMjzbHd/EU3or4QpSFYCLMgslR11prwHuA3siz5jmOkqoLhjgOyDRmXBKmA==", + "version": "5.33.1", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", + "integrity": "sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==", "license": "MIT", "os": [ "darwin", @@ -6477,9 +6477,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { From 3c934066f3d49d5850a42475821a2d47dbf52b2e Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 12:55:30 -0400 Subject: [PATCH 05/31] fix(networking): ignore verified Mesh attachments in drift (#1729) --- .../compose-network-inspector.test.ts | 192 ++++++++- backend/src/__tests__/drift-detection.test.ts | 69 ++++ .../__tests__/managed-mesh-attachment.test.ts | 124 ++++++ .../mesh-remove-override-remote.test.ts | 34 +- backend/src/__tests__/mesh-service.test.ts | 382 ++++++++++++++++++ .../src/__tests__/networking-summary.test.ts | 36 ++ backend/src/services/DriftDetectionService.ts | 34 +- backend/src/services/MeshService.ts | 292 +++++++++++-- .../network/composeNetworkInspector.ts | 12 +- .../services/network/managedMeshAttachment.ts | 55 +++ .../src/services/network/networkingSummary.ts | 10 +- backend/src/services/network/normalize.ts | 10 +- docs/features/compose-networking.mdx | 2 +- docs/features/stack-drift.mdx | 4 +- 14 files changed, 1206 insertions(+), 50 deletions(-) create mode 100644 backend/src/__tests__/managed-mesh-attachment.test.ts create mode 100644 backend/src/services/network/managedMeshAttachment.ts diff --git a/backend/src/__tests__/compose-network-inspector.test.ts b/backend/src/__tests__/compose-network-inspector.test.ts index 702a9328..2ca505b8 100644 --- a/backend/src/__tests__/compose-network-inspector.test.ts +++ b/backend/src/__tests__/compose-network-inspector.test.ts @@ -4,14 +4,21 @@ * runtime-vs-Compose drift comparison (system/default/external networks and * stopped containers are not flagged). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; import type { DeclaredCompose } from '../helpers/composeDependencyParse'; import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController'; import { fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts, + type ManagedNetworkAttachmentPredicate, } from '../services/network/normalize'; -import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { assembleStackNetworkFacts, buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; +import { buildNodeNetworkingFindings } from '../services/network/networkingFindings'; +import type { NetworkingNetworkBase } from '../services/network/networkingTypes'; +import DockerController from '../services/DockerController'; +import { ComposeService } from '../services/ComposeService'; +import { DatabaseService } from '../services/DatabaseService'; +import { FileSystemService } from '../services/FileSystemService'; function effSvc(over: Partial = {}): EffService { const hasHealthcheck = over.hasHealthcheck ?? true; @@ -264,6 +271,187 @@ describe('compareStackNetworks', () => { expect(drift.foreignNetworkAttachments).toEqual([]); }); + it('removes managed Mesh drift before Networking findings while preserving advanced-driver info', () => { + const meshOnlyModel: EffectiveModel = { + projectName: 'myapp', + services: [], + networks: {}, + volumes: {}, + }; + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ id: 'm', name: 'sencho_mesh', driver: 'macvlan', composeProject: null, stack: null })], + ); + const facts = assembleStackNetworkFacts( + 'myapp', + meshOnlyModel, + null, + snap, + (_runtimeContainer, networkName) => networkName === 'sencho_mesh', + ); + const baseNetworks: NetworkingNetworkBase[] = [{ + id: 'm', + name: 'sencho_mesh', + driver: 'macvlan', + scope: 'local', + isSystem: false, + ingress: false, + composeProject: null, + stack: null, + connectedCount: 1, + isSencho: true, + ownership: 'sencho-managed', + declaredByStacks: [], + declaredExternalByStacks: [], + isExternalDependency: false, + }]; + + const findings = buildNodeNetworkingFindings(1, snap, [facts], baseNetworks); + + expect(facts.drift.runtimeOnlyAttachments).toEqual([]); + expect(facts.drift.foreignNetworkAttachments).toEqual([]); + expect(findings.some(f => f.kind === 'network-undeclared' || f.kind === 'foreign-network-attachment')).toBe(false); + expect(findings).toContainEqual(expect.objectContaining({ + kind: 'advanced-driver-caveat', + severity: 'info', + network: 'sencho_mesh', + })); + }); + + it('keeps Networking facts available and Mesh drift actionable when opt-in authority fails', async () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ + rendered: JSON.stringify({ name: 'myapp', services: { web: { image: 'nginx:1.27' } } }), + stderr: '', + code: 0, + timedOut: false, + }), + } as unknown as ComposeService); + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStacks: vi.fn().mockResolvedValue(['myapp']), + } as unknown as FileSystemService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue(snap), + } as unknown as DockerController); + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const facts = await buildStackNetworkFacts(1, 'myapp'); + + expect(facts.runtime).toBe('available'); + expect(facts.drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_mesh' }]); + } finally { + vi.restoreAllMocks(); + } + }); + + it('threads opted-in authority through Networking facts and preserves advanced-driver info', async () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ id: 'm', name: 'sencho_mesh', driver: 'macvlan', composeProject: null, stack: null })], + ); + vi.spyOn(ComposeService, 'getInstance').mockReturnValue({ + renderConfig: vi.fn().mockResolvedValue({ + rendered: JSON.stringify({ name: 'myapp', services: { web: { image: 'nginx:1.27' } } }), + stderr: '', + code: 0, + timedOut: false, + }), + } as unknown as ComposeService); + vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ + getStacks: vi.fn().mockResolvedValue(['myapp']), + } as unknown as FileSystemService); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue(snap), + } as unknown as DockerController); + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(true), + getStackExposureIntents: vi.fn().mockReturnValue([]), + getStackDossier: vi.fn().mockReturnValue(null), + } as unknown as DatabaseService); + + try { + const facts = await buildStackNetworkFacts(1, 'myapp'); + const baseNetworks: NetworkingNetworkBase[] = [{ + id: 'm', name: 'sencho_mesh', driver: 'macvlan', scope: 'local', isSystem: false, + ingress: false, composeProject: null, stack: null, connectedCount: 1, isSencho: true, + ownership: 'sencho-managed', declaredByStacks: [], declaredExternalByStacks: [], + isExternalDependency: false, + }]; + const findings = buildNodeNetworkingFindings(1, snap, [facts], baseNetworks); + + expect(facts.drift.runtimeOnlyAttachments).toEqual([]); + expect(facts.drift.foreignNetworkAttachments).toEqual([]); + expect(findings.filter(f => f.kind === 'network-undeclared' || f.kind === 'foreign-network-attachment')).toEqual([]); + expect(findings).toContainEqual(expect.objectContaining({ + kind: 'advanced-driver-caveat', + severity: 'info', + network: 'sencho_mesh', + })); + } finally { + vi.restoreAllMocks(); + } + }); + + it('ignores a verified Sencho Mesh attachment for the Sencho container', () => { + const snap = snapshot( + [container({ id: 'sencho-id', name: 'sencho', service: 'sencho', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + const managed: ManagedNetworkAttachmentPredicate = (runtimeContainer, networkName) => + runtimeContainer.id === 'sencho-id' && networkName === 'sencho_mesh'; + + const drift = compareStackNetworks(declared, snap, 'myapp', managed); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('ignores a verified Mesh attachment for an opted-in application stack', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + const managed: ManagedNetworkAttachmentPredicate = (_runtimeContainer, networkName) => + networkName === 'sencho_mesh'; + + const drift = compareStackNetworks(declared, snap, 'myapp', managed); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([]); + }); + + it('keeps an unverified manual Mesh attachment actionable', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + [depNet({ name: 'sencho_mesh', composeProject: null, stack: null })], + ); + + const drift = compareStackNetworks(declared, snap, 'myapp', () => false); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_mesh' }]); + }); + + it('keeps sencho_extra actionable even when the stack is Mesh-managed', () => { + const snap = snapshot( + [container({ networks: [{ name: 'sencho_extra', id: 'e', ip: '' }] })], + [depNet({ name: 'sencho_extra', composeProject: null, stack: null })], + ); + + const drift = compareStackNetworks(declared, snap, 'myapp', () => true); + + expect(drift.runtimeOnlyAttachments).toEqual([]); + expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'sencho_extra' }]); + }); + it('does not flag attachments from stopped containers', () => { const snap = snapshot( [container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })], diff --git a/backend/src/__tests__/drift-detection.test.ts b/backend/src/__tests__/drift-detection.test.ts index c5198e68..fa810e2d 100644 --- a/backend/src/__tests__/drift-detection.test.ts +++ b/backend/src/__tests__/drift-detection.test.ts @@ -18,6 +18,7 @@ import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompos import type { DeclaredCompose, DeclaredService, DeclaredPort } from '../helpers/composeDependencyParse'; import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; import { fromDeclaredCompose, fromEffectiveModel } from '../services/network/normalize'; +import { DatabaseService } from '../services/DatabaseService'; // ── builders ──────────────────────────────────────────────────────────── @@ -580,6 +581,32 @@ describe('assembleStackDrift - network drift', () => { expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]); expect(report.status).toBe('in-sync'); }); + + it('reports in-sync when a verified Mesh attachment is the only runtime difference', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + managedNetworkAttachment: (_runtimeContainer, networkName) => networkName === 'sencho_mesh', + }); + + expect(report.status).toBe('in-sync'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toEqual([]); + }); + + it('keeps an unverified manual Mesh attachment drifted', () => { + const report = assembleStackDrift({ + stack: 'app', + declared: declared([service({ name: 'web' })]), + containers: [container({ id: 'c1', service: 'web', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + managedNetworkAttachment: () => false, + }); + + expect(report.status).toBe('drifted'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toHaveLength(1); + }); }); // ── declaredFromEffectiveModel ───────────────────────────────────────────── @@ -815,4 +842,46 @@ describe('buildStackDriftReport - boundaries', () => { expect(findingKinds(report)).toContain('network-undeclared'); vi.restoreAllMocks(); }); + + it('keeps the report available and Mesh drift actionable when opt-in authority fails', async () => { + const snapshot: DependencySnapshot = { + containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + volumes: [], + }; + stubDockerRender({ name: 'app', services: { web: { image: 'nginx:1.25' } } }); + stubFsAndSnapshot(snapshot); + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const report = await buildStackDriftReport(0, 'app'); + + expect(report.status).toBe('drifted'); + expect(report.findings).toContainEqual(expect.objectContaining({ + kind: 'network-undeclared', + actual: 'sencho_mesh', + })); + vi.restoreAllMocks(); + }); + + it('reports in-sync through the public builder when DB authority opts the stack into Mesh', async () => { + const snapshot: DependencySnapshot = { + containers: [container({ id: 'c1', service: 'web', stack: 'app', image: 'nginx:1.25', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }] })], + networks: [depNet('sencho_mesh', { composeProject: null, stack: null })], + volumes: [], + }; + stubDockerRender({ name: 'app', services: { web: { image: 'nginx:1.25' } } }); + stubFsAndSnapshot(snapshot); + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(true), + } as unknown as DatabaseService); + + const report = await buildStackDriftReport(0, 'app'); + + expect(report.status).toBe('in-sync'); + expect(report.findings.filter(f => f.kind === 'network-undeclared')).toEqual([]); + vi.restoreAllMocks(); + }); }); diff --git a/backend/src/__tests__/managed-mesh-attachment.test.ts b/backend/src/__tests__/managed-mesh-attachment.test.ts new file mode 100644 index 00000000..c80fdf11 --- /dev/null +++ b/backend/src/__tests__/managed-mesh-attachment.test.ts @@ -0,0 +1,124 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DatabaseService } from '../services/DatabaseService'; +import SelfIdentityService from '../services/SelfIdentityService'; +import type { DependencyContainer } from '../services/DockerController'; +import { resolveManagedMeshAttachment } from '../services/network/managedMeshAttachment'; + +const originalDataDir = process.env.DATA_DIR; +const originalMode = process.env.SENCHO_MODE; +let tempDir: string | null = null; + +function runtimeContainer(overrides: Partial = {}): DependencyContainer { + return { + id: 'app-id', + name: 'app-web-1', + service: 'web', + composeProject: 'app', + stack: 'app', + state: 'running', + exitCode: null, + image: 'nginx:latest', + networks: [], + volumes: [], + ports: [], + ...overrides, + }; +} + +function stubAuthorities(meshStackEnabled: boolean, ownContainers: string[] = []): void { + vi.spyOn(DatabaseService, 'getInstance').mockReturnValue({ + isMeshStackEnabled: vi.fn().mockReturnValue(meshStackEnabled), + } as unknown as DatabaseService); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + isOwnContainer: vi.fn((idOrName: string) => ownContainers.includes(idOrName)), + } as unknown as SelfIdentityService); +} + +afterEach(async () => { + vi.restoreAllMocks(); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + if (originalMode === undefined) delete process.env.SENCHO_MODE; + else process.env.SENCHO_MODE = originalMode; + if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; +}); + +describe('resolveManagedMeshAttachment', () => { + it('authorizes the canonical Mesh attachment for a centrally opted-in stack', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(true); + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(true); + expect(isManaged(runtimeContainer(), 'sencho_extra')).toBe(false); + }); + + it('authorizes the canonical Mesh attachment for the actual Sencho container', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(false, ['sencho-id']); + const isManaged = await resolveManagedMeshAttachment(1, 'sencho'); + + expect(isManaged(runtimeContainer({ id: 'sencho-id', name: 'sencho' }), 'sencho_mesh')).toBe(true); + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('keeps a manual Mesh attachment actionable for an opted-out stack', async () => { + process.env.SENCHO_MODE = 'server'; + stubAuthorities(false); + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('uses Pilot override presence as the authoritative opt-in representation', async () => { + process.env.SENCHO_MODE = 'pilot'; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-mesh-drift-')); + process.env.DATA_DIR = tempDir; + const overrideDir = path.join(tempDir, 'mesh', 'overrides', '7'); + await fs.mkdir(overrideDir, { recursive: true }); + await fs.writeFile(path.join(overrideDir, 'app.override.yml'), 'services: {}\n'); + stubAuthorities(false); + + const isManaged = await resolveManagedMeshAttachment(7, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(true); + }); + + it('does not let stale server override presence supersede opted-out DB state', async () => { + process.env.SENCHO_MODE = 'server'; + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-mesh-drift-')); + process.env.DATA_DIR = tempDir; + const overrideDir = path.join(tempDir, 'mesh', 'overrides', '1'); + await fs.mkdir(overrideDir, { recursive: true }); + await fs.writeFile(path.join(overrideDir, 'app.override.yml'), 'services: {}\n'); + stubAuthorities(false); + + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + }); + + it('fails closed when Mesh opt-in state cannot be read', async () => { + process.env.SENCHO_MODE = 'server'; + vi.spyOn(DatabaseService, 'getInstance').mockImplementation(() => { + throw new Error('database unavailable'); + }); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + isOwnContainer: vi.fn().mockReturnValue(false), + } as unknown as SelfIdentityService); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const isManaged = await resolveManagedMeshAttachment(1, 'app'); + + expect(isManaged(runtimeContainer(), 'sencho_mesh')).toBe(false); + expect(warn).toHaveBeenCalledWith( + '[NetworkDrift] Could not verify Mesh opt-in state for %s:', + 'app', + 'database unavailable', + ); + }); +}); diff --git a/backend/src/__tests__/mesh-remove-override-remote.test.ts b/backend/src/__tests__/mesh-remove-override-remote.test.ts index 5fc15682..a027ea9a 100644 --- a/backend/src/__tests__/mesh-remove-override-remote.test.ts +++ b/backend/src/__tests__/mesh-remove-override-remote.test.ts @@ -96,7 +96,7 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { db.deleteNode(remoteNodeId); }); - it('swallows network errors from the remote so the disable cascade can continue', async () => { + it('rejects network errors so callers can preserve authoritative opt-in state', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); const remoteNodeId = db.addNode({ @@ -117,11 +117,37 @@ describe('MeshService.removeOverrideFromNode (remote dispatch)', () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED')); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); - // Must not throw: the remote being offline is a tolerable condition; - // the cascade upstream uses Promise.allSettled and continues. - await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')).resolves.toBeUndefined(); + await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')).rejects.toThrow('ECONNREFUSED'); expect(warnSpy).toHaveBeenCalled(); db.deleteNode(remoteNodeId); }); + + it('rejects a non-success response from a busy remote target', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remove-override-busy-test', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: 'https://remote.example.com:1852', + api_token: 'remote-tok', + }); + + vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ + apiUrl: 'https://remote.example.com:1852', + apiToken: 'remote-tok', + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('another operation is already in progress', { status: 500 }), + ); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.removeOverrideFromNode(remoteNodeId, 'sample-stack')) + .rejects.toThrow('HTTP 500'); + + db.deleteNode(remoteNodeId); + }); }); diff --git a/backend/src/__tests__/mesh-service.test.ts b/backend/src/__tests__/mesh-service.test.ts index ea28d274..b4cdd0b7 100644 --- a/backend/src/__tests__/mesh-service.test.ts +++ b/backend/src/__tests__/mesh-service.test.ts @@ -1,6 +1,7 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { EventEmitter } from 'events'; import fsSync from 'fs'; +import fs from 'fs/promises'; import path from 'path'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { getSenchoIpFromSubnet, MeshError, type MeshTarget, type MeshTcpStreamLike } from '../services/MeshService'; @@ -20,6 +21,7 @@ afterAll(() => { }); beforeEach(() => { + process.env.SENCHO_MODE = 'server'; const db = DatabaseService.getInstance().getDb(); db.prepare('DELETE FROM mesh_stacks').run(); db.prepare('DELETE FROM nodes WHERE is_default = 0').run(); @@ -106,6 +108,84 @@ describe('MeshService.optInStack', () => { expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false); }); + it('restores opt-in authority when target override removal is rejected', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'busy-stack', 'setup'); + vi.spyOn(svc, 'removeOverrideFromNode').mockRejectedValue( + new Error('HTTP 500: another operation is already in progress'), + ); + + await expect(svc.optOutStack(localNodeId, 'busy-stack', 'tester')) + .rejects.toThrow('another operation is already in progress'); + + expect(db.isMeshStackEnabled(localNodeId, 'busy-stack')).toBe(true); + }); + + it('serializes concurrent opt-out requests for the same node', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'queued-stack', 'setup'); + let releaseRemoval!: () => void; + const removalPending = new Promise((resolve) => { + releaseRemoval = resolve; + }); + const removeSpy = vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending); + vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, 'regenerateOverridesAcrossFleet') + .mockResolvedValue(undefined); + vi.spyOn(svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, 'cascadeRecomposeAcrossFleet') + .mockImplementation(() => { /* noop */ }); + vi.spyOn(svc, 'triggerRedeploy').mockImplementation(() => { /* noop */ }); + + const first = svc.optOutStack(localNodeId, 'queued-stack', 'tester'); + await vi.waitFor(() => expect(removeSpy).toHaveBeenCalledTimes(1)); + const second = svc.optOutStack(localNodeId, 'queued-stack', 'tester'); + await Promise.resolve(); + expect(removeSpy).toHaveBeenCalledTimes(1); + + releaseRemoval(); + await Promise.all([first, second]); + + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(db.isMeshStackEnabled(localNodeId, 'queued-stack')).toBe(false); + }); + + it('serializes different Mesh mutations per node without blocking another node', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const remoteNodeId = db.addNode({ + name: 'independent-node', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + db.setNodeMeshEnabled(localNodeId, true); + db.insertMeshStack(localNodeId, 'held-stack', 'setup'); + let releaseRemoval!: () => void; + const removalPending = new Promise((resolve) => { + releaseRemoval = resolve; + }); + vi.spyOn(svc, 'removeOverrideFromNode').mockReturnValue(removalPending); + vi.spyOn(svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, 'regenerateOverridesAcrossFleet') + .mockResolvedValue(undefined); + vi.spyOn(svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, 'cascadeRecomposeAcrossFleet') + .mockImplementation(() => { /* noop */ }); + vi.spyOn(svc, 'triggerRedeploy').mockImplementation(() => { /* noop */ }); + + const optOut = svc.optOutStack(localNodeId, 'held-stack', 'tester'); + await vi.waitFor(() => expect(svc.removeOverrideFromNode).toHaveBeenCalledTimes(1)); + const disable = svc.disableForNode(localNodeId, 'tester'); + await svc.enableForNode(remoteNodeId); + + expect(db.getNodeMeshEnabled(localNodeId)).toBe(true); + expect(db.getNodeMeshEnabled(remoteNodeId)).toBe(true); + releaseRemoval(); + await Promise.all([optOut, disable]); + expect(db.getNodeMeshEnabled(localNodeId)).toBe(false); + db.deleteNode(remoteNodeId); + }); + it('rejects an invalid stack name (path traversal attempt)', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); @@ -427,6 +507,44 @@ describe('MeshService.disableForNode', () => { db.deleteNode(remoteNodeId); }); + it('keeps failed remote stacks authoritative when node disable is incomplete', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'remote-partial-disable', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + db.setNodeMeshEnabled(remoteNodeId, true); + db.insertMeshStack(remoteNodeId, 'removed-stack', 'setup'); + db.insertMeshStack(remoteNodeId, 'busy-stack', 'setup'); + vi.spyOn(svc, 'removeOverrideFromNode').mockImplementation(async (_nodeId, stackName) => { + if (stackName === 'busy-stack') throw new Error('target busy'); + }); + vi.spyOn( + svc as unknown as { regenerateOverridesAcrossFleet: () => Promise }, + 'regenerateOverridesAcrossFleet', + ).mockResolvedValue(undefined); + vi.spyOn( + svc as unknown as { cascadeRecomposeAcrossFleet: () => void }, + 'cascadeRecomposeAcrossFleet', + ).mockImplementation(() => { /* noop */ }); + const redeployed: string[] = []; + vi.spyOn(svc, 'triggerRedeploy').mockImplementation((_nodeId, stackName) => { + redeployed.push(stackName); + }); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.disableForNode(remoteNodeId, 'tester')).rejects.toThrow('busy-stack'); + + expect(db.getNodeMeshEnabled(remoteNodeId)).toBe(true); + expect(db.isMeshStackEnabled(remoteNodeId, 'removed-stack')).toBe(false); + expect(db.isMeshStackEnabled(remoteNodeId, 'busy-stack')).toBe(true); + expect(redeployed).toContain('removed-stack'); + db.deleteNode(remoteNodeId); + }); + it('defaults the actor when none is supplied so legacy callers still log a non-empty actor', async () => { const svc = MeshService.getInstance(); const db = DatabaseService.getInstance(); @@ -673,6 +791,62 @@ describe('MeshService.optInStack rollback', () => { .rejects.toThrow(/simulated remote pilot offline/); expect(db.isMeshStackEnabled(localNodeId, 'api')).toBe(false); }); + + it('retains remote authority when the push outcome is unknown', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'ambiguous-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc, 'pushOverrideToNode').mockRejectedValue(new Error('connection reset')); + + await expect(svc.optInStack(remoteNodeId, 'ambiguous-stack', 'tester')) + .rejects.toThrow('connection reset'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'ambiguous-stack')).toBe(true); + db.deleteNode(remoteNodeId); + }); + + it('treats a remote gateway error as an ambiguous push outcome', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'gateway-error-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc as unknown as { proxyFetch: () => Promise }, 'proxyFetch') + .mockResolvedValue(new Response('gateway timeout', { status: 502 })); + + await expect(svc.optInStack(remoteNodeId, 'gateway-error-stack', 'tester')) + .rejects.toThrow('HTTP 502'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'gateway-error-stack')).toBe(true); + db.deleteNode(remoteNodeId); + }); + + it('rolls back remote authority when the target explicitly rejects the push', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const remoteNodeId = db.addNode({ + name: 'rejected-push', type: 'remote', mode: 'pilot_agent', + compose_dir: '/tmp', is_default: false, api_url: '', api_token: '', + }); + vi.spyOn(svc as unknown as { inspectStackServices: (n: number, s: string) => Promise }, 'inspectStackServices') + .mockResolvedValue([{ service: 'web', ports: [8080] }]); + vi.spyOn(svc, 'pushOverrideToNode') + .mockRejectedValue(new MeshError('push_failed', 'target rejected override')); + + await expect(svc.optInStack(remoteNodeId, 'rejected-stack', 'tester')) + .rejects.toThrow('target rejected override'); + + expect(db.isMeshStackEnabled(remoteNodeId, 'rejected-stack')).toBe(false); + db.deleteNode(remoteNodeId); + }); }); describe('MeshService.optInStack guard rails (network setup)', () => { @@ -984,6 +1158,7 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { const db = DatabaseService.getInstance(); const localNodeId = db.getNodes()[0].id; + process.env.SENCHO_MODE = 'pilot'; // Simulate the pilot scenario: no mesh_stacks row (isMeshStackEnabled → false), // but the override file already exists on disk, pushed by central via D-1. const dataDir = process.env.DATA_DIR as string; @@ -1008,6 +1183,184 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { // Cleanup. fsSync.unlinkSync(overrideFile); + process.env.SENCHO_MODE = 'server'; + }); + + it('persists and removes proxy-target opt-in state with a pushed local override', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + + const file = await svc.applyLocalOverride('proxy-stack', []); + + expect(file).not.toBeNull(); + const yaml = fsSync.readFileSync(file as string, 'utf8'); + expect(yaml).toContain('web:'); + expect(yaml).toContain('sencho_mesh'); + expect(fsSync.readdirSync(path.dirname(file as string)).some((name) => name.includes('proxy-stack') && name.endsWith('.tmp'))).toBe(false); + expect(db.isMeshStackEnabled(localNodeId, 'proxy-stack')).toBe(true); + + await svc.removeLocalOverride('proxy-stack'); + + expect(db.isMeshStackEnabled(localNodeId, 'proxy-stack')).toBe(false); + }); + + it('does not write a proxy override when DB authority cannot be recorded', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(db, 'insertMeshStack').mockImplementation(() => { + throw new Error('database unavailable'); + }); + + await expect(svc.applyLocalOverride('db-failure', [])).rejects.toThrow('database unavailable'); + + const overrideFile = path.join( + process.env.DATA_DIR as string, + 'mesh', + 'overrides', + String(localNodeId), + 'db-failure.override.yml', + ); + expect(fsSync.existsSync(overrideFile)).toBe(false); + }); + + it('restores an existing override when DB authority cannot be recorded', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'db-replacement-failure.override.yml'); + const originalYaml = 'services:\n prior:\n networks:\n - sencho_mesh\n'; + fsSync.writeFileSync(overrideFile, originalYaml, 'utf8'); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(db, 'insertMeshStack').mockImplementation(() => { + throw new Error('database unavailable'); + }); + + await expect(svc.applyLocalOverride('db-replacement-failure', [])).rejects.toThrow('database unavailable'); + + expect(fsSync.readFileSync(overrideFile, 'utf8')).toBe(originalYaml); + expect(db.isMeshStackEnabled(localNodeId, 'db-replacement-failure')).toBe(false); + expect(fsSync.readdirSync(overrideDir).some((name) => name.includes('db-replacement-failure') && name.endsWith('.tmp'))).toBe(false); + }); + + it('does not publish DB authority or a final file when atomic override publication fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(fs, 'rename').mockRejectedValue(new Error('rename failed')); + + await expect(svc.applyLocalOverride('write-failure', [])).rejects.toThrow('rename failed'); + + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + expect(db.isMeshStackEnabled(localNodeId, 'write-failure')).toBe(false); + expect(fsSync.existsSync(path.join(overrideDir, 'write-failure.override.yml'))).toBe(false); + expect(fsSync.readdirSync(overrideDir).some((name) => name.includes('write-failure'))).toBe(false); + }); + + it('preserves the prior override and authority when atomic replacement fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'replacement-failure.override.yml'); + const originalYaml = 'services:\n prior:\n networks:\n - sencho_mesh\n'; + fsSync.writeFileSync(overrideFile, originalYaml, 'utf8'); + db.insertMeshStack(localNodeId, 'replacement-failure', 'tester'); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(fs, 'rename').mockRejectedValue(new Error('rename failed')); + + await expect(svc.applyLocalOverride('replacement-failure', [])).rejects.toThrow('rename failed'); + + expect(fsSync.readFileSync(overrideFile, 'utf8')).toBe(originalYaml); + expect(db.isMeshStackEnabled(localNodeId, 'replacement-failure')).toBe(true); + }); + + it('prevents overlapping override mutations for the same stack', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + let releaseServices!: (services: string[]) => void; + const servicesPending = new Promise((resolve) => { + releaseServices = resolve; + }); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockReturnValue(servicesPending); + + const first = svc.applyLocalOverride('concurrent-stack', []); + await vi.waitFor(() => expect(svc.getDeclaredStackServiceNames).toHaveBeenCalledTimes(1)); + + await expect(svc.applyLocalOverride('concurrent-stack', [])).rejects.toThrow('another operation'); + releaseServices(['web']); + await first; + + expect(db.isMeshStackEnabled(localNodeId, 'concurrent-stack')).toBe(true); + }); + + it('prevents removal from overlapping an in-flight override apply', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + let releaseServices!: (services: string[]) => void; + const servicesPending = new Promise((resolve) => { + releaseServices = resolve; + }); + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockReturnValue(servicesPending); + + const apply = svc.applyLocalOverride('apply-remove-stack', []); + await vi.waitFor(() => expect(svc.getDeclaredStackServiceNames).toHaveBeenCalledTimes(1)); + + await expect(svc.removeLocalOverride('apply-remove-stack')).rejects.toThrow('another operation'); + releaseServices(['web']); + const file = await apply; + + expect(file).not.toBeNull(); + expect(fsSync.existsSync(file as string)).toBe(true); + expect(db.isMeshStackEnabled(localNodeId, 'apply-remove-stack')).toBe(true); + }); + + it('does not report committed removal as failed when alias refresh fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'refresh-failure.override.yml'); + fsSync.writeFileSync(overrideFile, 'services: {}\n', 'utf8'); + db.insertMeshStack(localNodeId, 'refresh-failure', 'setup'); + (svc as unknown as { pilotAliasOverlay: Map }).pilotAliasOverlay.set('refresh-failure', []); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + await expect(svc.removeLocalOverride('refresh-failure')).resolves.toBeUndefined(); + + expect(fsSync.existsSync(overrideFile)).toBe(false); + expect(db.isMeshStackEnabled(localNodeId, 'refresh-failure')).toBe(false); + }); + + it('does not report committed apply as failed when alias refresh fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + vi.spyOn(svc, 'getDeclaredStackServiceNames').mockResolvedValue(['web']); + vi.spyOn(svc as unknown as { refreshAliasCache: () => Promise }, 'refreshAliasCache') + .mockRejectedValue(new Error('refresh failed')); + vi.spyOn(console, 'warn').mockImplementation(() => { /* silence */ }); + + const file = await svc.applyLocalOverride('apply-refresh-failure', [], [{ + host: 'web.example', nodeId: localNodeId, nodeName: 'local', stackName: 'apply-refresh-failure', + serviceName: 'web', port: 8080, + }]); + + expect(file).not.toBeNull(); + expect(fsSync.existsSync(file as string)).toBe(true); + expect(db.isMeshStackEnabled(localNodeId, 'apply-refresh-failure')).toBe(true); }); it('returns null for pilot nodes when no pushed override file exists', async () => { @@ -1015,9 +1368,38 @@ describe('MeshService.ensureStackOverride (BUG-1 fix)', () => { const db = DatabaseService.getInstance(); const localNodeId = db.getNodes()[0].id; + process.env.SENCHO_MODE = 'pilot'; // No mesh_stacks row, no file on disk. const result = await svc.ensureStackOverride(localNodeId, 'no-such-stack'); expect(result).toBeNull(); + process.env.SENCHO_MODE = 'server'; + }); + + it('does not use stale override presence as authority on a server', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + const overrideDir = path.join(process.env.DATA_DIR as string, 'mesh', 'overrides', String(localNodeId)); + fsSync.mkdirSync(overrideDir, { recursive: true }); + const overrideFile = path.join(overrideDir, 'stale-server.override.yml'); + fsSync.writeFileSync(overrideFile, 'services: {}\n', 'utf8'); + + const result = await svc.ensureStackOverride(localNodeId, 'stale-server'); + + expect(result).toBeNull(); + fsSync.unlinkSync(overrideFile); + }); + + it('restores proxy-target DB authority when override removal fails', async () => { + const svc = MeshService.getInstance(); + const db = DatabaseService.getInstance(); + const localNodeId = db.getNodes()[0].id; + db.insertMeshStack(localNodeId, 'unlink-failure', 'tester'); + vi.spyOn(fs, 'unlink').mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + + await expect(svc.removeLocalOverride('unlink-failure')).rejects.toThrow('permission denied'); + + expect(db.isMeshStackEnabled(localNodeId, 'unlink-failure')).toBe(true); }); }); diff --git a/backend/src/__tests__/networking-summary.test.ts b/backend/src/__tests__/networking-summary.test.ts index caa3d011..b774b16b 100644 --- a/backend/src/__tests__/networking-summary.test.ts +++ b/backend/src/__tests__/networking-summary.test.ts @@ -45,6 +45,7 @@ describe('networking summary', () => { afterEach(() => { vi.restoreAllMocks(); DatabaseService.getInstance().deleteStackExposureIntents(1, STACK); + DatabaseService.getInstance().deleteMeshStack(1, STACK); fs.rmSync(stackDir, { recursive: true, force: true }); }); @@ -72,6 +73,41 @@ describe('networking summary', () => { expect(res.body.networkDrift.stacks).toContain(STACK); }); + it('does not count an opted-in Mesh attachment as network drift', async () => { + DatabaseService.getInstance().insertMeshStack(1, STACK, 'tester'); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ + containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', exitCode: null, image: 'nginx', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }], volumes: [], ports: [] }], + networks: [ + { id: 'm', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }, + ], + volumes: [], + }), + } as unknown as DockerController); + + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.networkDrift).toEqual({ count: 0, stacks: [] }); + }); + + it('counts an opted-out manual Mesh attachment as network drift', async () => { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDependencySnapshot: vi.fn().mockResolvedValue({ + containers: [{ id: 'c1', name: 'web1', service: 'web', composeProject: STACK, stack: STACK, state: 'running', exitCode: null, image: 'nginx', networks: [{ name: 'sencho_mesh', id: 'm', ip: '' }], volumes: [], ports: [] }], + networks: [ + { id: 'm', name: 'sencho_mesh', driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null }, + ], + volumes: [], + }), + } as unknown as DockerController); + + const res = await request(app).get('/api/networking/summary').set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.networkDrift).toEqual({ count: 1, stacks: [STACK] }); + }); + it('still reports declared signals when the snapshot is unavailable (drift skipped)', async () => { vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getDependencySnapshot: vi.fn().mockRejectedValue(new Error('docker down')), diff --git a/backend/src/services/DriftDetectionService.ts b/backend/src/services/DriftDetectionService.ts index 3151b4d2..69bd9da4 100644 --- a/backend/src/services/DriftDetectionService.ts +++ b/backend/src/services/DriftDetectionService.ts @@ -5,7 +5,12 @@ import { declaredFromEffectiveModel } from '../helpers/effectiveToDeclaredCompos import type { DeclaredCompose, DeclaredService } from '../helpers/composeDependencyParse'; import { parseMissingRequiredVars } from '../helpers/envVarParse'; import { parseEffectiveModel } from './preflight/effectiveModel'; -import { compareStackNetworks, fromDeclaredCompose } from './network/normalize'; +import { + compareStackNetworks, + fromDeclaredCompose, + type ManagedNetworkAttachmentPredicate, +} from './network/normalize'; +import { resolveManagedMeshAttachment } from './network/managedMeshAttachment'; import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; import { isCleanOneShotCompletion } from '../utils/oneShotCompletion'; @@ -111,6 +116,8 @@ export interface AssembleStackDriftInput { containers: DependencyContainer[]; /** Every network on the node (for resolving foreign vs stack-owned attachments). */ networks?: DependencyNetwork[]; + /** Authoritative runtime attachments that are intentionally absent from authored Compose. */ + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate; /** Set when the compose file could not be parsed. */ parseError?: string; } @@ -137,12 +144,18 @@ function networkDriftFindings( declared: DeclaredCompose, containers: DependencyContainer[], networks: DependencyNetwork[], + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate, ): StackDriftFinding[] { // Runtime resource names use the Compose project (top-level `name:` when set), // not the stack directory, so a stack with `name:` resolves its networks the // same way Docker does. Containers are still attributed to the stack directory. const normalized = fromDeclaredCompose(declared, declared.projectName ?? stack); - const facts = compareStackNetworks(normalized, { containers, networks, volumes: [] }, stack); + const facts = compareStackNetworks( + normalized, + { containers, networks, volumes: [] }, + stack, + managedNetworkAttachment, + ); const findings: StackDriftFinding[] = []; const serviceByContainer = new Map(containers.map(c => [c.name, c.service ?? c.name])); @@ -293,7 +306,13 @@ export function assembleStackDrift(input: AssembleStackDriftInput): StackDriftRe } } - findings.push(...networkDriftFindings(stack, declared, containers, networks)); + findings.push(...networkDriftFindings( + stack, + declared, + containers, + networks, + input.managedNetworkAttachment, + )); const status: StackDriftStatus = findings.length > 0 ? 'drifted' : 'in-sync'; return { stack, status, hasComposeFile: true, hasContainers, findings }; @@ -384,5 +403,12 @@ export async function buildStackDriftReport(nodeId: number, stackName: string): }; } - return assembleStackDrift({ stack: stackName, declared: render.declared, containers, networks }); + const managedNetworkAttachment = await resolveManagedMeshAttachment(nodeId, stackName); + return assembleStackDrift({ + stack: stackName, + declared: render.declared, + containers, + networks, + managedNetworkAttachment, + }); } diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index b7b0ff85..ca87971d 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -1,6 +1,7 @@ import net from 'net'; import path from 'path'; import fs from 'fs/promises'; +import { randomUUID } from 'crypto'; import { EventEmitter } from 'events'; import * as YAML from 'yaml'; import { ComposeService } from './ComposeService'; @@ -20,6 +21,7 @@ import { STREAM_PENDING_DATA_MAX_BYTES } from '../pilot/protocol'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { isDebugEnabled } from '../utils/debug'; import { isPathWithinBase, isValidStackName, isValidRelativeStackPath } from '../utils/validation'; +import { getErrorMessage } from '../utils/errors'; import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; @@ -1497,6 +1499,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // --- Opt-in / opt-out --- public async optInStack(nodeId: number, stackName: string, actor: string): Promise { + return this.runMeshNodeMutation(nodeId, () => this.optInStackExclusive(nodeId, stackName, actor)); + } + + private async optInStackExclusive(nodeId: number, stackName: string, actor: string): Promise { this.logDiag('opt-in start', { nodeId, stackName, actor }); const t0 = Date.now(); if (!isValidStackName(stackName)) { @@ -1541,19 +1547,41 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } db.insertMeshStack(nodeId, stackName, actor); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + db.deleteMeshStack(nodeId, stackName); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (rollbackError) { + console.warn('[MeshService] Failed to refresh aliases after opt-in rollback:', sanitizeForLog(getErrorMessage(rollbackError, 'unknown'))); + } + throw error; + } - // Push the just-opted-in stack's override loudly. If this fails the - // DB state is invalid (alias claimed but remote pilot has no - // override file) so roll back rather than leave a half-state that - // future opt-in calls would short-circuit on `isMeshStackEnabled`. + // Push the just-opted-in stack's override loudly. Explicit target + // rejection rolls back the row. A remote transport failure is + // ambiguous because the target may already have committed, so retain + // authority and let normal regeneration reconcile it. try { await this.pushOverrideToNode(nodeId, stackName); } catch (err) { - db.deleteMeshStack(nodeId, stackName); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + const node = db.getNode(nodeId); + const explicitlyRejected = node?.type !== 'remote' || err instanceof MeshError; + if (explicitlyRejected) { + db.deleteMeshStack(nodeId, stackName); + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } else { + this.logActivity({ + source: 'mesh', level: 'warn', type: 'forwarder.error', + nodeId, + message: `mesh override push outcome unknown for ${stackName}; retaining opt-in authority for reconciliation`, + details: { stackName }, + }); + } throw err; } // Regenerate every other meshed stack's override across the fleet @@ -1584,6 +1612,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } public async optOutStack(nodeId: number, stackName: string, actor: string): Promise { + return this.runMeshNodeMutation(nodeId, () => this.optOutStackExclusive(nodeId, stackName, actor)); + } + + private async optOutStackExclusive(nodeId: number, stackName: string, actor: string): Promise { this.logDiag('opt-out start', { nodeId, stackName, actor }); if (!isValidStackName(stackName)) { throw new MeshError('denied', `invalid stack name: ${stackName}`); @@ -1591,9 +1623,18 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const db = DatabaseService.getInstance(); if (!db.isMeshStackEnabled(nodeId, stackName)) return; db.deleteMeshStack(nodeId, stackName); - await this.removeOverrideFromNode(nodeId, stackName); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.removeOverrideFromNode(nodeId, stackName); + } catch (error) { + db.insertMeshStack(nodeId, stackName, actor); + throw error; + } + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed opt-out:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } // The opted-out row is already deleted, so listMeshStacks() will not // include it. Walk the remaining fleet-wide rows so every other // meshed stack regenerates its override without the dropped alias. @@ -1620,6 +1661,10 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } public async enableForNode(nodeId: number): Promise { + return this.runMeshNodeMutation(nodeId, () => this.enableForNodeExclusive(nodeId)); + } + + private async enableForNodeExclusive(nodeId: number): Promise { this.logDiag('enable-for-node', { nodeId }); DatabaseService.getInstance().setNodeMeshEnabled(nodeId, true); this.logActivity({ @@ -1642,26 +1687,42 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { nodeId: number, actor: string = 'system:mesh.disable', ): Promise { + return this.runMeshNodeMutation(nodeId, () => this.disableForNodeExclusive(nodeId, actor)); + } + + private async disableForNodeExclusive(nodeId: number, actor: string): Promise { this.logDiag('disable-for-node start', { nodeId, actor }); const t0 = Date.now(); - DatabaseService.getInstance().setNodeMeshEnabled(nodeId, false); - const stacks = DatabaseService.getInstance().listMeshStacks(nodeId); - for (const s of stacks) { - DatabaseService.getInstance().deleteMeshStack(nodeId, s.stack_name); - } + const db = DatabaseService.getInstance(); + const stacks = db.listMeshStacks(nodeId); // Dispatch DELETE /api/mesh/local-override/:stack for remote nodes // (pilot or proxy) so the override file pushed earlier via // applyLocalOverride is removed; falls back to local deletion for // local nodes. Parallelize per the regenerateOverridesForNode // rationale: each remote call is its own HTTP round-trip, so // awaiting sequentially turns N stacks into N serialised DELETEs. - // `allSettled` so a single failure does not abort the others - // (removeOverrideFromNode already swallows errors internally). - await Promise.allSettled( + // `allSettled` so a single failure does not abort the others. + const removals = await Promise.allSettled( stacks.map((s) => this.removeOverrideFromNode(nodeId, s.stack_name)), ); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + const failed: string[] = []; + const removed: typeof stacks = []; + removals.forEach((result, index) => { + const stack = stacks[index]; + if (result.status === 'fulfilled') { + db.deleteMeshStack(nodeId, stack.stack_name); + removed.push(stack); + } else { + failed.push(stack.stack_name); + } + }); + if (failed.length === 0) db.setNodeMeshEnabled(nodeId, false); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed node disable changes:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } // Mirror optOutStack: regenerate every remaining node's override // without the dropped aliases, recompose the rest of the fleet so // their containers shed the stale extra_hosts, and redeploy the @@ -1669,9 +1730,15 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // sencho_mesh network and lose the alias entries they owned. await this.regenerateOverridesAcrossFleet(); this.cascadeRecomposeAcrossFleet(undefined, undefined, actor); - for (const s of stacks) { + for (const s of removed) { this.triggerRedeploy(nodeId, s.stack_name, actor); } + if (failed.length > 0) { + throw new MeshError( + 'push_failed', + `Could not disable Mesh on node ${nodeId}: override removal failed for ${failed.join(', ')}.`, + ); + } this.logDiag('disable-for-node complete', { nodeId, stacks: stacks.length, ms: Date.now() - t0 }); this.logActivity({ source: 'mesh', level: 'info', type: 'mesh.disable', @@ -1679,6 +1746,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { }); } + private readonly meshNodeMutations = new Map>(); + + private async runMeshNodeMutation(nodeId: number, operation: () => Promise): Promise { + const previous = this.meshNodeMutations.get(nodeId) ?? Promise.resolve(); + let release!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + this.meshNodeMutations.set(nodeId, pending); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.meshNodeMutations.get(nodeId) === pending) this.meshNodeMutations.delete(nodeId); + } + } + // --- Override file management --- public async ensureStackOverride(nodeId: number, stackName: string): Promise { @@ -1690,6 +1775,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { // lives on central per the C-3 design). Use file-presence as the // fallback: if central pushed an override via applyLocalOverride, return // that path so ComposeService picks it up on the next deploy. + if (process.env.SENCHO_MODE !== 'pilot') return null; const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return null; try { @@ -1754,13 +1840,37 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { portAliases?: MeshGlobalAlias[], ): Promise { if (!isValidStackName(stackName)) return null; - if (!this.senchoIp) { + const senchoIp = this.senchoIp; + if (!senchoIp) { throw new MeshError( 'push_failed', this.networkSetupError || 'mesh data plane unavailable on this node', ); } const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const lock = await StackOpLockService.getInstance().runExclusive( + localNodeId, + stackName, + 'deploy', + 'system:mesh.override', + () => this.applyLocalOverrideExclusive(localNodeId, stackName, aliases, senchoIp, portAliases), + ); + if (!lock.ran) { + throw new MeshError( + 'push_failed', + `Cannot apply Mesh override for "${stackName}": another operation (${lock.existing.action}) is already in progress.`, + ); + } + return lock.result; + } + + private async applyLocalOverrideExclusive( + localNodeId: number, + stackName: string, + aliases: MeshAlias[], + senchoIp: string, + portAliases?: MeshGlobalAlias[], + ): Promise { const serviceNames = await this.getDeclaredStackServiceNames(stackName, localNodeId); const dir = this.overrideDirFor(localNodeId); @@ -1785,6 +1895,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { message: `mesh override preserved for ${stackName}: declared services unreadable, keeping ${existing.length} existing entries`, details: { stackName, preservedServices: existing }, }); + this.recordLocalOverrideIntent(localNodeId, stackName); return file; } } @@ -1792,13 +1903,24 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const yaml = generateOverrideYaml({ services: serviceNames, aliases, - senchoIp: this.senchoIp, + senchoIp, }); - await fs.writeFile(file, yaml, 'utf8'); + const previousYaml = await this.readOverrideContent(file); + await this.writeOverrideAtomically(file, yaml); + try { + this.recordLocalOverrideIntent(localNodeId, stackName); + } catch (error) { + await this.restoreOverrideAfterAuthorityFailure(file, previousYaml); + throw error; + } if (portAliases && portAliases.length > 0) { this.pilotAliasOverlay.set(stackName, portAliases); - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed override apply:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } } return file; } @@ -1810,13 +1932,95 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { public async removeLocalOverride(stackName: string): Promise { if (!isValidStackName(stackName)) return; const localNodeId = NodeRegistry.getInstance().getDefaultNodeId(); + const lock = await StackOpLockService.getInstance().runExclusive( + localNodeId, + stackName, + 'deploy', + 'system:mesh.override', + () => this.removeLocalOverrideExclusive(localNodeId, stackName), + ); + if (!lock.ran) { + throw new MeshError( + 'push_failed', + `Cannot remove Mesh override for "${stackName}": another operation (${lock.existing.action}) is already in progress.`, + ); + } + } + + private async removeLocalOverrideExclusive(localNodeId: number, stackName: string): Promise { const dir = this.overrideDirFor(localNodeId); const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return; - try { await fs.unlink(file); } catch { /* ignore not-exist */ } + const db = DatabaseService.getInstance(); + const hadIntent = db.isMeshStackEnabled(localNodeId, stackName); + if (hadIntent) db.deleteMeshStack(localNodeId, stackName); + try { + await fs.unlink(file); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) { + if (hadIntent) db.insertMeshStack(localNodeId, stackName, null); + throw error; + } + } if (this.pilotAliasOverlay.delete(stackName)) { - await this.refreshAliasCache(); - await this.syncForwarderListeners(); + try { + await this.refreshAliasCache(); + await this.syncForwarderListeners(); + } catch (error) { + console.warn('[MeshService] Failed to refresh aliases after committed override removal:', sanitizeForLog(getErrorMessage(error, 'unknown'))); + } + } + } + + private async writeOverrideAtomically(file: string, yaml: string): Promise { + const dir = path.dirname(file); + const tempFile = path.resolve(dir, `.${path.basename(file)}.${randomUUID()}.tmp`); + if (!isPathWithinBase(tempFile, dir)) throw new Error('Invalid Mesh override temporary path'); + + let handle: Awaited> | null = null; + try { + handle = await fs.open(tempFile, 'wx'); + await handle.writeFile(yaml, 'utf8'); + await handle.sync(); + await handle.close(); + handle = null; + await fs.rename(tempFile, file); + } catch (error) { + if (handle) { + try { await handle.close(); } catch (closeError) { + console.warn('[MeshService] Failed to close temporary override:', sanitizeForLog(getErrorMessage(closeError, 'unknown'))); + } + } + try { + await fs.unlink(tempFile); + } catch (cleanupError) { + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) { + console.warn('[MeshService] Failed to clean up temporary override:', sanitizeForLog(getErrorMessage(cleanupError, 'unknown'))); + } + } + throw error; + } + } + + private async readOverrideContent(file: string): Promise { + try { + return await fs.readFile(file, 'utf8'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null; + throw error; + } + } + + private async restoreOverrideAfterAuthorityFailure(file: string, previousYaml: string | null): Promise { + try { + if (previousYaml === null) { + await fs.unlink(file); + } else { + await this.writeOverrideAtomically(file, previousYaml); + } + } catch (error) { + if (previousYaml === null && error instanceof Error && 'code' in error && error.code === 'ENOENT') return; + console.warn('[MeshService] Failed to restore override after authority error:', sanitizeForLog(getErrorMessage(error, 'unknown'))); } } @@ -1825,7 +2029,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { const dir = this.overrideDirFor(nodeId); const file = path.resolve(dir, `${path.basename(stackName)}.override.yml`); if (!isPathWithinBase(file, dir)) return; - try { await fs.unlink(file); } catch { /* ignore not-exist */ } + try { + await fs.unlink(file); + } catch (error) { + if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; + } } private overrideDirFor(nodeId: number): string { @@ -1833,6 +2041,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { return path.join(dataDir, 'mesh', 'overrides', String(nodeId)); } + private recordLocalOverrideIntent(nodeId: number, stackName: string): void { + if (process.env.SENCHO_MODE === 'pilot') return; + const db = DatabaseService.getInstance(); + if (db.isMeshStackEnabled(nodeId, stackName)) return; + db.insertMeshStack(nodeId, stackName, null); + } + private async regenerateOverridesForNode(nodeId: number, skipStack?: string): Promise { const db = DatabaseService.getInstance(); const stacks = db.listMeshStacks(nodeId); @@ -2331,7 +2546,11 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { ); } if (!res.ok) { - throw new MeshError('push_failed', `HTTP ${res.status} from node ${node.name}`); + const message = `HTTP ${res.status} from node ${node.name}`; + if (res.status >= 400 && res.status < 500) { + throw new MeshError('push_failed', message); + } + throw new Error(message); } } @@ -2436,15 +2655,20 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { } try { - await this.proxyFetch( + const response = await this.proxyFetch( nodeId, 'DELETE', `/api/mesh/local-override/${encodeURIComponent(stackName)}`, undefined, 5_000, ); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status} from node ${node.name}: ${body.slice(0, 256)}`); + } } catch (err) { console.warn('[MeshService] removeOverrideFromNode failed:', sanitizeForLog((err as Error).message)); + throw err; } } diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index 1cd05f1b..9e667404 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -23,6 +23,8 @@ import type { NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts, } from './types'; import { classifyMissingExternalNetworks, type MissingExternalNetwork } from './missingExternalNetworks'; +import { resolveManagedMeshAttachment } from './managedMeshAttachment'; +import type { ManagedNetworkAttachmentPredicate } from './normalize'; import { getErrorMessage } from '../../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; @@ -43,6 +45,7 @@ export function assembleStackNetworkFacts( model: EffectiveModel | null, renderError: string | null, snapshot: DependencySnapshot | null, + managedNetworkAttachment?: ManagedNetworkAttachmentPredicate, ): StackNetworkFacts { const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable'; @@ -82,7 +85,9 @@ export function assembleStackNetworkFacts( extraHosts: s.extraHosts, })); - const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT; + const drift = snapshot + ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName, managedNetworkAttachment) + : EMPTY_DRIFT; const missingExternalNetworks: MissingExternalNetwork[] = snapshot ? classifyMissingExternalNetworks( model, @@ -147,5 +152,8 @@ export async function buildStackNetworkFacts( } } - return assembleStackNetworkFacts(stackName, model, renderError, snapshot); + const managedNetworkAttachment = snapshot && model + ? await resolveManagedMeshAttachment(nodeId, stackName) + : undefined; + return assembleStackNetworkFacts(stackName, model, renderError, snapshot, managedNetworkAttachment); } diff --git a/backend/src/services/network/managedMeshAttachment.ts b/backend/src/services/network/managedMeshAttachment.ts new file mode 100644 index 00000000..93c8ce4c --- /dev/null +++ b/backend/src/services/network/managedMeshAttachment.ts @@ -0,0 +1,55 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { DatabaseService } from '../DatabaseService'; +import { SENCHO_MESH_NETWORK } from '../MeshComposeOverride'; +import SelfIdentityService from '../SelfIdentityService'; +import { getErrorMessage } from '../../utils/errors'; +import { sanitizeForLog } from '../../utils/safeLog'; +import { isPathWithinBase, isValidStackName } from '../../utils/validation'; +import type { ManagedNetworkAttachmentPredicate } from './normalize'; + +async function hasPilotMeshOverride(nodeId: number, stackName: string): Promise { + if (process.env.SENCHO_MODE !== 'pilot' || !isValidStackName(stackName)) return false; + + const dataDir = process.env.DATA_DIR || '/app/data'; + const overrideDir = path.resolve(dataDir, 'mesh', 'overrides', String(nodeId)); + const overridePath = path.resolve(overrideDir, `${path.basename(stackName)}.override.yml`); + if (!isPathWithinBase(overridePath, overrideDir)) return false; + + try { + await fs.access(overridePath); + return true; + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false; + console.warn( + '[NetworkDrift] Could not verify Pilot Mesh override for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return false; + } +} + +export async function resolveManagedMeshAttachment( + nodeId: number, + stackName: string, +): Promise { + let stackManaged = false; + try { + stackManaged = DatabaseService.getInstance().isMeshStackEnabled(nodeId, stackName); + } catch (error) { + console.warn( + '[NetworkDrift] Could not verify Mesh opt-in state for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + } + if (!stackManaged) stackManaged = await hasPilotMeshOverride(nodeId, stackName); + const selfIdentity = SelfIdentityService.getInstance(); + + return (container, networkName) => networkName === SENCHO_MESH_NETWORK && ( + stackManaged + || selfIdentity.isOwnContainer(container.id) + || selfIdentity.isOwnContainer(container.name) + ); +} diff --git a/backend/src/services/network/networkingSummary.ts b/backend/src/services/network/networkingSummary.ts index aeb24696..80874865 100644 --- a/backend/src/services/network/networkingSummary.ts +++ b/backend/src/services/network/networkingSummary.ts @@ -10,6 +10,7 @@ import { FileSystemService } from '../FileSystemService'; import { DatabaseService } from '../DatabaseService'; import { parseComposeDependencies } from '../../helpers/composeDependencyParse'; import { assembleStackDrift } from '../DriftDetectionService'; +import { resolveManagedMeshAttachment } from './managedMeshAttachment'; import { isHostNetwork, isLoopback } from './normalize'; import { getErrorMessage } from '../../utils/errors'; import { sanitizeForLog } from '../../utils/safeLog'; @@ -86,7 +87,14 @@ export async function computeNodeNetworkingSummary(nodeId: number): Promise c.stack === stack); - const report = assembleStackDrift({ stack, declared, containers, networks: snapshot.networks }); + const managedNetworkAttachment = await resolveManagedMeshAttachment(nodeId, stack); + const report = assembleStackDrift({ + stack, + declared, + containers, + networks: snapshot.networks, + managedNetworkAttachment, + }); if (report.findings.some(f => f.kind === 'network-undeclared' || f.kind === 'network-missing')) networkDrift.push(stack); } } diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts index c2694cb1..523c4f18 100644 --- a/backend/src/services/network/normalize.ts +++ b/backend/src/services/network/normalize.ts @@ -8,8 +8,9 @@ */ import type { EffectiveModel } from '../preflight/effectiveModel'; import type { DeclaredCompose } from '../../helpers/composeDependencyParse'; -import type { DependencySnapshot } from '../DockerController'; +import type { DependencyContainer, DependencySnapshot } from '../DockerController'; import type { NetworkDriftFacts } from './types'; +import { SENCHO_MESH_NETWORK } from '../MeshComposeOverride'; /** Container states that count as "deployed" for drift, matching DriftDetectionService. */ const RUNNING_STATES = new Set(['running', 'restarting']); @@ -62,6 +63,11 @@ export interface NormalizedNetworkModel { services: { name: string; networkKeys: string[]; networkMode?: string }[]; } +export type ManagedNetworkAttachmentPredicate = ( + container: DependencyContainer, + networkName: string, +) => boolean; + /** Rendered model: resource names are already resolved by `docker compose config`. */ export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel { const networks: NormalizedNetworkModel['networks'] = {}; @@ -97,6 +103,7 @@ export function compareStackNetworks( declared: NormalizedNetworkModel, snapshot: DependencySnapshot, stackName: string, + isManagedAttachment: ManagedNetworkAttachmentPredicate = () => false, ): NetworkDriftFacts { const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = []; const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = []; @@ -118,6 +125,7 @@ export function compareStackNetworks( const net = networkByName.get(attached.name); if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue; if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; } + if (attached.name === SENCHO_MESH_NETWORK && isManagedAttachment(c, attached.name)) continue; if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) { runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name }); } else { diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index f1cad0bb..8da7bdcf 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -153,7 +153,7 @@ When the node is reachable, the tab compares the declared effective model agains | **Declared but unused** | A network is declared in the Compose file but no currently running service is connected to it. Often seen when a service is stopped or removed without `docker compose down`. | | **Missing from runtime** | A network is declared but does not exist in Docker. The stack may not have been deployed, or the network was deleted externally. | -System-managed networks (`bridge`, `host`, `none`) and Docker's implicit default bridge are excluded from all drift findings. +System-managed networks (`bridge`, `host`, `none`) and Docker's implicit default bridge are excluded from all drift findings. Attachments to `sencho_mesh` are also excluded when Sencho verifies that the container is its own instance or that the stack is opted into Sencho Mesh. A manual attachment from an opted-out stack remains visible as drift. When the runtime matches the Compose file, the section shows a green **runtime matches compose** card. diff --git a/docs/features/stack-drift.mdx b/docs/features/stack-drift.mdx index 81cf7404..246e90e1 100644 --- a/docs/features/stack-drift.mdx +++ b/docs/features/stack-drift.mdx @@ -83,7 +83,7 @@ A Compose port range such as `8000-8002:8000-8002` is compared conservatively. B ### Network findings and the Networking tab -The **network-undeclared** and **network-missing** findings reuse the same comparison the stack's [Networking](/features/compose-networking) tab uses, so the two surfaces never disagree about a network attachment. The difference is history: this tab persists findings in the drift ledger over time, while the Networking tab always shows the current live state with no history. A stack with an open network finding also counts toward the **Drift** chip in the Fleet Overview's Networking filter group, so a network-attachment mismatch is visible both per-stack here and across the fleet there. +The **network-undeclared** and **network-missing** findings reuse the same comparison the stack's [Networking](/features/compose-networking) tab uses, so the two surfaces never disagree about a network attachment. Sencho-verified `sencho_mesh` attachments for its own container and Mesh-opted-in stacks are excluded, while a manual attachment from an opted-out stack remains actionable. The difference is history: this tab persists findings in the drift ledger over time, while the Networking tab always shows the current live state with no history. A stack with an open network finding also counts toward the **Drift** chip in the Fleet Overview's Networking filter group, so a network-attachment mismatch is visible both per-stack here and across the fleet there. ## When drift is recorded @@ -93,6 +93,8 @@ The **network-undeclared** and **network-missing** findings reuse the same compa **After every deploy or update**, Sencho automatically records a new baseline hash and runs a full reconciliation. You do not need to click re-check after deploying; the ledger is updated as part of the deploy pipeline. +An open ledger entry for an attachment that is no longer reported clears during the next re-check or post-deploy reconciliation. Opening the tab refreshes the live report but does not change persisted history. + The Drift tab showing both the Findings section and the Drift history section with an open finding marked just now Date: Wed, 29 Jul 2026 12:58:43 -0400 Subject: [PATCH 06/31] chore(deps): bump the all-npm-frontend group in /frontend with 25 updates (#1733) * chore(deps): bump the all-npm-frontend group Bumps the all-npm-frontend group in /frontend with 25 updates: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.8` | `1.3.11` | | [@radix-ui/react-context-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/context-menu) | `2.3.4` | `2.3.7` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.21` | `2.1.24` | | [@radix-ui/react-hover-card](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/hover-card) | `1.1.20` | `1.1.23` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.12` | `2.1.15` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.20` | `1.1.23` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.15` | `1.2.18` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.4` | `2.3.7` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.12` | `1.1.15` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.4` | `1.4.7` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.0` | `1.3.3` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.18` | `1.1.21` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.13` | `1.2.16` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.25.0` | `1.27.0` | | [monaco-editor](https://github.com/microsoft/monaco-editor) | `0.55.1` | `0.56.0` | | [motion](https://github.com/motiondivision/motion) | `12.42.2` | `12.43.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.4` | `1.6.7` | | [recharts](https://github.com/recharts/recharts) | `3.10.0` | `3.10.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.1.1` | `26.1.2` | | [eslint](https://github.com/eslint/eslint) | `10.7.0` | `10.8.0` | | [globals](https://github.com/sindresorhus/globals) | `17.7.0` | `17.8.0` | | [jsdom](https://github.com/jsdom/jsdom) | `29.1.1` | `30.0.1` | | [typescript](https://github.com/microsoft/TypeScript) | `6.0.2` | `7.0.2` | Updates `@radix-ui/react-alert-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.8 to 1.3.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-context-menu` from 2.3.4 to 2.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/context-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/context-menu) Updates `@radix-ui/react-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.21 to 2.1.24 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-hover-card` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/hover-card/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/hover-card) Updates `@radix-ui/react-label` from 2.1.12 to 2.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-scroll-area` from 1.2.15 to 1.2.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.4 to 2.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.12 to 1.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.4 to 1.4.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.3.0 to 1.3.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-tabs` from 1.1.18 to 1.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.13 to 1.2.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `lucide-react` from 1.25.0 to 1.27.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.27.0/packages/lucide-react) Updates `monaco-editor` from 0.55.1 to 0.56.0 - [Release notes](https://github.com/microsoft/monaco-editor/releases) - [Changelog](https://github.com/microsoft/monaco-editor/blob/main/CHANGELOG.md) - [Commits](https://github.com/microsoft/monaco-editor/compare/v0.55.1...v0.56.0) Updates `motion` from 12.42.2 to 12.43.0 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.42.2...v12.43.0) Updates `radix-ui` from 1.6.4 to 1.6.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/1.6.7/packages/react/radix-ui) Updates `recharts` from 3.10.0 to 3.10.1 - [Release notes](https://github.com/recharts/recharts/releases) - [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md) - [Commits](https://github.com/recharts/recharts/compare/v3.10.0...v3.10.1) Updates `@types/node` from 26.1.1 to 26.1.2 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` from 10.7.0 to 10.8.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v10.7.0...v10.8.0) Updates `globals` from 17.7.0 to 17.8.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.7.0...v17.8.0) Updates `jsdom` from 29.1.1 to 30.0.1 - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1) Updates `typescript` from 6.0.2 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.11 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-context-menu" dependency-version: 2.3.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.24 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-hover-card" dependency-version: 1.1.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.23 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.18 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.21 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: lucide-react dependency-version: 1.27.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: monaco-editor dependency-version: 0.56.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: motion dependency-version: 12.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: radix-ui dependency-version: 1.6.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: recharts dependency-version: 3.10.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: "@types/node" dependency-version: 26.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: all-npm-frontend - dependency-name: eslint dependency-version: 10.8.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: globals dependency-version: 17.8.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: all-npm-frontend - dependency-name: jsdom dependency-version: 30.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: all-npm-frontend - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: all-npm-frontend ... Signed-off-by: dependabot[bot] * fix(deps): use Monaco's public worker entrypoint * fix(deps): retain TypeScript 6 compatibility --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: SaelixCode --- frontend/package-lock.json | 1387 +++++++++++++++-------------- frontend/package.json | 48 +- frontend/src/lib/monacoLoader.tsx | 2 +- 3 files changed, 727 insertions(+), 710 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 86c1ddcc..2a287f69 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,21 +11,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-context-menu": "^2.3.4", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-hover-card": "^1.1.20", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -38,18 +38,18 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.25.0", - "monaco-editor": "^0.55.1", - "motion": "^12.42.2", + "lucide-react": "^1.27.0", + "monaco-editor": "^0.56.0", + "motion": "^12.43.0", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.4", + "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", @@ -62,15 +62,15 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.1.1", + "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "jsdom": "^29.1.1", + "globals": "^17.8.0", + "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", @@ -90,56 +90,58 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "20 || >=22" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -404,9 +406,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -424,9 +426,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -448,9 +450,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -464,8 +466,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -499,9 +501,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -656,9 +658,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -727,9 +729,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -937,24 +939,24 @@ } }, "node_modules/@radix-ui/number": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", - "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", + "integrity": "sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==", "license": "MIT" }, "node_modules/@radix-ui/primitive": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.6.tgz", - "integrity": "sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", "license": "MIT" }, "node_modules/@radix-ui/react-accessible-icon": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.12.tgz", - "integrity": "sha512-Y0zhCQ/XUdTom5hAxvE8RlXqR4hZmKGK6g2//LfgHmb88PJFOpXSh9B/7FlfYXezVY5FKGjRYWCYz5FXxZ9WZQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.15.tgz", + "integrity": "sha512-WTQwcAvQf5sOcuUyi90lKPbhwcvQ+j55cjrSmeaN+L2vKU3DooOvlKw2MDeiJ5IkV5N905KW0/fGojKOBhD11A==", "license": "MIT", "dependencies": { - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -972,20 +974,20 @@ } }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.17.tgz", - "integrity": "sha512-l3Dmp+qPPc3SqT8+SPnxIgoWBEU2MMBxcQ7BsoRgak2UT75xY83SFvFcrUkUAWukOV3LFF+BQ9aBIFtZsIG8yQ==", + "version": "1.2.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.20.tgz", + "integrity": "sha512-jDhG9FvAEnlhnjrsINbNXcUa4G+L1KqSkJSunkbKEzFRcAb52jvM0PjPxPRvhe1HNc5F5yc0yzzWeeqlH4yBIg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1003,16 +1005,16 @@ } }, "node_modules/@radix-ui/react-alert-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.20.tgz", - "integrity": "sha512-Ft1W+jPqSh5BKfSTe4dpq6UYQKKQJ5Tvq3wfux+WVlg7nPwFK/3pIlHTb3Rbe+b/tNurx8YGXD9em91ujmgwuQ==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.23.tgz", + "integrity": "sha512-VAYOiQRqj3GPpYJE0I9J+X8Ip05cyVlNdKOFeiGS2Ou1HHGfpl0BxOyZm6nmVDyU+W+NF3/XLzmjHmVGydhwgA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1030,12 +1032,12 @@ } }, "node_modules/@radix-ui/react-arrow": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.12.tgz", - "integrity": "sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.15.tgz", + "integrity": "sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1053,12 +1055,12 @@ } }, "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.12.tgz", - "integrity": "sha512-Sok2IBJxA1XO4pU3ldzZMwUBMumIt64EY8zOUlVq5CdS+i0FrEbajVslfDB+YGWLMsrjY2kZQB0DgkrZXLZvcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.15.tgz", + "integrity": "sha512-fy+dyVR+90nelK8rqIznFlxzx7uPcGbhxH8Nfr2bHb4UfSe+e3hklOC0luK0hDwVwnRX7xTRySpsrQVeW+/oNQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1076,17 +1078,17 @@ } }, "node_modules/@radix-ui/react-avatar": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.3.tgz", - "integrity": "sha512-peavtnApRB1tABx42tHw+rPU83GSg5tXicMYO/Xi1/lqNcRsF6jkr6L7Njo7gj4q/xtDRDKBkqJvbMtoOMYWtA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.2.6.tgz", + "integrity": "sha512-4ULOTJ/mqy2hT9GlWa/MFHxHSvH3nJzHnZM1waNsc5Bonv7i70aNenghXmD97S6OJ81ekXONGGt4nT1r0PfEdA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1104,18 +1106,18 @@ } }, "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.8.tgz", - "integrity": "sha512-wfN60IGuxynWK7rP4Ks2p7u9G7gqirzkAiFptuzVbsR1ot2/K+PavNUAtxiKxyRfLOvSbVfvvm9m3rFqLEXz7A==", + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.11.tgz", + "integrity": "sha512-Gnptr9pDDQxD3hgq2dtPbtrp/c2qH1mBwIzw3X/ivrMb2e1t0jMTi606fVEqFPaQR1ggXIVQWKj3P2WW9v7zGQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1133,19 +1135,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.17", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.17.tgz", - "integrity": "sha512-DJgqGsNXa0df3ifz9PFNgvgj/bzIu5QTVWCt5nQWaUkM6y0EarUv4QG4s6mCoeQdOIyVOT/Q1osFuEGub2TDXQ==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.20.tgz", + "integrity": "sha512-mcGesGplBnzN2sbvJETzpCNfSMyPnb29q1GRLU+Ib7bJrpIG2ywmRoh2V5VbA2uNvKikKUlVbAPks7JDjz4A8Q==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1163,15 +1165,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.12.tgz", - "integrity": "sha512-nb67INpE0IahJKN7EYPp9m9YGwYeKlnzxT3MwXVkgCskaSJia97kG4T0ywpjNUSSnoJk/uvk12V8vbrEHEj+/Q==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.15.tgz", + "integrity": "sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -1189,9 +1191,9 @@ } }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", - "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1204,9 +1206,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", - "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1219,16 +1221,16 @@ } }, "node_modules/@radix-ui/react-context-menu": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.4.tgz", - "integrity": "sha512-eO9tkvHvo4dNwb+lytEcKWjy8c8To+ttLwNt0f9XzzsVFIaspqt3i1/c0JaaksxBB5G//zPo9CCgn39huWQyBA==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.3.7.tgz", + "integrity": "sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1246,24 +1248,24 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.20.tgz", - "integrity": "sha512-cngVJcvK0yMvR7wICJpv+1uW3Qw4T7QM5sdbb+oE/lxOdTdvF00oaRpWUjVgmjyXe3J+xh7eZyXZlVF3g2g59g==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.23.tgz", + "integrity": "sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1283,9 +1285,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", - "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.4.tgz", + "integrity": "sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1298,16 +1300,16 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.16.tgz", - "integrity": "sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.19.tgz", + "integrity": "sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-effect-event": "0.0.3" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" }, "peerDependencies": { "@types/react": "*", @@ -1325,18 +1327,18 @@ } }, "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.21.tgz", - "integrity": "sha512-gavFM1iWLmWdxWNdGJHVeWeSQul5WE/0pxfvWWt1QnD71hyyujyMCDVacqBomaSOjdxwDzYB+Ng4+MxOvrFB1A==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.24.tgz", + "integrity": "sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1354,9 +1356,9 @@ } }, "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", - "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1369,14 +1371,14 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.13.tgz", - "integrity": "sha512-dE04aPEuP9rvKKT0d0KjSOtTEYNg6bmCYFsoSJpfC+y91Hic28ZfDCGgv6aJ+2Kw/LBXYipMZpyqVj/OD3Z8Gg==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.16.tgz", + "integrity": "sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1394,17 +1396,17 @@ } }, "node_modules/@radix-ui/react-form": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.13.tgz", - "integrity": "sha512-PopvWqiutoZh5TJXk9EV9Wh+khbp+LQ+A0H4uHocIjVcKIi6gMlBy4sAaW15thwUSc6PrR8J62nB2uM+htqrcg==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.16.tgz", + "integrity": "sha512-Q4TLEn2A7TAypxwmd6R9EwrlXDvkfYSDMrq9/887AXAGh+G1rH+kYJKSTv+Si9Y0JPKTwKYv6PviAJosysNimA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1422,20 +1424,20 @@ } }, "node_modules/@radix-ui/react-hover-card": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.20.tgz", - "integrity": "sha512-UPmdiR8NsngWjG/y9mClzFg+Rbbpy8u0p0SKM+t7mfH4V07TiLsuylqR0RhJiRibopsawoTtMQudm/TxwHWa9w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.23.tgz", + "integrity": "sha512-H8qONfZd3ltrU3+jHCIgITbWo6e1iTKvP9DHdrvYbX48ooRM5FjEDTn16AMwdfuOGkWdZEhpl3PLL/Wk/AnHDQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1453,12 +1455,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", - "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1471,12 +1473,12 @@ } }, "node_modules/@radix-ui/react-label": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.12.tgz", - "integrity": "sha512-dxioNQ7VOrYKKWJIxMRmJPDSWQN0gNCUy3zaqUSBwsuFAiFzI0yLGJr2q3ml07k/HlOk55N8KEfwa1ZgfprJ3w==", + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.15.tgz", + "integrity": "sha512-o/rdYEwZTTo5tjknnPeyQFU45kUC4i/XyeDPP+HGyi6XqpOP6Zf5Ya5vh/Yfe9Id5JiuWnnAx2XqIeD3UYZt0g==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1494,27 +1496,27 @@ } }, "node_modules/@radix-ui/react-menu": { - "version": "2.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.21.tgz", - "integrity": "sha512-2BHtaJHvvoWTECyrja1mOjN6z2dWdpeHL6b8PxqZYgex8J8xakT2KAchpZIaMwNPauIRHH/VlPJYhSSKe8lz2g==", + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.24.tgz", + "integrity": "sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1534,21 +1536,21 @@ } }, "node_modules/@radix-ui/react-menubar": { - "version": "1.1.21", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.21.tgz", - "integrity": "sha512-uQONG1qM4D8FSEt0xRs5yDpzeSWggf8lOKqHa84NvqoVoc1qJ6XN+gdkrJuQCyEY55793gLlQI3wjgWO5A/Oqg==", + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.24.tgz", + "integrity": "sha512-eeVs0vf7cuqXaM0qLQCPcufImiJNVBXdJDLu7ZGYl2732UH23Qat/foNGrr6vYV3/DdTsBqASoggUFgH14OcZA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -1566,25 +1568,25 @@ } }, "node_modules/@radix-ui/react-navigation-menu": { - "version": "1.2.19", - "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.19.tgz", - "integrity": "sha512-58OVQUrpWx/zGVV3lxGUyAtjX4n0305Z8xIdUAq2QlFO2m2hd1eBS4x1yIVtV8bzCQJja0TJttWcwiPI6y6tmw==", + "version": "1.2.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.22.tgz", + "integrity": "sha512-ou7iLEJ+yrhQndkkA4U21XIdS/CS45F4iXIkTZcb6/Ne9EMsOuDudVmCwmDnfFZZ+y1FZqXRNSIgBy+YMvZVZg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -1602,23 +1604,23 @@ } }, "node_modules/@radix-ui/react-one-time-password-field": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.13.tgz", - "integrity": "sha512-reLtbZtEBsMcqXkjd/wOga4e8t9uxzFHdX9W/j/ZfGznTNJxLGjRrDNGnGOOWcBazMH1BI/b7Cx+hblSWSD7aw==", + "version": "0.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.16.tgz", + "integrity": "sha512-Tj9P6ntAJEw52oq/F0AGknXR4XncxEt7XU47O3xJQOiWfLzEy3d9gtgKfvjSzGxzHkfL+VzvxGu2KTFsloJqXw==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1636,19 +1638,19 @@ } }, "node_modules/@radix-ui/react-password-toggle-field": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.8.tgz", - "integrity": "sha512-NH9puF7Es5Loh8vFELm+SyayzV27nyBw8kiP/uD9wbkwgq359FfbkKEvccrNk75z0LiSqC4REWk1iL9xdeWJkQ==", + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.11.tgz", + "integrity": "sha512-4gvFnmDXu3dgj21CqsufzIameRvlRd4SBqaWhcrlrNhRo0Y5i/49AmRJYe1fdAM3G2VNBbmin4b0D6cdQocwgw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-is-hydrated": "0.1.1" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-is-hydrated": "0.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1666,24 +1668,24 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.20.tgz", - "integrity": "sha512-/PYqbsyuDkNj+IxMcRx71qNt6GelnuNulMwdCV7AtFEhUyK6XkbwreEN6CCLydMeTiDozBV4uv5aF5d12dDH7w==", + "version": "1.1.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.23.tgz", + "integrity": "sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1703,21 +1705,21 @@ } }, "node_modules/@radix-ui/react-popper": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.4.tgz", - "integrity": "sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.7.tgz", + "integrity": "sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==", "license": "MIT", "dependencies": { "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-rect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/rect": "1.1.2" + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -1735,13 +1737,13 @@ } }, "node_modules/@radix-ui/react-portal": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.14.tgz", - "integrity": "sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==", + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.17.tgz", + "integrity": "sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1759,12 +1761,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.8.tgz", - "integrity": "sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==", + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.10.tgz", + "integrity": "sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1782,12 +1784,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", - "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.10.tgz", + "integrity": "sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.3.0" + "@radix-ui/react-slot": "1.3.3" }, "peerDependencies": { "@types/react": "*", @@ -1805,13 +1807,13 @@ } }, "node_modules/@radix-ui/react-progress": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.13.tgz", - "integrity": "sha512-1dUdKDd63Tz9FfbTw20MVr28ohG4v7HOJ1dsavGBBPBS3KGzLOyLKiMJAC1OdgiY18nTSHpD4fULGK5gsLY/ww==", + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.16.tgz", + "integrity": "sha512-5XnomAsoZZCY+KNTxbIghpGqPruZvKFNlvcAljVAOdDRDsH4/OZQxhtwo5wdtoDM5R6MhJBb2sPnDuRFep3lzg==", "license": "MIT", "dependencies": { - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1829,20 +1831,20 @@ } }, "node_modules/@radix-ui/react-radio-group": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.4.tgz", - "integrity": "sha512-OpbUmp/korY+tjEQmHwGyQ+QQ3LBlCPC70z03Q/NSqGaHf2EijuwpjQPnswrH6cZLWyT2J6FmB+kzRoMUtPBig==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.4.7.tgz", + "integrity": "sha512-cgYFEkntCxppHZgtSZ+7vh0wbZQ+IC7PPMw8DSnRG27B6kDd32/Zw0OJt7dGDigCoprMuWHjg2PvUn3PYvPFoQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1860,22 +1862,22 @@ } }, "node_modules/@radix-ui/react-roving-focus": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.16.tgz", - "integrity": "sha512-w7lLsTSd3940vFYEshKkHw+NGf7H0QDJPHYsy8NRjDCVbO6ZdKW1X/xoJSYHZtttnrdZiYqbN2O/2uHGB0zasw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.19.tgz", + "integrity": "sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1893,20 +1895,20 @@ } }, "node_modules/@radix-ui/react-scroll-area": { - "version": "1.2.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.15.tgz", - "integrity": "sha512-JVBHNfTBbGd9hhq/xZZOgmVnBCXhLs8PJJ8vMzgwI0pLZNsKckW9pkoqHyxokUCt1hoxbwDNvF9DItEeZsG68g==", + "version": "1.2.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.18.tgz", + "integrity": "sha512-Zn5Cd171wxsO3Dfg8HaW6RifTb9CYTKQJHs/G4+LN1GfmJpaQMZQyQxMprVPHpaz7QY4l9BxK2JwQuzHsXC8nA==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -1924,31 +1926,31 @@ } }, "node_modules/@radix-ui/react-select": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.4.tgz", - "integrity": "sha512-E2JxqAvaTUEhWtBptWo02g8FnLYPymv9ahEvW/cZQPPV4ySeyo0M8n3sXccsLUAIfMbexnfXt91qF7UjTbTMMg==", + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.7.tgz", + "integrity": "sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8", + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.7.2" }, @@ -1968,12 +1970,12 @@ } }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.12.tgz", - "integrity": "sha512-2hezgFBBR5jU3S9L9bIZ9Uag6LnvxuFBNsLCfTR8qx+NshuvFmpL4C72+5zMS3Z6UgHNSU1thOw2UaBBPEDpsQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.15.tgz", + "integrity": "sha512-jOLO4lssEzWpoDu7G+Ze4VjwMRUBt291pnZD0gmalREZipnTX3wadQo7Fy48GCTfe14/YRN6rw/rOJqrE85Wxw==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -1991,22 +1993,22 @@ } }, "node_modules/@radix-ui/react-slider": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.4.tgz", - "integrity": "sha512-8dUytW34KoJaB22ctfP7hqUCuyYa8xn2w7H8kCneeOtS5oM7UBivcnZtR8P4kPYMgdoAZlkMhE9/qkYZ5MlRzQ==", + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.4.7.tgz", + "integrity": "sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==", "license": "MIT", "dependencies": { - "@radix-ui/number": "1.1.2", - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-previous": "1.1.2", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/number": "1.1.3", + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-previous": "1.1.4", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2024,12 +2026,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", - "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", + "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.3" + "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", @@ -2042,17 +2044,17 @@ } }, "node_modules/@radix-ui/react-switch": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.4.tgz", - "integrity": "sha512-7iGMj1SfZBAc6xRiy0Y3Wr/v52viQeDhOmaM3fNRyNf2nbYooZA3kKoEDGLPtrDv8JitpzcueqdZLusVMojLdQ==", + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.7.tgz", + "integrity": "sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-size": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-size": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2070,19 +2072,19 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.18.tgz", - "integrity": "sha512-1zq2XkQkK/KfbZn84edytYpOLquhNalra5LXc3NAMKhNRSGtyXqjMv6OyC9jlSuNKpqvQtsb57WKoICNk1v/sQ==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.21.tgz", + "integrity": "sha512-UKxJlZid7FVtsk/WTxj4i4uSEgj2Au+KBbS7SQyTlzMhhn+86Cz3tISZdTa87bfEfcuvZezf2ZsxD4xuEKtkog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2100,23 +2102,23 @@ } }, "node_modules/@radix-ui/react-toast": { - "version": "1.2.20", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.20.tgz", - "integrity": "sha512-S28OtO1IvYSpWfaUBtiYCTTwRLF8doafj+a+uQw8rc8dLINS52uuG3CIPCeZc3Jfdb/S7o7HhlQxLoXlIYRu6g==", + "version": "1.2.23", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.23.tgz", + "integrity": "sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -2134,14 +2136,14 @@ } }, "node_modules/@radix-ui/react-toggle": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.15.tgz", - "integrity": "sha512-tyCejFjhJ51UKFVIG8jh9nTdRIsFPxrgrI4IdlxuJeP+AKTfTko+0gBueyBFLHqsyE71Aj9PKHjMnG+YRPyKhA==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.18.tgz", + "integrity": "sha512-7lonPlKfSacd20GlOBx2ltuVKz9oqWYZz+oMQyOltw6t1y2nyftj2ZmwwUHYn49kqfDWcp8dNZm5NgV+5Z+mug==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2159,18 +2161,18 @@ } }, "node_modules/@radix-ui/react-toggle-group": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.16.tgz", - "integrity": "sha512-uil+A0Um3LaZQJkMap4nIg0VgqWc0j3iNU4AXf9a/zHOgPHNYWfVk5WVsG2296Y8HLv1bxiN7uQJblHc1+00tw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.19.tgz", + "integrity": "sha512-OtnwuSVjd1Ofi+AdnvhsjQdyuhCDwYs1w9RyB5BN/OavXOVQo42SYqQjwUnbPnaiPFBpQ9aX70dWeee+v2oBLA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-use-controllable-state": "1.2.4" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-use-controllable-state": "1.2.6" }, "peerDependencies": { "@types/react": "*", @@ -2188,18 +2190,18 @@ } }, "node_modules/@radix-ui/react-toolbar": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.16.tgz", - "integrity": "sha512-ZnvUAH+ftoRYzUzFQ8gqKnQ1lUFYb3amguGu+BXpfjvLIkjmXCcHCJlQeBLBlJCOtGNVtP+wHrZaUCC/zYKQMg==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.19.tgz", + "integrity": "sha512-Ph0IvtYw4VB12ZnZg+YtrGs8yJQsnizwo/zu0R4Y/nWugtJzA7Pg1eWeuDR9+LSqn+xjamss+UOSOJJJ4gx8jw==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-toggle-group": "1.1.16" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-toggle-group": "1.1.19" }, "peerDependencies": { "@types/react": "*", @@ -2217,24 +2219,24 @@ } }, "node_modules/@radix-ui/react-tooltip": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.13.tgz", - "integrity": "sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.16.tgz", + "integrity": "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-id": "1.1.2", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -2252,9 +2254,9 @@ } }, "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", - "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2267,14 +2269,14 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.4.tgz", - "integrity": "sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2287,12 +2289,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", - "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2305,12 +2307,12 @@ } }, "node_modules/@radix-ui/react-use-escape-keydown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.3.tgz", - "integrity": "sha512-3wEkMiPHXha/2VadZ68rYBcmYnPINVGl4Y3gtcM7fKRjANk0OscK+cdqBgUWdozb7YJxsh0vefM7vgAMHXOjqg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.5.tgz", + "integrity": "sha512-ge3ipobwSXTj4JyVtswQ7qZj0ZHdtbGuOno/LrgAAeSxtsJ6Vs4Gz5IkPH2bmqpjcLUFoqGhA/mueuIf63UXlA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-callback-ref": "1.1.2" + "@radix-ui/react-use-callback-ref": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2323,9 +2325,9 @@ } }, "node_modules/@radix-ui/react-use-is-hydrated": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.1.tgz", - "integrity": "sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2338,9 +2340,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", - "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2353,9 +2355,9 @@ } }, "node_modules/@radix-ui/react-use-previous": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", - "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.4.tgz", + "integrity": "sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2368,12 +2370,12 @@ } }, "node_modules/@radix-ui/react-use-rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", - "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", "license": "MIT", "dependencies": { - "@radix-ui/rect": "1.1.2" + "@radix-ui/rect": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -2386,12 +2388,12 @@ } }, "node_modules/@radix-ui/react-use-size": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", - "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.4.tgz", + "integrity": "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.2" + "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", @@ -2404,12 +2406,12 @@ } }, "node_modules/@radix-ui/react-visually-hidden": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.8.tgz", - "integrity": "sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.11.tgz", + "integrity": "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.7" + "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", @@ -2427,9 +2429,9 @@ } }, "node_modules/@radix-ui/rect": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", - "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, "node_modules/@reduxjs/toolkit": { @@ -3370,9 +3372,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", "dev": true, "license": "MIT", "dependencies": { @@ -4724,9 +4726,9 @@ } }, "node_modules/eslint": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz", - "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -4736,7 +4738,7 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -4760,7 +4762,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -5047,12 +5049,12 @@ "license": "ISC" }, "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -5153,9 +5155,9 @@ } }, "node_modules/globals": { - "version": "17.7.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", - "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "version": "17.8.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.8.0.tgz", + "integrity": "sha512-Zz/LMDZScFmkakeL2cTHzf+PbWKdpU3uclqkZT7TjDG58j5WPt0PpA+n9uPI24fZtlw07q0OtEi84K+umsRzqQ==", "dev": true, "license": "MIT", "engines": { @@ -5482,39 +5484,39 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", + "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -5523,15 +5525,30 @@ } }, "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5901,9 +5918,9 @@ } }, "node_modules/lucide-react": { - "version": "1.25.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.25.0.tgz", - "integrity": "sha512-/mdJTRbiwcLOQ1NZZK1amZF9rIZyvO18D6r9TngE6TG1NmqHgFuT4eE7Xrkm9UsXMbBJD1NlfwHVltCDWHrOTw==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", + "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6814,13 +6831,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6830,22 +6847,22 @@ } }, "node_modules/monaco-editor": { - "version": "0.55.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", - "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", "license": "MIT", "dependencies": { - "dompurify": "3.2.7", + "dompurify": "3.4.8", "marked": "14.0.0" } }, "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.2", + "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6866,9 +6883,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -7193,66 +7210,66 @@ } }, "node_modules/radix-ui": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.4.tgz", - "integrity": "sha512-Kpgb9sx08toOydBK42//0N3MqIPlqjHcY39CYuGG8+7DrF6+NTfAnc3o+f1kvoKzG6cI56ri7Z45XEBQqG1QqQ==", + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.6.7.tgz", + "integrity": "sha512-QBdhh1arIEUvPC0dQ5+nwWAxt7+N+oP/9jPwjJkGFoSk/sqxg32gJtSXGtFh8frAIcS6oC9cx2Q+7KYCQLOAeA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.6", - "@radix-ui/react-accessible-icon": "1.1.12", - "@radix-ui/react-accordion": "1.2.17", - "@radix-ui/react-alert-dialog": "1.1.20", - "@radix-ui/react-arrow": "1.1.12", - "@radix-ui/react-aspect-ratio": "1.1.12", - "@radix-ui/react-avatar": "1.2.3", - "@radix-ui/react-checkbox": "1.3.8", - "@radix-ui/react-collapsible": "1.1.17", - "@radix-ui/react-collection": "1.1.12", - "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.2.0", - "@radix-ui/react-context-menu": "2.3.4", - "@radix-ui/react-dialog": "1.1.20", - "@radix-ui/react-direction": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.16", - "@radix-ui/react-dropdown-menu": "2.1.21", - "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.13", - "@radix-ui/react-form": "0.1.13", - "@radix-ui/react-hover-card": "1.1.20", - "@radix-ui/react-label": "2.1.12", - "@radix-ui/react-menu": "2.1.21", - "@radix-ui/react-menubar": "1.1.21", - "@radix-ui/react-navigation-menu": "1.2.19", - "@radix-ui/react-one-time-password-field": "0.1.13", - "@radix-ui/react-password-toggle-field": "0.1.8", - "@radix-ui/react-popover": "1.1.20", - "@radix-ui/react-popper": "1.3.4", - "@radix-ui/react-portal": "1.1.14", - "@radix-ui/react-presence": "1.1.8", - "@radix-ui/react-primitive": "2.1.7", - "@radix-ui/react-progress": "1.1.13", - "@radix-ui/react-radio-group": "1.4.4", - "@radix-ui/react-roving-focus": "1.1.16", - "@radix-ui/react-scroll-area": "1.2.15", - "@radix-ui/react-select": "2.3.4", - "@radix-ui/react-separator": "1.1.12", - "@radix-ui/react-slider": "1.4.4", - "@radix-ui/react-slot": "1.3.0", - "@radix-ui/react-switch": "1.3.4", - "@radix-ui/react-tabs": "1.1.18", - "@radix-ui/react-toast": "1.2.20", - "@radix-ui/react-toggle": "1.1.15", - "@radix-ui/react-toggle-group": "1.1.16", - "@radix-ui/react-toolbar": "1.1.16", - "@radix-ui/react-tooltip": "1.2.13", - "@radix-ui/react-use-callback-ref": "1.1.2", - "@radix-ui/react-use-controllable-state": "1.2.4", - "@radix-ui/react-use-effect-event": "0.0.3", - "@radix-ui/react-use-escape-keydown": "1.1.3", - "@radix-ui/react-use-is-hydrated": "0.1.1", - "@radix-ui/react-use-layout-effect": "1.1.2", - "@radix-ui/react-use-size": "1.1.2", - "@radix-ui/react-visually-hidden": "1.2.8" + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-accessible-icon": "1.1.15", + "@radix-ui/react-accordion": "1.2.20", + "@radix-ui/react-alert-dialog": "1.1.23", + "@radix-ui/react-arrow": "1.1.15", + "@radix-ui/react-aspect-ratio": "1.1.15", + "@radix-ui/react-avatar": "1.2.6", + "@radix-ui/react-checkbox": "1.3.11", + "@radix-ui/react-collapsible": "1.1.20", + "@radix-ui/react-collection": "1.1.15", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-context-menu": "2.3.7", + "@radix-ui/react-dialog": "1.1.23", + "@radix-ui/react-direction": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.19", + "@radix-ui/react-dropdown-menu": "2.1.24", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.1.16", + "@radix-ui/react-form": "0.1.16", + "@radix-ui/react-hover-card": "1.1.23", + "@radix-ui/react-label": "2.1.15", + "@radix-ui/react-menu": "2.1.24", + "@radix-ui/react-menubar": "1.1.24", + "@radix-ui/react-navigation-menu": "1.2.22", + "@radix-ui/react-one-time-password-field": "0.1.16", + "@radix-ui/react-password-toggle-field": "0.1.11", + "@radix-ui/react-popover": "1.1.23", + "@radix-ui/react-popper": "1.3.7", + "@radix-ui/react-portal": "1.1.17", + "@radix-ui/react-presence": "1.1.10", + "@radix-ui/react-primitive": "2.1.10", + "@radix-ui/react-progress": "1.1.16", + "@radix-ui/react-radio-group": "1.4.7", + "@radix-ui/react-roving-focus": "1.1.19", + "@radix-ui/react-scroll-area": "1.2.18", + "@radix-ui/react-select": "2.3.7", + "@radix-ui/react-separator": "1.1.15", + "@radix-ui/react-slider": "1.4.7", + "@radix-ui/react-slot": "1.3.3", + "@radix-ui/react-switch": "1.3.7", + "@radix-ui/react-tabs": "1.1.21", + "@radix-ui/react-toast": "1.2.23", + "@radix-ui/react-toggle": "1.1.18", + "@radix-ui/react-toggle-group": "1.1.19", + "@radix-ui/react-toolbar": "1.1.19", + "@radix-ui/react-tooltip": "1.2.16", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-escape-keydown": "1.1.5", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-size": "1.1.4", + "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", @@ -7457,9 +7474,9 @@ } }, "node_modules/recharts": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.0.tgz", - "integrity": "sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", "license": "MIT", "workspaces": [ "www" @@ -7974,29 +7991,29 @@ } }, "node_modules/tldts": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", - "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.28" + "tldts-core": "^7.4.9" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", - "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", "dev": true, "license": "MIT" }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -8072,9 +8089,9 @@ } }, "node_modules/typescript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", - "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -8110,13 +8127,13 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/frontend/package.json b/frontend/package.json index c41c755d..1a7bbb2d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,21 +18,21 @@ "dependencies": { "@dagrejs/dagre": "^3.0.0", "@monaco-editor/react": "^4.7.0", - "@radix-ui/react-alert-dialog": "^1.1.20", - "@radix-ui/react-checkbox": "^1.3.8", - "@radix-ui/react-context-menu": "^2.3.4", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-dropdown-menu": "^2.1.21", - "@radix-ui/react-hover-card": "^1.1.20", - "@radix-ui/react-label": "^2.1.12", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-select": "^2.3.4", - "@radix-ui/react-separator": "^1.1.12", - "@radix-ui/react-slider": "^1.4.4", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", - "@radix-ui/react-tooltip": "^1.2.13", + "@radix-ui/react-alert-dialog": "^1.1.23", + "@radix-ui/react-checkbox": "^1.3.11", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-hover-card": "^1.1.23", + "@radix-ui/react-label": "^2.1.15", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-separator": "^1.1.15", + "@radix-ui/react-slider": "^1.4.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", + "@radix-ui/react-tooltip": "^1.2.16", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-search": "^0.16.0", "@xterm/addon-serialize": "^0.14.0", @@ -45,18 +45,18 @@ "date-fns": "^4.4.0", "fflate": "^0.8.2", "geist": "^1.7.2", - "lucide-react": "^1.25.0", - "monaco-editor": "^0.55.1", - "motion": "^12.42.2", + "lucide-react": "^1.27.0", + "monaco-editor": "^0.56.0", + "motion": "^12.43.0", "qrcode.react": "^4.2.0", - "radix-ui": "^1.6.4", + "radix-ui": "^1.6.7", "react": "^19.2.8", "react-day-picker": "^10.0.1", "react-dom": "^19.2.8", "react-is": "^19.2.8", "react-markdown": "^10.1.0", "react-use-measure": "^2.1.7", - "recharts": "^3.10.0", + "recharts": "^3.10.1", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", @@ -74,15 +74,15 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/node": "^26.1.1", + "@types/node": "^26.1.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.4", - "eslint": "^10.7.0", + "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", - "globals": "^17.7.0", - "jsdom": "^29.1.1", + "globals": "^17.8.0", + "jsdom": "^30.0.1", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", diff --git a/frontend/src/lib/monacoLoader.tsx b/frontend/src/lib/monacoLoader.tsx index 72ac30a4..f1708dcc 100644 --- a/frontend/src/lib/monacoLoader.tsx +++ b/frontend/src/lib/monacoLoader.tsx @@ -26,7 +26,7 @@ function setupMonaco(): Promise { const [monacoMod, reactMonaco, editorWorkerMod] = await Promise.all([ import('monaco-editor'), import('@monaco-editor/react'), - import('monaco-editor/esm/vs/editor/editor.worker?worker'), + import('monaco-editor/editor/editor.worker?worker'), ]); window.MonacoEnvironment = { getWorker(): Worker { From 44d607824113a6bca9723978f7200492c9e28736 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 14:30:18 -0400 Subject: [PATCH 07/31] feat(fleet): show itemized prune plans (#1734) * feat(fleet): itemize prune review plans Build and display fingerprint-bound prune candidates for every reviewed fleet node. Preflight all node plans before mutation and preserve detailed removed, skipped, failed, and partial outcomes. Add safe resource metadata projection, managed ownership attribution, runtime contract validation, transport parity coverage, and operator docs. Closes #1724 * fix(security): harden stack path lookup Use a Map for Compose working-directory ownership resolution so untrusted path strings cannot become object property writes. * fix(fleet): harden prune execution safeguards --- .../fleet-action-card-endpoints.test.ts | 118 +++- .../fleet-pilot-dispatch-parity.test.ts | 82 ++- .../__tests__/fleet-prune-df-timeout.test.ts | 39 +- backend/src/__tests__/fleet-prune.test.ts | 609 ++++++++++++------ backend/src/__tests__/prune-plan.test.ts | 162 +++++ .../system-maintenance-prune.test.ts | 45 ++ backend/src/helpers/bulkActionLocks.ts | 3 + backend/src/helpers/fleetPrune.ts | 597 +++++++++++++++++ backend/src/routes/fleet.ts | 177 +---- backend/src/routes/labels.ts | 9 +- backend/src/routes/systemMaintenance.ts | 19 +- backend/src/services/DockerController.ts | 196 +++--- backend/src/services/prunePlan.ts | 68 +- docs/features/fleet-actions.mdx | 73 ++- frontend/src/components/ResourcesView.tsx | 25 +- .../__tests__/ResourcesView.test.tsx | 15 +- .../FleetActions/PrunePlanResults.test.tsx | 60 ++ .../fleet/FleetActions/PrunePlanResults.tsx | 156 +++++ .../cards/FleetPruneCard.test.tsx | 452 +++++++------ .../FleetActions/cards/FleetPruneCard.tsx | 411 +++++++----- frontend/src/lib/prunePlan.ts | 135 ++++ 21 files changed, 2582 insertions(+), 869 deletions(-) create mode 100644 backend/src/helpers/bulkActionLocks.ts create mode 100644 backend/src/helpers/fleetPrune.ts create mode 100644 frontend/src/components/fleet/FleetActions/PrunePlanResults.test.tsx create mode 100644 frontend/src/components/fleet/FleetActions/PrunePlanResults.tsx create mode 100644 frontend/src/lib/prunePlan.ts diff --git a/backend/src/__tests__/fleet-action-card-endpoints.test.ts b/backend/src/__tests__/fleet-action-card-endpoints.test.ts index e14956fc..6765810f 100644 --- a/backend/src/__tests__/fleet-action-card-endpoints.test.ts +++ b/backend/src/__tests__/fleet-action-card-endpoints.test.ts @@ -19,6 +19,8 @@ const pruneManagedOnly = vi.fn(); const pruneSystem = vi.fn(); const estimateManagedReclaim = vi.fn(); const estimateSystemReclaim = vi.fn(); +const buildPrunePlan = vi.fn(); +const executePrunePlan = vi.fn(); const getContainersByStack = vi.fn(); const stopContainer = vi.fn(); const restartContainer = vi.fn(); @@ -47,6 +49,8 @@ vi.mock('../services/DockerController', () => ({ pruneSystem, estimateManagedReclaim, estimateSystemReclaim, + buildPrunePlan, + executePrunePlan, getContainersByStack, stopContainer, restartContainer, @@ -90,6 +94,11 @@ beforeEach(() => { pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 }); estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 }); estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 }); + buildPrunePlan.mockResolvedValue({ + nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0, + fingerprint: 'empty-plan', createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] }); getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]); stopContainer.mockResolvedValue(undefined); restartContainer.mockResolvedValue(undefined); @@ -1000,8 +1009,19 @@ describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => { }); describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { - it('routes to estimateManagedReclaim and marks each target dryRun: true', async () => { - estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 2048 }); + it('returns one multi-target itemized plan without calling prune methods', async () => { + buildPrunePlan.mockResolvedValue({ + nodeId: 1, + scope: 'managed', + targets: ['volumes', 'images'], + items: [ + { target: 'volumes', id: 'data', name: 'data', sizeBytes: 512, managed: true, reason: 'unused' }, + { target: 'images', id: 'image', name: 'app:latest', sizeBytes: 1536, managed: true, reason: 'unused' }, + ], + reclaimableBytes: 2048, + fingerprint: 'itemized-plan', + createdAt: 1, + }); const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) @@ -1009,41 +1029,105 @@ describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { expect(res.status).toBe(200); const node = res.body.results[0]; expect(node.reachable).toBe(true); + expect(node.fingerprint).toBe('itemized-plan'); + expect(node.items).toHaveLength(2); expect(node.targets).toHaveLength(2); - for (const t of node.targets) { - expect(t.success).toBe(true); - expect(t.reclaimedBytes).toBe(2048); - expect(t.dryRun).toBe(true); - } + expect(node.targets).toEqual([ + { target: 'images', success: true, reclaimedBytes: 1536, dryRun: true }, + { target: 'volumes', success: true, reclaimedBytes: 512, dryRun: true }, + ]); expect(pruneManagedOnly).not.toHaveBeenCalled(); expect(pruneSystem).not.toHaveBeenCalled(); - expect(estimateManagedReclaim).toHaveBeenCalledTimes(2); + expect(buildPrunePlan).toHaveBeenCalledTimes(1); expect(invalidateNodeCaches).not.toHaveBeenCalled(); }); - it('routes to estimateSystemReclaim when scope is "all"', async () => { - estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 8192 }); + it('loads known stacks and builds attribution even when scope is "all"', async () => { const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) .send({ targets: ['images'], scope: 'all', dryRun: true }); expect(res.status).toBe(200); expect(estimateManagedReclaim).not.toHaveBeenCalled(); - expect(estimateSystemReclaim).toHaveBeenCalled(); + expect(estimateSystemReclaim).not.toHaveBeenCalled(); + expect(buildPrunePlan).toHaveBeenCalledWith(['images'], 'all', ['alpha', 'beta'], expect.any(Number), expect.any(Function)); expect(pruneManagedOnly).not.toHaveBeenCalled(); expect(pruneSystem).not.toHaveBeenCalled(); - expect(res.body.results[0].targets[0].reclaimedBytes).toBe(8192); }); - it('still invokes pruneManagedOnly when dryRun is omitted', async () => { - pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 512 }); + it('requires reviewed roster and fingerprints when dryRun is omitted', async () => { const res = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(pruneManagedOnly).toHaveBeenCalled(); + expect(res.status).toBe(400); + expect(pruneManagedOnly).not.toHaveBeenCalled(); + expect(executePrunePlan).not.toHaveBeenCalled(); expect(estimateManagedReclaim).not.toHaveBeenCalled(); - expect(res.body.results[0].targets[0].dryRun).toBeUndefined(); + }); + + it('invalidates local caches only after an item is removed', async () => { + const local = db.getNodes().find((node) => node.type === 'local')!; + buildPrunePlan.mockResolvedValue({ + nodeId: local.id, + scope: 'managed', + targets: ['images'], + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused' }], + reclaimableBytes: 0, + fingerprint: 'reviewed-plan', + createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ + success: true, + reclaimedBytes: 0, + outcomes: [{ target: 'images', id: 'image', status: 'removed' }], + }); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], + scope: 'managed', + dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: 'reviewed-plan' }], + }); + expect(res.status).toBe(200); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + expect(invalidateNodeCaches).toHaveBeenCalledWith(local.id); + }); + + it('does not invalidate local caches for empty, skipped, or failed outcomes', async () => { + const local = db.getNodes().find((node) => node.type === 'local')!; + const cases = [ + { items: [], outcomes: [] }, + { + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }], + outcomes: [{ target: 'images', id: 'image', status: 'skipped', reason: 'became active' }], + }, + { + items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }], + outcomes: [{ target: 'images', id: 'image', status: 'failed', error: 'remove failed' }], + }, + ] as const; + for (const [index, testCase] of cases.entries()) { + const fingerprint = `reviewed-plan-${index}`; + buildPrunePlan.mockResolvedValue({ + nodeId: local.id, scope: 'managed', targets: ['images'], items: [...testCase.items], + reclaimableBytes: 0, fingerprint, createdAt: 1, + }); + executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [...testCase.outcomes] }); + invalidateNodeCaches.mockClear(); + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint }], + }); + expect(res.status).toBe(200); + expect(invalidateNodeCaches).not.toHaveBeenCalled(); + } }); }); diff --git a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts index 5201a3ae..aa5f6f3f 100644 --- a/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts +++ b/backend/src/__tests__/fleet-pilot-dispatch-parity.test.ts @@ -40,6 +40,8 @@ let proxyNodeId: number; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let LicenseService: typeof import('../services/LicenseService').LicenseService; +let DockerController: typeof import('../services/DockerController').default; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; beforeAll(async () => { tmpDir = await setupTestDb(); @@ -47,6 +49,8 @@ beforeAll(async () => { ({ NodeRegistry } = await import('../services/NodeRegistry')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ LicenseService } = await import('../services/LicenseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ FileSystemService } = await import('../services/FileSystemService')); const db = DatabaseService.getInstance(); pilotNodeId = db.addNode({ @@ -259,7 +263,19 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => { mockFetch((url, init) => { const headers = (init?.headers as Record) ?? {}; calls.push({ url, auth: headers.Authorization }); - return new Response(JSON.stringify({ success: true, reclaimedBytes: 999, dryRun: true }), { + return new Response(JSON.stringify({ + nodeId: 1, + scope: 'managed', + targets: ['images'], + items: [{ + target: 'images', id: 'sha256:pilot', name: 'pilot/app:latest', sizeBytes: 999, + managed: true, reason: 'Image is not used by any container', stackName: 'app', + image: { references: ['pilot/app:latest'] }, + }], + reclaimableBytes: 999, + fingerprint: 'pilot-plan', + createdAt: 1, + }), { status: 200, headers: { 'content-type': 'application/json' }, }); }); @@ -272,11 +288,75 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => { expect(res.status).toBe(200); const pilotCall = calls.find(c => c.url.startsWith(PILOT_LOOPBACK)); expect(pilotCall).toBeDefined(); + expect(pilotCall?.url).toBe(`${PILOT_LOOPBACK}/api/system/prune/plan`); expect(pilotCall?.auth).toBeUndefined(); const pilotResult = res.body.results.find((r: { nodeId: number }) => r.nodeId === pilotNodeId); expect(pilotResult.reachable).toBe(true); expect(pilotResult.targets[0].reclaimedBytes).toBe(999); }); + + it('uses one plan request and one fingerprint-bound execute request per remote', async () => { + mockPaidTier(); + mockTargets(); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const localPlan = { + nodeId: local.id, scope: 'managed' as const, targets: ['images' as const], items: [], + reclaimableBytes: 0, fingerprint: 'local-plan', createdAt: 1, + }; + const executePrunePlan = vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(localPlan), + executePrunePlan, + } as unknown as ReturnType); + vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]); + + const calls: Array<{ url: string; auth: string | undefined; body: Record }> = []; + mockFetch((url, init) => { + const headers = (init?.headers as Record) ?? {}; + const body = JSON.parse(String(init?.body)) as Record; + calls.push({ url, auth: headers.Authorization, body }); + const pilot = url.startsWith(PILOT_LOOPBACK); + if (url.endsWith('/api/system/prune/plan')) { + return new Response(JSON.stringify({ + nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0, + fingerprint: pilot ? 'pilot-plan' : 'proxy-plan', createdAt: 1, + }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response(JSON.stringify({ success: true, reclaimedBytes: 0, outcomes: [] }), { + status: 200, headers: { 'content-type': 'application/json' }, + }); + }); + + const res = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [ + { nodeId: local.id, reachable: true }, + { nodeId: pilotNodeId, reachable: true }, + { nodeId: proxyNodeId, reachable: true }, + ], + plans: [ + { nodeId: local.id, fingerprint: 'local-plan' }, + { nodeId: pilotNodeId, fingerprint: 'pilot-plan' }, + { nodeId: proxyNodeId, fingerprint: 'proxy-plan' }, + ], + }); + + expect(res.status).toBe(200); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + const pilotCalls = calls.filter((call) => call.url.startsWith(PILOT_LOOPBACK)); + expect(pilotCalls.map((call) => call.url)).toEqual([ + `${PILOT_LOOPBACK}/api/system/prune/plan`, + `${PILOT_LOOPBACK}/api/system/prune/system`, + ]); + expect(pilotCalls.every((call) => call.auth === undefined)).toBe(true); + expect(pilotCalls[1].body).toMatchObject({ targets: ['images'], planFingerprint: 'pilot-plan' }); + const proxyCalls = calls.filter((call) => call.url.startsWith(PROXY_URL)); + expect(proxyCalls).toHaveLength(2); + expect(proxyCalls.every((call) => call.auth === `Bearer ${PROXY_TOKEN}`)).toBe(true); + }); }); describe('GET /api/image-updates/fleet (pilot inclusion)', () => { diff --git a/backend/src/__tests__/fleet-prune-df-timeout.test.ts b/backend/src/__tests__/fleet-prune-df-timeout.test.ts index 04d8f381..76504dc5 100644 --- a/backend/src/__tests__/fleet-prune-df-timeout.test.ts +++ b/backend/src/__tests__/fleet-prune-df-timeout.test.ts @@ -1,8 +1,7 @@ /** - * F-6 regression: fleet routes that call estimateSystemReclaim on local - * nodes must also bound the slow `docker system df` call (8s) and surface - * a recognizable timeout message to the operator, matching the - * /api/system/prune/estimate behavior. + * F-6 regression: Fleet itemized plan enumeration and byte estimation both + * bound the slow `docker system df` call (8s) and surface a recognizable + * timeout message to the operator. * * Covers: * - POST /api/fleet/labels/fleet-prune with dryRun: true @@ -42,17 +41,27 @@ afterEach(() => { activeBulkActions.clear(); }); -function stubLocalEstimate(impl: () => Promise<{ reclaimableBytes: number }>) { +function stubLocalEstimate( + estimateImpl: () => Promise<{ reclaimableBytes: number }>, + planImpl: () => Promise = async () => ({ + nodeId: 1, scope: 'all', targets: ['volumes'], items: [], reclaimableBytes: 0, + fingerprint: 'empty', createdAt: 1, + }), +) { vi.spyOn(DockerController, 'getInstance').mockReturnValue({ - estimateSystemReclaim: vi.fn().mockImplementation(impl), + estimateSystemReclaim: vi.fn().mockImplementation(estimateImpl), estimateManagedReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }), + buildPrunePlan: vi.fn().mockImplementation(planImpl), } as unknown as ReturnType); vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]); } describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () => { it('POST /api/fleet/labels/fleet-prune dry-run surfaces a busy-daemon error on local timeout', async () => { - stubLocalEstimate(() => new Promise(() => { /* never resolves */ })); + stubLocalEstimate( + () => Promise.resolve({ reclaimableBytes: 0 }), + () => new Promise(() => { /* never resolves */ }), + ); const t0 = Date.now(); const res = await request(app) @@ -86,7 +95,21 @@ describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () => }, 20_000); it('fleet-prune dry-run succeeds normally when estimateSystemReclaim resolves quickly', async () => { - stubLocalEstimate(() => Promise.resolve({ reclaimableBytes: 256 })); + stubLocalEstimate( + () => Promise.resolve({ reclaimableBytes: 256 }), + async () => ({ + nodeId: 1, + scope: 'all', + targets: ['volumes'], + items: [{ + target: 'volumes', id: 'volume-a', name: 'volume-a', sizeBytes: 256, + managed: false, reason: 'Volume is not referenced by any container', + }], + reclaimableBytes: 256, + fingerprint: 'volume-plan', + createdAt: 1, + }), + ); const res = await request(app) .post('/api/fleet/labels/fleet-prune') diff --git a/backend/src/__tests__/fleet-prune.test.ts b/backend/src/__tests__/fleet-prune.test.ts index 1b175d1f..13a83f48 100644 --- a/backend/src/__tests__/fleet-prune.test.ts +++ b/backend/src/__tests__/fleet-prune.test.ts @@ -1,17 +1,12 @@ -/** - * Tests for the fleet-wide Docker prune endpoint. Covers auth, tier gating, - * input validation, local node orchestration with mocked DockerController, - * remote-node fan-out with mocked fetch, lock contention, and partial failures. - */ -import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import request from 'supertest'; import jwt from 'jsonwebtoken'; -import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { cleanupTestDb, setupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; +import type { PruneItemOutcome, PrunePlan, PrunePlanItem } from '../services/prunePlan'; let tmpDir: string; let app: import('express').Express; let authHeader: string; -let LicenseService: typeof import('../services/LicenseService').LicenseService; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; @@ -20,13 +15,11 @@ let activeBulkActions: typeof import('../routes/labels').activeBulkActions; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); - ({ LicenseService } = await import('../services/LicenseService')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); ({ DatabaseService } = await import('../services/DatabaseService')); ({ activeBulkActions } = await import('../routes/labels')); - const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); - authHeader = `Bearer ${token}`; + authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' })}`; }); afterAll(() => cleanupTestDb(tmpDir)); @@ -36,198 +29,448 @@ afterEach(() => { activeBulkActions.clear(); }); -function mockTier(tier: 'paid' | 'community') { - vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier); +function item(overrides: Partial = {}): PrunePlanItem { + return { + target: 'images', + id: 'sha256:image', + name: 'example/app:latest', + sizeBytes: 256, + managed: true, + reason: 'Image is not used by any container', + stackName: 'app', + image: { references: ['example/app:latest'] }, + ...overrides, + } as PrunePlanItem; } -function mockLocalPrune(opts: { managedBytes?: Partial>; allBytes?: Partial>; throwOn?: string } = {}) { +function plan(nodeId: number, fingerprint = `fingerprint-${nodeId}`, items: PrunePlanItem[] = [item()]): PrunePlan { + return { + nodeId, + scope: 'managed', + targets: ['images'], + items, + reclaimableBytes: items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0), + fingerprint, + createdAt: 1, + }; +} + +function mockLocal(planFactory: (nodeId: number) => PrunePlan = (nodeId) => plan(nodeId)) { const fake = { - pruneManagedOnly: vi.fn(async (target: string) => { - if (opts.throwOn === target) throw new Error(`mock pruneManagedOnly threw for ${target}`); - return { success: true, reclaimedBytes: opts.managedBytes?.[target] ?? 0 }; - }), - pruneSystem: vi.fn(async (target: string) => { - if (opts.throwOn === target) throw new Error(`mock pruneSystem threw for ${target}`); - return { success: true, reclaimedBytes: opts.allBytes?.[target] ?? 0 }; - }), + buildPrunePlan: vi.fn(async (_targets, _scope, _stacks, nodeId: number) => planFactory(nodeId)), + executePrunePlan: vi.fn(async (reviewedPlan: PrunePlan): Promise<{ + success: boolean; + reclaimedBytes: number; + outcomes: PruneItemOutcome[]; + }> => ({ + success: true, + reclaimedBytes: reviewedPlan.reclaimableBytes, + outcomes: reviewedPlan.items.map((entry) => ({ + id: entry.id, + target: entry.target, + status: 'removed' as const, + sizeBytes: entry.sizeBytes, + })), + })), }; vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType); - // Spy on the prototype so the mock applies to whichever FileSystemService - // instance the route creates for the local node id, not a throwaway one. - vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['stack-a', 'stack-b']); + vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['app']); return fake; } +function localReview(fingerprint: string) { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + return { + local, + body: { + targets: ['images'], + scope: 'managed', + dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint }], + }, + }; +} + +function addRemote(name: string): number { + return DatabaseService.getInstance().addNode({ + name, + type: 'remote', + api_url: `http://${name}.example:1852`, + api_token: 'token', + compose_dir: '/app/compose', + is_default: false, + }); +} + describe('POST /api/fleet/labels/fleet-prune', () => { - it('returns 401 without auth', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(401); - }); - - it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => { - mockTier('community'); - mockLocalPrune({ managedBytes: { images: 128 } }); - const res = await request(app) + it('requires authentication and validates the request', async () => { + expect((await request(app).post('/api/fleet/labels/fleet-prune').send({})).status).toBe(401); + const invalid = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(res.body.code).not.toBe('PAID_REQUIRED'); - expect(Array.isArray(res.body.results)).toBe(true); - }); - - it('returns 400 when body is missing', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send(); - expect(res.status).toBe(400); - }); - - it('returns 400 when targets is empty', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: [], scope: 'managed' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/non-empty/); - }); - - it('returns 400 when a target is unrecognized', async () => { - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'containers'], scope: 'managed' }); - expect(res.status).toBe(400); - expect(res.body.error).toMatch(/Invalid target/); - }); - - it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => { - const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'managed' }); - expect(res.status).toBe(200); - expect(res.body.results).toHaveLength(1); - const node = res.body.results[0]; - expect(node.reachable).toBe(true); - expect(node.targets).toEqual([ - { target: 'images', success: true, reclaimedBytes: 1500 }, - { target: 'volumes', success: true, reclaimedBytes: 320 }, - ]); - expect(fake.pruneManagedOnly).toHaveBeenCalledTimes(2); - expect(fake.pruneSystem).not.toHaveBeenCalled(); - }); - - it('runs pruneSystem when scope is "all" and dedupes targets', async () => { - const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'networks', 'images'], scope: 'all' }); - expect(res.status).toBe(200); - expect(fake.pruneManagedOnly).not.toHaveBeenCalled(); - expect(fake.pruneSystem).toHaveBeenCalledTimes(2); - const node = res.body.results[0]; - expect(node.targets.map((t: { target: string }) => t.target).sort()).toEqual(['images', 'networks']); - }); - - it('records per-target failure when DockerController throws but continues remaining targets', async () => { - mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' }); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'managed' }); - expect(res.status).toBe(200); - const node = res.body.results[0]; - expect(node.targets.find((t: { target: string }) => t.target === 'images').success).toBe(true); - const volumes = node.targets.find((t: { target: string }) => t.target === 'volumes'); - expect(volumes.success).toBe(false); - expect(volumes.reclaimedBytes).toBe(0); - expect(volumes.error).toMatch(/pruneManagedOnly threw/); - }); - - it('reports lock contention when bulk-prune lock is already held', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const localId = db.getNodes().find(n => n.type === 'local')!.id; - activeBulkActions.add(`bulk-prune:${localId}`); - const res = await request(app) - .post('/api/fleet/labels/fleet-prune') - .set('Authorization', authHeader) - .send({ targets: ['images'], scope: 'managed' }); - expect(res.status).toBe(200); - const node = res.body.results.find((n: { nodeId: number }) => n.nodeId === localId); - expect(node.targets[0].success).toBe(false); - expect(node.targets[0].error).toMatch(/already running/); - }); - - it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const remoteId = db.addNode({ - name: 'remote-test', - type: 'remote', - api_url: 'http://remote.example:1852', - api_token: 'tok', - compose_dir: '/app/compose', - is_default: false, - }); - try { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); - const res = await request(app) + .send({ targets: ['containers'], dryRun: true }); + expect(invalid.status).toBe(400); + for (const scope of [undefined, 'everything', 1]) { + const response = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes', 'networks'], scope: 'managed' }); - expect(res.status).toBe(200); - const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); - expect(remote.reachable).toBe(false); - expect(remote.error).toMatch(/ECONNREFUSED/); - expect(remote.targets).toHaveLength(3); - for (const t of remote.targets) expect(t.success).toBe(false); - // Only the first target attempts the fetch; the rest short-circuit. - expect(fetchSpy).toHaveBeenCalledTimes(1); - } finally { - db.deleteNode(remoteId); + .send({ targets: ['images'], scope, dryRun: true }); + expect(response.status).toBe(400); } }); - it('parses remote node responses into per-target reclaimed bytes', async () => { - mockLocalPrune(); - const db = DatabaseService.getInstance(); - const remoteId = db.addNode({ - name: 'remote-ok', - type: 'remote', - api_url: 'http://remote-ok.example:1852/', - api_token: 'tok', - compose_dir: '/app/compose', - is_default: false, - }); + it('rejects malformed remote plan contracts', async () => { + mockLocal(); + const remoteId = addRemote('remote-malformed-plan'); + const base = plan(remoteId); + const malformedPlans = [ + { ...base, targets: ['images', 'images'] }, + { ...base, items: [item(), item()], reclaimableBytes: 512 }, + { ...base, reclaimableBytes: -1 }, + { ...base, nodeId: 'remote' }, + { ...base, createdAt: Number.NaN }, + ]; try { - const responses = new Map([['images', 4096], ['volumes', 512]]); - vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => { - const body = JSON.parse((init?.body as string) ?? '{}') as { target: string }; - const reclaimedBytes = responses.get(body.target) ?? 0; - return new Response(JSON.stringify({ message: 'ok', success: true, reclaimedBytes }), { - status: 200, headers: { 'content-type': 'application/json' }, - }); - }); - const res = await request(app) + for (const malformed of malformedPlans) { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true }); + const remote = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId); + expect(remote).toMatchObject({ reachable: true, code: 'REMOTE_PLAN_INVALID' }); + expect(remote.fingerprint).toBeUndefined(); + vi.restoreAllMocks(); + mockLocal(); + } + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('returns itemized dry-run plans without taking the destructive lock', async () => { + const fake = mockLocal(); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'managed', dryRun: true }); + + expect(response.status).toBe(200); + expect(response.body.results[0]).toMatchObject({ + reachable: true, + fingerprint: expect.stringMatching(/^fingerprint-/), + reclaimableBytes: 256, + items: [expect.objectContaining({ name: 'example/app:latest', managed: true, stackName: 'app' })], + targets: [{ target: 'images', success: true, reclaimedBytes: 256, dryRun: true }], + }); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(activeBulkActions.size).toBe(0); + }); + + it('loads known stacks for All unused attribution', async () => { + mockLocal(); + const stackSpy = vi.spyOn(FileSystemService.prototype, 'getStacks'); + await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ targets: ['images'], scope: 'all', dryRun: true }); + expect(stackSpy).toHaveBeenCalled(); + }); + + it('rejects missing, duplicate, and malformed reviewed entries', async () => { + mockLocal(); + const { local } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const cases = [ + { reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [] }, + { reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: local.id, reachable: true }], plans: [] }, + { reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [{ nodeId: local.id, fingerprint: '' }] }, + ]; + for (const testCase of cases) { + const response = await request(app) .post('/api/fleet/labels/fleet-prune') .set('Authorization', authHeader) - .send({ targets: ['images', 'volumes'], scope: 'all' }); - expect(res.status).toBe(200); - const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId); - expect(remote.reachable).toBe(true); - expect(remote.targets).toEqual([ - { target: 'images', success: true, reclaimedBytes: 4096 }, - { target: 'volumes', success: true, reclaimedBytes: 512 }, - ]); + .send({ targets: ['images'], scope: 'managed', dryRun: false, ...testCase }); + expect([400, 409]).toContain(response.status); + } + }); + + it('executes a valid empty plan and releases the lock', async () => { + const fake = mockLocal((nodeId) => plan(nodeId, `empty-${nodeId}`, [])); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `empty-${local.id}` }], + }); + expect(response.status).toBe(200); + expect(fake.executePrunePlan).toHaveBeenCalledTimes(1); + expect(response.body.results[0].outcomes).toEqual([]); + expect(activeBulkActions.size).toBe(0); + }); + + it('fails closed when a local prune lock is active', async () => { + const fake = mockLocal(); + const { local, body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const remoteId = addRemote('remote-lock-check'); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const lockKey = `bulk-prune:${local.id}`; + activeBulkActions.add(lockKey); + try { + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + ...body, + reviewedNodes: [...body.reviewedNodes, { nodeId: remoteId, reachable: true }], + plans: [...body.plans, { nodeId: remoteId, fingerprint: 'remote-plan' }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_ALREADY_RUNNING'); + expect(fake.buildPrunePlan).not.toHaveBeenCalled(); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(activeBulkActions.has(lockKey)).toBe(true); } finally { - db.deleteNode(remoteId); + activeBulkActions.delete(lockKey); + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('returns a node failure when local execution setup fails after preflight', async () => { + const fake = mockLocal(); + vi.spyOn(FileSystemService.prototype, 'getStacks') + .mockResolvedValueOnce(['app']) + .mockRejectedValueOnce(new Error('stack inventory unavailable')); + const { body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send(body); + + expect(response.status).toBe(200); + expect(response.body.results[0]).toMatchObject({ + code: 'PRUNE_EXECUTE_FAILED', + error: 'stack inventory unavailable', + targets: [{ success: false }], + }); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + }); + + it('prevents every destructive call when one node plan is stale', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-stale'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response( + JSON.stringify(plan(99, 'remote-new')), + { status: 200, headers: { 'content-type': 'application/json' } }, + )); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-old' }, + ], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_PLAN_STALE'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(String(fetchSpy.mock.calls[0][0])).toContain('/api/system/prune/plan'); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a reviewed-unreachable node that becomes reachable', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-newly-reachable'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(plan(99)), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: false }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a reviewed-reachable node that becomes unreachable', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-now-offline'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED')); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-plan' }, + ], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED'); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a changed configured-node roster before preflight', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-added-after-review'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }], + }); + expect(response.status).toBe(409); + expect(response.body.code).toBe('PRUNE_NODE_ROSTER_CHANGED'); + expect(fake.buildPrunePlan).not.toHaveBeenCalled(); + expect(fake.executePrunePlan).not.toHaveBeenCalled(); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects a present but incomplete remote outcome list', async () => { + const fake = mockLocal(); + const remoteId = addRemote('remote-bad-outcomes'); + try { + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify(plan(remoteId, 'remote-reviewed')), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + success: true, + reclaimedBytes: 256, + outcomes: [], + }), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [ + { nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, + { nodeId: remoteId, fingerprint: 'remote-reviewed' }, + ], + }); + + expect(response.status).toBe(200); + expect(fake.executePrunePlan).toHaveBeenCalledTimes(1); + expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({ + code: 'REMOTE_PRUNE_INVALID', + error: 'Remote returned malformed or incomplete prune outcomes', + }); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('rejects malformed numeric and success fields in remote execute results', async () => { + mockLocal(); + const remoteId = addRemote('remote-bad-result-fields'); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const remotePlan = plan(remoteId, 'remote-fields'); + const malformedResults = [ + { success: 'yes', reclaimedBytes: 256 }, + { success: true, reclaimedBytes: -1 }, + { + success: true, reclaimedBytes: 0, + outcomes: [{ target: 'images', id: 'sha256:image', status: 'removed', sizeBytes: -1 }], + }, + ]; + try { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + for (const malformed of malformedResults) { + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 })); + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, { nodeId: remoteId, fingerprint: 'remote-fields' }], + }); + expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({ + code: 'REMOTE_PRUNE_INVALID', + }); + } + } finally { + DatabaseService.getInstance().deleteNode(remoteId); + } + }); + + it('projects mixed local outcomes and accepts a legacy remote total', async () => { + const items = [ + item({ id: 'removed', sizeBytes: 100 }), + item({ id: 'skipped', sizeBytes: 200 }), + item({ id: 'failed', sizeBytes: 300 }), + ]; + const fake = mockLocal((nodeId) => plan(nodeId, `mixed-${nodeId}`, items)); + fake.executePrunePlan.mockResolvedValue({ + success: false, + reclaimedBytes: 100, + outcomes: [ + { target: 'images', id: 'removed', status: 'removed', sizeBytes: 100 }, + { target: 'images', id: 'skipped', status: 'skipped', reason: 'became active' }, + { target: 'images', id: 'failed', status: 'failed', error: 'remove failed' }, + ], + }); + const remoteId = addRemote('remote-legacy-total'); + const remotePlan = plan(remoteId, 'legacy-plan', []); + try { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ success: true, reclaimedBytes: 999 }), { status: 200 })); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `mixed-${local.id}` }, { nodeId: remoteId, fingerprint: 'legacy-plan' }], + }); + const localResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === local.id); + expect(localResult.targets[0]).toMatchObject({ + success: false, reclaimedBytes: 100, removed: 1, skipped: 1, failed: 1, + }); + const remoteResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId); + expect(remoteResult.reclaimedBytes).toBe(999); + expect(remoteResult.outcomes).toBeUndefined(); + expect(remoteResult.targets[0]).toMatchObject({ success: true, reclaimedBytes: 0 }); + } finally { + DatabaseService.getInstance().deleteNode(remoteId); } }); }); diff --git a/backend/src/__tests__/prune-plan.test.ts b/backend/src/__tests__/prune-plan.test.ts index 5c4fc81d..0a504a1a 100644 --- a/backend/src/__tests__/prune-plan.test.ts +++ b/backend/src/__tests__/prune-plan.test.ts @@ -105,6 +105,168 @@ describe('normalizePruneTargets', () => { }); describe('DockerController.buildPrunePlan', () => { + it('projects target metadata and only safe ownership labels in all scope', async () => { + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:dangling', + RepoTags: [':'], + RepoDigests: ['example/app@sha256:digest'], + Created: 1_700_000_000, + Size: 100, + Containers: 0, + Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' }, + }]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ + Name: 'my-stack_data', + Driver: 'local', + Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' }, + }] }); + mockDocker.listNetworks.mockResolvedValue([{ + Id: 'network-id', + Name: 'my-stack_default', + Driver: 'bridge', + Scope: 'local', + Labels: { + 'com.docker.compose.project': 'my-stack', + 'com.docker.compose.network': 'default', + secret: 'do-not-return', + }, + }]); + mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 42 } }], + Images: [{ Id: 'sha256:dangling', SharedSize: 10 }], + LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan( + ['images', 'volumes', 'networks'], 'all', ['my-stack'], 1, + ); + + expect(plan.items.find((entry) => entry.target === 'images')).toMatchObject({ + name: ':', + managed: true, + stackName: 'my-stack', + image: { + references: [], + digest: 'example/app@sha256:digest', + createdAt: 1_700_000_000, + }, + }); + expect(plan.items.find((entry) => entry.target === 'volumes')).toMatchObject({ + managed: true, + stackName: 'my-stack', + volume: { + driver: 'local', + ownershipLabels: { 'com.docker.compose.project': 'my-stack' }, + }, + }); + expect(plan.items.find((entry) => entry.target === 'networks')).toMatchObject({ + managed: true, + stackName: 'my-stack', + network: { + driver: 'bridge', + scope: 'local', + ownershipLabels: { + 'com.docker.compose.project': 'my-stack', + 'com.docker.compose.network': 'default', + }, + }, + }); + expect(JSON.stringify(plan.items)).not.toContain('do-not-return'); + expect(plan.reclaimableBytes).toBe( + plan.items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0), + ); + }); + + it('uses Compose path ownership fallbacks for non-container resources', async () => { + const ownershipLabels = { + 'com.docker.compose.project.working_dir': '/app/compose/my-stack', + 'com.docker.compose.project.config_files': '/app/compose/my-stack/compose.yml', + }; + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:path-owned', RepoTags: ['example/path:latest'], Size: 100, Containers: 0, Labels: ownershipLabels, + }]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: ownershipLabels }] }); + mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: ownershipLabels }]); + mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }], + Images: [{ Id: 'sha256:path-owned', SharedSize: 0 }], + LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan( + ['images', 'volumes', 'networks'], 'managed', ['my-stack'], 1, + ); + + expect(plan.items).toHaveLength(3); + expect(plan.items.every((entry) => entry.managed && entry.stackName === 'my-stack')).toBe(true); + }); + + it.each(['__proto__', 'constructor', 'toString', 'prototype'])( + 'does not attribute inherited project key %s to a managed stack', + async (project) => { + const labels = { 'com.docker.compose.project': project }; + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: `${project}_data`, Labels: labels }] }); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: `${project}_data`, UsageData: { RefCount: 0, Size: 42 } }], + Images: [], + LayersSize: 0, + }); + + const managedPlan = await DockerController.getInstance(1).buildPrunePlan( + ['volumes'], 'managed', ['my-stack'], 1, + ); + const allPlan = await DockerController.getInstance(1).buildPrunePlan( + ['volumes'], 'all', ['my-stack'], 1, + ); + + expect(managedPlan.items).toEqual([]); + expect(allPlan.items).toEqual([ + expect.objectContaining({ id: `${project}_data`, managed: false }), + ]); + expect(allPlan.items[0].stackName).toBeUndefined(); + }, + ); + + it('does not plan an image referenced by a container when Docker reports Containers as unknown', async () => { + mockDocker.listContainers.mockResolvedValue([{ Id: 'container', ImageID: 'sha256:in-use' }]); + mockDocker.listImages.mockResolvedValue([{ + Id: 'sha256:in-use', RepoTags: ['example/in-use:latest'], Size: 100, Containers: -1, + }]); + mockDocker.df.mockResolvedValue({ + Volumes: [], Images: [{ Id: 'sha256:in-use', SharedSize: 0 }], LayersSize: 0, + }); + + const plan = await DockerController.getInstance(1).buildPrunePlan(['images'], 'all', [], 1); + expect(plan.items).toEqual([]); + }); + + it('preserves Compose path ownership fallback during volume and network execution', async () => { + const labels = { 'com.docker.compose.project.working_dir': '/app/compose/my-stack' }; + const volumeRemove = vi.fn().mockResolvedValue(undefined); + const networkRemove = vi.fn().mockResolvedValue(undefined); + const networkInspect = vi.fn().mockResolvedValue({ Name: 'path_default', Labels: labels, Containers: {} }); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: labels }] }); + mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: labels }]); + mockDocker.df.mockResolvedValue({ + Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }], Images: [], LayersSize: 0, + }); + mockDocker.getVolume.mockReturnValue({ remove: volumeRemove }); + mockDocker.getNetwork.mockReturnValue({ inspect: networkInspect, remove: networkRemove }); + + const controller = DockerController.getInstance(1); + const plan = await controller.buildPrunePlan(['volumes', 'networks'], 'managed', ['my-stack'], 1); + const result = await controller.executePrunePlan(plan, ['my-stack']); + + expect(result.outcomes).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: 'volumes', status: 'removed' }), + expect.objectContaining({ target: 'networks', status: 'removed' }), + ])); + expect(volumeRemove).toHaveBeenCalledWith({ force: false }); + expect(networkRemove).toHaveBeenCalled(); + }); + it('enumerates managed stopped containers and never calls pruneSystem', async () => { mockDocker.listContainers.mockResolvedValue([ { diff --git a/backend/src/__tests__/system-maintenance-prune.test.ts b/backend/src/__tests__/system-maintenance-prune.test.ts index d7d5e021..7ca13686 100644 --- a/backend/src/__tests__/system-maintenance-prune.test.ts +++ b/backend/src/__tests__/system-maintenance-prune.test.ts @@ -16,12 +16,16 @@ let app: import('express').Express; let authHeader: string; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let CacheService: typeof import('../services/CacheService').CacheService; +let activeBulkActions: typeof import('../helpers/bulkActionLocks').activeBulkActions; beforeAll(async () => { tmpDir = await setupTestDb(); ({ app } = await import('../index')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); + ({ CacheService } = await import('../services/CacheService')); + ({ activeBulkActions } = await import('../helpers/bulkActionLocks')); // 10-minute expiry survives the full file even when two timeout tests // burn ~8.5s each in real-timer mode. const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' }); @@ -31,6 +35,7 @@ beforeAll(async () => { afterAll(() => cleanupTestDb(tmpDir)); afterEach(() => { + activeBulkActions.clear(); vi.restoreAllMocks(); }); @@ -150,6 +155,7 @@ describe('Prune plan routes', () => { buildPrunePlan: vi.fn().mockResolvedValue(plan), executePrunePlan, } as unknown as ReturnType); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate'); const res = await request(app) .post('/api/system/prune/system') @@ -161,6 +167,45 @@ describe('Prune plan routes', () => { expect(res.body.reclaimedBytes).toBe(42); expect(res.body.outcomes).toHaveLength(1); expect(executePrunePlan).toHaveBeenCalled(); + expect(invalidate).toHaveBeenCalledWith('stats:1'); + expect(invalidate).toHaveBeenCalledWith('stack-statuses:1'); + }); + + it('rejects an overlapping destructive prune on the same node', async () => { + stubFsStacks(); + const plan = samplePlan('fp-lock'); + let releaseExecution!: () => void; + const executionBlocked = new Promise((resolve) => { + releaseExecution = resolve; + }); + const executePrunePlan = vi.fn().mockImplementation(async () => { + await executionBlocked; + return { outcomes: [], reclaimedBytes: 0, success: true }; + }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(plan), + executePrunePlan, + } as unknown as ReturnType); + + const firstRequest = request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' }); + const firstResponse = firstRequest.then((response) => response); + await vi.waitFor(() => expect(executePrunePlan).toHaveBeenCalledTimes(1)); + + const overlapping = await request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' }); + + expect(overlapping.status).toBe(409); + expect(overlapping.body.code).toBe('PRUNE_ALREADY_RUNNING'); + expect(executePrunePlan).toHaveBeenCalledTimes(1); + + releaseExecution(); + expect((await firstResponse).status).toBe(200); + expect(activeBulkActions.size).toBe(0); }); it('POST /api/system/prune/system returns 409 PRUNE_PLAN_STALE on fingerprint mismatch', async () => { diff --git a/backend/src/helpers/bulkActionLocks.ts b/backend/src/helpers/bulkActionLocks.ts new file mode 100644 index 00000000..4d91cadb --- /dev/null +++ b/backend/src/helpers/bulkActionLocks.ts @@ -0,0 +1,3 @@ +// Process-local mutation locks shared by direct node actions and fleet-wide +// orchestration. Callers must use the same operation-specific key format. +export const activeBulkActions = new Set(); diff --git a/backend/src/helpers/fleetPrune.ts b/backend/src/helpers/fleetPrune.ts new file mode 100644 index 00000000..46bd4896 --- /dev/null +++ b/backend/src/helpers/fleetPrune.ts @@ -0,0 +1,597 @@ +import type { Node } from '../services/DatabaseService'; +import DockerController from '../services/DockerController'; +import { FileSystemService } from '../services/FileSystemService'; +import { NodeRegistry } from '../services/NodeRegistry'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { + PrunePlanStaleError, + hasOnlyPruneOwnershipLabels, + projectPruneOwnershipLabels, + type PruneItemOutcome, + type PrunePlan, + type PrunePlanItem, + type PruneScope, +} from '../services/prunePlan'; +import { invalidateNodeCaches } from './cacheInvalidation'; +import { getErrorMessage } from '../utils/errors'; +import { TimeoutError, withTimeout } from '../utils/withTimeout'; +import { formatNoTargetError } from '../utils/remoteTarget'; +import { sanitizeForLog } from '../utils/safeLog'; + +export const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; +export type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; + +export interface ReviewedFleetNode { + nodeId: number; + reachable: boolean; +} + +export interface ReviewedFleetPlan { + nodeId: number; + fingerprint: string; +} + +export interface FleetPruneTargetResult { + target: FleetPruneTarget; + success: boolean; + reclaimedBytes: number; + dryRun: boolean; + removed?: number; + skipped?: number; + failed?: number; + error?: string; +} + +export interface FleetPruneNodeResult { + nodeId: number; + nodeName: string; + reachable: boolean; + code?: string; + error?: string; + fingerprint?: string; + items?: PrunePlanItem[]; + reclaimableBytes?: number; + reclaimedBytes?: number; + outcomes?: PruneItemOutcome[]; + targets: FleetPruneTargetResult[]; +} + +export type ParsedFleetPruneRequest = { + targets: FleetPruneTarget[]; + scope: PruneScope; + dryRun: boolean; + reviewedNodes: ReviewedFleetNode[]; + plans: ReviewedFleetPlan[]; +}; + +type ParseResult = { request: ParsedFleetPruneRequest } | { error: string }; + +type Preflight = { + node: Node; + reachable: boolean; + plan?: PrunePlan; + code?: string; + error?: string; +}; + +type FleetPruneResponse = { + status: number; + body: { error?: string; code?: string; nodeId?: number; results?: FleetPruneNodeResult[] }; +}; + +const PLAN_TIMEOUT_MS = 8_000; +const REMOTE_PLAN_TIMEOUT_MS = 120_000; +const BUSY_DAEMON_ERROR = 'Docker daemon is busy. Please try again in a moment.'; + +function parseTargets(value: unknown): FleetPruneTarget[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const targets = new Set(); + for (const target of value) { + if (typeof target !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(target)) { + return null; + } + targets.add(target as FleetPruneTarget); + } + return [...targets]; +} + +function parseReviewedNodes(value: unknown): ReviewedFleetNode[] | null { + if (!Array.isArray(value) || value.length === 0) return null; + const parsed: ReviewedFleetNode[] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') return null; + const { nodeId, reachable } = entry as { nodeId?: unknown; reachable?: unknown }; + if (!Number.isInteger(nodeId) || typeof reachable !== 'boolean' || seen.has(nodeId as number)) return null; + seen.add(nodeId as number); + parsed.push({ nodeId: nodeId as number, reachable }); + } + return parsed; +} + +function parsePlans(value: unknown): ReviewedFleetPlan[] | null { + if (!Array.isArray(value)) return null; + const parsed: ReviewedFleetPlan[] = []; + const seen = new Set(); + for (const entry of value) { + if (!entry || typeof entry !== 'object') return null; + const { nodeId, fingerprint } = entry as { nodeId?: unknown; fingerprint?: unknown }; + if (!Number.isInteger(nodeId) || typeof fingerprint !== 'string' || fingerprint.trim() === '' || seen.has(nodeId as number)) { + return null; + } + seen.add(nodeId as number); + parsed.push({ nodeId: nodeId as number, fingerprint: fingerprint.trim() }); + } + return parsed; +} + +export function parseFleetPruneRequest(body: unknown): ParseResult { + if (!body || typeof body !== 'object') return { error: 'Request body is required' }; + const input = body as Record; + const targets = parseTargets(input.targets); + if (!targets) return { error: 'targets must be a non-empty array of images, volumes, or networks' }; + if (input.scope !== 'managed' && input.scope !== 'all') return { error: 'scope must be managed or all' }; + const scope: PruneScope = input.scope; + const dryRun = input.dryRun === true; + if (dryRun) return { request: { targets, scope, dryRun, reviewedNodes: [], plans: [] } }; + const reviewedNodes = parseReviewedNodes(input.reviewedNodes); + const plans = parsePlans(input.plans); + if (!reviewedNodes || !plans) return { error: 'reviewedNodes and plans are required for fleet prune execution' }; + return { request: { targets, scope, dryRun, reviewedNodes, plans } }; +} + +function validateReviewedRoster( + nodes: Node[], + reviewedNodes: ReviewedFleetNode[], + plans: ReviewedFleetPlan[], +): string | null { + const currentIds = nodes.map((node) => node.id).sort((a, b) => a - b); + const reviewedIds = reviewedNodes.map((node) => node.nodeId).sort((a, b) => a - b); + if (currentIds.length !== reviewedIds.length || currentIds.some((id, index) => id !== reviewedIds[index])) { + return 'The fleet node roster changed after the dry run'; + } + const reachableIds = reviewedNodes.filter((node) => node.reachable).map((node) => node.nodeId).sort((a, b) => a - b); + const planIds = plans.map((plan) => plan.nodeId).sort((a, b) => a - b); + if (reachableIds.length !== planIds.length || reachableIds.some((id, index) => id !== planIds[index])) { + return 'Plans must exactly cover the reachable nodes from the dry run'; + } + return null; +} + +function targetRowsFromPlan(plan: PrunePlan, targets: FleetPruneTarget[]): FleetPruneTargetResult[] { + return targets.map((target) => ({ + target, + success: true, + reclaimedBytes: plan.items + .filter((item) => item.target === target) + .reduce((sum, item) => sum + (item.sizeBytes ?? 0), 0), + dryRun: true, + })); +} + +function failedTargetRows( + targets: FleetPruneTarget[], + error: string, + dryRun: boolean, +): FleetPruneTargetResult[] { + return targets.map((target) => ({ target, success: false, reclaimedBytes: 0, dryRun, error })); +} + +function isPrunePlan( + value: unknown, + targets: FleetPruneTarget[], + scope: PruneScope, +): value is PrunePlan { + if (!value || typeof value !== 'object') return false; + const plan = value as Partial; + const planTargets = Array.isArray(plan.targets) ? plan.targets : []; + const requestedTargets = new Set(targets); + const uniquePlanTargets = new Set(planTargets); + const itemKeys = new Set(); + const itemBytes = Array.isArray(plan.items) + ? plan.items.reduce((sum, item) => sum + (typeof item?.sizeBytes === 'number' ? item.sizeBytes : 0), 0) + : -1; + return plan.scope === scope + && planTargets.length === requestedTargets.size + && uniquePlanTargets.size === requestedTargets.size + && planTargets.every((target) => typeof target === 'string' && requestedTargets.has(target as FleetPruneTarget)) + && typeof plan.fingerprint === 'string' + && plan.fingerprint.length > 0 + && Number.isInteger(plan.nodeId) + && typeof plan.createdAt === 'number' && Number.isFinite(plan.createdAt) && plan.createdAt >= 0 + && typeof plan.reclaimableBytes === 'number' && Number.isFinite(plan.reclaimableBytes) && plan.reclaimableBytes >= 0 + && plan.reclaimableBytes === itemBytes + && Array.isArray(plan.items) + && plan.items.every((item) => { + if (!isPrunePlanItem(item) || !requestedTargets.has(item.target as FleetPruneTarget)) return false; + const key = `${item.target}\0${item.id}`; + if (itemKeys.has(key)) return false; + itemKeys.add(key); + return true; + }); +} + +function isPrunePlanItem(value: unknown): value is PrunePlanItem { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + const validTarget = typeof item.target === 'string' + && ['images', 'volumes', 'networks', 'containers'].includes(item.target); + const validSize = item.sizeBytes === undefined + || (typeof item.sizeBytes === 'number' && Number.isFinite(item.sizeBytes) && item.sizeBytes >= 0); + const targetMetadata = item.target === 'images' + ? Boolean(item.image && typeof item.image === 'object' + && Array.isArray((item.image as { references?: unknown }).references) + && (item.image as { references: unknown[] }).references.every((ref) => typeof ref === 'string')) + : item.target === 'volumes' + ? Boolean(item.volume && typeof item.volume === 'object') + : item.target === 'networks' + ? Boolean(item.network && typeof item.network === 'object') + : true; + return validTarget + && typeof item.id === 'string' + && typeof item.name === 'string' + && typeof item.managed === 'boolean' + && typeof item.reason === 'string' + && validSize + && targetMetadata + && hasOnlyPruneOwnershipLabels((item.volume as { ownershipLabels?: unknown } | undefined)?.ownershipLabels) + && hasOnlyPruneOwnershipLabels((item.network as { ownershipLabels?: unknown } | undefined)?.ownershipLabels); +} + +function projectRemoteItem(item: PrunePlanItem): PrunePlanItem { + const base = { + id: item.id, + name: item.name, + managed: item.managed, + reason: item.reason, + ...(typeof item.sizeBytes === 'number' ? { sizeBytes: item.sizeBytes } : {}), + ...(typeof item.stackName === 'string' ? { stackName: item.stackName } : {}), + }; + if (item.target === 'images') { + return { ...base, target: 'images', image: { + references: Array.isArray(item.image.references) + ? item.image.references.filter((reference): reference is string => typeof reference === 'string') + : [], + ...(typeof item.image.digest === 'string' ? { digest: item.image.digest } : {}), + ...(typeof item.image.createdAt === 'number' ? { createdAt: item.image.createdAt } : {}), + } }; + } + if (item.target === 'volumes') { + const ownershipLabels = projectPruneOwnershipLabels(item.volume.ownershipLabels); + return { ...base, target: 'volumes', volume: { + ...(typeof item.volume.driver === 'string' ? { driver: item.volume.driver } : {}), + ...(ownershipLabels ? { ownershipLabels } : {}), + } }; + } + if (item.target === 'networks') { + const ownershipLabels = projectPruneOwnershipLabels(item.network.ownershipLabels); + return { ...base, target: 'networks', network: { + ...(typeof item.network.driver === 'string' ? { driver: item.network.driver } : {}), + ...(typeof item.network.scope === 'string' ? { scope: item.network.scope } : {}), + ...(ownershipLabels ? { ownershipLabels } : {}), + } }; + } + return { ...base, target: 'containers' }; +} + +function projectRemotePlan(plan: PrunePlan): PrunePlan { + return { + scope: plan.scope, + targets: [...plan.targets], + items: plan.items.map(projectRemoteItem), + reclaimableBytes: plan.reclaimableBytes, + fingerprint: plan.fingerprint, + createdAt: plan.createdAt, + nodeId: plan.nodeId, + }; +} + +function isPruneItemOutcome(value: unknown): value is PruneItemOutcome { + if (!value || typeof value !== 'object') return false; + const outcome = value as { id?: unknown; target?: unknown; status?: unknown; sizeBytes?: unknown; reason?: unknown; error?: unknown }; + if (typeof outcome.id !== 'string' || typeof outcome.target !== 'string') return false; + if (outcome.status === 'removed') return outcome.sizeBytes === undefined + || (typeof outcome.sizeBytes === 'number' && Number.isFinite(outcome.sizeBytes) && outcome.sizeBytes >= 0); + if (outcome.status === 'skipped') return typeof outcome.reason === 'string'; + if (outcome.status === 'failed') return typeof outcome.error === 'string'; + return false; +} + +function validateRemoteOutcomes( + value: unknown, + plan: PrunePlan, + targets: FleetPruneTarget[], +): PruneItemOutcome[] | null { + if (!Array.isArray(value) || value.length !== plan.items.length) return null; + const requestedTargets = new Set(targets); + const expected = new Set(plan.items.map((item) => `${item.target}\0${item.id}`)); + const seen = new Set(); + for (const outcome of value) { + if (!isPruneItemOutcome(outcome) || !requestedTargets.has(outcome.target)) return null; + const key = `${outcome.target}\0${outcome.id}`; + if (!expected.has(key) || seen.has(key)) return null; + seen.add(key); + } + return seen.size === expected.size ? value : null; +} + +async function buildLocalPreflight(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + try { + const knownStacks = await FileSystemService.getInstance(node.id).getStacks(); + const controller = DockerController.getInstance(node.id); + const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(node.id); + const plan = await withTimeout( + controller.buildPrunePlan(targets, scope, knownStacks, node.id, isImageHeld), + PLAN_TIMEOUT_MS, + 'docker prune plan', + ); + return { node, reachable: true, plan }; + } catch (error) { + console.error(`[Fleet prune] Plan failed on ${sanitizeForLog(node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + return { + node, + reachable: true, + code: error instanceof TimeoutError ? 'DOCKER_DAEMON_BUSY' : 'PRUNE_PLAN_FAILED', + error: error instanceof TimeoutError ? BUSY_DAEMON_ERROR : getErrorMessage(error, 'Failed to build prune plan'), + }; + } +} + +async function fetchRemotePlan(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!proxyTarget) return { node, reachable: false, error: formatNoTargetError(node) }; + const headers: Record = { 'Content-Type': 'application/json' }; + if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; + try { + const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/plan`, { + method: 'POST', + headers, + body: JSON.stringify({ targets, scope }), + signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), + }); + const data: unknown = await response.json().catch(() => null); + if (!response.ok) { + const message = data && typeof data === 'object' && typeof (data as { error?: unknown }).error === 'string' + ? (data as { error: string }).error + : `Remote returned ${response.status}`; + return { node, reachable: true, code: 'REMOTE_PLAN_FAILED', error: message }; + } + if (!isPrunePlan(data, targets, scope)) { + return { node, reachable: true, code: 'REMOTE_PLAN_INVALID', error: 'Remote returned a malformed prune plan' }; + } + return { node, reachable: true, plan: projectRemotePlan(data) }; + } catch (error) { + console.error(`[Fleet prune] Remote plan transport failed for ${sanitizeForLog(node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + return { node, reachable: false, error: getErrorMessage(error, 'Failed to reach remote node') }; + } +} + +function buildPreflight(node: Node, targets: FleetPruneTarget[], scope: PruneScope): Promise { + return node.type === 'local' + ? buildLocalPreflight(node, targets, scope) + : fetchRemotePlan(node, targets, scope); +} + +function preflightResult(entry: Preflight, targets: FleetPruneTarget[]): FleetPruneNodeResult { + if (entry.plan) { + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + fingerprint: entry.plan.fingerprint, + items: entry.plan.items, + reclaimableBytes: entry.plan.reclaimableBytes, + targets: targetRowsFromPlan(entry.plan, targets), + }; + } + const error = entry.error ?? 'Failed to build prune plan'; + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: entry.reachable, + code: entry.code, + error, + reclaimableBytes: 0, + targets: failedTargetRows(targets, error, true), + }; +} + +function outcomeTargetRows( + targets: FleetPruneTarget[], + outcomes: PruneItemOutcome[], + fallbackSuccess = true, +): FleetPruneTargetResult[] { + return targets.map((target) => { + const targetOutcomes = outcomes.filter((outcome) => outcome.target === target); + const failed = targetOutcomes.filter((outcome) => outcome.status === 'failed'); + const skipped = targetOutcomes.filter((outcome) => outcome.status === 'skipped'); + const removed = targetOutcomes.filter((outcome) => outcome.status === 'removed'); + const removedBytes = removed.reduce((sum, outcome) => sum + (outcome.status === 'removed' ? outcome.sizeBytes ?? 0 : 0), 0); + return { + target, + success: outcomes.length === 0 ? fallbackSuccess : failed.length === 0, + reclaimedBytes: outcomes.length === 0 ? 0 : removedBytes, + dryRun: false, + removed: removed.length, + skipped: skipped.length, + failed: failed.length, + error: failed.length > 0 ? failed.map((outcome) => outcome.status === 'failed' ? outcome.error : '').filter(Boolean).join('; ') : undefined, + }; + }); +} + +async function executeLocal(entry: Preflight, targets: FleetPruneTarget[]): Promise { + try { + const plan = entry.plan; + if (!plan) throw new Error('Local prune preflight is missing'); + const knownStacks = await FileSystemService.getInstance(entry.node.id).getStacks(); + const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(entry.node.id); + const result = await DockerController.getInstance(entry.node.id).executePrunePlan(plan, knownStacks, isImageHeld); + if (result.outcomes.some((outcome) => outcome.status === 'removed')) { + try { + invalidateNodeCaches(entry.node.id); + } catch (error) { + console.error(`[Fleet prune] Cache invalidation failed on ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + } + } + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + reclaimedBytes: result.reclaimedBytes, + outcomes: result.outcomes, + targets: outcomeTargetRows(targets, result.outcomes), + }; + } catch (error) { + console.error(`[Fleet prune] Execution failed on ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(getErrorMessage(error, 'Unknown error'))}`); + const stale = error instanceof PrunePlanStaleError; + const message = getErrorMessage(error, stale ? 'Prune plan changed' : 'Prune failed'); + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + code: stale ? 'PRUNE_PLAN_STALE' : 'PRUNE_EXECUTE_FAILED', + error: message, + reclaimedBytes: 0, + targets: failedTargetRows(targets, message, false), + }; + } +} + +async function executeRemote(entry: Preflight, targets: FleetPruneTarget[], scope: PruneScope): Promise { + const plan = entry.plan; + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(entry.node.id); + if (!plan || !proxyTarget) { + const error = 'Node became unreachable after fleet preflight'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: false, error, + reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + const headers: Record = { 'Content-Type': 'application/json' }; + if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`; + try { + const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/system/prune/system`, { + method: 'POST', + headers, + body: JSON.stringify({ targets, scope, planFingerprint: plan.fingerprint }), + signal: AbortSignal.timeout(REMOTE_PLAN_TIMEOUT_MS), + }); + const data: unknown = await response.json().catch(() => null); + const record = data && typeof data === 'object' ? data as Record : null; + if (!response.ok) { + const message = typeof record?.error === 'string' ? record.error : `Remote returned ${response.status}`; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: typeof record?.code === 'string' ? record.code : 'REMOTE_PRUNE_FAILED', + error: message, reclaimedBytes: 0, targets: failedTargetRows(targets, message, false), + }; + } + if (!record || typeof record.reclaimedBytes !== 'number' || !Number.isFinite(record.reclaimedBytes) + || record.reclaimedBytes < 0 || (record.success !== undefined && typeof record.success !== 'boolean')) { + const error = 'Remote returned a malformed prune result'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: 'REMOTE_PRUNE_INVALID', error, reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + const hasOutcomes = Object.prototype.hasOwnProperty.call(record, 'outcomes'); + const outcomes = hasOutcomes ? validateRemoteOutcomes(record.outcomes, plan, targets) : undefined; + if (hasOutcomes && !outcomes) { + const error = 'Remote returned malformed or incomplete prune outcomes'; + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: true, + code: 'REMOTE_PRUNE_INVALID', error, reclaimedBytes: 0, targets: failedTargetRows(targets, error, false), + }; + } + return { + nodeId: entry.node.id, + nodeName: entry.node.name, + reachable: true, + reclaimedBytes: record.reclaimedBytes, + outcomes: outcomes ?? undefined, + targets: outcomeTargetRows(targets, outcomes ?? [], record.success !== false), + }; + } catch (error) { + const message = getErrorMessage(error, 'Failed to reach remote node'); + console.error(`[Fleet prune] Remote execution transport failed for ${sanitizeForLog(entry.node.name)}: ${sanitizeForLog(message)}`); + return { + nodeId: entry.node.id, nodeName: entry.node.name, reachable: false, + error: message, reclaimedBytes: 0, targets: failedTargetRows(targets, message, false), + }; + } +} + +function comparePreflight( + preflights: Preflight[], + reviewedNodes: ReviewedFleetNode[], + plans: ReviewedFleetPlan[], +): { code: string; error: string; nodeId?: number } | null { + const reviewedById = new Map(reviewedNodes.map((node) => [node.nodeId, node])); + const plansById = new Map(plans.map((plan) => [plan.nodeId, plan])); + for (const entry of preflights) { + const reviewed = reviewedById.get(entry.node.id); + if (!reviewed) return { code: 'PRUNE_NODE_ROSTER_CHANGED', error: 'The fleet node roster changed after the dry run' }; + if (entry.reachable !== reviewed.reachable) { + return { + code: 'PRUNE_NODE_REACHABILITY_CHANGED', + nodeId: entry.node.id, + error: `Reachability changed for ${entry.node.name} after the dry run`, + }; + } + if (!reviewed.reachable) continue; + if (!entry.plan) { + return { + code: entry.code ?? 'PRUNE_PLAN_FAILED', + nodeId: entry.node.id, + error: entry.error ?? `Failed to rebuild the plan for ${entry.node.name}`, + }; + } + if (entry.plan.fingerprint !== plansById.get(entry.node.id)?.fingerprint) { + return { + code: 'PRUNE_PLAN_STALE', + nodeId: entry.node.id, + error: `The prune plan changed on ${entry.node.name} after the dry run`, + }; + } + } + return null; +} + +export async function runFleetPrune( + nodes: Node[], + request: ParsedFleetPruneRequest, + activeLocks: Set, +): Promise { + if (request.dryRun) { + const preflights = await Promise.all(nodes.map((node) => buildPreflight(node, request.targets, request.scope))); + return { status: 200, body: { results: preflights.map((entry) => preflightResult(entry, request.targets)) } }; + } + + const rosterError = validateReviewedRoster(nodes, request.reviewedNodes, request.plans); + if (rosterError) return { status: 409, body: { code: 'PRUNE_NODE_ROSTER_CHANGED', error: rosterError } }; + + const lockKeys = nodes.filter((node) => node.type === 'local').map((node) => `bulk-prune:${node.id}`); + const busyKey = lockKeys.find((key) => activeLocks.has(key)); + if (busyKey) return { status: 409, body: { code: 'PRUNE_ALREADY_RUNNING', error: 'A prune is already running on a reviewed node' } }; + for (const key of lockKeys) activeLocks.add(key); + + try { + const preflights = await Promise.all(nodes.map((node) => buildPreflight(node, request.targets, request.scope))); + const conflict = comparePreflight(preflights, request.reviewedNodes, request.plans); + if (conflict) { + return { + status: 409, + body: { ...conflict, results: preflights.map((entry) => preflightResult(entry, request.targets)) }, + }; + } + const reviewedReachable = new Set(request.reviewedNodes.filter((node) => node.reachable).map((node) => node.nodeId)); + const executable = preflights.filter((entry) => reviewedReachable.has(entry.node.id)); + const results = await Promise.all(executable.map((entry) => entry.node.type === 'local' + ? executeLocal(entry, request.targets) + : executeRemote(entry, request.targets, request.scope))); + return { status: 200, body: { results } }; + } finally { + for (const key of lockKeys) activeLocks.delete(key); + } +} diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index dc9c6091..be99ee00 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -10,7 +10,6 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD import { NodeRegistry } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; -import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import { getHostMemory } from '../helpers/hostMemory'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; @@ -47,8 +46,14 @@ import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog'; import { formatNoTargetError } from '../utils/remoteTarget'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; -import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; +import { invalidateRemoteMetaCache } from '../helpers/cacheInvalidation'; import { activeBulkActions } from './labels'; +import { + FLEET_PRUNE_TARGETS, + parseFleetPruneRequest, + runFleetPrune, + type FleetPruneTarget, +} from '../helpers/fleetPrune'; import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop'; import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary'; import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign'; @@ -2202,167 +2207,23 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res } }); -// Fleet-wide Docker prune. Fans out to every node, running per-target prune -// (images/volumes/networks) under the chosen scope. Local nodes call -// DockerController directly under a per-node bulk-prune lock; remote nodes -// receive one POST /api/system/prune/system per target via the standard -// Bearer-token path. Concurrent execution against the per-node prune route in -// systemMaintenance.ts is safe because Docker's prune API is internally -// serialized and idempotent (the worst case is a duplicate call returning 0 -// reclaimed bytes). +// Fleet-wide Docker prune. Dry runs collect one itemized plan per node. Execute +// validates the reviewed roster, preflights every plan, then starts mutation. // Tier: requireAdmin (admin-only fleet plumbing; available on every license). -const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const; -type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number]; - fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise => { if (!requireAdmin(req, res)) return; - - const body = req.body as { targets?: unknown; scope?: unknown; dryRun?: unknown } | undefined; - if (!body || typeof body !== 'object') { - res.status(400).json({ error: 'Request body is required' }); - return; - } - const rawTargets = Array.isArray(body.targets) ? body.targets : null; - if (!rawTargets || rawTargets.length === 0) { - res.status(400).json({ error: 'targets must be a non-empty array' }); - return; - } - const dedup = new Set(); - for (const t of rawTargets) { - if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) { - res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` }); + try { + const parsed = parseFleetPruneRequest(req.body); + if ('error' in parsed) { + res.status(400).json({ error: parsed.error }); return; } - dedup.add(t as FleetPruneTarget); - } - const targets: FleetPruneTarget[] = Array.from(dedup); - const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed'; - const isDryRun = body.dryRun === true; - - type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean }; - type NodeResult = { - nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; - }; - - try { - const db = DatabaseService.getInstance(); - const nodes = db.getNodes(); - if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-prune:', { targets, scope, dryRun: isDryRun, nodes: nodes.length }); - - const results: NodeResult[] = await Promise.all(nodes.map(async (node): Promise => { - if (node.type === 'local') { - const lockKey = `bulk-prune:${node.id}`; - if (activeBulkActions.has(lockKey)) { - return { - nodeId: node.id, nodeName: node.name, reachable: true, - targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' })), - }; - } - activeBulkActions.add(lockKey); - try { - const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : []; - const dockerController = DockerController.getInstance(node.id); - const targetResults: TargetResult[] = []; - let anySuccess = false; - for (const target of targets) { - try { - if (isDryRun) { - // estimateSystemReclaim hits `docker system df`; bound it - // so a slow local daemon doesn't hang the fleet admin tab - // (F-6). estimateManagedReclaim is fast (no df) and stays - // unwrapped. - const estimate = scope === 'managed' - ? await dockerController.estimateManagedReclaim(target, knownStacks) - : await withTimeout( - dockerController.estimateSystemReclaim(target, knownStacks), - FLEET_DF_TIMEOUT_MS, - 'docker disk usage', - ); - targetResults.push({ target, success: true, reclaimedBytes: estimate.reclaimableBytes, dryRun: true }); - continue; - } - const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(node.id); - const result = scope === 'managed' - ? await dockerController.pruneManagedOnly(target, knownStacks, isImageHeld) - : await dockerController.pruneSystem(target, undefined, isImageHeld); - targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes }); - if (result.reclaimedBytes > 0 || result.success) anySuccess = true; - } catch (err) { - const error = err instanceof TimeoutError - ? 'Docker daemon is busy. Please try again in a moment.' - : getErrorMessage(err, 'Prune failed'); - targetResults.push({ target, success: false, reclaimedBytes: 0, error }); - } - } - if (anySuccess && !isDryRun) invalidateNodeCaches(node.id); - return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults }; - } finally { - activeBulkActions.delete(lockKey); - } - } - - // Remote node: POST /api/system/prune/system per target, short-circuiting - // on the first transport-level failure so we don't hammer a dead node. - const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!proxyTarget) { - const error = formatNoTargetError(node); - return { - nodeId: node.id, nodeName: node.name, reachable: false, error, - targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error })), - }; - } - const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); - const remoteHeaders: Record = { 'Content-Type': 'application/json' }; - if (proxyTarget.apiToken) remoteHeaders.Authorization = `Bearer ${proxyTarget.apiToken}`; - const targetResults: TargetResult[] = []; - let nodeUnreachable: string | null = null; - for (const target of targets) { - if (nodeUnreachable) { - targetResults.push({ target, success: false, reclaimedBytes: 0, error: nodeUnreachable }); - continue; - } - try { - const response = await fetch(`${baseUrl}/api/system/prune/system`, { - method: 'POST', - headers: remoteHeaders, - body: JSON.stringify({ target, scope, dryRun: isDryRun }), - signal: AbortSignal.timeout(120000), - }); - if (!response.ok) { - const errBody = (await response.json().catch(() => ({}))) as { error?: string }; - const message = errBody.error || `Remote returned ${response.status}`; - nodeUnreachable = message; - targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); - continue; - } - const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number; dryRun?: boolean } | null; - if (!remote || typeof remote.reclaimedBytes !== 'number') { - targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' }); - continue; - } - const entry: TargetResult = { target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes }; - if (remote.dryRun) entry.dryRun = true; - targetResults.push(entry); - } catch (err) { - const message = getErrorMessage(err, 'Failed to reach remote node'); - nodeUnreachable = message; - targetResults.push({ target, success: false, reclaimedBytes: 0, error: message }); - } - } - return { - nodeId: node.id, nodeName: node.name, - reachable: nodeUnreachable === null, - error: nodeUnreachable ?? undefined, - targets: targetResults, - }; - })); - - if (isDebugEnabled()) { - const reachable = results.filter(r => r.reachable).length; - const reclaimed = results.reduce((n, r) => n + r.targets.reduce((m, t) => m + t.reclaimedBytes, 0), 0); - console.debug('[Fleet:debug] fleet-prune complete:', { reachable, unreachable: results.length - reachable, reclaimedBytes: reclaimed }); - } - res.json({ results }); + const response = await runFleetPrune( + DatabaseService.getInstance().getNodes(), + parsed.request, + activeBulkActions, + ); + res.status(response.status).json(response.body); } catch (error) { console.error('[Fleet] fleet-prune error:', error); res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet prune') }); diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index d63fe40c..35cbc991 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -16,12 +16,11 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; +import { activeBulkActions } from '../helpers/bulkActionLocks'; -// Module-scope lock shared by `POST /api/labels/:id/action` and the fleet-wide -// bulk endpoints in `routes/fleet.ts`. Keyed by `${nodeId}` so concurrent bulk -// actions targeting the same node serialize and a fleet-stop cannot race a -// per-label action on the same containers. -export const activeBulkActions = new Set(); +// Shared with the fleet-wide bulk endpoints. Label actions use `${nodeId}` so +// a fleet stop cannot race a per-label action on the same containers. +export { activeBulkActions }; export const labelsRouter = Router(); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index e86afda0..6926dfc5 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -12,6 +12,7 @@ import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryS import SelfIdentityService from '../services/SelfIdentityService'; import { requireAdmin } from '../middleware/tierGates'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import { activeBulkActions } from '../helpers/bulkActionLocks'; import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; @@ -155,6 +156,7 @@ systemMaintenanceRouter.post('/prune/plan', async (req: Request, res: Response) systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; + let pruneLockHeld = false; try { const body = req.body as { target?: unknown; @@ -200,8 +202,17 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response return; } - // Resources path: fingerprint-bound execute. Fleet still calls without a - // fingerprint and keeps the legacy pruneManagedOnly / pruneSystem path. + const pruneLockKey = `bulk-prune:${req.nodeId}`; + if (activeBulkActions.has(pruneLockKey)) { + return res.status(409).json({ + error: 'A prune is already running on this node', + code: 'PRUNE_ALREADY_RUNNING', + }); + } + activeBulkActions.add(pruneLockKey); + pruneLockHeld = true; + + // Fingerprint-bound execute used by Resources and Fleet. if (planFingerprint) { const built = await withTimeout( dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId, isImageHeld), @@ -231,7 +242,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response success: result.success, }); } - if (built.targets.includes('containers')) { + if (result.outcomes.some((outcome) => outcome.status === 'removed')) { invalidateNodeCaches(req.nodeId); } res.json({ @@ -288,6 +299,8 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response } console.error('System prune error:', error); res.status(500).json({ error: 'System prune failed' }); + } finally { + if (pruneLockHeld) activeBulkActions.delete(`bulk-prune:${req.nodeId}`); } }); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 00ae367e..443b09c3 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -13,6 +13,7 @@ import SelfIdentityService from './SelfIdentityService'; import { fingerprintPrunePlan, normalizePruneTargets, + projectPruneOwnershipLabels, PRUNEABLE_CONTAINER_STATES, PrunePlanStaleError, type PruneItemOutcome, @@ -921,17 +922,18 @@ class DockerController { if (selfIdentity.isOwnContainer(c.Id)) continue; const state = String(c.State ?? '').toLowerCase(); if (!PRUNEABLE_CONTAINER_STATES.has(state)) continue; - if (scope === 'managed') { - const stack = DockerController.resolveContainerStack( - c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, - ); - if (!stack) continue; - } + const stack = DockerController.resolveContainerStack( + c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; items.push({ target: 'containers', id: c.Id, name: containerName(c), sizeBytes: typeof c.SizeRw === 'number' && c.SizeRw > 0 ? c.SizeRw : undefined, + managed: Boolean(stack), + reason: `Container is ${state} and no longer running`, + stackName: stack ?? undefined, }); } } @@ -946,23 +948,29 @@ class DockerController { const rawVolumeData = await this.docker.listVolumes(); const rawVolumes = (this.validateApiData<{ Volumes?: Array<{ Name: string; + Driver?: string; Labels?: Record; }> }>(rawVolumeData)).Volumes || []; for (const vol of rawVolumes) { if (selfIdentity.isOwnVolume(vol.Name)) continue; const usage = volumeUsage.get(vol.Name); if (!usage || usage.refCount !== 0) continue; - if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); - if (!stack) continue; - } + const stack = DockerController.resolveContainerStack( + vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; items.push({ target: 'volumes', id: vol.Name, name: vol.Name, sizeBytes: usage.size > 0 ? usage.size : undefined, + managed: Boolean(stack), + reason: 'Volume is not referenced by any container', + stackName: stack ?? undefined, + volume: { + driver: vol.Driver, + ownershipLabels: projectPruneOwnershipLabels(vol.Labels), + }, }); } } @@ -971,6 +979,8 @@ class DockerController { const rawNetworks = await this.docker.listNetworks() as Array<{ Id: string; Name: string; + Driver?: string; + Scope?: string; Labels?: Record; }>; const networksInUse = new Set(); @@ -998,19 +1008,30 @@ class DockerController { ); continue; } - if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - net.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); - if (!stack) continue; - } - items.push({ target: 'networks', id: net.Id, name: net.Name }); + const stack = DockerController.resolveContainerStack( + net.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (scope === 'managed' && !stack) continue; + items.push({ + target: 'networks', + id: net.Id, + name: net.Name, + managed: Boolean(stack), + reason: 'Network has no attached containers', + stackName: stack ?? undefined, + network: { + driver: net.Driver, + scope: net.Scope, + ownershipLabels: projectPruneOwnershipLabels(net.Labels), + }, + }); } } if (ordered.includes('images')) { const unmanagedImageIds = new Set(); const managedImageIds = new Set(); + const imageToStack = new Map(); const imageToContainerIds = new Map(); for (const c of allContainers) { if (!c.ImageID) continue; @@ -1020,7 +1041,10 @@ class DockerController { const stack = DockerController.resolveContainerStack( c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); - if (stack) managedImageIds.add(c.ImageID); + if (stack) { + managedImageIds.add(c.ImageID); + if (!imageToStack.has(c.ImageID)) imageToStack.set(c.ImageID, stack); + } else unmanagedImageIds.add(c.ImageID); } const plannedContainerIds = new Set( @@ -1029,10 +1053,12 @@ class DockerController { const rawImages = await this.docker.listImages({ all: false }) as Array<{ Id: string; RepoTags?: string[] | null; + RepoDigests?: string[] | null; Labels?: Record; Size?: number; VirtualSize?: number; Containers?: number; + Created?: number; }>; // An image becomes free only when every container that references it is // also in this plan (not merely when any planned container uses it). @@ -1040,31 +1066,39 @@ class DockerController { for (const img of rawImages) { if (selfIdentity.isOwnImage(img.Id)) continue; if (isImageHeld?.(img.Id)) continue; - const containers = img.Containers ?? 0; const refs = imageToContainerIds.get(img.Id) ?? []; const becomesFree = freeingImages && refs.length > 0 - && refs.length >= containers && refs.every((id) => plannedContainerIds.has(id)); - if (containers > 0 && !becomesFree) continue; + if (refs.length > 0 && !becomesFree) continue; + const labeled = DockerController.resolveContainerStack( + img.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + const stack = labeled ?? imageToStack.get(img.Id) ?? null; if (scope === 'managed') { if (unmanagedImageIds.has(img.Id)) continue; - const labeled = DockerController.resolveProjectLabel( - img.Labels?.['com.docker.compose.project'], knownSet, projectToStack, - ); // Unattributed unused images (no managed container, no compose label) // are not Sencho-managed; keep them out of managed prune. - if (!becomesFree && !labeled && !managedImageIds.has(img.Id)) continue; + if (!becomesFree && !stack && !managedImageIds.has(img.Id)) continue; } - const name = img.RepoTags?.[0] && img.RepoTags[0] !== ':' - ? img.RepoTags[0] - : img.Id.slice(0, 12); + const references = (img.RepoTags ?? []).filter((ref) => ref && ref !== ':'); + const name = references[0] ?? ':'; const unique = DockerController.imageUniqueBytes(img, sharedSizes); items.push({ target: 'images', id: img.Id, name, sizeBytes: unique > 0 ? unique : undefined, + managed: Boolean(stack || managedImageIds.has(img.Id)), + reason: becomesFree + ? 'Image becomes unused after planned container removal' + : 'Image is not used by any container', + stackName: stack ?? undefined, + image: { + references, + digest: img.RepoDigests?.find((digest) => Boolean(digest)), + createdAt: typeof img.Created === 'number' ? img.Created : undefined, + }, }); } } @@ -1093,7 +1127,8 @@ class DockerController { /** * Rebuild the plan with the same targets/scope. Returns the fresh plan when - * the fingerprint still matches, otherwise null (caller maps to 409). + * the fingerprint still matches, otherwise null so the caller can report + * staleness through its route-specific response contract. */ public async assertPlanFresh( plan: PrunePlan, @@ -1173,14 +1208,18 @@ class DockerController { } if (target === 'volumes') { - const outcome = await this.executePlannedVolume(item, fresh.scope, knownSet, projectToStack, selfIdentity); + const outcome = await this.executePlannedVolume( + item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + ); outcomes.push(outcome); if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0; continue; } if (target === 'networks') { - outcomes.push(await this.executePlannedNetwork(item, fresh.scope, knownSet, projectToStack, selfIdentity)); + outcomes.push(await this.executePlannedNetwork( + item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + )); continue; } @@ -1207,7 +1246,7 @@ class DockerController { } } const outcome = await this.executePlannedImage( - item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity, + item, selfIdentity, ); outcomes.push(outcome); if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0; @@ -1238,9 +1277,10 @@ class DockerController { private async imageStillReferenced(imageId: string): Promise { try { - const images = await this.docker.listImages({ all: false }) as Array<{ Id: string; Containers?: number }>; - const match = images.find((img) => img.Id === imageId || img.Id.startsWith(imageId) || imageId.startsWith(img.Id)); - return (match?.Containers ?? 0) > 0; + const containers = await this.docker.listContainers({ all: true }) as Array<{ ImageID?: string }>; + return containers.some((container) => container.ImageID === imageId + || Boolean(container.ImageID?.startsWith(imageId)) + || imageId.startsWith(container.ImageID ?? '')); } catch { return true; } @@ -1250,8 +1290,8 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, - absDirToStack: Record, + projectToStack: Map, + absDirToStack: Map, resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise< @@ -1305,7 +1345,9 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, + projectToStack: Map, + absDirToStack: Map, + resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnVolume(item.id)) { @@ -1325,8 +1367,8 @@ class DockerController { return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume is in use' }; } if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack, + const stack = DockerController.resolveContainerStack( + vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); if (!stack) { return { id: item.id, target: 'volumes', status: 'skipped', reason: 'No longer a managed volume' }; @@ -1340,7 +1382,9 @@ class DockerController { item: PrunePlanItem, scope: PruneScope, knownSet: Set, - projectToStack: Record, + projectToStack: Map, + absDirToStack: Map, + resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnNetwork(item.id)) { @@ -1363,8 +1407,8 @@ class DockerController { return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network is in use' }; } if (scope === 'managed') { - const stack = DockerController.resolveProjectLabel( - inspected.Labels?.['com.docker.compose.project'], knownSet, projectToStack, + const stack = DockerController.resolveContainerStack( + inspected.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, ); if (!stack) { return { id: item.id, target: 'networks', status: 'skipped', reason: 'No longer a managed network' }; @@ -1376,11 +1420,6 @@ class DockerController { private async executePlannedImage( item: PrunePlanItem, - scope: PruneScope, - knownSet: Set, - projectToStack: Record, - absDirToStack: Record, - resolvedBase: string, selfIdentity: SelfIdentityService, ): Promise { if (selfIdentity.isOwnImage(item.id)) { @@ -1389,30 +1428,21 @@ class DockerController { const rawImages = await this.docker.listImages({ all: false }) as Array<{ Id: string; Size?: number; - Containers?: number; }>; const img = rawImages.find((i) => i.Id === item.id || i.Id.startsWith(item.id) || item.id.startsWith(i.Id)); if (!img) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' }; } - if ((img.Containers ?? 0) > 0) { + const allContainers = await this.docker.listContainers({ all: true }) as Array<{ + ImageID?: string; + Labels?: Record; + }>; + const references = allContainers.filter((container) => container.ImageID === img.Id + || Boolean(container.ImageID?.startsWith(img.Id)) + || img.Id.startsWith(container.ImageID ?? '')); + if (references.length > 0) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' }; } - if (scope === 'managed') { - const allContainers = await this.docker.listContainers({ all: true }) as Array<{ - ImageID?: string; - Labels?: Record; - }>; - for (const c of allContainers) { - if (c.ImageID !== img.Id) continue; - const stack = DockerController.resolveContainerStack( - c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, - ); - if (!stack) { - return { id: item.id, target: 'images', status: 'skipped', reason: 'Image referenced by unmanaged container' }; - } - } - } await this.docker.getImage(item.id).remove({ force: false }); // Prefer plan unique-bytes; do not fall back to full Size (shared layers). return { id: item.id, target: 'images', status: 'removed', sizeBytes: item.sizeBytes ?? 0 }; @@ -1925,19 +1955,20 @@ class DockerController { private static resolveProjectLabel( project: string | undefined, knownSet: Set, - projectToStack: Record, + projectToStack: Map, ): string | null { if (!project) return null; if (knownSet.has(project)) return project; - if (projectToStack[project]) return projectToStack[project]; - return null; + return projectToStack.get(project) ?? null; } /** Builds a map from absolute stack directory paths to stack names. */ - private static buildAbsDirMap(stackNames: string[]): Record { - const map: Record = {}; + private static buildAbsDirMap(stackNames: string[]): Map { + const map = new Map(); for (const stackDir of stackNames) { - map[path.join(COMPOSE_DIR, stackDir)] = stackDir; + const stackPath = path.join(COMPOSE_DIR, stackDir); + map.set(stackPath, stackDir); + map.set(path.resolve(stackPath), stackDir); } return map; } @@ -1948,21 +1979,24 @@ class DockerController { */ private static resolveContainerStack( containerLabels: Record | undefined, - projectToStack: Record, + projectToStack: Map, knownStackSet: Set, - absDirToStack: Record, + absDirToStack: Map, resolvedBase: string, ): string | null { if (!containerLabels) return null; // Primary: match by project name (handles name: overrides and standard directory-based names) const project = containerLabels['com.docker.compose.project']; - if (project && projectToStack[project]) return projectToStack[project]; + if (project) { + const stack = projectToStack.get(project); + if (stack) return stack; + } // Fallback 1: match by working_dir const workingDir = containerLabels['com.docker.compose.project.working_dir']; if (workingDir) { - const match = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)]; + const match = absDirToStack.get(workingDir) ?? absDirToStack.get(path.resolve(workingDir)); if (match) return match; } @@ -1989,15 +2023,15 @@ class DockerController { * Builds (or returns cached) mapping from Docker project name to Sencho stack directory name. * Compose files with a top-level `name:` field override the default project name. */ - private static async resolveProjectNameMap(stackNames: string[]): Promise> { + private static async resolveProjectNameMap(stackNames: string[]): Promise> { return CacheService.getInstance().getOrFetch( PROJECT_NAME_CACHE_KEY, PROJECT_NAME_CACHE_TTL_MS, async () => { - const map: Record = {}; + const map = new Map(); await Promise.all(stackNames.map(async (stackDir) => { - map[stackDir] = stackDir; + map.set(stackDir, stackDir); for (const fileName of COMPOSE_FILE_NAMES) { const filePath = path.join(COMPOSE_DIR, stackDir, fileName); @@ -2005,7 +2039,7 @@ class DockerController { const content = await fs.readFile(filePath, 'utf-8'); const parsed = yaml.parse(content); if (parsed?.name && typeof parsed.name === 'string') { - map[parsed.name] = stackDir; + map.set(parsed.name, stackDir); } break; } catch (err: unknown) { diff --git a/backend/src/services/prunePlan.ts b/backend/src/services/prunePlan.ts index a3612884..585d04e5 100644 --- a/backend/src/services/prunePlan.ts +++ b/backend/src/services/prunePlan.ts @@ -3,13 +3,47 @@ import { createHash } from 'crypto'; export type PruneTarget = 'images' | 'volumes' | 'networks' | 'containers'; export type PruneScope = 'managed' | 'all'; -export interface PrunePlanItem { - target: PruneTarget; +interface PrunePlanItemBase { id: string; name: string; sizeBytes?: number; + managed: boolean; + reason: string; + stackName?: string; } +export type PrunePlanItem = + | (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never }) + | (PrunePlanItemBase & { + target: 'images'; + image: { + references: string[]; + digest?: string; + createdAt?: number; + }; + volume?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'volumes'; + volume: { + driver?: string; + ownershipLabels?: Record; + }; + image?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'networks'; + network: { + driver?: string; + scope?: string; + ownershipLabels?: Record; + }; + image?: never; + volume?: never; + }); + export interface PrunePlan { scope: PruneScope; /** Ordered execution sequence (dependency-safe when multi-target). */ @@ -34,6 +68,36 @@ export const PRUNE_EXECUTION_ORDER: readonly PruneTarget[] = ['volumes', 'contai export const PRUNEABLE_CONTAINER_STATES = new Set(['created', 'exited', 'dead']); +const COMPOSE_OWNERSHIP_LABEL_KEYS = new Set([ + 'com.docker.compose.project', + 'com.docker.compose.project.working_dir', + 'com.docker.compose.project.config_files', + 'com.docker.compose.volume', + 'com.docker.compose.network', + 'com.docker.compose.service', +]); + +/** + * Disclosure allowlist for ownership evidence returned to API clients. + * Do not broaden it without reviewing Docker label values for sensitive data. + */ +export function projectPruneOwnershipLabels(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const projected = Object.entries(value).filter( + (entry): entry is [string, string] => COMPOSE_OWNERSHIP_LABEL_KEYS.has(entry[0]) + && typeof entry[1] === 'string' && entry[1].length > 0, + ); + return projected.length > 0 ? Object.fromEntries(projected) : undefined; +} + +export function hasOnlyPruneOwnershipLabels(value: unknown): boolean { + if (value === undefined) return true; + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + return Object.entries(value).every( + ([key, label]) => COMPOSE_OWNERSHIP_LABEL_KEYS.has(key) && typeof label === 'string' && label.length > 0, + ); +} + export function isPruneTarget(value: unknown): value is PruneTarget { return typeof value === 'string' && (PRUNE_TARGETS as readonly string[]).includes(value); } diff --git a/docs/features/fleet-actions.mdx b/docs/features/fleet-actions.mdx index 43b70633..947ec9cf 100644 --- a/docs/features/fleet-actions.mdx +++ b/docs/features/fleet-actions.mdx @@ -5,7 +5,7 @@ description: "Bulk operations across the fleet from one tab: stop stacks by labe The **Actions** tab on the Fleet view groups bulk operations that touch more than a single stack on a single node. Each action lives in its own card, orchestrates from the control instance, and reports per-node and per-stack results inline so you never have to click through a modal to learn what happened. -Three cards ship today: **Prune Docker resources fleet-wide**, **Bulk label assign**, and **Stop by label**. Every card follows the same discipline before it touches anything: a live, debounced readout resolves the exact blast radius as you type or select, and the destructive or state-changing button stays disabled until that readout resolves to a concrete node and stack list. You confirm against real names, not a label string or a byte estimate. +Three cards ship today: **Prune Docker resources fleet-wide**, **Bulk label assign**, and **Stop by label**. Every card resolves its blast radius before it touches anything. Fleet Prune also requires an itemized dry run, so its destructive button stays disabled until every reachable node has returned the exact resources and fingerprint that will authorize execution. Fleet view with the Actions tab selected. A two-column grid: Prune fleet-wide (top left, Maintenance chip), Bulk label assign (top right, Transformative chip), and Stop by label (bottom, Destructive chip). Each card shows a toolbar row with an action-class chip, a live blast-radius readout, a Dry run or Reset button, and the primary action. @@ -26,17 +26,17 @@ Fleet Actions is the home for operations that span the fleet but don't fit anywh | Trigger a Sencho self-update across remote nodes | **Check Updates** button on the Fleet masthead | | Steer where new blueprint deployments land | [Fleet Federation](/features/fleet-federation) | | Replicate scan policies and CVE suppressions to remotes | [Fleet Sync](/features/fleet-sync) | -| Reclaim disk space on a single node with an itemized, undo-safe preview | **Resources → Prune** on that node (a different endpoint and flow from the fleet-wide card; see [Prune Docker resources fleet-wide](#prune-docker-resources-fleet-wide)) | +| Reclaim disk space on a single node with an itemized preview | **Resources → Prune** on that node (the single-node version of the fingerprint-bound flow described in [Prune Docker resources fleet-wide](#prune-docker-resources-fleet-wide)) | ## How every card works: preview, confirm, execute All three cards share one execution model, and understanding it explains every result panel, timeout, and edge case below. 1. **You describe the target.** A stack label name (Stop by label), a label plus checked stacks (Bulk label assign), or a set of resource targets and a scope (Prune). -2. **A live, debounced readout resolves the real blast radius.** Typing a label name or checking a target fires a non-destructive preview call (`POST /api/fleet/labels/match-preview` or `POST /api/fleet/prune/estimate`) roughly 350-500ms after you stop changing input. The readout in the card's toolbar shows `awaiting target` until something is selected, `resolving…` while the call is in flight, and then a concrete count (`7 stacks · 1 nodes`, `~ 6.09 GB reclaimable`). The primary button stays disabled until this resolves to a non-zero, non-loading result. -3. **You confirm against the resolved list, not the input.** Clicking the primary button opens a confirmation dialog that lists the actual nodes and stacks (Stop, Bulk assign) or restates the scope (Prune). For Stop by label specifically, the confirmation carries the exact node/stack list the preview resolved, and the real stop only touches stacks that are still in that list *and* still carry the label at execution time: a stack that gains the label after you opened the confirmation is never touched, and a node that reconnects after the preview does not get pulled into the stop. -4. **The control instance fans the confirmed action out to every node in parallel.** The local node runs in-process; each remote node is called over the standard Bearer-token proxy path. A node that cannot be reached, returns a non-2xx response, or returns a shape Sencho does not recognize is reported as a failure for that node only; the fan-out to every other node still completes. -5. **Results render per node, grouped and expandable**, in a `Per-node breakdown` section below the form. Stop and Prune also expose a **Dry run** button that walks the identical code path and locks without performing the destructive step, so you can rehearse the exact fan-out before committing. +2. **A live, debounced readout estimates the blast radius.** Typing a label name or checking a target fires a non-destructive preview call (`POST /api/fleet/labels/match-preview` or `POST /api/fleet/prune/estimate`) roughly 350-500ms after you stop changing input. The toolbar shows a stack count or approximate reclaimable bytes while you refine the action. +3. **You review the resolved list, not only the input.** Stop and Bulk assign resolve concrete stacks. Prune requires **Dry run**, which lists every candidate image, volume, and network for each reachable node. Changing the targets, scope, node roster, or node reachability clears that authorization. +4. **The control instance verifies before mutation.** Fleet Prune rebuilds every reviewed plan and checks the complete node roster before any node starts deleting. A stale plan or changed reachability rejects the whole preflight. A later race can still produce an explicit partial result because each node revalidates again immediately before deletion. +5. **Results render per node, grouped and expandable.** Prune retains the reviewed item identity and, when the node returns item outcomes, marks each candidate Removed, Skipped, or Failed after execution. Unreachable nodes remain visible as excluded rather than appearing as successful empty plans. ## The three cards @@ -46,7 +46,7 @@ All three cards share one execution model, and understanding it explains every r | Bulk label assign | Transformative | `POST /api/fleet/labels/bulk-assign` | (computed client-side from `/api/labels` and `/api/fleet/node/:id/stacks` per node) | Only the nodes whose stacks you select | | Prune Docker resources fleet-wide | Maintenance | `POST /api/fleet/labels/fleet-prune` | `POST /api/fleet/prune/estimate` | Every configured node | -Every card is admin-only and available on every license tier. Stop and Prune iterate every node in **Settings → Nodes**; Bulk label assign iterates only the nodes whose stacks you actually checked. Each card runs the authoritative work on the executing node (the local node in process, every remote over the node proxy), so an unreachable node shows up in the results with a transport error rather than blocking the rest of the batch. +Every card is admin-only and available on every license tier. Stop and Prune iterate every node in **Settings → Nodes**; Bulk label assign iterates only the nodes whose stacks you actually checked. Each card runs the authoritative work on the executing node (the local node in process, every remote over the node proxy). Stop and Bulk label assign report unreachable nodes without blocking work elsewhere. Fleet Prune excludes unreachable nodes during review, then rejects execution if that reviewed reachability changes. ## Stop by label @@ -120,7 +120,7 @@ A single Apply accepts up to **1,000 stack assignments** summed across every tar ## Prune Docker resources fleet-wide -Reclaim disk space on every reachable node by deleting unused images, volumes, and networks. The control instance fans out to each node and reports reclaimed bytes per node and per target. +Reclaim disk space on every reachable node by deleting unused images, volumes, and networks. A dry run lists the exact candidates on each node, and the real prune is authorized by the fingerprint of each reviewed plan. Prune fleet-wide card with Images and Volumes targets checked, scope set to All unused, and a live per-node estimate: Local 153.85 MB, Opsix 3.06 GB, Pitt-Moba 1.37 GB, SLX-Mars 1.51 GB, totaling roughly 6.09 GB reclaimable in the toolbar readout. @@ -135,33 +135,48 @@ The **Targets** checkboxes are independent and at least one must be ticked: **Im Scope is a segmented control with two options: - **Managed only** (default). Sencho looks up the stacks it knows about on the node, then prunes only resources owned by those stacks. Active containers and resources placed by other tools are untouched. -- **All unused**. Sencho runs the equivalent of `docker system prune` for each selected target. Any image, volume, or network not currently in use is deleted, including resources from workloads Sencho does not manage. The confirmation title flips to **Prune ALL unused resources across the fleet?**. +- **All unused**. Sencho applies the target-specific Docker prune eligibility rules to each selected resource type. Any selected image, volume, or network not currently in use is deleted, including resources from workloads Sencho does not manage. The confirmation title flips to **Prune ALL unused resources across the fleet?**. -### Live estimate and behaviour +### Review the itemized dry run -- Changing a target or the scope re-triggers a debounced call to `POST /api/fleet/prune/estimate`, which walks the same Docker enumeration the destructive path uses so the estimate matches what pruning would actually reclaim. A completed real prune that succeeds on at least one target re-triggers the same estimate so the toolbar total and per-node list reflect post-prune Docker state. **Prune fleet** stays disabled until the estimate resolves: you cannot confirm a destructive fleet-wide prune with no context on what it will reclaim. -- Each remote node receives one `POST /api/system/prune/system` call per selected target, with a 120-second timeout. If a transport error fires for one target, the remaining targets on that node are short-circuited with the same error rather than retried, so a dead node doesn't absorb the full multi-target timeout budget. -- Local nodes serialize against a per-node lock (`bulk-prune:`). A second fleet prune launched against the same local node while the first is still in flight returns *A prune is already running on this node* for each target. -- Reclaimed bytes are reported by the Docker daemon and are approximate. Per-node rows in the results panel sum the per-target reclaim; the per-target children show how much each individual prune actually freed. +Click **Dry run** after choosing targets and scope. Each reachable node returns one multi-target plan grouped into Images, Volumes, and Networks. Candidate rows show the stable ID, display name, reclaimable size when Docker provides one, why the resource is unused, managed or unmanaged ownership, and the associated stack when it can be resolved. Images also show available digest and creation details; volumes show their driver; networks show driver and scope. Only Compose ownership labels are shown, not arbitrary Docker labels. - -Fleet Actions' prune card calls the same node-local prune route as the single-node **Resources → Prune** page, but without that page's itemized plan-and-fingerprint flow. It never returns the `PRUNE_PLAN_STALE` (409) error you can see on Resources; each fleet prune call targets exactly one resource type per node and executes immediately. If you want an itemized, reviewable plan before pruning a specific node, use that node's own Resources page instead. - +An untagged image is identified as `:` alongside its short ID. Under **All unused**, unmanaged candidates carry an **UNMANAGED** badge. Nodes that cannot be reached are shown as **excluded** and never as zero-candidate success. A reachable plan with zero items is still valid. + +The node total is the sum of the sizes shown in that node's candidate rows. Image totals are estimates because Docker layers may be shared; the actual bytes reclaimed can differ after Docker accounts for layers still referenced by other images. + +### Fingerprint-bound execution + +**Prune fleet** remains disabled until the current targets, scope, and node roster have a valid reviewed plan for every reachable node. Execution sends one fingerprint per reviewed reachable node. Before deletion begins, the control instance rebuilds all plans, confirms that reviewed-unreachable nodes are still unreachable, and compares the complete configured-node roster. + +If a node was added, removed, connected, disconnected, or changed candidates after the dry run, no node starts pruning. Run **Dry run** again to review the new state. Once fleet-wide preflight passes, each node revalidates immediately before deletion. A race at that point can produce a partial result, which is reported rather than hidden. + +Local plan enumeration has an eight-second Docker-daemon timeout. Real local execution holds the per-node prune lock from preflight through mutation. Proxy remotes and Pilot nodes use one multi-target plan request and one fingerprint-bound execute request through their normal fleet transport. Mesh-managed stacks follow the transport of the node that hosts them. + +### Read post-prune outcomes + +The result keeps the reviewed name and metadata for every candidate and adds one outcome when the node returns itemized outcomes: + +- **Removed** means the reviewed resource was deleted. +- **Skipped** means it became active, was already absent, or became protected before deletion. +- **Failed** includes the resource-level error returned by the node. + +If a remote reports only its reclaimed total, the node shows that total without inventing per-item statuses. A completed mutation refreshes the live estimate. ## Prerequisites | Requirement | Why it matters | |---|---| -| **Configured remote nodes in Settings → Nodes** | Stop and Prune iterate the configured node list; Bulk label assign iterates whichever nodes you select stacks on. A node missing its `api_url` or `api_token`, or one that cannot be reached, is reported once per node as unreachable and never blocks the reachable nodes. | +| **Configured remote nodes in Settings → Nodes** | Stop and Prune iterate the configured node list; Bulk label assign iterates whichever nodes you select stacks on. Stop and Bulk label assign report an unreachable node without blocking other nodes. Prune excludes it from the reviewed plan and rejects execution if its reachability later changes. | | **Admin role** | Every card requires the admin role to apply. | | **Labels you intend to target** | Stop by label and its autocomplete depend on stack labels existing on at least one node; Bulk label assign depends on at least one stack label existing anywhere in the fleet. See [Stack Labels](/features/stack-labels) for the authoring flow. | ## Behaviour and lifecycle -- **Always returns 200.** Every destructive endpoint is structured so the HTTP status reflects the request shape, not the operational outcome. Partial failure is encoded in per-row fields, not in the status code. +- **Operational outcomes are itemized.** Normal fan-out results use per-node and per-item fields. Fleet Prune uses `409` when the reviewed roster, reachability, or fingerprint changes before mutation, because that rejection guarantees no node has started deleting. - **No retry, no scheduling, no undo.** Fleet Actions runs synchronously and is operator-driven; there is no background scheduler and no roll-back. For recurrence, use [Scheduled Operations](/features/scheduled-operations). -- **Offline remotes still receive the request.** A node that is down at the moment of the action returns a transport-error row but does not block the fan-out across the rest of the fleet. -- **Concurrent runs serialize per node.** All three cards take per-node locks before touching Docker or the label tables, so kicking off a second prune, a second fleet stop, or a fleet stop overlapping a per-label stop on the same node yields a calm "already running on this node" row rather than silent double-execution. +- **Offline remotes stay visible.** Dry run marks an unreachable node as excluded. If its reachability changes before Prune executes, the reviewed authorization is rejected and must be rebuilt. +- **Concurrent mutations serialize per node.** A real local Fleet Prune holds its prune lock through preflight and execution. Dry-run enumeration stays outside the destructive lock. ## Limitations and non-goals @@ -174,7 +189,7 @@ Fleet Actions is intentionally narrow. The following are deliberately out of sco - **No undo.** A stopped stack stays stopped until you start it again; a pruned image is gone until it is pulled or rebuilt. - **Approximate reclaim numbers.** The bytes the Prune card reports come from the Docker daemon and are best-effort, not authoritative. - **Confirmed-target stops need a current remote.** A real (non-dry-run) stop bound to specific stacks refuses to run against a remote that doesn't advertise support for confirmed-target binding; upgrade the remote to retry. -- **Timeouts scale with the fan-out, not with any one node.** 60 seconds per remote on fleet-stop and bulk-assign, 120 seconds per remote per prune target. A remote with many stacks or a very slow filesystem may produce a timeout row before the underlying work fully completes; the action itself usually still finishes on the remote, the control instance just stopped waiting. +- **Timeouts scale with the fan-out, not with any one node.** Remote fleet-stop and bulk-assign calls allow 60 seconds. Fleet Prune allows 120 seconds for each node's combined multi-target plan or execute request. A remote with many stacks or a very slow filesystem may produce a timeout row before the underlying work fully completes; during execution, check that remote's logs before retrying because the control instance may have stopped waiting after mutation began. ## Practical workflows @@ -184,7 +199,7 @@ Tag the stacks you want to bring down with a dedicated label (for example `eveni ### Rehearse a destructive action before committing -For Stop and Prune, click **Dry run** first. It walks the identical lock, fan-out, and per-node logic as the real action but skips the destructive leaf call, so the results panel shows exactly what would happen (including which nodes are unreachable right now) before you commit to it. +For Stop and Prune, click **Dry run** first. Fleet Prune shows the exact Docker candidates, including which nodes are excluded, and stores the fingerprints needed to unlock the destructive action. ### Propagate a label across the fleet @@ -192,7 +207,7 @@ Define a label like `Media` on one node (for example the local node) under **Set ### Free disk before a heavy deploy -Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed only** scope, and check the live per-node estimate before confirming. It gives a quick read on which hosts have accumulated the most stale layers. Switch to **All unused** if you want the prune to reach workloads that Sencho does not manage. +Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed only** scope. Check the live estimate, run the itemized dry run, and review each image before confirming. Switch to **All unused** if you want the plan to include workloads that Sencho does not manage. ## Common questions @@ -201,13 +216,13 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed Bulk mode operates on a hand-picked set of stacks **on one node** and supports start, stop, restart, and update. Fleet Actions operates **across every configured node** by selector (a label, or a checked cross-node set), and only Stop is a lifecycle action here (Bulk mode covers restart and update, Fleet Actions does not). - No. Dry run walks the same code path, including acquiring the per-node lock, but every card skips the destructive Docker or label-table call and returns what it would have done instead. It is safe to run repeatedly. + No. Fleet Prune enumerates candidates without calling Docker remove methods or invalidating caches. It is safe to run repeatedly. - Every destructive or state-changing button stays disabled until the live preview or estimate resolves to a non-zero, non-loading result. This is deliberate: you always confirm against a concrete, current blast radius rather than an unresolved input. + Fleet Prune requires a successful **Dry run** for the current targets, scope, and node roster. Run it again after any of those inputs or a node's reachability changes. - - Resources → Prune builds an itemized plan with a fingerprint and re-validates it at execute time, which is where that error comes from. Fleet Actions' prune card calls the simpler legacy single-target path on each node instead, so there is no plan to go stale. + + The reviewed node roster, reachability, or candidate fingerprint changed before deletion began. Sencho rejected the entire fleet preflight so you can review the current candidates before trying again. @@ -239,7 +254,7 @@ Run **Prune Docker resources fleet-wide** with **Images** selected and **Managed Fleet Actions runs admin-only. Confirm the active user has the admin role under **Settings → Users**; operator and viewer roles see every card but cannot apply them. - The node is in **Settings → Nodes** but its `api_url` or `api_token` is missing, expired, or unreachable. Stop by label reports it once as a single ` (unreachable)` row; Prune reports it per target. Open **Settings → Nodes** on the control instance and test the connection for the remote; fix the credential or the reachability, then re-run the action. + The node is in **Settings → Nodes** but its `api_url` or `api_token` is missing, expired, or unreachable. Stop by label reports one ` (unreachable)` row. Fleet Prune keeps the node visible as excluded from the reviewed plan; its target rows carry the same reachability error. Open **Settings → Nodes** on the control instance and test the connection for the remote; fix the credential or reachability, then run a new dry run. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index f436378a..a0a8e513 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -33,6 +33,7 @@ import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { VolumeNameLabel } from './resources/VolumeNameLabel'; import { useTableSort } from '@/hooks/useTableSort'; import { SortableTableHead } from '@/components/ui/sortable-table'; +import { isPrunePlan, type PrunePlan, type PruneScope, type PruneTarget } from '@/lib/prunePlan'; // ── Interfaces ───────────────────────────────────────────────────────────────── @@ -90,25 +91,6 @@ interface UnmanagedContainer { } type ResourceFilter = 'all' | 'managed' | 'unmanaged'; -type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; -type PruneScope = 'managed' | 'all'; - -interface PrunePlanItem { - target: PruneTarget; - id: string; - name: string; - sizeBytes?: number; -} - -interface PrunePlan { - scope: PruneScope; - targets: PruneTarget[]; - items: PrunePlanItem[]; - reclaimableBytes: number; - fingerprint: string; - createdAt: number; - nodeId: number; -} const PLAN_PREVIEW_CAP = 30; @@ -530,8 +512,9 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} if (!res.ok) { throw new Error(data?.error || 'Failed to build prune plan'); } - setPrunePlan(data as PrunePlan); - return data as PrunePlan; + if (!isPrunePlan(data)) throw new Error('The node returned a malformed prune plan'); + setPrunePlan(data); + return data; } catch (error) { if (planFetchGenRef.current !== generation) return null; const err = error as { message?: string }; diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index ab2bc896..34324aa5 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -120,7 +120,10 @@ function samplePrunePlan(overrides: Record = {}) { return { scope: 'managed', targets: ['images'], - items: [{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }], + items: [{ + target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000, + managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] }, + }], reclaimableBytes: 1000, fingerprint: 'fp-test', createdAt: Date.now(), @@ -134,8 +137,14 @@ function reclaimPlan() { scope: 'all', targets: ['volumes', 'containers', 'images'], items: [ - { target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500 }, - { target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }, + { + target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500, + managed: true, reason: 'Volume is not referenced by any container', volume: {}, + }, + { + target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000, + managed: true, reason: 'Image is not used by any container', image: { references: ['old:v1'] }, + }, ], reclaimableBytes: 1500, fingerprint: 'fp-reclaim', diff --git a/frontend/src/components/fleet/FleetActions/PrunePlanResults.test.tsx b/frontend/src/components/fleet/FleetActions/PrunePlanResults.test.tsx new file mode 100644 index 00000000..2f9b0808 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/PrunePlanResults.test.tsx @@ -0,0 +1,60 @@ +import { expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { FleetPruneNodeResult, PrunePlanItem } from '@/lib/prunePlan'; +import { PrunePlanResults } from './PrunePlanResults'; + +const items: PrunePlanItem[] = [ + { target: 'images', id: 'removed-id', name: 'removed:latest', managed: true, reason: 'unused', image: { references: ['removed:latest'] } }, + { target: 'images', id: 'skipped-id', name: 'skipped:latest', managed: true, reason: 'unused', image: { references: ['skipped:latest'] } }, + { target: 'images', id: 'failed-id', name: 'failed:latest', managed: false, reason: 'unused', image: { references: ['failed:latest'] } }, +]; + +const plan: FleetPruneNodeResult = { + nodeId: 1, + nodeName: 'central', + reachable: true, + fingerprint: 'plan', + items, + reclaimableBytes: 0, + targets: [{ target: 'images', success: true, reclaimedBytes: 0, dryRun: true }], +}; + +it('renders removed, skipped, and failed outcomes with reviewed item names', () => { + const execution: FleetPruneNodeResult = { + nodeId: 1, + nodeName: 'central', + reachable: true, + reclaimedBytes: 0, + outcomes: [ + { target: 'images', id: 'removed-id', status: 'removed' }, + { target: 'images', id: 'skipped-id', status: 'skipped', reason: 'Became active' }, + { target: 'images', id: 'failed-id', status: 'failed', error: 'Docker refused removal' }, + ], + targets: [{ + target: 'images', success: false, reclaimedBytes: 0, dryRun: false, + removed: 1, skipped: 1, failed: 1, + }], + }; + render(); + + expect(screen.getByText('removed:latest')).toBeInTheDocument(); + expect(screen.getByText('skipped:latest')).toBeInTheDocument(); + expect(screen.getByText('failed:latest')).toBeInTheDocument(); + expect(screen.getByText('removed')).toBeInTheDocument(); + expect(screen.getByText('skipped')).toBeInTheDocument(); + expect(screen.getByText('failed')).toBeInTheDocument(); + expect(screen.getByText('Became active')).toBeInTheDocument(); + expect(screen.getByText('Docker refused removal')).toBeInTheDocument(); +}); + +it('renders the total-only fallback when a remote omits outcomes', () => { + const execution: FleetPruneNodeResult = { + nodeId: 1, + nodeName: 'central', + reachable: true, + reclaimedBytes: 1024, + targets: [{ target: 'images', success: true, reclaimedBytes: 1024, dryRun: false }], + }; + render(); + expect(screen.getByText('This node reported 1 KB reclaimed without itemized outcomes.')).toBeInTheDocument(); +}); diff --git a/frontend/src/components/fleet/FleetActions/PrunePlanResults.tsx b/frontend/src/components/fleet/FleetActions/PrunePlanResults.tsx new file mode 100644 index 00000000..d6f0e104 --- /dev/null +++ b/frontend/src/components/fleet/FleetActions/PrunePlanResults.tsx @@ -0,0 +1,156 @@ +import { cn, formatBytes } from '@/lib/utils'; +import type { + FleetPruneNodeResult, + FleetPruneTarget, + PruneItemOutcome, + PrunePlanItem, +} from '@/lib/prunePlan'; + +const TARGET_LABELS: Record = { + images: 'Images', + volumes: 'Volumes', + networks: 'Networks', +}; + +interface Props { + planResults: FleetPruneNodeResult[]; + executeResults?: FleetPruneNodeResult[]; +} + +function shortId(id: string): string { + return id.replace(/^sha256:/, '').slice(0, 12); +} + +function metadata(item: PrunePlanItem): string[] { + const values: string[] = []; + if (item.image?.references) { + values.push(...item.image.references.filter((reference) => reference !== item.name)); + } + if (item.image?.digest) values.push(item.image.digest); + if (item.image?.createdAt) values.push(new Date(item.image.createdAt * 1000).toLocaleString()); + if (item.volume?.driver) values.push(`driver ${item.volume.driver}`); + if (item.network?.driver) values.push(`driver ${item.network.driver}`); + if (item.network?.scope) values.push(`scope ${item.network.scope}`); + const labels = item.volume?.ownershipLabels ?? item.network?.ownershipLabels; + if (labels) values.push(...Object.entries(labels).map(([key, value]) => `${key}=${value}`)); + return values; +} + +function OutcomeBadge({ outcome }: { outcome?: PruneItemOutcome }) { + if (!outcome) return null; + const tone = outcome.status === 'removed' + ? 'border-success/40 bg-success/10 text-success' + : outcome.status === 'skipped' + ? 'border-warning/40 bg-warning/10 text-warning' + : 'border-destructive/40 bg-destructive/10 text-destructive'; + return ( + + {outcome.status} + + ); +} + +function ItemRow({ item, outcome }: { item: PrunePlanItem; outcome?: PruneItemOutcome }) { + const detail = outcome?.status === 'skipped' + ? outcome.reason + : outcome?.status === 'failed' + ? outcome.error + : item.reason; + return ( +
  • +
    +
    +
    + {item.name} + + {item.managed ? 'managed' : 'unmanaged'} + + +
    +

    + {shortId(item.id)}{item.stackName ? ` · stack ${item.stackName}` : ''} +

    + {metadata(item).map((value) => ( +

    {value}

    + ))} +

    {detail}

    +
    + + {item.sizeBytes == null ? 'size unavailable' : formatBytes(item.sizeBytes)} + +
    +
  • + ); +} + +function NodePlan({ plan, execution }: { plan: FleetPruneNodeResult; execution?: FleetPruneNodeResult }) { + if (!plan.reachable || !plan.fingerprint) { + return ( +
    +
    {plan.nodeName} · excluded
    +

    {plan.error ?? 'Node was unreachable during the dry run.'}

    +
    + ); + } + const items = plan.items ?? []; + const outcomesByItem = new Map( + execution?.outcomes?.map((outcome) => [`${outcome.target}\0${outcome.id}`, outcome]) ?? [], + ); + return ( +
    + + {plan.nodeName} · {formatBytes(execution?.reclaimedBytes ?? plan.reclaimableBytes ?? 0)} + +
    + {execution?.error && ( +

    + {execution.error} +

    + )} + {(['images', 'volumes', 'networks'] as FleetPruneTarget[]).map((target) => { + const targetItems = items.filter((item) => item.target === target); + const bytes = targetItems.reduce((sum, item) => sum + (item.sizeBytes ?? 0), 0); + return ( +
    0} className="rounded border border-card-border/40"> + + {TARGET_LABELS[target]} · {targetItems.length} · {formatBytes(bytes)} + + {targetItems.length > 0 && ( +
      + {targetItems.map((item) => ( + + ))} +
    + )} +
    + ); + })} + {execution && !execution.error && !execution.outcomes && ( +

    + This node reported {formatBytes(execution.reclaimedBytes ?? 0)} reclaimed without itemized outcomes. +

    + )} +
    +
    + ); +} + +export function PrunePlanResults({ planResults, executeResults }: Props) { + const executionByNode = new Map(executeResults?.map((result) => [result.nodeId, result])); + return ( +
    + {planResults.map((plan) => ( + + ))} +
    + ); +} diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx index 6ad8850b..ab4b2bd3 100644 --- a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx @@ -1,11 +1,4 @@ -/** - * Coverage for FleetPruneCard. - * - * The key safety property: the destructive "Prune fleet" confirm is blocked - * until the operator has seen a live reclaim estimate. Also locks dry-run - * payload shape, the all-scope confirm copy, and the failure toast. - */ -import { it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, expect, it, vi } from 'vitest'; import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -16,18 +9,18 @@ const toastSuccess = vi.fn(); const toastWarning = vi.fn(); vi.mock('@/components/ui/toast-store', () => ({ toast: { - error: (...a: unknown[]) => toastError(...a), - success: (...a: unknown[]) => toastSuccess(...a), + error: (...args: unknown[]) => toastError(...args), + success: (...args: unknown[]) => toastSuccess(...args), + warning: (...args: unknown[]) => toastWarning(...args), info: vi.fn(), - warning: (...a: unknown[]) => toastWarning(...a), loading: vi.fn(() => 'toast-id'), dismiss: vi.fn(), }, })); import { apiFetch } from '@/lib/api'; -import { FleetPruneCard } from './FleetPruneCard'; import type { FleetNode } from '@/components/FleetView/types'; +import { FleetPruneCard } from './FleetPruneCard'; const mockedFetch = apiFetch as unknown as ReturnType; @@ -37,241 +30,322 @@ function jsonResponse(status: number, body: unknown): Response { const nodes = [{ id: 1, name: 'central', status: 'online' }] as unknown as FleetNode[]; +const planResult = { + nodeId: 1, + nodeName: 'central', + reachable: true, + fingerprint: 'reviewed-fingerprint', + reclaimableBytes: 4096, + items: [{ + target: 'images', + id: 'sha256:abcdef1234567890', + name: 'example/app:latest', + sizeBytes: 4096, + managed: true, + reason: 'Image is not used by any container', + stackName: 'app', + image: { + references: ['example/app:latest'], + digest: 'example/app@sha256:digest', + createdAt: 1_700_000_000, + }, + }], + targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }], +}; + +function estimateResponse() { + return jsonResponse(200, { + totalBytes: 4096, + perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 4096, reachable: true }], + }); +} + beforeEach(() => { vi.clearAllMocks(); - mockedFetch.mockResolvedValue(jsonResponse(404, {})); + mockedFetch.mockImplementation((url: string) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + return Promise.resolve(jsonResponse(404, {})); + }); }); -it('blocks the prune confirm until a reclaim estimate is ready', async () => { - // Estimate endpoint never resolves to ready (404 -> unavailable). +it('keeps destructive prune disabled until an itemized dry run is reviewed', async () => { render(); - // images is selected by default, so an estimate is requested but unavailable. - await waitFor(() => expect(screen.getByText('~ estimate unavailable')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText('~ 4 KB reclaimable')).toBeInTheDocument()); expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); }); -it('enables the prune confirm once the estimate resolves', async () => { - mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { totalBytes: 1024, perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }] })); - } - return Promise.resolve(jsonResponse(404, {})); - }); - render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); -}); - -it('all-scope confirm spells out the irreversible all-unused prune', async () => { +it('renders item metadata and enables prune after a valid dry run', async () => { const user = userEvent.setup(); mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { totalBytes: 2048, perNode: [] })); + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(404, {})); + }); + + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + expect(screen.getByText('example/app:latest')).toBeInTheDocument(); + expect(screen.getByText('managed')).toBeInTheDocument(); + expect(screen.getByText(/stack app/)).toBeInTheDocument(); + expect(screen.getByText('example/app@sha256:digest')).toBeInTheDocument(); + const dryRunCall = mockedFetch.mock.calls.find((call) => call[0] === '/fleet/labels/fleet-prune'); + expect(JSON.parse(dryRunCall![1].body)).toEqual({ targets: ['images'], scope: 'managed', dryRun: true }); +}); + +it('submits the reviewed roster and fingerprint then renders item outcomes', async () => { + const user = userEvent.setup(); + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') { + const body = JSON.parse(String(init?.body)); + if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(200, { + results: [{ + nodeId: 1, + nodeName: 'central', + reachable: true, + reclaimedBytes: 4096, + outcomes: [{ id: 'sha256:abcdef1234567890', target: 'images', status: 'removed', sizeBytes: 4096 }], + targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: false, removed: 1, skipped: 0, failed: 0 }], + }], + })); } return Promise.resolve(jsonResponse(404, {})); }); + render(); - await user.click(screen.getByRole('button', { name: 'All unused' })); + await user.click(screen.getByRole('button', { name: 'Dry run' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); await user.click(screen.getByRole('button', { name: 'Prune fleet' })); - const dialog = await screen.findByRole('alertdialog'); - expect(within(dialog).getByText('Prune ALL unused resources across the fleet?')).toBeInTheDocument(); - expect(within(dialog).getByText(/This cannot be undone\./)).toBeInTheDocument(); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); + + await waitFor(() => expect(screen.getByText('removed')).toBeInTheDocument()); + const executeCall = mockedFetch.mock.calls + .filter((call) => call[0] === '/fleet/labels/fleet-prune') + .find((call) => JSON.parse(call[1].body).dryRun === false); + expect(JSON.parse(executeCall![1].body)).toEqual({ + targets: ['images'], + scope: 'managed', + dryRun: false, + reviewedNodes: [{ nodeId: 1, reachable: true }], + plans: [{ nodeId: 1, fingerprint: 'reviewed-fingerprint' }], + }); + expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); }); -it('dry run sends dryRun:true and reports reclaimable bytes', async () => { +it('invalidates authorization when targets or scope change', async () => { const user = userEvent.setup(); mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { totalBytes: 0, perNode: [] })); - } + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(404, {})); + }); + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + await user.click(screen.getByRole('checkbox', { name: 'Volumes' })); + expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); + expect(screen.queryByText('example/app:latest')).not.toBeInTheDocument(); +}); + +it('invalidates authorization when the node roster or status changes', async () => { + const user = userEvent.setup(); + mockedFetch.mockImplementation((url: string) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(404, {})); + }); + const view = render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + view.rerender(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled()); +}); + +it('shows unreachable nodes as excluded without treating them as empty plans', async () => { + const user = userEvent.setup(); + const unreachable = { + nodeId: 1, + nodeName: 'central', + reachable: false, + error: 'Pilot tunnel is disconnected', + reclaimableBytes: 0, + targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: true, error: 'Pilot tunnel is disconnected' }], + }; + mockedFetch.mockImplementation((url: string) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [unreachable] })); + return Promise.resolve(jsonResponse(404, {})); + }); + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + expect(await screen.findByText('central · excluded')).toBeInTheDocument(); + expect(screen.getByText('Pilot tunnel is disconnected')).toBeInTheDocument(); +}); + +it('accepts a valid empty plan', async () => { + const user = userEvent.setup(); + mockedFetch.mockImplementation((url: string) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { + results: [{ ...planResult, items: [], reclaimableBytes: 0, targets: [{ ...planResult.targets[0], reclaimedBytes: 0 }] }], + })); + return Promise.resolve(jsonResponse(404, {})); + }); + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + expect(screen.getByText('Images · 0 · 0 Bytes')).toBeInTheDocument(); +}); + +it('uses the exact stale-plan toast and clears authorization', async () => { + const user = userEvent.setup(); + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); if (url === '/fleet/labels/fleet-prune') { - return Promise.resolve(jsonResponse(200, { - results: [{ nodeId: 1, nodeName: 'central', reachable: true, targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }] }], + const body = JSON.parse(String(init?.body)); + if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(409, { + code: 'PRUNE_PLAN_STALE', + nodeId: 1, + error: 'The prune plan changed on central after the dry run', })); } return Promise.resolve(jsonResponse(404, {})); }); render(); await user.click(screen.getByRole('button', { name: 'Dry run' })); - await waitFor(() => { - const call = mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-prune'); - expect(call).toBeTruthy(); - expect(JSON.parse(call![1].body)).toEqual({ targets: ['images'], scope: 'managed', dryRun: true }); - }); - await waitFor(() => expect(toastSuccess).toHaveBeenCalled()); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + await user.click(screen.getByRole('button', { name: 'Prune fleet' })); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); + await waitFor(() => expect(toastError).toHaveBeenCalledWith( + 'The prune plan changed on “central” after the dry run. Run the dry run again before pruning.', + )); + expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); + expect(screen.queryByText('example/app:latest')).not.toBeInTheDocument(); }); -it('surfaces an error toast when the prune returns non-ok', async () => { +it('reports an execution-time stale race from a partial result', async () => { const user = userEvent.setup(); - mockedFetch.mockImplementation((url: string) => { + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); if (url === '/fleet/labels/fleet-prune') { - return Promise.resolve(jsonResponse(500, { error: 'prune blew up' })); + const body = JSON.parse(String(init?.body)); + if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(200, { + results: [{ + nodeId: 1, + nodeName: 'central', + reachable: true, + code: 'PRUNE_PLAN_STALE', + error: 'Prune plan changed', + reclaimedBytes: 0, + targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false, error: 'Prune plan changed' }], + }], + })); } return Promise.resolve(jsonResponse(404, {})); }); render(); await user.click(screen.getByRole('button', { name: 'Dry run' })); - await waitFor(() => expect(toastError).toHaveBeenCalledWith('prune blew up')); -}); - -function estimateCallCount(): number { - return mockedFetch.mock.calls.filter(c => c[0] === '/fleet/prune/estimate').length; -} - -it('re-fetches the estimate after a partial-success real prune', async () => { - const user = userEvent.setup(); - let estimatePhase: 'pre' | 'post' = 'pre'; - mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - if (estimatePhase === 'pre') { - return Promise.resolve(jsonResponse(200, { - totalBytes: 1024, - perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }], - })); - } - return Promise.resolve(jsonResponse(200, { - totalBytes: 0, - perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 0, reachable: true }], - })); - } - if (url === '/fleet/labels/fleet-prune') { - // One target succeeded before a later failure left the node unreachable. - // reachable:false must not suppress the refresh (target.success is the signal). - return Promise.resolve(jsonResponse(200, { - results: [{ - nodeId: 1, - nodeName: 'central', - reachable: false, - error: 'transport failed after images', - targets: [ - { target: 'images', success: true, reclaimedBytes: 1024 }, - { target: 'volumes', success: false, reclaimedBytes: 0, error: 'unreachable' }, - ], - }], - })); - } - return Promise.resolve(jsonResponse(404, {})); - }); - - render(); await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); - expect(screen.getByText('~ 1 KB reclaimable')).toBeInTheDocument(); - expect(screen.getByText('1 KB')).toBeInTheDocument(); - const callsBeforePrune = estimateCallCount(); - await user.click(screen.getByRole('button', { name: 'Prune fleet' })); - const dialog = await screen.findByRole('alertdialog'); - estimatePhase = 'post'; - await user.click(within(dialog).getByRole('button', { name: 'Prune managed' })); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); - await waitFor(() => expect(estimateCallCount()).toBe(callsBeforePrune + 1)); - const postEstimateCall = [...mockedFetch.mock.calls].reverse().find(c => c[0] === '/fleet/prune/estimate'); - expect(postEstimateCall).toBeTruthy(); - expect(JSON.parse(postEstimateCall![1].body)).toEqual({ targets: ['images'], scope: 'managed' }); - await waitFor(() => expect(screen.getByText('0 reclaimable')).toBeInTheDocument()); - await waitFor(() => expect(screen.getByText('0 Bytes')).toBeInTheDocument()); + await waitFor(() => expect(toastError).toHaveBeenCalledWith( + 'The prune plan changed on “central” after the dry run. Run the dry run again before pruning.', + )); + expect(toastWarning).not.toHaveBeenCalled(); + expect(screen.getByText('Prune plan changed')).toBeInTheDocument(); }); -it('does not re-fetch the estimate after a dry run', async () => { +it('rejects an incomplete successful execute response', async () => { const user = userEvent.setup(); - mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { - totalBytes: 1024, - perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }], - })); - } + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); if (url === '/fleet/labels/fleet-prune') { - return Promise.resolve(jsonResponse(200, { - results: [{ - nodeId: 1, - nodeName: 'central', - reachable: true, - targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }], - }], - })); + const body = JSON.parse(String(init?.body)); + if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(200, { results: [] })); } return Promise.resolve(jsonResponse(404, {})); }); - render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); - const callsBefore = estimateCallCount(); await user.click(screen.getByRole('button', { name: 'Dry run' })); - await waitFor(() => expect(toastSuccess).toHaveBeenCalled()); - // Debounce window: a refresh would schedule another estimate within 350ms. - await new Promise(r => setTimeout(r, 500)); - expect(estimateCallCount()).toBe(callsBefore); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + await user.click(screen.getByRole('button', { name: 'Prune fleet' })); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); + + await waitFor(() => expect(toastError).toHaveBeenCalledWith('Fleet prune returned an incomplete node result set')); + expect(toastSuccess).not.toHaveBeenCalledWith(expect.stringContaining('Reclaimed')); }); -it('does not re-fetch the estimate when every target fails on a 2xx response', async () => { +it.each([ + ['unexpected node', [{ nodeId: 99, nodeName: 'other', reachable: true, reclaimedBytes: 0, targets: [] }]], + ['malformed target', [{ nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 0, targets: [null] }]], + ['negative total', [{ + nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: -1, + targets: [{ target: 'images', success: true, reclaimedBytes: 0, dryRun: false }], + }]], + ['malformed outcomes', [{ + nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 0, + outcomes: [{ target: 'images', id: 'sha256:abcdef1234567890', status: 'failed' }], + targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false }], + }]], +])('rejects an execute response with %s', async (_label, results) => { const user = userEvent.setup(); - mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { - totalBytes: 1024, - perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }], - })); - } + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); if (url === '/fleet/labels/fleet-prune') { + const body = JSON.parse(String(init?.body)); + return Promise.resolve(jsonResponse(200, body.dryRun ? { results: [planResult] } : { results })); + } + return Promise.resolve(jsonResponse(404, {})); + }); + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + await user.click(screen.getByRole('button', { name: 'Prune fleet' })); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); + await waitFor(() => expect(toastError).toHaveBeenCalledWith('Fleet prune returned an incomplete node result set')); + expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); +}); + +it('warns when another node mutated before an execution-time stale result', async () => { + const user = userEvent.setup(); + const twoNodes = [...nodes, { id: 2, name: 'edge', status: 'online' }] as unknown as FleetNode[]; + const edgePlan = { ...planResult, nodeId: 2, nodeName: 'edge', fingerprint: 'edge-fingerprint' }; + mockedFetch.mockImplementation((url: string, init?: RequestInit) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') { + const body = JSON.parse(String(init?.body)); + if (body.dryRun) return Promise.resolve(jsonResponse(200, { results: [planResult, edgePlan] })); return Promise.resolve(jsonResponse(200, { - results: [{ - nodeId: 1, - nodeName: 'central', - reachable: true, - targets: [{ target: 'images', success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' }], - }], + results: [ + { + nodeId: 1, nodeName: 'central', reachable: true, reclaimedBytes: 4096, + outcomes: [{ id: 'sha256:abcdef1234567890', target: 'images', status: 'removed', sizeBytes: 4096 }], + targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: false }], + }, + { + nodeId: 2, nodeName: 'edge', reachable: true, code: 'PRUNE_PLAN_STALE', + error: 'Prune plan changed', reclaimedBytes: 0, + targets: [{ target: 'images', success: false, reclaimedBytes: 0, dryRun: false, error: 'Prune plan changed' }], + }, + ], })); } return Promise.resolve(jsonResponse(404, {})); }); - - render(); - await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); - const callsBefore = estimateCallCount(); - await user.click(screen.getByRole('button', { name: 'Prune fleet' })); - const dialog = await screen.findByRole('alertdialog'); - await user.click(within(dialog).getByRole('button', { name: 'Prune managed' })); - await waitFor(() => expect(toastError).toHaveBeenCalled()); - await new Promise(r => setTimeout(r, 500)); - expect(estimateCallCount()).toBe(callsBefore); -}); - -it('warns instead of claiming total failure when a reachable node has mixed per-target outcomes', async () => { - const user = userEvent.setup(); - mockedFetch.mockImplementation((url: string) => { - if (url === '/fleet/prune/estimate') { - return Promise.resolve(jsonResponse(200, { - totalBytes: 1024, - perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }], - })); - } - if (url === '/fleet/labels/fleet-prune') { - // Images succeed, volumes fail on the same reachable node. Pre-fix toast - // logic treated the node as fully failed (okNodes=0) and said every node - // failed even though Docker mutated for images. - return Promise.resolve(jsonResponse(200, { - results: [{ - nodeId: 1, - nodeName: 'central', - reachable: true, - targets: [ - { target: 'images', success: true, reclaimedBytes: 1024 }, - { target: 'volumes', success: false, reclaimedBytes: 0, error: 'volume prune failed' }, - ], - }], - })); - } - return Promise.resolve(jsonResponse(404, {})); - }); - - render(); + render(); + await user.click(screen.getByRole('button', { name: 'Dry run' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); await user.click(screen.getByRole('button', { name: 'Prune fleet' })); - const dialog = await screen.findByRole('alertdialog'); - await user.click(within(dialog).getByRole('button', { name: 'Prune managed' })); + await user.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: 'Prune managed' })); - await waitFor(() => expect(toastWarning).toHaveBeenCalled()); - expect(toastWarning.mock.calls[0][0]).toMatch(/1\/1 nodes reclaimed space/); - expect(toastError).not.toHaveBeenCalledWith('Prune failed on every node. See results below.'); + await waitFor(() => expect(toastWarning).toHaveBeenCalledWith( + 'Fleet prune partially completed: 4 KB reclaimed before the plan changed on “edge”. Run the dry run again before pruning.', + )); }); diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx index 34729407..24850b15 100644 --- a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx @@ -8,24 +8,32 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn, formatBytes } from '@/lib/utils'; import type { FleetNode } from '@/components/FleetView/types'; -import { ResultsList, type ResultRow } from '../ResultsList'; +import type { + FleetPruneNodeResult, + FleetPruneTarget, + PruneScope, +} from '@/lib/prunePlan'; +import { isPruneItemOutcome, isPrunePlanItem } from '@/lib/prunePlan'; +import { PrunePlanResults } from '../PrunePlanResults'; -type PruneTarget = 'images' | 'volumes' | 'networks'; -type PruneScope = 'managed' | 'all'; - -const ALL_TARGETS: ReadonlyArray<{ id: PruneTarget; label: string }> = [ +const ALL_TARGETS: ReadonlyArray<{ id: FleetPruneTarget; label: string }> = [ { id: 'images', label: 'Images' }, { id: 'volumes', label: 'Volumes' }, { id: 'networks', label: 'Networks' }, ]; -interface TargetResult { target: PruneTarget; success: boolean; reclaimedBytes: number; error?: string; dryRun?: boolean } -interface FleetPruneNodeResult { - nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[]; +interface PruneEstimateNode { + nodeId: number; + nodeName: string; + reclaimableBytes: number; + reachable: boolean; + error?: string; } -interface PruneEstimateNode { nodeId: number; nodeName: string; reclaimableBytes: number; reachable: boolean; error?: string } -interface PruneEstimateResponse { totalBytes: number; perNode: PruneEstimateNode[] } +interface PruneEstimateResponse { + totalBytes: number; + perNode: PruneEstimateNode[]; +} type EstimateState = | { kind: 'idle' } @@ -33,6 +41,11 @@ type EstimateState = | { kind: 'unavailable' } | { kind: 'ready'; data: PruneEstimateResponse }; +interface ReviewedPlanState { + key: string; + results: FleetPruneNodeResult[]; +} + interface Props { nodes: FleetNode[]; } @@ -40,54 +53,128 @@ interface Props { const KICKER = 'font-mono text-[10px] uppercase tracking-[0.18em]'; const ESTIMATE_ROW_LIMIT = 6; +function reviewStateIsValid( + reviewed: ReviewedPlanState | null, + reviewKey: string, + nodes: FleetNode[], + selectedTargets: FleetPruneTarget[], +): reviewed is ReviewedPlanState { + if (!reviewed || reviewed.key !== reviewKey || reviewed.results.length !== nodes.length) return false; + const currentIds = new Set(nodes.map((node) => node.id)); + const resultIds = new Set(reviewed.results.map((result) => result.nodeId)); + return resultIds.size === reviewed.results.length + && reviewed.results.every((result) => currentIds.has(result.nodeId) + && (result.reachable + ? typeof result.fingerprint === 'string' && result.fingerprint.length > 0 + && Array.isArray(result.items) && result.items.every((item) => isPrunePlanItem(item) && selectedTargets.includes(item.target as FleetPruneTarget)) + : !result.fingerprint)); +} + +function executeResultsAreValid( + value: unknown, + reviewedResults: FleetPruneNodeResult[], + selectedTargets: FleetPruneTarget[], +): value is FleetPruneNodeResult[] { + if (!Array.isArray(value)) return false; + const expectedIds = reviewedResults.filter((result) => result.reachable).map((result) => result.nodeId); + if (value.length !== expectedIds.length) return false; + const seen = new Set(); + return value.every((result) => { + if (!result || typeof result !== 'object') return false; + const entry = result as Partial; + if (!Number.isInteger(entry.nodeId) || seen.has(entry.nodeId as number)) return false; + seen.add(entry.nodeId as number); + if (!expectedIds.includes(entry.nodeId as number)) return false; + if (typeof entry.nodeName !== 'string' || typeof entry.reachable !== 'boolean' || !Array.isArray(entry.targets)) return false; + const targetIds = new Set(); + for (const target of entry.targets) { + if (!target || typeof target !== 'object') return false; + const row = target as Partial; + if (!selectedTargets.includes(row.target as FleetPruneTarget) || targetIds.has(row.target as FleetPruneTarget) + || typeof row.success !== 'boolean' || row.dryRun !== false + || typeof row.reclaimedBytes !== 'number' || !Number.isFinite(row.reclaimedBytes) || row.reclaimedBytes < 0) return false; + for (const count of [row.removed, row.skipped, row.failed]) { + if (count !== undefined && (!Number.isInteger(count) || count < 0)) return false; + } + targetIds.add(row.target as FleetPruneTarget); + } + if (targetIds.size !== selectedTargets.length) return false; + if (entry.reclaimedBytes !== undefined + && (typeof entry.reclaimedBytes !== 'number' || !Number.isFinite(entry.reclaimedBytes) || entry.reclaimedBytes < 0)) return false; + if (entry.outcomes !== undefined) { + if (!Array.isArray(entry.outcomes) || !entry.outcomes.every(isPruneItemOutcome)) return false; + const reviewed = reviewedResults.find((result) => result.nodeId === entry.nodeId); + const expectedItems = new Set((reviewed?.items ?? []).map((item) => `${item.target}\0${item.id}`)); + const outcomeKeys = new Set(entry.outcomes.map((outcome) => `${outcome.target}\0${outcome.id}`)); + if (outcomeKeys.size !== entry.outcomes.length || outcomeKeys.size !== expectedItems.size + || [...outcomeKeys].some((key) => !expectedItems.has(key))) return false; + } + return true; + }); +} + export function FleetPruneCard({ nodes }: Props) { - const nodeCount = nodes.length; - const [targets, setTargets] = useState>(new Set(['images'])); + const [targets, setTargets] = useState>(new Set(['images'])); const [scope, setScope] = useState('managed'); const [confirmOpen, setConfirmOpen] = useState(false); const [running, setRunning] = useState(false); - const [results, setResults] = useState([]); - const [estimate, setEstimate] = useState({ kind: 'idle' }); - // Bumped after a real prune mutates at least one target so the estimate - // effect re-runs without changing targets/scope. + const [planResults, setPlanResults] = useState([]); + const [displayKey, setDisplayKey] = useState(''); + const [executeResults, setExecuteResults] = useState(); + const [reviewed, setReviewed] = useState(null); + const [estimate, setEstimate] = useState({ kind: 'loading' }); const [estimateEpoch, setEstimateEpoch] = useState(0); - const toggleTarget = (target: PruneTarget) => { - setTargets(prev => { - const next = new Set(prev); + const selectedTargets = useMemo(() => [...targets].sort(), [targets]); + const rosterKey = useMemo( + () => nodes.map((node) => `${node.id}:${node.status}`).sort().join(','), + [nodes], + ); + const reviewKey = `${selectedTargets.join(',')}|${scope}|${rosterKey}`; + const reviewValid = reviewStateIsValid(reviewed, reviewKey, nodes, selectedTargets); + + const toggleTarget = (target: FleetPruneTarget) => { + setReviewed(null); + setExecuteResults(undefined); + setEstimate({ kind: 'loading' }); + setTargets((current) => { + const next = new Set(current); if (next.has(target)) next.delete(target); else next.add(target); return next; }); }; - // Re-estimate when the operator's choices change. Debounced because each - // tick fans out per-target HTTP to every remote node; back-to-back clicks - // on the target checkboxes would otherwise pile concurrent fleet-wide fans - // onto the backend. + const changeScope = (nextScope: PruneScope) => { + setReviewed(null); + setExecuteResults(undefined); + setEstimate({ kind: 'loading' }); + setScope(nextScope); + }; + const estimateDebounceRef = useRef | null>(null); useEffect(() => { if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current); if (targets.size === 0) { - setEstimate({ kind: 'idle' }); return; } let cancelled = false; - setEstimate({ kind: 'loading' }); estimateDebounceRef.current = setTimeout(async () => { + if (!cancelled) setEstimate({ kind: 'loading' }); try { - const res = await apiFetch('/fleet/prune/estimate', { + const response = await apiFetch('/fleet/prune/estimate', { method: 'POST', - body: JSON.stringify({ targets: Array.from(targets), scope }), + body: JSON.stringify({ targets: selectedTargets, scope }), }); if (cancelled) return; - if (res.status === 404 || !res.ok) { + if (!response.ok) { setEstimate({ kind: 'unavailable' }); return; } - const data = (await res.json()) as PruneEstimateResponse; + const data = await response.json() as PruneEstimateResponse; if (!cancelled) setEstimate({ kind: 'ready', data }); - } catch { + } catch (error) { + console.error('Failed to estimate fleet prune', error); if (!cancelled) setEstimate({ kind: 'unavailable' }); } }, 350); @@ -95,178 +182,173 @@ export function FleetPruneCard({ nodes }: Props) { cancelled = true; if (estimateDebounceRef.current) clearTimeout(estimateDebounceRef.current); }; - }, [targets, scope, estimateEpoch]); + }, [selectedTargets, scope, targets.size, estimateEpoch]); - async function run(opts: { dryRun: boolean }) { - if (targets.size === 0) return; - const selected = Array.from(targets); - const verb = opts.dryRun ? 'Dry-running prune of' : 'Pruning'; - const toastId = toast.loading(`${verb} ${selected.join(', ')} across the fleet…`); + const runDryRun = async () => { + if (selectedTargets.length === 0) return; + const toastId = toast.loading(`Building prune plans for ${selectedTargets.join(', ')}…`); setRunning(true); - setResults([]); + setReviewed(null); + setExecuteResults(undefined); try { - const res = await apiFetch('/fleet/labels/fleet-prune', { + const response = await apiFetch('/fleet/labels/fleet-prune', { method: 'POST', - body: JSON.stringify({ targets: selected, scope, dryRun: opts.dryRun }), + body: JSON.stringify({ targets: selectedTargets, scope, dryRun: true }), }); - const body = await res.json().catch(() => ({})); - toast.dismiss(toastId); - if (!res.ok) { - toast.error(body.error || 'Fleet prune failed'); - return; - } - const apiResults = (body.results as FleetPruneNodeResult[]) ?? []; - // HTTP 200 still arrives when every target fails; only a successful - // target means Docker state changed and the live estimate is stale. - // Scan targets independently of node.reachable (an early remote target - // can succeed before a later transport error marks the node unreachable). - if (!opts.dryRun && apiResults.some(node => node.targets.some(target => target.success))) { - setEstimateEpoch(e => e + 1); - } - const rows: ResultRow[] = apiResults.map((node) => { - const totalBytes = node.targets.reduce((sum, t) => sum + (t.reclaimedBytes ?? 0), 0); - const allOk = node.reachable && node.targets.every(t => t.success); - return { - key: `node-${node.nodeId}`, - label: node.reachable - ? `${node.nodeName} · ${formatBytes(totalBytes)}${opts.dryRun ? ' (dry run)' : ''}` - : `${node.nodeName} (unreachable)`, - success: allOk, - error: node.reachable ? undefined : node.error, - sub: node.targets.map((t, i) => ({ - key: `${node.nodeId}-${t.target}-${i}`, - label: `${t.target} · ${formatBytes(t.reclaimedBytes ?? 0)}`, - success: t.success, - error: t.error, - })), - }; - }); - setResults(rows); - const totalNodes = apiResults.length; - // Fully OK: every target on a reachable node succeeded. Partial: at - // least one target succeeded anywhere (mixed per-target on one node - // still counts). "Failed on every node" only when zero targets succeeded. - const fullyOkNodes = apiResults.filter(n => n.reachable && n.targets.every(t => t.success)).length; - const nodesWithAnySuccess = apiResults.filter(n => n.targets.some(t => t.success)).length; - const anyTargetSucceeded = nodesWithAnySuccess > 0; - const totalReclaimed = apiResults.reduce( - (sum, n) => sum + n.targets.reduce((s, t) => s + (t.reclaimedBytes ?? 0), 0), - 0, - ); - if (opts.dryRun) { - toast.success(`Dry run: ${formatBytes(totalReclaimed)} would be reclaimed across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`); - } else if (fullyOkNodes === totalNodes && totalNodes > 0) { - toast.success(`Reclaimed ${formatBytes(totalReclaimed)} across ${totalNodes} node${totalNodes === 1 ? '' : 's'}.`); - } else if (!anyTargetSucceeded) { - toast.error('Prune failed on every node. See results below.'); + const data = await response.json().catch(() => null) as { error?: string; results?: FleetPruneNodeResult[] } | null; + if (!response.ok) throw new Error(data?.error || 'Failed to build fleet prune plans'); + const results = Array.isArray(data?.results) ? data.results : []; + setPlanResults(results); + setDisplayKey(reviewKey); + const nextReview = { key: reviewKey, results }; + if (reviewStateIsValid(nextReview, reviewKey, nodes, selectedTargets)) { + setReviewed(nextReview); + const total = results.reduce((sum, result) => sum + (result.reclaimableBytes ?? 0), 0); + toast.success(`Dry run ready: ${formatBytes(total)} across ${results.length} node${results.length === 1 ? '' : 's'}.`); } else { - toast.warning(`${nodesWithAnySuccess}/${totalNodes} nodes reclaimed space · ${formatBytes(totalReclaimed)} reclaimed. See results below.`); + toast.error('Dry run did not return a valid plan for every reachable node.'); } - } catch (err) { - toast.dismiss(toastId); - toast.error(err instanceof Error ? err.message : 'Network error'); + } catch (error) { + console.error('Failed to build fleet prune plans', error); + toast.error(error instanceof Error ? error.message : 'Failed to build fleet prune plans'); } finally { + toast.dismiss(toastId); + setRunning(false); + } + }; + + const runExecute = async () => { + if (!reviewValid) return; + const reviewedSnapshot = reviewed; + const toastId = toast.loading(`Pruning ${selectedTargets.join(', ')} across the fleet…`); + setRunning(true); + setExecuteResults(undefined); + try { + const reviewedNodes = reviewedSnapshot.results.map((result) => ({ + nodeId: result.nodeId, + reachable: result.reachable, + })); + const plans = reviewedSnapshot.results + .filter((result) => result.reachable && result.fingerprint) + .map((result) => ({ nodeId: result.nodeId, fingerprint: result.fingerprint as string })); + const response = await apiFetch('/fleet/labels/fleet-prune', { + method: 'POST', + body: JSON.stringify({ targets: selectedTargets, scope, dryRun: false, reviewedNodes, plans }), + }); + const data = await response.json().catch(() => null) as { + error?: string; + code?: string; + nodeId?: number; + results?: FleetPruneNodeResult[]; + } | null; + if (!response.ok) { + if (data?.code === 'PRUNE_PLAN_STALE') { + setPlanResults([]); + setDisplayKey(''); + const nodeName = reviewedSnapshot.results.find((result) => result.nodeId === data.nodeId)?.nodeName + ?? data.error?.match(/on (.+) after/)?.[1] + ?? 'a node'; + toast.error(`The prune plan changed on “${nodeName}” after the dry run. Run the dry run again before pruning.`); + return; + } + throw new Error(data?.error || 'Fleet prune failed'); + } + if (!executeResultsAreValid(data?.results, reviewedSnapshot.results, selectedTargets)) { + throw new Error('Fleet prune returned an incomplete node result set'); + } + const results = data.results; + setExecuteResults(results); + const reclaimed = results.reduce((sum, result) => sum + (result.reclaimedBytes ?? 0), 0); + const staleResult = results.find((result) => result.code === 'PRUNE_PLAN_STALE'); + const removedAny = results.some((result) => result.outcomes?.some((outcome) => outcome.status === 'removed')); + const failed = results.some((result) => result.targets.some((target) => !target.success)); + if (staleResult && removedAny) { + toast.warning(`Fleet prune partially completed: ${formatBytes(reclaimed)} reclaimed before the plan changed on “${staleResult.nodeName}”. Run the dry run again before pruning.`); + } else if (staleResult) { + toast.error(`The prune plan changed on “${staleResult.nodeName}” after the dry run. Run the dry run again before pruning.`); + } else if (failed) toast.warning(`Fleet prune completed with item failures. ${formatBytes(reclaimed)} reclaimed.`); + else toast.success(`Reclaimed ${formatBytes(reclaimed)} across ${results.length} node${results.length === 1 ? '' : 's'}.`); + if (removedAny) { + setEstimateEpoch((epoch) => epoch + 1); + } + } catch (error) { + console.error('Fleet prune failed', error); + toast.error(error instanceof Error ? error.message : 'Fleet prune failed'); + } finally { + setReviewed(null); + toast.dismiss(toastId); setRunning(false); setConfirmOpen(false); } - } - - const isAllScope = scope === 'all'; + }; const blastValue = useMemo(() => { if (targets.size === 0) return 'awaiting target'; if (estimate.kind === 'loading') return '~ estimating…'; if (estimate.kind === 'unavailable') return '~ estimate unavailable'; if (estimate.kind === 'ready') { - const { totalBytes } = estimate.data; - if (totalBytes === 0) return '0 reclaimable'; - return `~ ${formatBytes(totalBytes)} reclaimable`; + if (estimate.data.totalBytes === 0) return '0 reclaimable'; + return `~ ${formatBytes(estimate.data.totalBytes)} reclaimable`; } return 'awaiting target'; }, [targets.size, estimate]); const blastTone = estimate.kind === 'loading' || estimate.kind === 'unavailable' ? 'muted' as const : undefined; + const isAllScope = scope === 'all'; return ( <> run({ dryRun: true }), + onClick: runDryRun, disabled: running || targets.size === 0, }} primaryAction={{ label: 'Prune fleet', onClick: () => setConfirmOpen(true), variant: 'destructive', - // Block the destructive confirm until the operator has actually - // seen what the readout says will be reclaimed. Falling back to a - // confirm modal with no estimate context is the audit §F20.3 problem. - disabled: running || targets.size === 0 || estimate.kind !== 'ready', + disabled: running || !reviewValid, }} - footerContext={`Reversible · no · serial across ${nodeCount} node${nodeCount === 1 ? '' : 's'}`} + footerContext={`Reversible · no · reviewed across ${nodes.length} node${nodes.length === 1 ? '' : 's'}`} > - +
    - {ALL_TARGETS.map(t => ( -
    -
    - -

    {scope === 'managed' - ? 'Restricts to resources owned by stacks Sencho manages.' - : 'Removes every unused resource, including workloads Sencho does not manage.'} + ? 'Restricts candidates to resources owned by stacks Sencho manages.' + : 'Includes unused resources from workloads Sencho does not manage.'}

    {targets.size > 0 && } - {results.length > 0 && ( - - + {displayKey === reviewKey && planResults.length > 0 && ( + + )}
    @@ -277,14 +359,12 @@ export function FleetPruneCard({ nodes }: Props) { variant="destructive" kicker="Fleet prune" title={isAllScope ? 'Prune ALL unused resources across the fleet?' : 'Prune managed resources across the fleet?'} - description={ - isAllScope - ? 'This runs docker prune --all on every reachable node. Any image, volume, or network not currently in use will be deleted, including resources from workloads Sencho does not manage. This cannot be undone.' - : 'Sencho will remove unused Docker resources owned by stacks known to this fleet on every reachable node. Active resources are not touched.' - } + description={isAllScope + ? 'This removes the reviewed unused images, volumes, and networks, including resources from workloads Sencho does not manage. This cannot be undone.' + : 'Sencho will remove only the reviewed unused resources owned by stacks known to this fleet. Active resources are not touched.'} confirmLabel={isAllScope ? 'Prune everything unused' : 'Prune managed'} confirming={running} - onConfirm={() => run({ dryRun: false })} + onConfirm={runExecute} /> ); @@ -305,35 +385,28 @@ function EstimateSection({ estimate }: { estimate: EstimateState }) { ); } - const { perNode } = estimate.data; - const visible = perNode.slice(0, ESTIMATE_ROW_LIMIT); - const remaining = perNode.length - visible.length; + const visible = estimate.data.perNode.slice(0, ESTIMATE_ROW_LIMIT); + const remaining = estimate.data.perNode.length - visible.length; return ( - -
    + +
      - {visible.map((n) => ( -
    • + {visible.map((node) => ( +
    • - {n.reachable ? 'OK' : '--'} + {node.reachable ? 'OK' : '--'} - {n.nodeName} - - {n.reachable ? formatBytes(n.reclaimableBytes) : (n.error ?? 'unreachable')} + {node.nodeName} + + {node.reachable ? formatBytes(node.reclaimableBytes) : (node.error ?? 'unreachable')}
    • ))} - {remaining > 0 && ( -
    • - + {remaining} more node{remaining === 1 ? '' : 's'} -
    • - )} + {remaining > 0 &&
    • + {remaining} more node{remaining === 1 ? '' : 's'}
    • }
    diff --git a/frontend/src/lib/prunePlan.ts b/frontend/src/lib/prunePlan.ts new file mode 100644 index 00000000..2b1f7421 --- /dev/null +++ b/frontend/src/lib/prunePlan.ts @@ -0,0 +1,135 @@ +export type PruneTarget = 'containers' | 'images' | 'volumes' | 'networks'; +export type FleetPruneTarget = Exclude; +export type PruneScope = 'managed' | 'all'; + +interface PrunePlanItemBase { + id: string; + name: string; + sizeBytes?: number; + managed: boolean; + reason: string; + stackName?: string; +} + +export type PrunePlanItem = + | (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never }) + | (PrunePlanItemBase & { + target: 'images'; + image: { + references: string[]; + digest?: string; + createdAt?: number; + }; + volume?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'volumes'; + volume: { + driver?: string; + ownershipLabels?: Record; + }; + image?: never; + network?: never; + }) + | (PrunePlanItemBase & { + target: 'networks'; + network: { + driver?: string; + scope?: string; + ownershipLabels?: Record; + }; + image?: never; + volume?: never; + }); + +export interface PrunePlan { + scope: PruneScope; + targets: PruneTarget[]; + items: PrunePlanItem[]; + reclaimableBytes: number; + fingerprint: string; + createdAt: number; + nodeId: number; +} + +export type PruneItemOutcome = + | { id: string; target: PruneTarget; status: 'removed'; sizeBytes?: number } + | { id: string; target: PruneTarget; status: 'skipped'; reason: string } + | { id: string; target: PruneTarget; status: 'failed'; error: string }; + +export interface FleetPruneTargetResult { + target: FleetPruneTarget; + success: boolean; + reclaimedBytes: number; + dryRun: boolean; + removed?: number; + skipped?: number; + failed?: number; + error?: string; +} + +export interface FleetPruneNodeResult { + nodeId: number; + nodeName: string; + reachable: boolean; + code?: string; + error?: string; + fingerprint?: string; + items?: PrunePlanItem[]; + reclaimableBytes?: number; + reclaimedBytes?: number; + outcomes?: PruneItemOutcome[]; + targets: FleetPruneTargetResult[]; +} + +function finiteNonnegative(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} + +export function isPrunePlanItem(value: unknown): value is PrunePlanItem { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + if (typeof item.id !== 'string' || typeof item.name !== 'string' + || typeof item.managed !== 'boolean' || typeof item.reason !== 'string' + || (item.sizeBytes !== undefined && !finiteNonnegative(item.sizeBytes))) return false; + if (item.target === 'containers') return true; + if (item.target === 'images') { + const image = item.image as Record | undefined; + return Boolean(image && Array.isArray(image.references) && image.references.every((ref) => typeof ref === 'string')); + } + if (item.target === 'volumes') return Boolean(item.volume && typeof item.volume === 'object'); + if (item.target === 'networks') return Boolean(item.network && typeof item.network === 'object'); + return false; +} + +export function isPruneItemOutcome(value: unknown): value is PruneItemOutcome { + if (!value || typeof value !== 'object') return false; + const outcome = value as Record; + if (typeof outcome.id !== 'string' || typeof outcome.target !== 'string') return false; + if (outcome.status === 'removed') return outcome.sizeBytes === undefined || finiteNonnegative(outcome.sizeBytes); + if (outcome.status === 'skipped') return typeof outcome.reason === 'string'; + if (outcome.status === 'failed') return typeof outcome.error === 'string'; + return false; +} + +export function isPrunePlan(value: unknown): value is PrunePlan { + if (!value || typeof value !== 'object') return false; + const plan = value as Partial; + if ((plan.scope !== 'managed' && plan.scope !== 'all') || !Array.isArray(plan.targets) + || new Set(plan.targets).size !== plan.targets.length || !Array.isArray(plan.items) + || !finiteNonnegative(plan.reclaimableBytes) || typeof plan.fingerprint !== 'string' + || plan.fingerprint.length === 0 || !Number.isInteger(plan.nodeId) + || !finiteNonnegative(plan.createdAt)) return false; + const targets = new Set(plan.targets); + const itemKeys = new Set(); + let total = 0; + for (const item of plan.items) { + if (!isPrunePlanItem(item) || !targets.has(item.target)) return false; + const key = `${item.target}\0${item.id}`; + if (itemKeys.has(key)) return false; + itemKeys.add(key); + total += item.sizeBytes ?? 0; + } + return total === plan.reclaimableBytes; +} From a65bf4d46aab9e668ca11ed078b09efac4a9e3df Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 29 Jul 2026 15:51:27 -0400 Subject: [PATCH 08/31] fix(rbac): handle permission metadata failures (#1735) --- docs/features/rbac.mdx | 3 + frontend/src/App.tsx | 16 +++++- frontend/src/components/EditorLayout.tsx | 12 ++-- .../components/EditorLayout/ViewRouter.tsx | 3 +- .../src/components/settings/SectionGate.tsx | 6 +- frontend/src/context/AuthContext.test.tsx | 53 ++++++++++++++++++ frontend/src/context/AuthContext.tsx | 55 ++++++++++++------- frontend/src/lib/routing/reachability.test.ts | 7 +++ 8 files changed, 127 insertions(+), 28 deletions(-) create mode 100644 frontend/src/context/AuthContext.test.tsx diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index bd978f68..8efb4f74 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -225,6 +225,9 @@ Entries include the acting user, IP address, HTTP method and path, response stat ## Troubleshooting + + Sencho could not verify your current permissions. Existing pages stay open, but changes remain disabled until verification succeeds. Select **Retry** in the notification bar. If the notice returns, check that the Sencho instance is reachable and sign in again if your session has expired. + The Users entry is hidden in two cases. **One,** you are signed in as a non-admin (Viewer, Deployer, Auditor): the entry is admin-only. **Two,** you have a remote node selected: the panel is hub-only and is hidden in the sidebar when any remote node is active. Switch back to the local node via the node switcher in the masthead. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aea66650..461b5779 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import { MfaChallenge } from './components/MfaChallenge'; import { DeployFeedbackProvider } from './context/DeployFeedbackContext'; import { DeployFeedbackPortal } from './components/DeployFeedbackPortal'; import { ToastContainer } from './components/ui/toast'; +import { Button } from './components/ui/button'; +import { AlertCircle, RefreshCw } from 'lucide-react'; /** Gates framer-motion animations on the "Reduced motion" appearance setting. * 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS @@ -27,7 +29,7 @@ function MotionProvider({ children }: { children: ReactNode }) { } function AppContent() { - const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); + const { appStatus, isAuthenticated, needsSetup, completeSetup, permissionsStatus, retryPermissions } = useAuth(); if (appStatus === 'loading') { return ( @@ -53,6 +55,18 @@ function AppContent() { + {permissionsStatus === 'error' && ( +
    +
    + + Permission controls are unavailable. Changes remain disabled until access is verified. +
    + +
    + )} {/* Portal lives inside LicenseProvider so the editor surface and its portalled overlays can read license state via useLicense(). diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 88a94c67..8c73458b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -84,7 +84,7 @@ const NetworkingView = lazy(() => import('./networking/NetworkingView').then(m = const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView }))); export default function EditorLayout() { - const { isAdmin, can, permissions } = useAuth(); + const { isAdmin, can, permissions, permissionsStatus } = useAuth(); const { status: trivy } = useTrivyStatus(); const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback(); @@ -837,12 +837,14 @@ export default function EditorLayout() { } }, [permissions, can]); - const createStackSlot = can('stack:create') ? ( + const canCreateStack = can('stack:create'); + const createStackSlot = (canCreateStack || permissionsStatus === 'loading') ? ( <>
    @@ -188,7 +189,7 @@ export function FederationTab({ canManage }: FederationTabProps) { {describeSelector(bp.selector)} - {canManage ? ( + {canManageAnyNode ? ( setFormName(e.target.value)} /> @@ -243,9 +244,9 @@ export function WebhooksSection() { } title="No webhooks yet" - subtitle={isAdmin + subtitle={canManageWebhooks ? 'Create one to trigger stack actions from CI/CD.' - : 'An admin operator can create webhooks for this instance.'} + : 'An operator with webhook permission can create webhooks for this instance.'} /> )} @@ -274,7 +275,7 @@ export function WebhooksSection() {
    - {isAdmin ? ( + {canManageWebhooks ? ( <> handleToggle(wh.id!, c)} />
    )} - + {canRefresh && ( + + )}
    @@ -791,6 +796,8 @@ interface AutoUpdateReadinessProps { function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) { const isMobile = useIsMobile(); const { runWithLog } = useDeployFeedback(); + const { can } = useAuth(); + const canRefreshFleet = can('node:manage'); const { nodes, nodeMeta, refreshNodeMeta } = useNodes(); const [groups, setGroups] = useState([]); const [reachableNodeCount, setReachableNodeCount] = useState(null); @@ -1296,10 +1303,12 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) />
    - + {canRefreshFleet && ( + + )}
    @@ -1351,6 +1360,7 @@ function AutoUpdateReadinessContent({ headerActions }: AutoUpdateReadinessProps) nodeCount={groups.length} refreshing={refreshing} onRefresh={handleRefresh} + canRefresh={canRefreshFleet} unresolvedChecks={checkFailures.length > 0} detectionDisabled={cadence?.enabled === false} /> diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts index 5bd311df..1ee613f8 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -10,10 +10,13 @@ vi.mock('@/context/NodeContext', () => ({ // buildMenuCtx derives canOpenApp from the active node plus the stack's // published port; only the fields it reads need to be real, the handler // closures are never invoked here. +type CanFn = (action: string, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean; + function makeOptions( activeNode: Node | null, stackPorts: Record, stackStatuses: Record = { 'web.yml': 'running' }, + can: CanFn = () => true, ) { const stackListState = { stackStatuses, @@ -42,10 +45,16 @@ function makeOptions( stackActions, activeNode, isAdmin: true, - can: () => true, + can, } as unknown as Parameters[0]; } +// Reach past the `unknown` cast makeOptions returns to assert on the inner +// stackActions mocks it built. +function stackActionsOf(options: Parameters[0]) { + return (options as unknown as { stackActions: { checkUpdatesForStack: ReturnType } }).stackActions; +} + describe('useSidebarContextMenu canOpenApp', () => { it('is true for a local node with a published port', () => { const { result } = renderHook(() => @@ -89,3 +98,36 @@ describe('useSidebarContextMenu stackStatus', () => { expect(missing.result.current('web.yml').stackStatus).toBe('unknown'); }); }); + +describe('useSidebarContextMenu checkUpdates', () => { + it('calls checkUpdatesForStack with the stack name (not the .yml file)', () => { + const options = makeOptions({ id: 1, type: 'local' } as Node, { 'web.yml': 8989 }); + const { result } = renderHook(() => useSidebarContextMenu(options)); + result.current('web.yml').checkUpdates(); + expect(stackActionsOf(options).checkUpdatesForStack).toHaveBeenCalledWith('web'); + }); +}); + +describe('useSidebarContextMenu canViewMonitor / canCheckUpdates wiring', () => { + it('derives canViewMonitor from stack:read and canCheckUpdates from stack:deploy, both scoped to the stack and active node', () => { + const can = vi.fn((action) => action === 'stack:read'); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(true); + expect(ctx.canCheckUpdates).toBe(false); + expect(can).toHaveBeenCalledWith('stack:read', 'stack', 'web', 7); + expect(can).toHaveBeenCalledWith('stack:deploy', 'stack', 'web', 7); + }); + + it('denies both when the permission check fails closed', () => { + const can = vi.fn(() => false); + const options = makeOptions({ id: 7, type: 'local' } as Node, { 'web.yml': 8989 }, undefined, can); + const { result } = renderHook(() => useSidebarContextMenu(options)); + const ctx = result.current('web.yml'); + + expect(ctx.canViewMonitor).toBe(false); + expect(ctx.canCheckUpdates).toBe(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 3cefcb23..2628237c 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -20,7 +20,6 @@ import type { useViewNavigationState } from './useViewNavigationState'; import type { Node } from '@/context/NodeContext'; import { useNodes } from '@/context/NodeContext'; import type { PermissionAction } from '@/context/AuthContext'; -import { canManageNode } from '@/lib/canManageNode'; type StackListState = ReturnType; type NavState = ReturnType; @@ -65,6 +64,7 @@ export function useSidebarContextMenu({ canDelete: can('stack:delete', 'stack', sName, nodeId), canDeploy: can('stack:deploy', 'stack', sName, nodeId), canEditLabels: can('stack:edit', 'stack', sName, nodeId), + canViewMonitor: can('stack:read', 'stack', sName, nodeId), // POST /api/labels (the inline "New label" entry) is guarded by the // unscoped requirePermission('stack:edit'); a user with only per-stack // scoped edit can toggle existing labels but cannot create new ones. @@ -75,8 +75,8 @@ export function useSidebarContextMenu({ menuVisibility: stackActions.getStackMenuVisibility(file), openAlertSheet: () => overlayState.openAlertSheet(file), openAutoHeal: () => overlayState.openAutoHeal(file), - canCheckUpdates: canManageNode(can, nodeId), - checkUpdates: () => stackActions.checkUpdatesForStack(), + canCheckUpdates: can('stack:deploy', 'stack', sName, nodeId), + checkUpdates: () => stackActions.checkUpdatesForStack(sName), openStackApp: () => stackActions.openStackApp(file), deploy: () => stackActions.executeStackActionByFile(file, 'deploy', 'deploy'), stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'), diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 0fdeabcc..f00aa0be 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/api', () => ({ }), })); vi.mock('@/components/ui/toast-store', () => ({ - toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn(() => 'loading-id'), dismiss: vi.fn() }, })); import { apiFetch } from '@/lib/api'; @@ -249,6 +249,87 @@ describe('useStackActions.handleSaveAndDeploy', () => { }); }); +describe('useStackActions.checkUpdatesForStack', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(apiFetch).mockReset(); + }); + + it('hits the per-stack refresh endpoint and shows success when the stack is cleared', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response(JSON.stringify({ outcome: 'cleared', warning: null }), { status: 200 }), + ); + const { result, stackListState } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(apiFetch).toHaveBeenCalledWith('/image-updates/refresh/web', { method: 'POST' }); + expect(stackListState.fetchImageUpdates).toHaveBeenCalled(); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.success).toHaveBeenCalledWith('Image update check complete.'); + expect(toast.info).not.toHaveBeenCalled(); + }); + + it('shows the warning via toast.info instead of success when verification did not cleanly complete', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ outcome: 'verification_failed', warning: 'Could not verify the update.' }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('Could not verify the update.'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('replaces the generic post-update warning copy for an incomplete verification too', async () => { + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'verification_incomplete', + warning: 'The update command completed, but Sencho could not fully verify whether an image update remains.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.info).toHaveBeenCalledWith('Could not fully verify update status for web.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + }); + + it('uses stack-scoped copy instead of the backend post-update warning when an update is still present', async () => { + // The backend reuses its post-update reconciliation result for this + // manual pre-update check, so its "still_present" warning text ("The + // update command completed...") does not apply here; the frontend must + // not forward it verbatim. + vi.mocked(apiFetch).mockResolvedValue( + new Response( + JSON.stringify({ + outcome: 'still_present', + warning: 'The update command completed, but Sencho still detects an available image update.', + }), + { status: 200 }, + ), + ); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.info).toHaveBeenCalledWith('web still has an update available.'); + expect(toast.info).not.toHaveBeenCalledWith(expect.stringContaining('update command completed')); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('shows a loading toast immediately and dismisses it on error', async () => { + vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ error: 'nope' }), { status: 500 })); + const { result } = setup(); + await result.current.checkUpdatesForStack('web'); + expect(toast.loading).toHaveBeenCalledWith('Checking web for image updates...'); + expect(toast.dismiss).toHaveBeenCalledWith('loading-id'); + expect(toast.error).toHaveBeenCalledWith('nope'); + }); +}); + describe('useStackActions node binding', () => { const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index f945c407..088dfad7 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -79,6 +79,16 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = { const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +// Mirrors ImageUpdateService's UPDATE_STILL_PRESENT_WARNING / UPDATE_VERIFICATION_INCOMPLETE_WARNING: +// that service's warning copy assumes an update was just applied, but +// checkUpdatesForStack runs before any update, so these two generic messages +// are replaced with accurate pre-update copy. A stack-specific reason (e.g. a +// compose render failure) is still forwarded as-is. +const GENERIC_POST_UPDATE_WARNINGS: ReadonlySet = new Set([ + 'The update command completed, but Sencho still detects an available image update.', + 'The update command completed, but Sencho could not fully verify whether an image update remains.', +]); + const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => { @@ -416,7 +426,6 @@ export function useStackActions(options: UseStackActionsOptions) { const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); - const checkUpdatesIntervalRef = useRef | null>(null); // True from a deploy click through the async pre-deploy advisory phase until // the deploy starts or is cancelled, so a double-click cannot start two deploys. const deployPendingRef = useRef(false); @@ -471,9 +480,6 @@ export function useStackActions(options: UseStackActionsOptions) { useEffect(() => { return () => { - if (checkUpdatesIntervalRef.current !== null) { - clearInterval(checkUpdatesIntervalRef.current); - } loadFileAbortRef.current?.abort(); containersFetchGenRef.current += 1; }; @@ -2244,38 +2250,32 @@ export function useStackActions(options: UseStackActionsOptions) { } }; - const checkUpdatesForStack = async () => { + const checkUpdatesForStack = async (stackName: string) => { + const loadingId = toast.loading(`Checking ${stackName} for image updates...`); try { - const res = await apiFetch('/image-updates/refresh', { method: 'POST' }); + const res = await apiFetch(`/image-updates/refresh/${encodeURIComponent(stackName)}`, { method: 'POST' }); if (res.ok) { - toast.success('Checking for image updates...'); - let elapsed = 0; - const poll = setInterval(async () => { - elapsed += 2000; - try { - const statusRes = await apiFetch('/image-updates/status'); - if (statusRes.ok) { - const { checking } = await statusRes.json(); - if (!checking || elapsed >= 60000) { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - if (!checking) toast.success('Image update check complete.'); - } - } - } catch { - clearInterval(poll); - checkUpdatesIntervalRef.current = null; - await stackListState.fetchImageUpdates(); - } - }, 2000); - checkUpdatesIntervalRef.current = poll; + const data = await res.json().catch(() => ({})) as { outcome?: unknown; warning?: unknown }; + await stackListState.fetchImageUpdates(); + const warning = typeof data.warning === 'string' ? data.warning : undefined; + if (data.outcome === 'still_present') { + toast.info(`${stackName} still has an update available.`); + } else if (warning && GENERIC_POST_UPDATE_WARNINGS.has(warning)) { + toast.info(`Could not fully verify update status for ${stackName}.`); + } else if (warning) { + toast.info(warning); + } else { + toast.success('Image update check complete.'); + } } else { const data = await res.json().catch(() => ({})); toast.error(data.error || 'Failed to check for updates'); } - } catch { + } catch (error) { + console.error(`Failed to check updates for stack ${stackName}:`, error); toast.error('Failed to check for updates'); + } finally { + toast.dismiss(loadingId); } }; diff --git a/frontend/src/components/StackAlertSheet.test.tsx b/frontend/src/components/StackAlertSheet.test.tsx index a598be3d..81a4a6fd 100644 --- a/frontend/src/components/StackAlertSheet.test.tsx +++ b/frontend/src/components/StackAlertSheet.test.tsx @@ -34,8 +34,9 @@ vi.mock('@/context/NodeContext', () => ({ hasCapability: (cap: string) => nodeState.activeNodeMeta?.capabilities.includes(cap) === true, }), })); +const useAuthMock = vi.fn(); vi.mock('@/context/AuthContext', () => ({ - useAuth: () => ({ isAdmin: true }), + useAuth: () => useAuthMock(), })); import { apiFetch } from '@/lib/api'; @@ -62,6 +63,8 @@ beforeEach(() => { mockedFetch.mockReset(); vi.mocked(toast.success).mockReset(); vi.mocked(toast.error).mockReset(); + useAuthMock.mockReset(); + useAuthMock.mockReturnValue({ isAdmin: true, can: () => true }); }); function mockHappyPath(services: string[] = ['api', 'database'], alerts: unknown[] = []) { @@ -306,3 +309,72 @@ describe('StackAlertSheet Alerts tab', () => { }); }); }); + +describe('StackAlertSheet permission gating (stack:edit deny path)', () => { + it('AlertsTab hides Add new rule and the delete-alert control when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockHappyPath(['api'], [{ + id: 1, + stack_name: 'my-stack', + service_name: 'api', + metric: 'cpu_percent', + operator: '>', + threshold: 80, + duration_mins: 5, + cooldown_mins: 60, + }]); + + render( {}} stackName="my-stack" />); + + // Reads stay visible: the rule itself still renders for a stack:read-only caller. + await waitFor(() => expect(screen.getByText('api')).toBeInTheDocument()); + expect(screen.queryByText('Add Rule')).toBeNull(); + expect(screen.queryByText('Add new rule')).toBeNull(); + expect(screen.queryByLabelText('Delete alert')).toBeNull(); + }); + + it('AutoHealTab hides Add new policy and PolicyRow edit affordances when the caller lacks stack:edit', async () => { + useAuthMock.mockReturnValue({ + isAdmin: false, + can: (action: string) => action !== 'stack:edit', + }); + mockedFetch.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/auto-heal/policies')) { + return jsonRes([{ + id: 1, + stack_name: 'my-stack', + service_name: null, + unhealthy_duration_mins: 5, + cooldown_mins: 5, + max_restarts_per_hour: 3, + auto_disable_after_failures: 5, + enabled: 1, + consecutive_failures: 0, + }]); + } + if (url.includes('/services')) return jsonRes(['api', 'database']); + return jsonRes(null, false); + }); + + render( + {}} + stackName="my-stack" + initialTab="auto-heal" + />, + ); + + // Reads stay visible: the policy row itself still renders. + await waitFor(() => expect(screen.getByText('All services')).toBeInTheDocument()); + expect(screen.queryByText('Add Policy')).toBeNull(); + expect(screen.queryByLabelText(/toggle policy for/i)).toBeNull(); + expect(screen.queryByLabelText('Delete policy')).toBeNull(); + // History is not edit-gated and should remain available either way. + expect(screen.getByLabelText('Toggle history')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/StackAlertSheet.tsx b/frontend/src/components/StackAlertSheet.tsx index bd1766d2..69f8c036 100644 --- a/frontend/src/components/StackAlertSheet.tsx +++ b/frontend/src/components/StackAlertSheet.tsx @@ -184,8 +184,9 @@ export function StackAlertSheet({ } function AlertsTab({ stackName, initialService }: { stackName: string; initialService?: string }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); const { activeNode, activeNodeMeta } = useNodes(); + const canEditAlerts = can('stack:edit', 'stack', stackName, activeNode?.id); const isRemote = activeNode?.type === 'remote'; const canScopeService = activeNodeMeta?.capabilities.includes(SERVICE_SCOPED_STACK_ALERT_CAPABILITY) === true; @@ -443,13 +444,14 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe {' '}• Trigger after {alert.duration_mins}m • Cooldown {alert.cooldown_mins}m
    - {isAdmin && ( + {canEditAlerts && ( @@ -460,7 +462,7 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe )} - {isAdmin && ( + {canEditAlerts && (
    {canScopeService && ( @@ -595,7 +597,9 @@ function AutoHealTab({ open: boolean; initialService?: string; }) { - const { isAdmin } = useAuth(); + const { can } = useAuth(); + const { activeNode } = useNodes(); + const canEditAutoHeal = can('stack:edit', 'stack', stackName, activeNode?.id); const [policies, setPolicies] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -739,14 +743,14 @@ function AutoHealTab({ onToggle={handleToggle} deleting={deleting} saving={saving} - isAdmin={isAdmin} + canEdit={canEditAutoHeal} /> ))}
    )}
    - {isAdmin && ( + {canEditAutoHeal && (
    @@ -835,10 +839,11 @@ interface PolicyRowProps { onToggle: (id: number, enabled: boolean) => void; deleting: boolean; saving: boolean; - isAdmin: boolean; + /** True when the caller may toggle/delete this policy (stack:edit). */ + canEdit: boolean; } -function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: PolicyRowProps) { +function PolicyRow({ policy, onDelete, onToggle, deleting, saving, canEdit }: PolicyRowProps) { const [historyOpen, setHistoryOpen] = useState(false); const [history, setHistory] = useState([]); const [loadingHistory, setLoadingHistory] = useState(false); @@ -886,7 +891,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )}
    - {isAdmin && ( + {canEdit && ( policy.id != null && onToggle(policy.id, checked)} @@ -910,7 +915,7 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: Po )} - {isAdmin && ( + {canEdit && ( } /> + {saveDisabledReason && ( +

    {saveDisabledReason}

    + )} {/* Delete Confirmation */} diff --git a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx index 0b213182..a210701c 100644 --- a/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx +++ b/frontend/src/components/__tests__/ScheduledOperationsView.test.tsx @@ -14,6 +14,19 @@ vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn() }, })); +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ + can: () => true, + permissions: { + globalRole: 'admin' as const, + globalPermissions: ['stack:deploy', 'node:manage', 'system:settings'] as string[], + scopedPermissions: {}, + }, + isAdmin: true, + permissionsStatus: 'ready' as const, + permissionsReady: true, + }), +})); import { apiFetch, fetchForNode } from '@/lib/api'; import { SCHEDULED_ACTIONS } from '@/lib/scheduledActions'; diff --git a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts index 2b885a2e..a577be52 100644 --- a/frontend/src/components/sidebar/useNextAutoUpdateRun.ts +++ b/frontend/src/components/sidebar/useNextAutoUpdateRun.ts @@ -25,8 +25,10 @@ export function useNextAutoUpdateRun(): number | null { const abortRef = useRef(null); useEffect(() => { - // The list endpoint is admin-only; non-admins would 403 on every poll. - // Skip all fetching/polling/listeners for them and report no scheduled run. + // This indicator is shown fleet-wide in the sidebar regardless of the active + // view; gating to admin avoids showing a partial "next auto-update run" to a + // role with only scoped permissions. Revisit when scoped roles are extended + // to this indicator. if (!isAdmin) { setNextRunAt(null); // eslint-disable-line react-hooks/set-state-in-effect return; diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 55b8316b..d6f6ca7e 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -138,12 +138,18 @@ describe('useStackMenuItems', () => { expect(lifecycle.items.some(i => i.id === 'schedule')).toBe(true); }); - it('hides Schedule task when not admin', () => { - const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false }))); + it('hides Schedule task when canDeploy is false', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canDeploy: false }))); const lifecycle = result.current.find(g => g.id === 'lifecycle'); expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeFalsy(); }); + it('shows Schedule task when canDeploy is true even when not admin', () => { + const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isAdmin: false, canDeploy: true }))); + const lifecycle = result.current.find(g => g.id === 'lifecycle'); + expect(lifecycle?.items.some(i => i.id === 'schedule')).toBeTruthy(); + }); + it('includes Mute submenu in Inspect when canMuteNotifications', () => { const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canMuteNotifications: true }))); const inspect = result.current.find(g => g.id === 'inspect')!; @@ -211,11 +217,8 @@ describe('useStackMenuItems', () => { canDeploy: false, menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true }, }))); - const lifecycle = result.current.find(g => g.id === 'lifecycle')!; - const ids = lifecycle.items.map(i => i.id); - expect(ids).not.toContain('deploy'); - expect(ids).not.toContain('take-down'); - expect(ids).toEqual(['schedule']); + // With canDeploy false, the entire lifecycle group is empty and omitted. + expect(result.current.find(g => g.id === 'lifecycle')).toBeUndefined(); }); it('disables take down for the self stack', () => { diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index f1ff6953..07bf312b 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -20,7 +20,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] { const { - stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, @@ -89,7 +89,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy }); if (showTakeDown) lifecycle.push({ id: 'take-down', label: 'Take down', icon: ArrowDownToLine, shortcut: '⌘↓', onSelect: takeDown, disabled: isBusy || isSelfStack }); } - if (isAdmin) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); + if (canDeploy) lifecycle.push({ id: 'schedule', label: 'Schedule task', icon: CalendarClock, onSelect: openScheduleTask }); if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle }); if (canDelete) { @@ -109,7 +109,7 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] return groups; }, [ - stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canDeploy, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, canDelete, canDeploy, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, showTakeDown, openAlertSheet, openAutoHeal, canViewMonitor, canCheckUpdates, checkUpdates, openStackApp, deploy, stop, restart, update, takeDown, remove, pin, unpin, toggleLabel, openScheduleTask, diff --git a/frontend/src/lib/navigation/buildNavigationModel.test.ts b/frontend/src/lib/navigation/buildNavigationModel.test.ts index cf4a1e37..3bc76af7 100644 --- a/frontend/src/lib/navigation/buildNavigationModel.test.ts +++ b/frontend/src/lib/navigation/buildNavigationModel.test.ts @@ -14,6 +14,7 @@ function makeCtx(overrides: Partial = {}): ReachabilityCont licenseStatus: 'ready', experimental: true, experimentalReady: true, + scheduledOpsAccessible: true, ...overrides, }; } diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts index 328f85c8..20f1ed5e 100644 --- a/frontend/src/lib/routing/reachability.test.ts +++ b/frontend/src/lib/routing/reachability.test.ts @@ -20,6 +20,7 @@ function ctx(over: Partial = {}): ReachabilityContext { licenseStatus: 'ready', experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, ...over, }; } @@ -71,6 +72,7 @@ describe('reachability', () => { isPaid: false, experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, can: (a) => a === 'system:console', }); expect(isViewHidden('host-console', community)).toBe(false); @@ -102,7 +104,8 @@ describe('reachability', () => { }); it('does not hide fleet-mesh settings for experimental off', () => { - const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true }); + const off = ctx({ experimental: false, experimentalReady: true, + scheduledOpsAccessible: false, isAdmin: true }); expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false); }); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts index 02637dfa..adbce787 100644 --- a/frontend/src/lib/routing/reachability.ts +++ b/frontend/src/lib/routing/reachability.ts @@ -19,6 +19,8 @@ export interface ReachabilityContext { experimental: boolean; /** True once /meta experimental has settled (success or fail-closed). */ experimentalReady: boolean; + /** Whether the user can reach the Scheduled Operations view (global or scoped grants). */ + scheduledOpsAccessible: boolean; } /** RBAC/tier gates apply only when permission and license metadata are ready. */ @@ -41,10 +43,11 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea if (ctx.isRemote && HUB_ONLY_VIEWS.has(view)) return true; if ( !ctx.isAdmin && - (view === 'global-observability' || view === 'auto-updates' || view === 'scheduled-ops') + (view === 'global-observability' || view === 'auto-updates') ) { return true; } + if (view === 'scheduled-ops' && !ctx.scheduledOpsAccessible) return true; if (!ctx.can('node:read') && (view === 'fleet' || view === 'networking')) return true; if (view === 'host-console') return !ctx.can('system:console'); // Permission-driven on Community and Admiral (14-day window vs paid depth is in-view). diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts index 1252800d..2ef26328 100644 --- a/frontend/src/lib/scheduledActions.ts +++ b/frontend/src/lib/scheduledActions.ts @@ -1,4 +1,5 @@ import type { ScheduledTask } from '@/types/scheduling'; +import type { PermissionAction } from '@/context/AuthContext'; /** * Single source of truth for scheduled-operation action metadata on the @@ -86,6 +87,8 @@ export interface ScheduledActionDefinition { helperText: string; /** Risk level shown as a coloured chip next to the helper text. */ riskLevel: ScheduledActionRiskLevel; + /** Permission required to schedule this action (mirrors backend registry). */ + permission: PermissionAction; } /** Action pre-selected when the create modal opens. Decoupled from picker order. */ @@ -94,24 +97,24 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart'; /** Ordered for the create-flow action picker, grouped by category. */ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [ // Lifecycle - { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' }, - { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' }, - { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' }, - { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' }, - { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' }, - { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' }, - { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' }, - { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' }, + { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe', permission: 'stack:deploy' }, + { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive', permission: 'stack:deploy' }, + { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers', permission: 'stack:deploy' }, + { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive', permission: 'node:manage' }, + { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Updates - { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' }, - { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' }, - { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change' }, + { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change', permission: 'stack:deploy' }, + { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, + { id: 'update-by-label', backendAction: 'update', label: 'Auto-update stacks by label', shortLabel: 'update label', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Resolves stacks that currently carry a Stack Label at each run, across the entire fleet or one node, and updates those with newer images.', riskLevel: 'runtime-change', permission: 'node:manage' }, // Security - { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' }, + { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only', permission: 'node:manage' }, // Maintenance - { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' }, + { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive', permission: 'system:settings' }, // Backups - { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' }, + { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe', permission: 'node:manage' }, ]; const ACTION_BY_ID = new Map(SCHEDULED_ACTIONS.map(a => [a.id, a])); @@ -201,3 +204,93 @@ export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [ { key: 'maintenance', label: 'Upkeep', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' }, { key: 'backups', label: 'Backups', color: 'var(--brand)', bg: 'oklch(from var(--brand) l c h / 0.18)' }, ]; + +// ── Permission helpers ────────────────────────────────────────────────────── + +/** Permission actions that authorize any scheduleable action. */ +const SCHEDULABLE_ACTIONS: readonly PermissionAction[] = ['stack:deploy', 'node:manage', 'system:settings']; + +export interface ScheduleActionTarget { + nodeId?: number | null; + stackName?: string | null; + labelScope?: 'fleet' | 'node'; +} + +/** + * Check whether the user can schedule the given action on the given target. + * Scope resolution mirrors the backend `resolveTaskPermissionScope`: per-action, + * not per target-type bucket. + */ +export function canScheduleAction( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + target: ScheduleActionTarget, +): boolean { + // Stack lifecycle: scoped to (nodeId, stackName) + if (def.targetType === 'stack') { + return can(def.permission, 'stack', target.stackName ?? undefined, target.nodeId); + } + // Prune: always unscoped (admin-only via system:settings in the role matrix) + if (def.id === 'prune') { + return can(def.permission); + } + // Snapshot: unscoped (spans all nodes) + if (def.id === 'snapshot') { + return can(def.permission); + } + // Container targets + scan: node-scoped + if (def.targetType === 'container' || def.id === 'scan') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet update with specific node (non-label): node-scoped + if (def.id === 'update-fleet') { + return can(def.permission, 'node', target.nodeId != null ? String(target.nodeId) : undefined, target.nodeId); + } + // Fleet-wide label update: unscoped when no node; node-scoped when node + if (def.id === 'update-by-label') { + if (target.labelScope === 'node' && target.nodeId != null) { + return can(def.permission, 'node', String(target.nodeId), target.nodeId); + } + return can(def.permission); + } + return can(def.permission); +} + +/** + * True when the user can schedule at least one action. Used to determine whether + * the Scheduled Operations view and its "New scheduled task" button should be + * reachable. + */ +export function canScheduleAny( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + // Global role check + if (can('stack:deploy') || can('node:manage') || can('system:settings')) return true; + // Scoped permissions check: any scoped grant covering a scheduleable action + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if (actions.some(a => (SCHEDULABLE_ACTIONS as readonly string[]).includes(a))) return true; + } + } + return false; +} + +/** + * True when the user can schedule this action on at least one possible target + * (global role or any scoped grant). Used to filter the action picker so + * actions the user can NEVER schedule are not shown. + */ +export function canScheduleActionAnywhere( + can: (action: PermissionAction, resourceType?: string, resourceId?: string, nodeId?: number | null) => boolean, + def: ScheduledActionDefinition, + permissions?: { scopedPermissions?: Record } | null, +): boolean { + if (can(def.permission)) return true; + if (permissions?.scopedPermissions) { + for (const actions of Object.values(permissions.scopedPermissions)) { + if ((actions as readonly string[]).includes(def.permission)) return true; + } + } + return false; +} diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index 3c81da69..244b7459 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -8,6 +8,8 @@ export interface ScheduledTask { cron_expression: string; enabled: number; created_by: string; + /** The user ID who created this schedule. Null for legacy rows (pre-RBAC). */ + creator_user_id?: number | null; created_at: number; updated_at: number; last_run_at: number | null; From 7c02f6eeb520f8f77a4c6dbc72f1b6dfb0da331c Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 02:03:57 -0400 Subject: [PATCH 18/31] feat: add stack:read permission gate to Settings Labels section (#1747) The Labels section registry entry had no requiredPermission, leaving it visible to every authenticated operator. The backend already enforces stack:read on GET and stack:edit on write endpoints, and the frontend component already hides edit controls behind can('stack:edit'). Adding stack:read to the registry declares the contract explicitly. All five built-in roles hold stack:read, so this has no observable effect on current users. It becomes a functioning gate automatically if a future role or scoped user type is introduced without the permission. --- frontend/src/components/settings/__tests__/registry.test.ts | 1 + .../settings/__tests__/settingsVisibilityMatrix.test.ts | 1 + frontend/src/components/settings/registry.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index 26990c97..2455bc79 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -156,6 +156,7 @@ describe('requiredPermission registry mapping', () => { expect(byId.get('developer')?.requiredPermission).toBe('system:settings'); expect(byId.get('data-retention')?.requiredPermission).toBe('system:settings'); expect(byId.get('image-updates')?.requiredPermission).toBe('system:settings'); + expect(byId.get('labels')?.requiredPermission).toBe('stack:read'); }); it('keeps adminOnly on identity, credentials, and emergency surfaces', () => { diff --git a/frontend/src/components/settings/__tests__/settingsVisibilityMatrix.test.ts b/frontend/src/components/settings/__tests__/settingsVisibilityMatrix.test.ts index 9037ed29..7ecd46c8 100644 --- a/frontend/src/components/settings/__tests__/settingsVisibilityMatrix.test.ts +++ b/frontend/src/components/settings/__tests__/settingsVisibilityMatrix.test.ts @@ -25,6 +25,7 @@ describe('settings section visibility by role', () => { 'registries', 'webhooks', 'nodes', + 'labels', ] as const; it('shows permission-gated sections only to roles that hold the permission', () => { diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 366ecd8f..b1ba031f 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -267,6 +267,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ keywords: ['labels', 'tags', 'palette', 'organisation'], tier: null, scope: 'node', + requiredPermission: 'stack:read', }, // Operations { From ba017ee665bb2b7f7ef479b5d8a9f8cbb0681302 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 03:00:09 -0400 Subject: [PATCH 19/31] fix: forward scoped node-admin permission for Settings writes through remote proxy (#1748) * fix: forward scoped node-admin permission for Settings writes through remote proxy The proxy forwards only the user's global role via PROXY_ROLE_HEADER to remote nodes. Scoped role assignments live only on the hub's role_assignments table and are never transmitted, so a scoped Node Admin could not save settings on their granted remote node through the hub proxy. Add a settings-write pre-authorization gate in runGatedProxy that buffers the body, extracts required permission buckets from SETTING_WRITE_PERMISSIONS, checks them hub-side, and elevates PROXY_ROLE_HEADER to 'node-admin' when the scoped check passes. The gate is fail-closed: empty or unparseable bodies require checkNodeManage on the hub, matching the existing requireSettingsWritePermission empty-keys branch. Fixes the gate-parity gap where scoped node:manage worked locally but not through the proxy for Settings writes. * fix: remove unused UserRole import from remoteNodeProxy.ts * test(self-update): poll instead of a fixed delay in triggerUpdate assertion The 600ms sleep raced the route's 500ms post-response timer plus the persist/watch work executeClaimedCommunityUpdate does before calling triggerUpdate, leaving too little margin under CI's forked test pool. Poll with vi.waitFor instead, matching the pattern already used elsewhere in this suite. --- .../proxy-scoped-settings-authz.test.ts | 343 ++++++++++++++++++ .../self-update-pinned-routes.test.ts | 15 +- backend/src/proxy/remoteNodeProxy.ts | 110 +++++- backend/src/routes/settings.ts | 2 +- backend/src/types/express.ts | 7 +- 5 files changed, 454 insertions(+), 23 deletions(-) create mode 100644 backend/src/__tests__/proxy-scoped-settings-authz.test.ts diff --git a/backend/src/__tests__/proxy-scoped-settings-authz.test.ts b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts new file mode 100644 index 00000000..2262dd13 --- /dev/null +++ b/backend/src/__tests__/proxy-scoped-settings-authz.test.ts @@ -0,0 +1,343 @@ +/** + * Hub → remote proxy coverage for scoped node-admin Settings writes. + * Exercises the settings pre-authorization gate in createRemoteProxyMiddleware + * through live loopback remotes. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import { PROXY_ROLE_HEADER } from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let viewerId: number; + +let grantedServer: http.Server; +let ungrantedServer: http.Server; +let grantedNodeId: number; +let ungrantedNodeId: number; + +interface CapturedHop { + method: string; + url: string; + roleHeader: string | undefined; +} +const grantedHops: CapturedHop[] = []; +const ungrantedHops: CapturedHop[] = []; + +function captureHop(req: http.IncomingMessage, into: CapturedHop[]): void { + into.push({ + method: req.method ?? '', + url: req.url ?? '', + roleHeader: req.headers[PROXY_ROLE_HEADER] as string | undefined, + }); +} + +function grantedRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + captureHop(req, grantedHops); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); +} + +function ungrantedRemote(): http.Server { + return http.createServer((req, res) => { + if (req.url?.startsWith('/api/meta')) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + version: '0.93.0', + capabilities: ['cross-node-rbac'], + })); + return; + } + captureHop(req, ungrantedHops); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + const db = DatabaseService.getInstance(); + const hash = await bcrypt.hash('password123', 1); + viewerId = db.addUser({ + username: 'settings-scoped-viewer', + password_hash: hash, + role: 'viewer', + }); + const viewer = db.getUserByUsername('settings-scoped-viewer')!; + viewerBearer = jwt.sign( + { username: 'settings-scoped-viewer', role: 'viewer', tv: viewer.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + grantedServer = grantedRemote(); + ungrantedServer = ungrantedRemote(); + const grantedPort = await listen(grantedServer); + const ungrantedPort = await listen(ungrantedServer); + + grantedNodeId = db.addNode({ + name: 'settings-granted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${grantedPort}`, + api_token: 'granted-token', + }); + ungrantedNodeId = db.addNode({ + name: 'settings-ungranted-remote', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: `http://127.0.0.1:${ungrantedPort}`, + api_token: 'ungranted-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => grantedServer.close(() => resolve())); + await new Promise((resolve) => ungrantedServer.close(() => resolve())); + cleanupTestDb(tmpDir); +}); + +beforeEach(async () => { + vi.restoreAllMocks(); + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + grantedHops.length = 0; + ungrantedHops.length = 0; +}); + +describe('remote proxy scoped node-admin settings writes', () => { + it('allows scoped node-admin on granted remote node and elevates role header', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies scoped node-admin on ungranted remote node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(ungrantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies viewer with no scoped grant on any remote node', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85 }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('denies empty-body PATCH from viewer with no grant (fail-closed)', async () => { + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({}); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + }); + + it('allows empty-body PATCH from scoped node-admin on granted node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({}); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('denies mixed node:manage + system:settings PATCH from scoped node-admin', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 85, developer_mode: '1' }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('rejects compressed settings body with 415', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Encoding', 'gzip') + .send(Buffer.from('compressed')); + + expect(res.status).toBe(415); + expect(res.body.code).toBe('encoding_unsupported'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('rejects oversized settings body with 413', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + // Build a body larger than 100 KB + const bigValue = 'x'.repeat(102 * 1024); + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .set('Content-Length', String(bigValue.length + 30)) + .send(Buffer.from(bigValue)); + + expect(res.status).toBe(413); + expect(res.body.code).toBe('entity_too_large'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); + + it('allows global node-admin to write on remote without elevation gate', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + // The viewer already has role viewer; create a global node-admin + const nodeAdminHash = await bcrypt.hash('nodeadmin123', 1); + const nodeAdminId = db.addUser({ + username: 'settings-global-na', + password_hash: nodeAdminHash, + role: 'node-admin', + }); + const nodeAdmin = db.getUserByUsername('settings-global-na')!; + const nodeAdminBearer = jwt.sign( + { username: 'settings-global-na', role: 'node-admin', tv: nodeAdmin.token_version }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + + const res = await request(app) + .patch('/api/settings') + .set('Authorization', `Bearer ${nodeAdminBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ host_cpu_limit: 90 }); + + expect(res.status).toBe(200); + + db.deleteUser(nodeAdminId); + }); + + it('allows scoped node-admin POST single key on granted node', async () => { + const db = (await import('../services/DatabaseService')).DatabaseService.getInstance(); + db.addRoleAssignment({ + user_id: viewerId, + role: 'node-admin', + resource_type: 'node', + resource_id: String(grantedNodeId), + }); + + const res = await request(app) + .post('/api/settings') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ key: 'host_cpu_limit', value: 95 }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/settings')); + expect(hop).toBeDefined(); + expect(hop!.roleHeader).toBe('node-admin'); + + db.deleteRoleAssignmentsByUser(viewerId); + }); +}); diff --git a/backend/src/__tests__/self-update-pinned-routes.test.ts b/backend/src/__tests__/self-update-pinned-routes.test.ts index 50da0aca..051de0bb 100644 --- a/backend/src/__tests__/self-update-pinned-routes.test.ts +++ b/backend/src/__tests__/self-update-pinned-routes.test.ts @@ -115,13 +115,14 @@ describe('POST /api/system/update', () => { expect(res.status).toBe(202); expect(res.body?.message).toMatch(/restart/i); - // triggerUpdate runs on res finish + delay; flush the microtask queue. - await new Promise(r => setTimeout(r, 600)); - expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({ - targetVersion: '0.99.0', - successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/), - successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/), - })); + // The route schedules triggerUpdate 500ms after responding, so poll for it. + await vi.waitFor(() => { + expect(triggerSpy).toHaveBeenCalledWith(expect.objectContaining({ + targetVersion: '0.99.0', + successMarkerFile: expect.stringMatching(/image-op-success-[\w-]+\.json$/), + successMarkerContent: expect.stringMatching(/"operationId":"[\w-]+"/), + })); + }, { timeout: 5_000 }); }); }); diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 37a8545e..bbb29531 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -34,6 +34,8 @@ import { ROLE_PERMISSIONS, scopedActionsForStack, } from '../middleware/permissions'; +import type { PermissionAction } from '../middleware/permissions'; +import { SETTING_WRITE_PERMISSIONS } from '../routes/settings'; /** * Per-request hop timing for the critical hydration GETs, kept off the Request @@ -420,6 +422,59 @@ export function createRemoteProxyMiddleware(): RequestHandler { } } + // Settings-write pre-auth gate: when a non-admin, non-global-node-admin + // user writes settings on a remote node, the remote only sees the global + // role header and cannot verify scoped assignments. Check hub-side first + // and elevate PROXY_ROLE_HEADER to node-admin when the scoped check passes. + if (isSettingsWrite(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { + if (hasNonIdentityContentEncoding(req)) { + await drainRequestBody(req); + res.status(415).json({ + error: 'Compressed request bodies are not supported for remote settings writes', + code: 'encoding_unsupported', + }); + return; + } + try { + req.rawBody = await bufferRequestBody(req, SETTINGS_PROXY_BODY_LIMIT); + } catch (err) { + const status = Number((err as { status?: number }).status); + if (status === 413) { + res.status(413).json({ error: 'Settings payload too large', code: 'entity_too_large' }); + return; + } + if (status === 400) { + res.status(400).json({ error: 'Incomplete request body' }); + return; + } + throw err; + } + const needed = settingsBodyPermissions(req.rawBody); + // Fail-closed on empty/unparseable body: require hub-side node:manage on + // the target node (mirrors requireSettingsWritePermission's empty-keys + // branch in routes/settings.ts:62-68). A user with no scoped grant is + // denied; a user with a scoped grant passes through elevated. + let preAuthOk = true; + if (needed.length === 0) { + preAuthOk = checkNodeManageOnHub(req); + } else { + for (const action of needed) { + const ok = action === 'node:manage' + ? checkNodeManageOnHub(req) + : checkPermission(req, action); + if (!ok) { + preAuthOk = false; + break; + } + } + } + if (!preAuthOk) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + req.proxyElevatedRole = 'node-admin'; + } + // Alerts POST scoped-evidence gate: when a non-admin, non-node-admin user // creates a stack-scoped alert on a remote node, forward the scoped grant // as evidence so the remote can authorize the write. The body was already @@ -539,6 +594,50 @@ export function createRemoteProxyMiddleware(): RequestHandler { }; } +/** Max request body size for buffered settings writes (same as ALERT_PROXY_BODY_LIMIT). */ +const SETTINGS_PROXY_BODY_LIMIT = 100 * 1024; + +/** True when the request is a settings write destined for a remote node (path is post-/api strip). */ +function isSettingsWrite(req: Request): boolean { + if (req.method !== 'POST' && req.method !== 'PATCH') return false; + return /^\/settings\/?$/.test(req.path); +} + +/** + * Hub-side `node:manage` resolve against the active node so scoped Node Admin + * grants on the target remote node are detected before the hop. + */ +function checkNodeManageOnHub(req: Request): boolean { + if (typeof req.nodeId === 'number') { + return checkPermission(req, 'node:manage', 'node', String(req.nodeId)); + } + return checkPermission(req, 'node:manage'); +} + +/** + * Extract the set of required PermissionAction values from a buffered settings + * body. Returns the distinct actions for a valid body, or an empty array when + * the body is empty or JSON.parse fails (caller must then fall back to requiring + * checkNodeManageOnHub, fail-closed). + */ +function settingsBodyPermissions(rawBody: Buffer): PermissionAction[] { + if (rawBody.length === 0) return []; + try { + const parsed = JSON.parse(rawBody.toString('utf-8')) as Record; + // POST /api/settings sends { key, value }; PATCH sends a flat key/value map. + const keys = typeof parsed.key === 'string' ? [parsed.key] : Object.keys(parsed); + if (keys.length === 0) return []; + const needed = new Set(); + for (const key of keys) { + const action = SETTING_WRITE_PERMISSIONS[key]; + if (action) needed.add(action); + } + return [...needed]; + } catch { + return []; + } +} + /** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */ function isStackDownWithRemoveVolumes(req: Request): boolean { if (req.method !== 'POST') return false; @@ -578,17 +677,6 @@ function isImageRefreshNodeWide(req: Request): boolean { return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path); } -/** - * Hub-side node:manage resolve against the active node so scoped Node Admin - * grants on the target remote node are detected before the hop. - */ -function checkNodeManageOnHub(req: Request): boolean { - if (typeof req.nodeId === 'number') { - return checkPermission(req, 'node:manage', 'node', String(req.nodeId)); - } - return checkPermission(req, 'node:manage'); -} - /** * Extract the stack_name from a buffered JSON POST body. * Returns a valid stack name, `null` when the field is absent or diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index fcbae7c9..51971e39 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -11,7 +11,7 @@ import { parseNotificationDispatchRetries } from '../helpers/notificationDispatc // keys so secrets written to global_settings by other subsystems (cloud // backup credentials, auth_* login secrets) are never returned; writes // outside the map are rejected. -const SETTING_WRITE_PERMISSIONS: Record = { +export const SETTING_WRITE_PERMISSIONS: Record = { host_cpu_limit: 'node:manage', host_ram_limit: 'node:manage', host_disk_limit: 'node:manage', diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 1b04e5e4..2292e2a0 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -45,10 +45,9 @@ declare global { */ proxyNamedStackRoute?: { stackName: string; action: PermissionAction }; /** - * Hub-side role override for the outbound proxy hop. When set, the - * PROXY_ROLE_HEADER is elevated to node-admin instead of the caller's - * global role, so scoped node-level grants can be forwarded. - * Request-scoped; consumed only by remoteNodeProxy.ts. + * Elevated role for a single proxied request. Set by the settings + * pre-authorization gate when the hub-side scoped permission check + * passes for a non-admin user. Resets to undefined after the hop. */ proxyElevatedRole?: 'node-admin'; } From a74905ff1ed7a7309a899eddd612fab56246ea02 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 17:13:44 -0400 Subject: [PATCH 20/31] fix(auth): align WebSocket token_version checks with HTTP (#1744) * test(auth): cover legacy no-tv JWT rejection on WebSocket upgrades * fix(auth): treat missing tv claim as token_version 1 on WebSocket upgrades --- backend/src/__tests__/exec.test.ts | 29 +++++++++++++++++++ backend/src/__tests__/host-console-ws.test.ts | 15 ++++++++++ backend/src/websocket/generic.ts | 4 ++- backend/src/websocket/upgradeHandler.ts | 4 ++- 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts index 633e0af5..497c1373 100644 --- a/backend/src/__tests__/exec.test.ts +++ b/backend/src/__tests__/exec.test.ts @@ -351,6 +351,35 @@ describe('WebSocket upgrade - exec auth enforcement', () => { }); } + it('rejects legacy no-tv admin JWT after token_version bump (401)', async () => { + // A legacy token without a tv claim is treated as version 1; once the + // account version is bumped it must be rejected on /ws like on HTTP. + const { DatabaseService } = await import('../services/DatabaseService'); + const bcrypt = await import('bcrypt'); + const db = DatabaseService.getInstance(); + const username = `legacy-tv-admin-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); + + const legacyNoTv = jwt.sign( + { username, role: 'admin' }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + // A regression (upgrade accepted) must fail fast with 200, not hang. + ws.on('open', () => { ws.close(); resolve(200); }); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(401); + }); + it('rejects WebSocket upgrade with node_proxy token (403)', async () => { const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); diff --git a/backend/src/__tests__/host-console-ws.test.ts b/backend/src/__tests__/host-console-ws.test.ts index 0c7db4d1..8e0cede2 100644 --- a/backend/src/__tests__/host-console-ws.test.ts +++ b/backend/src/__tests__/host-console-ws.test.ts @@ -85,6 +85,21 @@ describe('WebSocket upgrade - host console auth enforcement', () => { }); } + it('rejects a legacy no-tv admin JWT after token_version bump (401)', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const db = DatabaseService.getInstance(); + const username = `hc-legacy-tv-${Date.now()}`; + const id = db.addUser({ + username, + password_hash: await bcrypt.hash('password123', 1), + role: 'admin', + }); + db.bumpTokenVersion(id); + const legacyNoTv = jwt.sign({ username, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${legacyNoTv}` } }); + expect(await expectRejected(ws)).toBe(401); + }); + it('accepts a Community-tier admin', async () => { getTierSpy.mockReturnValueOnce('community'); const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } }); diff --git a/backend/src/websocket/generic.ts b/backend/src/websocket/generic.ts index 29c339cd..73466311 100644 --- a/backend/src/websocket/generic.ts +++ b/backend/src/websocket/generic.ts @@ -85,7 +85,9 @@ export function handleGenericWs( console.warn('[Exec] User account not found:', decoded.username); return reject(socket, 401, 'Unauthorized'); } - if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (execUser.token_version !== (decoded.tv ?? 1)) { console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username); return reject(socket, 401, 'Unauthorized'); } diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index a10dd489..b8f5ae3c 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -201,7 +201,9 @@ export function attachUpgrade( if (!decoded.scope && decoded.username) { const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username); if (!dbUser) return reject(socket, 401, 'Unauthorized'); - if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) { + // Missing `tv` is a pre-migration legacy token at version 1, same default + // as `authMiddleware`; a bumped account version must reject it. + if (dbUser.token_version !== (decoded.tv ?? 1)) { console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username); return reject(socket, 401, 'Unauthorized'); } From c613010199fc67b21f940a379fbe3d801f0d36a4 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 20:48:38 -0400 Subject: [PATCH 21/31] feat: account for VM memory ballooning in host memory reporting (#1750) * feat: account for VM memory ballooning in host memory reporting Extend hostMemory.ts with a readBalloonedMemory() function that parses the Balloon: field from /proc/meminfo, following the same fail-open pattern as the ZFS ARC integration. When a nonzero balloon is detected, effective memory fields (effectiveUsed, effectiveFree, effectiveUsagePercent) are computed and exposed through /api/system/stats and /api/fleet/overview. All consumers that derive meaning from host memory now prefer effective values when present: the dashboard gauge, Fleet card RAM bar, mobile views, health verdict, health status bar stat tile, and host RAM alerts. Backward compatible: missing /proc/meminfo or absent Balloon: line preserves exact current behavior. Old remote nodes without the new fields continue rendering normally. * refactor: extract shared helpers for balloon memory wiring Extract readCandidateFile() and logSelectedPath() in hostMemory.ts to deduplicate ARC and balloon file-read logic. Add memoryToWire() to centralize the optional-field spread used by /api/system/stats and /api/fleet/overview. Add getNodeMemUsed()/getNodeMemTotal() helpers in nodeUtils.ts for frontend byte-text consumers. * fix: make desktop fleet masthead aggregate balloon-aware The desktop fleet overview's memory aggregate in useFleetOverview.ts still summed raw memory.used, while the mobile fleet aggregate and per-node cards already used effective values. Update to use getNodeMemUsed/getNodeMemTotal helpers. * fix: revert balloon adjustment from alerting and health decisions Ballooned memory is host-reclaimed (unlike ZFS ARC, which the guest can reclaim on demand). The guest cannot get ballooned pages back until the hypervisor deflates them, so treating ballooned memory as available for alerting or health can mask real memory pressure. Keep balloon parsing, wire fields, and the dashboard context line as informational-only. The memory gauge, health verdict, and host RAM alerts now use the standard ARC-adjusted working-set percentage regardless of balloon. Updated configuration.mdx and dashboard.mdx to document that balloon data is informational and does not influence alerting. --- .env.example | 5 + .../src/__tests__/helpers/arcstatsFsMock.ts | 60 ++++- backend/src/__tests__/host-memory.test.ts | 192 +++++++++++++- backend/src/helpers/hostMemory.ts | 245 ++++++++++++++---- backend/src/routes/fleet.ts | 17 +- backend/src/routes/metrics.ts | 13 +- backend/src/services/MonitorService.ts | 8 +- docker-compose.yml | 5 + docs/features/dashboard.mdx | 2 + docs/getting-started/configuration.mdx | 16 ++ .../src/components/FleetView/NodeCard.tsx | 6 +- .../FleetView/hooks/useFleetOverview.ts | 6 +- .../src/components/FleetView/nodeUtils.ts | 14 +- frontend/src/components/FleetView/types.ts | 13 +- .../components/dashboard/HealthStatusBar.tsx | 2 +- .../components/dashboard/ResourceGauges.tsx | 14 +- .../src/components/dashboard/deriveHealth.ts | 5 +- frontend/src/components/dashboard/types.ts | 6 + .../src/components/mobile/MobileDashboard.tsx | 2 +- .../src/components/mobile/MobileFleet.tsx | 8 +- 20 files changed, 540 insertions(+), 99 deletions(-) diff --git a/.env.example b/.env.example index 34628858..bacb6adb 100644 --- a/.env.example +++ b/.env.example @@ -157,3 +157,8 @@ GITSOURCE_MAX_CLONE_BYTES=104857600 # only if your ARC stats live at a non-standard path inside the container. If no # ARC stats are readable, host memory reporting is unchanged. # SENCHO_ZFS_ARCSTATS_PATH= + +# Path inside the container to /proc/meminfo, for VM memory ballooning awareness. +# Sencho checks this path first, then /host/proc/meminfo, then /proc/meminfo. +# Set it only when your meminfo lives at a non-standard path inside the container. +# SENCHO_PROC_MEMINFO_PATH= diff --git a/backend/src/__tests__/helpers/arcstatsFsMock.ts b/backend/src/__tests__/helpers/arcstatsFsMock.ts index 4baf8ffd..3e8198c5 100644 --- a/backend/src/__tests__/helpers/arcstatsFsMock.ts +++ b/backend/src/__tests__/helpers/arcstatsFsMock.ts @@ -1,26 +1,33 @@ import { promises as fs } from 'fs'; import { vi } from 'vitest'; -import { ARCSTATS_FIXED_PATHS } from '../../helpers/hostMemory'; +import { ARCSTATS_FIXED_PATHS, MEMINFO_FIXED_PATHS } from '../../helpers/hostMemory'; /** - * Path-aware partial mock of `fs.promises` for ZFS arcstats reads. + * Path-aware partial mock of `fs.promises` for ZFS arcstats and /proc/meminfo + * reads. * - * `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (and optional - * variants) to compute reclaimable ARC. Tests may run on a ZFS host, so a real - * read would make results host-dependent. This installs a spy that intercepts - * ONLY registered/ARC-candidate paths and delegates every other - * `readFile`/`stat` to the real filesystem, so `setupTestDb` and - * `DatabaseService` keep working. Default behavior: ARC candidates reject with - * ENOENT (no ARC), so consumers fall back to the plain `active/total` reading. + * `helpers/hostMemory.ts` reads `/proc/spl/kstat/zfs/arcstats` (for ARC) and + * `/proc/meminfo` (for VM ballooning). Tests may run on a ZFS host or a + * ballooned VM, so real reads would make results host-dependent. This installs + * a spy that intercepts ONLY registered/candidate paths and delegates every + * other `readFile`/`stat` to the real filesystem, so `setupTestDb` and + * `DatabaseService` keep working. Default behavior: candidates reject with + * ENOENT, so consumers fall back to the plain `active/total` reading. */ // Sourced from the helper so the mock cannot silently drift from the paths the // production code actually reads. export const ARC_CANDIDATE_PATHS = ARCSTATS_FIXED_PATHS; -/** Second fixed candidate; the default path fixtures are served from. */ +/** Second fixed ARC candidate; the default path ARC fixtures are served from. */ export const DEFAULT_ARC_PATH = ARC_CANDIDATE_PATHS[1]; +/** Meminfo candidate paths (same source-of-truth import pattern as ARC). */ +export const MEMINFO_CANDIDATE_PATHS = MEMINFO_FIXED_PATHS; + +/** Default meminfo path for test fixtures. */ +export const DEFAULT_MEMINFO_PATH = MEMINFO_CANDIDATE_PATHS[1]; + type StatDescriptor = { isFile: boolean; size: number }; export interface ArcstatsFsMock { @@ -47,7 +54,8 @@ export function installArcstatsFsMock(): ArcstatsFsMock { const realStat = fs.stat.bind(fs); const reads = new Map(); const stats = new Map(); - const isArcCandidate = (p: string): boolean => ARC_CANDIDATE_PATHS.includes(p); + const isCandidatePath = (p: string): boolean => + ARC_CANDIDATE_PATHS.includes(p) || MEMINFO_CANDIDATE_PATHS.includes(p); vi.spyOn(fs, 'readFile').mockImplementation((async (p: unknown, ...rest: unknown[]) => { const key = String(p); @@ -56,7 +64,7 @@ export function installArcstatsFsMock(): ArcstatsFsMock { if (v instanceof Error) throw v; return v; } - if (isArcCandidate(key)) throw enoent(key); + if (isCandidatePath(key)) throw enoent(key); return (realReadFile as (...a: unknown[]) => unknown)(p, ...rest); }) as unknown as typeof fs.readFile); @@ -73,7 +81,7 @@ export function installArcstatsFsMock(): ArcstatsFsMock { const size = typeof v === 'string' ? Buffer.byteLength(v) : 0; return { isFile: () => true, size }; } - if (isArcCandidate(key)) throw enoent(key); + if (isCandidatePath(key)) throw enoent(key); return (realStat as (...a: unknown[]) => unknown)(p, ...rest); }) as unknown as typeof fs.stat); @@ -96,3 +104,29 @@ export function arcstatsBody(sizeRow: string | number, cMinRow: string | number) '', ].join('\n'); } + +/** + * Build a realistic /proc/meminfo snippet with the given Balloon value in kB. + * Pass undefined / a negative value to omit the Balloon line entirely. + */ +export function meminfoBody(balloonKb?: number): string { + const balloonLine = balloonKb !== undefined && balloonKb >= 0 + ? `Balloon: ${balloonKb} kB\n` + : ''; + return [ + 'MemTotal: 16433188 kB', + 'MemFree: 620452 kB', + 'MemAvailable: 3489624 kB', + 'Buffers: 158668 kB', + 'Cached: 3335960 kB', + 'SwapCached: 0 kB', + 'Active: 5280444 kB', + 'Inactive: 7478672 kB', + balloonLine, + 'SwapTotal: 8388604 kB', + 'SwapFree: 8388604 kB', + 'Dirty: 124 kB', + 'Writeback: 0 kB', + '', + ].join('\n'); +} diff --git a/backend/src/__tests__/host-memory.test.ts b/backend/src/__tests__/host-memory.test.ts index b7c6bf63..f021b800 100644 --- a/backend/src/__tests__/host-memory.test.ts +++ b/backend/src/__tests__/host-memory.test.ts @@ -9,8 +9,11 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vite import { installArcstatsFsMock, arcstatsBody, + meminfoBody, DEFAULT_ARC_PATH, + DEFAULT_MEMINFO_PATH, ARC_CANDIDATE_PATHS, + MEMINFO_CANDIDATE_PATHS, type ArcstatsFsMock, } from './helpers/arcstatsFsMock'; @@ -20,7 +23,7 @@ vi.mock('systeminformation', () => ({ default: { mem: (...args: unknown[]) => mockMem(...args) }, })); -import { getHostMemory, adjustForArc } from '../helpers/hostMemory'; +import { getHostMemory, adjustForArc, adjustForBalloon } from '../helpers/hostMemory'; // mem.active === total - available on Linux, so used/free below mirror the // real systeminformation shape the helper consumes. @@ -43,6 +46,7 @@ beforeEach(() => { arcFs.clear(); mockMem.mockReset(); delete process.env.SENCHO_ZFS_ARCSTATS_PATH; + delete process.env.SENCHO_PROC_MEMINFO_PATH; }); describe('adjustForArc', () => { @@ -199,6 +203,192 @@ describe('getHostMemory ARC discovery', () => { }); }); +describe('adjustForBalloon', () => { + const arcAdjusted = (total: number, used: number, free: number, usagePercent: number): ReturnType => + ({ total, used, free, usagePercent }); + + it('returns the input unchanged when ballooned is 0', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, 0); + expect(result).toBe(input); // identity for zero + }); + + it('returns the input unchanged when ballooned is negative', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, -5); + expect(result).toBe(input); + }); + + it('subtracts ballooned from used, adds to free, sets optional fields', () => { + const input = arcAdjusted(1000, 400, 600, 40); + const result = adjustForBalloon(input, 200); + expect(result.ballooned).toBe(200); + expect(result.effectiveTotal).toBe(1000); + expect(result.effectiveUsed).toBe(200); // 400 - 200 + expect(result.effectiveFree).toBe(800); // 600 + 200 + expect(result.effectiveUsagePercent).toBe(20); // 200 / 1000 * 100 + expect(result.balloonSource).toBe('linux_proc_meminfo'); + // Base fields unchanged. + expect(result.total).toBe(1000); + expect(result.used).toBe(400); + expect(result.free).toBe(600); + }); + + it('clamps effectiveUsed at 0 when balloon exceeds used', () => { + const input = arcAdjusted(1000, 100, 900, 10); + const result = adjustForBalloon(input, 5000); + expect(result.effectiveUsed).toBe(0); + expect(result.effectiveFree).toBe(1000); + expect(result.effectiveUsagePercent).toBe(0); + }); + + it('handles zero total gracefully', () => { + const input = arcAdjusted(0, 0, 0, 0); + const result = adjustForBalloon(input, 100); + expect(result.effectiveUsagePercent).toBe(0); + }); +}); + +describe('getHostMemory balloon discovery', () => { + it('returns the base ARC-adjusted shape when no meminfo is present', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('returns the base ARC-adjusted shape when Balloon is missing from meminfo', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody()); // no Balloon line + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('returns the base ARC-adjusted shape when Balloon is 0 kB', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(0)); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('subtracts ballooned memory and sets optional fields', async () => { + mockMem.mockResolvedValue(memSample(16000, 4000)); // 16 GB total, 4 GB available → 75% used + // 4 GiB balloon = 4194304 kB + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(4_194_304)); + const result = await getHostMemory(); + // ARC=0, available=4000: used=12000 + // ballooned=4194304*1024 = 4_294_967_296 bytes + // effectiveUsed = 12000 - ballooned ≈ 7705 MB + expect(result.used).toBe(12000); + expect(typeof result.ballooned).toBe('number'); + expect(result.ballooned!).toBeGreaterThan(0); + expect(result.effectiveUsed).toBeDefined(); + expect(result.effectiveFree).toBeDefined(); + expect(result.effectiveUsagePercent).toBeDefined(); + expect(result.effectiveUsed!).toBeLessThan(result.used); + expect(result.balloonSource).toBe('linux_proc_meminfo'); + }); + + it('combines ARC reclaim and balloon adjustment', async () => { + mockMem.mockResolvedValue(memSample(16000, 2000)); // 12.5% available + arcFs.setRead(DEFAULT_ARC_PATH, arcstatsBody(5000, 1000)); // reclaimable ARC = 4000 + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(2_097_152)); // 2 GiB balloon + const result = await getHostMemory(); + // ARC-adjusted: used = 16000 - (2000 + 4000) = 10000 + expect(result.used).toBe(10000); + // Balloon-adjusted: effectiveUsed = 10000 - 2GiB + expect(result.effectiveUsed).toBeDefined(); + expect(result.effectiveUsed!).toBeLessThan(result.used); + }); + + it('prefers the meminfo override path', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setRead('/custom/meminfo', meminfoBody(4_194_304)); // 4 GiB balloon + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(0)); // fixed path says no balloon + const result = await getHostMemory(); + expect(result.ballooned).toBeGreaterThan(0); // override won + }); + + it('falls through to a fixed meminfo path when the override is unreadable', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setReadError('/custom/meminfo', Object.assign(new Error('nope'), { code: 'ENOENT' })); + arcFs.setRead(DEFAULT_MEMINFO_PATH, meminfoBody(2_097_152)); // 2 GiB + const result = await getHostMemory(); + expect(result.ballooned).toBeGreaterThan(0); // fell through to fixed + }); + + it('reads the host-mounted meminfo candidate and prefers it over /proc', async () => { + mockMem.mockResolvedValue(memSample(16000, 4000)); + arcFs.setRead(MEMINFO_CANDIDATE_PATHS[0], meminfoBody(4_194_304)); // /host/proc: 4 GiB + arcFs.setRead(MEMINFO_CANDIDATE_PATHS[1], meminfoBody(1_048_576)); // /proc: 1 GiB + const result = await getHostMemory(); + // First candidate wins: 4 GiB balloon. + expect(result.ballooned).toBeGreaterThan(0); + expect(result.effectiveUsed).toBeDefined(); + }); + + it('skips an override path that is not a regular file', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/meminfo', { isFile: false, size: 10 }); + arcFs.setRead('/custom/meminfo', meminfoBody(100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); // override skipped, no meminfo on fixed + }); + + it('skips an override path that exceeds the size bound', async () => { + process.env.SENCHO_PROC_MEMINFO_PATH = '/custom/meminfo'; + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setStat('/custom/meminfo', { isFile: true, size: 2 * 1024 * 1024 }); + arcFs.setRead('/custom/meminfo', meminfoBody(100)); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it.each([ + ['non-numeric value', 'Balloon: abc kB\n'], + ['negative value', 'Balloon: -100 kB\n'], + ['no kB suffix', 'Balloon: 100\n'], + ['wrong suffix', 'Balloon: 100 MB\n'], + ['extra token', 'Balloon: 100 kB extra\n'], + ])('treats a %s Balloon line as unusable and yields no balloon', async (_label, body) => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setRead(DEFAULT_MEMINFO_PATH, body); + const result = await getHostMemory(); + expect(result).toEqual({ total: 1000, used: 400, free: 600, usagePercent: 40 }); + }); + + it('fails open (balloon 0) on a read error', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + arcFs.setReadError(DEFAULT_MEMINFO_PATH, Object.assign(new Error('nope'), { code: 'EACCES' })); + const result = await getHostMemory(); + expect(result.used).toBe(400); + }); + + it('logs an unexpected meminfo read error once per code', async () => { + mockMem.mockResolvedValue(memSample(1000, 600)); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + // Expected fs error: silent fall-through. + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[0], Object.assign(new Error('denied'), { code: 'EACCES' })); + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[1], Object.assign(new Error('denied'), { code: 'EACCES' })); + await getHostMemory(); + expect(warn).not.toHaveBeenCalled(); + + // Unexpected fs error: logged once. Use EBADF to avoid collision with + // the ARC test suite's own EIO trigger (loggedErrorCodes is shared). + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[0], Object.assign(new Error('badf'), { code: 'EBADF' })); + arcFs.setReadError(MEMINFO_CANDIDATE_PATHS[1], Object.assign(new Error('badf'), { code: 'EBADF' })); + await getHostMemory(); + await getHostMemory(); + const unexpectedLogs = warn.mock.calls.filter(([msg]) => String(msg).includes('EBADF')); + expect(unexpectedLogs).toHaveLength(1); + warn.mockRestore(); + }); +}); + afterEach(() => { delete process.env.SENCHO_ZFS_ARCSTATS_PATH; + delete process.env.SENCHO_PROC_MEMINFO_PATH; }); diff --git a/backend/src/helpers/hostMemory.ts b/backend/src/helpers/hostMemory.ts index d5858320..e271d0b6 100644 --- a/backend/src/helpers/hostMemory.ts +++ b/backend/src/helpers/hostMemory.ts @@ -2,22 +2,30 @@ import si from 'systeminformation'; import { promises as fs } from 'fs'; /** - * Shared host-memory computation, ZFS ARC aware. + * Shared host-memory computation, ZFS ARC and VM-balloon aware. * * `systeminformation.mem()` derives `active` as `total - available` on * Linux/BSD/macOS, so keying usage off `active` already dodges page-cache - * inflation. It does NOT account for the OpenZFS ARC: the kernel's - * MemAvailable treats ARC as unavailable even though ARC shrinks under - * memory pressure, so on ZFS hosts a large ARC reads as hard-used memory - * and produces false host-memory alerts. + * inflation. It does NOT account for two sources of reclaimable memory: + * + * 1. **OpenZFS ARC**: the kernel's MemAvailable treats ARC as unavailable + * even though ARC shrinks under memory pressure. + * 2. **VM memory ballooning**: hypervisors (TrueNAS/KVM, Proxmox) reclaim + * guest memory through a balloon driver. The reclaimed amount appears in + * `/proc/meminfo` as `Balloon: N kB` but is invisible to + * `systeminformation.mem()`, so a ballooned VM can read as memory-critical + * when the guest is actually healthy. * * When ARC kstats are readable we add the reclaimable portion - * (`max(size - c_min, 0)`) back into available memory. On non-ZFS hosts, or - * when the kstat file is not readable inside the container, ARC is treated as - * zero and the result is identical to the previous `active / total` behavior. + * (`max(size - c_min, 0)`) back into available memory. When `/proc/meminfo` + * reports a nonzero `Balloon:` value we subtract the ballooned amount from + * used and recompute an effective usage percentage. On non-ZFS / non-VM + * hosts, or when the files are not readable inside the container, both + * adjustments resolve to zero and the result is identical to the previous + * `active / total` behavior. */ -/** Effective host memory after adding reclaimable ZFS ARC back into available. */ +/** Effective host memory after ARC and balloon adjustments. */ export interface HostMemory { total: number; /** Effective used bytes (ARC-adjusted). */ @@ -26,6 +34,18 @@ export interface HostMemory { free: number; /** Effective used as a percentage of total (0 when total is 0). */ usagePercent: number; + /** Balloon-reclaimed bytes (from /proc/meminfo). Present only when > 0. */ + ballooned?: number; + /** Total memory (same as `total`; provided for symmetric UI code). */ + effectiveTotal?: number; + /** Used bytes after subtracting both ARC reclaim and balloon. */ + effectiveUsed?: number; + /** Free bytes after adding both ARC reclaim and balloon. */ + effectiveFree?: number; + /** Effective used as a percentage (balloon-adjusted). */ + effectiveUsagePercent?: number; + /** Source identifier for the balloon reading. */ + balloonSource?: 'linux_proc_meminfo'; } type MemData = Awaited>; @@ -40,18 +60,33 @@ export const ARCSTATS_FIXED_PATHS = [ '/proc/spl/kstat/zfs/arcstats', ]; -/** Bound reads of the operator-supplied override path; arcstats is a few KB. */ -const MAX_ARCSTATS_BYTES = 1024 * 1024; +/** Bound reads of the operator-supplied override path; both files are a few KB. */ +const MAX_CANDIDATE_BYTES = 1024 * 1024; + +/** + * Candidate /proc/meminfo paths in priority order. The operator override is + * only present when SENCHO_PROC_MEMINFO_PATH is set; the two fixed paths are + * the host-mounted and the standard container-visible locations. + */ +export const MEMINFO_FIXED_PATHS = [ + '/host/proc/meminfo', + '/proc/meminfo', +]; // Memoized so a 30s monitor tick / dashboard poll does not log on every cycle. const loggedSelectedPaths = new Set(); const loggedErrorCodes = new Set(); -function overridePath(): string | undefined { +function arcOverridePath(): string | undefined { const raw = process.env.SENCHO_ZFS_ARCSTATS_PATH?.trim(); return raw ? raw : undefined; } +function meminfoOverridePath(): string | undefined { + const raw = process.env.SENCHO_PROC_MEMINFO_PATH?.trim(); + return raw ? raw : undefined; +} + function isExpectedFsError(err: unknown): boolean { const code = (err as NodeJS.ErrnoException)?.code; return ( @@ -64,11 +99,39 @@ function isExpectedFsError(err: unknown): boolean { ); } -function logUnexpected(context: string, err: unknown): void { - const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; - if (loggedErrorCodes.has(code)) return; - loggedErrorCodes.add(code); - console.warn(`[HostMemory] Unexpected error reading ARC stats (${context}, ${code}); treating ARC as reclaimable=0`); +function logSelectedPath(path: string, label: string): void { + if (loggedSelectedPaths.has(path)) return; + loggedSelectedPaths.add(path); + console.debug(`[HostMemory] Using ${label} from ${path}`); +} + +/** + * Read a single candidate file, applying the operator-override guard (regular + * file, bounded size) and the fail-open error contract. Returns the raw text, + * or undefined when the path is unusable so the caller falls through to the + * next candidate. Unexpected errors are logged once per code. + */ +async function readCandidateFile(path: string, isOverride: boolean): Promise { + try { + // The override path is operator-supplied: verify it is a regular file of + // bounded size before reading (guards against a named pipe or an + // accidentally huge target). The fixed paths are trusted. + if (isOverride) { + const info = await fs.stat(path); + if (!info.isFile() || info.size > MAX_CANDIDATE_BYTES) return undefined; + } + return await fs.readFile(path, 'utf8'); + } catch (err) { + // Fail open: a missing or unreadable file is the normal non-ZFS/non-VM + // case (expected fs errors); an unexpected error is logged once but still + // falls through so the adjustment can only lower a false positive. + if (isExpectedFsError(err)) return undefined; + const code = (err as NodeJS.ErrnoException)?.code ?? 'UNKNOWN'; + if (loggedErrorCodes.has(code)) return undefined; + loggedErrorCodes.add(code); + console.warn(`[HostMemory] Unexpected error reading ${path} (${code}); treating as 0`); + return undefined; + } } /** Parse the kstat table for the `size` and `c_min` rows (` `). */ @@ -84,41 +147,62 @@ function parseArcstats(raw: string): { size?: number; cMin?: number } { return { size, cMin }; } +/** + * Parse a /proc/meminfo body for the `Balloon:` line. Returns bytes, or + * undefined when the field is absent/malformed. Only the standard ` kB` + * format is recognized. + */ +function parseMeminfoBalloon(raw: string): number | undefined { + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('Balloon:')) continue; + const parts = trimmed.split(/\s+/); + // Expect "Balloon: kB" (3 tokens). + if (parts.length !== 3 || parts[2] !== 'kB') return undefined; + const value = Number(parts[1]); + if (!Number.isFinite(value) || value < 0) return undefined; + return value * 1024; + } + return undefined; +} + +/** + * Ballooned memory in bytes, or 0 when the meminfo field is absent/unusable. + * Never throws: any error resolves to 0 so balloon awareness can only lower a + * false-positive reading, never break host-memory reporting. + */ +async function readBalloonedMemory(): Promise { + const override = meminfoOverridePath(); + const candidates = override ? [override, ...MEMINFO_FIXED_PATHS] : MEMINFO_FIXED_PATHS; + for (const candidatePath of candidates) { + const raw = await readCandidateFile(candidatePath, candidatePath === override); + if (raw === undefined) continue; + const ballooned = parseMeminfoBalloon(raw); + if (ballooned === undefined) continue; + logSelectedPath(candidatePath, 'meminfo'); + return ballooned; + } + return 0; +} + /** * Reclaimable ARC in bytes, or 0 when ARC stats are unavailable/unusable. * Never throws: any error resolves to 0 so ARC awareness can only lower a * false-positive reading, never break host-memory reporting. */ async function readReclaimableArc(): Promise { - const override = overridePath(); + const override = arcOverridePath(); const candidates = override ? [override, ...ARCSTATS_FIXED_PATHS] : ARCSTATS_FIXED_PATHS; for (const candidatePath of candidates) { - try { - // The override path is operator-supplied: verify it is a regular file - // of bounded size before reading (guards against a named pipe or an - // accidentally huge target). The fixed kstat paths are trusted. - if (candidatePath === override) { - const info = await fs.stat(candidatePath); - if (!info.isFile() || info.size > MAX_ARCSTATS_BYTES) continue; - } - const raw = await fs.readFile(candidatePath, 'utf8'); - const { size, cMin } = parseArcstats(raw); - if (size === undefined || cMin === undefined) continue; - if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue; - // A valid record resolves the lookup, even when reclaimable is 0 - // (size < c_min means ARC is at its floor). - if (!loggedSelectedPaths.has(candidatePath)) { - loggedSelectedPaths.add(candidatePath); - console.debug(`[HostMemory] Using ZFS ARC stats from ${candidatePath}`); - } - return Math.max(size - cMin, 0); - } catch (err) { - // Fail open: a missing or unreadable kstat is the normal non-ZFS case - // (expected fs errors); an unexpected error is logged once but still - // falls through so ARC awareness can only lower a false positive. - if (isExpectedFsError(err)) continue; - logUnexpected(candidatePath, err); - } + const raw = await readCandidateFile(candidatePath, candidatePath === override); + if (raw === undefined) continue; + const { size, cMin } = parseArcstats(raw); + if (size === undefined || cMin === undefined) continue; + if (!Number.isFinite(size) || !Number.isFinite(cMin) || size < 0 || cMin < 0) continue; + // A valid record resolves the lookup, even when reclaimable is 0 + // (size < c_min means ARC is at its floor). + logSelectedPath(candidatePath, 'ZFS ARC stats'); + return Math.max(size - cMin, 0); } return 0; } @@ -134,8 +218,73 @@ export function adjustForArc(mem: Pick, arcRecla return { total: mem.total, used: effectiveUsed, free: effectiveAvailable, usagePercent }; } -/** Fetch host memory and reclaimable ARC concurrently, return the adjusted view. */ -export async function getHostMemory(): Promise { - const [mem, arcReclaimable] = await Promise.all([si.mem(), readReclaimableArc()]); - return adjustForArc(mem, arcReclaimable); +/** + * Balloon adjustment layer. Applies on top of the ARC-adjusted result. + * When `ballooned <= 0` the input is returned unchanged, preserving exact + * `.toEqual()` backward compatibility for every existing test assertion. + */ +export function adjustForBalloon(hostMem: HostMemory, ballooned: number): HostMemory { + if (ballooned <= 0) return hostMem; + const effectiveUsed = Math.max(hostMem.used - ballooned, 0); + const effectiveFree = Math.min(hostMem.free + ballooned, hostMem.total); + const effectiveUsagePercent = hostMem.total > 0 + ? (effectiveUsed / hostMem.total) * 100 + : 0; + return { + ...hostMem, + ballooned, + effectiveTotal: hostMem.total, + effectiveUsed, + effectiveFree, + effectiveUsagePercent, + balloonSource: 'linux_proc_meminfo' as const, + }; +} + +/** Wire shape of host memory as served by /api/system/stats and fleet overviews. */ +export interface MemoryWire { + total: number; + used: number; + free: number; + usagePercent: string; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; +} + +/** + * Map adjusted host memory to the wire shape. Balloon fields are only + * included when a balloon reading was present, so the non-VM shape stays + * identical to the pre-balloon wire format. + */ +export function memoryToWire(hostMem: HostMemory): MemoryWire { + return { + total: hostMem.total, + used: hostMem.used, + free: hostMem.free, + usagePercent: hostMem.usagePercent.toFixed(1), + ...(hostMem.ballooned !== undefined + ? { + ballooned: hostMem.ballooned, + effectiveTotal: hostMem.effectiveTotal, + effectiveUsed: hostMem.effectiveUsed, + effectiveFree: hostMem.effectiveFree, + effectiveUsagePercent: hostMem.effectiveUsagePercent?.toFixed(1), + balloonSource: hostMem.balloonSource, + } + : {}), + }; +} + +/** Fetch host memory, reclaimable ARC, and ballooned memory concurrently. */ +export async function getHostMemory(): Promise { + const [mem, arcReclaimable, ballooned] = await Promise.all([ + si.mem(), + readReclaimableArc(), + readBalloonedMemory(), + ]); + return adjustForBalloon(adjustForArc(mem, arcReclaimable), ballooned); } diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index af2ff7d5..2947df29 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -10,7 +10,7 @@ import { FleetUpdateTrackerService, type UpdateTracker, type TerminalStatus, UPD import { NodeRegistry } from '../services/NodeRegistry'; import { computeNodeNetworkingSummary, type NodeNetworkingSummary } from '../services/network/networkingSummary'; import DockerController from '../services/DockerController'; -import { getHostMemory } from '../helpers/hostMemory'; +import { getHostMemory, memoryToWire, type MemoryWire } from '../helpers/hostMemory'; import { FileSystemService } from '../services/FileSystemService'; import { ComposeService } from '../services/ComposeService'; import { StackOpLockService } from '../services/StackOpLockService'; @@ -232,7 +232,7 @@ interface FleetNodeOverview { } | null; systemStats: { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: MemoryWire; disk: { total: number; used: number; free: number; usagePercent: string } | null; } | null; stacks: string[] | null; @@ -301,14 +301,9 @@ async function fetchLocalNodeOverview(node: Node): Promise { stats: { active, managed, unmanaged, exited, total }, systemStats: { cpu: { usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length }, - memory: { - total: hostMem.total, - // ZFS ARC aware: reclaimable ARC is added back into available so a - // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. - used: hostMem.used, - free: hostMem.free, - usagePercent: hostMem.usagePercent.toFixed(1), - }, + // ARC/balloon aware: reclaimable ARC is added back into available, + // and ballooned memory is subtracted from used. See helpers/hostMemory.ts. + memory: memoryToWire(hostMem), disk: mainDisk ? { total: mainDisk.size, used: mainDisk.used, @@ -389,7 +384,7 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise interface RemoteSystemStats { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: MemoryWire; disk?: { total: number; used: number; free: number; usagePercent: string } | null; } diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts index c71ec0d0..6fb917c4 100644 --- a/backend/src/routes/metrics.ts +++ b/backend/src/routes/metrics.ts @@ -9,7 +9,7 @@ import { PilotTunnelManager } from '../services/PilotTunnelManager'; import { authMiddleware } from '../middleware/auth'; import { requireAdmin } from '../middleware/tierGates'; import { STATS_CACHE_TTL_MS, SYSTEM_STATS_CACHE_TTL_MS } from '../helpers/constants'; -import { getHostMemory } from '../helpers/hostMemory'; +import { getHostMemory, memoryToWire } from '../helpers/hostMemory'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { isManagedByComposeDir } from '../utils/managed-containers'; @@ -310,14 +310,9 @@ metricsRouter.get('/system/stats', authMiddleware, async (req: Request, res: Res usage: currentLoad.currentLoad.toFixed(1), cores: currentLoad.cpus.length, }, - memory: { - total: hostMem.total, - // ZFS ARC aware: reclaimable ARC is added back into available so a - // large ARC cache is not reported as hard-used. See helpers/hostMemory.ts. - used: hostMem.used, - free: hostMem.free, - usagePercent: hostMem.usagePercent.toFixed(1), - }, + // ARC/balloon aware: reclaimable ARC is added back into available, + // and ballooned memory is subtracted from used. See helpers/hostMemory.ts. + memory: memoryToWire(hostMem), disk: mainDisk ? { fs: mainDisk.fs, mount: mainDisk.mount, diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 4df2a575..54268c63 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -330,8 +330,12 @@ export class MonitorService { this.clearHostMetricSuppression('cpu'); } - // ZFS ARC aware: reclaimable ARC is added back into available so a large ARC - // cache does not fire spurious host-memory alerts. See helpers/hostMemory.ts. + // ZFS ARC aware: reclaimable ARC is added back into available so a + // large ARC cache does not fire spurious host-memory alerts. + // Ballooned memory is deliberately NOT subtracted here: unlike + // ARC, ballooned pages are reclaimed by the hypervisor and the + // guest cannot get them back on demand. A ballooned VM with real + // memory pressure must still alert. See helpers/hostMemory.ts. const ramUsage = hostMem.usagePercent; const ramLimit = parseFloat(settings['host_ram_limit']); if (!isNaN(ramLimit) && ramLimit > 0 && ramUsage > ramLimit) { diff --git a/docker-compose.yml b/docker-compose.yml index a07d3e26..75095b8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,8 @@ services: # available memory. Usually already visible in the container; only needed # if your runtime does not expose /proc/spl/kstat/zfs/arcstats. # - /proc/spl/kstat/zfs/arcstats:/host/proc/spl/kstat/zfs/arcstats:ro + # VM ballooning: mount /proc/meminfo if your runtime does not expose it. + # - /proc/meminfo:/host/proc/meminfo:ro environment: # ENVIRONMENT VARIABLES FOR INSIDE THE CONTAINER @@ -50,6 +52,9 @@ services: # file, if it is not at a standard location. Leave empty to auto-detect # /host/proc/spl/kstat/zfs/arcstats then /proc/spl/kstat/zfs/arcstats. - SENCHO_ZFS_ARCSTATS_PATH=${SENCHO_ZFS_ARCSTATS_PATH:-} + # Optional: mount /proc/meminfo for VM memory ballooning awareness + # - /proc/meminfo:/host/proc/meminfo:ro + - SENCHO_PROC_MEMINFO_PATH=${SENCHO_PROC_MEMINFO_PATH:-} # ⚠️ GLOBAL ENVIRONMENT VARIABLES ⚠️ # If your compose files rely on host-level shell variables (like $PUID, $TZ) diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 5e4ddbb0..f54908c4 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -49,6 +49,8 @@ While the dashboard is loading the CPU tile reads `--` and the caption shows `co **ZFS hosts:** the memory tile and host RAM alerts are ZFS ARC-aware. Reclaimable ARC cache is treated as available memory rather than used, so a large ARC does not inflate the gauge or trigger false low-memory alerts. See [ZFS ARC-aware host memory](/getting-started/configuration#zfs-arc-aware-host-memory) for how to expose ARC stats to a Docker install. + + **Virtual machines:** the memory tile shows hypervisor-ballooned memory (TrueNAS/KVM, Proxmox) as informational context. Unlike ARC, ballooned pages are host-reclaimed and the guest cannot get them back on demand, so the gauge, health verdict, and alerts continue to use the standard working-set percentage. See [VM memory ballooning](/getting-started/configuration#vm-memory-ballooning) for details. ## Stack health diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 6aaeb3d5..82e68cb1 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -53,6 +53,7 @@ These tune optional subsystems. Most deployments never set them; the defaults ar | `SENCHO_COMPOSE_COMMAND_TIMEOUT_MS` | `1800000` | Hard timeout for a single Compose command (pull, up, down) during deploy and update, in milliseconds (30 minutes). Sencho kills the command and reports failure if it runs longer than this, regardless of whether it is still producing output. Raise it only for very large images or slow storage. | | `SENCHO_COMPOSE_STALL_TIMEOUT_MS` | `600000` | Idle-output backstop for deploy and update Compose steps (pull and recreate), separate from the hard timeout above. If a step produces no output for this long while still running, Sencho stops it so a hung image pull surfaces a clear failure and the in-app recovery actions instead of spinning. Raise it on slow links or for heavy local image builds. | | `SENCHO_ZFS_ARCSTATS_PATH` | *(auto)* | Path **inside the container** to the OpenZFS ARC kstat file, for [ZFS ARC-aware host memory](#zfs-arc-aware-host-memory). Sencho checks this path first, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set it only when your ARC stats live at a non-standard path. | +| `SENCHO_PROC_MEMINFO_PATH` | *(auto)* | Path **inside the container** to `/proc/meminfo`, for [VM memory ballooning](#vm-memory-ballooning). Sencho checks this path first, then `/host/proc/meminfo`, then `/proc/meminfo`. Set it only when you need a custom meminfo path. | Running a remote host as a pilot agent uses four more variables (`SENCHO_MODE`, `SENCHO_PRIMARY_URL`, `SENCHO_ENROLL_TOKEN`, and `SENCHO_PILOT_CA_FILE`), set only on the remote agent container. Sencho bakes them into the enrollment Compose file it generates, so you rarely write them by hand. See [Pilot Agent](/features/pilot-agent) for the full enrollment walkthrough. @@ -71,6 +72,21 @@ volumes: Sencho checks `SENCHO_ZFS_ARCSTATS_PATH`, then `/host/proc/spl/kstat/zfs/arcstats`, then `/proc/spl/kstat/zfs/arcstats`. Set `SENCHO_ZFS_ARCSTATS_PATH` only if your ARC stats live somewhere else inside the container. +## VM memory ballooning + +On Linux virtual machines with memory ballooning enabled (TrueNAS/KVM, Proxmox, VMware), the hypervisor can reclaim guest memory through a balloon driver. The reclaimed amount is tracked in `/proc/meminfo` on the `Balloon:` line but standard memory counters do not account for it, so a ballooned VM can appear memory-critical when the guest workload is actually healthy. + +Sencho reads the `Balloon:` field from `/proc/meminfo` when it is available and shows the ballooned amount on the dashboard memory tile alongside an effective-usage percentage. The memory gauge, health verdict, and host RAM alerts continue to use the standard working-set percentage: unlike ZFS ARC, ballooned memory is reclaimed by the hypervisor and the guest cannot get it back on demand, so balloon data is informational context rather than a factor in alerting or health decisions. When `/proc/meminfo` is unreadable or the `Balloon:` field is absent, the behavior is unchanged. + +`/proc/meminfo` is usually visible inside the container at `/proc/meminfo` with no extra configuration. If your runtime does not expose it, mount it read-only: + +```yaml +volumes: + - /proc/meminfo:/host/proc/meminfo:ro +``` + +Sencho checks `SENCHO_PROC_MEMINFO_PATH`, then `/host/proc/meminfo`, then `/proc/meminfo`. Set `SENCHO_PROC_MEMINFO_PATH` only if your meminfo lives somewhere else inside the container. + ## Listen port Sencho always listens on `1852` inside the container. The port is fixed and is not read from an environment variable. To expose Sencho on a different host port, remap with Docker's `-p` flag (or the `ports:` key in your compose file): diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 32e47dd4..10371c7f 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -29,7 +29,7 @@ import { PinnedUpdateBadge } from './PinnedUpdateBadge'; import { StackSection } from './NodeCardStackList'; import type { Label as StackLabel } from '../label-types'; import type { FleetNode, NodeUpdateStatus } from './types'; -import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from './nodeUtils'; +import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from './nodeUtils'; // --- Types --- @@ -97,6 +97,8 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, const formattedLatest = formatVersion(updateStatus?.latestVersion); const cpuPercent = getNodeCpu(node); const memPercent = getNodeMem(node); + const memUsed = getNodeMemUsed(node); + const memTotal = getNodeMemTotal(node); const diskPercent = getNodeDisk(node); const openCordonModal = () => { @@ -308,7 +310,7 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, RAM - {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)} + {formatBytes(memUsed, 1)} / {formatBytes(memTotal, 1)}
    80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
    diff --git a/frontend/src/components/FleetView/hooks/useFleetOverview.ts b/frontend/src/components/FleetView/hooks/useFleetOverview.ts index 6f454215..6cad486b 100644 --- a/frontend/src/components/FleetView/hooks/useFleetOverview.ts +++ b/frontend/src/components/FleetView/hooks/useFleetOverview.ts @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef } from 'react'; import { apiFetch } from '@/lib/api'; import { useFleetLabels, labelPaletteKey } from './useFleetLabels'; import { useNodeLabels } from './useNodeLabels'; -import { isCritical, getNodeCpu, getNodeMem, getNodeDisk } from '../nodeUtils'; +import { isCritical, getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk } from '../nodeUtils'; import type { FleetNode, ViewMode, FleetPreferences, NodeUpdateStatus } from '../types'; interface MastheadStats { @@ -96,8 +96,8 @@ export function useFleetOverview({ prefs, updatePrefs, updateStatuses }: UseFlee const worstCpu = worstCpuNode ? { name: worstCpuNode.name, percent: getNodeCpu(worstCpuNode) } : null; - const totalMemUsed = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.used ?? 0), 0); - const totalMemTotal = onlineNodes.reduce((sum, n) => sum + (n.systemStats?.memory.total ?? 0), 0); + const totalMemUsed = onlineNodes.reduce((sum, n) => sum + getNodeMemUsed(n), 0); + const totalMemTotal = onlineNodes.reduce((sum, n) => sum + getNodeMemTotal(n), 0); return { nodeCount: nodes.length, onlineCount, diff --git a/frontend/src/components/FleetView/nodeUtils.ts b/frontend/src/components/FleetView/nodeUtils.ts index 18cd8a73..b2c3f910 100644 --- a/frontend/src/components/FleetView/nodeUtils.ts +++ b/frontend/src/components/FleetView/nodeUtils.ts @@ -5,7 +5,19 @@ export function getNodeCpu(node: FleetNode): number { } export function getNodeMem(node: FleetNode): number { - return node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; + if (!node.systemStats) return 0; + const eff = node.systemStats.memory.effectiveUsagePercent; + return parseFloat(eff ?? node.systemStats.memory.usagePercent); +} + +export function getNodeMemUsed(node: FleetNode): number { + const mem = node.systemStats?.memory; + return mem?.effectiveUsed ?? mem?.used ?? 0; +} + +export function getNodeMemTotal(node: FleetNode): number { + const mem = node.systemStats?.memory; + return mem?.effectiveTotal ?? mem?.total ?? 0; } export function getNodeDisk(node: FleetNode): number { diff --git a/frontend/src/components/FleetView/types.ts b/frontend/src/components/FleetView/types.ts index c733a44f..1c242256 100644 --- a/frontend/src/components/FleetView/types.ts +++ b/frontend/src/components/FleetView/types.ts @@ -10,7 +10,18 @@ export interface FleetNodeStats { export interface FleetNodeSystemStats { cpu: { usage: string; cores: number }; - memory: { total: number; used: number; free: number; usagePercent: string }; + memory: { + total: number; + used: number; + free: number; + usagePercent: string; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; + }; disk: { total: number; used: number; free: number; usagePercent: string } | null; } diff --git a/frontend/src/components/dashboard/HealthStatusBar.tsx b/frontend/src/components/dashboard/HealthStatusBar.tsx index 02fa0fad..1d266dc2 100644 --- a/frontend/src/components/dashboard/HealthStatusBar.tsx +++ b/frontend/src/components/dashboard/HealthStatusBar.tsx @@ -80,7 +80,7 @@ export function HealthStatusBar({ const unreadAlerts = countVisibleUnread(notifications); const running = `${stats.active}/${stats.total}`; const cpuLabel = systemStats ? `${parseFloat(systemStats.cpu.usage).toFixed(0)}%` : '--'; - const memLabel = systemStats ? formatGib(systemStats.memory.used) : '--'; + const memLabel = systemStats ? formatGib(systemStats.memory.effectiveUsed ?? systemStats.memory.used) : '--'; const lastSyncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…'; const metaLine = `${activeNodeName} · ${nodeCount} ${nodeCount === 1 ? 'node' : 'nodes'} · ${lastSyncLabel}`; const reasonsLine = reasons.join(' · '); diff --git a/frontend/src/components/dashboard/ResourceGauges.tsx b/frontend/src/components/dashboard/ResourceGauges.tsx index 04c7a429..291f7e47 100644 --- a/frontend/src/components/dashboard/ResourceGauges.tsx +++ b/frontend/src/components/dashboard/ResourceGauges.tsx @@ -49,7 +49,13 @@ function GaugeBar({ value, warn = 80, crit = 90 }: { value: number; warn?: numbe export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEndAt }: ResourceGaugesProps) { const cpuVal = parseFloat(systemStats?.cpu.usage || '0'); + // Primary gauge driven by raw ARC-adjusted percent only. Ballooned memory + // is host-reclaimed (not guest-reclaimable like ARC), so it must not mask + // real memory pressure in the gauge color or percent hero. const ramVal = parseFloat(systemStats?.memory.usagePercent || '0'); + const ramEffectivePercent = systemStats?.memory.effectiveUsagePercent ?? null; + const ramUsed = systemStats?.memory.effectiveUsed ?? systemStats?.memory.used ?? 0; + const ramTotal = systemStats?.memory.effectiveTotal ?? systemStats?.memory.total ?? 0; const diskVal = parseFloat(systemStats?.disk?.usagePercent || '0'); const cpuPeak = cpuHistory.length > 0 ? Math.max(...cpuHistory) : 0; @@ -102,8 +108,14 @@ export function ResourceGauges({ systemStats, cpuHistory, netHistory, historyEnd {systemStats ? `${ramVal.toFixed(0)}%` : '--'}
    - {systemStats ? `${formatBytes(systemStats.memory.used)} / ${formatBytes(systemStats.memory.total)}` : '\u00A0'} + {systemStats ? `${formatBytes(ramUsed)} / ${formatBytes(ramTotal)}` : '\u00A0'}
    + {systemStats?.memory.ballooned && systemStats.memory.ballooned > 0 ? ( +
    + Ballooned to host: {formatBytes(systemStats.memory.ballooned)} + {ramEffectivePercent !== null ? ` (effective ${parseFloat(ramEffectivePercent).toFixed(0)}%)` : ''} +
    + ) : null} {systemStats ? : null} diff --git a/frontend/src/components/dashboard/deriveHealth.ts b/frontend/src/components/dashboard/deriveHealth.ts index 9c4c32b8..1c338eee 100644 --- a/frontend/src/components/dashboard/deriveHealth.ts +++ b/frontend/src/components/dashboard/deriveHealth.ts @@ -10,7 +10,10 @@ export interface HealthResult { // drift apart. export function deriveHealth(stats: Stats, systemStats: SystemStats | null, notifications: NotificationItem[]): HealthResult { const cpu = parseFloat(systemStats?.cpu.usage || '0'); - const ram = parseFloat(systemStats?.memory.usagePercent || '0'); + // Ballooned memory is NOT subtracted for health: unlike ARC, ballooned + // pages are host-reclaimed and the guest cannot get them back on demand. + // A ballooned VM with real memory pressure must still show degraded/critical. + const ram = parseFloat(systemStats?.memory.usagePercent ?? '0'); const disk = parseFloat(systemStats?.disk?.usagePercent || '0'); const unreadErrors = notifications.filter(n => !n.is_read && n.level === 'error').length; diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 0b0632f2..bedae63c 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -16,6 +16,12 @@ export interface SystemStats { used: number; free: number; usagePercent: string; + ballooned?: number; + effectiveTotal?: number; + effectiveUsed?: number; + effectiveFree?: number; + effectiveUsagePercent?: string; + balloonSource?: string; }; disk: { fs: string; diff --git a/frontend/src/components/mobile/MobileDashboard.tsx b/frontend/src/components/mobile/MobileDashboard.tsx index 96936e4a..a73c0855 100644 --- a/frontend/src/components/mobile/MobileDashboard.tsx +++ b/frontend/src/components/mobile/MobileDashboard.tsx @@ -95,7 +95,7 @@ export function MobileDashboard({ notifications, headerActions, onNavigateToStac ); const cpuVal = parseFloat(data.systemStats?.cpu.usage || '0'); - const ramVal = parseFloat(data.systemStats?.memory.usagePercent || '0'); + const ramVal = parseFloat(data.systemStats?.memory.effectiveUsagePercent ?? data.systemStats?.memory.usagePercent ?? '0'); const diskVal = parseFloat(data.systemStats?.disk?.usagePercent || '0'); const netPerSec = (data.systemStats?.network?.rxSec ?? 0) + (data.systemStats?.network?.txSec ?? 0); diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index 7d363dcc..ab88abda 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -7,7 +7,7 @@ import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; import { formatBytes } from '@/lib/utils'; -import { getNodeCpu, getNodeMem, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; +import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; import type { FleetNode } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; @@ -221,7 +221,7 @@ function NodeDetail({ {node.systemStats.disk ? ( sum + (n.stacks?.length ?? 0), 0); const running = nodes.reduce((sum, n) => sum + (n.stats?.active ?? 0), 0); const avgCpu = onlineNodes.length > 0 ? onlineNodes.reduce((s, n) => s + getNodeCpu(n), 0) / onlineNodes.length : 0; - const memUsed = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.used ?? 0), 0); - const memTotal = onlineNodes.reduce((s, n) => s + (n.systemStats?.memory.total ?? 0), 0); + const memUsed = onlineNodes.reduce((s, n) => s + getNodeMemUsed(n), 0); + const memTotal = onlineNodes.reduce((s, n) => s + getNodeMemTotal(n), 0); const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : 0; const syncLabel = lastSyncAt ? `last sync ${formatAgo(now - lastSyncAt)}` : 'connecting…'; From 48c89d217d2d7e1f360c2b9a1c73d8d60de5c303 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 21:08:07 -0400 Subject: [PATCH 22/31] fix(mesh): close error-listener race in pilot tunnel reverse-dial connect (#1754) acceptReverseLocal swapped a pre-connect error handler for permanent close/error handlers inside the socket's 'connect' callback, briefly leaving 'error' with the old handler removed and the new ones not yet attached. A Node EventEmitter 'error' event with zero listeners throws instead of being swallowed, and CI's runner timing hit this window intermittently (backend-tests-pilot-tunnel-bridge-reverse- route-events.test.ts's post-handshake-close test), surfacing as an unhandled ECONNRESET exception even though every assertion passed. Replace the swap with a single 'error' listener attached at socket creation that branches on a connected flag, so the socket is never without an error listener at any point in its lifecycle. --- backend/src/services/PilotTunnelBridge.ts | 43 ++++++++++++++--------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/backend/src/services/PilotTunnelBridge.ts b/backend/src/services/PilotTunnelBridge.ts index 5a998830..04a318b1 100644 --- a/backend/src/services/PilotTunnelBridge.ts +++ b/backend/src/services/PilotTunnelBridge.ts @@ -918,23 +918,33 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle if (sendClose) this.sendJson({ t: 'tcp_close', s }); }; - // Pre-connect failure: ack-fail and drop. The handler is removed in - // 'connect' below so post-connect errors fall through to the - // mid-stream teardown path instead of double-firing. - const onPreConnectError = (err?: Error) => { - if (!this.streams.has(s)) return; - this.streams.delete(s); - meshSvc.logActivity({ - source: 'mesh', level: 'error', type: 'route.resolve.fail', - nodeId: this.nodeId, - message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`, - details: { ...baseDetails, reason: 'connect_error' }, - }); - this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' }); - }; - socket.once('error', onPreConnectError); + // One persistent 'error' listener attached at socket creation, + // branching on `connected`, instead of swapping a pre-connect handler + // for a post-connect one inside the 'connect' callback. A swap leaves + // a window (real under some schedulers, e.g. CI runners) where the + // socket briefly has zero 'error' listeners between removing the old + // one and attaching the new one; a Node EventEmitter 'error' with no + // listener throws instead of being swallowed. Keeping a single + // listener for the socket's whole lifetime removes that window + // entirely rather than narrowing it. + let connected = false; + socket.on('error', (err?: Error) => { + if (!connected) { + if (!this.streams.has(s)) return; + this.streams.delete(s); + meshSvc.logActivity({ + source: 'mesh', level: 'error', type: 'route.resolve.fail', + nodeId: this.nodeId, + message: `reverse dial failed pre-connect: ${err?.message ?? 'socket error'}`, + details: { ...baseDetails, reason: 'connect_error' }, + }); + this.sendJson({ t: 'tcp_open_ack', s, ok: false, err: 'unreachable' }); + return; + } + teardown(true); + }); socket.once('connect', () => { - socket.off('error', onPreConnectError); + connected = true; meshSvc.logActivity({ source: 'mesh', level: 'info', type: 'route.resolve.ok', nodeId: this.nodeId, @@ -960,7 +970,6 @@ export class PilotTunnelBridge extends EventEmitter implements MeshTunnelHandle this.refreshIdleTimer(s, cur); }); socket.on('close', () => teardown(true)); - socket.on('error', () => teardown(true)); }); } From 97be019696ef31a95765a21cd0a5d1caa9c1c4b6 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 21:35:50 -0400 Subject: [PATCH 23/31] feat(fleet): add Node details sheet to the node card kebab (#1752) * feat(fleet): add Node details sheet to the node card kebab Every Fleet node card now carries a "Node details" kebab item, open to any role that can see the card (previously the kebab only rendered for users with node-manage permissions, so plain viewers had none). The sheet shows connectivity, live capacity, Compose workload, version/capability compatibility, and governance info (labels, cordon reason and date, default-node flag, Compose directory, registration date) using data the Fleet page already fetches, plus one lazy call to the existing node meta endpoint for capabilities. Wired into both the desktop card and the mobile bespoke Fleet screen. * fix(fleet): correct Node details sheet timestamp units and update-status fallback QA against a live 3-node fleet found that last_successful_contact and pilot_last_seen come back from the fleet-overview endpoint in Unix seconds, but the sheet passed them straight into a milliseconds-only formatter, rendering values like "20647d ago" instead of "just now". Both are now converted before formatting. The Compatibility section's update-status badge also fell through to a confident "Up to date" whenever updateStatus was absent (e.g. on mobile, which doesn't poll update status) instead of reflecting that there was no data to back the claim; it now renders "Unknown" in that case. The local node no longer shows a misleading "Last successful contact: Never". Reworded the "read-only sheet" language in the docs page to describe the sheet accurately, since the Governance section's label picker stays editable for node managers by design. --- docs/features/fleet-view.mdx | 7 +- frontend/src/components/FleetView.tsx | 17 +- .../src/components/FleetView/NodeCard.tsx | 96 ++--- .../components/FleetView/NodeDetailsSheet.tsx | 350 ++++++++++++++++++ .../src/components/FleetView/OverviewTab.tsx | 3 + .../FleetView/__tests__/NodeCard.test.tsx | 33 +- .../__tests__/NodeDetailsSheet.test.tsx | 190 ++++++++++ .../FleetView/__tests__/OverviewTab.test.tsx | 1 + .../src/components/mobile/MobileFleet.tsx | 13 + frontend/src/context/NodeContext.tsx | 15 + 10 files changed, 676 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/FleetView/NodeDetailsSheet.tsx create mode 100644 frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 61856b69..448ecfb0 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -105,18 +105,19 @@ Every node renders as a card. The local node is pinned at the top of the grid wi Offline nodes render dimmed, with no stats grid, no usage bars, and no update affordance. -### Node actions menu (admin) +### Node actions menu -Every card carries a three-dot **Node actions** kebab in the top-right corner. The menu surfaces the same lifecycle actions you would find in **Settings · Infrastructure · Nodes**: +Every card carries a three-dot **Node actions** kebab in the top-right corner: | Action | Notes | |--------|-------| +| **Node details** | Opens an info sheet with the node's connectivity, live capacity, Compose workload, version and update compatibility, and governance info (labels, cordon reason and date, default-node status, Compose directory, registration date). Available to anyone who can see the card; the label picker inside the sheet stays editable only for whoever holds `node:manage` on that node. | | **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. | | **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. | | **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it. Existing deployments keep running. Requires the `node:manage` permission (admin, or node-admin when scoped to that node). | | **Mute** submenu | Mute node notifications, mute update notifications, mute monitor alerts for this node, or open the full mute-rule manager. Shown to whoever can manage mute rules for the node. See [Alerts & Notifications](/features/alerts-notifications). | -Edit and delete remain admin-only. Users without `node:manage`, without mute permission, and without edit/delete affordances see no kebab on the card. +Edit, delete, cordon, and mute stay gated on `node:manage` or mute permission as before. Every card shows the kebab with at least **Node details**, even for a viewer with no manage permissions. ### Topology view diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 6e104594..7585e430 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp import { FleetMasthead } from './fleet/FleetMasthead'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet'; +import { NodeDetailsSheet } from './FleetView/NodeDetailsSheet'; import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog'; import { OverviewTab } from './FleetView/OverviewTab'; import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences'; @@ -66,7 +67,7 @@ export function FleetView({ const { isAdmin, can } = useAuth(); const canManageFleet = can('node:manage'); const canExportDossier = can('node:read') && can('stack:read'); - const { hasCapability } = useNodes(); + const { hasCapability, nodes: registryNodes } = useNodes(); const { experimental, experimentalReady } = useExperimental(); const containerLabelsEnabled = hasCapability('container-label-inventory'); // Visual fail-closed while /meta loads; paid/admin gates still apply when on. @@ -94,6 +95,7 @@ export function FleetView({ }); const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes'); + const [detailsNodeId, setDetailsNodeId] = useState(null); const [internalTab, setInternalTab] = useState('overview'); const activeTab = controlledTab ?? internalTab; @@ -294,6 +296,7 @@ export function FleetView({ onEditNode={openEdit} onDeleteNode={openDelete} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + onOpenNodeDetails={setDetailsNodeId} onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} checkingUpdates={updateStatus.checkingUpdates} @@ -374,6 +377,18 @@ export function FleetView({ triggerUpdateAll={updateStatus.triggerUpdateAll} /> + { if (!open) setDetailsNodeId(null); }} + node={detailsNodeId !== null ? (overview.allNodes.find(n => n.id === detailsNodeId) ?? null) : null} + registryNode={detailsNodeId !== null ? (registryNodes.find(n => n.id === detailsNodeId) ?? null) : null} + updateStatus={detailsNodeId !== null ? overview.updateStatusMap.get(detailsNodeId) : undefined} + networkingSignal={detailsNodeId !== null ? overview.networkingByNode.get(detailsNodeId) : undefined} + canManageNode={detailsNodeId !== null && can('node:manage', 'node', String(detailsNodeId))} + onOpenNetworking={onOpenNodeNetworking} + onEdit={openEdit} + /> + void; onDelete?: (node: Node) => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + /** Opens the read-only Node details sheet. Available to any role that can see the card. */ + onOpenDetails?: (nodeId: number) => void; } // --- Sub-Components --- @@ -67,7 +70,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) { // --- Main Export --- -export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) { +export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill, onOpenDetails }: NodeCardProps) { const [expanded, setExpanded] = useState(false); const [stacks, setStacks] = useState(node.stacks); const [loadingStacks, setLoadingStacks] = useState(false); @@ -89,7 +92,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, node.name, onOpenMuteRulesWithPrefill ?? (() => {}), ); - const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); + // "Node details" is always available to anyone who can see the card (same + // node:read gate that already governs Fleet card visibility), so the kebab + // itself is no longer conditional. This flag now only decides whether the + // manage items (which stay node:manage-gated) render below the separator. + const hasManageMenuItems = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill)); const isOnline = node.status === 'online'; const isLocal = node.type === 'local'; @@ -158,49 +165,54 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, {/* Card Header */}
    {isLocal && ( - + ★ Local )} - {showMenu && ( -
    - - - + + + {onOpenDetails && ( + onOpenDetails(node.id)}> + + Node details + + )} + {onOpenDetails && hasManageMenuItems && } + {canEdit && registryNode && ( + onEdit!(registryNode)}> + + Edit node + + )} + {canDelete && registryNode && ( + onDelete!(registryNode)} + className="text-destructive focus:text-destructive" > - - - - - {canEdit && registryNode && ( - onEdit!(registryNode)}> - - Edit node - - )} - {canDelete && registryNode && ( - onDelete!(registryNode)} - className="text-destructive focus:text-destructive" - > - - Delete node - - )} - {canCordon && ( - - - {node.cordoned ? 'Uncordon node' : 'Cordon node'} - - )} - {onOpenMuteRulesWithPrefill && } - - -
    - )} + + Delete node + + )} + {canCordon && ( + + + {node.cordoned ? 'Uncordon node' : 'Cordon node'} + + )} + {onOpenMuteRulesWithPrefill && } + + +
    diff --git a/frontend/src/components/FleetView/NodeDetailsSheet.tsx b/frontend/src/components/FleetView/NodeDetailsSheet.tsx new file mode 100644 index 00000000..f9090269 --- /dev/null +++ b/frontend/src/components/FleetView/NodeDetailsSheet.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState, type ReactNode } from 'react'; +import { Cpu, MemoryStick, HardDrive, Globe, Monitor, Terminal, Ban, Pencil, KeyRound, Network } from 'lucide-react'; +import { SystemSheet, SheetSection } from '@/components/ui/system-sheet'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NodeLabelPicker } from '@/components/blueprints/NodeLabelPicker'; +import { useNodes, type Node } from '@/context/NodeContext'; +import { formatVersion } from '@/lib/version'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import { formatBytes } from '@/lib/utils'; +import { PinnedUpdateBadge } from './PinnedUpdateBadge'; +import type { FleetNode, NodeUpdateStatus } from './types'; + +interface NodeDetailsSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + node: FleetNode | null; + registryNode: Node | null; + updateStatus?: NodeUpdateStatus; + networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean }; + canManageNode: boolean; + onOpenNetworking?: (nodeId: number) => void; + onEdit?: (node: Node) => void; +} + +// A small nice-to-have translation for the most operator-relevant capability +// strings; anything not listed here just renders its raw identifier. +const CAPABILITY_LABELS: Partial> = { + 'cross-node-rbac': 'Cross-node RBAC', + 'self-update': 'Self-update', + 'fleet': 'Fleet management', + 'compose-networking': 'Networking inventory', +}; + +function formatTimestamp(ms: number): string { + return new Date(ms).toLocaleString(); +} + +// `FleetNode.last_successful_contact` and `FleetNode.pilot_last_seen` come from +// the fleet-overview endpoint in Unix SECONDS (DatabaseService.updateNodeLastContact +// writes Math.floor(Date.now()/1000); fleet.ts's pilotLastSeenSeconds() divides the +// millisecond DB value by 1000 for this same response). `formatTimeAgo`/`formatTimestamp` +// both expect milliseconds, so any FleetNode-sourced timestamp must convert here before +// use. `registryNode`-sourced timestamps (e.g. pilot_last_seen from /api/nodes) are +// already in milliseconds and must NOT be passed through this helper. +function fleetSecondsToMs(seconds: number): number { + return seconds * 1000; +} + +function UsageBar({ percent, color }: { percent: number; color: string }) { + return ( +
    +
    +
    + ); +} + +function Field({ label, children, span }: { label: string; children: ReactNode; span?: 1 | 2 }) { + return ( +
    + {label} + {children} +
    + ); +} + +export function NodeDetailsSheet({ + open, onOpenChange, node, registryNode, updateStatus, networkingSignal, + canManageNode, onOpenNetworking, onEdit, +}: NodeDetailsSheetProps) { + const { nodeMeta, refreshNodeMeta } = useNodes(); + const [capabilitiesExpanded, setCapabilitiesExpanded] = useState(false); + const nodeId = node?.id ?? null; + + useEffect(() => { + if (open && nodeId !== null) void refreshNodeMeta(nodeId); + }, [open, nodeId, refreshNodeMeta]); + + useEffect(() => { + if (!open) setCapabilitiesExpanded(false); + }, [open]); + + if (!node) return null; + + const meta = nodeMeta.get(node.id) ?? null; + const isLocal = node.type === 'local'; + const isPilot = registryNode?.mode === 'pilot_agent'; + const connectionModeLabel = isLocal ? 'Local' : isPilot ? 'Pilot Agent' : 'API Proxy'; + const versionLabel = formatVersion(updateStatus?.version ?? meta?.version ?? null); + const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0; + const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0; + const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0; + const hasNetworkingSignal = Boolean( + networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift), + ); + + const metaLine = [ + connectionModeLabel, + node.status === 'online' ? 'Online' : node.status === 'offline' ? 'Offline' : 'Unknown', + versionLabel, + node.stacks ? `${node.stacks.length} stack${node.stacks.length === 1 ? '' : 's'}` : null, + ].filter(Boolean).join(' · '); + + const footerContext = node.status === 'online' + ? 'Live · refreshes with the fleet overview' + : node.last_successful_contact + ? `Last seen ${formatTimeAgo(fleetSecondsToMs(node.last_successful_contact))}` + : 'Never contacted'; + + return ( + onEdit(registryNode), + } : undefined} + secondaryActions={onOpenNetworking && hasNetworkingSignal ? [{ + label: 'View networking', + icon: Network, + onClick: () => onOpenNetworking(node.id), + }] : undefined} + footerContext={footerContext} + size="md" + > + +
    + +

    + {isLocal ? : isPilot ? : } + {connectionModeLabel} +

    +
    + +

    + {isLocal + ? 'docker.sock' + : isPilot + ? (registryNode?.pilot_last_seen ? `Tunnel (seen ${formatTimeAgo(registryNode.pilot_last_seen)})` : 'Tunnel (waiting)') + : (registryNode?.api_url || '-')} +

    +
    + {typeof node.latency_ms === 'number' && ( + +

    {node.latency_ms} ms

    +
    + )} + {!isLocal && ( + +

    + {node.last_successful_contact ? formatTimeAgo(fleetSecondsToMs(node.last_successful_contact)) : 'Never'} +

    +
    + )} + {isPilot && ( + <> + +

    + {node.pilot_last_seen ? formatTimeAgo(fleetSecondsToMs(node.pilot_last_seen)) : 'Never'} +

    +
    + +

    {formatVersion(registryNode?.pilot_agent_version) ?? 'Unknown'}

    +
    + + )} + +

    + + + {registryNode?.has_token ? 'Yes' : 'No'} + +

    +
    +
    +
    + + + {node.systemStats ? ( +
    +
    +
    + + CPU · {node.systemStats.cpu.cores} cores + + {node.systemStats.cpu.usage}% +
    + 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} /> +
    +
    +
    + + Memory + + {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)} +
    + 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} /> +
    + {node.systemStats.disk && ( +
    +
    + + Disk + + {formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)} +
    + 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} /> +
    + )} +
    + ) : ( +

    Unavailable while the node is offline.

    + )} +
    + + + {node.stats ? ( +
    +
    +
    {node.stats.active}
    +
    Running
    +
    +
    +
    {node.stats.exited}
    +
    Stopped
    +
    +
    +
    {node.stats.managed}
    +
    Managed
    +
    +
    +
    {node.stats.unmanaged}
    +
    Unmanaged
    +
    +
    + ) : ( +

    Unavailable while the node is offline.

    + )} + {onOpenNetworking && hasNetworkingSignal && networkingSignal && ( + onOpenNetworking(node.id)} + > + Networking · {networkingSignal.drift ? 'drift' : networkingSignal.exposed ? 'exposed' : 'unknown exposure'} + + )} +
    + + +
    + +

    {versionLabel ?? 'Unknown'}

    +
    + +

    {updateStatus?.imageChannel ?? 'Unknown'}

    +
    + +

    {updateStatus?.imagePinKind ?? 'Unknown'}

    +
    + +

    + {!updateStatus ? ( + Unknown + ) : updateStatus.updateBlocked ? ( + + ) : updateStatus.updateAvailable ? ( + Update available + ) : ( + Up to date + )} +

    +
    +
    + {meta ? ( +
    + + {capabilitiesExpanded && ( +
      + {meta.capabilities.map(c => ( +
    • + {CAPABILITY_LABELS[c] ?? c} +
    • + ))} +
    + )} +
    + ) : ( + + )} +
    + + +
    +
    + Labels +
    + +
    +
    +
    + +

    + {node.cordoned ? ( + + Cordoned + + ) : ( + Schedulable + )} +

    +
    + {node.cordoned && ( + <> + +

    {node.cordoned_at ? formatTimestamp(node.cordoned_at) : 'Unknown'}

    +
    + +

    {node.cordoned_reason ?? 'No reason given'}

    +
    + + )} + +

    {registryNode?.is_default ? 'Yes' : 'No'}

    +
    + +

    {registryNode?.compose_dir ?? '-'}

    +
    + +

    {registryNode?.created_at ? formatTimestamp(registryNode.created_at) : 'Unknown'}

    +
    +
    +
    +
    +
    + ); +} diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 46465953..72c6f4c8 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -39,6 +39,7 @@ interface OverviewTabProps { onEditNode?: (node: Node) => void; onDeleteNode?: (node: Node) => void; onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; + onOpenNodeDetails: (nodeId: number) => void; topologyMode: LayoutMode; onTopologyModeChange: (mode: LayoutMode) => void; topologyPositions: SavedPositions; @@ -77,6 +78,7 @@ export function OverviewTab({ onEditNode, onDeleteNode, onOpenMuteRulesWithPrefill, + onOpenNodeDetails, topologyMode, onTopologyModeChange, topologyPositions, @@ -159,6 +161,7 @@ export function OverviewTab({ onEdit={onEditNode} onDelete={onDeleteNode} onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill} + onOpenDetails={onOpenNodeDetails} /> ))}
    diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index b771228c..fa32cc88 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -28,7 +28,7 @@ function offlineNode(): FleetNode { } function baseProps(node: FleetNode) { - return { node, onNavigate: vi.fn() }; + return { node, onNavigate: vi.fn(), onOpenDetails: vi.fn() }; } beforeEach(() => { @@ -76,10 +76,37 @@ describe('NodeCard', () => { expect(screen.getByText('Delete node')).toBeInTheDocument(); }); - it('hides the cordon control from a user lacking node:manage', () => { + it('shows only Node details to a user lacking node:manage', async () => { useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); render(); - expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + expect(await screen.findByText('Node details')).toBeInTheDocument(); + expect(screen.queryByText('Cordon node')).not.toBeInTheDocument(); + expect(screen.queryByText('Edit node')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete node')).not.toBeInTheDocument(); + }); + + it('calls onOpenDetails with the node id when Node details is clicked', async () => { + const onOpenDetails = vi.fn(); + useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + await userEvent.click(await screen.findByText('Node details')); + expect(onOpenDetails).toHaveBeenCalledWith(2); + }); + + it('shows Node details ahead of the manage items for a node:manage user', async () => { + const can = vi.fn((action: string) => action === 'node:manage'); + useAuthMock.mockReturnValue({ isAdmin: false, can }); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'Node actions' })); + const menuItems = await screen.findAllByRole('menuitem'); + const labels = menuItems.map(item => item.textContent); + expect(labels[0]).toBe('Node details'); + expect(labels).toContain('Cordon node'); }); it('shows Uncordon when the node is already cordoned', async () => { diff --git a/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx new file mode 100644 index 00000000..8aa9adf4 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx @@ -0,0 +1,190 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const useNodesMock = vi.fn(); +vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() })); + +// NodeLabelPicker is a fully self-fetching reused unit (its own tests cover its +// behavior); shallow-mock it here so this file stays focused on the sheet. +vi.mock('@/components/blueprints/NodeLabelPicker', () => ({ + NodeLabelPicker: ({ nodeId, canEdit }: { nodeId: number; canEdit: boolean }) => ( +
    labels for {nodeId} · editable={String(canEdit)}
    + ), +})); + +import { NodeDetailsSheet } from '../NodeDetailsSheet'; +import type { FleetNode, NodeUpdateStatus } from '../types'; +import type { Node } from '@/context/NodeContext'; + +// FleetNode's last_successful_contact/pilot_last_seen come from the +// fleet-overview endpoint in Unix SECONDS (see fleetSecondsToMs's comment in +// the component) — these fixtures must use seconds, not milliseconds, or a +// bug in the component's unit handling would go undetected here. +function fleetNode(overrides: Partial = {}): FleetNode { + return { + id: 2, + name: 'Edge', + type: 'remote', + mode: 'proxy', + status: 'online', + stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 }, + systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } }, + stacks: ['web'], + cordoned: false, + cordoned_at: null, + cordoned_reason: null, + latency_ms: 42, + last_successful_contact: Math.floor(Date.now() / 1000) - 5, + ...overrides, + }; +} + +function registryNode(overrides: Partial = {}): Node { + return { + id: 2, + name: 'Edge', + type: 'remote', + mode: 'proxy', + compose_dir: '/srv/compose', + is_default: false, + status: 'online', + created_at: Date.UTC(2026, 0, 1), + api_url: 'https://edge.internal:1852', + has_token: true, + ...overrides, + }; +} + +const UPDATE_STATUS: NodeUpdateStatus = { + nodeId: 2, name: 'Edge', type: 'remote', version: '1.2.0', latestVersion: '1.2.0', + updateAvailable: false, updateStatus: null, imageChannel: 'community', imagePinKind: 'semver', +}; + +function baseProps(overrides: Partial> = {}) { + return { + open: true, + onOpenChange: vi.fn(), + node: fleetNode(), + registryNode: registryNode(), + updateStatus: UPDATE_STATUS, + networkingSignal: { exposed: false, unknown: false, drift: false }, + canManageNode: false, + onOpenNetworking: vi.fn(), + onEdit: vi.fn(), + ...overrides, + }; +} + +beforeEach(() => { + useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() }); +}); +afterEach(() => vi.clearAllMocks()); + +describe('NodeDetailsSheet', () => { + it('renders all sections from the node, registry, and update-status data', () => { + render(); + expect(screen.getByRole('heading', { name: 'Edge' })).toBeInTheDocument(); + expect(screen.getByText('Connectivity')).toBeInTheDocument(); + expect(screen.getByText('Capacity')).toBeInTheDocument(); + expect(screen.getByText(/Compose workload/)).toBeInTheDocument(); + expect(screen.getByText('Compatibility')).toBeInTheDocument(); + expect(screen.getByText('Governance')).toBeInTheDocument(); + expect(screen.getByText('42 ms')).toBeInTheDocument(); + expect(screen.getByTestId('node-label-picker')).toHaveTextContent('labels for 2 · editable=false'); + }); + + it('renders cordon reason and date as visible text, not tooltip-only', () => { + render( + , + ); + expect(screen.getByText('Host maintenance')).toBeInTheDocument(); + expect(screen.getByText(new Date(Date.UTC(2026, 6, 1)).toLocaleString())).toBeInTheDocument(); + }); + + it('shows token-configured as a yes/no badge and never renders a raw token value', () => { + render(); + expect(screen.getByText('Token configured')).toBeInTheDocument(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.queryByText(/eyJ|Bearer /)).not.toBeInTheDocument(); + }); + + it('reuses the existing networking handler instead of rendering networking detail inline', async () => { + const onOpenNetworking = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const badge = screen.getByText(/Networking/); + await user.click(badge); + expect(onOpenNetworking).toHaveBeenCalledWith(2); + // No inline network detail (IPAM, subnet, etc.) is rendered by this sheet. + expect(screen.queryByText(/subnet/i)).not.toBeInTheDocument(); + }); + + it('shows a skeleton for capabilities until nodeMeta resolves, then renders the count', () => { + useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() }); + const { rerender } = render(); + expect(screen.queryByText(/capabilities advertised/)).not.toBeInTheDocument(); + + useNodesMock.mockReturnValue({ + nodeMeta: new Map([[2, { version: '1.2.0', capabilities: ['fleet', 'self-update'], fetchedAt: Date.now() }]]), + refreshNodeMeta: vi.fn(), + }); + rerender(); + expect(screen.getByText('2 capabilities advertised (show)')).toBeInTheDocument(); + }); + + it('returns null when no node is selected', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('converts FleetNode seconds-based timestamps correctly, not decades off', () => { + render( + , + ); + // "just now" appears for both Last successful contact and Pilot heartbeat. + // If the seconds value were passed straight to formatTimeAgo (which expects + // ms), this would instead render something like "20647d ago". + expect(screen.getAllByText('just now').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/d ago/)).not.toBeInTheDocument(); + }); + + it('omits Last successful contact for the local node instead of showing Never', () => { + render(); + expect(screen.queryByText('Last successful contact')).not.toBeInTheDocument(); + expect(screen.queryByText('Never')).not.toBeInTheDocument(); + }); + + it('renders Update status as Unknown, never a confident Up to date, when updateStatus is absent', () => { + render(); + const updateStatusLabel = screen.getByText('Update status'); + const updateStatusField = updateStatusLabel.parentElement as HTMLElement; + expect(within(updateStatusField).getByText('Unknown')).toBeInTheDocument(); + expect(screen.queryByText('Up to date')).not.toBeInTheDocument(); + }); + + it('still renders Up to date when updateStatus confirms no update is available', () => { + render(); + expect(screen.getByText('Up to date')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index b9448b09..742c0040 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -37,6 +37,7 @@ function props(overrides: Partial> = {} onNavigateToNode: vi.fn(), onOpenNodeNetworking: vi.fn(), networkingByNode: new Map(), + onOpenNodeDetails: vi.fn(), updatingNodeId: null, topologyMode: 'hub' as const, onTopologyModeChange: vi.fn(), diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index ab88abda..6eefb642 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -8,6 +8,7 @@ import { toast } from '@/components/ui/toast-store'; import { ConfirmModal } from '@/components/ui/modal'; import { formatBytes } from '@/lib/utils'; import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils'; +import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet'; import type { FleetNode } from '@/components/FleetView/types'; import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui'; import type { Tone as UiTone } from './mobile-ui'; @@ -159,9 +160,12 @@ function NodeDetail({ onCordonChange: () => void; }) { const { can } = useAuth(); + const { nodes: registryNodes } = useNodes(); + const registryNode = registryNodes.find(n => n.id === node.id) ?? null; const canCordon = can('node:manage', 'node', String(node.id)); const [confirmOpen, setConfirmOpen] = useState(false); const [submitting, setSubmitting] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); const tone = nodeTone(node); const online = node.status === 'online'; @@ -207,6 +211,7 @@ function NodeDetail({
    onInspectNode(node.id)}>Inspect + setDetailsOpen(true)}>Details {canCordon ? ( setConfirmOpen(true)}> {node.cordoned ? 'Uncordon' : 'Drain'} @@ -271,6 +276,14 @@ function NodeDetail({ confirming={submitting} onConfirm={handleCordon} /> + +
    ); } diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 80397e45..2063c379 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -22,10 +22,19 @@ export interface Node { pilot_agent_version?: string | null; } +export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown'; + export interface NodeMeta { version: string | null; capabilities: string[]; fetchedAt: number; + /** Remote-only fields below; absent (undefined) for local nodes' /meta response. */ + startedAt?: number | null; + updateError?: string | null; + online?: boolean; + imagePinKind?: ImagePinKind | null; + updateBlocked?: boolean; + imageChannel?: 'community' | 'hardened' | 'unknown' | null; } interface NodeContextType { @@ -95,6 +104,12 @@ export function NodeProvider({ children }: { children: React.ReactNode }) { version: data.version ?? null, capabilities: Array.isArray(data.capabilities) ? data.capabilities : [], fetchedAt: Date.now(), + startedAt: data.startedAt ?? null, + updateError: data.updateError ?? null, + online: data.online, + imagePinKind: data.imagePinKind ?? null, + updateBlocked: data.updateBlocked, + imageChannel: data.imageChannel ?? null, }); } else { // A non-OK response (proxy error, auth, 5xx) is a resolved failure: record an From 41bf075eb06c2991eaaab670cffc91698a8c042b Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 21:55:22 -0400 Subject: [PATCH 24/31] feat(recovery): make rollback-recovery image lifecycle visible and controllable (#1753) * feat(recovery): make rollback-recovery image lifecycle visible and controllable GitHub discussion #1751 asked why Sencho creates sencho-rb//:hold images during automatic updates and how to clean them up. That surfaced a real safety bug alongside the missing visibility: the manual single-image delete route did not consult the held-image predicate every other deletion path already honors, so a user could delete a rollback-protected image straight through the Images tab and silently break automatic recovery for that update. A short/truncated id also bypassed the predicate's full-id lookup. Fixes: - POST /images/delete now resolves the submitted id to its canonical form and checks the unified held-image predicate before deleting, returning 409 IMAGE_HELD_FOR_ROLLBACK for a protected image. - The Images tab no longer mislabels a protected image as plain "Unused"; a fully-synthetic hold image is kept out of the generic inventory entirely and surfaced instead in a new Resources -> Rollback tab, with an additive "Rollback protected" badge for images that still carry a normal tag too. New capability: - Two settings (Deploy Guardrails): superseded-generation retention (days, replaces a hardcoded 7) and a cap on retained generations per stack. - A new Resources -> Rollback tab lists every generation (stack, short id, state, retention) with an admin-gated manual release action, including releasing the current generation with an explicit warning that automatic rollback becomes unavailable until the next successful update. Release is a single atomic, server-revalidated transition so a stale UI read can never release a row that has since become ineligible. Also consolidated three near-duplicate implementations of the held-image predicate (two of which relied on a require() of a sibling .ts file that silently failed to resolve under the test runner and was never actually exercised by a real test before this change) into one shared module. Known follow-up, not fixed here: an orphaned sencho-rb tag whose recovery row no longer exists (DB restore, node re-add) is invisible in both the Images and Rollback tabs with no UI path to reclaim it. * fix(audit): add summary mapping for rollback generation release * fix(security): sanitize prune target in log sinks and cover release RBAC Closes two open js/log-injection findings on the system prune route by applying the same inline sanitizeForLog barrier the rest of the file already uses. The prune target is validated against an enum by parsePruneTargets before reaching these sinks, so the findings were false positives, but the barrier is cheap and removes the standing alerts on a file this change already touches. Also wraps the generation id in the release log line for consistency with the stack name beside it. Adds coverage for gaps a QA pass identified: - Release endpoint refuses a viewer and a deployer (Admin-only), leaving the generation and its artifacts untouched. - Viewer can still read the generations list, matching the sibling Resources routes. - The predicate the prune routes build reports full-stack rollback holds, not just service-scoped ones, and re-reads per call so a hold taken between plan and delete still gates the delete. - After releasing the current generation, no rollback point is claimed for the stack through any consumer of the current-generation lookup. --- backend/src/__tests__/compose-service.test.ts | 5 +- .../deployed-stack-deletion-service.test.ts | 2 + .../src/__tests__/docker-controller.test.ts | 127 +++++ .../__tests__/recovery-held-images.test.ts | 83 ++++ .../rollback-generation-lifecycle.test.ts | 455 ++++++++++++++++++ backend/src/__tests__/settings-routes.test.ts | 82 ++++ .../stack-update-recovery-service.test.ts | 6 + .../system-maintenance-self-protect.test.ts | 76 +++ backend/src/routes/settings.ts | 4 + backend/src/routes/systemMaintenance.ts | 98 +++- backend/src/services/ComposeService.ts | 3 +- backend/src/services/DatabaseService.ts | 102 +++- backend/src/services/DockerController.ts | 83 +++- backend/src/services/NotificationService.ts | 5 +- .../services/ServiceUpdateRecoveryService.ts | 24 +- .../services/StackUpdateRecoveryService.ts | 137 +++++- backend/src/services/recoveryHeldImages.ts | 18 + backend/src/utils/audit-summaries.ts | 1 + docs/features/health-gated-updates.mdx | 21 + frontend/src/components/ResourcesView.tsx | 70 ++- .../__tests__/ResourcesView.test.tsx | 37 ++ frontend/src/components/dashboard/types.ts | 1 + .../resources/RollbackGenerationsTab.tsx | 205 ++++++++ .../components/resources/TableSkeleton.tsx | 20 + .../__tests__/RollbackGenerationsTab.test.tsx | 120 +++++ .../src/components/settings/StacksSection.tsx | 30 +- .../__tests__/SectionSavePayloads.test.tsx | 2 + frontend/src/components/settings/registry.ts | 2 +- frontend/src/components/settings/types.ts | 4 + .../stack/StackActivityTimeline.tsx | 3 +- frontend/src/lib/notificationCategories.ts | 1 + 31 files changed, 1734 insertions(+), 93 deletions(-) create mode 100644 backend/src/__tests__/recovery-held-images.test.ts create mode 100644 backend/src/__tests__/rollback-generation-lifecycle.test.ts create mode 100644 backend/src/services/recoveryHeldImages.ts create mode 100644 frontend/src/components/resources/RollbackGenerationsTab.tsx create mode 100644 frontend/src/components/resources/TableSkeleton.tsx create mode 100644 frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 61d271cc..c71880db 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -181,6 +181,10 @@ vi.mock('../services/MeshService', () => ({ }, })); +vi.mock('../services/recoveryHeldImages', () => ({ + buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate, +})); + vi.mock('../services/StackUpdateRecoveryService', () => ({ StackUpdateRecoveryService: { getInstance: () => ({ @@ -191,7 +195,6 @@ vi.mock('../services/StackUpdateRecoveryService', () => ({ markImmediateVerified: mockMarkImmediateVerified, abandon: mockAbandon, compensateWithCandidate: mockCompensateWithCandidate, - buildUnifiedHeldImagePredicate: mockBuildUnifiedHeldImagePredicate, get: mockGetRecovery, linkGateOrRetain: vi.fn(), }), diff --git a/backend/src/__tests__/deployed-stack-deletion-service.test.ts b/backend/src/__tests__/deployed-stack-deletion-service.test.ts index d8dfc0b4..c156ed15 100644 --- a/backend/src/__tests__/deployed-stack-deletion-service.test.ts +++ b/backend/src/__tests__/deployed-stack-deletion-service.test.ts @@ -60,6 +60,8 @@ describe('DeployedStackDeletionService ready transaction', () => { updated_at: now, created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; db().insertStackUpdateRecoveryGeneration(gen); const svc: ServiceUpdateRecoveryRow = { diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index 67591035..bb9fdd79 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -60,6 +60,8 @@ vi.mock('util', () => ({ import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController'; import { CacheService } from '../services/CacheService'; +import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import fs from 'fs/promises'; import os from 'os'; import path from 'path'; @@ -529,6 +531,9 @@ describe('DockerController - getClassifiedResources', () => { beforeEach(() => { CacheService.getInstance().invalidate('project-name-map'); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it('classifies managed and unmanaged images', async () => { mockDocker.listImages.mockResolvedValue([ @@ -616,6 +621,94 @@ describe('DockerController - getClassifiedResources', () => { expect(result.volumes.find(v => v.Name === 'my-stack_data')!.managedStatus).toBe('managed'); expect(result.volumes.find(v => v.Name === 'random_vol')!.managedStatus).toBe('unmanaged'); }); + + it('excludes an image whose only tag is a synthetic sencho-rb rollback hold', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-hold-only', RepoTags: ['sencho-rb/abc123456789/web:hold'], Size: 50, Containers: 0 }, + { Id: 'img-normal', RepoTags: ['nginx:latest'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + expect(result.images.find(i => i.Id === 'img-hold-only')).toBeUndefined(); + expect(result.images.find(i => i.Id === 'img-normal')).toBeDefined(); + }); + + it('keeps an image visible when it carries both a normal tag and a sencho-rb hold tag', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-multi-tag', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-multi-tag'); + expect(img).toBeDefined(); + expect(img!.RepoTags).toEqual(['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold']); + }); + + it('marks an image rollbackProtected with kind "stack" when StackUpdateRecoveryService holds it', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-stack-held', RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-stack-held'); + expect(img?.rollbackProtected).toBe(true); + expect(img?.rollbackProtectionKind).toBe('stack'); + }); + + it('marks an image rollbackProtected with kind "service" when only ServiceUpdateRecoveryService holds it', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-service-held', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['img-service-held'])); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-service-held'); + expect(img?.rollbackProtected).toBe(true); + expect(img?.rollbackProtectionKind).toBe('service'); + }); + + it('fails closed (marks every image rollbackProtected) when a held-image lookup fails', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-unrelated', RepoTags: ['myregistry/app:1.4'], Size: 100, Containers: 0 }, + ]); + mockDocker.listContainers.mockResolvedValue([]); + mockDocker.listVolumes.mockResolvedValue({ Volumes: [] }); + mockDocker.listNetworks.mockResolvedValue([]); + // getHeldImageIds returns null when its own DB lookup fails (already logs + // internally); the badge must fail the same direction as the delete guard + // (recoveryHeldImages.ts), not the opposite. + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const dc = DockerController.getInstance(1); + const result = await dc.getClassifiedResources(['my-stack']); + + const img = result.images.find(i => i.Id === 'img-unrelated'); + expect(img?.rollbackProtected).toBe(true); + }); }); // ── pruneManagedOnly / estimateManagedReclaim (images) ───────────────── @@ -1102,6 +1195,40 @@ describe('DockerController - inspectImage', () => { }); }); +// --- resolveImageId -------------------------------------------------------------- + +describe('DockerController - resolveImageId', () => { + it('returns the canonical full Id from docker.getImage(id).inspect()', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockResolvedValue({ Id: 'sha256:' + 'a'.repeat(64) }), + }); + + const dc = DockerController.getInstance(1); + const result = await dc.resolveImageId('a'.repeat(12)); + + expect(result).toBe('sha256:' + 'a'.repeat(64)); + expect(mockDocker.getImage).toHaveBeenCalledWith('a'.repeat(12)); + }); + + it('returns null on a 404 from Docker', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(Object.assign(new Error('No such image'), { statusCode: 404 })), + }); + + const dc = DockerController.getInstance(1); + expect(await dc.resolveImageId('missing')).toBeNull(); + }); + + it('rethrows a non-404 Docker error', async () => { + mockDocker.getImage.mockReturnValue({ + inspect: vi.fn().mockRejectedValue(Object.assign(new Error('docker daemon unreachable'), { statusCode: 500 })), + }); + + const dc = DockerController.getInstance(1); + await expect(dc.resolveImageId('sha256:abc')).rejects.toThrow('docker daemon unreachable'); + }); +}); + // --- label / image inspection for the label inventory -------------------------- describe('DockerController - inspectImageLabels', () => { diff --git a/backend/src/__tests__/recovery-held-images.test.ts b/backend/src/__tests__/recovery-held-images.test.ts new file mode 100644 index 00000000..d64191ad --- /dev/null +++ b/backend/src/__tests__/recovery-held-images.test.ts @@ -0,0 +1,83 @@ +/** + * Unit tests for the unified held-image predicate's fail-closed composition: + * a lookup failure on either underlying service must protect every image, + * not just the ones the other service happens to hold. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { buildUnifiedHeldImagePredicate } from '../services/recoveryHeldImages'; +import { StackUpdateRecoveryService } from '../services/StackUpdateRecoveryService'; +import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('buildUnifiedHeldImagePredicate', () => { + it('holds an image present in either service\'s held set', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:service-held'])); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:stack-held')).toBe(true); + expect(predicate('sha256:service-held')).toBe(true); + expect(predicate('sha256:unrelated')).toBe(false); + }); + + it('fails closed (protects every image) when StackUpdateRecoveryService.getHeldImageIds returns null', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); + + it('fails closed (protects every image) when ServiceUpdateRecoveryService.getHeldImageIds returns null', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + + const predicate = buildUnifiedHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); +}); + +/** + * The prune routes (/system/prune/plan, /system/prune/system) build their + * predicate via ServiceUpdateRecoveryService.buildHeldImagePredicate, not the + * module function directly. That method delegates to the shared module, so a + * full-stack rollback hold must gate prune too, not just service-scoped holds. + */ +describe('ServiceUpdateRecoveryService.buildHeldImagePredicate (the prune-path entry point)', () => { + it('protects a full-stack rollback hold, not just service-scoped holds', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set(['sha256:stack-held'])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + + expect(predicate('sha256:stack-held')).toBe(true); + expect(predicate('sha256:unrelated')).toBe(false); + }); + + it('re-reads the held set on every call so a hold taken after plan time still gates the delete', () => { + const stackSpy = vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + expect(predicate('sha256:late-hold')).toBe(false); + + // A generation is captured between plan and delete. + stackSpy.mockReturnValue(new Set(['sha256:late-hold'])); + expect(predicate('sha256:late-hold')).toBe(true); + }); + + it('fails closed on the prune path when a held lookup fails', () => { + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(null); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const predicate = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(1); + + expect(predicate('sha256:anything')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/rollback-generation-lifecycle.test.ts b/backend/src/__tests__/rollback-generation-lifecycle.test.ts new file mode 100644 index 00000000..bfd4045d --- /dev/null +++ b/backend/src/__tests__/rollback-generation-lifecycle.test.ts @@ -0,0 +1,455 @@ +/** + * Real-DB tests for the rollback-generation retention/cap/release lifecycle: + * DatabaseService's retention/cap/release SQL, StackUpdateRecoveryService's + * cap enforcement and releaseGeneration orchestration, and the + * GET/POST /api/system/rollback/generations routes. Docker is stubbed + * (no real daemon); the DB is real via setupTestDb() so the SQL under test + * (atomic release UPDATE, retention/cap queries) runs for real. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { randomUUID } from 'crypto'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; +import type { StackUpdateRecoveryGenerationRow, HealthGateRunRow } from '../services/DatabaseService'; + +let tmpDir: string; +let app: import('express').Express; +let authHeader: string; +let viewerCookie: string; +let deployerCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let StackUpdateRecoveryService: typeof import('../services/StackUpdateRecoveryService').StackUpdateRecoveryService; +let DockerController: typeof import('../services/DockerController').default; + +const mockRemove = vi.fn().mockResolvedValue(undefined); +const mockGetImage = vi.fn(() => ({ remove: mockRemove })); + +const NODE = 1; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ StackUpdateRecoveryService } = await import('../services/StackUpdateRecoveryService')); + ({ default: DockerController } = await import('../services/DockerController')); + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; + + // Non-admin personas for the RBAC block below. Release is requireAdmin + // (a host-destructive Docker operation); the list endpoint is stack:read. + const db = DatabaseService.getInstance(); + for (const [role, pw] of [['viewer', 'vwpass'], ['deployer', 'dppass']] as const) { + const hash = await bcrypt.hash(pw, 1); + db.addUser({ username: `rb-${role}`, password_hash: hash, role }); + const res = await request(app).post('/api/auth/login').send({ username: `rb-${role}`, password: pw }); + const cookies = res.headers['set-cookie'] as string | string[]; + const c = Array.isArray(cookies) ? cookies[0] : cookies; + if (role === 'viewer') viewerCookie = c; + else deployerCookie = c; + } +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + mockRemove.mockClear().mockResolvedValue(undefined); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDocker: () => ({ getImage: mockGetImage }), + } as unknown as ReturnType); +}); + +afterEach(() => { + vi.restoreAllMocks(); + const db = DatabaseService.getInstance(); + // Restore the shipped defaults so a test that tunes retention/cap does not + // leak that value into the next one. + db.updateGlobalSetting('recovery_retention_days', '7'); + db.updateGlobalSetting('recovery_max_generations', '0'); + db.getDb().prepare('DELETE FROM stack_update_recovery_generations').run(); + db.getDb().prepare('DELETE FROM health_gate_runs').run(); +}); + +function makeRow(overrides: Partial = {}): StackUpdateRecoveryGenerationRow { + const id = overrides.id ?? randomUUID(); + const now = Date.now(); + return { + id, + node_id: NODE, + stack_name: 'my-stack', + status: 'active', + phase: 'immediate_verified', + is_current: 1, + backup_slot_id: null, + override_path: null, + services_json: JSON.stringify([{ + serviceName: 'web', + scale: 1, + hasBuild: false, + declaredImageRef: 'nginx:latest', + referenceKind: 'moving_tag', + replicas: [{ + containerId: 'c1', + imageId: `sha256:${id.replace(/-/g, '').padEnd(64, '0').slice(0, 64)}`, + repoDigest: null, + state: 'running', + rollbackTag: `sencho-rb/${id.replace(/-/g, '').slice(0, 12)}/web:hold`, + }], + }]), + health_gate_id: null, + gate_retain_until: null, + artifact_expires_at: null, + operation_lease_expires_at: null, + created_at: now, + updated_at: now, + created_by: null, + artifacts_retired: 0, + released_at: null, + released_by: null, + ...overrides, + }; +} + +function insertRow(overrides: Partial = {}): StackUpdateRecoveryGenerationRow { + const row = makeRow(overrides); + DatabaseService.getInstance().insertStackUpdateRecoveryGeneration(row); + return row; +} + +function insertHealthGate(overrides: Partial = {}): HealthGateRunRow { + const run: HealthGateRunRow = { + id: randomUUID(), + node_id: NODE, + stack_name: 'my-stack', + trigger_action: 'update', + status: 'observing', + reason: null, + window_seconds: 90, + containers_json: '[]', + started_at: Date.now(), + ended_at: null, + created_by: null, + target_scope: 'stack', + service_name: null, + failure_source: null, + ...overrides, + }; + DatabaseService.getInstance().insertHealthGateRun(run); + return run; +} + +function imageIdOf(row: StackUpdateRecoveryGenerationRow): string { + const parsed = JSON.parse(row.services_json); + return parsed[0].replicas[0].imageId as string; +} + +describe('recovery_retention_days wired into casHandoffGeneration', () => { + it('uses a configured retention value instead of the hardcoded 7 days', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_retention_days', '2'); + const current = insertRow({ status: 'active', is_current: 1 }); + const candidate = insertRow({ + id: randomUUID(), + status: 'candidate', + phase: 'acquired', + is_current: 0, + stack_name: current.stack_name, + }); + const ok = db.casHandoffGeneration(candidate.id, NODE, current.stack_name); + expect(ok).toBe(true); + + const superseded = db.getStackUpdateRecoveryGeneration(current.id)!; + expect(superseded.status).toBe('superseded'); + const expiresInDays = (superseded.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(expiresInDays).toBeGreaterThan(1.9); + expect(expiresInDays).toBeLessThan(2.1); + }); + + it('reflects a retention-days change made between two consecutive handoffs, not the value at the time of the first', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_retention_days', '2'); + const stackA = insertRow({ stack_name: 'stack-a', status: 'active', is_current: 1 }); + const candidateA = insertRow({ + id: randomUUID(), status: 'candidate', phase: 'acquired', is_current: 0, stack_name: stackA.stack_name, + }); + expect(db.casHandoffGeneration(candidateA.id, NODE, stackA.stack_name)).toBe(true); + const supersededA = db.getStackUpdateRecoveryGeneration(stackA.id)!; + const daysA = (supersededA.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysA).toBeGreaterThan(1.9); + expect(daysA).toBeLessThan(2.1); + + // Change the setting without restarting anything, then handoff a + // different stack: its expiry must reflect the new value, not a value + // cached from the first call. + db.updateGlobalSetting('recovery_retention_days', '5'); + const stackB = insertRow({ stack_name: 'stack-b', status: 'active', is_current: 1 }); + const candidateB = insertRow({ + id: randomUUID(), status: 'candidate', phase: 'acquired', is_current: 0, stack_name: stackB.stack_name, + }); + expect(db.casHandoffGeneration(candidateB.id, NODE, stackB.stack_name)).toBe(true); + const supersededB = db.getStackUpdateRecoveryGeneration(stackB.id)!; + const daysB = (supersededB.artifact_expires_at! - Date.now()) / (24 * 60 * 60 * 1000); + expect(daysB).toBeGreaterThan(4.9); + expect(daysB).toBeLessThan(5.1); + + // The earlier write is not retroactively touched by the later setting change. + const supersededAAfter = db.getStackUpdateRecoveryGeneration(stackA.id)!; + expect(supersededAAfter.artifact_expires_at).toBe(supersededA.artifact_expires_at); + }); +}); + +describe('recovery_max_generations cap enforcement (reconcileIncomplete)', () => { + it('retains current + (cap - 1) superseded generations; forces the rest to expire now', async () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_max_generations', '2'); + const stackName = 'capped-stack'; + insertRow({ stack_name: stackName, status: 'active', is_current: 1 }); + const superseded: StackUpdateRecoveryGenerationRow[] = []; + for (let i = 0; i < 3; i++) { + superseded.push(insertRow({ + id: randomUUID(), + stack_name: stackName, + status: 'superseded', + is_current: 0, + artifact_expires_at: Date.now() + 6 * 24 * 60 * 60 * 1000, + created_at: Date.now() - (3 - i) * 60_000, + })); + } + + const svc = StackUpdateRecoveryService.getInstance(); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + + // cap=2 => current (1) + 1 superseded kept; the other 2 superseded get + // artifact_expires_at pulled to now and their artifacts retired. + const rows = superseded.map((r) => db.getStackUpdateRecoveryGeneration(r.id)!); + const stillRetained = rows.filter((r) => !r.artifacts_retired); + expect(stillRetained.length).toBe(1); + // Keeps the newest superseded row. + expect(stillRetained[0].id).toBe(superseded[2].id); + }); + + it('never touches a recovery_required generation', async () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('recovery_max_generations', '1'); + const stackName = 'stuck-stack'; + insertRow({ stack_name: stackName, status: 'active', is_current: 1 }); + const stuck = insertRow({ + id: randomUUID(), + stack_name: stackName, + status: 'recovery_required', + is_current: 0, + }); + + const svc = StackUpdateRecoveryService.getInstance(); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + + const after = db.getStackUpdateRecoveryGeneration(stuck.id)!; + expect(after.artifacts_retired).toBe(0); + expect(after.artifact_expires_at).toBeNull(); + }); +}); + +describe('StackUpdateRecoveryService.releaseGeneration', () => { + it('release on the current generation clears the held-image set for its image', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.getHeldImageIds(NODE)?.has(imageIdOf(row))).toBe(true); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + expect(svc.getHeldImageIds(NODE)?.has(imageIdOf(row))).toBe(false); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.is_current).toBe(0); + expect(after.released_at).not.toBeNull(); + }); + + it('after releasing the current generation, no rollback point is claimed for the stack (D05)', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.getCurrent(NODE, row.stack_name)).toBeDefined(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + + // Both consumers of the current-generation lookup filter on is_current = 1, + // which release clears, so a released row can never be offered as a live + // rollback target by a later failed update. + expect(svc.getCurrent(NODE, row.stack_name)).toBeUndefined(); + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(false); + + // It is also gone from the list endpoint's supported-status projection. + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(res.body.some((g: { id: string }) => g.id === row.id)).toBe(false); + }); + + it('release on a restored_current generation clears the service-update pin', async () => { + const row = insertRow({ status: 'restored_current', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(true); + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + expect(svc.isRestoredCurrentPinActive(NODE, row.stack_name)).toBe(false); + }); + + it('rejects release while the linked health gate is observing', async () => { + const gate = insertHealthGate({ status: 'observing' }); + const row = insertRow({ status: 'active', is_current: 1, health_gate_id: gate.id, stack_name: gate.stack_name }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('not_eligible'); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(after.is_current).toBe(1); + }); + + it('allows release once the linked health gate has passed', async () => { + const gate = insertHealthGate({ status: 'passed' }); + const row = insertRow({ status: 'active', is_current: 1, health_gate_id: gate.id, stack_name: gate.stack_name }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + }); + + it('rejects release when the row has already moved to recovery_required (race)', async () => { + const row = insertRow({ status: 'recovery_required', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe('not_eligible'); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.artifacts_retired).toBe(0); + }); + + it('rejects a second release of an already-released generation', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const first = await svc.releaseGeneration(row.id, 'tester'); + expect(first.ok).toBe(true); + const second = await svc.releaseGeneration(row.id, 'tester'); + expect(second.ok).toBe(false); + if (!second.ok) expect(second.reason).toBe('already_released'); + }); + + it('leaves artifacts_retired at 0 (retryable) when Docker tag removal fails', async () => { + mockRemove.mockRejectedValueOnce(Object.assign(new Error('docker busy'), { statusCode: 500 })); + const row = insertRow({ status: 'active', is_current: 1 }); + const svc = StackUpdateRecoveryService.getInstance(); + + const result = await svc.releaseGeneration(row.id, 'tester'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.artifactsCleaned).toBe(false); + + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).not.toBeNull(); + expect(after.artifacts_retired).toBe(0); + + // The reconcile sweep retries a released-but-uncleaned row immediately. + mockRemove.mockResolvedValue(undefined); + svc.start(); + await svc.reconcileIncomplete(); + svc.stop(); + const retried = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(retried.artifacts_retired).toBe(1); + }); +}); + +describe('GET/POST /api/system/rollback/generations', () => { + it('lists generations for the requesting node with a releasable flag', async () => { + const row = insertRow({ status: 'active', is_current: 1 }); + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Authorization', authHeader); + + expect(res.status).toBe(200); + const found = res.body.find((g: { id: string }) => g.id === row.id); + expect(found).toBeDefined(); + expect(found.stackName).toBe(row.stack_name); + expect(found.isCurrent).toBe(true); + expect(found.releasable).toBe(true); + }); + + it('releases a generation and returns success', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('404s releasing a generation that belongs to a different node', async () => { + const row = insertRow({ status: 'superseded', is_current: 0, node_id: 999 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(404); + }); + + it('409s releasing an ineligible generation', async () => { + const row = insertRow({ status: 'recovery_required', is_current: 1 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Authorization', authHeader); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('NOT_ELIGIBLE'); + }); +}); + +describe('RBAC on the rollback-generation routes', () => { + it('refuses a viewer POST to the release endpoint and leaves the generation intact', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Cookie', viewerCookie); + + expect(res.status).toBe(403); + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(after.artifacts_retired).toBe(0); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + it('refuses a deployer POST to the release endpoint (release is Admin-only)', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .post(`/api/system/rollback/generations/${row.id}/release`) + .set('Cookie', deployerCookie); + + expect(res.status).toBe(403); + const after = DatabaseService.getInstance().getStackUpdateRecoveryGeneration(row.id)!; + expect(after.released_at).toBeNull(); + expect(mockRemove).not.toHaveBeenCalled(); + }); + + it('allows a viewer to read the generations list (stack:read, matching sibling Resources routes)', async () => { + const row = insertRow({ status: 'superseded', is_current: 0 }); + const res = await request(app) + .get('/api/system/rollback/generations') + .set('Cookie', viewerCookie); + + expect(res.status).toBe(200); + expect(res.body.some((g: { id: string }) => g.id === row.id)).toBe(true); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index cc3fb9bb..1eee2cf4 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -241,6 +241,88 @@ describe('prune_on_update (auto-prune after updates)', () => { }); }); +describe('recovery_retention_days (superseded rollback generation retention)', () => { + it('defaults to 7 days in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).toBe('7'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.recovery_retention_days).toBeDefined(); + }); + + it('accepts a well-formed write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: '14' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).toBe('14'); + DatabaseService.getInstance().updateGlobalSetting('recovery_retention_days', '7'); + }); + + it('rejects an out-of-range value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: '91' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_retention_days).not.toBe('91'); + }); + + it('rejects a non-numeric value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_retention_days', value: 'banana' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + }); +}); + +describe('recovery_max_generations (cap on retained rollback generations per stack)', () => { + it('defaults to 0 (unlimited) in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).toBe('0'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.recovery_max_generations).toBeDefined(); + }); + + it('accepts a well-formed write and persists it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '3' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).toBe('3'); + DatabaseService.getInstance().updateGlobalSetting('recovery_max_generations', '0'); + }); + + it('rejects a negative value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '-1' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().recovery_max_generations).not.toBe('-1'); + }); + + it('rejects an out-of-range value (400) and does not write it', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'recovery_max_generations', value: '51' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Validation failed'); + }); +}); + describe('session_sliding_refresh (keep active sessions alive)', () => { it('defaults to ON in a freshly seeded database', () => { expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('1'); diff --git a/backend/src/__tests__/stack-update-recovery-service.test.ts b/backend/src/__tests__/stack-update-recovery-service.test.ts index b7e784bc..385aa08c 100644 --- a/backend/src/__tests__/stack-update-recovery-service.test.ts +++ b/backend/src/__tests__/stack-update-recovery-service.test.ts @@ -152,6 +152,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; vi.spyOn(DatabaseService.prototype, 'getStackUpdateRecoveryGeneration').mockReturnValue(row); @@ -193,6 +195,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; vi.spyOn(DatabaseService.prototype, 'getStackUpdateRecoveryGeneration').mockReturnValue(row); const update = vi.spyOn(DatabaseService.prototype, 'updateStackUpdateRecoveryGeneration') @@ -354,6 +358,8 @@ describe('StackUpdateRecoveryService', () => { updated_at: Date.now(), created_by: null, artifacts_retired: 0, + released_at: null, + released_by: null, }; mockRemove.mockRejectedValueOnce(Object.assign(new Error('docker busy'), { statusCode: 500 })); const markRetired = vi.spyOn(DatabaseService.prototype, 'markStackUpdateRecoveryArtifactsRetired') diff --git a/backend/src/__tests__/system-maintenance-self-protect.test.ts b/backend/src/__tests__/system-maintenance-self-protect.test.ts index 760d46f2..211d4342 100644 --- a/backend/src/__tests__/system-maintenance-self-protect.test.ts +++ b/backend/src/__tests__/system-maintenance-self-protect.test.ts @@ -13,6 +13,8 @@ let app: import('express').Express; let authHeader: string; let SelfIdentityService: typeof import('../services/SelfIdentityService').default; let DockerController: typeof import('../services/DockerController').default; +let ServiceUpdateRecoveryService: typeof import('../services/ServiceUpdateRecoveryService').ServiceUpdateRecoveryService; +let StackUpdateRecoveryService: typeof import('../services/StackUpdateRecoveryService').StackUpdateRecoveryService; const SELF_IMAGE = 'a'.repeat(64); const SELF_NETWORK = 'b'.repeat(64); @@ -26,6 +28,8 @@ beforeAll(async () => { ({ app } = await import('../index')); ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); ({ default: DockerController } = await import('../services/DockerController')); + ({ ServiceUpdateRecoveryService } = await import('../services/ServiceUpdateRecoveryService')); + ({ StackUpdateRecoveryService } = await import('../services/StackUpdateRecoveryService')); const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); authHeader = `Bearer ${token}`; }); @@ -48,6 +52,10 @@ function stubSelfIdentity(opts: { imageId?: string; networkId?: string; containe function stubDockerControllerNoops() { const fake = { removeImage: vi.fn().mockResolvedValue(undefined), + // Identity resolver by default: canonicalId === the submitted id, so + // existing removeImage(id) assertions keep working. Tests that need + // short-id canonicalization override this per-test. + resolveImageId: vi.fn().mockImplementation(async (id: string) => id), removeNetwork: vi.fn().mockResolvedValue(undefined), removeVolume: vi.fn().mockResolvedValue(undefined), removeContainers: vi.fn().mockResolvedValue([]), @@ -209,3 +217,71 @@ describe('Self-protection in dev mode (SelfIdentityService empty)', () => { }); }); +describe('Held-image protection on /api/system/images/delete', () => { + it('refuses to delete a rollback-held image with 409 IMAGE_HELD_FOR_ROLLBACK', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + const heldId = 'sha256:' + OTHER_IMAGE; + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set([heldId])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: heldId }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('IMAGE_HELD_FOR_ROLLBACK'); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); + + it('still deletes an unrelated image when a held predicate is active', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds') + .mockReturnValue(new Set(['sha256:' + 'z'.repeat(64)])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_IMAGE }); + + expect(res.status).toBe(200); + expect(docker.removeImage).toHaveBeenCalledWith(OTHER_IMAGE); + }); + + it('canonicalizes a short/truncated id before checking the held predicate, closing the bypass', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + const shortId = OTHER_IMAGE.slice(0, 12); + const canonicalId = 'sha256:' + OTHER_IMAGE; + docker.resolveImageId.mockImplementation(async (id: string) => (id === shortId ? canonicalId : id)); + vi.spyOn(StackUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set([canonicalId])); + vi.spyOn(ServiceUpdateRecoveryService.getInstance(), 'getHeldImageIds').mockReturnValue(new Set()); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: shortId }); + + expect(res.status).toBe(409); + expect(res.body.code).toBe('IMAGE_HELD_FOR_ROLLBACK'); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); + + it('returns 404 when the image no longer exists', async () => { + stubSelfIdentity({}); + const docker = stubDockerControllerNoops(); + docker.resolveImageId.mockResolvedValue(null); + + const res = await request(app) + .post('/api/system/images/delete') + .set('Authorization', authHeader) + .send({ id: OTHER_IMAGE }); + + expect(res.status).toBe(404); + expect(docker.removeImage).not.toHaveBeenCalled(); + }); +}); + diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 51971e39..a6bf4740 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -27,6 +27,8 @@ export const SETTING_WRITE_PERMISSIONS: Record = { env_block_deploy_on_missing_required: 'node:manage', auto_create_missing_external_networks: 'node:manage', notification_dispatch_retries: 'node:manage', + recovery_retention_days: 'node:manage', + recovery_max_generations: 'node:manage', developer_mode: 'system:settings', metrics_retention_hours: 'system:settings', log_retention_days: 'system:settings', @@ -114,6 +116,8 @@ const SettingsPatchSchema = z.object({ env_block_deploy_on_missing_required: z.enum(['0', '1']), auto_create_missing_external_networks: z.enum(['0', '1']), image_update_sidebar_indicators: z.enum(['0', '1']), + recovery_retention_days: z.coerce.number().int().min(1).max(90).transform(String), + recovery_max_generations: z.coerce.number().int().min(0).max(50).transform(String), // Strict: do not use bare z.coerce.number() (null/false/'' become 0; true becomes 1). notification_dispatch_retries: z.unknown().superRefine((v, ctx) => { if (parseNotificationDispatchRetries(v) === null) { diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 2a5639fc..5e1d248d 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -9,6 +9,9 @@ import DockerController, { import { isPruneTarget } from '../services/prunePlan'; import { FileSystemService } from '../services/FileSystemService'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; +import { StackUpdateRecoveryService, shortGenerationId } from '../services/StackUpdateRecoveryService'; +import { buildUnifiedHeldImagePredicate } from '../services/recoveryHeldImages'; +import { DatabaseService } from '../services/DatabaseService'; import SelfIdentityService from '../services/SelfIdentityService'; import { requireAdmin } from '../middleware/tierGates'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -262,7 +265,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response }); } const target = targets[0]; - console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`); + console.log(`[Resources] System prune: ${sanitizeForLog(target)} (scope: ${pruneScope})`); const pruneStartedAt = Date.now(); let result: { success: boolean; reclaimedBytes: number }; if (pruneScope === 'managed' && target !== 'containers') { @@ -280,7 +283,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response result = await dockerController.pruneSystem(target, undefined, isImageHeld); } - console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`); + console.log(`[Resources] System prune completed: ${sanitizeForLog(target)}, reclaimed ${result.reclaimedBytes} bytes`); if (isDebugEnabled()) { console.debug('[Resources:debug] System prune', { target, scope: pruneScope, ms: Date.now() - pruneStartedAt, reclaimedBytes: result.reclaimedBytes, @@ -459,9 +462,23 @@ systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Respons return res.status(400).json({ error: 'Invalid image ID format' }); } if (rejectIfSelf('image', id, res)) return; - console.log(`[Resources] Delete image: ${hexId.substring(0, 12)}`); const dockerController = DockerController.getInstance(req.nodeId); - await dockerController.removeImage(id); + // Resolve to the canonical full image ID before the held-image check: the + // submitted id can be a short/truncated form (isValidDockerResourceId + // accepts 12-64 hex chars), which a full-64-char held-set lookup would miss. + const canonicalId = await dockerController.resolveImageId(id); + if (!canonicalId) { + return res.status(404).json({ error: 'Image not found' }); + } + const isImageHeld = buildUnifiedHeldImagePredicate(req.nodeId); + if (isImageHeld(canonicalId)) { + return res.status(409).json({ + error: 'Image is held for a pending update rollback and cannot be deleted manually. It is removed automatically once the rollback window expires, or can be released from Resources → Rollback.', + code: 'IMAGE_HELD_FOR_ROLLBACK', + }); + } + console.log(`[Resources] Delete image: ${hexId.substring(0, 12)}`); + await dockerController.removeImage(canonicalId); invalidateNodeCaches(req.nodeId); res.json({ success: true, message: 'Image deleted' }); } catch (error: unknown) { @@ -470,6 +487,79 @@ systemMaintenanceRouter.post('/images/delete', async (req: Request, res: Respons } }); +// Full-stack rollback generations (the sencho-rb//:hold images). +// Global read under stack:read, matching the rest of the Docker resource +// inventory on this page (/system/resources, /system/images); release is +// requireAdmin, matching every other host-destructive Docker action here. +systemMaintenanceRouter.get('/rollback/generations', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; + try { + const service = StackUpdateRecoveryService.getInstance(); + const rows = DatabaseService.getInstance() + .listStackUpdateRecoveryGenerationsForNode(req.nodeId) + .filter((row) => row.artifacts_retired === 0 + && (row.status === 'active' || row.status === 'restored_current' + || row.status === 'superseded' || row.status === 'recovery_required')); + res.json(rows.map((row) => ({ + id: row.id, + shortId: shortGenerationId(row.id), + stackName: row.stack_name, + status: row.status, + isCurrent: row.is_current === 1, + phase: row.phase, + createdAt: row.created_at, + artifactExpiresAt: row.artifact_expires_at, + releasable: service.isReleaseEligible(row), + }))); + } catch (error) { + console.error('Failed to fetch rollback generations:', error); + res.status(500).json({ error: 'Failed to fetch rollback generations' }); + } +}); + +systemMaintenanceRouter.post('/rollback/generations/:id/release', async (req: Request, res: Response) => { + if (!requireAdmin(req, res)) return; + try { + const id = req.params.id as string; + const service = StackUpdateRecoveryService.getInstance(); + const row = service.get(id); + if (!row || row.node_id !== req.nodeId) { + return res.status(404).json({ error: 'Rollback generation not found' }); + } + const result = await service.releaseGeneration(id, req.user?.username ?? null); + if (!result.ok) { + switch (result.reason) { + case 'not_found': + return res.status(404).json({ error: 'Rollback generation not found' }); + case 'already_released': + return res.status(409).json({ + error: 'Rollback protection was already released for this generation.', + code: 'ALREADY_RELEASED', + }); + case 'not_eligible': + return res.status(409).json({ + error: 'This rollback generation cannot be released right now (it may be observing a health gate, mid-recovery, or already in progress).', + code: 'NOT_ELIGIBLE', + }); + default: { + const _exhaustive: never = result.reason; + throw new Error(`Unhandled release reason: ${_exhaustive}`); + } + } + } + console.log(`[Resources] Released rollback generation ${sanitizeForLog(shortGenerationId(id))} for ${sanitizeForLog(result.row.stack_name)}`); + invalidateNodeCaches(req.nodeId); + res.json({ + success: true, + message: result.artifactsCleaned ? 'Rollback protection released' : 'Rollback protection released; cleanup will finish shortly', + artifactsCleaned: result.artifactsCleaned, + }); + } catch (error) { + console.error('Failed to release rollback generation:', error); + res.status(500).json({ error: 'Failed to release rollback generation' }); + } +}); + systemMaintenanceRouter.post('/volumes/delete', async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 4648b1d3..badaed7b 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -30,6 +30,7 @@ import { MissingExternalNetworksError, type DeployInvocationContext, } from './network/missingExternalNetworksError'; +import { buildUnifiedHeldImagePredicate } from './recoveryHeldImages'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import type { NotificationCategory } from './NotificationService'; @@ -1019,7 +1020,7 @@ export class ComposeService { try { const pruneOnUpdate = DatabaseService.getInstance().getGlobalSettings()['prune_on_update'] === '1'; if (pruneOnUpdate) { - const isImageHeld = recoverySvc.buildUnifiedHeldImagePredicate(this.nodeId); + const isImageHeld = buildUnifiedHeldImagePredicate(this.nodeId); const result = await DockerController.getInstance(this.nodeId).pruneDanglingImages(isImageHeld); const reclaimed = result.reclaimedBytes > 0 ? ` · reclaimed ${(result.reclaimedBytes / (1024 * 1024)).toFixed(1)} MB` diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f47f6d1e..fdd0a315 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -71,6 +71,8 @@ export interface StackUpdateDetail { } const SERVICES_JSON_VERSION = 1; +const DEFAULT_RECOVERY_RETENTION_DAYS = 7; +const DEFAULT_RECOVERY_MAX_GENERATIONS = 0; function isStackServiceStatus(value: unknown): value is StackServiceStatus { if (!value || typeof value !== 'object') return false; @@ -253,6 +255,9 @@ export interface StackUpdateRecoveryGenerationRow { updated_at: number; created_by: string | null; artifacts_retired: number; + /** Set when an operator manually released rollback protection early (see releaseStackUpdateRecoveryGeneration). */ + released_at: number | null; + released_by: string | null; } /** Durable cleanup tombstone for stack/node deletion artifact sweep. */ @@ -1898,6 +1903,12 @@ export class DatabaseService { `); maybeAddCol('stack_update_recovery_generations', 'artifacts_retired', 'INTEGER NOT NULL DEFAULT 0'); + // Manual release (operator gave up rollback protection early). Additive + // columns rather than a new `status` enum value, since `status` carries a + // CHECK constraint that would need the heavier table-rebuild migration + // pattern used for health_gate_runs below. + maybeAddCol('stack_update_recovery_generations', 'released_at', 'INTEGER'); + maybeAddCol('stack_update_recovery_generations', 'released_by', 'TEXT'); maybeAddCol('stack_update_cleanup_pending', 'required_blueprint_id', 'INTEGER'); // Distributed API model columns @@ -2040,6 +2051,11 @@ export class DatabaseService { stmt.run('reclaim_hero', '0'); stmt.run('health_gate_enabled', '1'); stmt.run('health_gate_window_seconds', '90'); + // Superseded-generation retention (days) and a per-stack cap on total + // retained generations (0 = unlimited). Never applies to the current + // generation, which stays protected until superseded or released. + stmt.run('recovery_retention_days', '7'); + stmt.run('recovery_max_generations', '0'); stmt.run('image_update_check_interval_minutes', '120'); stmt.run('image_update_check_mode', 'interval'); stmt.run('image_update_check_cron', ''); @@ -4213,16 +4229,39 @@ export class DatabaseService { return result.changes === 1; } + /** Days a superseded generation's Docker/FS artifacts are retained before automatic cleanup. Never applies to the current generation. */ + public getRecoveryRetentionDays(): number { + try { + const raw = parseInt(this.getGlobalSettings()['recovery_retention_days'] ?? '', 10); + return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 90) : DEFAULT_RECOVERY_RETENTION_DAYS; + } catch (e) { + console.warn('[DatabaseService] recovery_retention_days read failed; using default:', (e as Error).message); + return DEFAULT_RECOVERY_RETENTION_DAYS; + } + } + + /** Total generations retained per stack, current included (0 = unlimited). */ + public getRecoveryMaxGenerations(): number { + try { + const raw = parseInt(this.getGlobalSettings()['recovery_max_generations'] ?? '', 10); + return Number.isFinite(raw) && raw >= 0 ? Math.min(raw, 50) : DEFAULT_RECOVERY_MAX_GENERATIONS; + } catch (e) { + console.warn('[DatabaseService] recovery_max_generations read failed; using default:', (e as Error).message); + return DEFAULT_RECOVERY_MAX_GENERATIONS; + } + } + public casHandoffGeneration(candidateId: string, nodeId: number, stackName: string): boolean { const handoff = this.db.transaction(() => { const candidate = this.getStackUpdateRecoveryGeneration(candidateId); if (!candidate || candidate.node_id !== nodeId || candidate.stack_name !== stackName) return false; if (candidate.status !== 'candidate' || candidate.phase !== 'acquired') return false; + const retentionMs = this.getRecoveryRetentionDays() * 24 * 60 * 60 * 1000; this.db.prepare( `UPDATE stack_update_recovery_generations SET status = 'superseded', is_current = 0, artifact_expires_at = ?, updated_at = ? WHERE node_id = ? AND stack_name = ? AND is_current = 1 AND id != ?` - ).run(Date.now() + 7 * 24 * 60 * 60 * 1000, Date.now(), nodeId, stackName, candidateId); + ).run(Date.now() + retentionMs, Date.now(), nodeId, stackName, candidateId); const result = this.db.prepare( `UPDATE stack_update_recovery_generations SET status = 'active', is_current = 1, phase = 'handoff_committed', updated_at = ? @@ -4234,18 +4273,41 @@ export class DatabaseService { } - /** Generations whose Docker/FS artifacts can be retired (not actively held). */ + /** + * Generations whose Docker/FS artifacts can be retired (not actively held). + * A manually released row (released_at set) is swept immediately regardless + * of its expiry timers; a naturally abandoned/superseded row still waits out + * artifact_expires_at / gate_retain_until. + */ public listStackUpdateRecoveryGenerationsForArtifactRetirement(now: number): StackUpdateRecoveryGenerationRow[] { return this.db.prepare( `SELECT * FROM stack_update_recovery_generations WHERE artifacts_retired = 0 AND is_current = 0 - AND status IN ('abandoned', 'superseded') - AND (artifact_expires_at IS NULL OR artifact_expires_at <= ?) - AND (gate_retain_until IS NULL OR gate_retain_until <= ?)` + AND ( + released_at IS NOT NULL + OR ( + status IN ('abandoned', 'superseded') + AND (artifact_expires_at IS NULL OR artifact_expires_at <= ?) + AND (gate_retain_until IS NULL OR gate_retain_until <= ?) + ) + )` ).all(now, now) as StackUpdateRecoveryGenerationRow[]; } + /** + * Superseded, not-yet-retired, not-released generations across every node + * for cap enforcement (mirrors the other reconcile-sweep list methods, + * which are also unscoped by node), newest first per (node_id, stack_name). + */ + public listActiveSupersededGenerations(): StackUpdateRecoveryGenerationRow[] { + return this.db.prepare( + `SELECT * FROM stack_update_recovery_generations + WHERE status = 'superseded' AND artifacts_retired = 0 AND released_at IS NULL + ORDER BY node_id, stack_name, created_at DESC, id DESC` + ).all() as StackUpdateRecoveryGenerationRow[]; + } + public markStackUpdateRecoveryArtifactsRetired(id: string): boolean { const result = this.db.prepare( `UPDATE stack_update_recovery_generations @@ -4266,6 +4328,33 @@ export class DatabaseService { return result.changes === 1; } + /** + * Operator-initiated release of rollback protection. A single conditional + * UPDATE both revalidates eligibility and performs the transition + * atomically, so a stale caller can never release a row that has since + * become ineligible (e.g. it started a health gate observation, or moved + * to recovery_required). Only clears is_current/timestamps; Docker tag and + * override-file cleanup is the caller's job via retireGenerationArtifacts, + * matching how abandon() already separates the DB transition from cleanup. + */ + public releaseStackUpdateRecoveryGeneration(id: string, releasedBy: string | null): boolean { + const now = Date.now(); + const result = this.db.prepare( + `UPDATE stack_update_recovery_generations + SET released_at = ?, released_by = ?, is_current = 0, updated_at = ? + WHERE id = ? + AND released_at IS NULL + AND artifacts_retired = 0 + AND phase = 'immediate_verified' + AND status IN ('active', 'restored_current', 'superseded') + AND (health_gate_id IS NULL OR NOT EXISTS ( + SELECT 1 FROM health_gate_runs g + WHERE g.id = stack_update_recovery_generations.health_gate_id AND g.status = 'observing' + ))` + ).run(now, releasedBy, now, id); + return result.changes === 1; + } + /** Pre-handoff candidates whose operation lease has expired. */ public listStaleStackUpdateRecoveryCandidates(now: number): StackUpdateRecoveryGenerationRow[] { return this.db.prepare( @@ -4303,6 +4392,7 @@ export class DatabaseService { const rows = this.db.prepare( `SELECT services_json FROM stack_update_recovery_generations WHERE node_id = ? + AND released_at IS NULL AND status IN ('candidate','active','restored_current','recovery_required') AND (artifact_expires_at IS NULL OR artifact_expires_at > ? OR gate_retain_until > ? OR is_current = 1)` ).all(nodeId, now, now) as Array<{ services_json: string }>; @@ -4438,7 +4528,7 @@ export class DatabaseService { const categories = [ 'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed', - 'network_auto_created', + 'network_auto_created', 'rollback_generation_released', ]; const placeholders = categories.map(() => '?').join(', '); const sql = ` diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 443b09c3..919d1237 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -143,6 +143,9 @@ export interface ClassifiedImage { managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; + /** True when a StackUpdateRecoveryService/ServiceUpdateRecoveryService hold protects this image from pruning. Additive: does not change managedStatus semantics. */ + rollbackProtected: boolean; + rollbackProtectionKind?: 'stack' | 'service'; } export interface PortInUseInfo { @@ -561,23 +564,52 @@ class DockerController { const selfIdentity = SelfIdentityService.getInstance(); - const images: ClassifiedImage[] = this.validateApiData(rawImages).map((img: any) => { - const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); - const managedBy = usedByStacks[0] ?? null; - const managedStatus: ClassifiedImage['managedStatus'] = - img.Containers === 0 ? 'unused' : - managedBy ? 'managed' : 'unmanaged'; - return { - Id: img.Id, - RepoTags: img.RepoTags ?? [], - Size: img.Size ?? 0, - Containers: img.Containers ?? 0, - usedByStacks, - managedBy, - managedStatus, - isSencho: selfIdentity.isOwnImage(img.Id), - }; - }); + // Dynamic (async) imports avoid a static cycle: StackUpdateRecoveryService + // imports DockerController directly, and ServiceUpdateRecoveryService + // reaches it transitively through ComposeService. It must be `await import` + // rather than require(), which does not resolve under Vitest's loader. + const { StackUpdateRecoveryService } = await import('./StackUpdateRecoveryService'); + const { ServiceUpdateRecoveryService } = await import('./ServiceUpdateRecoveryService'); + const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(this.nodeId); + const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(this.nodeId); + // A null lookup means "held state unknown" (the DB read failed); treat it + // as held so the badge never disagrees with the delete guard, which fails + // the same way (recoveryHeldImages.ts's buildUnifiedHeldImagePredicate). + const rollbackKind = (imageId: string): ClassifiedImage['rollbackProtectionKind'] => { + if (stackHeld === null || stackHeld.has(imageId)) return 'stack'; + if (serviceHeld === null || serviceHeld.has(imageId)) return 'service'; + return undefined; + }; + + // Only hide an image from the generic inventory when every visible tag is + // a synthetic sencho-rb hold tag; an image that also carries a normal + // registry tag stays visible here (with the badge below) so the generic + // inventory stays complete. Its generation still surfaces in the Rollback tab. + const isFullySyntheticHoldImage = (repoTags: string[]): boolean => + repoTags.length > 0 && repoTags.every((tag) => tag.startsWith('sencho-rb/')); + + const images: ClassifiedImage[] = this.validateApiData(rawImages) + .map((img: any) => { + const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); + const managedBy = usedByStacks[0] ?? null; + const managedStatus: ClassifiedImage['managedStatus'] = + img.Containers === 0 ? 'unused' : + managedBy ? 'managed' : 'unmanaged'; + const rollbackProtectionKind = rollbackKind(img.Id); + return { + Id: img.Id, + RepoTags: img.RepoTags ?? [], + Size: img.Size ?? 0, + Containers: img.Containers ?? 0, + usedByStacks, + managedBy, + managedStatus, + isSencho: selfIdentity.isOwnImage(img.Id), + rollbackProtected: rollbackProtectionKind !== undefined, + rollbackProtectionKind, + }; + }) + .filter((img) => !isFullySyntheticHoldImage(img.RepoTags)); const volumes: ClassifiedVolume[] = rawVolumes.map((vol: any) => { const stack = DockerController.resolveProjectLabel(vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack); @@ -1504,6 +1536,23 @@ class DockerController { return { inspect, history }; } + /** + * Resolve any valid Docker image reference (full ID, short ID, digest, or + * tag) to its canonical full sha256 ID. isValidDockerResourceId accepts + * short IDs down to 12 hex chars, which a held-image-id set lookup (always + * keyed on the full 64-char form) would miss without this resolve step. + * Returns null when the image does not exist. + */ + public async resolveImageId(id: string): Promise { + try { + const info = await this.docker.getImage(id).inspect(); + return info.Id; + } catch (error) { + if ((error as { statusCode?: number })?.statusCode === 404) return null; + throw error; + } + } + public async removeVolume(name: string) { const volume = this.docker.getVolume(name); await volume.remove({ force: true }); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index ba414ce0..eaa03f7e 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -48,6 +48,9 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + // Manual rollback-generation release (Resources → Rollback). History-only + // for the same reason as the drift pair above. + | 'rollback_generation_released' // Automatic external-network creation during deploy. History-only. | 'network_auto_created' | 'node_update_available' @@ -67,7 +70,7 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [ ...ALL_NOTIFICATION_CATEGORIES, 'drift_detected', 'drift_resolved', 'update_started', 'health_gate_passed', 'health_gate_failed', - 'network_auto_created', + 'network_auto_created', 'rollback_generation_released', ]; /** Webhook timeout: 10 seconds per external dispatch call. */ diff --git a/backend/src/services/ServiceUpdateRecoveryService.ts b/backend/src/services/ServiceUpdateRecoveryService.ts index d51c0e63..c607e29a 100644 --- a/backend/src/services/ServiceUpdateRecoveryService.ts +++ b/backend/src/services/ServiceUpdateRecoveryService.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'crypto'; import { DatabaseService, type ServiceUpdateRecoveryRow } from './DatabaseService'; import { getComposeCommandTimeoutMs } from './ComposeService'; +import { buildUnifiedHeldImagePredicate } from './recoveryHeldImages'; import { getErrorMessage } from '../utils/errors'; const SWEEP_INTERVAL_MS = 5 * 60_000; @@ -229,26 +230,13 @@ export class ServiceUpdateRecoveryService { /** * A predicate a pruner can call immediately before deleting each candidate * image. Re-reads the held set on every call (rather than snapshotting it - * once) so a snapshot that becomes eligible between plan and delete is - * still honored. When the held set cannot be read, returns true for every - * id so prune skips deletes (fail closed). + * once, unlike recoveryHeldImages.buildUnifiedHeldImagePredicate) so a + * generation that becomes eligible between plan and delete is still + * honored. When the held set cannot be read, returns true for every id so + * prune skips deletes (fail closed). */ public buildHeldImagePredicate(nodeId: number): (imageId: string) => boolean { - return (imageId: string) => { - const held = this.getHeldImageIds(nodeId); - if (held === null) return true; - if (held.has(imageId)) return true; - try { - // Dynamic import avoids a static cycle with StackUpdateRecoveryService. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { StackUpdateRecoveryService } = require('./StackUpdateRecoveryService') as typeof import('./StackUpdateRecoveryService'); - const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); - if (stackHeld === null) return true; - return stackHeld.has(imageId); - } catch { - return true; - } - }; + return (imageId: string) => buildUnifiedHeldImagePredicate(nodeId)(imageId); } private nextClaimExpiry(now: number): number { diff --git a/backend/src/services/StackUpdateRecoveryService.ts b/backend/src/services/StackUpdateRecoveryService.ts index 4a6fccd9..dc65ef82 100644 --- a/backend/src/services/StackUpdateRecoveryService.ts +++ b/backend/src/services/StackUpdateRecoveryService.ts @@ -70,9 +70,13 @@ function sanitizeServiceSlug(name: string): string { return name.replace(/[^a-zA-Z0-9._-]/g, '-').toLowerCase() || 'svc'; } +/** Same short form used in the opaque rollback tag, so the UI's "Generation" label matches the Docker tag. */ +export function shortGenerationId(generationId: string): string { + return generationId.replace(/-/g, '').slice(0, 12); +} + function opaqueRollbackTag(generationId: string, serviceName: string): string { - const short = generationId.replace(/-/g, '').slice(0, 12); - return `sencho-rb/${short}/${sanitizeServiceSlug(serviceName)}:hold`; + return `sencho-rb/${shortGenerationId(generationId)}/${sanitizeServiceSlug(serviceName)}:hold`; } function parseServicesJson(raw: string): StackRecoveryServiceCapture[] { @@ -284,6 +288,8 @@ export class StackUpdateRecoveryService { updated_at: now, created_by: createdBy, artifacts_retired: 0, + released_at: null, + released_by: null, }; DatabaseService.getInstance().insertStackUpdateRecoveryGeneration(row); return row; @@ -329,7 +335,7 @@ export class StackUpdateRecoveryService { throw new Error('Stack directory escapes compose base'); } - const short = generationId.replace(/-/g, '').slice(0, 12); + const short = shortGenerationId(generationId); if (!/^[a-f0-9]{12}$/i.test(short)) { throw new Error('Invalid recovery generation id'); } @@ -398,6 +404,74 @@ export class StackUpdateRecoveryService { return ok; } + /** + * Informational mirror of releaseStackUpdateRecoveryGeneration's WHERE + * clause, for the list endpoint to grey out a row it already knows is + * ineligible. Not authoritative: releaseGeneration revalidates for real. + */ + public isReleaseEligible(row: StackUpdateRecoveryGenerationRow): boolean { + if (row.released_at !== null || row.artifacts_retired !== 0) return false; + if (row.phase !== 'immediate_verified') return false; + if (!['active', 'restored_current', 'superseded'].includes(row.status)) return false; + if (row.health_gate_id) { + const gate = DatabaseService.getInstance().getHealthGateRun(row.node_id, row.stack_name, row.health_gate_id); + if (gate?.status === 'observing') return false; + } + return true; + } + + /** + * Operator-initiated release of rollback protection, current generation + * included. The DB transition (releaseStackUpdateRecoveryGeneration) + * atomically revalidates eligibility and clears is_current, which is what + * stops getCurrent()/isRestoredCurrentPinActive() from reporting a released + * row as the live rollback point. Docker tag + override cleanup reuses the + * same idempotent retireGenerationArtifacts() that abandon() already relies + * on, so a mid-cleanup Docker failure leaves artifacts_retired at 0 and is + * retried by the next reconcileIncomplete() sweep rather than silently + * "succeeding" in the UI. + */ + public async releaseGeneration( + id: string, + releasedBy: string | null, + ): Promise< + | { ok: true; row: StackUpdateRecoveryGenerationRow; artifactsCleaned: boolean } + | { ok: false; reason: 'not_found' | 'already_released' | 'not_eligible' } + > { + const before = this.get(id); + if (!before) return { ok: false, reason: 'not_found' }; + if (before.released_at !== null) return { ok: false, reason: 'already_released' }; + + const released = DatabaseService.getInstance().releaseStackUpdateRecoveryGeneration(id, releasedBy); + if (!released) return { ok: false, reason: 'not_eligible' }; + + const row = this.get(id); + if (!row) return { ok: false, reason: 'not_found' }; + const artifactsCleaned = await this.retireGenerationArtifacts(row); + + const wasCurrent = before.is_current === 1; + try { + DatabaseService.getInstance().addNotificationHistory(row.node_id, { + level: wasCurrent ? 'warning' : 'info', + category: 'rollback_generation_released', + message: wasCurrent + ? `${row.stack_name}: current rollback protection released. Automatic rollback is unavailable until the next successful full-stack update.` + : `${row.stack_name}: rollback protection released for generation ${shortGenerationId(row.id)}.`, + timestamp: Date.now(), + stack_name: row.stack_name, + actor_username: releasedBy, + }); + } catch (error) { + console.warn( + '[StackUpdateRecovery] Failed to record release activity for %s:', + sanitizeForLog(id), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + } + + return { ok: true, row, artifactsCleaned }; + } + public linkHealthGate(id: string, healthGateId: string): void { DatabaseService.getInstance().linkStackUpdateRecoveryHealthGate(id, healthGateId); } @@ -460,22 +534,6 @@ export class StackUpdateRecoveryService { } } - /** - * Unified held-image predicate: service-scoped + full-stack holds. - * Fail closed (skip prune) when either lookup fails. - */ - public buildUnifiedHeldImagePredicate(nodeId: number): (imageId: string) => boolean { - // Dynamic require avoids a static cycle with ServiceUpdateRecoveryService. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { ServiceUpdateRecoveryService } = require('./ServiceUpdateRecoveryService') as typeof import('./ServiceUpdateRecoveryService'); - const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); - const stackHeld = this.getHeldImageIds(nodeId); - if (serviceHeld === null || stackHeld === null) { - return () => true; - } - return (imageId: string) => serviceHeld.has(imageId) || stackHeld.has(imageId); - } - /** * Post-handoff compensation: restore files + pinned up, then probe before * reporting restored_current / immediate_verified. @@ -655,7 +713,20 @@ export class StackUpdateRecoveryService { } } if (!tagsOk || !overrideOk) return false; - DatabaseService.getInstance().markStackUpdateRecoveryArtifactsRetired(row.id); + try { + DatabaseService.getInstance().markStackUpdateRecoveryArtifactsRetired(row.id); + } catch (error) { + // Tags/override are already gone at this point; a DB write failure here + // must not surface as "release/abandon failed" to the caller (the + // mutation it asked for already happened). Leave artifacts_retired at 0 + // so the next reconcileIncomplete() sweep retries the DB write alone. + console.warn( + '[StackUpdateRecovery] Failed to mark artifacts retired for %s: %s', + sanitizeForLog(row.id), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return false; + } return true; } @@ -680,16 +751,38 @@ export class StackUpdateRecoveryService { }); flagged += 1; } + let capped = 0; + const maxGenerations = db.getRecoveryMaxGenerations(); + if (maxGenerations > 0) { + // The current generation always counts as one of the cap, so the + // superseded budget is one less; it can never itself be evicted here. + const supersededBudget = Math.max(0, maxGenerations - 1); + const byStack = new Map(); + for (const row of db.listActiveSupersededGenerations()) { + const key = `${row.node_id}:${row.stack_name}`; + const list = byStack.get(key) ?? []; + list.push(row); + byStack.set(key, list); + } + for (const rows of byStack.values()) { + for (const row of rows.slice(supersededBudget)) { + if (row.artifact_expires_at === null || row.artifact_expires_at > now) { + db.updateStackUpdateRecoveryGeneration(row.id, { artifact_expires_at: now }); + capped += 1; + } + } + } + } let retired = 0; for (const row of db.listStackUpdateRecoveryGenerationsForArtifactRetirement(now)) { // Never retire an active/current or recovery_required hold target. if (row.is_current === 1 || row.status === 'recovery_required') continue; if (await this.retireGenerationArtifacts(row)) retired += 1; } - if (abandoned > 0 || flagged > 0 || retired > 0) { + if (abandoned > 0 || flagged > 0 || capped > 0 || retired > 0) { console.log( `[StackUpdateRecovery] Reconciled ${abandoned} stale candidate(s), ` - + `${flagged} stuck generation(s), retired ${retired} artifact set(s)`, + + `${flagged} stuck generation(s), ${capped} generation(s) over cap, retired ${retired} artifact set(s)`, ); } } catch (error) { diff --git a/backend/src/services/recoveryHeldImages.ts b/backend/src/services/recoveryHeldImages.ts new file mode 100644 index 00000000..408fd992 --- /dev/null +++ b/backend/src/services/recoveryHeldImages.ts @@ -0,0 +1,18 @@ +import { ServiceUpdateRecoveryService } from './ServiceUpdateRecoveryService'; +import { StackUpdateRecoveryService } from './StackUpdateRecoveryService'; + +/** + * Unified held-image predicate: service-scoped + full-stack rollback holds. + * Lives in its own module (rather than on either service) so both can be + * imported here statically without a cycle -- ServiceUpdateRecoveryService + * and StackUpdateRecoveryService intentionally do not import each other. + * Fails closed (protects every image) when either lookup fails. + */ +export function buildUnifiedHeldImagePredicate(nodeId: number): (imageId: string) => boolean { + const serviceHeld = ServiceUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); + const stackHeld = StackUpdateRecoveryService.getInstance().getHeldImageIds(nodeId); + if (serviceHeld === null || stackHeld === null) { + return () => true; + } + return (imageId: string) => serviceHeld.has(imageId) || stackHeld.has(imageId); +} diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 4ecdb21b..2b39e7da 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -40,6 +40,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'POST /system/images/delete': 'Deleted images', 'POST /system/volumes/delete': 'Deleted volumes', 'POST /system/networks/delete': 'Deleted networks', + 'POST /system/rollback/generations/*/release': 'Released rollback protection', 'POST /system/networks': 'Created network', 'POST /system/console-token': 'Generated console token', 'POST /system/reapply-compose': 'Triggered compose reapply', diff --git a/docs/features/health-gated-updates.mdx b/docs/features/health-gated-updates.mdx index be9c27fa..cc995c04 100644 --- a/docs/features/health-gated-updates.mdx +++ b/docs/features/health-gated-updates.mdx @@ -101,6 +101,21 @@ The Stack Dossier carries a **Rollback readiness** section that answers one ques Rollback readiness section in the Stack Dossier showing the overall state chip and the six rows: Previous compose file, Previous env file, Previous image tag, Last successful deploy, Healthchecks, and the Application data row marked not covered +## Automatic rollback images + +Before a full-stack update runs, Sencho captures the running image of every service as an opaque, uniquely named copy so it can automatically restore the prior state if the update or its health gate fails. These copies exist in Docker as `sencho-rb//:hold`, but they are Sencho-internal recovery state, not part of your image inventory: they are kept out of **Resources → Images** and listed instead in **Resources → Rollback**. If a captured image still carries its original registry tag alongside the hold tag (a compose file pinned to an immutable tag, for example), it stays visible in the Images tab too, badged **Rollback protected** instead of the usual unused label, since it is held on purpose rather than left behind by accident. + +Each capture is one **rollback generation**. The generation currently backing a stack's live deployment is retained for as long as it is current; once a newer update supersedes it, it is retained for a configurable window before Sencho cleans it up automatically. A stack updated repeatedly in a short span can have more than one superseded generation in that window at once. + +**Resources → Rollback** lists every generation on the node: the stack it belongs to, a short generation id, whether it is the current protection or a superseded one awaiting cleanup, and roughly when it clears. An admin can release a generation's protection early from that list, including the current one, which immediately frees its image but means Sencho cannot automatically roll that stack back until its next successful full-stack update; the confirmation dialog says so before you proceed. A generation that is mid-recovery or still being observed by a health gate cannot be released until that finishes. + +Because these images are deliberately held, deleting one directly (by id, including through the API) is refused. Release the generation from **Resources → Rollback** instead, or leave it to clear on its own. + +Two settings under **Settings > Infrastructure > Stacks > Deploy Guardrails** control the automatic cleanup: + +- **Superseded rollback retention** sets how many days a superseded generation is kept before its image is cleaned up. The current generation is unaffected by this window; it stays protected until it is superseded or manually released. Default 7 days. +- **Maximum retained rollback generations per stack** caps how many generations a stack keeps at once, current generation included, so the oldest superseded generations beyond the cap are cleaned up ahead of the retention window. 0 (default) leaves the count unlimited and relies on the retention window alone. + ## Classified failures When a deploy or update fails, Sencho classifies the failure from the compose output and shows the cause with a suggested next step in the recovery panel: an image pull failure, a missing environment variable, a host port conflict, a missing bind-mount path, a permission problem, a crashed container, a failed healthcheck, an unavailable dependency, an unreachable node or Docker daemon, or an invalid compose file. The classification also lands in **Copy details**, so a bug report carries the cause, not just the raw output. @@ -126,4 +141,10 @@ When a deploy or update fails, Sencho classifies the failure from the compose ou The sidebar's per-stack **Update** action runs the same path as the editor toolbar, so it shows the same readiness dialog and deploy progress. One click on **Update now** proceeds. On nodes that do not advertise the capability, updates run directly without the dialog. + + Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** on purpose (they are recovery state, not image inventory) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge. + + + That failure is intentional: the image is protected by an active or recently superseded rollback generation. Open **Resources → Rollback**, find the matching generation, and use **Release** there if you are sure you do not need it. Releasing the current generation means Sencho cannot automatically roll that stack back until its next successful full-stack update. + diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 1311f550..b74b47ab 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -29,6 +29,8 @@ import { cn } from '@/lib/utils'; import { ReclaimHero } from './resources/ReclaimHero'; import { FootprintTreemap } from './resources/FootprintTreemap'; import { ImageDetailsSheet } from './resources/ImageDetailsSheet'; +import { RollbackGenerationsTab, type RollbackGeneration } from './resources/RollbackGenerationsTab'; +import { TableSkeleton } from './resources/TableSkeleton'; import { VolumeBrowserSheet } from './resources/VolumeBrowserSheet'; import { VolumeNameLabel } from './resources/VolumeNameLabel'; import { useTableSort } from '@/hooks/useTableSort'; @@ -59,6 +61,9 @@ interface DockerImage { managedBy: string | null; managedStatus: 'managed' | 'unmanaged' | 'unused'; isSencho: boolean; + /** True when a rollback hold protects this image from pruning; additive, independent of managedStatus. */ + rollbackProtected: boolean; + rollbackProtectionKind?: 'stack' | 'service'; } interface DockerVolume { @@ -272,6 +277,26 @@ function SenchoBadge() { ); } +function RollbackProtectedBadge({ kind }: { kind?: 'stack' | 'service' }) { + return ( + + + + + + Rollback protected + + + + {kind === 'stack' + ? 'Held as a full-stack rollback point. See Resources → Rollback.' + : 'Held for a pending per-service update rollback.'} + + + + ); +} + // ── Severity Badge ───────────────────────────────────────────────────────────── // ── Quick Clean Prune Button ─────────────────────────────────────────────────── @@ -327,24 +352,6 @@ function PruneButton({ target, icon, label, accentClass, onManaged, onAll }: Pru ); } -// ── Table Skeleton ───────────────────────────────────────────────────────────── - -function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { - return ( - - {Array.from({ length: rows }).map((_, r) => ( - - {Array.from({ length: cols }).map((_, c) => ( - - - - ))} - - ))} - - ); -} - // Stable comparator maps for the resource tables (module scope so useTableSort // does not re-sort on every render). Mirrors the Security Images sort standard. const IMAGE_COMPARATORS: Record<'repo' | 'size' | 'status', (a: DockerImage, b: DockerImage) => number> = { @@ -366,7 +373,7 @@ interface ResourcesViewProps { export default function ResourcesView({ headerActions }: ResourcesViewProps = {}) { const isMobile = useIsMobile(); - const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged'>('images'); + const [resourceTab, setResourceTab] = useState<'images' | 'volumes' | 'unmanaged' | 'rollback'>('images'); const { isAdmin, can } = useAuth(); const canReadResources = can('stack:read'); const canDeployResources = can('stack:deploy'); @@ -377,6 +384,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const [volumes, setVolumes] = useState([]); const [networks, setNetworks] = useState([]); const [orphans, setOrphans] = useState>({}); + const [rollbackGenerations, setRollbackGenerations] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isActioning, setIsActioning] = useState(false); @@ -438,12 +446,13 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const generation = ++fetchGenerationRef.current; setIsLoading(true); try { - const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes] = await Promise.all([ + const [usageRes, resourcesRes, orphansRes, summariesRes, settingsRes, rollbackRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), apiFetch('/security/image-summaries').catch(() => null), apiFetch('/settings').catch(() => null), + apiFetch('/system/rollback/generations').catch(() => null), ]); // Resolve every body before the staleness check so a stale @@ -453,6 +462,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const orphansData = orphansRes.ok ? await orphansRes.json() : null; const summariesData = summariesRes && summariesRes.ok ? await summariesRes.json() : null; const settingsData = settingsRes && settingsRes.ok ? await settingsRes.json() : null; + const rollbackData = rollbackRes && rollbackRes.ok ? await rollbackRes.json() : null; if (fetchGenerationRef.current !== generation) return; @@ -471,6 +481,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} setSelectedOrphans([]); } if (summariesData) setScanSummaries(summariesData); + setRollbackGenerations(Array.isArray(rollbackData) ? rollbackData : []); } catch (err) { if (fetchGenerationRef.current !== generation) return; console.error('Failed to fetch data', err); @@ -928,6 +939,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} { value: 'images', label: 'Images', count: images.length }, { value: 'volumes', label: 'Volumes', count: volumes.length }, { value: 'unmanaged', label: 'Unmanaged', count: totalOrphansCount }, + { value: 'rollback', label: 'Rollback', count: rollbackGenerations.length }, ]} /> ) : ( @@ -950,6 +962,12 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} {totalOrphansCount} + + + Rollback + {rollbackGenerations.length} + +
    @@ -1050,6 +1068,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} ) : undefined} /> {img.isSencho && } + {img.rollbackProtected && } {(() => { const tag = img.RepoTags?.[0]; const summary = tag ? scanSummaries[tag] : undefined; @@ -1351,6 +1370,17 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} )}
    + + {/* Rollback */} + + +
    diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index 8725d0ec..e5f96c84 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -474,4 +474,41 @@ describe('ResourcesView', () => { await screen.findByText('off-img:latest'); expect(screen.queryByTestId('reclaim-hero')).not.toBeInTheDocument(); }); + + it('badges a rollback-protected image without changing its managed/unused status', async () => { + mockedFetch.mockImplementation((url: string) => { + if (url === '/system/resources') { + return Promise.resolve(jsonResponse({ + images: [{ ...image('nginx:1.25'), managedStatus: 'unused', rollbackProtected: true, rollbackProtectionKind: 'stack' }], + volumes: [], + networks: [], + })); + } + return Promise.resolve(jsonResponse({})); + }); + + render(); + await screen.findByText('nginx:1.25'); + expect(screen.getByText('Rollback protected')).toBeInTheDocument(); + expect(screen.getByText('Unused')).toBeInTheDocument(); + }); + + it('shows rollback generations in the Rollback tab, admin-gated release button included', async () => { + mockedFetch.mockImplementation((url: string) => { + if (url === '/system/rollback/generations') { + return Promise.resolve(jsonResponse([ + { id: 'gen-1', shortId: 'abc123456789', stackName: 'seerr', status: 'active', isCurrent: true, phase: 'immediate_verified', createdAt: Date.now(), artifactExpiresAt: null, releasable: true }, + ])); + } + return Promise.resolve(jsonResponse({})); + }); + + render(); + await userEvent.click(await screen.findByRole('tab', { name: /rollback/i })); + + expect(await screen.findByText('seerr')).toBeInTheDocument(); + expect(screen.getByText('abc123456789')).toBeInTheDocument(); + expect(screen.getByText('Current')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index bedae63c..8aae9bfd 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -66,6 +66,7 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + | 'rollback_generation_released' | 'node_update_available' | 'system'; diff --git a/frontend/src/components/resources/RollbackGenerationsTab.tsx b/frontend/src/components/resources/RollbackGenerationsTab.tsx new file mode 100644 index 00000000..c2dd867f --- /dev/null +++ b/frontend/src/components/resources/RollbackGenerationsTab.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { ConfirmModal } from '@/components/ui/modal'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Unlock } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events'; +import { TableSkeleton } from './TableSkeleton'; + +export interface RollbackGeneration { + id: string; + shortId: string; + stackName: string; + status: 'active' | 'restored_current' | 'superseded' | 'recovery_required'; + isCurrent: boolean; + phase: string; + createdAt: number; + artifactExpiresAt: number | null; + /** Best-effort UI hint only; the server revalidates eligibility on release. */ + releasable: boolean; +} + +interface RollbackGenerationsTabProps { + generations: RollbackGeneration[]; + isLoading: boolean; + isAdmin: boolean; + nodeId?: number; + /** Refetches the Resources page's data after a successful release. */ + onReleased: () => void | Promise; +} + +function formatExpiry(gen: RollbackGeneration): string { + if (gen.isCurrent) return 'Protected while current'; + if (gen.status === 'recovery_required') return 'Recovery required'; + if (gen.artifactExpiresAt === null) return 'Pending'; + const days = (gen.artifactExpiresAt - Date.now()) / (24 * 60 * 60 * 1000); + if (days <= 0) return 'Expiring now'; + if (days < 1) return `Expires in ${Math.max(1, Math.round(days * 24))}h`; + return `Expires in ${Math.round(days)}d`; +} + +function StateBadge({ gen }: { gen: RollbackGeneration }) { + switch (gen.status) { + case 'recovery_required': + return Recovery required; + case 'superseded': + return Superseded; + case 'active': + case 'restored_current': + return gen.isCurrent + ? Current + : Superseded; + default: { + const unhandled: never = gen.status; + return {String(unhandled)}; + } + } +} + +/** + * Full-stack rollback generations (the sencho-rb//:hold images + * StackUpdateRecoveryService creates). Kept in its own tab rather than the + * generic Images list: this is durable recovery state with its own lifecycle + * (stack, generation, retention, release), not ordinary Docker image inventory. + */ +export function RollbackGenerationsTab({ generations, isLoading, isAdmin, nodeId, onReleased }: RollbackGenerationsTabProps) { + const [confirmRelease, setConfirmRelease] = useState(null); + const [isReleasing, setIsReleasing] = useState(false); + + const handleRelease = async () => { + if (!confirmRelease) return; + setIsReleasing(true); + const loadingId = toast.loading(`Releasing rollback protection for ${confirmRelease.shortId}...`); + try { + const res = await apiFetch(`/system/rollback/generations/${confirmRelease.id}/release`, { method: 'POST' }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error(data?.error || 'Failed to release rollback protection'); + } + toast.success(data?.message || 'Rollback protection released'); + await onReleased(); + } catch (error) { + const err = error as Record; + toast.error(String(err?.message || 'Failed to release rollback protection')); + } finally { + toast.dismiss(loadingId); + setIsReleasing(false); + setConfirmRelease(null); + } + }; + + return ( + <> +

    + Rollback-protected images from full-stack updates. Each generation is kept so a failed update can be + automatically rolled back, and clears on its own once it is superseded and its retention window + passes (configurable under Settings → Infrastructure → Stacks → Deploy Guardrails). +

    +
    + + + + + Stack + Generation + State + Retention + Actions + + + {isLoading ? : ( + + {generations.length === 0 ? ( + + + No rollback-protected generations on this node. + + + ) : generations.map((gen, i) => ( + + + + + {gen.shortId} + + {formatExpiry(gen)} + + {isAdmin && ( + + + + + + + {gen.releasable + ? 'Release rollback protection' + : 'Not releasable right now (mid-recovery or observing a health gate)'} + + + + )} + + + ))} + + )} +
    +
    +
    + + !open && setConfirmRelease(null)} + variant="destructive" + kicker="ROLLBACK · RELEASE · IRREVERSIBLE" + title={`Release rollback protection for ${confirmRelease?.stackName ?? ''}`} + confirmLabel={isReleasing ? 'Releasing...' : 'Release'} + confirming={isReleasing} + onConfirm={handleRelease} + > +

    + {confirmRelease?.isCurrent ? ( + <> + This is {confirmRelease?.stackName}'s + current rollback point. Releasing it now means Sencho will not be able to automatically + roll this stack back until its next successful full-stack update. + + ) : ( + <> + Permanently removes the held rollback image for generation{' '} + {confirmRelease?.shortId}{' '} + ahead of its normal retention window. + + )} +

    +
    + + ); +} diff --git a/frontend/src/components/resources/TableSkeleton.tsx b/frontend/src/components/resources/TableSkeleton.tsx new file mode 100644 index 00000000..22db984f --- /dev/null +++ b/frontend/src/components/resources/TableSkeleton.tsx @@ -0,0 +1,20 @@ +import { TableBody, TableRow, TableCell } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +/** Shared loading placeholder for the Resources page's tabbed tables (Images, Volumes, Rollback). */ +export function TableSkeleton({ cols, rows = 5 }: { cols: number; rows?: number }) { + return ( + + {Array.from({ length: rows }).map((_, r) => ( + + {Array.from({ length: cols }).map((_, c) => ( + + + + ))} + + ))} + + ); +} diff --git a/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx b/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx new file mode 100644 index 00000000..355258d8 --- /dev/null +++ b/frontend/src/components/resources/__tests__/RollbackGenerationsTab.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RollbackGenerationsTab, type RollbackGeneration } from '../RollbackGenerationsTab'; +import { toast } from '@/components/ui/toast-store'; + +const apiFetch = vi.fn(); +vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetch(...args) })); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + loading: vi.fn(() => 'toast-id'), + dismiss: vi.fn(), + }, +})); + +function generation(overrides: Partial = {}): RollbackGeneration { + return { + id: 'gen-1', + shortId: 'abc123456789', + stackName: 'seerr', + status: 'superseded', + isCurrent: false, + phase: 'immediate_verified', + createdAt: Date.now(), + artifactExpiresAt: Date.now() + 3 * 24 * 60 * 60 * 1000, + releasable: true, + ...overrides, + }; +} + +beforeEach(() => { + apiFetch.mockReset(); + (toast.success as ReturnType).mockReset(); + (toast.error as ReturnType).mockReset(); +}); + +describe('RollbackGenerationsTab', () => { + it('shows superseded-generation confirm copy (not the current-generation warning) for a non-current release', async () => { + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + + expect(await screen.findByText(/Permanently removes the held rollback image/i)).toBeInTheDocument(); + expect(screen.queryByText(/Automatic rollback is unavailable until/i)).not.toBeInTheDocument(); + }); + + it('shows the current-generation warning copy when releasing the current generation', async () => { + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + + expect(await screen.findByText(/Sencho will not be able to automatically/i)).toBeInTheDocument(); + }); + + it('confirming release POSTs to the release endpoint and calls onReleased on success', async () => { + apiFetch.mockResolvedValue({ ok: true, json: async () => ({ success: true, message: 'Rollback protection released', artifactsCleaned: true }) }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(apiFetch).toHaveBeenCalledWith('/system/rollback/generations/gen-1/release', { method: 'POST' })); + await waitFor(() => expect(onReleased).toHaveBeenCalled()); + expect(toast.success).toHaveBeenCalledWith('Rollback protection released'); + }); + + it('surfaces the backend partial-cleanup message distinctly from a full release', async () => { + apiFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true, message: 'Rollback protection released; cleanup will finish shortly', artifactsCleaned: false }), + }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Rollback protection released; cleanup will finish shortly')); + }); + + it('surfaces the server error via toast and closes the modal without a lingering Releasing state on failure', async () => { + apiFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'This rollback generation cannot be released right now (it may be observing a health gate, mid-recovery, or already in progress).', code: 'NOT_ELIGIBLE' }) }); + const onReleased = vi.fn(); + render(); + + await userEvent.click(screen.getByRole('button', { name: /release rollback protection/i })); + await userEvent.click(await screen.findByRole('button', { name: 'Release' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('cannot be released right now'))); + expect(onReleased).not.toHaveBeenCalled(); + // Modal closes (confirm button no longer present) rather than staying stuck mid-action. + await waitFor(() => expect(screen.queryByRole('button', { name: 'Release' })).not.toBeInTheDocument()); + }); + + it('hides the Release action for a non-admin', () => { + render(); + expect(screen.queryByRole('button', { name: /release rollback protection/i })).not.toBeInTheDocument(); + }); + + it('disables the Release action when the generation is not releasable', () => { + render(); + expect(screen.getByRole('button', { name: /release rollback protection/i })).toBeDisabled(); + }); + + it('renders an empty state when there are no generations', () => { + render(); + expect(screen.getByText(/No rollback-protected generations on this node/i)).toBeInTheDocument(); + }); + + it('shows a loading skeleton instead of the empty state while the initial fetch is in flight', () => { + render(); + expect(screen.queryByText(/No rollback-protected generations on this node/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/settings/StacksSection.tsx b/frontend/src/components/settings/StacksSection.tsx index 62b114f4..85b8bae6 100644 --- a/frontend/src/components/settings/StacksSection.tsx +++ b/frontend/src/components/settings/StacksSection.tsx @@ -32,11 +32,13 @@ interface StacksSectionProps { onDirtyChange?: (dirty: boolean) => void; } -type GuardrailFields = Pick; +type GuardrailFields = Pick; const DEFAULT_GUARDRAILS: GuardrailFields = { health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds, + recovery_retention_days: DEFAULT_SETTINGS.recovery_retention_days, + recovery_max_generations: DEFAULT_SETTINGS.recovery_max_generations, env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required, auto_create_missing_external_networks: DEFAULT_SETTINGS.auto_create_missing_external_networks, }; @@ -92,6 +94,8 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) { const safe: GuardrailFields = { health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds, + recovery_retention_days: nodeData.recovery_retention_days ?? DEFAULT_SETTINGS.recovery_retention_days, + recovery_max_generations: nodeData.recovery_max_generations ?? DEFAULT_SETTINGS.recovery_max_generations, env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required, auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks, }; @@ -219,6 +223,30 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) { max={600} /> + + onGuardrailChange('recovery_retention_days', v)} + suffix="d" + min={1} + max={90} + /> + + + onGuardrailChange('recovery_max_generations', v)} + suffix="generations" + min={0} + max={50} + /> + { 'env_block_deploy_on_missing_required', 'health_gate_enabled', 'health_gate_window_seconds', + 'recovery_max_generations', + 'recovery_retention_days', ]); }); diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index b1ba031f..efccebeb 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -131,7 +131,7 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ group: 'infrastructure', label: 'Stacks', description: 'Stack editor, lifecycle workflow preferences, and deploy guardrails.', - keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow'], + keywords: ['stack', 'compose', 'deploy', 'guardrail', 'health gate', 'observation', 'env', 'required variable', 'progress', 'modal', 'inline', 'diff', 'preview', 'save', 'editor', 'workflow', 'rollback', 'retention', 'generation'], tier: null, scope: 'node', }, diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 53ece7ad..a7d5306b 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -19,6 +19,8 @@ export interface PatchableSettings { snapshot_documentation?: '0' | '1'; health_gate_enabled?: '0' | '1'; health_gate_window_seconds?: string; + recovery_retention_days?: string; + recovery_max_generations?: string; env_block_deploy_on_missing_required?: '0' | '1'; auto_create_missing_external_networks?: '0' | '1'; image_update_sidebar_indicators?: '0' | '1'; @@ -47,6 +49,8 @@ export const DEFAULT_SETTINGS: PatchableSettings = { snapshot_documentation: '0', health_gate_enabled: '1', health_gate_window_seconds: '90', + recovery_retention_days: '7', + recovery_max_generations: '0', env_block_deploy_on_missing_required: '0', auto_create_missing_external_networks: '0', image_update_sidebar_indicators: '1', diff --git a/frontend/src/components/stack/StackActivityTimeline.tsx b/frontend/src/components/stack/StackActivityTimeline.tsx index 384b6492..23563862 100644 --- a/frontend/src/components/stack/StackActivityTimeline.tsx +++ b/frontend/src/components/stack/StackActivityTimeline.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2, AlertCircle, - TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine, + TriangleAlert, CircleCheck, HeartPulse, HeartCrack, ArrowDownToLine, Unlock, } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -49,6 +49,7 @@ const CATEGORY_ICON: Record = { update_started: ArrowUp, health_gate_passed: HeartPulse, health_gate_failed: HeartCrack, + rollback_generation_released: Unlock, }; const DAY_MS = 86_400_000; diff --git a/frontend/src/lib/notificationCategories.ts b/frontend/src/lib/notificationCategories.ts index 6e4951b2..e615163d 100644 --- a/frontend/src/lib/notificationCategories.ts +++ b/frontend/src/lib/notificationCategories.ts @@ -17,6 +17,7 @@ export const CATEGORY_LABELS: Record = { update_started: 'Update started', health_gate_passed: 'Health gate passed', health_gate_failed: 'Health gate failed', + rollback_generation_released: 'Rollback protection released', node_update_available: 'Node update', system: 'System', }; From 2ad1212bbb78ba2da67d5f92eac63341d95a102f Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 22:46:16 -0400 Subject: [PATCH 25/31] test(mfa): cover session invalidation on admin MFA reset (#1755) Add seedMfaUserWithToken helper that wraps seedMfaUser and returns a signed JWT with the current token_version claim, removing the manual sign-and-read-boilerplate from callers. Add an integration test in the MFA reset describe block that exercises the full invalidation cycle: a target user's pre-reset JWT is accepted before reset and rejected with 401 'Session invalidated' after an admin resets their MFA. The test also confirms a pre-minted admin JWT survives the reset unchanged. --- backend/src/__tests__/helpers/setupTestDb.ts | 21 ++++++++++++++++ backend/src/__tests__/mfa.test.ts | 25 ++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/backend/src/__tests__/helpers/setupTestDb.ts b/backend/src/__tests__/helpers/setupTestDb.ts index fa604f56..aaf7d6a8 100644 --- a/backend/src/__tests__/helpers/setupTestDb.ts +++ b/backend/src/__tests__/helpers/setupTestDb.ts @@ -121,3 +121,24 @@ export async function seedMfaUser( return { userId, secret, backupCodes }; } + +/** + * Seed a user with MFA enrolled and return a session JWT signed with the + * user's current token_version so tests can verify that an operation that + * bumps it (MFA reset, password change) invalidates pre-existing sessions. + */ +export async function seedMfaUserWithToken( + username: string, + password: string, +): Promise<{ userId: number; secret: string; backupCodes: string[]; token: string }> { + const { userId, secret, backupCodes } = await seedMfaUser(username, password); + const { DatabaseService } = await import('../../services/DatabaseService'); + const jwtLib = (await import('jsonwebtoken')).default; + const user = DatabaseService.getInstance().getUser(userId)!; + const token = jwtLib.sign( + { username, role: user.role, tv: user.token_version }, + TEST_JWT_SECRET, + { expiresIn: '1m' }, + ); + return { userId, secret, backupCodes, token }; +} diff --git a/backend/src/__tests__/mfa.test.ts b/backend/src/__tests__/mfa.test.ts index 1f702393..e96b8e2a 100644 --- a/backend/src/__tests__/mfa.test.ts +++ b/backend/src/__tests__/mfa.test.ts @@ -18,6 +18,7 @@ import { setupTestDb, cleanupTestDb, seedMfaUser, + seedMfaUserWithToken, TEST_USERNAME, TEST_JWT_SECRET, } from './helpers/setupTestDb'; @@ -568,6 +569,30 @@ describe('POST /api/users/:id/mfa/reset', () => { expect(resetRows).toHaveLength(1); expect(resetRows[0].summary).toBe(`Reset two-factor authentication: ${userId}`); }); + + it('invalidates target pre-reset JWT after admin MFA reset', async () => { + const { userId, token } = await seedMfaUserWithToken('victim4', 'victim4pass123'); + const adminJwt = adminToken(); // Pre-minted so we prove the admin session survives the reset. + const stacksWith = (jwt: string) => + request(app).get('/api/stacks').set('Authorization', `Bearer ${jwt}`); + + // Pre-reset JWT is accepted (viewer role grants stack:read). + expect((await stacksWith(token)).status).toBe(200); + + // Admin reset bumps the target's token_version, invalidating their sessions. + const reset = await request(app) + .post(`/api/users/${userId}/mfa/reset`) + .set('Authorization', `Bearer ${adminJwt}`); + expect(reset.status).toBe(200); + + // The same pre-reset JWT is now rejected. + const after = await stacksWith(token); + expect(after.status).toBe(401); + expect(after.body.error).toContain('Session invalidated'); + + // Admin session is untouched (only the target's token_version was bumped). + expect((await stacksWith(adminJwt)).status).toBe(200); + }); }); // ─── SSO bypass toggle ──────────────────────────────────────────────────────── From bfabdc244447af6ad033a611584484c9b76277fa Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 23:45:53 -0400 Subject: [PATCH 26/31] fix(fleet-secrets): invalidate stale preview when target inputs change (#1756) * fix(fleet-secrets): invalidate stale preview when target inputs change Clear the preview plan and push results whenever a target input (selectedLabels, labelMode, stackName, or envFile) changes, so the operator cannot review a diff computed from different inputs. A version counter in a ref drops in-flight preview/push responses when inputs change while a request is still pending. * fix(fleet-secrets): always surface push results, add invalidation test Remove the version guard from handlePush so a completed secret write always reports its outcome to the operator, even if target inputs changed while the push was in flight. The guard remains on handlePreview (read-only). Add a component test covering: plan cleared on input change, stale in-flight preview discarded, and push results always surfaced. --- .../fleet/secrets/SecretPushSheet.test.tsx | 157 ++++++++++++++++++ .../fleet/secrets/SecretPushSheet.tsx | 13 +- 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx diff --git a/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx b/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx new file mode 100644 index 00000000..a04cf998 --- /dev/null +++ b/frontend/src/components/fleet/secrets/SecretPushSheet.test.tsx @@ -0,0 +1,157 @@ +/** + * SecretPushSheet: stale-preview invalidation and push-result always surfaced. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { SecretPushPlanEntry, SecretPushResultEntry } from '@/lib/secretsApi'; + +vi.mock('@/lib/secretsApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, previewPush: vi.fn(), executePush: vi.fn() }; +}); + +vi.mock('@/lib/blueprintsApi', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, listDistinctLabels: vi.fn() }; +}); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() }, +})); + +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => ({ nodes: [{ id: 1, name: 'central', type: 'local' }] }), +})); + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn().mockResolvedValue({ ok: true, json: async () => ({ envFiles: ['.env'] }) }), +})); + +import { previewPush, executePush } from '@/lib/secretsApi'; +import { listDistinctLabels } from '@/lib/blueprintsApi'; +import { toast } from '@/components/ui/toast-store'; +import { SecretPushSheet } from './SecretPushSheet'; + +function planEntry(overrides: Partial = {}): SecretPushPlanEntry { + return { + nodeId: 1, nodeName: 'central', stackName: 'my-app', envFileBasename: '.env', + reachable: true, stackExists: true, + diff: [{ key: 'DB_PASSWORD', status: 'changed', before: '***', after: '***' }], + added: 0, changed: 1, unchanged: 4, removedInformational: 0, + ...overrides, + }; +} + +function resultEntry(overrides: Partial = {}): SecretPushResultEntry { + return { + nodeId: 1, nodeName: 'central', stackName: 'my-app', envFileBasename: '.env', + status: 'ok', added: 0, changed: 1, unchanged: 4, + ...overrides, + }; +} + +const secret = { id: 7, name: 'db-creds', description: '', currentVersion: 2, keyCount: 5, createdAt: 0, createdBy: 'admin', updatedAt: 0 }; + +beforeEach(() => { + vi.mocked(listDistinctLabels).mockResolvedValue(['app', 'web', 'db']); + vi.mocked(previewPush).mockResolvedValue([planEntry()]); + vi.mocked(executePush).mockResolvedValue({ pushId: 'p1', results: [resultEntry()] }); +}); + +function renderSheet() { + return render(); +} + +/** Select a label, fill stack name, click Preview. */ +async function fillAndPreview() { + // Wait for labels to load + await screen.findByText('Target'); + // Open label combobox (first element with role combobox;