mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
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:
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
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<string[]> {
|
||||
const candidates = new Set<string>();
|
||||
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<Record<string, string> | 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<boolean> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string, BulkStackInfo & { source: 'local' | 'git' }> = {};
|
||||
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<void> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Array<{ source: string; destination: string }> | null>;
|
||||
/** Directory name of the running Sencho compose project under COMPOSE_DIR. */
|
||||
selfStackDirectoryName: () => Promise<SelfStackDirectory>;
|
||||
/** Disk usage of the filesystem backing the compose dir, or null when unknown. */
|
||||
diskUsage: (dir: string) => Promise<DiskUsage | null>;
|
||||
}
|
||||
@@ -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<EnvironmentCheck> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -125,4 +125,6 @@ export interface PreflightContext {
|
||||
accessUrlPorts: Set<number>;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user