mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 00:49:45 +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:
@@ -757,6 +757,93 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/labels/fleet-stop nodeIds allowlist', () => {
|
||||
// Guards the wrong-node drift fix: the real stop carries the node ids the
|
||||
// operator confirmed in the preview, so a node that was unreachable then and
|
||||
// reconnects before execution cannot silently enter the fan-out.
|
||||
it('contacts a confirmed remote in the allowlist and excludes the unconfirmed local node', async () => {
|
||||
const remoteId = db.addNode({
|
||||
name: 'confirmed-remote', type: 'remote', api_url: 'http://confirmed.example:1852',
|
||||
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
|
||||
});
|
||||
try {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true, status: 200, json: async () => ({ matched: true, results: [{ stackName: 'alpha', success: true }] }),
|
||||
} as unknown as Response);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'any-label', nodeIds: [remoteId] });
|
||||
expect(res.status).toBe(200);
|
||||
// Only the confirmed remote is in the fan-out: its local-stop receiver is
|
||||
// contacted and the unconfirmed local node is absent from the results.
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(res.body.results[0].nodeId).toBe(remoteId);
|
||||
const urls = fetchSpy.mock.calls.map(c => String(c[0]));
|
||||
expect(urls.some(u => u.endsWith('/api/fleet-actions/labels/local-stop'))).toBe(true);
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('excludes an otherwise-reachable remote that is not in the allowlist', async () => {
|
||||
const label = await createAssignedLabel('confirmed-only', ['alpha']);
|
||||
const localId = label.node_id;
|
||||
const remoteId = db.addNode({
|
||||
name: 'excluded-remote', type: 'remote', api_url: 'http://excluded.example:1852',
|
||||
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
|
||||
});
|
||||
try {
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: label.name, nodeIds: [localId] });
|
||||
expect(res.status).toBe(200);
|
||||
// The excluded remote has a proxy target and would otherwise be contacted;
|
||||
// the allowlist keeps it out of execution entirely.
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(res.body.results[0].nodeId).toBe(localId);
|
||||
expect(res.body.results.some((r: { nodeId: number }) => r.nodeId === remoteId)).toBe(false);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an empty allowlist as zero target nodes, stopping nothing', async () => {
|
||||
const label = await createAssignedLabel('empty-allow', ['alpha']);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: label.name, nodeIds: [] });
|
||||
expect(res.status).toBe(200);
|
||||
// An empty allowlist filters to no nodes: a fail-safe no-op rather than a
|
||||
// full-fleet stop. The local node is not acted on and no remote is contacted.
|
||||
expect(res.body.results).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-array nodeIds with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'whatever', nodeIds: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeIds/);
|
||||
});
|
||||
|
||||
it('rejects a non-integer nodeIds entry with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'whatever', nodeIds: [1, 2.5] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeIds/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => {
|
||||
it('marks each stack dryRun: true and does not invoke containerActionForStack', async () => {
|
||||
const label = await createAssignedLabel('dry-stop', ['alpha', 'beta']);
|
||||
|
||||
@@ -640,6 +640,54 @@ describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
expect(row.error).toMatch(/malformed/);
|
||||
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
|
||||
});
|
||||
|
||||
it('fails a node whose 200 body returns an empty results array for a non-empty request', async () => {
|
||||
const remoteId = addRemote('assign-remote-empty');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
// A well-shaped { created, results } body whose results are empty used to
|
||||
// pass the bare Array.isArray check and read as a successful zero-stack
|
||||
// assign. Membership validation must fail the node instead.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: [] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toMatch(/malformed/);
|
||||
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
|
||||
});
|
||||
|
||||
it('fails a node whose results omit one of the requested stacks', async () => {
|
||||
const remoteId = addRemote('assign-remote-partial');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
// Two stacks requested, only one row returned: a partial body the control
|
||||
// must not accept as a clean assign of the covered stack alone.
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1', 'r2'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toMatch(/malformed/);
|
||||
expect(row.stackResults).toEqual([
|
||||
{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' },
|
||||
{ stackName: 'r2', success: false, error: 'Remote returned a malformed response' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet-stop degrades the local leg per-node instead of failing the whole fan-out', () => {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Unit tests for validateRemoteAssignResults: the membership + shape guard the
|
||||
* bulk-assign orchestrator applies to a remote node's local-assign 200 body.
|
||||
* The receiver returns exactly one result row per unique requested stack, so a
|
||||
* body that drops, duplicates, or adds rows is a contract failure the control
|
||||
* must not read as a successful (possibly zero-stack) assign.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateRemoteAssignResults } from '../helpers/fleetLabelAssign';
|
||||
|
||||
describe('validateRemoteAssignResults', () => {
|
||||
it('accepts a body whose results cover exactly the requested stacks', () => {
|
||||
const out = validateRemoteAssignResults(['a', 'b'], {
|
||||
created: true,
|
||||
results: [
|
||||
{ stackName: 'a', success: true },
|
||||
{ stackName: 'b', success: false, error: 'Stack not found' },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual({
|
||||
ok: true,
|
||||
created: true,
|
||||
results: [
|
||||
{ stackName: 'a', success: true },
|
||||
{ stackName: 'b', success: false, error: 'Stack not found' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('dedupes the requested set so a duplicated request still validates one row each', () => {
|
||||
const out = validateRemoteAssignResults(['a', 'a'], {
|
||||
created: false,
|
||||
results: [{ stackName: 'a', success: true }],
|
||||
});
|
||||
expect(out).toEqual({ ok: true, created: false, results: [{ stackName: 'a', success: true }] });
|
||||
});
|
||||
|
||||
it('rejects an empty results array for a non-empty request (the false-success case)', () => {
|
||||
expect(validateRemoteAssignResults(['a'], { created: true, results: [] })).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a body that omits a requested stack', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a', 'b'], { created: true, results: [{ stackName: 'a', success: true }] }),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a result for a stack that was never requested', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a'], {
|
||||
created: true,
|
||||
results: [{ stackName: 'a', success: true }, { stackName: 'rogue', success: true }],
|
||||
}),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a duplicated result row for the same stack', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a'], {
|
||||
created: true,
|
||||
results: [{ stackName: 'a', success: true }, { stackName: 'a', success: false }],
|
||||
}),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a malformed result row (missing success)', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a'], { created: true, results: [{ stackName: 'a' }] }),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a result row with a non-string error', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a'], { created: true, results: [{ stackName: 'a', success: false, error: 5 }] }),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a non-boolean created', () => {
|
||||
expect(
|
||||
validateRemoteAssignResults(['a'], { created: 'yes', results: [{ stackName: 'a', success: true }] }),
|
||||
).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a non-array results', () => {
|
||||
expect(validateRemoteAssignResults(['a'], { created: true, results: 'nope' })).toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects a null or non-object body', () => {
|
||||
expect(validateRemoteAssignResults(['a'], null)).toEqual({ ok: false });
|
||||
expect(validateRemoteAssignResults(['a'], 'string')).toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Authorization tests for the stack-specific and fleet container/stack read
|
||||
* routes that were previously auth-only (no permission check):
|
||||
* - GET /api/stacks/:stackName/containers
|
||||
* - GET /api/fleet/node/:nodeId/stacks
|
||||
* - GET /api/fleet/node/:nodeId/stacks/:stackName/containers
|
||||
*
|
||||
* They are now gated by `requirePermission('stack:read')`, the same read model
|
||||
* the generic container/port routes and the rest of the stacks router use.
|
||||
* Every shipped role carries `stack:read`, so the denial path is exercised by
|
||||
* temporarily removing it from a role at runtime.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let DockerController: typeof import('../services/DockerController').default;
|
||||
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
|
||||
let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS;
|
||||
|
||||
const VIEWER = 'fleet-read-viewer';
|
||||
let READ_PATHS: string[];
|
||||
|
||||
function viewerToken(): string {
|
||||
const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!;
|
||||
return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
/** Stub the Docker/FS singletons so the admitted path resolves without a daemon. */
|
||||
function stubDockerAndFs(): { docker: ReturnType<typeof vi.spyOn>; fs: ReturnType<typeof vi.spyOn> } {
|
||||
const docker = vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
getContainersByStack: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
||||
getStacks: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
|
||||
return { docker, fs };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
({ default: DockerController } = await import('../services/DockerController'));
|
||||
({ FileSystemService } = await import('../services/FileSystemService'));
|
||||
({ ROLE_PERMISSIONS } = await import('../middleware/permissions'));
|
||||
({ app } = await import('../index'));
|
||||
|
||||
const hash = await bcrypt.hash('password123', 1);
|
||||
DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' });
|
||||
const localId = DatabaseService.getInstance().getNodes().find(n => n.is_default)!.id;
|
||||
READ_PATHS = [
|
||||
'/api/stacks/alpha/containers',
|
||||
`/api/fleet/node/${localId}/stacks`,
|
||||
`/api/fleet/node/${localId}/stacks/alpha/containers`,
|
||||
];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('stack/fleet container reads deny a role without stack:read', () => {
|
||||
let originalViewerPerms: typeof ROLE_PERMISSIONS.viewer;
|
||||
let docker: ReturnType<typeof vi.spyOn>;
|
||||
let fs: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
originalViewerPerms = ROLE_PERMISSIONS.viewer;
|
||||
ROLE_PERMISSIONS.viewer = originalViewerPerms.filter((p) => p !== 'stack:read');
|
||||
({ docker, fs } = stubDockerAndFs());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ROLE_PERMISSIONS.viewer = originalViewerPerms;
|
||||
});
|
||||
|
||||
it('rejects each read before any Docker/FS work', async () => {
|
||||
for (const path of READ_PATHS) {
|
||||
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
|
||||
expect(res.status, `denied ${path}`).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
}
|
||||
// The guard short-circuits before the handler instantiates the controller.
|
||||
expect(docker).not.toHaveBeenCalled();
|
||||
expect(fs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stack/fleet container reads admit a role with stack:read', () => {
|
||||
beforeEach(() => {
|
||||
stubDockerAndFs();
|
||||
});
|
||||
|
||||
it('admits each read', async () => {
|
||||
for (const path of READ_PATHS) {
|
||||
const res = await request(app).get(path).set('Authorization', `Bearer ${viewerToken()}`);
|
||||
expect(res.status, `admitted ${path}`).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stack/fleet container reads reject unauthenticated requests', () => {
|
||||
it('returns 401 without a token', async () => {
|
||||
for (const path of READ_PATHS) {
|
||||
const res = await request(app).get(path);
|
||||
expect(res.status, `unauth ${path}`).toBe(401);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+35
-10
@@ -17,6 +17,7 @@ import SelfUpdateService from '../services/SelfUpdateService';
|
||||
import { getSenchoVersion, isValidVersion } from '../services/CapabilityRegistry';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { scheduleLocalUpdate } from './license';
|
||||
import { runPolicyGate, assertPolicyGateAllows, buildPolicyGateOptions } from '../helpers/policyGate';
|
||||
import { captureLocalNodeFiles, captureRemoteNodeFiles, buildSnapshotDocumentation, pickDossierFields, dossierHasContent, type SnapshotNodeData, type SnapshotDocumentation } from '../utils/snapshot-capture';
|
||||
@@ -40,7 +41,7 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
|
||||
import { activeBulkActions } from './labels';
|
||||
import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
|
||||
import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, failAllAssign, type LabelLocalAssignResponse, type AssignNodeResult } from '../helpers/fleetLabelAssign';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults, failAllAssign, type AssignNodeResult } from '../helpers/fleetLabelAssign';
|
||||
import { MAX_ASSIGNMENTS } from '../helpers/constants';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
|
||||
@@ -791,7 +792,12 @@ fleetRouter.get('/networking-summary', authMiddleware, async (_req: Request, res
|
||||
}
|
||||
});
|
||||
|
||||
// Read guard uses the unscoped stack:read (no resource): a fleet node view is a
|
||||
// cross-node aggregate with no single control-DB stack to scope a per-stack
|
||||
// assignment against, so it requires the global stack:read every shipped role
|
||||
// holds. The per-stack scoped form is correct only on the local stacks router.
|
||||
fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
if (nodeId === null) return;
|
||||
@@ -830,7 +836,10 @@ fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res
|
||||
}
|
||||
});
|
||||
|
||||
// Unscoped stack:read for the same reason as /node/:nodeId/stacks above: a
|
||||
// fleet-routed read has no local control-DB stack resource to scope against.
|
||||
fleetRouter.get('/node/:nodeId/stacks/:stackName/containers', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requirePermission(req, res, 'stack:read')) return;
|
||||
try {
|
||||
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
|
||||
if (nodeId === null) return;
|
||||
@@ -1370,23 +1379,37 @@ type FleetStopNodeResult = {
|
||||
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
|
||||
fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const body = req.body as { labelName?: unknown; dryRun?: unknown } | undefined;
|
||||
const body = req.body as { labelName?: unknown; dryRun?: unknown; nodeIds?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
}
|
||||
const { labelName, dryRun } = body;
|
||||
const { labelName, dryRun, nodeIds } = body;
|
||||
if (typeof labelName !== 'string' || labelName.trim().length === 0) {
|
||||
res.status(400).json({ error: 'labelName is required' });
|
||||
return;
|
||||
}
|
||||
// Optional allowlist binding execution to the preview the operator confirmed.
|
||||
// The confirm flow sends exactly the nodes shown in the resolved blast radius,
|
||||
// so a node that was unreachable during preview and reconnects before the stop
|
||||
// cannot silently enter execution and have unlisted stacks stopped. Absent
|
||||
// (e.g. a dry run) the fan-out scans the whole fleet as before.
|
||||
let allowedNodeIds: Set<number> | null = null;
|
||||
if (nodeIds !== undefined) {
|
||||
if (!Array.isArray(nodeIds) || !nodeIds.every(n => typeof n === 'number' && Number.isInteger(n))) {
|
||||
res.status(400).json({ error: 'nodeIds must be an array of integers' });
|
||||
return;
|
||||
}
|
||||
allowedNodeIds = new Set(nodeIds as number[]);
|
||||
}
|
||||
const trimmed = labelName.trim();
|
||||
const isDryRun = dryRun === true;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodes = db.getNodes();
|
||||
if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-stop:', { labelName: trimmed, dryRun: isDryRun, nodes: nodes.length });
|
||||
const results = await Promise.all(nodes.map(async (node): Promise<FleetStopNodeResult> => {
|
||||
const targetNodes = allowedNodeIds ? nodes.filter(n => allowedNodeIds.has(n.id)) : nodes;
|
||||
if (isDebugEnabled()) console.debug('[Fleet:debug] fleet-stop:', { labelName: trimmed, dryRun: isDryRun, nodes: targetNodes.length, scoped: allowedNodeIds !== null });
|
||||
const results = await Promise.all(targetNodes.map(async (node): Promise<FleetStopNodeResult> => {
|
||||
if (node.type === 'local') {
|
||||
// Match + stop runs in-process against the control's own Docker. The
|
||||
// helper shares the per-node `bulk:<id>` lock with the per-label action
|
||||
@@ -1562,11 +1585,13 @@ fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res
|
||||
const message = err.error || `Remote returned ${response.status}`;
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
|
||||
}
|
||||
// A 200 whose body is not the expected { created, results } shape is a
|
||||
// degraded node, not a clean no-op: report it as a per-node failure so a
|
||||
// malformed remote cannot read as a successful zero-stack assign.
|
||||
const remote = (await response.json().catch(() => null)) as Partial<LabelLocalAssignResponse> | null;
|
||||
if (!remote || typeof remote.created !== 'boolean' || !Array.isArray(remote.results)) {
|
||||
// A 200 whose body is not the expected { created, results } shape, or
|
||||
// whose results do not cover exactly the stacks this node was asked to
|
||||
// label, is a degraded node, not a clean no-op: report it as a per-node
|
||||
// failure so a malformed or partial remote cannot read as a successful
|
||||
// zero-stack assign.
|
||||
const remote = validateRemoteAssignResults(target.stackNames, await response.json().catch(() => null));
|
||||
if (!remote.ok) {
|
||||
const message = 'Remote returned a malformed response';
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
|
||||
}
|
||||
|
||||
@@ -1102,6 +1102,7 @@ stacksRouter.get('/:stackName/containers', async (req: Request, res: Response) =
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
try {
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const containers = await dockerController.getContainersByStack(stackName);
|
||||
|
||||
Reference in New Issue
Block a user