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:
Anso
2026-07-19 02:42:29 -04:00
committed by GitHub
parent 31d4e4669b
commit 63213c0960
89 changed files with 7608 additions and 331 deletions
@@ -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')) {