mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
fa503ddf27
* feat: add node-scoped opt-out for image update detection Operators who use an external update authority can disable Sencho registry polling per node without losing explicit stack Update, pull, or redeploy. * test: fix mocks and lint for image-update checks opt-out Scheduler tests need isChecksEnabled on the ImageUpdateService mock, and the UpdatesSection older-node fixture must not leave an unused binding. * fix: gate update-preview and recheck when detection is off Anatomy was still calling stack update-preview (and contacting registries) while checks were disabled. Short-circuit those routes and skip recheckStack writes so disabled nodes stay quiet until detection is re-enabled.
204 lines
9.8 KiB
TypeScript
204 lines
9.8 KiB
TypeScript
/**
|
|
* 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 raw boolean map ignoring check_status', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
|
db().upsertStackUpdateStatus(NODE, 'api', false, 1000, 'failed', 'boom');
|
|
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
|
expect(db().getStackUpdateStatus(NODE)).toEqual({ web: true, api: false, sticky: true });
|
|
});
|
|
|
|
it('projects confirmed updates only via getConfirmedStackUpdateStatus', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
|
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
|
db().upsertStackUpdateStatus(NODE, 'failed', true, 1000, 'failed', 'boom');
|
|
db().upsertStackUpdateStatus(NODE, 'clean', false, 1000, 'ok', null);
|
|
expect(db().getConfirmedStackUpdateStatus(NODE)).toEqual({
|
|
web: true,
|
|
sticky: false,
|
|
failed: false,
|
|
clean: false,
|
|
});
|
|
});
|
|
|
|
it('clearStackUpdateStatus returns deleted row count and removes services_json', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'partial', 'half', [
|
|
{ service: 'web', image: 'web:1', hasUpdate: true, checkStatus: 'ok', lastError: null },
|
|
]);
|
|
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(1);
|
|
expect(db().getStackUpdateDetail(NODE).web).toBeUndefined();
|
|
expect(db().clearStackUpdateStatus(NODE, 'web')).toBe(0);
|
|
});
|
|
|
|
it('clearAllStackUpdateStatus deletes only the given node rows', () => {
|
|
const other = NODE + 1;
|
|
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
|
db().upsertStackUpdateStatus(NODE, 'api', true, 1000, 'ok', null);
|
|
db().upsertStackUpdateStatus(other, 'web', true, 1000, 'ok', null);
|
|
expect(db().clearAllStackUpdateStatus(NODE)).toBe(2);
|
|
expect(db().getStackUpdateDetail(NODE)).toEqual({});
|
|
expect(db().getStackUpdateDetail(other).web).toBeDefined();
|
|
});
|
|
|
|
it('getNodeUpdateSummary counts only confirmed updates', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'web', true, 1000, 'ok', null);
|
|
db().upsertStackUpdateStatus(NODE, 'sticky', true, 1000, 'partial', 'half');
|
|
const summary = db().getNodeUpdateSummary().find((r) => r.node_id === NODE);
|
|
expect(summary?.stacks_with_updates).toBe(1);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('stack_update_status services_json (per-service reduction)', () => {
|
|
const SERVICES = [
|
|
{ service: 'web', image: 'web:latest', hasUpdate: true, checkStatus: 'ok' as const, lastError: null },
|
|
{ service: 'worker', image: 'worker:latest', hasUpdate: false, checkStatus: 'ok' as const, lastError: null },
|
|
];
|
|
|
|
it('persists and returns a per-service breakdown via getStackUpdateDetail', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 3);
|
|
|
|
const detail = db().getStackUpdateDetail(NODE).stackA;
|
|
expect(detail.services).toEqual(SERVICES);
|
|
});
|
|
|
|
it('omits the services field entirely for a stack with no persisted per-service data', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
|
|
|
|
const detail = db().getStackUpdateDetail(NODE).stackA;
|
|
expect(detail.services).toBeUndefined();
|
|
expect('services' in detail).toBe(false);
|
|
});
|
|
|
|
it('leaves a prior services_json untouched when a later write omits services (COALESCE)', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 1);
|
|
// A legacy-path caller (or the fallback tally) writes without a services array.
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 2000, 'ok', null);
|
|
|
|
expect(db().getStackUpdateDetail(NODE).stackA.services).toEqual(SERVICES);
|
|
});
|
|
|
|
it('getStackServicesJson returns [] for a stack with no persisted row', () => {
|
|
expect(db().getStackServicesJson(NODE, 'missing')).toEqual([]);
|
|
});
|
|
|
|
it('getStackServicesJson returns [] for a stack row with no services_json', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
|
|
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
|
|
});
|
|
|
|
it('getStackServicesJson round-trips a persisted per-service breakdown', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null, SERVICES, 5);
|
|
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES);
|
|
});
|
|
|
|
it('recordStackCheckFailure persists a per-service breakdown alongside the failure', () => {
|
|
db().recordStackCheckFailure(NODE, 'stackA', 'Registry unreachable', 4000, SERVICES, 2);
|
|
|
|
const detail = db().getStackUpdateDetail(NODE).stackA;
|
|
expect(detail.checkStatus).toBe('failed');
|
|
expect(detail.services).toEqual(SERVICES);
|
|
});
|
|
|
|
it('treats corrupt services_json as an empty list rather than throwing (empty model invariant)', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
|
|
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
|
|
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
|
|
.run('{not valid json', NODE, 'stackA');
|
|
|
|
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
|
|
const detail = db().getStackUpdateDetail(NODE).stackA;
|
|
expect(detail.services).toBeUndefined();
|
|
expect(detail.hasUpdate).toBe(true); // aggregate columns are unaffected by corrupt services_json
|
|
});
|
|
|
|
it('treats a services_json shape-version mismatch as an empty list', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
|
|
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
|
|
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
|
|
.run(JSON.stringify({ version: 999, generation: 1, services: SERVICES }), NODE, 'stackA');
|
|
|
|
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual([]);
|
|
});
|
|
|
|
it('filters out malformed entries within an otherwise valid services_json array', () => {
|
|
db().upsertStackUpdateStatus(NODE, 'stackA', true, 1000, 'ok', null);
|
|
const raw = (db() as unknown as { db: { prepare: (s: string) => { run: (...args: unknown[]) => void } } }).db;
|
|
const malformed = [...SERVICES, { service: 'broken' /* missing required fields */ }];
|
|
raw.prepare('UPDATE stack_update_status SET services_json = ? WHERE node_id = ? AND stack_name = ?')
|
|
.run(JSON.stringify({ version: 1, generation: 1, services: malformed }), NODE, 'stackA');
|
|
|
|
expect(db().getStackServicesJson(NODE, 'stackA')).toEqual(SERVICES);
|
|
});
|
|
});
|