mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
2a29fed117
* feat(labels): harden stack-labels surface (gate parity, abort, dry-run policy, cap) - Cap labelIds array at MAX_LABELS_PER_NODE on PUT /api/stacks/:name/labels. - Hide sidebar Labels submenu and Settings mutation buttons for roles without stack:edit, matching the backend requirePermission gate. - Break the per-label bulk-action loop on req.aborted so cancelled requests release the per-node lock once the in-flight op completes. - Reset saving state on success in LabelInlineCreateForm so the form stays interactive when reused outside the kebab/context menus. - Invoke enforcePolicyPreDeploy inside the dry-run deploy branch and report blocked stacks honestly; previously dry-run skipped the gate and would predict success for stacks the real deploy would block. * fix(labels): split sidebar gate so inline create matches unscoped backend guard Codex review of #1232 surfaced a parity miss: POST /api/labels is guarded by unscoped requirePermission('stack:edit'), but the sidebar inline "New label" entry was gated by canEditLabels (per-stack scoped). An Admiral user with only scoped grants on a stack could toggle existing labels but the inline create request would 403. Splits the sidebar gate: - canEditLabels (scoped) keeps gating the Labels submenu trigger and toggle items, matching PUT /api/stacks/:name/labels. - canCreateLabels (unscoped) now gates the inline "New label" entry, matching POST /api/labels. Also restores the swallow-catch in LabelInlineCreateForm. The earlier catch-to-finally change in #1232 let the rethrow from createAndAssignLabel surface as an unhandled event-handler rejection in the browser console. Parents already toast on failure; swallowing in the form is the intended behavior with the finally reset still in place.
323 lines
14 KiB
TypeScript
323 lines
14 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { FileSystemService } from '../services/FileSystemService';
|
|
import { ComposeService } from '../services/ComposeService';
|
|
import DockerController from '../services/DockerController';
|
|
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { requirePermission } from '../middleware/permissions';
|
|
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
|
import { buildPolicyGateOptions } from '../helpers/policyGate';
|
|
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
|
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from '../helpers/constants';
|
|
import { isValidStackName } from '../utils/validation';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
|
|
import { parseIntParam } from '../utils/parseIntParam';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
// Module-scope lock shared by `POST /api/labels/:id/action` and the fleet-wide
|
|
// bulk endpoints in `routes/fleet.ts`. Keyed by `${nodeId}` so concurrent bulk
|
|
// actions targeting the same node serialize and a fleet-stop cannot race a
|
|
// per-label action on the same containers.
|
|
export const activeBulkActions = new Set<string>();
|
|
|
|
export const labelsRouter = Router();
|
|
|
|
labelsRouter.get('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
const labels = DatabaseService.getInstance().getLabels(nodeId);
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] List labels: nodeId=', nodeId, 'count=', labels.length);
|
|
res.json(labels);
|
|
} catch (error) {
|
|
console.error('[Labels] List error:', error);
|
|
res.status(500).json({ error: 'Failed to list labels' });
|
|
}
|
|
});
|
|
|
|
labelsRouter.post('/', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePermission(req, res, 'stack:edit')) return;
|
|
if (!requireBody(req, res)) return;
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
const { name, color } = req.body;
|
|
|
|
if (!name || typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
|
|
res.status(400).json({ error: 'name is required and must be 1-30 characters' });
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
|
|
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
|
|
return;
|
|
}
|
|
if (!color || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
|
|
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
|
|
return;
|
|
}
|
|
|
|
const db = DatabaseService.getInstance();
|
|
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
|
|
res.status(409).json({ error: `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached` });
|
|
return;
|
|
}
|
|
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Create label:', { nodeId, name: name.trim(), color });
|
|
const label = db.createLabel(nodeId, name.trim(), color);
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Created label:', label.id);
|
|
res.status(201).json(label);
|
|
} catch (error: unknown) {
|
|
if (isSqliteUniqueViolation(error)) {
|
|
res.status(409).json({ error: 'A label with that name already exists' });
|
|
return;
|
|
}
|
|
console.error('[Labels] Create error:', error);
|
|
res.status(500).json({ error: 'Failed to create label' });
|
|
}
|
|
});
|
|
|
|
labelsRouter.get('/assignments', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const nodeId = req.nodeId ?? 0;
|
|
const db = DatabaseService.getInstance();
|
|
const assignments = db.getLabelsForStacks(nodeId);
|
|
|
|
// Opportunistic cleanup: only scan the filesystem when there are
|
|
// assignments to validate.
|
|
const assignedStacks = Object.keys(assignments);
|
|
if (assignedStacks.length > 0) {
|
|
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
|
const fsSet = new Set(fsStacks);
|
|
const staleNames = assignedStacks.filter(name => !fsSet.has(name));
|
|
if (staleNames.length > 0) {
|
|
db.cleanupStaleAssignments(nodeId, fsStacks);
|
|
for (const name of staleNames) {
|
|
delete assignments[name];
|
|
}
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Cleaned up stale assignments:', staleNames);
|
|
}
|
|
}
|
|
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Assignments: nodeId=', nodeId, 'stacks=', Object.keys(assignments).length);
|
|
res.json(assignments);
|
|
} catch (error) {
|
|
console.error('[Labels] Assignments error:', error);
|
|
res.status(500).json({ error: 'Failed to fetch label assignments' });
|
|
}
|
|
});
|
|
|
|
labelsRouter.put('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePermission(req, res, 'stack:edit')) return;
|
|
if (!requireBody(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'label ID');
|
|
if (id === null) return;
|
|
const nodeId = req.nodeId ?? 0;
|
|
const { name, color } = req.body;
|
|
|
|
if (name !== undefined) {
|
|
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
|
|
res.status(400).json({ error: 'name must be 1-30 characters' });
|
|
return;
|
|
}
|
|
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
|
|
res.status(400).json({ error: 'name may only contain letters, numbers, spaces, and hyphens' });
|
|
return;
|
|
}
|
|
}
|
|
if (color !== undefined && !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
|
|
res.status(400).json({ error: `color must be one of: ${VALID_LABEL_COLORS.join(', ')}` });
|
|
return;
|
|
}
|
|
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Update label:', { id, nodeId, name: name?.trim(), color });
|
|
const updated = DatabaseService.getInstance().updateLabel(id, nodeId, {
|
|
name: name?.trim(),
|
|
color,
|
|
});
|
|
if (!updated) {
|
|
res.status(404).json({ error: 'Label not found' });
|
|
return;
|
|
}
|
|
res.json(updated);
|
|
} catch (error: unknown) {
|
|
if (isSqliteUniqueViolation(error)) {
|
|
res.status(409).json({ error: 'A label with that name already exists' });
|
|
return;
|
|
}
|
|
console.error('[Labels] Update error:', error);
|
|
res.status(500).json({ error: 'Failed to update label' });
|
|
}
|
|
});
|
|
|
|
labelsRouter.delete('/:id', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePermission(req, res, 'stack:edit')) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'label ID');
|
|
if (id === null) return;
|
|
const nodeId = req.nodeId ?? 0;
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Delete label:', { id, nodeId });
|
|
DatabaseService.getInstance().deleteLabel(id, nodeId);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[Labels] Delete error:', error);
|
|
res.status(500).json({ error: 'Failed to delete label' });
|
|
}
|
|
});
|
|
|
|
labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
if (!requirePaid(req, res)) return;
|
|
if (!requireAdmin(req, res)) return;
|
|
if (!requireBody(req, res)) return;
|
|
try {
|
|
const id = parseIntParam(req, res, 'id', 'label ID');
|
|
if (id === null) return;
|
|
const { action, dryRun } = req.body;
|
|
const validActions = ['deploy', 'stop', 'restart'];
|
|
if (!action || !validActions.includes(action)) {
|
|
res.status(400).json({ error: `action must be one of: ${validActions.join(', ')}` });
|
|
return;
|
|
}
|
|
const isDryRun = dryRun === true;
|
|
|
|
const nodeId = req.nodeId ?? 0;
|
|
|
|
const label = DatabaseService.getInstance().getLabel(id, nodeId);
|
|
if (!label) {
|
|
res.status(404).json({ error: 'Label not found' });
|
|
return;
|
|
}
|
|
|
|
const lockKey = `bulk:${nodeId}`;
|
|
if (activeBulkActions.has(lockKey)) {
|
|
res.status(429).json({ error: 'A bulk action is already running for this node. Please wait.' });
|
|
return;
|
|
}
|
|
activeBulkActions.add(lockKey);
|
|
|
|
try {
|
|
const stackNames = DatabaseService.getInstance().getStacksForLabel(id, nodeId);
|
|
const fsStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
|
const fsStackNames = new Set(fsStacks);
|
|
const validStacks = stackNames.filter(name => fsStackNames.has(name));
|
|
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action start:', { id, action, nodeId, totalLabeled: stackNames.length, validStacks: validStacks.length, dryRun: isDryRun });
|
|
|
|
const results: { stackName: string; success: boolean; error?: string; dryRun?: boolean }[] = [];
|
|
|
|
for (const stackName of validStacks) {
|
|
// Client disconnected mid-bulk: stop dispatching new per-stack ops.
|
|
// The currently in-flight call still runs to completion; the outer
|
|
// finally releases the lock when it returns. Stays on `req.aborted`
|
|
// rather than `req.destroyed` because supertest's in-process server
|
|
// mode flips `destroyed` between handler and response write, which
|
|
// would cause every bulk-action test to look like a client abort.
|
|
if (req.aborted) {
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action aborted by client at stack:', stackName);
|
|
break;
|
|
}
|
|
try {
|
|
if (action === 'deploy') {
|
|
// Policy gate runs for both real and dry-run deploys: a dry-run
|
|
// that omits the policy check would falsely report success for
|
|
// stacks the real deploy would block.
|
|
const gate = await enforcePolicyPreDeploy(
|
|
stackName,
|
|
req.nodeId,
|
|
buildPolicyGateOptions(req),
|
|
);
|
|
if (!gate.ok) {
|
|
const blockedMsg = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
|
|
results.push({ stackName, success: false, error: blockedMsg, ...(isDryRun ? { dryRun: true } : {}) });
|
|
continue;
|
|
}
|
|
if (isDryRun) {
|
|
results.push({ stackName, success: true, dryRun: true });
|
|
continue;
|
|
}
|
|
await ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false);
|
|
} else {
|
|
// stop / restart have no pre-action policy gate; dry-run just
|
|
// confirms the stack would be reached.
|
|
if (isDryRun) {
|
|
results.push({ stackName, success: true, dryRun: true });
|
|
continue;
|
|
}
|
|
const dockerController = DockerController.getInstance(req.nodeId);
|
|
const containers = await dockerController.getContainersByStack(stackName);
|
|
if (action === 'stop') {
|
|
await Promise.all(containers.map(c => dockerController.stopContainer(c.Id)));
|
|
} else {
|
|
await Promise.all(containers.map(c => dockerController.restartContainer(c.Id)));
|
|
}
|
|
}
|
|
results.push({ stackName, success: true });
|
|
} catch (err: unknown) {
|
|
results.push({ stackName, success: false, error: getErrorMessage(err, 'Unknown error') });
|
|
}
|
|
}
|
|
|
|
const succeeded = results.filter(r => r.success).length;
|
|
const failed = results.length - succeeded;
|
|
// Two-axis truncation reporting: `results.length` is what we actually
|
|
// processed; `validStacks.length` is what we set out to process. They
|
|
// differ when the client aborted mid-loop.
|
|
console.log(`[Labels] Bulk ${sanitizeForLog(action)}${isDryRun ? ' (dry run)' : ''} on label ${id}: ${results.length}/${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, processed: results.length, total: validStacks.length, succeeded, failed, dryRun: isDryRun });
|
|
|
|
if (succeeded > 0 && !isDryRun) {
|
|
invalidateNodeCaches(req.nodeId);
|
|
}
|
|
// Writing the response is harmless even if the client already
|
|
// disconnected; Node swallows the EPIPE / write-after-end. The
|
|
// lock release in the outer finally still happens.
|
|
res.json({ results });
|
|
} finally {
|
|
activeBulkActions.delete(lockKey);
|
|
}
|
|
} catch (error) {
|
|
console.error('[Labels] Bulk action error:', error);
|
|
res.status(500).json({ error: 'Failed to execute bulk action' });
|
|
}
|
|
});
|
|
|
|
// Mounted at `/api/stacks` so `/:stackName/labels` handles
|
|
// PUT /api/stacks/:stackName/labels. Kept alongside the other label handlers
|
|
// rather than bundled with the stack router because the underlying data
|
|
// model is "labels that reference stacks" - a label concern with a stack
|
|
// addressable path.
|
|
export const stackLabelsRouter = Router();
|
|
|
|
stackLabelsRouter.put('/:stackName/labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
|
try {
|
|
const stackName = req.params.stackName as string;
|
|
if (!isValidStackName(stackName)) {
|
|
res.status(400).json({ error: 'Invalid stack name' });
|
|
return;
|
|
}
|
|
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
|
if (!requireBody(req, res)) return;
|
|
const nodeId = req.nodeId ?? 0;
|
|
const { labelIds } = req.body;
|
|
|
|
if (!Array.isArray(labelIds) || !labelIds.every((id: unknown) => typeof id === 'number')) {
|
|
res.status(400).json({ error: 'labelIds must be an array of numbers' });
|
|
return;
|
|
}
|
|
// A node can hold at most MAX_LABELS_PER_NODE labels, so any stack
|
|
// assignment over that count is either an authenticated client mistake
|
|
// or a deliberate transaction-bloat attempt. Reject before the DB sees it.
|
|
if (labelIds.length > MAX_LABELS_PER_NODE) {
|
|
res.status(400).json({ error: `labelIds may not exceed ${MAX_LABELS_PER_NODE} entries` });
|
|
return;
|
|
}
|
|
|
|
if (isDebugEnabled()) console.debug('[Labels:debug] Set stack labels:', { stackName, nodeId, labelIds });
|
|
DatabaseService.getInstance().setStackLabels(stackName, nodeId, labelIds);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
console.error('[Labels] Set stack labels error:', error);
|
|
res.status(500).json({ error: 'Failed to set stack labels' });
|
|
}
|
|
});
|