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