fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses (#1392)

* fix: gate fleet stop-by-label on a resolved preview and validate remote stop responses

The destructive "Stop fleet by label" action could run before its blast
radius was known, and a malformed remote response could be rendered as a
successful zero-stack stop. Both are release blockers for the fleet
stop-by-label flow.

Gate the "Stop fleet" button on a resolved blast radius: the live
match-preview resolving to at least one matching stack, or a dry run of the
current label when the preview endpoint is unavailable. Carry the resolved
node and stack list into the confirm modal so the operator confirms against
the concrete targets rather than a label name alone. Editing the label
invalidates a prior dry-run snapshot, so a stale blast radius cannot
re-enable the action.

Validate the remote local-stop 200 body before trusting it. A body that is
not the local-stop contract (missing matched flag, non-array results, or a
malformed result element) now fails that node with a clear error, consistent
with the non-ok and unreachable paths, instead of defaulting matched to true
and results to empty.

* fix: ignore stale fleet stop-by-label preview responses after the label changes

The debounced match-preview callback set the preview state unconditionally,
so a request issued for one label that resolved after the operator switched
to another label would mark the preview ready with the old label's blast
radius. That re-enabled the destructive Stop and showed stale nodes/stacks
in the confirm modal for a label that no longer matched them.

Guard the in-flight request with a per-run cancelled flag flipped in the
effect cleanup, so a response for a label that is no longer current cannot
set the preview. This mirrors the cancellation pattern already used by the
suggestions effect. Add a test that holds a preview request in-flight,
switches the label, then resolves the stale response and asserts Stop stays
disabled and the old targets do not leak.
This commit is contained in:
Anso
2026-06-18 23:29:20 -04:00
committed by GitHub
parent ba09e6f69e
commit 2821e87d3f
6 changed files with 359 additions and 24 deletions
@@ -609,7 +609,37 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
}
});
it('degrades a malformed 200 body (non-array results) to empty instead of forwarding it', async () => {
it('accepts a legitimate empty stop (matched:true, results:[]) without flagging it malformed', async () => {
const remoteId = db.addNode({
name: 'remote-empty', type: 'remote', api_url: 'http://remote-empty.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
const label = db.createLabel(remoteId, `remote-empty-${++labelCounter}`, 'teal');
db.setStackLabels('alpha', remoteId, [label.id]);
// The local-stop receiver emits exactly this when the label exists with no
// assigned stacks. The guard must let it through, not over-reject it.
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, status: 200, json: async () => ({ matched: true, results: [] }),
} as unknown as Response);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(remoteRow.reachable).toBe(true);
expect(remoteRow.matched).toBe(true);
expect(remoteRow.stackResults).toEqual([]);
expect(remoteRow.error).toBeUndefined();
} finally {
db.deleteNode(remoteId);
}
});
it('fails a node whose 200 body has a non-array results, never rendering it as zero-stack success', async () => {
const remoteId = db.addNode({
name: 'remote-malformed', type: 'remote', api_url: 'http://remote-malformed.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
@@ -628,7 +658,41 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
// A malformed contract is a node failure: reachable:false + error, not the
// matched:true/empty-results shape the UI reads as a successful no-op.
expect(remoteRow.reachable).toBe(false);
expect(remoteRow.matched).toBe(false);
expect(remoteRow.stackResults).toEqual([]);
expect(remoteRow.error).toMatch(/malformed/i);
} finally {
db.deleteNode(remoteId);
}
});
it('fails a node whose 200 body omits the matched flag instead of defaulting it to true', async () => {
const remoteId = db.addNode({
name: 'remote-no-matched', type: 'remote', api_url: 'http://remote-no-matched.example:1852',
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
});
try {
const label = db.createLabel(remoteId, `remote-no-matched-${++labelCounter}`, 'teal');
db.setStackLabels('alpha', remoteId, [label.id]);
// The previous default (`matched: remote.matched ?? true`) turned this body
// into a matched:true/zero-stack success; the guard must fail the node.
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true, status: 200, json: async () => ({ results: [] }),
} as unknown as Response);
const res = await request(app)
.post('/api/fleet/labels/fleet-stop')
.set('Authorization', authHeader)
.send({ labelName: label.name });
expect(res.status).toBe(200);
const remoteRow = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
expect(remoteRow.reachable).toBe(false);
expect(remoteRow.matched).toBe(false);
expect(remoteRow.error).toMatch(/malformed/i);
} finally {
db.deleteNode(remoteId);
}
+22
View File
@@ -28,6 +28,28 @@ export interface LabelLocalStopResponse {
results: StackStopResult[];
}
function isStackStopResult(value: unknown): value is StackStopResult {
if (typeof value !== 'object' || value === null) return false;
const r = value as Record<string, unknown>;
return typeof r.stackName === 'string' && typeof r.success === 'boolean';
}
/**
* Validate a remote node's `local-stop` 200 body before the control trusts it.
* A response that is not this exact shape (a missing/non-boolean `matched`, a
* non-array `results`, or a malformed result element) is a remote contract
* failure, not an empty stop. The control-side fan-out fails that node rather
* than defaulting `matched` to true and `results` to [], which the UI would
* otherwise render as a successful zero-stack node and hide the failure.
*/
export function isLabelLocalStopResponse(value: unknown): value is LabelLocalStopResponse {
if (typeof value !== 'object' || value === null) return false;
const r = value as Record<string, unknown>;
return typeof r.matched === 'boolean'
&& Array.isArray(r.results)
&& r.results.every(isStackStopResult);
}
/**
* Run a label-name-matched container stop against one node's own local Docker.
*
+12 -7
View File
@@ -37,7 +37,7 @@ import { CloudBackupService } from '../services/CloudBackupService';
import { NotificationService } from '../services/NotificationService';
import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cacheInvalidation';
import { activeBulkActions } from './labels';
import { runLocalLabelStop, type LabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
@@ -1320,14 +1320,19 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
const err = (await response.json().catch(() => ({}))) as { error?: string };
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: err.error || `Remote returned ${response.status}` };
}
// Trust the remote's own matched flag. Guard results as an array so a
// malformed 200 body degrades to empty rather than flowing a non-array
// into the per-stack renderers.
const remote = (await response.json()) as Partial<LabelLocalStopResponse>;
// A 200 with a body that isn't the local-stop contract is a remote
// failure, not an empty stop. Fail the node (matching the non-ok and
// unreachable paths above) instead of defaulting matched to true and
// results to [], which the UI renders as a successful zero-stack node
// and hides the remote contract break.
const remote = await response.json().catch(() => null);
if (!isLabelLocalStopResponse(remote)) {
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: 'Remote returned a malformed response' };
}
return {
nodeId: node.id, nodeName: node.name, reachable: true,
matched: remote.matched ?? true,
stackResults: Array.isArray(remote.results) ? remote.results : [],
matched: remote.matched,
stackResults: remote.results,
};
} catch (err) {
return { nodeId: node.id, nodeName: node.name, reachable: false, matched: false, stackResults: [], error: getErrorMessage(err, 'Failed to reach remote node') };