fix(fleet-actions): stop-by-label works on Community remote nodes (#1270)

* fix(fleet-actions): stop-by-label works on Community remote nodes

Fleet-stop's remote leg fanned out to POST /api/labels/:id/action, which
is gated to Skipper/Admiral, so on a Community fleet the control node
stopped its own stacks but every remote node returned 403. Fleet-stop
itself is admin-only and available on every license, so the remote leg
contradicted the feature's own gate.

Extract the label-match plus bulk-stop logic into a shared
runLocalLabelStop helper and add an admin-only, every-license
POST /api/fleet-actions/labels/local-stop receiver. The control now fans
out to that receiver, so remote stacks stop on every tier. Each node runs
under its own per-node bulk lock, so a fleet-stop and a per-label action
still serialize cleanly instead of double-stopping containers.

Also degrade the control's own leg per-node instead of failing the whole
fan-out when its filesystem read throws, and gate fleet-stop and
fleet-prune diagnostics behind developer_mode.

Tests: local-stop auth, tier, validation, and behavior; a remote-leg
routing guard that asserts the fan-out targets local-stop and never the
paid route; local-leg graceful degradation; and the three Fleet Action
card UIs.

* fix(fleet-actions): honor the remote stop receiver's matched flag

The control reached the remote leg only because its own mirror had the
label, then hardcoded matched:true and trusted results without guarding
its shape. A mirror-skewed control (mirror has the label, remote does
not) then showed a remote mismatch as "matched, 0 stacks" instead of
"no matching label", and a malformed 200 body could flow a non-array
into the per-stack renderers.

Honor the remote's own matched flag and coerce results to an array when
the body is malformed. Add regression tests for the matched:false skew
case and the non-array results case.
This commit is contained in:
Anso
2026-06-01 14:18:44 -04:00
committed by GitHub
parent 085267b466
commit 7e0cffa376
9 changed files with 797 additions and 44 deletions
+87
View File
@@ -0,0 +1,87 @@
import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { containerActionForStack } from '../routes/stacks';
import { activeBulkActions } from '../routes/labels';
import { invalidateNodeCaches } from './cacheInvalidation';
export interface StackStopResult {
stackName: string;
success: boolean;
error?: string;
dryRun?: boolean;
}
export interface LabelStopOutcome {
matched: boolean;
stackResults: StackStopResult[];
}
/**
* Wire shape of `POST /api/fleet-actions/labels/local-stop`. The in-process
* helper returns `stackResults`; the HTTP response names the same array
* `results` to match the fleet-stop fan-out's existing remote contract. Keep
* the rename in this one type so the producer and the control-side consumer
* cannot drift.
*/
export interface LabelLocalStopResponse {
matched: boolean;
results: StackStopResult[];
}
/**
* Run a label-name-matched container stop against one node's own local Docker.
*
* Used by the gateway-orchestrated fleet-stop for the control node's own stacks
* and by the per-node `POST /api/fleet-actions/labels/local-stop` receiver that
* a control instance calls on each remote during a fleet-wide stop. Matching by
* name (not by a shared label id) keeps the work self-contained on the executing
* node, so the control never has to assert that its mirrored label ids line up
* with the remote's local ids.
*
* Shares the per-node `bulk:<nodeId>` lock with `POST /api/labels/:id/action` so
* a fleet-stop and a per-label action cannot double-stop the same containers.
*/
export async function runLocalLabelStop(
nodeId: number,
labelName: string,
dryRun: boolean,
): Promise<LabelStopOutcome> {
const db = DatabaseService.getInstance();
const label = db.getLabels(nodeId).find(l => l.name === labelName);
if (!label) return { matched: false, stackResults: [] };
const stackNames = db.getStacksForLabel(label.id, nodeId);
if (stackNames.length === 0) return { matched: true, stackResults: [] };
const lockKey = `bulk:${nodeId}`;
if (activeBulkActions.has(lockKey)) {
return {
matched: true,
stackResults: stackNames.map(stackName => ({
stackName,
success: false,
error: 'A bulk action is already running on this node',
})),
};
}
activeBulkActions.add(lockKey);
try {
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
const fsStackSet = new Set(fsStacks);
const validStacks = stackNames.filter(name => fsStackSet.has(name));
const stackResults: StackStopResult[] = [];
for (const stackName of validStacks) {
if (dryRun) {
stackResults.push({ stackName, success: true, dryRun: true });
continue;
}
const outcome = await containerActionForStack(nodeId, stackName, 'stop');
if (outcome.kind === 'ok') stackResults.push({ stackName, success: true });
else if (outcome.kind === 'no-containers') stackResults.push({ stackName, success: false, error: 'No containers found for this stack' });
else stackResults.push({ stackName, success: false, error: outcome.message });
}
if (!dryRun && stackResults.some(r => r.success)) invalidateNodeCaches(nodeId);
return { matched: true, stackResults };
} finally {
activeBulkActions.delete(lockKey);
}
}