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
This commit is contained in:
Anso
2026-07-06 02:08:16 -04:00
committed by GitHub
parent f30a65ee08
commit 0f9925e04f
30 changed files with 905 additions and 31 deletions
@@ -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');
@@ -21,6 +21,7 @@ function baseProbes(overrides: Partial<EnvironmentProbes> = {}): 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({
+14 -2
View File
@@ -31,7 +31,8 @@ function ctx(over: Partial<PreflightContext> = {}): 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());
@@ -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<typeof DockerController.getInstance>);
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<typeof DockerController.getInstance>);
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,
});
});
});
@@ -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<typeof import('../services/ComposeService')>(
'../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<typeof import('../services/DockerController')>(
'../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);
});
});