mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user