mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +00:00
0f9925e04f
* 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
136 lines
5.0 KiB
TypeScript
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;
|
|
}
|