Files
sencho/backend/src/helpers/fleetLabelAssign.ts
T
Anso d26ab58189 feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)
* feat(fleet): cross-node bulk label assign with authoritative label discovery

Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack
label that exists anywhere in the fleet, select stacks on one or more nodes,
and the control orchestrates: each target node resolves the label by name,
creating it with the same name and color if missing, then adds it to the
selected stacks while preserving their existing labels. The local node runs
in process; each remote runs its own admin-only local-assign receiver over
the node proxy. Per-node failures (unknown node, no proxy target, unreachable,
mixed-version remote) degrade that node only and are reported per node in the
result. Assignment writes use a transactional INSERT OR IGNORE so the
add-preserve path is idempotent and race-free.

Also make the shared fleet label discovery authoritative: suggestions,
match-preview, and the fleet-stop remote leg now read each node's labels live
over the proxy instead of the control database, which does not mirror remote
labels. A propagated label therefore appears in, and is stoppable by,
Stop-by-label across the fleet, and unreachable nodes are surfaced rather than
silently dropped.

Fleet Actions runs against the unfiltered node list, so overview filters no
longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign
endpoint is removed.

* fix(fleet): treat malformed remote label responses as per-node failures

A 200 response from a remote node whose body is not the expected shape was
treated as a benign empty result, so a malformed remote could read as a clean
zero-stack assign or a "matched, nothing to stop" no-op and even surface a
success toast. Validate the wire shape in the bulk-assign and fleet-stop remote
legs and in the authoritative label discovery fan-out; on a malformed body,
report the node as a per-node failure with the error attributed to its stacks
instead of silently dropping it.

* chore: drop accidentally committed temp file
2026-06-20 11:52:28 -04:00

138 lines
5.2 KiB
TypeScript

import { DatabaseService } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from './constants';
import { isValidStackName } from '../utils/validation';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
export interface LabelTemplate {
name: string;
color: string;
}
export interface LabelAssignResult {
stackName: string;
success: boolean;
error?: string;
}
export interface LabelAssignOutcome {
/** True when this node did not have the label and it was created here. */
created: boolean;
stackResults: LabelAssignResult[];
}
/**
* Wire shape of `POST /api/fleet-actions/labels/local-assign`. The in-process
* helper returns `stackResults`; the HTTP response names the same array
* `results` to match the assign fan-out's remote contract. Keep the rename in
* this one type so the producer and the control-side consumer cannot drift.
*/
export interface LabelLocalAssignResponse {
created: boolean;
results: LabelAssignResult[];
}
/**
* Per-node row in the fleet bulk-assign orchestrator response
* (`POST /api/fleet/labels/bulk-assign`). `reachable` is always set; `error`
* carries the node-level cause when a node could not be reached or resolved.
*/
export interface AssignNodeResult {
nodeId: number;
nodeName: string;
reachable: boolean;
created: boolean;
error?: string;
stackResults: LabelAssignResult[];
}
/** Attribute one node-level error to every stack a node was meant to receive. */
export function failAllAssign(stackNames: string[], error: string): LabelAssignResult[] {
return Array.from(new Set(stackNames)).map(stackName => ({ stackName, success: false, error }));
}
/**
* 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
* validator shared by the per-node receiver and the fleet orchestrator.
*/
export function validateLabelTemplate(
input: unknown,
): { ok: true; template: LabelTemplate } | { ok: false; error: string } {
if (!input || typeof input !== 'object') {
return { ok: false, error: 'label is required' };
}
const { name, color } = input as { name?: unknown; color?: unknown };
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
return { ok: false, error: 'label.name is required and must be 1-30 characters' };
}
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
return { ok: false, error: 'label.name may only contain letters, numbers, spaces, and hyphens' };
}
if (typeof color !== 'string' || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
return { ok: false, error: `label.color must be one of: ${VALID_LABEL_COLORS.join(', ')}` };
}
return { ok: true, template: { name: name.trim(), color } };
}
/**
* Resolve-or-create a label by name on one node, then assign it to the given
* stacks while preserving their existing labels (add semantics).
*
* Used by the gateway-orchestrated bulk-assign for the control node's own stacks
* and by the per-node `POST /api/fleet-actions/labels/local-assign` receiver that
* a control instance calls on each remote. Matching/creating by name (never by a
* shared id) keeps labels node-local: each node owns its own label id, so the
* control never reuses a local id on a remote.
*/
export async function runLocalLabelAssign(
nodeId: number,
label: LabelTemplate,
stackNames: string[],
): Promise<LabelAssignOutcome> {
const db = DatabaseService.getInstance();
// Resolve the label on this node by exact name; create it if missing.
let resolved = db.getLabels(nodeId).find(l => l.name === label.name);
let created = false;
if (!resolved) {
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
return { created: false, stackResults: failAllAssign(stackNames, `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached`) };
}
try {
resolved = db.createLabel(nodeId, label.name, label.color);
created = true;
} catch (err) {
// A concurrent create can win the UNIQUE(node_id, name) race; re-fetch and
// reuse the now-existing label rather than failing the assignment.
if (isSqliteUniqueViolation(err)) {
resolved = db.getLabels(nodeId).find(l => l.name === label.name);
}
if (!resolved) {
return { created: false, stackResults: failAllAssign(stackNames, getErrorMessage(err, 'Failed to create label')) };
}
}
}
const labelId = resolved.id;
const fsStacks = new Set(await FileSystemService.getInstance(nodeId).getStacks());
const stackResults: LabelAssignResult[] = [];
for (const stackName of Array.from(new Set(stackNames))) {
if (!isValidStackName(stackName)) {
stackResults.push({ stackName, success: false, error: 'Invalid stack name' });
continue;
}
if (!fsStacks.has(stackName)) {
stackResults.push({ stackName, success: false, error: 'Stack not found' });
continue;
}
try {
db.addStackLabels(stackName, nodeId, [labelId]);
stackResults.push({ stackName, success: true });
} catch (err) {
stackResults.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to assign label') });
}
}
return { created, stackResults };
}