mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 20:29:15 +00:00
fix: harden cross-node fleet label actions and guard container reads (#1503)
* fix: harden cross-node fleet label actions and guard container reads
Release-stabilization fixes for the Fleet Actions surface:
- Stop-by-label binds execution to the nodes shown in the confirmed
preview. The real stop sends the confirmed node ids and the backend
restricts the fan-out to them, so a node that was unreachable during
preview and reconnects before the stop can no longer enter execution
and have unlisted stacks stopped.
- Bulk label assign validates each remote node's result against the
stacks it was asked to label: a body whose results are empty, partial,
duplicated, or shaped wrong is a per-node failure instead of reading as
a successful zero-stack assign. The card mirrors this, rejecting a
missing or non-array results body and only reporting success when at
least one stack was assigned.
- Bulk label assign re-reads authoritative per-node stacks and labels on
demand via a Refresh control, and the confirmation lists the affected
node and stack names rather than bare counts.
- The stack-specific and fleet container/stack read routes require the
stack:read permission, matching the generic container and stack routes.
Every shipped role already carries stack:read, so reachability is
unchanged; the guard closes the routes that were auth-only.
Adds unit coverage for the assign-result validator, route coverage for
the stop allowlist and assign membership checks, and authorization
coverage for the newly guarded reads.
* test: assert the confirmed node allowlist in the fleet stop-card test
The stop-card component test pinned the real-stop request body to
{ labelName, dryRun } and broke once the stop began carrying the
confirmed-preview node ids. Update it to expect the nodeIds allowlist
derived from the resolved preview, so the test asserts the binding
rather than the pre-fix shape.
This commit is contained in:
@@ -51,6 +51,48 @@ export function failAllAssign(stackNames: string[], error: string): LabelAssignR
|
||||
return Array.from(new Set(stackNames)).map(stackName => ({ stackName, success: false, error }));
|
||||
}
|
||||
|
||||
function isLabelAssignResult(value: unknown): value is LabelAssignResult {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const r = value as Record<string, unknown>;
|
||||
return typeof r.stackName === 'string'
|
||||
&& typeof r.success === 'boolean'
|
||||
&& (r.error === undefined || typeof r.error === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a remote node's `local-assign` 200 body before the control trusts it.
|
||||
*
|
||||
* Beyond the `{ created: boolean, results: LabelAssignResult[] }` shape, this
|
||||
* checks result *membership*: the receiver returns exactly one row per unique
|
||||
* requested stack, so a body that drops rows (an empty `results` for a non-empty
|
||||
* request), duplicates a stack, or returns a stack that was never requested is a
|
||||
* remote contract failure, not a clean assign. Without this, an empty `results`
|
||||
* passes the bare `Array.isArray` check and the control reports the node as a
|
||||
* successful zero-stack assign, which the UI then renders as success.
|
||||
*
|
||||
* `requestedStacks` is the per-node target list the control sent; it is deduped
|
||||
* here so the caller does not have to.
|
||||
*/
|
||||
export function validateRemoteAssignResults(
|
||||
requestedStacks: string[],
|
||||
body: unknown,
|
||||
): { ok: true; created: boolean; results: LabelAssignResult[] } | { ok: false } {
|
||||
if (!body || typeof body !== 'object') return { ok: false };
|
||||
const b = body as Record<string, unknown>;
|
||||
if (typeof b.created !== 'boolean' || !Array.isArray(b.results)) return { ok: false };
|
||||
const requested = new Set(requestedStacks);
|
||||
const seen = new Set<string>();
|
||||
const results: LabelAssignResult[] = [];
|
||||
for (const row of b.results) {
|
||||
if (!isLabelAssignResult(row)) return { ok: false };
|
||||
if (!requested.has(row.stackName) || seen.has(row.stackName)) return { ok: false };
|
||||
seen.add(row.stackName);
|
||||
results.push(row);
|
||||
}
|
||||
if (seen.size !== requested.size) return { ok: false };
|
||||
return { ok: true, created: b.created, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a label template (the name/color a cross-node assign propagates).
|
||||
* Mirrors the create-label rules in `routes/labels.ts` and is the single
|
||||
|
||||
Reference in New Issue
Block a user