mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
519a59ed2e
* feat(fleet): open Fleet Actions tab to Community (admin-only) Removes the requirePaid guard from the five Fleet Actions endpoints (fleet-stop, fleet-prune, match-preview, prune/estimate, bulk-assign) and drops the matching isPaid parent gate on FleetActionsTab so Community admins can run fleet-wide bulk operations. requireAdmin stays on every endpoint; operator and viewer roles still 403 on apply. Tests flipped from "403 PAID_REQUIRED on community" to positive "reachable on community + admin" assertions. Docs (fleet-actions, fleet-view, licensing, overview, stack-labels) rewritten to state the admin-role requirement once and drop the prior Skipper framing. * fix(fleet): apply audit findings from PR #1153 review - stack-labels.mdx: fix the page intro that still framed fleet label actions as "Operators on a Skipper or Admiral license". The cards are now Community + admin, so the intro reads "Admins also get a pair of fleet-wide actions". - Collapse redundant role-rule statements on the two affected pages. fleet-actions.mdx now states the admin gate once in the lead-in Note and again only in the troubleshooting accordion (the Prerequisites row was duplicative). stack-labels.mdx trims the "Limits and rules" bullet to the value-add half (label authoring is open to every role) and drops the Fleet Actions repetition. - Strip now-no-op mockTier('paid') calls from non-tier tests across the three fleet test files, plus the test-wide default in the fleet-action-card-endpoints beforeEach. Those mocks were misleading after the routes stopped consulting tier; if a future change re-adds requirePaid the tests will fail loudly instead of silently passing.
69 lines
3.2 KiB
TypeScript
69 lines
3.2 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requireAdmin, requireBody } from '../middleware/tierGates';
|
|
import { isValidStackName } from '../utils/validation';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
|
|
// Per-node fleet-action endpoints. Mounted under `/api/fleet-actions/`, which
|
|
// is NOT in `PROXY_EXEMPT_PREFIXES`, so when `x-node-id` targets a remote node
|
|
// the gateway proxies the call and the remote Sencho instance runs its own
|
|
// local handler. Multi-node orchestration endpoints live in `routes/fleet.ts`
|
|
// because their path must sit behind the `/api/fleet/` proxy-exempt prefix.
|
|
export const fleetActionsRouter = Router();
|
|
|
|
// Hard cap to bound a single bulk-assign request. A node typically has tens of
|
|
// stacks, not thousands; the cap protects against accidental or malicious
|
|
// payloads that would force thousands of DB writes in one handler.
|
|
const MAX_ASSIGNMENTS = 1000;
|
|
|
|
// Bulk label assignment for many stacks on a single node. The single-stack
|
|
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
|
|
// this wrapper applies the same operation to many stacks atomically per HTTP
|
|
// request. Tier: requireAdmin (admin-only fleet plumbing). The per-stack
|
|
// endpoint is Community-tier organization metadata; this multi-stack wrapper
|
|
// matches the surrounding Fleet Actions surface, which is admin-only but
|
|
// available on every license.
|
|
fleetActionsRouter.post(
|
|
'/labels/bulk-assign',
|
|
authMiddleware,
|
|
async (req: Request, res: Response): Promise<void> => {
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireBody(req, res)) return;
|
|
const { assignments } = req.body as { assignments?: unknown };
|
|
if (!Array.isArray(assignments)) {
|
|
res.status(400).json({ error: 'assignments must be an array' });
|
|
return;
|
|
}
|
|
if (assignments.length > MAX_ASSIGNMENTS) {
|
|
res.status(400).json({ error: `assignments may not exceed ${MAX_ASSIGNMENTS} entries` });
|
|
return;
|
|
}
|
|
const nodeId = req.nodeId ?? 0;
|
|
const db = DatabaseService.getInstance();
|
|
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
|
for (const entry of assignments as unknown[]) {
|
|
if (!entry || typeof entry !== 'object') {
|
|
results.push({ stackName: '', success: false, error: 'Invalid assignment entry' });
|
|
continue;
|
|
}
|
|
const { stackName, labelIds } = entry as { stackName?: unknown; labelIds?: unknown };
|
|
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
|
|
results.push({ stackName: typeof stackName === 'string' ? stackName : '', success: false, error: 'Invalid stack name' });
|
|
continue;
|
|
}
|
|
if (!Array.isArray(labelIds) || !labelIds.every(id => typeof id === 'number')) {
|
|
results.push({ stackName, success: false, error: 'labelIds must be an array of numbers' });
|
|
continue;
|
|
}
|
|
try {
|
|
db.setStackLabels(stackName, nodeId, labelIds);
|
|
results.push({ stackName, success: true });
|
|
} catch (err) {
|
|
results.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to set stack labels') });
|
|
}
|
|
}
|
|
res.json({ results });
|
|
},
|
|
);
|