mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +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:
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user