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
@@ -52,6 +52,18 @@ const NODE_UNREACHABLE_FAILURE: FailureClassification = {
const UNREACHABLE_STATUSES: ReadonlySet<number> = new Set([502, 503, 504]);
const SELF_STACK_PROTECTED_CODE = 'self_stack_protected';
const isSelfStackProtectedResponse = (rawBody: string, status?: number): boolean => {
if (status !== 409) return false;
try {
const parsed: unknown = JSON.parse(rawBody);
return isRecord(parsed) && parsed.code === SELF_STACK_PROTECTED_CODE;
} catch {
return false;
}
};
const parseFailureClassification = (value: unknown): FailureClassification | undefined => {
if (
isRecord(value) &&
@@ -265,14 +277,24 @@ export function useStackActions(options: UseStackActionsOptions) {
// lifecycle actions (stop/restart/update) rather than deploy.
const raw = stackListState.stackStatuses[file];
const status = raw === 'partial' ? 'running' : raw;
const isSelf = stackListState.stackSelfFlags[file] === true;
return {
showDeploy: status !== 'running',
showStop: status === 'running',
showDeploy: !isSelf && status !== 'running',
showStop: !isSelf && status === 'running',
showRestart: status === 'running',
showUpdate: status === 'running',
showUpdate: !isSelf && status === 'running',
};
};
const isSelfStackFile = (file: string | null | undefined): boolean =>
!!file && stackListState.stackSelfFlags[file] === true;
const openSelfStackProtectedIfNeeded = (file: string | null | undefined): boolean => {
if (!isSelfStackFile(file)) return false;
overlayState.openSelfStackProtected();
return true;
};
const openStackApp = (file: string) => {
const port = stackListState.stackPorts[file];
if (!port) return;
@@ -706,6 +728,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(path, withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const rawBody = await response.text();
if (isSelfStackProtectedResponse(rawBody, response.status)) {
overlayState.openSelfStackProtected();
return { ok: false, errorMessage: 'Sencho instance protected' };
}
if (response.status === 409) {
// Either 409 sub-case (op-in-progress or policy block) leaves the
// stack in its prior state; undo the optimistic "running" flip once.
@@ -764,16 +790,13 @@ export function useStackActions(options: UseStackActionsOptions) {
const deployStack = async (e?: React.MouseEvent) => {
e?.preventDefault();
e?.stopPropagation();
// deployPendingRef blocks a second deploy from the moment of the click
// through the async advisory phase, before setStackAction marks the stack
// busy. Without it, a double-click during the advisory fetch window could
// launch two deploys. Cleared on cancel and in the deploy's finally.
if (
!stackListState.selectedFile ||
stackListState.isStackBusy(stackListState.selectedFile) ||
deployPendingRef.current
)
return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackFile = stackListState.selectedFile;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
// Snapshot the node once so the advisory fetch and the deploy stay bound to
@@ -868,6 +891,7 @@ export function useStackActions(options: UseStackActionsOptions) {
if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile))
return;
const stackFile = stackListState.selectedFile;
if (openSelfStackProtectedIfNeeded(stackFile)) return;
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
const startedAt = Date.now();
stackListState.setStackAction(stackFile, 'rollback');
@@ -879,6 +903,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const res = await apiFetch(path, { method: 'POST', nodeId: opNodeId });
if (!res.ok) {
const rawBody = await res.text();
if (isSelfStackProtectedResponse(rawBody, res.status)) {
overlayState.openSelfStackProtected();
return;
}
if (res.status === 409) {
const inProgress = parseStackOpInProgress(rawBody);
if (inProgress) {
@@ -996,6 +1024,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(url, withDeploySession(ds, { method: 'POST', nodeId: opNodeId }));
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
return { ok: false as const, errorMessage: 'Sencho instance protected' };
}
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
@@ -1056,6 +1088,7 @@ export function useStackActions(options: UseStackActionsOptions) {
e?.preventDefault();
e?.stopPropagation();
if (!stackListState.selectedFile) return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
await runStackAction(stackListState.selectedFile, 'stop', 'stop', 'exited', 'Stack stopped successfully!');
};
@@ -1071,13 +1104,21 @@ export function useStackActions(options: UseStackActionsOptions) {
serviceName: string,
) => {
if (!stackListState.selectedFile) return;
if (action === 'stop' && openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
const stackName = stackListState.selectedFile.replace(/\.(yml|yaml)$/, '');
try {
const r = await apiFetch(
`/stacks/${stackName}/services/${encodeURIComponent(serviceName)}/${action}`,
{ method: 'POST' },
);
if (!r.ok) throw new Error((await r.text()) || `${action} failed`);
if (!r.ok) {
const rawBody = await r.text();
if (isSelfStackProtectedResponse(rawBody, r.status)) {
overlayState.openSelfStackProtected();
return;
}
throw new Error(rawBody || `${action} failed`);
}
const label =
action === 'restart' ? 'restarted' : action === 'stop' ? 'stopped' : 'started';
toast.success(`Service "${serviceName}" ${label}`);
@@ -1122,6 +1163,7 @@ export function useStackActions(options: UseStackActionsOptions) {
e?.preventDefault();
e?.stopPropagation();
if (!stackListState.selectedFile) return;
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
await requestStackUpdate(stackListState.selectedFile);
};
@@ -1141,6 +1183,11 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(url, { method: 'DELETE' });
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
overlayState.closeDeleteDialog();
return;
}
throw new Error(errText || 'Failed to delete stack');
}
toast.success('Stack deleted successfully!');
@@ -1202,6 +1249,7 @@ export function useStackActions(options: UseStackActionsOptions) {
};
const requestDeleteStack = () => {
if (openSelfStackProtectedIfNeeded(stackListState.selectedFile)) return;
overlayState.openDeleteDialog(stackListState.selectedFile ?? '');
};
@@ -1211,6 +1259,12 @@ export function useStackActions(options: UseStackActionsOptions) {
endpoint: string,
) => {
if (stackListState.isStackBusy(stackFile)) return;
if (
(action === 'deploy' || action === 'update' || action === 'stop' || action === 'delete' || action === 'rollback') &&
openSelfStackProtectedIfNeeded(stackFile)
) {
return;
}
// Updates route through the shared update path so the sidebar gets the
// readiness dialog, the deploy-feedback modal, and the same failure
// handling as the toolbar.
@@ -1235,6 +1289,10 @@ export function useStackActions(options: UseStackActionsOptions) {
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST', nodeId: opNodeId });
if (!response.ok) {
const errText = await response.text();
if (isSelfStackProtectedResponse(errText, response.status)) {
overlayState.openSelfStackProtected();
return;
}
if (response.status === 409) {
const inProgress = parseStackOpInProgress(errText);
if (inProgress) {
@@ -1371,6 +1429,8 @@ export function useStackActions(options: UseStackActionsOptions) {
closeBashModal,
openLogViewer,
closeLogViewer,
isSelfStackFile,
openSelfStackProtectedIfNeeded,
};
}