Files
sencho/backend/src/helpers/selfStackGuard.ts
T
Anso 0f9925e04f 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
2026-07-06 02:08:16 -04:00

136 lines
5.0 KiB
TypeScript

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;
}