mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user