mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
fix(fleet-actions): stop-by-label works on Community remote nodes (#1270)
* fix(fleet-actions): stop-by-label works on Community remote nodes Fleet-stop's remote leg fanned out to POST /api/labels/:id/action, which is gated to Skipper/Admiral, so on a Community fleet the control node stopped its own stacks but every remote node returned 403. Fleet-stop itself is admin-only and available on every license, so the remote leg contradicted the feature's own gate. Extract the label-match plus bulk-stop logic into a shared runLocalLabelStop helper and add an admin-only, every-license POST /api/fleet-actions/labels/local-stop receiver. The control now fans out to that receiver, so remote stacks stop on every tier. Each node runs under its own per-node bulk lock, so a fleet-stop and a per-label action still serialize cleanly instead of double-stopping containers. Also degrade the control's own leg per-node instead of failing the whole fan-out when its filesystem read throws, and gate fleet-stop and fleet-prune diagnostics behind developer_mode. Tests: local-stop auth, tier, validation, and behavior; a remote-leg routing guard that asserts the fan-out targets local-stop and never the paid route; local-leg graceful degradation; and the three Fleet Action card UIs. * fix(fleet-actions): honor the remote stop receiver's matched flag The control reached the remote leg only because its own mirror had the label, then hardcoded matched:true and trusted results without guarding its shape. A mirror-skewed control (mirror has the label, remote does not) then showed a remote mismatch as "matched, 0 stacks" instead of "no matching label", and a malformed 200 body could flow a non-array into the per-stack renderers. Honor the remote's own matched flag and coerce results to an array when the body is malformed. Add regression tests for the matched:false skew case and the non-array results case.
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Coverage for BulkLabelAssignCard.
|
||||
*
|
||||
* Loads the node's stacks + labels, gates Apply on a stack-and-label selection,
|
||||
* confirms before applying, sends the assignment per-node, and surfaces a toast
|
||||
* on every outcome (no silent failure).
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ fetchForNode: vi.fn() }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: (...a: unknown[]) => toastError(...a),
|
||||
success: (...a: unknown[]) => toastSuccess(...a),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
loading: vi.fn(() => 'toast-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { fetchForNode } from '@/lib/api';
|
||||
import { BulkLabelAssignCard } from './BulkLabelAssignCard';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
|
||||
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
const nodes = [{ id: 1, name: 'central', type: 'local', status: 'online' }] as unknown as FleetNode[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedFetchForNode.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
|
||||
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
|
||||
return Promise.resolve(jsonResponse(200, { results: [] }));
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Apply disabled until both a stack and a label are selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<BulkLabelAssignCard nodes={nodes} />);
|
||||
await screen.findByText('web');
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
// Stack selected but no label yet.
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByText('prod'));
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('applies the assignment after confirmation and renders per-stack results', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetchForNode.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
|
||||
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
|
||||
if (path === '/fleet-actions/labels/bulk-assign') return Promise.resolve(jsonResponse(200, { results: [{ stackName: 'web', success: true }] }));
|
||||
return Promise.resolve(jsonResponse(200, {}));
|
||||
});
|
||||
render(<BulkLabelAssignCard nodes={nodes} />);
|
||||
await screen.findByText('web');
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByText('prod'));
|
||||
await user.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const call = mockedFetchForNode.mock.calls.find(c => c[0] === '/fleet-actions/labels/bulk-assign');
|
||||
expect(call).toBeTruthy();
|
||||
expect(JSON.parse(call![2].body)).toEqual({ assignments: [{ stackName: 'web', labelIds: [10] }] });
|
||||
});
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('surfaces an error toast when the assignment returns non-ok', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetchForNode.mockImplementation((path: string) => {
|
||||
if (path === '/fleet/node/1/stacks') return Promise.resolve(jsonResponse(200, ['web']));
|
||||
if (path === '/labels') return Promise.resolve(jsonResponse(200, [{ id: 10, name: 'prod', color: '#3b82f6' }]));
|
||||
if (path === '/fleet-actions/labels/bulk-assign') return Promise.resolve(jsonResponse(500, { error: 'assign failed' }));
|
||||
return Promise.resolve(jsonResponse(200, {}));
|
||||
});
|
||||
render(<BulkLabelAssignCard nodes={nodes} />);
|
||||
await screen.findByText('web');
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByText('prod'));
|
||||
await user.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Apply' }));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('assign failed'));
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Coverage for FleetPruneCard.
|
||||
*
|
||||
* The key safety property: the destructive "Prune fleet" confirm is blocked
|
||||
* until the operator has seen a live reclaim estimate. Also locks dry-run
|
||||
* payload shape, the all-scope confirm copy, and the failure toast.
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: (...a: unknown[]) => toastError(...a),
|
||||
success: (...a: unknown[]) => toastSuccess(...a),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
loading: vi.fn(() => 'toast-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { FleetPruneCard } from './FleetPruneCard';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
const nodes = [{ id: 1, name: 'central', status: 'online' }] as unknown as FleetNode[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedFetch.mockResolvedValue(jsonResponse(404, {}));
|
||||
});
|
||||
|
||||
it('blocks the prune confirm until a reclaim estimate is ready', async () => {
|
||||
// Estimate endpoint never resolves to ready (404 -> unavailable).
|
||||
render(<FleetPruneCard nodes={nodes} />);
|
||||
// images is selected by default, so an estimate is requested but unavailable.
|
||||
await waitFor(() => expect(screen.getByText('~ estimate unavailable')).toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables the prune confirm once the estimate resolves', async () => {
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/prune/estimate') {
|
||||
return Promise.resolve(jsonResponse(200, { totalBytes: 1024, perNode: [{ nodeId: 1, nodeName: 'central', reclaimableBytes: 1024, reachable: true }] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<FleetPruneCard nodes={nodes} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('all-scope confirm spells out the irreversible all-unused prune', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/prune/estimate') {
|
||||
return Promise.resolve(jsonResponse(200, { totalBytes: 2048, perNode: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<FleetPruneCard nodes={nodes} />);
|
||||
await user.click(screen.getByRole('button', { name: 'All unused' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled());
|
||||
await user.click(screen.getByRole('button', { name: 'Prune fleet' }));
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('Prune ALL unused resources across the fleet?')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(/This cannot be undone\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dry run sends dryRun:true and reports reclaimable bytes', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/prune/estimate') {
|
||||
return Promise.resolve(jsonResponse(200, { totalBytes: 0, perNode: [] }));
|
||||
}
|
||||
if (url === '/fleet/labels/fleet-prune') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [{ nodeId: 1, nodeName: 'central', reachable: true, targets: [{ target: 'images', success: true, reclaimedBytes: 4096, dryRun: true }] }],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<FleetPruneCard nodes={nodes} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
await waitFor(() => {
|
||||
const call = mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-prune');
|
||||
expect(call).toBeTruthy();
|
||||
expect(JSON.parse(call![1].body)).toEqual({ targets: ['images'], scope: 'managed', dryRun: true });
|
||||
});
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('surfaces an error toast when the prune returns non-ok', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-prune') {
|
||||
return Promise.resolve(jsonResponse(500, { error: 'prune blew up' }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<FleetPruneCard nodes={nodes} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('prune blew up'));
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Coverage for LabelFleetStopCard.
|
||||
*
|
||||
* Locks the destructive-action contract: buttons gated on a label name, the
|
||||
* confirm modal gates the real stop, dry run bypasses the modal, per-node
|
||||
* results render, and every failure path surfaces a toast (no silent failure).
|
||||
*/
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
fetchForNode: vi.fn(),
|
||||
}));
|
||||
|
||||
const toastError = vi.fn();
|
||||
const toastSuccess = vi.fn();
|
||||
const toastInfo = vi.fn();
|
||||
const toastWarning = vi.fn();
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: {
|
||||
error: (...a: unknown[]) => toastError(...a),
|
||||
success: (...a: unknown[]) => toastSuccess(...a),
|
||||
info: (...a: unknown[]) => toastInfo(...a),
|
||||
warning: (...a: unknown[]) => toastWarning(...a),
|
||||
loading: vi.fn(() => 'toast-id'),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { LabelFleetStopCard } from './LabelFleetStopCard';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
|
||||
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const mockedFetchForNode = fetchForNode as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => body } as unknown as Response;
|
||||
}
|
||||
|
||||
const nodes = [
|
||||
{ id: 1, name: 'central', status: 'online' },
|
||||
{ id: 2, name: 'edge-1', status: 'online' },
|
||||
] as unknown as FleetNode[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Suggestion load on mount: no labels.
|
||||
mockedFetchForNode.mockResolvedValue(jsonResponse(200, []));
|
||||
// Default: preview unavailable so the debounce effect never throws.
|
||||
mockedFetch.mockResolvedValue(jsonResponse(404, {}));
|
||||
});
|
||||
|
||||
it('disables both actions until a label name is entered', () => {
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Dry run' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables the actions once a label is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
expect(screen.getByRole('button', { name: 'Stop fleet' })).toBeEnabled();
|
||||
expect(screen.getByRole('button', { name: 'Dry run' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('dry run calls fleet-stop with dryRun:true and never opens the confirm modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [{ nodeId: 1, nodeName: 'central', matched: true, stackResults: [{ stackName: 'web', success: true, dryRun: true }] }],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const stopCall = mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-stop');
|
||||
expect(stopCall).toBeTruthy();
|
||||
expect(JSON.parse(stopCall![1].body)).toEqual({ labelName: 'prod', dryRun: true });
|
||||
});
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('stop fleet opens the confirm modal, and confirming runs a real stop that renders per-node results', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(200, {
|
||||
results: [
|
||||
{ nodeId: 1, nodeName: 'central', matched: true, stackResults: [{ stackName: 'web', success: true }] },
|
||||
{ nodeId: 2, nodeName: 'edge-1', matched: false, stackResults: [] },
|
||||
],
|
||||
}));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Stop fleet' }));
|
||||
|
||||
const dialog = await screen.findByRole('alertdialog');
|
||||
expect(within(dialog).getByText('Stop all stacks labeled "prod"?')).toBeInTheDocument();
|
||||
|
||||
// The real stop must not have fired yet.
|
||||
expect(mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-stop')).toBeFalsy();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Stop fleet' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const stopCall = mockedFetch.mock.calls.find(c => c[0] === '/fleet/labels/fleet-stop');
|
||||
expect(JSON.parse(stopCall![1].body)).toEqual({ labelName: 'prod', dryRun: false });
|
||||
});
|
||||
expect(await screen.findByText('web')).toBeInTheDocument();
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('surfaces an error toast when fleet-stop returns a non-ok response', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/fleet-stop') {
|
||||
return Promise.resolve(jsonResponse(500, { error: 'boom' }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await user.click(screen.getByRole('button', { name: 'Dry run' }));
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledWith('boom'));
|
||||
});
|
||||
|
||||
it('populates the blast readout from the debounced match-preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/fleet/labels/match-preview') {
|
||||
return Promise.resolve(jsonResponse(200, { matchedNodes: 2, matchedStacks: 3, perNode: [] }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(404, {}));
|
||||
});
|
||||
render(<LabelFleetStopCard nodes={nodes} />);
|
||||
await user.type(screen.getByPlaceholderText('e.g. production'), 'prod');
|
||||
await waitFor(() => expect(screen.getByText('3 stacks · 2 nodes')).toBeInTheDocument(), { timeout: 2000 });
|
||||
});
|
||||
Reference in New Issue
Block a user