mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
feat: add service-scoped Compose update and restore (#1648)
* feat: add service-scoped Compose update and restore Allow updating or rebuilding one declared Compose service on multi-service stacks without recreating siblings, with recovery snapshots, health-gate observation, and prune holds for rollback images. Full-stack update paths and single-service UX stay unchanged. * fix: sanitize service-scoped update log messages for CodeQL * fix: address service-scoped update audit findings B-01 through B-07 * fix: complete service-scoped update audit metadata and surfaces * test: wrap Updates readiness tests for deploy-feedback context * fix: keep service recovery reachable without Deploy Progress Make failed service-gate recovery discoverable when Deploy Progress is disabled or dismissed, suppress stale image-scan notification side effects, normalize ComposeService line endings, and add focused regression coverage. * fix: resurface ContainersHealth density and expand on multi-service stacks Service grouping hid the summary strip and Compact/Detailed/Expand controls that still applied to multi-container stacks.
This commit is contained in:
@@ -8,15 +8,34 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, act, waitFor, fireEvent } from '@testing-library/react';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), fetchForNode: vi.fn() }));
|
||||
vi.mock('@/lib/serviceUpdate', () => ({
|
||||
requestServiceUpdate: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn(), warning: vi.fn(), info: vi.fn(), loading: vi.fn(), dismiss: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/hooks/use-is-mobile', () => ({ useIsMobile: () => false }));
|
||||
vi.mock('@/context/DeployFeedbackContext', () => ({
|
||||
useDeployFeedback: () => ({
|
||||
runWithLog: async (_params: unknown, fn: (started: Promise<void>, ds: string) => Promise<unknown>) =>
|
||||
fn(Promise.resolve(), 'test-session'),
|
||||
}),
|
||||
}));
|
||||
// nodeMeta/refreshNodeMeta must be stable across renders (matching the real
|
||||
// NodeContext), or a fresh Map/fn on every useNodes() call churns the
|
||||
// loadReadiness useCallback identity and re-triggers its effect forever.
|
||||
const mockNodeMeta = new Map();
|
||||
const mockRefreshNodeMeta = vi.fn();
|
||||
vi.mock('@/context/NodeContext', () => ({
|
||||
useNodes: () => ({ nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }] }),
|
||||
useNodes: () => ({
|
||||
nodes: [{ id: 1, name: 'Local', type: 'local', status: 'online' }],
|
||||
nodeMeta: mockNodeMeta,
|
||||
refreshNodeMeta: mockRefreshNodeMeta,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { requestServiceUpdate } from '@/lib/serviceUpdate';
|
||||
import AutoUpdateReadinessView, { MobileReadinessCard, CadenceStrip, type StackCard } from '../AutoUpdateReadinessView';
|
||||
|
||||
function card(over: Partial<StackCard> = {}): StackCard {
|
||||
@@ -25,6 +44,7 @@ function card(over: Partial<StackCard> = {}): StackCard {
|
||||
nodeId: 1,
|
||||
previewLoaded: true,
|
||||
applying: false,
|
||||
applyingService: null,
|
||||
autoUpdateEnabled: true,
|
||||
scheduledTask: null,
|
||||
preview: {
|
||||
@@ -83,6 +103,48 @@ it('enables Apply when no schedule covers the stack', () => {
|
||||
expect(apply()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('offers per-service Apply when build-only companions make the stack multi-service', () => {
|
||||
const onApplyService = vi.fn();
|
||||
render(
|
||||
<MobileReadinessCard
|
||||
canServiceUpdate
|
||||
onApply={vi.fn()}
|
||||
onApplyService={onApplyService}
|
||||
card={card({
|
||||
preview: {
|
||||
stack_name: 'nextcloud',
|
||||
images: [{
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
}],
|
||||
build_services: ['cron'],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch',
|
||||
update_kind: 'tag',
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
has_build_services: true,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
},
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const serviceApply = screen.getByRole('button', { name: /^Apply$/i });
|
||||
expect(serviceApply).toBeEnabled();
|
||||
fireEvent.click(serviceApply);
|
||||
expect(onApplyService).toHaveBeenCalledWith('nextcloud', 1, 'app');
|
||||
});
|
||||
|
||||
/**
|
||||
* The desktop StackReadinessCard is not exported, so its Apply-now gating is
|
||||
* covered through a full-view render (useIsMobile is mocked false). A safe
|
||||
@@ -97,6 +159,8 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
afterEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedFetchForNode.mockReset();
|
||||
mockNodeMeta.clear();
|
||||
vi.mocked(requestServiceUpdate).mockReset();
|
||||
});
|
||||
|
||||
it('enables Apply for a safe update with no covering schedule', async () => {
|
||||
@@ -122,6 +186,97 @@ describe('AutoUpdateReadinessView desktop Apply now', () => {
|
||||
// apply automatically": that still requires a covering schedule.
|
||||
expect(screen.getByText(/0 of 1 ready to apply automatically/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies a single service and refreshes the authoritative update preview', async () => {
|
||||
mockNodeMeta.set(1, {
|
||||
version: '1.0.0',
|
||||
capabilities: ['service-scoped-update'],
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
const multiPreview = {
|
||||
stack_name: 'nextcloud',
|
||||
images: [
|
||||
{
|
||||
service: 'app',
|
||||
image: 'nextcloud:27',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
has_update: true,
|
||||
semver_bump: 'patch' as const,
|
||||
},
|
||||
{
|
||||
service: 'redis',
|
||||
image: 'redis:7',
|
||||
current_tag: '7.2',
|
||||
next_tag: '7.2',
|
||||
has_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
has_update: true,
|
||||
primary_image: 'nextcloud',
|
||||
current_tag: '27.1.4',
|
||||
next_tag: '27.1.5',
|
||||
semver_bump: 'patch' as const,
|
||||
update_kind: 'tag' as const,
|
||||
blocked: false,
|
||||
blocked_reason: null,
|
||||
},
|
||||
rollback_target: null,
|
||||
changelog: 'Fixes.',
|
||||
};
|
||||
const refreshedPreview = {
|
||||
...multiPreview,
|
||||
images: multiPreview.images.map((img) => (
|
||||
img.service === 'app' ? { ...img, has_update: false, current_tag: '27.1.5', next_tag: '27.1.5' } : img
|
||||
)),
|
||||
summary: { ...multiPreview.summary, has_update: false, current_tag: '27.1.5' },
|
||||
};
|
||||
mockedFetch.mockImplementation((url: string) => {
|
||||
if (url === '/image-updates/fleet') {
|
||||
return Promise.resolve({ ok: true, json: async () => ({ '1': { nextcloud: true } }) });
|
||||
}
|
||||
if (url.startsWith('/scheduled-tasks')) {
|
||||
return Promise.resolve({ ok: true, json: async () => [] });
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
mockedFetchForNode.mockImplementation((url: string) => {
|
||||
if (String(url).includes('/update-preview')) {
|
||||
const call = mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => (call <= 1 ? multiPreview : refreshedPreview),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => ({}) });
|
||||
});
|
||||
vi.mocked(requestServiceUpdate).mockResolvedValue({
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: 'app',
|
||||
healthGateId: null,
|
||||
observing: false,
|
||||
recoveryId: null,
|
||||
recoveryAvailable: false,
|
||||
});
|
||||
|
||||
render(<AutoUpdateReadinessView />);
|
||||
const serviceApply = await screen.findByRole('button', { name: /^Apply$/i });
|
||||
await act(async () => { fireEvent.click(serviceApply); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestServiceUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stackName: 'nextcloud',
|
||||
serviceName: 'app',
|
||||
mode: 'update',
|
||||
}));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchForNode.mock.calls.filter((c) => String(c[0]).includes('/update-preview')).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,15 @@ import { DeployFeedbackProvider, useDeployFeedback } from '@/context/DeployFeedb
|
||||
import { DeployFeedbackModal } from '../DeployFeedbackModal';
|
||||
|
||||
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
|
||||
vi.mock('@/lib/serviceUpdate', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/serviceUpdate')>();
|
||||
return {
|
||||
...actual,
|
||||
requestServiceRestore: vi.fn(),
|
||||
};
|
||||
});
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { requestServiceRestore } from '@/lib/serviceUpdate';
|
||||
|
||||
// Lets a test simulate a mid-stream drop (onReady then onError) so the panel
|
||||
// reaches 'streaming' with progressUnavailable set.
|
||||
@@ -15,12 +23,21 @@ const ctl = vi.hoisted(() => ({ drop: false, lastNodeId: undefined as number | n
|
||||
// the stream connected on mount so the panel reaches the 'streaming' state, and
|
||||
// records the captured nodeId it was mounted with.
|
||||
vi.mock('@/components/Terminal', () => {
|
||||
const MockTerminal = ({ onReady, onError, nodeId }: { onReady?: () => void; onError?: () => void; nodeId?: number | null }) => {
|
||||
const MockTerminal = ({
|
||||
onReady, onError, nodeId, deploySessionId,
|
||||
}: {
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
nodeId?: number | null;
|
||||
deploySessionId?: string | null;
|
||||
}) => {
|
||||
ctl.lastNodeId = nodeId;
|
||||
// Re-fire on each deploy session so a Restore (second runWithLog) can
|
||||
// release its progress-stream gate the same way the first update does.
|
||||
React.useEffect(() => {
|
||||
onReady?.();
|
||||
if (ctl.drop) onError?.();
|
||||
}, [onReady, onError]);
|
||||
}, [onReady, onError, deploySessionId]);
|
||||
return null;
|
||||
};
|
||||
return { default: MockTerminal };
|
||||
@@ -28,19 +45,28 @@ vi.mock('@/components/Terminal', () => {
|
||||
|
||||
// Resolver for the in-flight operation, assigned inside the run callback (async,
|
||||
// after render) so the test can leave it pending or settle it on demand.
|
||||
let resolveRun: ((r: { ok: boolean; errorMessage?: string; healthGateId?: string | null }) => void) | null = null;
|
||||
let resolveRun: ((r: {
|
||||
ok: boolean;
|
||||
errorMessage?: string;
|
||||
healthGateId?: string | null;
|
||||
recoveryId?: string | null;
|
||||
}) => void) | null = null;
|
||||
// The runWithLog promise itself, so a test can await full result propagation.
|
||||
let runOuter: Promise<unknown> | null = null;
|
||||
// Node the driver captures for the operation; default local, overridden per test.
|
||||
let driverNodeId: number | null = null;
|
||||
let driverServiceName: string | undefined;
|
||||
|
||||
function Driver() {
|
||||
const { runWithLog } = useDeployFeedback();
|
||||
React.useEffect(() => {
|
||||
runOuter = runWithLog({ stackName: 'web', action: 'update', nodeId: driverNodeId }, async (started) => {
|
||||
await started;
|
||||
return new Promise<{ ok: boolean; errorMessage?: string; healthGateId?: string | null }>((res) => { resolveRun = res; });
|
||||
});
|
||||
runOuter = runWithLog(
|
||||
{ stackName: 'web', action: 'update', nodeId: driverNodeId, serviceName: driverServiceName },
|
||||
async (started) => {
|
||||
await started;
|
||||
return new Promise((res) => { resolveRun = res; });
|
||||
},
|
||||
);
|
||||
}, [runWithLog]);
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +86,14 @@ async function renderStreaming() {
|
||||
|
||||
type GateStatus = 'observing' | 'passed' | 'failed' | 'unknown';
|
||||
|
||||
function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?: string | null }>) {
|
||||
function routeGateApi(responses: Array<{
|
||||
id: string;
|
||||
status: GateStatus;
|
||||
reason?: string | null;
|
||||
serviceName?: string | null;
|
||||
targetScope?: 'stack' | 'service';
|
||||
failureSource?: 'primary' | 'collateral' | null;
|
||||
}>) {
|
||||
let call = 0;
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (!String(url).includes('/health-gate')) {
|
||||
@@ -71,6 +104,7 @@ function routeGateApi(responses: Array<{ id: string; status: GateStatus; reason?
|
||||
return Promise.resolve(new Response(JSON.stringify({
|
||||
stack: 'web', id: r.id, status: r.status, trigger: 'update',
|
||||
reason: r.reason ?? null, windowSeconds: 90, startedAt: Date.now(), endedAt: null, containers: [],
|
||||
targetScope: r.targetScope ?? 'stack', serviceName: r.serviceName ?? null, failureSource: r.failureSource ?? null,
|
||||
}), { status: 200 }));
|
||||
});
|
||||
}
|
||||
@@ -83,8 +117,10 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
ctl.drop = false;
|
||||
ctl.lastNodeId = undefined;
|
||||
driverNodeId = null;
|
||||
driverServiceName = undefined;
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response('{}', { status: 200 }));
|
||||
vi.mocked(requestServiceRestore).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -152,6 +188,55 @@ describe('DeployFeedbackModal health gate', () => {
|
||||
expect(screen.getByTestId('deploy-feedback-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('names the service in the banner for a service-scoped gate and notes a collateral failure', async () => {
|
||||
routeGateApi([{
|
||||
id: 'gate-1', status: 'failed', reason: 'service web has no running replicas to observe',
|
||||
serviceName: 'web', targetScope: 'service', failureSource: 'collateral',
|
||||
}]);
|
||||
await succeedWithGate('gate-1');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
expect(screen.getByTestId('health-gate-banner')).toHaveAttribute('data-status', 'failed');
|
||||
expect(screen.getByText(/A dependent service triggered the failure/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers Restore for a failed service gate and calls requestServiceRestore with the recovery id', async () => {
|
||||
driverServiceName = 'api';
|
||||
vi.mocked(requestServiceRestore).mockResolvedValue({
|
||||
ok: true,
|
||||
mode: 'update',
|
||||
serviceName: 'api',
|
||||
healthGateId: null,
|
||||
observing: false,
|
||||
recoveryId: 'rec-1',
|
||||
recoveryAvailable: false,
|
||||
});
|
||||
routeGateApi([{
|
||||
id: 'gate-1', status: 'failed', reason: 'service api reported unhealthy',
|
||||
serviceName: 'api', targetScope: 'service', failureSource: 'primary',
|
||||
}]);
|
||||
await renderStreaming();
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60); });
|
||||
await act(async () => {
|
||||
resolveRun?.({ ok: true, healthGateId: 'gate-1', recoveryId: 'rec-1' });
|
||||
await runOuter;
|
||||
});
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
|
||||
const restoreBtn = screen.getByTestId('service-restore-from-gate');
|
||||
expect(restoreBtn).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
restoreBtn.click();
|
||||
});
|
||||
// Restore starts a fresh runWithLog; Terminal remounts for the new
|
||||
// deploySessionId and releases the gate after the 50ms handshake.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(60); });
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(requestServiceRestore).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stackName: 'web',
|
||||
serviceName: 'api',
|
||||
recoveryId: 'rec-1',
|
||||
}));
|
||||
});
|
||||
|
||||
it('gives up with an unknown verdict after repeated poll failures', async () => {
|
||||
vi.mocked(apiFetch).mockImplementation((url: string) => {
|
||||
if (String(url).includes('/health-gate')) {
|
||||
|
||||
Reference in New Issue
Block a user