mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user