feat(stacks): server-side POST /api/stacks/bulk endpoint (#1185)

* feat(stacks): server-side POST /api/stacks/bulk

The frontend's bulk action UI fanned out N parallel POSTs to
/api/stacks/:name/{start,stop,restart,update}. For 30 stacks on a
remote node that was 30 round-trips through the proxy + auth + audit
chain, with no shared mutex and partial-failure UX bolted on the
client.

The new endpoint accepts {action, stackNames} (max 100 names), runs
ops under bounded parallelism (4 concurrent), reuses the per-(nodeId,
stackName) lock from the lifecycle-mutex change so collisions report
stack_op_in_progress as a per-row outcome, and returns
{action, results: [{stackName, ok, error?, code?}, ...]} with a 200
envelope. Per-stack errors are rows, not response codes.

Update action keeps the policy-enforcement check (per-stack, returns
policy_blocked rows) and the post-deploy scan trigger so the
single-stack security contract is preserved. State-invalidate and
image-update notifications fire per successful row so the activity
timeline and image-updates UI reflect bulk operations the same way
as single-stack ones.

Frontend useBulkStackActions swaps the Promise.allSettled fan-out for
a single call; the per-stack toast aggregation moves to reading the
results array. isPaid pre-flight stays in place to avoid a round trip
for Community-tier users on update.

* chore(stacks): dedupe bulk inputs; document tier asymmetry; regression test

Three follow-ups from independent review:

- Dedupe stackNames before scheduling so a payload like ['web','web']
  produces one row, not one ok-row plus one stack_op_in_progress row
  whose presence depended on worker scheduling.
- Add a route-ordering regression test verifying that a stack literally
  named 'bulk' is still reachable via /api/stacks/bulk/restart. Express
  matches the literal /bulk before /:stackName paths only at the
  no-suffix level; the :stackName/restart route still catches it.
- Comment the deliberate tier asymmetry: bulk update is requirePaid;
  single-stack /:stackName/update is open to all tiers. The fan-out
  blast radius is the reason, and it matches the prior frontend gate.

Existing 'policy_blocked per-row' test now uses the real ScanPolicy /
PolicyViolation / PolicyEnforcementResult shapes (the first cut elided
fields tsc strict-checked).
This commit is contained in:
Anso
2026-05-24 15:56:53 -04:00
committed by GitHub
parent 27b8954676
commit 5aedc52737
3 changed files with 552 additions and 24 deletions
+46 -23
View File
@@ -17,6 +17,18 @@ interface BulkCallbacks {
onAfter?: (files: string[]) => void;
}
interface BulkResultItem {
stackName: string;
ok: boolean;
error?: string;
code?: string;
}
interface BulkResponse {
action: BulkAction;
results: BulkResultItem[];
}
export function useBulkStackActions() {
const { isPaid } = useLicense();
@@ -33,32 +45,43 @@ export function useBulkStackActions() {
cbs?.onBefore?.(files);
const results = await Promise.allSettled(
files.map(file => {
const stackName = file.replace(/\.(yml|yaml)$/, '');
const headers: Record<string, string> = action === 'update' ? { 'x-bulk-mode': '1' } : {};
return apiFetch(`/stacks/${encodeURIComponent(stackName)}/${action}`, {
method: 'POST',
headers,
}).then(res => {
if (!res.ok) return Promise.reject(new Error(file));
return file;
});
})
);
const stackNames = files.map(file => file.replace(/\.(yml|yaml)$/, ''));
cbs?.onAfter?.(files);
try {
const response = await apiFetch('/stacks/bulk', {
method: 'POST',
body: JSON.stringify({ action, stackNames }),
});
const failed = results
.filter((r): r is PromiseRejectedResult => r.status === 'rejected')
.map(r => (r.reason as Error).message);
const okCount = results.length - failed.length;
cbs?.onAfter?.(files);
if (failed.length === 0) {
const noun = okCount === 1 ? 'stack' : 'stacks';
toast.success(`${okCount} ${noun} ${pastTense[action]}`);
} else {
toast.error(`${okCount} of ${files.length} ${pastTense[action]}; ${failed.length} failed: ${failed.join(', ')}`);
if (!response.ok) {
const errBody = await response.json().catch(() => ({}));
const errMsg = (errBody as { error?: string })?.error
?? `Bulk ${action} failed (HTTP ${response.status})`;
toast.error(errMsg);
return;
}
const payload = (await response.json()) as BulkResponse;
const results = Array.isArray(payload.results) ? payload.results : [];
const okCount = results.filter(r => r.ok).length;
const failed = results.filter(r => !r.ok);
if (failed.length === 0) {
const noun = okCount === 1 ? 'stack' : 'stacks';
toast.success(`${okCount} ${noun} ${pastTense[action]}`);
return;
}
const failedNames = failed.map(r => r.stackName).join(', ');
toast.error(
`${okCount} of ${results.length} ${pastTense[action]}; ${failed.length} failed: ${failedNames}`,
);
} catch (err) {
cbs?.onAfter?.(files);
console.error('Bulk action failed:', err);
toast.error(`Bulk ${action} failed: ${(err as Error).message}`);
}
}, [isPaid]);