mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
feat(schedules): next-24h timeline + merge auto-update into schedules (#681)
* feat(backend): add stack update-preview endpoint for readiness board Adds GET /api/stacks/:stackName/update-preview that returns per-image semver diff, bump classification, and a stack-level summary powering the Auto-Update readiness board. - New UpdatePreviewService parses compose images, inspects local digests, fetches remote digests and tag lists, and finds the highest compatible semver tag. - Major bumps are flagged blocked until human review; unknown bumps rank below real semver so they cannot mask a major. - Rollback target is reconstructed through parseImageRef to preserve registry ports and drop the Docker Hub library/ prefix. - Registry helpers (httpGet, auth token, digest, tag list, ref parse) are extracted into registry-api.ts and shared with ImageUpdateService. - 28 Vitest cases cover parse, selection, bump math, digest rebuilds, blocked policy, and rollback target construction. * feat(schedules): next-24h timeline, merge auto-update crud, add readiness board Replace the flat task table with a Timeline view as the default, showing the next 24 hours of scheduled work across four lanes (Restart, Update, Scan, Prune) with a live now rail and per-firing pills. The All tasks tab preserves the existing CRUD surface. Merge Auto-update Stack into Schedules as a first-class action and replace the standalone Auto-Update Policies view with a per-stack Readiness board that surfaces version diffs, risk tags, changelog previews, and rollback targets sourced from the stack update-preview endpoint.
This commit is contained in:
@@ -171,6 +171,45 @@ describe('SchedulerService - calculateNextRun', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulerService - calculateRunsWithin', () => {
|
||||
it('expands hourly cron into every firing within a 24h window when limit allows', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
const from = Date.now();
|
||||
const to = from + 24 * 60 * 60 * 1000;
|
||||
const runs = svc.calculateRunsWithin('0 * * * *', from, to, 32);
|
||||
expect(runs.length).toBeGreaterThanOrEqual(23);
|
||||
expect(runs.length).toBeLessThanOrEqual(24);
|
||||
for (const run of runs) {
|
||||
expect(run).toBeGreaterThanOrEqual(from);
|
||||
expect(run).toBeLessThanOrEqual(to);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a single firing for a daily cron', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
const from = Date.now();
|
||||
const to = from + 24 * 60 * 60 * 1000;
|
||||
const runs = svc.calculateRunsWithin('0 3 * * *', from, to);
|
||||
expect(runs.length).toBeLessThanOrEqual(2);
|
||||
expect(runs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('honours the limit parameter to avoid runaway expansions', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
const from = Date.now();
|
||||
const to = from + 60 * 60 * 1000;
|
||||
const runs = svc.calculateRunsWithin('* * * * *', from, to, 5);
|
||||
expect(runs.length).toBe(5);
|
||||
});
|
||||
|
||||
it('returns empty array for invalid cron instead of throwing', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
const from = Date.now();
|
||||
const to = from + 60 * 60 * 1000;
|
||||
expect(svc.calculateRunsWithin('not a cron', from, to)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── License gating ─────────────────────────────────────────────────────
|
||||
|
||||
describe('SchedulerService - license gating', () => {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
parseSemverTag,
|
||||
findNextTag,
|
||||
computeSemverBump,
|
||||
computeImagePreview,
|
||||
buildSummary,
|
||||
type ComputePreviewDeps,
|
||||
} from '../services/UpdatePreviewService';
|
||||
|
||||
describe('parseSemverTag', () => {
|
||||
it('parses bare semver', () => {
|
||||
expect(parseSemverTag('1.2.3')).toMatchObject({ prefix: '', major: 1, minor: 2, patch: 3, suffix: '' });
|
||||
});
|
||||
it('parses v-prefixed semver', () => {
|
||||
expect(parseSemverTag('v1.2.3')).toMatchObject({ prefix: 'v', major: 1, minor: 2, patch: 3 });
|
||||
});
|
||||
it('parses suffixed semver (alpine, slim)', () => {
|
||||
expect(parseSemverTag('27.1.4-alpine')).toMatchObject({ major: 27, minor: 1, patch: 4, suffix: 'alpine' });
|
||||
});
|
||||
it('rejects non-semver', () => {
|
||||
expect(parseSemverTag('latest')).toBeNull();
|
||||
expect(parseSemverTag('main')).toBeNull();
|
||||
expect(parseSemverTag('1.2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNextTag', () => {
|
||||
it('picks highest semver greater than current', () => {
|
||||
const tags = ['27.1.3', '27.1.4', '27.1.5', '27.2.0', '27.1.5-alpine'];
|
||||
expect(findNextTag('27.1.4', tags)).toBe('27.2.0');
|
||||
});
|
||||
it('keeps prefix style (v vs bare)', () => {
|
||||
const tags = ['1.2.3', '1.2.4', 'v1.2.4', 'v1.3.0'];
|
||||
expect(findNextTag('v1.2.3', tags)).toBe('v1.3.0');
|
||||
expect(findNextTag('1.2.3', tags)).toBe('1.2.4');
|
||||
});
|
||||
it('keeps suffix style (alpine)', () => {
|
||||
const tags = ['1.2.3', '1.2.4', '1.2.3-alpine', '1.2.4-alpine'];
|
||||
expect(findNextTag('1.2.3-alpine', tags)).toBe('1.2.4-alpine');
|
||||
});
|
||||
it('returns null when current tag is not semver', () => {
|
||||
expect(findNextTag('latest', ['latest', '1.2.3'])).toBeNull();
|
||||
});
|
||||
it('returns null when no higher semver exists', () => {
|
||||
expect(findNextTag('1.2.3', ['1.2.0', '1.2.1', '1.2.2'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSemverBump', () => {
|
||||
it('detects major jump', () => {
|
||||
expect(computeSemverBump('1.2.3', '2.0.0')).toBe('major');
|
||||
});
|
||||
it('detects minor jump', () => {
|
||||
expect(computeSemverBump('1.2.3', '1.3.0')).toBe('minor');
|
||||
});
|
||||
it('detects patch jump', () => {
|
||||
expect(computeSemverBump('1.2.3', '1.2.4')).toBe('patch');
|
||||
});
|
||||
it('returns patch when tags are identical (digest rebuild)', () => {
|
||||
expect(computeSemverBump('latest', 'latest')).toBe('patch');
|
||||
});
|
||||
it('returns none when no next tag', () => {
|
||||
expect(computeSemverBump('1.2.3', null)).toBe('none');
|
||||
});
|
||||
it('returns unknown for non-semver pairs', () => {
|
||||
expect(computeSemverBump('main', 'stable')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
function makeDeps(overrides: Partial<ComputePreviewDeps> = {}): ComputePreviewDeps {
|
||||
return {
|
||||
getCredentials: vi.fn().mockResolvedValue(null),
|
||||
getLocalDigest: vi.fn().mockResolvedValue(null),
|
||||
getRemoteDigest: vi.fn().mockResolvedValue(null),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('computeImagePreview', () => {
|
||||
it('reports no update when digests match and no higher tag exists', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3']),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:1.2.3', deps);
|
||||
expect(result.has_update).toBe(false);
|
||||
expect(result.semver_bump).toBe('none');
|
||||
expect(result.next_tag).toBeNull();
|
||||
});
|
||||
|
||||
it('reports digest rebuild as patch when tag is unchanged but digest differs', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
getRemoteDigest: vi.fn().mockResolvedValue('sha256:bbb'),
|
||||
listRegistryTags: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const result = await computeImagePreview('web', 'nginx:latest', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.current_tag).toBe('latest');
|
||||
expect(result.next_tag).toBe('latest');
|
||||
expect(result.semver_bump).toBe('patch');
|
||||
});
|
||||
|
||||
it('reports higher semver tag when available', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['27.1.4', '27.1.5', '27.2.0']),
|
||||
});
|
||||
const result = await computeImagePreview('engine', 'docker.io/library/docker:27.1.4', deps);
|
||||
expect(result.has_update).toBe(true);
|
||||
expect(result.next_tag).toBe('27.2.0');
|
||||
expect(result.semver_bump).toBe('minor');
|
||||
});
|
||||
|
||||
it('flags major semver jumps', async () => {
|
||||
const deps = makeDeps({
|
||||
getLocalDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
getRemoteDigest: vi.fn().mockResolvedValue('sha256:aaa'),
|
||||
listRegistryTags: vi.fn().mockResolvedValue(['1.2.3', '2.0.0']),
|
||||
});
|
||||
const result = await computeImagePreview('db', 'postgres:1.2.3', deps);
|
||||
expect(result.next_tag).toBe('2.0.0');
|
||||
expect(result.semver_bump).toBe('major');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSummary', () => {
|
||||
const baseImage = (partial: Partial<Parameters<typeof buildSummary>[1][number]>) => ({
|
||||
service: 'svc',
|
||||
image: 'nginx:1.0.0',
|
||||
current_tag: '1.0.0',
|
||||
next_tag: null,
|
||||
has_update: false,
|
||||
semver_bump: 'none' as const,
|
||||
...partial,
|
||||
});
|
||||
|
||||
it('flags blocked when any image has a major bump', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'web', has_update: true, semver_bump: 'major', next_tag: '2.0.0' }),
|
||||
baseImage({ service: 'cache', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', image: 'redis:1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.summary.blocked).toBe(true);
|
||||
expect(preview.summary.blocked_reason).toMatch(/major/i);
|
||||
expect(preview.summary.semver_bump).toBe('major');
|
||||
});
|
||||
|
||||
it('picks first updated image as primary', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'clean', has_update: false }),
|
||||
baseImage({ service: 'web', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'nginx:1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.summary.primary_image).toBe('nginx:1.0.0');
|
||||
expect(preview.summary.next_tag).toBe('1.1.0');
|
||||
expect(preview.summary.blocked).toBe(false);
|
||||
});
|
||||
|
||||
it('returns has_update=false when no images update', () => {
|
||||
const images = [baseImage({ service: 'clean', has_update: false })];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.semver_bump).toBe('none');
|
||||
});
|
||||
|
||||
it('handles empty image list', () => {
|
||||
const preview = buildSummary('empty', []);
|
||||
expect(preview.summary.has_update).toBe(false);
|
||||
expect(preview.summary.primary_image).toBeNull();
|
||||
expect(preview.rollback_target).toBeNull();
|
||||
});
|
||||
|
||||
it('computes rollback target from current tag of primary', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'web', image: 'nginx:1.0.0', has_update: true, semver_bump: 'patch', next_tag: '1.0.1', current_tag: '1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.rollback_target).toBe('nginx:1.0.0');
|
||||
});
|
||||
|
||||
it('computes rollback target for Docker Hub library image', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'db', image: 'library/postgres:16', has_update: true, semver_bump: 'patch', next_tag: '16', current_tag: '16' }),
|
||||
];
|
||||
expect(buildSummary('stacky', images).rollback_target).toBe('postgres:16');
|
||||
});
|
||||
|
||||
it('computes rollback target for registry with port', () => {
|
||||
const images = [
|
||||
baseImage({
|
||||
service: 'app',
|
||||
image: 'registry.example.com:5000/team/image:1.2.3',
|
||||
has_update: true,
|
||||
semver_bump: 'patch',
|
||||
next_tag: '1.2.4',
|
||||
current_tag: '1.2.3',
|
||||
}),
|
||||
];
|
||||
expect(buildSummary('stacky', images).rollback_target).toBe('registry.example.com:5000/team/image:1.2.3');
|
||||
});
|
||||
|
||||
it('leaves blocked false for patch/minor only updates', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'web', has_update: true, semver_bump: 'patch', next_tag: '1.0.1' }),
|
||||
baseImage({ service: 'cache', has_update: true, semver_bump: 'minor', next_tag: '1.1.0', image: 'redis:1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.summary.blocked).toBe(false);
|
||||
expect(preview.summary.blocked_reason).toBeNull();
|
||||
expect(preview.summary.semver_bump).toBe('minor');
|
||||
});
|
||||
|
||||
it('does not let unknown bumps mask a real major bump', () => {
|
||||
const images = [
|
||||
baseImage({ service: 'odd', has_update: true, semver_bump: 'unknown', next_tag: 'main', image: 'ghcr.io/org/odd:main' }),
|
||||
baseImage({ service: 'db', has_update: true, semver_bump: 'major', next_tag: '2.0.0', image: 'postgres:1.0.0' }),
|
||||
];
|
||||
const preview = buildSummary('stacky', images);
|
||||
expect(preview.summary.semver_bump).toBe('major');
|
||||
expect(preview.summary.blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user