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:
Anso
2026-04-18 17:48:02 -04:00
committed by GitHub
parent 0bf061a745
commit 95278843cf
16 changed files with 1515 additions and 878 deletions
@@ -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);
});
});
+28 -1
View File
@@ -26,6 +26,7 @@ import { MonitorService } from './services/MonitorService';
import { AutoHealService } from './services/AutoHealService';
import { DockerEventManager } from './services/DockerEventManager';
import { ImageUpdateService } from './services/ImageUpdateService';
import { UpdatePreviewService } from './services/UpdatePreviewService';
import { templateService } from './services/TemplateService';
import { ErrorParser } from './utils/ErrorParser';
import { NodeRegistry } from './services/NodeRegistry';
@@ -5198,6 +5199,21 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) =>
}
});
// Update preview: semver diff, risk tagging, rollback target for the readiness board
app.get('/api/stacks/:stackName/update-preview', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!isValidStackName(stackName)) {
return res.status(400).json({ error: 'Invalid stack name' });
}
try {
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
res.json(preview);
} catch (error) {
console.error(`[Stacks] Update preview failed: ${stackName}`, error);
res.status(500).json({ error: 'Failed to compute update preview' });
}
});
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
@@ -6428,7 +6444,18 @@ app.get('/api/scheduled-tasks', (req: Request, res: Response): void => {
} else if (excludeAction) {
tasks = tasks.filter(t => t.action !== excludeAction);
}
res.json(tasks);
// Timeline view needs every firing inside a rolling window, not just the next run.
const scheduler = SchedulerService.getInstance();
const windowHours = Math.min(Math.max(Number(req.query.window_hours) || 24, 1), 168);
const from = Date.now();
const to = from + windowHours * 60 * 60 * 1000;
const enriched = tasks.map(t => ({
...t,
next_runs: t.enabled === 1 ? scheduler.calculateRunsWithin(t.cron_expression, from, to) : [],
}));
res.json(enriched);
} catch (error) {
console.error('[ScheduledTasks] List error:', error);
res.status(500).json({ error: 'Failed to fetch scheduled tasks' });
+19 -151
View File
@@ -1,5 +1,3 @@
import https from 'https';
import http from 'http';
import path from 'path';
import YAML from 'yaml';
import DockerController from './DockerController';
@@ -8,87 +6,20 @@ import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import { parseImageRef, getRemoteDigest } from './registry-api';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
const BACKFILL_KEY = 'image_update_notifications_backfilled';
// ─── Image ref parsing ────────────────────────────────────────────────────────
interface ParsedRef {
registry: string; // e.g. "registry-1.docker.io", "lscr.io", "ghcr.io"
repo: string; // e.g. "library/nginx", "linuxserver/sonarr"
tag: string; // e.g. "latest", "1.25"
}
function parseImageRef(imageRef: string): ParsedRef | null {
if (imageRef.startsWith('sha256:')) return null;
// Strip digest pin (e.g. "nginx@sha256:abc" → "nginx")
const atIdx = imageRef.indexOf('@');
if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx);
let registry = 'registry-1.docker.io';
let rest = imageRef;
const slashIdx = imageRef.indexOf('/');
if (slashIdx !== -1) {
const firstPart = imageRef.slice(0, slashIdx);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
rest = imageRef.slice(slashIdx + 1);
}
}
// Extract tag
let tag = 'latest';
const colonIdx = rest.lastIndexOf(':');
if (colonIdx > 0) {
tag = rest.slice(colonIdx + 1);
rest = rest.slice(0, colonIdx);
}
// Docker Hub official images (no slash) → prepend "library/"
if (registry === 'registry-1.docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { registry, repo: rest, tag };
}
export interface ImageCheckResult {
hasUpdate: boolean;
error?: string;
}
// ─── Minimal HTTP helper ──────────────────────────────────────────────────────
interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
function httpGet(url: string, headers: Record<string, string> = {}, timeoutMs = 10000): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
const req = lib.get(url, { headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
}));
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => req.destroy(new Error('Request timed out')));
});
}
// ─── Compose file helpers ────────────────────────────────────────────────────
function loadDotEnv(content: string): Record<string, string> {
export function loadDotEnv(content: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
@@ -106,10 +37,15 @@ function loadDotEnv(content: string): Record<string, string> {
return vars;
}
function extractImagesFromCompose(
export interface ComposeServiceImage {
service: string;
image: string;
}
export function extractServiceImagesFromCompose(
yamlContent: string,
envVars: Record<string, string>
): string[] {
envVars: Record<string, string>,
): ComposeServiceImage[] {
let parsed: Record<string, unknown>;
try {
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
@@ -118,8 +54,8 @@ function extractImagesFromCompose(
}
if (!parsed?.services || typeof parsed.services !== 'object') return [];
const images: string[] = [];
for (const svc of Object.values(parsed.services as Record<string, unknown>)) {
const out: ComposeServiceImage[] = [];
for (const [service, svc] of Object.entries(parsed.services as Record<string, unknown>)) {
if (!svc || typeof svc !== 'object') continue;
const raw = (svc as Record<string, unknown>).image;
if (!raw || typeof raw !== 'string') continue;
@@ -137,84 +73,16 @@ function extractImagesFromCompose(
ref = ref.trim();
if (!ref || ref.includes('${') || ref.startsWith('sha256:')) continue;
images.push(ref);
out.push({ service, image: ref });
}
return images;
return out;
}
// ─── Registry auth ────────────────────────────────────────────────────────────
async function getAuthToken(
registry: string,
repo: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
// Ping /v2/ to get the WWW-Authenticate challenge
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
const scopeMatch = wwwAuth.match(/scope="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
return parsed.token ?? parsed.access_token ?? null;
} catch {
return null;
}
}
// ─── Remote digest lookup ─────────────────────────────────────────────────────
// Include manifest list types so we get the fat-manifest digest for multi-arch
// images - this matches what Docker stores in local RepoDigests.
const MANIFEST_ACCEPT = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
async function getRemoteDigest(
registry: string,
repo: string,
tag: string,
credentials?: { username: string; password: string } | null
): Promise<string | null> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers);
if (res.statusCode !== 200) return null;
return (res.headers['docker-content-digest'] as string) ?? null;
} catch {
return null;
}
export function extractImagesFromCompose(
yamlContent: string,
envVars: Record<string, string>,
): string[] {
return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image);
}
// ─── Service ──────────────────────────────────────────────────────────────────
+15
View File
@@ -140,6 +140,21 @@ export class SchedulerService {
return expr.next().toDate().getTime();
}
public calculateRunsWithin(cronExpression: string, fromMs: number, toMs: number, limit = 16): number[] {
try {
const expr = CronExpressionParser.parse(cronExpression, { currentDate: new Date(fromMs) });
const runs: number[] = [];
while (runs.length < limit) {
const next = expr.next().toDate().getTime();
if (next > toMs) break;
runs.push(next);
}
return runs;
} catch {
return [];
}
}
/**
* Fire a notification without awaiting completion, catching any promise
* rejection so the scheduler never crashes on a failed dispatch.
@@ -0,0 +1,270 @@
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
import {
extractServiceImagesFromCompose,
loadDotEnv,
type ComposeServiceImage,
} from './ImageUpdateService';
import {
parseImageRef,
getRemoteDigest,
listRegistryTags,
type RegistryCredentials,
} from './registry-api';
export type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
export interface UpdatePreviewImage {
service: string;
image: string;
current_tag: string;
next_tag: string | null;
has_update: boolean;
semver_bump: SemverBump;
}
export interface UpdatePreviewSummary {
has_update: boolean;
primary_image: string | null;
current_tag: string | null;
next_tag: string | null;
semver_bump: SemverBump;
blocked: boolean;
blocked_reason: string | null;
}
export interface UpdatePreview {
stack_name: string;
images: UpdatePreviewImage[];
summary: UpdatePreviewSummary;
rollback_target: string | null;
changelog: string | null;
}
interface SemverParts {
prefix: string;
major: number;
minor: number;
patch: number;
suffix: string;
raw: string;
}
const SEMVER_RE = /^(v)?(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z][A-Za-z0-9.-]*))?$/;
export function parseSemverTag(tag: string): SemverParts | null {
const m = tag.match(SEMVER_RE);
if (!m) return null;
return {
prefix: m[1] ?? '',
major: Number(m[2]),
minor: Number(m[3]),
patch: Number(m[4]),
suffix: m[5] ?? '',
raw: tag,
};
}
function compareSemver(a: SemverParts, b: SemverParts): number {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
export function findNextTag(currentTag: string, availableTags: string[]): string | null {
const current = parseSemverTag(currentTag);
if (!current) return null;
let best: SemverParts | null = null;
for (const tag of availableTags) {
const parsed = parseSemverTag(tag);
if (!parsed) continue;
if (parsed.prefix !== current.prefix) continue;
if (parsed.suffix !== current.suffix) continue;
if (compareSemver(parsed, current) <= 0) continue;
if (!best || compareSemver(parsed, best) > 0) best = parsed;
}
return best ? best.raw : null;
}
export function computeSemverBump(currentTag: string, nextTag: string | null): SemverBump {
if (!nextTag) return 'none';
if (nextTag === currentTag) return 'patch';
const current = parseSemverTag(currentTag);
const next = parseSemverTag(nextTag);
if (!current || !next) return 'unknown';
if (next.major > current.major) return 'major';
if (next.minor > current.minor) return 'minor';
if (next.patch > current.patch) return 'patch';
return 'none';
}
function maxBump(a: SemverBump, b: SemverBump): SemverBump {
// Ranking: none < unknown < patch < minor < major.
// unknown ranks below real semver bumps so a single unparseable tag never masks
// a genuine major bump elsewhere in the stack.
const order: SemverBump[] = ['none', 'unknown', 'patch', 'minor', 'major'];
const rank = (x: SemverBump) => order.indexOf(x);
return rank(a) >= rank(b) ? a : b;
}
async function loadStackImages(
nodeId: number,
stackName: string,
): Promise<ComposeServiceImage[]> {
const fs = FileSystemService.getInstance(nodeId);
const composeContent = await fs.getStackContent(stackName);
let envVars: Record<string, string> = {};
try {
const envContent = await fs.getEnvContent(stackName);
envVars = loadDotEnv(envContent);
} catch {
// No env file - fall back to process.env only
}
const merged: Record<string, string> = { ...envVars };
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) merged[k] = v;
}
return extractServiceImagesFromCompose(composeContent, merged);
}
export interface ComputePreviewDeps {
getLocalDigest: (imageRef: string) => Promise<string | null>;
getRemoteDigest: typeof getRemoteDigest;
listRegistryTags: typeof listRegistryTags;
getCredentials: (registry: string) => Promise<RegistryCredentials | null>;
}
export async function computeImagePreview(
service: string,
imageRef: string,
deps: ComputePreviewDeps,
): Promise<UpdatePreviewImage> {
const parsed = parseImageRef(imageRef);
if (!parsed) {
return {
service,
image: imageRef,
current_tag: 'unknown',
next_tag: null,
has_update: false,
semver_bump: 'none',
};
}
const credentials = await deps.getCredentials(parsed.registry);
// Digest-based: is a new build of the SAME tag available?
const [localDigest, remoteDigest] = await Promise.all([
deps.getLocalDigest(imageRef),
deps.getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials),
]);
const digestUpdate = Boolean(localDigest && remoteDigest && localDigest !== remoteDigest);
// Tag-based: is a higher semver tag available?
const tags = await deps.listRegistryTags(parsed.registry, parsed.repo, credentials);
const nextTag = findNextTag(parsed.tag, tags);
const hasUpdate = digestUpdate || nextTag !== null;
let semverBump: SemverBump = 'none';
let resolvedNext: string | null = null;
if (nextTag) {
resolvedNext = nextTag;
semverBump = computeSemverBump(parsed.tag, nextTag);
} else if (digestUpdate) {
resolvedNext = parsed.tag;
semverBump = 'patch';
}
return {
service,
image: imageRef,
current_tag: parsed.tag,
next_tag: resolvedNext,
has_update: hasUpdate,
semver_bump: semverBump,
};
}
function buildRollbackTarget(image: string, currentTag: string): string | null {
const parsed = parseImageRef(image);
if (!parsed) return null;
// Reconstruct without the library/ prefix Docker Hub uses internally,
// so "library/nginx" renders as "nginx:1.0.0" not "registry-1.docker.io/library/nginx:1.0.0".
const isDockerHub = parsed.registry === 'registry-1.docker.io';
const repo = isDockerHub && parsed.repo.startsWith('library/')
? parsed.repo.slice('library/'.length)
: parsed.repo;
const base = isDockerHub ? repo : `${parsed.registry}/${repo}`;
return `${base}:${currentTag}`;
}
export function buildSummary(stackName: string, images: UpdatePreviewImage[]): UpdatePreview {
const updated = images.filter(i => i.has_update);
const hasUpdate = updated.length > 0;
const primary = updated[0] ?? images[0] ?? null;
const overallBump = updated.reduce<SemverBump>(
(acc, img) => maxBump(acc, img.semver_bump),
'none',
);
const blocked = overallBump === 'major';
return {
stack_name: stackName,
images,
summary: {
has_update: hasUpdate,
primary_image: primary ? primary.image : null,
current_tag: primary ? primary.current_tag : null,
next_tag: primary ? primary.next_tag : null,
semver_bump: overallBump,
blocked,
blocked_reason: blocked ? 'Major version jumps require human review before applying.' : null,
},
rollback_target: primary ? buildRollbackTarget(primary.image, primary.current_tag) : null,
changelog: null,
};
}
export class UpdatePreviewService {
private static instance: UpdatePreviewService;
public static getInstance(): UpdatePreviewService {
if (!UpdatePreviewService.instance) {
UpdatePreviewService.instance = new UpdatePreviewService();
}
return UpdatePreviewService.instance;
}
public async getPreview(nodeId: number, stackName: string): Promise<UpdatePreview> {
const stackImages = await loadStackImages(nodeId, stackName);
if (stackImages.length === 0) {
return buildSummary(stackName, []);
}
const docker = DockerController.getInstance(nodeId);
const deps: ComputePreviewDeps = {
getCredentials: (registry) => RegistryService.getInstance().getAuthForRegistry(registry),
getRemoteDigest,
listRegistryTags,
getLocalDigest: async (imageRef: string) => {
try {
const inspect = await docker.getDocker().getImage(imageRef).inspect();
const repoDigests: string[] = inspect.RepoDigests ?? [];
for (const rd of repoDigests) {
if (!rd.includes('@sha256:')) continue;
const [, digest] = rd.split('@');
return digest;
}
return null;
} catch {
return null;
}
},
};
const results = await Promise.all(
stackImages.map(({ service, image }) => computeImagePreview(service, image, deps)),
);
return buildSummary(stackName, results);
}
}
+169
View File
@@ -0,0 +1,169 @@
import https from 'https';
import http from 'http';
export interface ParsedRef {
registry: string;
repo: string;
tag: string;
}
export interface HttpResult {
statusCode: number;
headers: Record<string, string | string[] | undefined>;
body: string;
}
export interface RegistryCredentials {
username: string;
password: string;
}
export function parseImageRef(imageRef: string): ParsedRef | null {
if (imageRef.startsWith('sha256:')) return null;
const atIdx = imageRef.indexOf('@');
if (atIdx !== -1) imageRef = imageRef.slice(0, atIdx);
let registry = 'registry-1.docker.io';
let rest = imageRef;
const slashIdx = imageRef.indexOf('/');
if (slashIdx !== -1) {
const firstPart = imageRef.slice(0, slashIdx);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
rest = imageRef.slice(slashIdx + 1);
}
}
let tag = 'latest';
const colonIdx = rest.lastIndexOf(':');
if (colonIdx > 0) {
tag = rest.slice(colonIdx + 1);
rest = rest.slice(0, colonIdx);
}
if (registry === 'registry-1.docker.io' && !rest.includes('/')) {
rest = `library/${rest}`;
}
return { registry, repo: rest, tag };
}
export function httpGet(
url: string,
headers: Record<string, string> = {},
timeoutMs = 10000,
): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https:') ? https : http;
let settled = false;
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
fn();
};
const req = lib.get(url, { headers }, (res) => {
let body = '';
res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
res.on('end', () => finish(() => resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers as Record<string, string | string[] | undefined>,
body,
})));
res.on('error', (err) => finish(() => reject(err)));
});
req.on('error', (err) => finish(() => reject(err)));
req.setTimeout(timeoutMs, () => {
const err = new Error('Request timed out');
req.destroy(err);
finish(() => reject(err));
});
});
}
export async function getAuthToken(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string | null> {
try {
const basicHeaders: Record<string, string> = {};
if (credentials) {
basicHeaders['Authorization'] = `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
let tokenUrl: string;
if (registry === 'registry-1.docker.io') {
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repo}:pull`;
} else {
const ping = await httpGet(`https://${registry}/v2/`, basicHeaders);
const wwwAuth = ping.headers['www-authenticate'] as string | undefined;
if (!wwwAuth) return null;
const realmMatch = wwwAuth.match(/realm="([^"]+)"/);
const serviceMatch = wwwAuth.match(/service="([^"]+)"/);
const scopeMatch = wwwAuth.match(/scope="([^"]+)"/);
if (!realmMatch) return null;
const params = new URLSearchParams();
if (serviceMatch) params.set('service', serviceMatch[1]);
params.set('scope', scopeMatch ? scopeMatch[1] : `repository:${repo}:pull`);
tokenUrl = `${realmMatch[1]}?${params.toString()}`;
}
const tokenRes = await httpGet(tokenUrl, basicHeaders);
if (tokenRes.statusCode !== 200) return null;
const parsed = JSON.parse(tokenRes.body);
return parsed.token ?? parsed.access_token ?? null;
} catch {
return null;
}
}
const MANIFEST_ACCEPT = [
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
].join(', ');
export async function getRemoteDigest(
registry: string,
repo: string,
tag: string,
credentials?: RegistryCredentials | null,
): Promise<string | null> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/manifests/${tag}`, headers);
if (res.statusCode !== 200) return null;
return (res.headers['docker-content-digest'] as string) ?? null;
} catch {
return null;
}
}
export async function listRegistryTags(
registry: string,
repo: string,
credentials?: RegistryCredentials | null,
): Promise<string[]> {
try {
const token = await getAuthToken(registry, repo, credentials);
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await httpGet(`https://${registry}/v2/${repo}/tags/list`, headers);
if (res.statusCode !== 200) return [];
const parsed = JSON.parse(res.body) as { tags?: string[] };
return Array.isArray(parsed.tags) ? parsed.tags : [];
} catch {
return [];
}
}