From 0f9925e04f93341137ebd50f8e4aa2c8cb15f04e Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 6 Jul 2026 02:08:16 -0400 Subject: [PATCH] feat: block self-stack lifecycle ops with UI and preflight guardrails (#1569) * feat: block self-stack lifecycle ops with UI and preflight guardrails Refuse update, deploy, down, stop, and delete when the stack matches Sencho's compose project. Return 409 self_stack_protected. Expose isSelf on /statuses and disable guarded UI actions. Add SelfStackProtectedDialog and self-managed-stack preflight warning. Closes #1564 * fix: add missing stackSelfFlags mock to useSidebarContextMenu test The production hook now reads stackListState.stackSelfFlags[file], but the test mock did not include it, causing 6 tests to fail with TypeError: Cannot read properties of undefined (reading 'web.yml'). * fix: harden self-stack protection during startup Add a global environment preflight warning when Sencho is managed inside COMPOSE_DIR. Align status decoration and route guards on Docker label fallback detection. Block rollback and service-level stop on the protected self stack. * fix: add self_stack_location to diagnostics-route expected check IDs --- .../src/__tests__/diagnostics-route.test.ts | 2 +- .../environment-check-service.test.ts | 42 +++- backend/src/__tests__/preflight-rules.test.ts | 16 +- .../src/__tests__/self-stack-guard.test.ts | 106 +++++++++ .../stack-self-protected-routes.test.ts | 221 ++++++++++++++++++ backend/src/helpers/selfStackGuard.ts | 135 +++++++++++ backend/src/routes/stacks.ts | 24 +- backend/src/services/ComposeDoctorService.ts | 3 + backend/src/services/DockerController.ts | 2 + .../src/services/EnvironmentCheckService.ts | 72 +++++- backend/src/services/preflight/rules.ts | 15 ++ backend/src/services/preflight/types.ts | 2 + docs/features/stack-management.mdx | 2 + docs/getting-started/configuration.mdx | 4 + frontend/src/components/EditorLayout.tsx | 11 + .../components/EditorLayout/EditorView.tsx | 4 + .../EditorLayout/MobileStackDetail.tsx | 2 + .../components/EditorLayout/ShellOverlays.tsx | 13 ++ .../EditorLayout/editor-view-blocks.tsx | 14 +- .../EditorLayout/hooks/useOverlayState.ts | 5 + .../hooks/useSidebarContextMenu.test.ts | 1 + .../hooks/useSidebarContextMenu.ts | 11 +- .../hooks/useStackActions.test.ts | 68 ++++++ .../EditorLayout/hooks/useStackActions.ts | 76 +++++- .../EditorLayout/hooks/useStackListState.ts | 6 + .../components/settings/EnvironmentChecks.tsx | 8 +- .../src/components/sidebar/sidebar-types.ts | 2 + .../stack/SelfStackProtectedDialog.tsx | 48 ++++ .../__tests__/useStackMenuItems.test.tsx | 7 + frontend/src/hooks/useStackMenuItems.tsx | 14 +- 30 files changed, 905 insertions(+), 31 deletions(-) create mode 100644 backend/src/__tests__/self-stack-guard.test.ts create mode 100644 backend/src/__tests__/stack-self-protected-routes.test.ts create mode 100644 backend/src/helpers/selfStackGuard.ts create mode 100644 frontend/src/components/stack/SelfStackProtectedDialog.tsx diff --git a/backend/src/__tests__/diagnostics-route.test.ts b/backend/src/__tests__/diagnostics-route.test.ts index 22c2277a..2da73684 100644 --- a/backend/src/__tests__/diagnostics-route.test.ts +++ b/backend/src/__tests__/diagnostics-route.test.ts @@ -81,7 +81,7 @@ describe('GET /api/diagnostics/environment', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body.checks)).toBe(true); const ids = (res.body.checks as Array<{ id: string }>).map(c => c.id); - expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space']); + expect(ids).toEqual(['docker_socket', 'docker_compose', 'compose_dir', 'self_stack_location', 'path_mapping', 'tls', 'disk_space']); for (const c of res.body.checks as Array<{ status: string; detail: string }>) { expect(['pass', 'warn', 'fail']).toContain(c.status); expect(typeof c.detail).toBe('string'); diff --git a/backend/src/__tests__/environment-check-service.test.ts b/backend/src/__tests__/environment-check-service.test.ts index fef96d7c..6a690560 100644 --- a/backend/src/__tests__/environment-check-service.test.ts +++ b/backend/src/__tests__/environment-check-service.test.ts @@ -21,6 +21,7 @@ function baseProbes(overrides: Partial = {}): EnvironmentProb composeVersion: async () => 'v2.29.0', accessDir: async () => ({ exists: true, isDir: true, writable: true }), bindMounts: async () => [{ source: '/app/compose', destination: '/app/compose' }], + selfStackDirectoryName: async () => null, diskUsage: async () => ({ usePercent: 40, freeBytes: 50 * 1024 ** 3 }), ...overrides, }; @@ -42,7 +43,7 @@ describe('collectEnvironmentReport', () => { it('passes every check on a healthy environment', async () => { const { checks } = await collectEnvironmentReport(baseProbes()); expect(checks.map(c => c.id)).toEqual([ - 'docker_socket', 'docker_compose', 'compose_dir', 'path_mapping', 'tls', 'disk_space', + 'docker_socket', 'docker_compose', 'compose_dir', 'self_stack_location', 'path_mapping', 'tls', 'disk_space', ]); expect(checks.every(c => c.status === 'pass')).toBe(true); }); @@ -61,6 +62,45 @@ describe('collectEnvironmentReport', () => { } }); + describe('self_stack_location', () => { + it('warns when Sencho compose project is inside COMPOSE_DIR', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + selfStackDirectoryName: async () => 'sencho', + accessDir: async (dir) => ({ + exists: dir.replace(/\\/g, '/').endsWith('/app/compose') || dir.replace(/\\/g, '/').endsWith('/app/compose/sencho'), + isDir: true, + writable: true, + }), + })); + const c = byId(checks, 'self_stack_location'); + expect(c.status).toBe('warn'); + expect(c.detail).toMatch(/inside COMPOSE_DIR/i); + expect(remediationOf(c)).toMatch(/Fleet -> Node Update/i); + }); + + it('passes when the running project is not a managed stack directory', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + selfStackDirectoryName: async () => 'sencho', + accessDir: async (dir) => ({ + exists: dir.replace(/\\/g, '/').endsWith('/app/compose'), + isDir: true, + writable: true, + }), + })); + const c = byId(checks, 'self_stack_location'); + expect(c.status).toBe('pass'); + }); + + it('warns when self-stack location cannot be verified', async () => { + const { checks } = await collectEnvironmentReport(baseProbes({ + selfStackDirectoryName: async () => { throw new Error('inspect failed'); }, + })); + const c = byId(checks, 'self_stack_location'); + expect(c.status).toBe('warn'); + expect(c.detail).toMatch(/Could not verify/i); + }); + }); + describe('docker_socket', () => { it('flags a permission error distinctly', async () => { const { checks } = await collectEnvironmentReport(baseProbes({ diff --git a/backend/src/__tests__/preflight-rules.test.ts b/backend/src/__tests__/preflight-rules.test.ts index e1cbbfa6..2a86fdc5 100644 --- a/backend/src/__tests__/preflight-rules.test.ts +++ b/backend/src/__tests__/preflight-rules.test.ts @@ -31,7 +31,8 @@ function ctx(over: Partial = {}): PreflightContext { sourceServiceNames: m ? m.services.map(s => s.name) : [], sourceReadable: true, nodePorts: [], existingNetworkNames: new Set(), existingVolumeNames: new Set(), existingContainers: [], nodeStateAvailable: true, bindChecks: [], - stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, ...over, + stackIntent: null, serviceIntents: {}, accessUrlPorts: new Set(), hasAccessUrls: false, + isSelfStack: false, ...over, }; } @@ -433,6 +434,17 @@ describe('node-state availability', () => { }); }); +describe('self-managed-stack', () => { + it('fires a warning when the stack is the running Sencho instance', () => { + const f = ids(runRules(ctx({ isSelfStack: true })), 'self-managed-stack'); + expect(f).toHaveLength(1); + expect(f[0].severity).toBe('warning'); + }); + it('stays silent for ordinary stacks', () => { + expect(ids(runRules(ctx({ isSelfStack: false })), 'self-managed-stack')).toHaveLength(0); + }); +}); + describe('rule registry completeness', () => { // The canonical rule set. Adding or removing a rule must update this list, // which forces a deliberate pass over the docs and the frontend severity map. @@ -444,7 +456,7 @@ describe('rule registry completeness', () => { 'external-network-missing', 'external-volume-missing', 'new-network', 'new-volume', 'anonymous-volume', 'container-name-internal-dup', 'container-name-collision', 'exposure-internal-published', 'sensitive-service-broad-exposure', 'exposure-unclassified', - 'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded', + 'exposure-port-vs-dossier', 'reverse-proxy-undocumented', 'effective-model-expanded', 'self-managed-stack', ]; it('the registry contains exactly the expected rules', () => { expect([...RULE_IDS].sort()).toEqual([...EXPECTED_RULE_IDS].sort()); diff --git a/backend/src/__tests__/self-stack-guard.test.ts b/backend/src/__tests__/self-stack-guard.test.ts new file mode 100644 index 00000000..4fbc92f3 --- /dev/null +++ b/backend/src/__tests__/self-stack-guard.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import SelfIdentityService from '../services/SelfIdentityService'; +import DockerController from '../services/DockerController'; +import { + isSelfStack, + getSelfStackProjectName, + SELF_STACK_PROTECTED_CODE, + SELF_STACK_PROTECTED_MESSAGE, + selfStackProtectedBulkResult, +} from '../helpers/selfStackGuard'; + +function stubComposeProject(name: string | null) { + const svc = SelfIdentityService.getInstance(); + vi.spyOn(svc, 'initialize').mockResolvedValue(undefined); + vi.spyOn(svc, 'getIdentity').mockReturnValue({ + containerId: 'a'.repeat(64), + containerName: 'sencho', + composeProjectName: name, + imageId: 'b'.repeat(64), + networkNames: [], + volumeNames: [], + }); +} + +afterEach(() => { + vi.restoreAllMocks(); + SelfIdentityService.getInstance().resetForTesting(); + delete process.env.HOSTNAME; +}); + +describe('isSelfStack', () => { + it('returns true when compose project matches the stack name', async () => { + stubComposeProject('sencho'); + expect(await isSelfStack('sencho')).toBe(true); + }); + + it('returns false for a different stack name', async () => { + stubComposeProject('sencho'); + expect(await isSelfStack('web')).toBe(false); + }); + + it('returns false when self identity is unavailable', async () => { + stubComposeProject(null); + expect(await isSelfStack('sencho')).toBe(false); + }); + + it('falls back to matching the running container against compose labels', async () => { + const runtimeId = 'a'.repeat(64); + process.env.HOSTNAME = runtimeId.slice(0, 12); + stubComposeProject(null); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDocker: () => ({ + listContainers: vi.fn().mockResolvedValue([ + { + Id: runtimeId, + Labels: { + 'com.docker.compose.project': 'renamed-project', + 'com.docker.compose.project.working_dir': '/app/compose/sencho', + }, + }, + ]), + }), + } as unknown as ReturnType); + + expect(await isSelfStack('sencho')).toBe(true); + }); + + it('falls back to the running container compose project label', async () => { + const runtimeId = 'b'.repeat(64); + process.env.HOSTNAME = runtimeId.slice(0, 12); + stubComposeProject(null); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getDocker: () => ({ + listContainers: vi.fn().mockResolvedValue([ + { + Id: runtimeId, + Labels: { + 'com.docker.compose.project': 'sencho', + }, + }, + ]), + }), + } as unknown as ReturnType); + + expect(await isSelfStack('sencho')).toBe(true); + }); +}); + +describe('getSelfStackProjectName', () => { + it('returns the compose project from SelfIdentityService', async () => { + stubComposeProject('my-sencho'); + expect(await getSelfStackProjectName()).toBe('my-sencho'); + }); +}); + +describe('selfStackProtectedBulkResult', () => { + it('returns a per-stack bulk failure with the protected code', () => { + const result = selfStackProtectedBulkResult('sencho'); + expect(result).toEqual({ + stackName: 'sencho', + ok: false, + error: SELF_STACK_PROTECTED_MESSAGE, + code: SELF_STACK_PROTECTED_CODE, + }); + }); +}); diff --git a/backend/src/__tests__/stack-self-protected-routes.test.ts b/backend/src/__tests__/stack-self-protected-routes.test.ts new file mode 100644 index 00000000..9365c588 --- /dev/null +++ b/backend/src/__tests__/stack-self-protected-routes.test.ts @@ -0,0 +1,221 @@ +/** + * Route-level tests for self-stack lifecycle protection. When Sencho's compose + * project is discovered as a managed stack, destructive lifecycle endpoints + * return 409 self_stack_protected instead of recreating or removing the instance. + */ +import fs from 'fs'; +import path from 'path'; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard'; + +const { + mockDeployStack, + mockRunCommand, + mockUpdateStack, + mockDownStack, + mockGetContainersByStack, + mockStopContainer, + mockRestartContainer, + mockGetBulkStackStatuses, +} = vi.hoisted(() => ({ + mockDeployStack: vi.fn(), + mockRunCommand: vi.fn(), + mockUpdateStack: vi.fn(), + mockDownStack: vi.fn(), + mockGetContainersByStack: vi.fn(), + mockStopContainer: vi.fn(), + mockRestartContainer: vi.fn(), + mockGetBulkStackStatuses: vi.fn(), +})); + +vi.mock('../services/ComposeService', async () => { + const actual = await vi.importActual( + '../services/ComposeService', + ); + return { + ...actual, + ComposeService: { + ...actual.ComposeService, + getInstance: () => ({ + deployStack: mockDeployStack, + runCommand: mockRunCommand, + updateStack: mockUpdateStack, + downStack: mockDownStack, + }), + }, + }; +}); + +vi.mock('../services/DockerController', async () => { + const actual = await vi.importActual( + '../services/DockerController', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + getContainersByStack: mockGetContainersByStack, + stopContainer: mockStopContainer, + restartContainer: mockRestartContainer, + getBulkStackStatuses: mockGetBulkStackStatuses, + }), + }, + }; +}); + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; + +function writeStack(name: string) { + const dir = path.join(process.env.COMPOSE_DIR!, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n'); +} + +function stubSelfProject(projectName: string | null) { + const svc = SelfIdentityService.getInstance(); + vi.spyOn(svc, 'initialize').mockResolvedValue(undefined); + vi.spyOn(svc, 'getIdentity').mockReturnValue({ + containerId: 'a'.repeat(64), + containerName: 'sencho', + composeProjectName: projectName, + imageId: 'b'.repeat(64), + networkNames: [], + volumeNames: [], + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); + authCookie = await loginAsTestAdmin(app); + writeStack('sencho'); + writeStack('web'); + + const { NotificationService } = await import('../services/NotificationService'); + vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined); +}); + +afterAll(() => { + vi.restoreAllMocks(); + cleanupTestDb(tmpDir); +}); + +afterEach(async () => { + vi.clearAllMocks(); + SelfIdentityService.getInstance().resetForTesting(); + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]); + mockRestartContainer.mockResolvedValue(undefined); + mockStopContainer.mockResolvedValue(undefined); + mockGetBulkStackStatuses.mockResolvedValue({ + sencho: { status: 'running' }, + web: { status: 'running' }, + }); + const { StackOpLockService } = await import('../services/StackOpLockService'); + StackOpLockService.resetForTests(); +}); + +describe('self stack lifecycle refusal', () => { + beforeEach(() => { + stubSelfProject('sencho'); + }); + + const protectedEndpoints = [ + ['POST', '/api/stacks/sencho/deploy'], + ['POST', '/api/stacks/sencho/update'], + ['POST', '/api/stacks/sencho/down'], + ['POST', '/api/stacks/sencho/stop'], + ['POST', '/api/stacks/sencho/rollback'], + ['POST', '/api/stacks/sencho/services/web/stop'], + ['DELETE', '/api/stacks/sencho'], + ] as const; + + it.each(protectedEndpoints)('%s %s returns 409 self_stack_protected', async (method, url) => { + const res = method === 'DELETE' + ? await request(app).delete(url).set('Cookie', authCookie) + : await request(app).post(url).set('Cookie', authCookie); + + expect(res.status).toBe(409); + expect(res.body.code).toBe(SELF_STACK_PROTECTED_CODE); + expect(mockDeployStack).not.toHaveBeenCalled(); + expect(mockUpdateStack).not.toHaveBeenCalled(); + expect(mockRunCommand).not.toHaveBeenCalled(); + expect(mockDownStack).not.toHaveBeenCalled(); + expect(mockStopContainer).not.toHaveBeenCalled(); + }); + + it('allows restart on the self stack', async () => { + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]); + const res = await request(app) + .post('/api/stacks/sencho/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + }); + + it('allows update on a non-self stack', async () => { + mockUpdateStack.mockResolvedValue(undefined); + const res = await request(app) + .post('/api/stacks/web/update') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(mockUpdateStack).toHaveBeenCalledWith('web', undefined, true); + }); +}); + +describe('POST /api/stacks/bulk self stack skip', () => { + beforeEach(() => { + stubSelfProject('sencho'); + mockUpdateStack.mockResolvedValue(undefined); + mockGetContainersByStack.mockResolvedValue([{ Id: 'c1' }]); + }); + + it('returns self_stack_protected for update/stop on the self stack but allows restart', async () => { + const updateRes = await request(app) + .post('/api/stacks/bulk') + .set('Cookie', authCookie) + .send({ action: 'update', stackNames: ['sencho', 'web'] }); + + expect(updateRes.status).toBe(200); + const updateRow = updateRes.body.results.find((r: { stackName: string }) => r.stackName === 'sencho'); + const webRow = updateRes.body.results.find((r: { stackName: string }) => r.stackName === 'web'); + expect(updateRow.ok).toBe(false); + expect(updateRow.code).toBe(SELF_STACK_PROTECTED_CODE); + expect(webRow.ok).toBe(true); + + const stopRes = await request(app) + .post('/api/stacks/bulk') + .set('Cookie', authCookie) + .send({ action: 'stop', stackNames: ['sencho'] }); + + expect(stopRes.body.results[0].code).toBe(SELF_STACK_PROTECTED_CODE); + + const restartRes = await request(app) + .post('/api/stacks/bulk') + .set('Cookie', authCookie) + .send({ action: 'restart', stackNames: ['sencho'] }); + + expect(restartRes.body.results[0].ok).toBe(true); + }); +}); + +describe('GET /api/stacks/statuses isSelf flag', () => { + it('marks the self stack in the statuses payload', async () => { + stubSelfProject('sencho'); + const res = await request(app) + .get('/api/stacks/statuses') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + const senchoKey = Object.keys(res.body).find(k => k.replace(/\.(yml|yaml)$/, '') === 'sencho'); + expect(senchoKey).toBeDefined(); + expect(res.body[senchoKey!].isSelf).toBe(true); + }); +}); diff --git a/backend/src/helpers/selfStackGuard.ts b/backend/src/helpers/selfStackGuard.ts new file mode 100644 index 00000000..ecf2c18a --- /dev/null +++ b/backend/src/helpers/selfStackGuard.ts @@ -0,0 +1,135 @@ +import type { Request, Response } from 'express'; +import path from 'path'; +import DockerController from '../services/DockerController'; +import { FileSystemService } from '../services/FileSystemService'; +import SelfIdentityService from '../services/SelfIdentityService'; + +export const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; + +export const SELF_STACK_PROTECTED_MESSAGE = + 'This stack is the running Sencho instance. Use Fleet -> Node Update to update Sencho. ' + + 'To manage it as a normal stack, move Sencho\'s compose project outside COMPOSE_DIR.'; + +type ListedContainer = { + Id?: string; + Labels?: Record; +}; + +const DEFAULT_COMPOSE_DIR = '/app/compose'; + +function isHexId(value: string): boolean { + return /^[a-f0-9]{12,64}$/i.test(value); +} + +function matchesContainerId(fullId: string, candidate: string): boolean { + if (!fullId || !candidate) return false; + if (fullId === candidate) return true; + if (!isHexId(fullId) || !isHexId(candidate)) return false; + return fullId.startsWith(candidate) || candidate.startsWith(fullId); +} + +async function getRuntimeContainerIdCandidates(): Promise { + const candidates = new Set(); + const hostname = process.env.HOSTNAME?.trim(); + if (hostname && isHexId(hostname)) candidates.add(hostname); + const cgroupId = await SelfIdentityService.readContainerIdFromCgroup(); + if (cgroupId) candidates.add(cgroupId); + return [...candidates]; +} + +function stackNameFromWorkingDir(workingDir: string | undefined, composeDir = process.env.COMPOSE_DIR || DEFAULT_COMPOSE_DIR): string | null { + if (!workingDir) return null; + const resolvedComposeDir = path.resolve(composeDir); + const resolvedWorkingDir = path.resolve(workingDir); + const underComposeDir = resolvedWorkingDir === resolvedComposeDir || resolvedWorkingDir.startsWith(resolvedComposeDir + path.sep); + return underComposeDir ? path.basename(resolvedWorkingDir) : null; +} + +function workingDirMatchesStack(workingDir: string | undefined, stackName: string, composeDir?: string): boolean { + return stackNameFromWorkingDir(workingDir, composeDir) === stackName; +} + +async function getRunningContainerLabels(): Promise | null> { + try { + const runtimeIds = await getRuntimeContainerIdCandidates(); + if (runtimeIds.length === 0) return null; + + const containers = await DockerController.getInstance().getDocker().listContainers({ all: true }) as ListedContainer[]; + const selfContainer = containers.find((container) => { + const containerId = container.Id; + return typeof containerId === 'string' && runtimeIds.some(id => matchesContainerId(containerId, id)); + }); + return selfContainer?.Labels ?? null; + } catch { + return null; + } +} + +async function runningContainerMatchesStack(stackName: string, composeDir?: string): Promise { + try { + const labels = await getRunningContainerLabels(); + if (!labels) return false; + if (labels['com.docker.compose.project'] === stackName) return true; + return workingDirMatchesStack(labels['com.docker.compose.project.working_dir'], stackName, composeDir); + } catch { + return false; + } +} + +/** Compose project name of the running Sencho container, or null when not in Docker. */ +export async function getSelfStackProjectName(): Promise { + try { + const self = SelfIdentityService.getInstance(); + await self.initialize(); + return self.getIdentity().composeProjectName; + } catch { + return null; + } +} + +/** Directory name of the running Sencho compose project, when it is under COMPOSE_DIR. */ +export async function getSelfStackDirectoryName(composeDir?: string): Promise { + const labels = await getRunningContainerLabels(); + const workingDirStack = stackNameFromWorkingDir(labels?.['com.docker.compose.project.working_dir'], composeDir); + if (workingDirStack) return workingDirStack; + return getSelfStackProjectName(); +} + +/** True when the stack appears to be the running Sencho compose project. */ +export async function isSelfStack(stackName: string, composeDir?: string): Promise { + try { + const project = await getSelfStackProjectName(); + if (project === stackName) return true; + return runningContainerMatchesStack(stackName, composeDir); + } catch { + return false; + } +} + +export interface SelfStackProtectedResult { + stackName: string; + ok: false; + error: string; + code: typeof SELF_STACK_PROTECTED_CODE; +} + +export function selfStackProtectedBulkResult(stackName: string): SelfStackProtectedResult { + return { + stackName, + ok: false, + error: SELF_STACK_PROTECTED_MESSAGE, + code: SELF_STACK_PROTECTED_CODE, + }; +} + +/** When the stack is Sencho itself, respond 409 and return true (caller should return). */ +export async function refuseIfSelfStack( + req: Request, + res: Response, + stackName: string, +): Promise { + const composeDir = FileSystemService.getInstance(req.nodeId).getBaseDir(); + if (!(await isSelfStack(stackName, composeDir))) return false; + res.status(409).json({ error: SELF_STACK_PROTECTED_MESSAGE, code: SELF_STACK_PROTECTED_CODE }); + return true; +} diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index f9689259..da40891e 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -55,6 +55,7 @@ import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelec import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution'; import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; +import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard'; // Authenticated users with edit permission can write arbitrarily large compose // files. Refuse to YAML.parse anything beyond this bound so a malformed (or @@ -270,9 +271,15 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => { console.error('Failed to load git sources for status labels; defaulting to local:', sourceError); } const withSource: Record = {}; + const composeDir = FileSystemService.getInstance(req.nodeId).getBaseDir(); for (const [stack, info] of Object.entries(result)) { const name = stack.replace(/\.(yml|yaml)$/, ''); - withSource[stack] = { ...info, source: gitStackNames.has(name) ? 'git' : 'local' }; + const isSelf = await isSelfStack(name, composeDir); + withSource[stack] = { + ...info, + source: gitStackNames.has(name) ? 'git' : 'local', + isSelf, + }; } res.json(withSource); } catch (error) { @@ -398,6 +405,12 @@ async function runStackBulkOp( return { stackName, ok: false, error: 'Stack not found', code: 'not_found' }; } + if (action === 'update' || action === 'stop') { + if (await isSelfStack(stackName, fsSvc.getBaseDir())) { + return selfStackProtectedBulkResult(stackName); + } + } + const user = req.user?.username ?? 'system'; const lockAction: StackOpAction = action; const lockResult = StackOpLockService.getInstance().tryAcquire(req.nodeId, stackName, lockAction, user); @@ -1017,6 +1030,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return; + if (await refuseIfSelfStack(req, res, stackName)) return; const pruneVolumes = req.query.pruneVolumes === 'true'; const debug = isDebugEnabled(); const sanitizedName = sanitizeForLog(stackName); @@ -1591,6 +1605,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (await refuseIfSelfStack(req, res, stackName)) return; // Lock held below. All early-returns must stay inside the try so finally fires. if (!tryAcquireStackOpLock(req, res, stackName, 'deploy')) return; const t0 = Date.now(); @@ -1644,6 +1659,7 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (await refuseIfSelfStack(req, res, stackName)) return; // Lock held below. All early-returns must stay inside the try so finally fires. if (!tryAcquireStackOpLock(req, res, stackName, 'down')) return; const t0 = Date.now(); @@ -1737,6 +1753,8 @@ async function bulkContainerOp( ): Promise { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (action === 'stop' && (await refuseIfSelfStack(req, res, stackName))) return; // Lock held below. All early-returns must stay inside the try so finally fires. if (!tryAcquireStackOpLock(req, res, stackName, action)) return; const t0 = Date.now(); @@ -1794,6 +1812,7 @@ async function handleServiceAction( const stackName = req.params.stackName as string; const serviceName = req.params.serviceName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (action === 'stop' && await refuseIfSelfStack(req, res, stackName)) return; if (!isValidServiceName(serviceName)) { res.status(400).json({ error: 'Invalid service name' }); return; @@ -1854,6 +1873,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (await refuseIfSelfStack(req, res, stackName)) return; // Lock held below. All early-returns must stay inside the try so finally fires. if (!tryAcquireStackOpLock(req, res, stackName, 'update')) return; const t0 = Date.now(); @@ -1915,6 +1935,8 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + if (await refuseIfSelfStack(req, res, stackName)) return; // Rollback restores files and re-deploys, so it must hold the same per-stack // lock deploy/update use. Without it a rollback racing an in-flight deploy // would mutate the compose files and run a second `docker compose up` against diff --git a/backend/src/services/ComposeDoctorService.ts b/backend/src/services/ComposeDoctorService.ts index 46f5cc94..7331bad9 100644 --- a/backend/src/services/ComposeDoctorService.ts +++ b/backend/src/services/ComposeDoctorService.ts @@ -22,6 +22,7 @@ import { getErrorMessage } from '../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { parseUnsetEnvVars, parseMissingRequiredVars, readEnvFileKeys } from '../helpers/envVarParse'; import { resolveStackEnvSources } from '../helpers/envFileResolution'; +import { isSelfStack } from '../helpers/selfStackGuard'; import { classifyUnsetEnvVars, type LiteralDollarWarning } from '../helpers/unsetEnvClassification'; const MAX_RENDER_ERROR = 600; // chars kept from a (redacted) render error @@ -266,6 +267,7 @@ export class ComposeDoctorService { const { nodePorts, existingNetworkNames, existingVolumeNames, existingContainers, nodeStateAvailable } = await this.nodeState(nodeId, fsSvc, stackName); const bindChecks = model ? await this.resolveBindChecks(model, baseDir) : []; const { stackIntent, serviceIntents, accessUrlPorts, hasAccessUrls } = this.exposureState(nodeId, stackName); + const selfStack = await isSelfStack(stackName); return { stackName, @@ -288,6 +290,7 @@ export class ComposeDoctorService { serviceIntents, accessUrlPorts, hasAccessUrls, + isSelfStack: selfStack, }; } diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 2f4b5e82..4c9ac26e 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -104,6 +104,8 @@ export interface BulkStackInfo { running?: number; /** Total container count for the stack; paired with `running` for the sidebar tooltip. */ total?: number; + /** True when this stack is the running Sencho instance (compose project matches stack name). */ + isSelf?: boolean; } export interface ClassifiedImage { diff --git a/backend/src/services/EnvironmentCheckService.ts b/backend/src/services/EnvironmentCheckService.ts index 930b1a1e..2fa45e4c 100644 --- a/backend/src/services/EnvironmentCheckService.ts +++ b/backend/src/services/EnvironmentCheckService.ts @@ -3,9 +3,10 @@ * step and the admin Recovery settings tab. Where DiagnosticsService answers * "is my install broken" (and runs without Docker), this answers "can my * install actually run Docker deploys": is the Docker socket reachable and - * permitted, is `docker compose` v2 present, is the compose directory writable - * and mounted at the same path on host and container, is the dashboard behind - * TLS, and is there disk headroom. + * permitted, is `docker compose` v2 present, is the compose directory writable, + * is Sencho's own compose project outside that managed directory, is the path + * mounted at the same path on host and container, is the dashboard behind TLS, + * and is there disk headroom. * * The mapping from raw probe results to check rows is kept pure and the IO is * injected (see `EnvironmentProbes` / `buildRealProbes`), so the verdict logic @@ -20,11 +21,13 @@ import fs from 'fs/promises'; import { constants as fsConstants } from 'fs'; import { execFile } from 'child_process'; +import path from 'path'; import { promisify } from 'util'; import si from 'systeminformation'; import DockerController from './DockerController'; import { NodeRegistry } from './NodeRegistry'; import SelfIdentityService from './SelfIdentityService'; +import { getSelfStackDirectoryName } from '../helpers/selfStackGuard'; import { withTimeout } from '../utils/withTimeout'; import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping'; @@ -41,6 +44,7 @@ export type CheckId = | 'docker_socket' | 'docker_compose' | 'compose_dir' + | 'self_stack_location' | 'path_mapping' | 'tls' | 'disk_space'; @@ -74,6 +78,8 @@ export interface DiskUsage { freeBytes: number; } +type SelfStackDirectory = string | null | 'unknown'; + /** * Injected IO for the checks. The route wires `buildRealProbes`; tests pass * stubs. `proto` / `host` come from the request so the TLS check reflects how @@ -94,6 +100,8 @@ export interface EnvironmentProbes { * unverified path-mapping warn rather than a false pass. */ bindMounts: () => Promise | null>; + /** Directory name of the running Sencho compose project under COMPOSE_DIR. */ + selfStackDirectoryName: () => Promise; /** Disk usage of the filesystem backing the compose dir, or null when unknown. */ diskUsage: (dir: string) => Promise; } @@ -199,6 +207,58 @@ function checkComposeDir(dir: string, access: DirAccess): EnvironmentCheck { return { ...base, status: 'pass', detail: `${dir} is present and writable.` }; } +async function checkSelfStackLocation( + composeDir: string, + directoryName: SelfStackDirectory, + accessDir: EnvironmentProbes['accessDir'], +): Promise { + const base = { id: 'self_stack_location' as const, label: 'Sencho compose location' }; + if (directoryName === 'unknown') { + return { + ...base, + status: 'warn', + detail: 'Could not verify whether Sencho is managed inside COMPOSE_DIR.', + remediation: + 'Confirm Sencho\'s own compose project is outside COMPOSE_DIR. Use Fleet -> Node Update for Sencho updates.', + }; + } + if (!directoryName) { + return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' }; + } + if (directoryName.includes('/') || directoryName.includes('\\') || directoryName === '.' || directoryName === '..') { + return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' }; + } + const resolvedComposeDir = path.resolve(composeDir); + const stackDir = path.resolve(resolvedComposeDir, directoryName); + const insideComposeDir = stackDir === resolvedComposeDir || stackDir.startsWith(resolvedComposeDir + path.sep); + if (!insideComposeDir) { + return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' }; + } + + try { + const access = await accessDir(stackDir); + if (access.exists && access.isDir) { + return { + ...base, + status: 'warn', + detail: `Sencho's own compose project appears at ${stackDir}, inside COMPOSE_DIR.`, + remediation: + 'Move Sencho\'s compose project outside COMPOSE_DIR and use Fleet -> Node Update for Sencho updates.', + }; + } + } catch { + return { + ...base, + status: 'warn', + detail: 'Could not verify whether Sencho is managed inside COMPOSE_DIR.', + remediation: + 'Confirm Sencho\'s own compose project is outside COMPOSE_DIR. Use Fleet -> Node Update for Sencho updates.', + }; + } + + return { ...base, status: 'pass', detail: 'Sencho is not detected as a managed stack inside COMPOSE_DIR.' }; +} + // Bind mounts on the Sencho container: an array when containerized, `null` when // confirmed not containerized, `'unknown'` when containerized but the mounts // could not be read (so the verdict is an unverified warn, not a false pass). @@ -297,7 +357,7 @@ export async function collectEnvironmentReport(probes: EnvironmentProbes): Promi const logProbeFailure = (label: string) => (e: unknown) => { console.warn(`[env-check] ${label} probe failed: ${(e as Error)?.message ?? String(e)}`); }; - const [socket, compose, access, mounts, disk] = await Promise.all([ + const [socket, compose, access, mounts, selfStackDirectory, disk] = await Promise.all([ checkDockerSocket(probes.pingDocker), checkDockerCompose(probes.composeVersion), probes.accessDir(probes.composeDir).then( @@ -308,13 +368,16 @@ export async function collectEnvironmentReport(probes: EnvironmentProbes): Promi (m): BindMounts => m, (e): BindMounts => { logProbeFailure('bindMounts')(e); return 'unknown'; }, ), + probes.selfStackDirectoryName().then(directory => directory, (e): SelfStackDirectory => { logProbeFailure('selfStackDirectoryName')(e); return 'unknown'; }), probes.diskUsage(probes.composeDir).then(d => d, (e) => { logProbeFailure('diskUsage')(e); return null; }), ]); + const selfStackLocation = await checkSelfStackLocation(probes.composeDir, selfStackDirectory, probes.accessDir); const checks: EnvironmentCheck[] = [ socket, compose, checkComposeDir(probes.composeDir, access), + selfStackLocation, checkPathMapping(probes.composeDir, mounts), checkTls(probes.proto, probes.host), checkDisk(probes.composeDir, disk), @@ -385,6 +448,7 @@ export function buildRealProbes(opts: { proto: string; host: string }): Environm }, accessDir: realAccessDir, bindMounts: () => SelfIdentityService.getInstance().getBindMounts(), + selfStackDirectoryName: () => getSelfStackDirectoryName(composeDir), diskUsage: realDiskUsage, }; } diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index dca4e00b..29a34099 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -608,6 +608,20 @@ const effectiveModelExpanded: PreflightRule = { }, }; +const selfManagedStack: PreflightRule = { + id: 'self-managed-stack', + run(ctx) { + if (!ctx.isSelfStack) return []; + return [{ + ruleId: 'self-managed-stack', + severity: 'warning', + title: 'This stack is the running Sencho instance', + message: 'Sencho discovered its own compose project as a managed stack. Generic deploy, update, stop, down, and delete actions are blocked here because they would recreate or remove the dashboard you are using.', + remediation: 'Update Sencho via Fleet -> Node Update. To manage it as a normal stack, move its compose project outside COMPOSE_DIR.', + }]; + }, +}; + // ----- exposure-intent rules ------------------------------------------------ // These read the user's stored exposure classification (resolved per service) // and the dossier's documented access URLs from the context, plus a sensitivity @@ -785,6 +799,7 @@ export const PREFLIGHT_RULES: PreflightRule[] = [ exposurePortVsDossier, reverseProxyUndocumented, effectiveModelExpanded, + selfManagedStack, ]; export const RULE_IDS: readonly string[] = PREFLIGHT_RULES.map(r => r.id); diff --git a/backend/src/services/preflight/types.ts b/backend/src/services/preflight/types.ts index 4b7ad3e6..cb50591c 100644 --- a/backend/src/services/preflight/types.ts +++ b/backend/src/services/preflight/types.ts @@ -125,4 +125,6 @@ export interface PreflightContext { accessUrlPorts: Set; /** Whether the dossier records any access URL (gates the port-vs-documented rule). */ hasAccessUrls: boolean; + /** True when this stack is the running Sencho instance on the node. */ + isSelfStack: boolean; } diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index c11f1360..e044c4c3 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -191,6 +191,8 @@ The header answers three questions at a glance: The action row to the right of the header keeps everyday lifecycle controls visible. The **More actions** overflow holds Rollback, Scan config, and Delete; the next sections cover both surfaces. +If Sencho discovers its own compose project as a stack (because the deployment directory lives inside `COMPOSE_DIR`), that stack is treated as the running Sencho instance: deploy, update, stop, down, and delete are disabled in the UI and refused by the API. Use **Fleet → Node Update** to upgrade Sencho. Compose Doctor flags the condition during preflight. + ### Image source links Next to the image line is a links button that turns the image reference into somewhere useful to go. It opens a small menu with: diff --git a/docs/getting-started/configuration.mdx b/docs/getting-started/configuration.mdx index 0fc30e50..db91a26e 100644 --- a/docs/getting-started/configuration.mdx +++ b/docs/getting-started/configuration.mdx @@ -21,6 +21,10 @@ Operational settings (host alerts, data retention, image-update automation, mesh When you point `COMPOSE_DIR` at a directory, Sencho expects each stack to live in its own subdirectory. If you create a stack through the UI, Sencho automatically creates a subfolder and places a blank `compose.yaml` inside it. Sencho does not move or "capture" existing files; it simply treats every subdirectory as a separate stack. + + **Do not place Sencho's own compose project inside `COMPOSE_DIR`.** Every subdirectory under the managed stack root is discovered as a stack. If Sencho's deployment directory lives there, the dashboard will list itself and generic stack lifecycle actions (deploy, update, stop, down, delete) are blocked for that stack because they would recreate or remove the instance you are using. Keep Sencho's compose project outside `COMPOSE_DIR`, mount `COMPOSE_DIR` read-write for the stacks you manage, and update Sencho through **Fleet → Node Update**. + + ## Optional environment variables | Variable | Default | Description | diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index a2d30b4f..f2b74cdd 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -113,6 +113,7 @@ export default function EditorLayout() { isScanning, searchQuery, setSearchQuery, stackStatuses, + stackSelfFlags, stackCounts, stackLabelMap, filterChip, setFilterChip, @@ -494,6 +495,7 @@ export default function EditorLayout() { setGitSourceOpen={setGitSourceOpen} setCopiedDigest={setCopiedDigest} requestDeleteStack={stackActions.requestDeleteStack} + isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false} recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined} onRefreshState={async () => { if (!selectedFile) return; @@ -796,6 +798,15 @@ export default function EditorLayout() { stackName={stackName} gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} + canSelfUpdate={hasCapability('self-update')} + onOpenFleetNodeUpdates={() => { + if (isMobile) { + navigateMobileAware('fleet'); + } else { + setFleetUpdatesIntent({ tab: 'nodes' }); + handleNavigate('fleet'); + } + }} /> ); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 5e157a74..801491f2 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -189,6 +189,8 @@ export interface EditorViewProps { // Composed action: wraps setStackToDelete + setDeleteDialogOpen requestDeleteStack: () => void; + /** True when this stack is the running Sencho instance on the active node. */ + isSelfStack?: boolean; // Recovery surface for a failed/stalled operation on this stack (undefined // when the last op succeeded or none has run). onRefreshState re-syncs @@ -270,6 +272,7 @@ export function EditorView(props: EditorViewProps) { setGitSourceOpen, setCopiedDigest, requestDeleteStack, + isSelfStack, recoveryResult, onRefreshState, onDismissRecovery, @@ -381,6 +384,7 @@ export function EditorView(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 5126ab7b..f4488c90 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -70,6 +70,7 @@ export function MobileStackDetail(props: EditorViewProps) { setGitSourceOpen, setCopiedDigest, requestDeleteStack, + isSelfStack = false, onMobileBack, onCloseEditor, hasUnsavedChanges, @@ -153,6 +154,7 @@ export function MobileStackDetail(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} /> diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 9ece1c16..3c86b0bd 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -2,6 +2,7 @@ import BashExecModal from '../BashExecModal'; import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; import { PreDeployScanDialog } from '../stack/PreDeployScanDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; +import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; import { UnsavedChangesDialog } from './UnsavedChangesDialog'; import { StackAlertSheet } from '../StackAlertSheet'; @@ -23,6 +24,8 @@ interface ShellOverlaysProps { stackName: string; gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; + canSelfUpdate: boolean; + onOpenFleetNodeUpdates: () => void; } export function ShellOverlays({ @@ -35,6 +38,8 @@ export function ShellOverlays({ stackName, gitSourceOpen, setGitSourceOpen, + canSelfUpdate, + onOpenFleetNodeUpdates, }: ShellOverlaysProps) { const { deleteDialogOpen, closeDeleteDialog, stackToDelete, @@ -45,6 +50,7 @@ export function ShellOverlays({ policyBlock, setPolicyBlock, policyBypassing, updateReadiness, setUpdateReadiness, preDeployAdvisory, + selfStackProtectedOpen, setSelfStackProtectedOpen, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } = overlayState; @@ -58,6 +64,13 @@ export function ShellOverlays({ onConfirm={stackActions.deleteStack} /> + + Promise; scanStackConfig: () => Promise; requestDeleteStack: () => void; + /** True when this stack is the running Sencho instance on the active node. */ + isSelfStack?: boolean; stackMuteActions?: ReturnType; } @@ -159,8 +161,10 @@ export function StackIdentityHeader({ rollbackStack, scanStackConfig, requestDeleteStack, + isSelfStack = false, stackMuteActions, }: StackIdentityHeaderProps) { + const selfProtected = isSelfStack; return (
{/* Identity block */} @@ -248,18 +252,18 @@ export function StackIdentityHeader({ {loadingAction === 'restart' ? 'Restarting...' : 'Restart'} ) : ( - )} {isRunning && ( - )} - @@ -274,7 +278,7 @@ export function StackIdentityHeader({ {canRollback && ( - +
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} @@ -299,7 +303,7 @@ export function StackIdentityHeader({ {canDelete && ( diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 7297ce3c..cae23d9c 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -115,6 +115,10 @@ export function useOverlayState() { cancel: () => void; } | null>(null); + const [selfStackProtectedOpen, setSelfStackProtectedOpen] = useState(false); + const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []); + const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []); + const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [diffPreview, setDiffPreview] = useState(null); @@ -132,6 +136,7 @@ export function useOverlayState() { policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing, updateReadiness, setUpdateReadiness, preDeployAdvisory, setPreDeployAdvisory, + selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } as const; diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts index 5f47c791..5bd311df 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.test.ts @@ -18,6 +18,7 @@ function makeOptions( const stackListState = { stackStatuses, stackPorts, + stackSelfFlags: {}, isStackBusy: () => false, isPinned: () => false, labels: [], diff --git a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts index 1e02f8c0..83aeeae9 100644 --- a/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts +++ b/frontend/src/components/EditorLayout/hooks/useSidebarContextMenu.ts @@ -55,6 +55,7 @@ export function useSidebarContextMenu({ const canMuteNotifications = isAdmin && hasCapability('notification-suppression'); return { stackStatus, + isSelfStack: stackListState.stackSelfFlags[file] === true, // Only offer "Open App" when a browser-reachable URL can actually be built // (a remote node with no API host, e.g. a pilot agent, yields none). canOpenApp: mainPort !== undefined && buildServiceUrl({ node: activeNode, publicPort: mainPort }) !== null, @@ -78,7 +79,13 @@ export function useSidebarContextMenu({ stop: () => stackActions.executeStackActionByFile(file, 'stop', 'stop'), restart: () => stackActions.executeStackActionByFile(file, 'restart', 'restart'), update: () => stackActions.executeStackActionByFile(file, 'update', 'update'), - remove: () => overlayState.openDeleteDialog(sName), + remove: () => { + if (stackListState.stackSelfFlags[file]) { + overlayState.openSelfStackProtected(); + return; + } + overlayState.openDeleteDialog(sName); + }, pin: () => stackListState.pin(file), unpin: () => stackListState.unpin(file), toggleLabel: async (labelId: number) => { @@ -170,7 +177,7 @@ export function useSidebarContextMenu({ // deps would force a rebuild on every parent render and defeat the memo. // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stackListState.stackStatuses, stackListState.stackPorts, isAdmin, + stackListState.stackStatuses, stackListState.stackPorts, stackListState.stackSelfFlags, isAdmin, stackListState.isPinned, stackListState.labels, stackListState.stackLabelMap, stackListState.pin, stackListState.unpin, activeNode?.type, activeNode?.api_url, activeNode?.id, hasCapability, navState.openMuteRulesWithPrefill, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index cf9c4674..9b6ffaff 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -56,6 +56,7 @@ function makeStackListState(over: Partial = {}): StackListState files: ['web.yml'], stackStatuses: { 'web.yml': 'running' }, stackPorts: {}, + stackSelfFlags: {}, setSelectedFile: vi.fn(), setOptimisticStatus: vi.fn(), setStackAction: vi.fn(), @@ -88,6 +89,7 @@ function makeOverlay(over: Partial = {}): OverlayState { setUpdateReadiness: vi.fn(), preDeployAdvisory: null, setPreDeployAdvisory: vi.fn(), + openSelfStackProtected: vi.fn(), setDiffPreview: vi.fn(), ...over, } as unknown as OverlayState; @@ -876,6 +878,72 @@ describe('useStackActions.getStackMenuVisibility', () => { showDeploy: true, showStop: false, showRestart: false, showUpdate: false, }); }); + + it('hides guarded lifecycle actions for the self stack but keeps restart', () => { + const { result } = setup({ + stackList: { + stackStatuses: { 'sencho.yml': 'running' } as never, + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({ + showDeploy: false, showStop: false, showRestart: true, showUpdate: false, + }); + }); + + it('opens the self-stack modal instead of calling update on a protected stack', async () => { + const { result, overlayState, stackListState } = setup({ + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.updateStack(); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(stackListState.setStackAction).not.toHaveBeenCalled(); + }); + + it('opens the self-stack modal instead of calling rollback on a protected stack', async () => { + vi.mocked(apiFetch).mockReset(); + const { result, overlayState, stackListState } = setup({ + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.rollbackStack(); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(stackListState.setStackAction).not.toHaveBeenCalled(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('opens the self-stack modal for rollback 409 fallback', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 })); + const { result, overlayState, stackListState } = setup(); + await act(async () => { await result.current.rollbackStack(); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); + }); + + it('opens the self-stack modal instead of calling service stop on a protected stack', async () => { + vi.mocked(apiFetch).mockReset(); + const { result, overlayState } = setup({ + stackList: { + selectedFile: 'sencho.yml', + stackSelfFlags: { 'sencho.yml': true }, + }, + }); + await act(async () => { await result.current.serviceAction('stop', 'web'); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('opens the self-stack modal for service stop 409 fallback', async () => { + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 })); + const { result, overlayState } = setup(); + await act(async () => { await result.current.serviceAction('stop', 'web'); }); + expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); + }); }); describe('useStackActions.openStackApp', () => { diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 1b6b8c7d..0a65658c 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -52,6 +52,18 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = { const UNREACHABLE_STATUSES: ReadonlySet = new Set([502, 503, 504]); +const SELF_STACK_PROTECTED_CODE = 'self_stack_protected'; + +const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => { + if (status !== 409) return false; + try { + const parsed: unknown = JSON.parse(rawBody); + return isRecord(parsed) && parsed.code === SELF_STACK_PROTECTED_CODE; + } catch { + return false; + } +}; + const parseFailureClassification = (value: unknown): FailureClassification | undefined => { if ( isRecord(value) && @@ -265,14 +277,24 @@ export function useStackActions(options: UseStackActionsOptions) { // lifecycle actions (stop/restart/update) rather than deploy. const raw = stackListState.stackStatuses[file]; const status = raw === 'partial' ? 'running' : raw; + const isSelf = stackListState.stackSelfFlags[file] === true; return { - showDeploy: status !== 'running', - showStop: status === 'running', + showDeploy: !isSelf && status !== 'running', + showStop: !isSelf && status === 'running', showRestart: status === 'running', - showUpdate: status === 'running', + showUpdate: !isSelf && status === 'running', }; }; + const isSelfStackFile = (file: string | null | undefined): boolean => + !!file && stackListState.stackSelfFlags[file] === true; + + const openSelfStackProtectedIfNeeded = (file: string | null | undefined): boolean => { + if (!isSelfStackFile(file)) return false; + overlayState.openSelfStackProtected(); + return true; + }; + const openStackApp = (file: string) => { const port = stackListState.stackPorts[file]; if (!port) return; @@ -706,6 +728,10 @@ export function useStackActions(options: UseStackActionsOptions) { const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId })); if (!response.ok) { const rawBody = await response.text(); + if (isSelfStackProtectedResponse(rawBody, response.status)) { + overlayState.openSelfStackProtected(); + return { ok: false, errorMessage: 'Sencho instance protected' }; + } if (response.status === 409) { // Either 409 sub-case (op-in-progress or policy block) leaves the // stack in its prior state; undo the optimistic "running" flip once. @@ -764,16 +790,13 @@ export function useStackActions(options: UseStackActionsOptions) { const deployStack = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); - // deployPendingRef blocks a second deploy from the moment of the click - // through the async advisory phase, before setStackAction marks the stack - // busy. Without it, a double-click during the advisory fetch window could - // launch two deploys. Cleared on cancel and in the deploy's finally. if ( !stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile) || deployPendingRef.current ) return; + if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; const stackFile = stackListState.selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); // Snapshot the node once so the advisory fetch and the deploy stay bound to @@ -868,6 +891,7 @@ export function useStackActions(options: UseStackActionsOptions) { if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile)) return; const stackFile = stackListState.selectedFile; + if (openSelfStackProtectedIfNeeded(stackFile)) return; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); const startedAt = Date.now(); stackListState.setStackAction(stackFile, 'rollback'); @@ -879,6 +903,10 @@ export function useStackActions(options: UseStackActionsOptions) { const res = await apiFetch(path, { method: 'POST', nodeId: opNodeId }); if (!res.ok) { const rawBody = await res.text(); + if (isSelfStackProtectedResponse(rawBody, res.status)) { + overlayState.openSelfStackProtected(); + return; + } if (res.status === 409) { const inProgress = parseStackOpInProgress(rawBody); if (inProgress) { @@ -996,6 +1024,10 @@ export function useStackActions(options: UseStackActionsOptions) { const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId })); if (!response.ok) { const errText = await response.text(); + if (isSelfStackProtectedResponse(errText, response.status)) { + overlayState.openSelfStackProtected(); + return { ok: false as const, errorMessage: 'Sencho instance protected' }; + } if (response.status === 409) { const inProgress = parseStackOpInProgress(errText); if (inProgress) { @@ -1056,6 +1088,7 @@ export function useStackActions(options: UseStackActionsOptions) { e?.preventDefault(); e?.stopPropagation(); if (!stackListState.selectedFile) return; + if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; await runStackAction(stackListState.selectedFile, 'stop', 'stop', 'exited', 'Stack stopped successfully!'); }; @@ -1071,13 +1104,21 @@ export function useStackActions(options: UseStackActionsOptions) { serviceName: string, ) => { if (!stackListState.selectedFile) return; + if (action === 'stop' && openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, ''); try { const r = await apiFetch( `/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`, { method: 'POST' }, ); - if (!r.ok) throw new Error((await r.text()) || `${action} failed`); + if (!r.ok) { + const rawBody = await r.text(); + if (isSelfStackProtectedResponse(rawBody, r.status)) { + overlayState.openSelfStackProtected(); + return; + } + throw new Error(rawBody || `${action} failed`); + } const label = action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started'; toast.success(`Service "${serviceName}" ${label}`); @@ -1122,6 +1163,7 @@ export function useStackActions(options: UseStackActionsOptions) { e?.preventDefault(); e?.stopPropagation(); if (!stackListState.selectedFile) return; + if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; await requestStackUpdate(stackListState.selectedFile); }; @@ -1141,6 +1183,11 @@ export function useStackActions(options: UseStackActionsOptions) { const response = await apiFetch(url, { method: 'DELETE' }); if (!response.ok) { const errText = await response.text(); + if (isSelfStackProtectedResponse(errText, response.status)) { + overlayState.openSelfStackProtected(); + overlayState.closeDeleteDialog(); + return; + } throw new Error(errText || 'Failed to delete stack'); } toast.success('Stack deleted successfully!'); @@ -1202,6 +1249,7 @@ export function useStackActions(options: UseStackActionsOptions) { }; const requestDeleteStack = () => { + if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return; overlayState.openDeleteDialog(stackListState.selectedFile ?? ''); }; @@ -1211,6 +1259,12 @@ export function useStackActions(options: UseStackActionsOptions) { endpoint: string, ) => { if (stackListState.isStackBusy(stackFile)) return; + if ( + (action === 'deploy' || action === 'update' || action === 'stop' || action === 'delete' || action === 'rollback') && + openSelfStackProtectedIfNeeded(stackFile) + ) { + return; + } // Updates route through the shared update path so the sidebar gets the // readiness dialog, the deploy-feedback modal, and the same failure // handling as the toolbar. @@ -1235,6 +1289,10 @@ export function useStackActions(options: UseStackActionsOptions) { const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST', nodeId: opNodeId }); if (!response.ok) { const errText = await response.text(); + if (isSelfStackProtectedResponse(errText, response.status)) { + overlayState.openSelfStackProtected(); + return; + } if (response.status === 409) { const inProgress = parseStackOpInProgress(errText); if (inProgress) { @@ -1371,6 +1429,8 @@ export function useStackActions(options: UseStackActionsOptions) { closeBashModal, openLogViewer, closeLogViewer, + isSelfStackFile, + openSelfStackProtectedIfNeeded, }; } diff --git a/frontend/src/components/EditorLayout/hooks/useStackListState.ts b/frontend/src/components/EditorLayout/hooks/useStackListState.ts index db36f41b..7befa7c6 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackListState.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackListState.ts @@ -53,6 +53,7 @@ interface StackStatusInfo { mainPort?: number; running?: number; total?: number; + isSelf?: boolean; } export interface RemoteResult { @@ -92,6 +93,7 @@ export function useStackListState() { const [searchQuery, setSearchQuery] = useState(''); const [stackStatuses, setStackStatuses] = useState({}); const [stackPorts, setStackPorts] = useState>({}); + const [stackSelfFlags, setStackSelfFlags] = useState>({}); const [stackCounts, setStackCounts] = useState({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); @@ -206,6 +208,7 @@ export function useStackListState() { if (stale()) return fileList; let bulkStatuses: Record = {}; const bulkPorts: Record = {}; + const bulkSelf: Record = {}; const bulkCounts: StackCounts = {}; const raw: unknown = statusRes.ok ? await statusRes.json() : null; @@ -213,6 +216,7 @@ export function useStackListState() { for (const [key, val] of Object.entries(raw as Record)) { bulkStatuses[key] = val.status; if (val.mainPort) bulkPorts[key] = val.mainPort; + if (val.isSelf) bulkSelf[key] = true; if (val.running !== undefined && val.total !== undefined) { bulkCounts[key] = { running: val.running, total: val.total }; } @@ -233,6 +237,7 @@ export function useStackListState() { if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev; return bulkPorts; }); + setStackSelfFlags(bulkSelf); setStackCounts(bulkCounts); refreshLabels(); return fileList; @@ -403,6 +408,7 @@ export function useStackListState() { searchQuery, setSearchQuery, stackStatuses, setStackStatuses, stackPorts, setStackPorts, + stackSelfFlags, stackCounts, labels, stackLabelMap, diff --git a/frontend/src/components/settings/EnvironmentChecks.tsx b/frontend/src/components/settings/EnvironmentChecks.tsx index 6dcd1599..e5a315a8 100644 --- a/frontend/src/components/settings/EnvironmentChecks.tsx +++ b/frontend/src/components/settings/EnvironmentChecks.tsx @@ -12,7 +12,7 @@ import { SettingsActions, SettingsSecondaryButton } from './SettingsActions'; // reads checks, so `remediation` stays optional here even though the backend // models it as required on every warn / fail row. type CheckStatus = 'pass' | 'warn' | 'fail'; -type CheckId = 'docker_socket' | 'docker_compose' | 'compose_dir' | 'path_mapping' | 'tls' | 'disk_space'; +type CheckId = 'docker_socket' | 'docker_compose' | 'compose_dir' | 'self_stack_location' | 'path_mapping' | 'tls' | 'disk_space'; interface EnvironmentCheck { id: CheckId; @@ -71,14 +71,14 @@ function CheckRow({ check }: { check: EnvironmentCheck }) { function ChecksSkeleton() { return (
- {[0, 1, 2, 3, 4, 5].map(i => )} + {[0, 1, 2, 3, 4, 5, 6].map(i => )}
); } /** - * Preflight environment checks (Docker engine + Compose, the compose directory - * and its host path mapping, TLS, disk headroom) with inline remediation. + * Preflight environment checks (Docker engine + Compose, the compose directory, + * Sencho compose location, host path mapping, TLS, disk headroom) with inline remediation. * Layout-neutral so it renders both inside the Recovery settings tab and as the * final step of the setup wizard. Self-contained: fetches on mount and exposes * a Re-run control. It never blocks; the caller decides what continue action, diff --git a/frontend/src/components/sidebar/sidebar-types.ts b/frontend/src/components/sidebar/sidebar-types.ts index 8b1d52ed..c6c49a92 100644 --- a/frontend/src/components/sidebar/sidebar-types.ts +++ b/frontend/src/components/sidebar/sidebar-types.ts @@ -23,6 +23,8 @@ export type StackLifecycleStatus = 'running' | 'exited' | 'unknown'; export interface StackMenuCtx { stackStatus: StackLifecycleStatus; + /** True when this stack is the running Sencho instance on the active node. */ + isSelfStack: boolean; canOpenApp: boolean; isBusy: boolean; isAdmin: boolean; diff --git a/frontend/src/components/stack/SelfStackProtectedDialog.tsx b/frontend/src/components/stack/SelfStackProtectedDialog.tsx new file mode 100644 index 00000000..6bd44f79 --- /dev/null +++ b/frontend/src/components/stack/SelfStackProtectedDialog.tsx @@ -0,0 +1,48 @@ +import { Ship } from 'lucide-react'; +import { ConfirmModal } from '@/components/ui/modal'; + +interface SelfStackProtectedDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** When true, the primary action routes to Fleet node updates. */ + canOpenFleetUpdates: boolean; + onOpenFleetUpdates?: () => void; +} + +export function SelfStackProtectedDialog({ + open, + onOpenChange, + canOpenFleetUpdates, + onOpenFleetUpdates, +}: SelfStackProtectedDialogProps) { + return ( + + + Open Fleet node updates + + ) : ( + 'Got it' + ) + } + onConfirm={() => { + if (canOpenFleetUpdates && onOpenFleetUpdates) { + onOpenFleetUpdates(); + } + onOpenChange(false); + }} + > +

+ This stack is the running Sencho instance. Use Fleet -> Node Update to update Sencho. + To manage it as a normal stack, move Sencho's compose project outside COMPOSE_DIR. +

+
+ ); +} diff --git a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx index 63ce1907..fddaa7c8 100644 --- a/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx +++ b/frontend/src/hooks/__tests__/useStackMenuItems.test.tsx @@ -7,6 +7,7 @@ import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; function makeCtx(overrides: Partial = {}): StackMenuCtx { return { stackStatus: 'running', + isSelfStack: false, canOpenApp: true, isBusy: false, isAdmin: true, @@ -179,4 +180,10 @@ describe('useStackMenuItems', () => { const scheduleItem = lifecycle.items.find(i => i.id === 'schedule')!; expect(scheduleItem.disabled).toBeFalsy(); }); + + it('disables delete for the self stack', () => { + const { result } = renderHook(() => useStackMenuItems('sencho.yml', makeCtx({ isSelfStack: true }))); + const destructive = result.current.find(g => g.id === 'destructive')!; + expect(destructive.items[0].disabled).toBe(true); + }); }); diff --git a/frontend/src/hooks/useStackMenuItems.tsx b/frontend/src/hooks/useStackMenuItems.tsx index 90b90db9..182a8e5d 100644 --- a/frontend/src/hooks/useStackMenuItems.tsx +++ b/frontend/src/hooks/useStackMenuItems.tsx @@ -19,7 +19,7 @@ import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sid export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] { const { - stackStatus, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, menuVisibility, openScheduleTask, @@ -88,13 +88,21 @@ export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] if (canDelete) { groups.push({ id: 'destructive', - items: [{ id: 'delete', label: 'Delete', icon: Trash2, shortcut: '⌘⌫', destructive: true, onSelect: remove }], + items: [{ + id: 'delete', + label: 'Delete', + icon: Trash2, + shortcut: '⌘⌫', + destructive: true, + disabled: isSelfStack, + onSelect: remove, + }], }); } return groups; }, [ - stackStatus, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, + stackStatus, isSelfStack, canOpenApp, isBusy, isAdmin, canDelete, canEditLabels, isPinned, labels, showDeploy, showStop, showRestart, showUpdate, openAlertSheet, openAutoHeal, checkUpdates, openStackApp, deploy, stop, restart, update, remove, pin, unpin, toggleLabel, openScheduleTask,