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:
Anso
2026-07-06 02:08:16 -04:00
committed by GitHub
parent f30a65ee08
commit 0f9925e04f
30 changed files with 905 additions and 31 deletions
+23 -1
View File
@@ -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