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:
Anso
2026-06-28 08:13:43 -04:00
committed by GitHub
parent b5810a9b55
commit 05c483f213
10 changed files with 510 additions and 31 deletions
@@ -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);
}
});
});
+42
View File
@@ -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
View File
@@ -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) };
}
+1
View File
@@ -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);