mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
fix: distinguish failed image-update checks from "up to date" (#1470)
* fix: distinguish failed image-update checks from "up to date" The image-update detector collapsed every failure (registry unreachable, missing auth, rate limit, unresolved local digest) into hasUpdate:false and dropped the captured reason, so a failed check was indistinguishable from a current image and never raised a notification, even while a manual stack update still pulled a newer image. Detection now records a tri-state per stack (ok / partial / failed) with the failure reason, exposed via a new GET /api/image-updates/detail (the boolean GET / is unchanged so fleet aggregation is unaffected). A fully-failed check preserves the last known has_update, so a transient outage neither erases a real update nor flaps the notification state. The sidebar shows a muted "couldn't check" indicator with the reason on hover, and the Update board lists stacks whose check failed in a "could not be checked" advisory. Detector hardening: the manifest digest lookup issues HEAD first (falling back to GET) so it no longer draws down Docker Hub's anonymous pull-rate budget, and local RepoDigest matching is normalized so official library/* images resolve their digest instead of falling through to a silent "no update". * fix: preserve confirmed updates through partial checks; tighten failure surfacing Address review findings on the tri-state image-update detection: - A partial check (some images errored) no longer erases a previously confirmed update; only a fully-ok check can lower has_update, so a single image's registry blip cannot drop the stack's update and re-fire the notification on recovery. Adds a regression test. - The image-level catch stores getErrorMessage(e) rather than raw String(e), since that value surfaces verbatim in the sidebar tooltip and readiness advisory. - useImageUpdates and the readiness detail fetch now log unexpected non-ok responses instead of silently leaving stale state. - Remove an unused checkFailedCount derivation (the row indicator is driven by the checkStatus prop). - Reword the recordStackCheckFailure docstring and the HEAD-first comment.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Coverage for the tri-state stack_update_status accessors on the real
|
||||
* DatabaseService (against a temp DB, so the migrated schema with check_status /
|
||||
* last_error is exercised exactly as in production):
|
||||
* - upsertStackUpdateStatus persists hasUpdate + check_status + last_error
|
||||
* - getStackUpdateDetail returns the rich per-stack shape
|
||||
* - getStackUpdateStatus stays the boolean map (fleet contract)
|
||||
* - recordStackCheckFailure preserves a prior has_update while marking failed
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function db() {
|
||||
return DatabaseService.getInstance();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db;
|
||||
raw.prepare('DELETE FROM stack_update_status').run();
|
||||
});
|
||||
|
||||
const NODE = 1;
|
||||
|
||||
describe('stack_update_status tri-state accessors', () => {
|
||||
it('persists and reads back check_status + last_error via getStackUpdateDetail', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'api', false, 2000, 'partial', 'Registry unreachable for ghcr.io/acme/api:v1');
|
||||
|
||||
const detail = db().getStackUpdateDetail(NODE);
|
||||
expect(detail.web).toEqual({ hasUpdate: true, checkStatus: 'ok', lastError: null, checkedAt: 1000 });
|
||||
expect(detail.api).toEqual({ hasUpdate: false, checkStatus: 'partial', lastError: 'Registry unreachable for ghcr.io/acme/api:v1', checkedAt: 2000 });
|
||||
});
|
||||
|
||||
it('defaults check_status to ok when omitted', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000);
|
||||
expect(db().getStackUpdateDetail(NODE).web.checkStatus).toBe('ok');
|
||||
});
|
||||
|
||||
it('keeps getStackUpdateStatus a boolean map for the fleet contract', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
|
||||
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false });
|
||||
});
|
||||
|
||||
it('recordStackCheckFailure preserves a prior has_update while marking failed', () => {
|
||||
// A stack with a confirmed update, then a scan where every image errored.
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().recordStackCheckFailure(NODE, 'web', 'Registry unreachable for registry-1.docker.io/library/nginx:latest', 3000);
|
||||
|
||||
const detail = db().getStackUpdateDetail(NODE).web;
|
||||
expect(detail.hasUpdate).toBe(true); // not erased by the failed check
|
||||
expect(detail.checkStatus).toBe('failed');
|
||||
expect(detail.lastError).toContain('Registry unreachable');
|
||||
expect(detail.checkedAt).toBe(3000);
|
||||
});
|
||||
|
||||
it('recordStackCheckFailure on a first-ever check inserts has_update 0 + failed', () => {
|
||||
db().recordStackCheckFailure(NODE, 'fresh', 'auth failed', 4000);
|
||||
const detail = db().getStackUpdateDetail(NODE).fresh;
|
||||
expect(detail).toEqual({ hasUpdate: false, checkStatus: 'failed', lastError: 'auth failed', checkedAt: 4000 });
|
||||
});
|
||||
|
||||
it('scopes detail rows to the node', () => {
|
||||
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
||||
db().upsertStackUpdateStatus(2, 'web', false, 1000, 'failed', 'boom');
|
||||
expect(Object.keys(db().getStackUpdateDetail(NODE))).toEqual(['web']);
|
||||
expect(db().getStackUpdateDetail(NODE).web.hasUpdate).toBe(true);
|
||||
expect(db().getStackUpdateDetail(2).web.checkStatus).toBe('failed');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user