fix(fleet): verify update status before removing readiness cards (#1697)

* fix(fleet): verify update status before removing readiness cards

Full-stack Apply now rechecks persisted status after the health gate starts, reloads the live preview before dropping a card, and invalidates the hub fleet aggregation so cleared updates cannot resurrect from a stale cache.

Closes #1686

* fix(fleet): align persisted update status with preview semver detection

Share digest-plus-tag detection so post-Apply sidebar status matches Fleet and Anatomy.

* fix(fleet): keep tag-only updates advisory for Compose automation

Expose digestUpdate vs tagUpdate from checkImage so scheduled and API auto-update only apply same-tag digest drift Compose can pull.

* docs: clarify scheduled auto-update applies digest drift only

Document that higher pinned tags stay advisory until Compose is changed, matching schedule and Run Now behavior.

* docs: require Compose pin edits for higher-tag advisories

Stop recommending Apply now or Update as remedies that cannot rewrite a pinned image tag.

* docs: clarify Apply now pulls pinned tags only

Align the detection-cadence bullet with digest-rebuild vs higher-tag guidance.

* fix(fleet): keep tag advisories after apply and scheduled updates

Tag-only previews were treated as cleared on Fleet reload, and scheduled/
Run Now paths wiped status without rechecking. Align post-update verification
with the manual Apply path (health gate first, recheck, no blind clear) and
block digest apply when sibling image checks failed.

* fix(fleet): clear eslint unused-arg and containers assignment
This commit is contained in:
Anso
2026-07-26 03:09:21 -04:00
committed by GitHub
parent bb7c76ba46
commit 719180f156
16 changed files with 1320 additions and 74 deletions
@@ -0,0 +1,193 @@
/**
* Manual POST /api/stacks/:name/update must start the health gate before
* registry recheck, never blind-clear update status, and keep Compose success
* as HTTP 200 even when recheck throws.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
const {
mockExecute,
mockRecheckStack,
mockBeginStack,
mockClearStackUpdateStatus,
mockBroadcastEvent,
mockDispatchAlert,
} = vi.hoisted(() => ({
mockExecute: vi.fn(),
mockRecheckStack: vi.fn(),
mockBeginStack: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
mockBroadcastEvent: vi.fn(),
mockDispatchAlert: vi.fn(),
}));
vi.mock('../services/StackUpdateOrchestrator', () => ({
StackUpdateOrchestrator: {
getInstance: () => ({ execute: mockExecute }),
},
shortImageId: (id: string) => id.slice(0, 12),
}));
vi.mock('../services/ImageUpdateService', async () => {
const actual = await vi.importActual<typeof import('../services/ImageUpdateService')>(
'../services/ImageUpdateService',
);
return {
...actual,
ImageUpdateService: {
getInstance: () => ({ recheckStack: mockRecheckStack }),
},
};
});
vi.mock('../services/HealthGateService', () => ({
HealthGateService: {
getInstance: () => ({
beginStack: mockBeginStack,
}),
},
}));
vi.mock('../services/FileSystemService', () => ({
FileSystemService: {
getInstance: () => ({
getBaseDir: () => '/tmp/compose',
hasComposeFile: vi.fn().mockResolvedValue(true),
}),
},
}));
vi.mock('../helpers/policyGate', async () => {
const actual = await vi.importActual<typeof import('../helpers/policyGate')>(
'../helpers/policyGate',
);
return {
...actual,
runPolicyGate: vi.fn().mockResolvedValue(true),
triggerPostDeployScan: vi.fn().mockResolvedValue(undefined),
};
});
let tmpDir: string;
let app: import('express').Express;
let authCookie: string;
let clearSpy: ReturnType<typeof vi.spyOn> | undefined;
let broadcastSpy: ReturnType<typeof vi.spyOn> | undefined;
const callOrder: string[] = [];
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
authCookie = await loginAsTestAdmin(app);
const { DatabaseService } = await import('../services/DatabaseService');
const { NotificationService } = await import('../services/NotificationService');
clearSpy = vi.spyOn(DatabaseService.getInstance(), 'clearStackUpdateStatus').mockImplementation((...args) => {
callOrder.push('clearStackUpdateStatus');
return mockClearStackUpdateStatus(...args);
});
broadcastSpy = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation((...args) => {
callOrder.push('broadcastEvent');
return mockBroadcastEvent(...args);
});
vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockImplementation((...args) => {
callOrder.push(`dispatchAlert:${String(args[2] ?? args[0])}`);
return mockDispatchAlert(...args) ?? Promise.resolve({ persisted: true });
});
});
afterAll(() => {
clearSpy?.mockRestore();
broadcastSpy?.mockRestore();
vi.restoreAllMocks();
cleanupTestDb(tmpDir);
});
beforeEach(() => {
callOrder.length = 0;
mockExecute.mockReset();
mockRecheckStack.mockReset();
mockBeginStack.mockReset();
mockClearStackUpdateStatus.mockReset();
mockBroadcastEvent.mockReset();
mockDispatchAlert.mockReset().mockResolvedValue({ persisted: true });
mockExecute.mockImplementation(async () => {
callOrder.push('execute');
return { kind: 'stack_compose_done', recoveryId: null };
});
mockBeginStack.mockImplementation(() => {
callOrder.push('beginStack');
return 'gate-1';
});
mockRecheckStack.mockImplementation(async () => {
callOrder.push('recheckStack');
return { outcome: 'cleared', warning: null };
});
});
describe('POST /api/stacks/:name/update post-compose verification', () => {
it('starts the health gate before recheck, skips clear, and broadcasts after recheck', async () => {
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ status: 'Update completed', healthGateId: 'gate-1' });
expect(res.body.recheckWarning).toBeUndefined();
expect(callOrder.indexOf('execute')).toBeLessThan(callOrder.indexOf('beginStack'));
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
expect(callOrder.indexOf('recheckStack')).toBeLessThan(callOrder.indexOf('broadcastEvent'));
expect(callOrder).not.toContain('clearStackUpdateStatus');
expect(mockClearStackUpdateStatus).not.toHaveBeenCalled();
});
it('returns recheckWarning when the update condition remains', async () => {
mockRecheckStack.mockImplementation(async () => {
callOrder.push('recheckStack');
return {
outcome: 'still_present',
warning: 'The update command completed, but Sencho still detects an available image update.',
};
});
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(200);
expect(res.body.recheckWarning).toBe(
'The update command completed, but Sencho still detects an available image update.',
);
});
it('keeps HTTP 200 and success notification when recheck throws after Compose', async () => {
mockRecheckStack.mockImplementation(async () => {
callOrder.push('recheckStack');
throw new Error('registry blew up');
});
const res = await request(app)
.post('/api/stacks/web/update')
.set('Cookie', authCookie)
.send({ skip_scan: true });
expect(res.status).toBe(200);
expect(res.body.healthGateId).toBe('gate-1');
expect(res.body.recheckWarning).toMatch(/could not fully verify/i);
expect(callOrder.indexOf('beginStack')).toBeLessThan(callOrder.indexOf('recheckStack'));
expect(callOrder).toContain('broadcastEvent');
// Success path still notifies; failure notification must not fire.
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
'image_update_applied',
expect.any(String),
expect.objectContaining({ stackName: 'web' }),
);
expect(mockDispatchAlert.mock.calls.some((c) => c[0] === 'error' && c[1] === 'deploy_failure')).toBe(false);
});
});