detect newer version tags for pinned images and show them as a badge with release notes

This commit is contained in:
jarek committed 2026-08-17 09:50:34 +02:00
1 parent 1740fdccfa
commit ba8670af66
36 files changed
+8820 -98

No files matched your search

+21 -3
View File
@@ -973,6 +973,7 @@ export const NOTIFICATION_EVENT_TYPES = [
{ id: 'auto_update_failed', label: 'Auto-update failed', description: 'Container auto-update failed (pull error, start error)', group: 'auto_update', scope: 'environment' },
{ id: 'auto_update_blocked', label: 'Auto-update blocked', description: 'Update blocked due to vulnerability criteria', group: 'auto_update', scope: 'environment' },
{ id: 'updates_detected', label: 'Updates detected', description: 'Container image updates are available (scheduled check)', group: 'auto_update', scope: 'environment' },
{ id: 'newer_version_available', label: 'Newer version tag', description: 'A newer version tag is published for a pinned image (semver, advisory)', group: 'auto_update', scope: 'environment' },
{ id: 'batch_update_success', label: 'Batch update completed', description: 'Scheduled container updates completed successfully', group: 'auto_update', scope: 'environment' },
// Git stack events (environment-scoped)
@@ -4867,6 +4868,12 @@ export interface EnvUpdateCheckSettings {
cron: string;
autoUpdate: boolean;
vulnerabilityCriteria: VulnerabilityCriteria;
// Semver (newer-version-tag) detection — optional so pre-existing rows (which
// never had these) parse as "off" without any migration.
checkPinnedVersions?: boolean;
semverMaxBump?: 'patch' | 'minor' | 'major';
semverMatchFlavor?: boolean;
semverIncludePrerelease?: boolean;
}
export async function getEnvUpdateCheckSettings(envId: number): Promise<EnvUpdateCheckSettings | null> {
@@ -5443,8 +5450,15 @@ export async function addPendingContainerUpdate(
environmentId: number,
containerId: string,
containerName: string,
currentImage: string
currentImage: string,
// A row can exist for a digest update, a newer-version-tag (semver) suggestion,
// or both. Both flags default to the classic "digest update only" shape so
// existing callers keep working unchanged.
options: { hasImageUpdate?: boolean; newerVersion?: unknown | null } = {}
): Promise<void> {
const hasImageUpdate = options.hasImageUpdate ?? true;
const newerVersion = options.newerVersion != null ? JSON.stringify(options.newerVersion) : null;
const now = new Date().toISOString();
// Use insert with onConflictDoUpdate for upsert behavior
await db.insert(pendingContainerUpdates)
.values({
@@ -5452,14 +5466,18 @@ export async function addPendingContainerUpdate(
containerId,
containerName,
currentImage,
checkedAt: new Date().toISOString()
hasImageUpdate,
newerVersion,
checkedAt: now
})
.onConflictDoUpdate({
target: [pendingContainerUpdates.environmentId, pendingContainerUpdates.containerId],
set: {
containerName,
currentImage,
checkedAt: new Date().toISOString()
hasImageUpdate,
newerVersion,
checkedAt: now
}
});
}
+6
View File
@@ -481,6 +481,12 @@ export const pendingContainerUpdates = sqliteTable('pending_container_updates',
containerId: text('container_id').notNull(),
containerName: text('container_name').notNull(),
currentImage: text('current_image').notNull(),
// True when a digest image update is pending (the classic amber icon). Defaults
// true so pre-existing rows keep behaving exactly as before.
hasImageUpdate: integer('has_image_update', { mode: 'boolean' }).notNull().default(true),
// A newer VERSION tag (semver) for a pinned image, as JSON {tag,bump,skipped}.
// Null when there's no semver suggestion. Advisory - never auto-applied.
newerVersion: text('newer_version'),
checkedAt: text('checked_at').default(sql`CURRENT_TIMESTAMP`),
createdAt: text('created_at').default(sql`CURRENT_TIMESTAMP`)
}, (table) => ({
+6
View File
@@ -484,6 +484,12 @@ export const pendingContainerUpdates = pgTable('pending_container_updates', {
containerId: text('container_id').notNull(),
containerName: text('container_name').notNull(),
currentImage: text('current_image').notNull(),
// True when a digest image update is pending (the classic amber icon). Defaults
// true so pre-existing rows keep behaving exactly as before.
hasImageUpdate: boolean('has_image_update').notNull().default(true),
// A newer VERSION tag (semver) for a pinned image, as JSON {tag,bump,skipped}.
// Null when there's no semver suggestion. Advisory - never auto-applied.
newerVersion: text('newer_version'),
checkedAt: timestamp('checked_at', { mode: 'string' }).defaultNow(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow()
}, (table) => ({
+4 -55
View File
@@ -19,6 +19,9 @@ import type { Environment } from './db';
import { getSetting } from './db';
import { getAdditionalVolumeBinds, dedupeVolumesForRecreate } from './mount-dedupe';
import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-recreate';
// Import-light image parsing shared with the semver layer; re-exported below for callers.
import { parseImageReference } from './registry/image-ref';
export { parseImageReference } from './registry/image-ref';
import { rebaseEnvOntoImage, rebaseLabelsOntoImage, rebaseCommand, describeEnvRebase, describeLabelRebase, type ImageEnvLabels } from './container-env-merge';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
@@ -3023,60 +3026,6 @@ export async function inspectImage(id: string, envId?: number | null) {
return dockerJsonRequest(`/images/${encodeURIComponent(id)}/json`, {}, envId);
}
/**
* Parse an image reference into registry, repository, and tag components.
* Follows Docker's reference parsing rules.
* Examples:
* nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' }
* ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' }
* registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' }
*/
function parseImageReference(imageName: string): { registry: string; repo: string; tag: string } {
let registry = 'index.docker.io'; // Docker Hub's actual host
let repo = imageName;
let tag = 'latest';
// Handle digest references (remove digest part for manifest lookup)
if (repo.includes('@')) {
const [repoWithoutDigest] = repo.split('@');
repo = repoWithoutDigest;
}
// Extract tag
const lastColon = repo.lastIndexOf(':');
if (lastColon > -1) {
const potentialTag = repo.substring(lastColon + 1);
// Make sure it's not a port number (no slashes in tags)
if (!potentialTag.includes('/')) {
tag = potentialTag;
repo = repo.substring(0, lastColon);
}
}
// Extract registry if present
const firstSlash = repo.indexOf('/');
if (firstSlash > -1) {
const firstPart = repo.substring(0, firstSlash);
// If the first part contains a dot, colon, or is "localhost", it's a registry
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
repo = repo.substring(firstSlash + 1);
}
}
// Normalize docker.io to index.docker.io (Docker Hub's actual registry host)
// docker.io redirects to www.docker.com, while index.docker.io is the real API
if (registry === 'docker.io') {
registry = 'index.docker.io';
}
// Docker Hub requires library/ prefix for official images
if (registry === 'index.docker.io' && !repo.includes('/')) {
repo = `library/${repo}`;
}
return { registry, repo, tag };
}
/**
* Parse a registry URL into host and path components.
@@ -3113,7 +3062,7 @@ export function parseRegistryUrl(url: string): { host: string; path: string; ful
* - Host-only stored: stored 'registry.example.com' matches requested 'registry.example.com/org'
* (allows a single credential entry to work for all org paths)
*/
async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
export async function findRegistryCredentials(registryHost: string): Promise<{ username: string; password: string } | null> {
try {
// Import here to avoid circular dependency
const { getRegistries } = await import('./db.js');
+75
View File
@@ -0,0 +1,75 @@
/**
* Parse a Docker image reference into its registry host, repository, and tag,
* following Docker's own reference rules. Pure and dependency-free so it can be
* unit-tested (and imported by the light semver layer) without pulling in the
* native modules that live in docker.ts.
*
* nginx:latest -> { registry: 'index.docker.io', repo: 'library/nginx', tag: 'latest' }
* ghcr.io/user/image:v1 -> { registry: 'ghcr.io', repo: 'user/image', tag: 'v1' }
* codeberg.org/forgejo/forgejo:15.0.2 -> { registry: 'codeberg.org', repo: 'forgejo/forgejo', tag: '15.0.2' }
* registry.example.com:5000/repo:tag -> { registry: 'registry.example.com:5000', repo: 'repo', tag: 'tag' }
*/
export interface ImageReference {
registry: string;
repo: string;
tag: string;
}
export function parseImageReference(imageName: string): ImageReference {
let registry = 'index.docker.io'; // Docker Hub's actual API host.
let repo = imageName;
let tag = 'latest';
// Drop a digest — irrelevant to tag/version lookups.
if (repo.includes('@')) {
repo = repo.split('@')[0];
}
// Split off the tag, but a colon before a `/` is a registry port, not a tag.
const lastColon = repo.lastIndexOf(':');
if (lastColon > -1) {
const potentialTag = repo.slice(lastColon + 1);
if (!potentialTag.includes('/')) {
tag = potentialTag;
repo = repo.slice(0, lastColon);
}
}
// The first path segment is the registry only if it looks like a host
// (has a dot or port, or is localhost) — otherwise it's part of the repo.
const firstSlash = repo.indexOf('/');
if (firstSlash > -1) {
const firstPart = repo.slice(0, firstSlash);
if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') {
registry = firstPart;
repo = repo.slice(firstSlash + 1);
}
}
if (registry === 'docker.io') registry = 'index.docker.io';
// Docker Hub official images are namespaced under library/.
if (registry === 'index.docker.io' && !repo.includes('/')) {
repo = `library/${repo}`;
}
return { registry, repo, tag };
}
/** Every hostname that means "Docker Hub". */
const DOCKER_HUB_HOSTS = new Set([
'index.docker.io',
'docker.io',
'registry-1.docker.io',
'registry.docker.io',
'hub.docker.com',
'registry.hub.docker.com'
]);
export function isDockerHub(registry: string): boolean {
return DOCKER_HUB_HOSTS.has(registry.toLowerCase());
}
export function isGhcr(registry: string): boolean {
return registry.toLowerCase() === 'ghcr.io';
}
@@ -15,8 +15,11 @@ import {
saveVulnerabilityScan,
clearPendingContainerUpdates,
addPendingContainerUpdate,
removePendingContainerUpdate
removePendingContainerUpdate,
getPendingContainerUpdates
} from '../../db';
import { checkNewerVersion } from '../../semver/check';
import type { NewerVersion } from '../../semver/find-newer';
import {
listContainers,
inspectContainer,
@@ -107,6 +110,32 @@ export async function runEnvUpdateCheckJob(
await log(`Starting update check for environment: ${env.name}`);
await log(`Auto-update mode: ${config.autoUpdate ? 'ON' : 'OFF'}`);
// Semver "newer version tag" detection rides this same pass when enabled.
const semverEnabled = !!config.checkPinnedVersions;
const semverOptions = {
maxBump: config.semverMaxBump ?? 'major',
matchFlavor: config.semverMatchFlavor ?? true,
includePrerelease: config.semverIncludePrerelease ?? false
} as const;
// Remember the target we last surfaced per container, so we only notify when
// a NEW newer version appears - not on every scheduled run. Read before clear.
const previousSemverTargets = new Map<string, string>();
if (semverEnabled) {
try {
for (const row of await getPendingContainerUpdates(environmentId)) {
if (row.newerVersion) {
try {
const nv = JSON.parse(row.newerVersion) as NewerVersion;
previousSemverTargets.set(row.containerId, nv.tag);
} catch { /* ignore malformed */ }
}
}
} catch { /* non-fatal */ }
}
// Collected here so a single notification can summarise all newly-found versions.
const newSemverFindings: { containerName: string; imageName: string; newerVersion: NewerVersion }[] = [];
const semverByContainer = new Map<string, NewerVersion>();
// Clear pending updates at the start - we'll re-add as we discover updates
await clearPendingContainerUpdates(environmentId);
@@ -182,20 +211,66 @@ export async function runEnvUpdateCheckJob(
newDigest: result.registryDigest,
oldImageConfig
});
// Add to pending table immediately - will be removed on successful update
await addPendingContainerUpdate(environmentId, container.id, container.name, imageName);
await log(` UPDATE AVAILABLE`);
await log(` Current: ${result.currentDigest?.substring(0, 24) || 'unknown'}...`);
await log(` New: ${result.registryDigest?.substring(0, 24) || 'unknown'}...`);
} else {
await log(` Up to date`);
}
// Newer-version-tag (semver) detection - independent of the digest check.
// Skips floating tags without a registry call. Never throws.
if (semverEnabled) {
const newer = await checkNewerVersion(imageName, semverOptions).catch(() => null);
if (newer) {
semverByContainer.set(container.id, newer);
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
if (previousSemverTargets.get(container.id) !== newer.tag) {
newSemverFindings.push({ containerName: container.name, imageName, newerVersion: newer });
}
}
}
} catch (err: any) {
await log(` [${container.name}] Error: ${err.message}`);
errorCount++;
}
}
// Persist pending rows once per container, merging the digest update and the
// semver suggestion so a pure-semver container still gets a (badge) row.
const pendingContainerIds = new Set<string>([
...updatesAvailable.map((u) => u.containerId),
...semverByContainer.keys()
]);
for (const cid of pendingContainerIds) {
const digest = updatesAvailable.find((u) => u.containerId === cid);
const semver = semverByContainer.get(cid) ?? null;
const container = containers.find((c) => c.id === cid);
await addPendingContainerUpdate(
environmentId,
cid,
digest?.containerName ?? container?.name ?? cid,
digest?.imageName ?? container?.image ?? '',
{ hasImageUpdate: !!digest, newerVersion: semver }
);
}
// Notify about NEW newer-version tags (advisory, never auto-applied). Fires
// independently of digest updates, and only for versions not surfaced last
// run - so a daily cron won't re-notify the same suggestion.
if (newSemverFindings.length > 0) {
const lines = newSemverFindings
.map((f) => `- ${f.containerName} (${f.imageName}): newer version ${f.newerVersion.tag} (${f.newerVersion.bump})`)
.join('\n');
await log('');
await log(`Newer version tags: ${newSemverFindings.length} new`);
await sendEventNotification('newer_version_available', {
title: `Newer version tag${newSemverFindings.length !== 1 ? 's' : ''} available on ${env.name}`,
message: `${newSemverFindings.length} container${newSemverFindings.length !== 1 ? 's have' : ' has'} a newer version tag published (advisory - not auto-applied):\n${lines}`,
type: 'info'
}, environmentId);
}
// Summary
await log('');
await log('=== SUMMARY ===');
@@ -205,8 +280,9 @@ export async function runEnvUpdateCheckJob(
await log(`Errors: ${errorCount}`);
if (updatesAvailable.length === 0) {
await log('All containers are up to date');
// Pending updates already cleared at start, nothing to add
await log('No digest updates');
// Any semver (newer-version) rows were already persisted + notified above;
// there's just no digest work to do, so skip the auto-update path.
await updateScheduleExecution(execution.id, {
status: 'success',
completedAt: new Date().toISOString(),
+27
View File
@@ -0,0 +1,27 @@
/**
* Decide whether a running image has a newer VERSION tag available. Glues the
* registry tag list (I/O) to the pure version comparison, and short-circuits for
* floating tags so `latest`/`stable`/sha never trigger a registry fetch.
*/
import { parseImageReference } from '../registry/image-ref';
import { parseTag } from './tag-parser';
import { listVersionTags } from './tag-source';
import { findNewerVersionTag, type FindNewerOptions, type NewerVersion } from './find-newer';
/**
* Returns the newer-version suggestion for `imageRef`, or null when the current
* tag isn't a version (floating) or nothing newer is published. Never throws —
* a registry failure yields null, so the update check degrades gracefully.
*/
export async function checkNewerVersion(
imageRef: string,
options: FindNewerOptions = {}
): Promise<NewerVersion | null> {
const { tag } = parseImageReference(imageRef);
// Floating tag -> nothing to compare, and we skip the registry call entirely.
if (!parseTag(tag)) return null;
const tags = await listVersionTags(imageRef);
return findNewerVersionTag(tag, tags, options);
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Given the tag a container currently runs and every tag the registry offers,
* decide whether a newer VERSION tag exists — and which versions were skipped
* along the way. Pure: no network, no registry knowledge, fully unit-testable.
*
* This is advisory only. We never auto-apply it: the tag lives in the user's
* compose, which we don't own. We just tell them "a newer version is out".
*/
import {
parseTag,
prefixMatches,
flavorMatches,
compareParts,
isPrerelease,
type ParsedTag
} from './tag-parser';
export type VersionBump = 'major' | 'minor' | 'patch';
export interface FindNewerOptions {
/** Cap the offered bump: `patch` stays quiet, `major` offers everything. Default `major`. */
maxBump?: VersionBump;
/** Consider `-rc`/`-beta`/... tags even when the current tag is stable. Default false. */
includePrerelease?: boolean;
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
matchFlavor?: boolean;
}
export interface NewerVersion {
/** The recommended tag to move to, e.g. `16.4-alpine`. */
tag: string;
/** How big the jump is, relative to the current version. */
bump: VersionBump;
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
skipped: string[];
}
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
export function classifyBump(current: ParsedTag, candidate: ParsedTag): VersionBump {
if ((candidate.parts[0] ?? 0) !== (current.parts[0] ?? 0)) return 'major';
if ((candidate.parts[1] ?? 0) !== (current.parts[1] ?? 0)) return 'minor';
return 'patch';
}
const BUMP_RANK: Record<VersionBump, number> = { patch: 1, minor: 2, major: 3 };
/** Canonical key for a numeric version: trailing zero segments trimmed, so `1.2`
* and `1.2.0` (equal under compareParts) map to the same key. Keeps at least one
* segment (`0.0.0` -> `0`). */
function versionKey(parts: number[]): string {
let end = parts.length;
while (end > 1 && parts[end - 1] === 0) end--;
return parts.slice(0, end).join('.');
}
/**
* Find the newest version tag strictly greater than `currentTag`, or null.
*
* Returns null when the current tag isn't a version at all (`latest`, `stable`,
* a sha) — there is nothing to compare, so no suggestion is ever made.
*/
export function findNewerVersionTag(
currentTag: string,
allTags: string[],
options: FindNewerOptions = {}
): NewerVersion | null {
const current = parseTag(currentTag);
if (!current) return null; // floating tag — not a version, never suggest.
const { maxBump = 'major', includePrerelease = false, matchFlavor = true } = options;
const maxRank = BUMP_RANK[maxBump];
const currentIsPrerelease = isPrerelease(current);
const candidates = allTags
.map((tag) => ({ tag, parsed: parseTag(tag) }))
.filter((c): c is { tag: string; parsed: ParsedTag } => c.parsed !== null)
.filter((c) => prefixMatches(c.parsed.prefix, current.prefix))
.filter((c) => !matchFlavor || flavorMatches(c.parsed, current))
.filter((c) => includePrerelease || currentIsPrerelease || !isPrerelease(c.parsed))
.filter((c) => compareParts(c.parsed, current) > 0)
.filter((c) => BUMP_RANK[classifyBump(current, c.parsed)] <= maxRank);
if (candidates.length === 0) return null;
// A registry commonly publishes the same version both with and without the
// `v` prefix (`v3.6` and `3.6`). They are the SAME version, so collapse them
// to one entry, keeping the variant whose prefix matches the running tag (so
// `v3.0` suggests `v3.6`, not `3.6`). Keyed by the numeric tuple.
const byVersion = new Map<string, { tag: string; parsed: ParsedTag }>();
for (const c of candidates) {
// Key on the numeric value with trailing zero segments trimmed, so `1.2` and
// `1.2.0` (equal under compareParts) collapse to one entry instead of both
// landing in `skipped` and racing for `target`.
const key = versionKey(c.parsed.parts);
const existing = byVersion.get(key);
if (!existing) {
byVersion.set(key, c);
continue;
}
// Prefer the variant that shares the current tag's exact prefix.
const cMatches = c.parsed.prefix === current.prefix;
const existingMatches = existing.parsed.prefix === current.prefix;
if (cMatches && !existingMatches) byVersion.set(key, c);
}
const deduped = Array.from(byVersion.values());
// Highest version wins; sort ascending so `skipped` reads oldest -> newest.
deduped.sort((a, b) => compareParts(a.parsed, b.parsed));
const target = deduped[deduped.length - 1];
return {
tag: target.tag,
bump: classifyBump(current, target.parsed),
skipped: deduped.map((c) => c.tag)
};
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Fetch per-version release notes from GitHub for the versions a semver check
* surfaced. The match core (release-tag <-> wanted-version) is pure and unit-
* tested; only the fetch touches the network. Degrades to an empty list on any
* failure, so the modal always renders (it falls back to a plain link).
*/
import { parseTag, compareParts } from './tag-parser';
import type { ReleaseSource } from './release-source';
export interface ReleaseNote {
/** The requested version tag this note is for, e.g. `16.4-alpine`. */
version: string;
/** The GitHub release name/title (may be empty). */
name: string;
/** The GitHub release tag, e.g. `v16.4`. */
githubTag: string;
/** Markdown body of the release. */
body: string;
/** ISO date the release was published. */
publishedAt: string | null;
/** Direct link to the release page. */
url: string;
}
interface GithubRelease {
tag_name: string;
name: string | null;
body: string | null;
published_at: string | null;
html_url: string;
draft: boolean;
prerelease: boolean;
}
/**
* Pick, for each wanted version tag, the GitHub release whose tag parses to the
* SAME numeric version. Pure: `releases` is whatever the API returned, `wanted`
* is the list of image tags (target + skipped). Flavor/prefix noise is ignored —
* only the numeric tuple has to match, so `v16.4` matches `16.4-alpine`.
*/
export function matchReleasesToVersions(
releases: Pick<GithubRelease, 'tag_name' | 'name' | 'body' | 'published_at' | 'html_url'>[],
wanted: string[]
): ReleaseNote[] {
const notes: ReleaseNote[] = [];
for (const version of wanted) {
const parsedWanted = parseTag(version);
if (!parsedWanted) continue;
const match = releases.find((r) => {
const parsedRelease = parseTag(r.tag_name);
return parsedRelease && compareParts(parsedRelease, parsedWanted) === 0;
});
if (match) {
notes.push({
version,
name: match.name ?? '',
githubTag: match.tag_name,
body: match.body ?? '',
publishedAt: match.published_at,
url: match.html_url
});
}
}
return notes;
}
const PER_PAGE = 100;
const MAX_PAGES = 3; // 300 recent releases is plenty to cover a handful of skipped versions.
/**
* Fetch release notes for `wanted` versions from a GitHub or Gitea/Forgejo forge.
* Both APIs return the same fields and page newest-first; only the page-size
* query param differs (`per_page` vs `limit`). Walks pages until every wanted
* version is matched or the page budget is spent. Never throws.
*/
export async function fetchReleaseNotes(
src: ReleaseSource,
wanted: string[],
fetchImpl: typeof fetch = fetch
): Promise<ReleaseNote[]> {
if (wanted.length === 0) return [];
const pageSizeParam = src.kind === 'github' ? 'per_page' : 'limit';
const collected: GithubRelease[] = [];
try {
for (let page = 1; page <= MAX_PAGES; page++) {
const url = `${src.apiBase}?${pageSizeParam}=${PER_PAGE}&page=${page}`;
const res = await fetchImpl(url, {
headers: { Accept: 'application/json', 'User-Agent': 'dockhand' }
});
if (!res.ok) break;
const batch = (await res.json()) as GithubRelease[];
if (!Array.isArray(batch) || batch.length === 0) break;
collected.push(...batch.filter((r) => !r.draft));
// Stop early once we can match everything we're after.
const matched = matchReleasesToVersions(collected, wanted);
if (matched.length >= wanted.length) break;
if (batch.length < PER_PAGE) break;
}
} catch {
return matchReleasesToVersions(collected, wanted);
}
return matchReleasesToVersions(collected, wanted);
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Resolve WHERE to fetch a container image's release notes from. Two forge
* families share one release-notes shape and are handled here:
*
* - GitHub -> https://api.github.com/repos/<slug>/releases
* - Gitea / Forgejo -> https://<host>/api/v1/repos/<slug>/releases
* (Codeberg is a Forgejo instance; self-hosted Gitea/Forgejo work too.)
*
* Both return the same fields (`tag_name`, `name`, `body`, `published_at`,
* `html_url`), so the fetch + version-match logic is identical - only the base
* URL differs. Priority mirrors resolveChangelogUrl: the
* `org.opencontainers.image.source` label wins, then a `ghcr.io/<owner>/<repo>`
* image name (always GitHub).
*
* Pure, no I/O, unit-tested. Images with no recognizable source resolve to null
* (we never guess a repo - a wrong repo is worse than no notes).
*/
const GHCR_PREFIX = 'ghcr.io/';
export type ForgeKind = 'github' | 'gitea';
export interface ReleaseSource {
kind: ForgeKind;
/** `owner/repo`. */
slug: string;
/** Fully-formed releases API base, ready for `?per_page`/`?limit` + `&page`. */
apiBase: string;
/** Human-facing releases page (for the "View releases" fallback link). */
releasesUrl: string;
}
/** Pull `owner/repo` out of a forge URL, tolerating trailing slash / `.git`. */
function slugFromUrl(url: string, host: string): string | null {
const idx = url.indexOf(host);
if (idx === -1) return null;
const after = url
.slice(idx + host.length)
.replace(/^\/+/, '')
.replace(/\/+$/, '')
.replace(/\.git$/, '');
const parts = after.split('/');
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
return `${parts[0]}/${parts[1]}`;
}
function hostOf(url: string): string | null {
try {
return new URL(url).host;
} catch {
return null;
}
}
function stripImageTag(image: string): string {
const atIdx = image.indexOf('@');
const withoutDigest = atIdx >= 0 ? image.slice(0, atIdx) : image;
const colonIdx = withoutDigest.lastIndexOf(':');
if (colonIdx > withoutDigest.lastIndexOf('/')) {
return withoutDigest.slice(0, colonIdx);
}
return withoutDigest;
}
export function resolveReleaseSource(
imageName: string | null | undefined,
labels?: Record<string, string> | null
): ReleaseSource | null {
const source = labels?.['org.opencontainers.image.source'];
if (source) {
const host = hostOf(source);
if (host === 'github.com') {
const slug = slugFromUrl(source, 'github.com');
if (slug) return githubSource(slug);
} else if (host) {
// Any other host is treated as a Gitea/Forgejo instance. Its releases
// API is host-relative, so the notes come from the SAME host the image
// declares (codeberg.org, gitea.com, a self-hosted forge, ...).
const slug = slugFromUrl(source, host);
if (slug) {
return {
kind: 'gitea',
slug,
apiBase: `https://${host}/api/v1/repos/${slug}/releases`,
releasesUrl: `https://${host}/${slug}/releases`
};
}
}
}
if (imageName && imageName.startsWith(GHCR_PREFIX)) {
const repo = stripImageTag(imageName.slice(GHCR_PREFIX.length));
const parts = repo.split('/');
if (parts.length >= 2 && parts[0] && parts[1]) {
return githubSource(`${parts[0]}/${parts[1]}`);
}
}
return null;
}
function githubSource(slug: string): ReleaseSource {
return {
kind: 'github',
slug,
apiBase: `https://api.github.com/repos/${slug}/releases`,
releasesUrl: `https://github.com/${slug}/releases`
};
}
@@ -0,0 +1,42 @@
/**
* List tags from Docker Hub via its own tags API (not the Registry v2
* `/tags/list`), ordered newest-first, so recent version tags land on the first
* page or two.
*
* No server-side `name=` filter: Hub's `name` param is a SUBSTRING match, so
* narrowing by the current major digit (e.g. `1` for `1.25`) would exclude a
* clean newer major like `2.0` (no `1` in the name) and silently hide the single
* most important upgrade. We page newest-first instead and let the pure
* comparator pick the target.
*/
/** Newest-first pages; a few pages cover recent version tags across major lines. */
const MAX_PAGES = 3;
const PAGE_SIZE = 100;
interface HubTag {
name: string;
}
interface HubTagsResponse {
results?: HubTag[];
next?: string | null;
}
/**
* @param repo Full Hub repo path, e.g. `library/nginx` or `grafana/grafana`.
*/
export async function fetchDockerHubTags(repo: string): Promise<string[]> {
const tags: string[] = [];
for (let page = 1; page <= MAX_PAGES; page++) {
const url = `https://hub.docker.com/v2/repositories/${repo}/tags?page_size=${PAGE_SIZE}&page=${page}&ordering=last_updated`;
const response = await fetch(url, { headers: { Accept: 'application/json' } });
if (!response.ok) break;
const data = (await response.json()) as HubTagsResponse;
tags.push(...(data.results ?? []).map((t) => t.name));
if (!data.next) break;
}
return tags;
}
@@ -0,0 +1,47 @@
/**
* List tags from any Registry HTTP API v2 host (Codeberg, GitLab, Harbor,
* self-hosted, ...) via `/v2/<repo>/tags/list`, following the `Link: rel="next"`
* header across pages so version tags aren't lost behind a first page full of
* git-hash tags. Auth uses our shared WWW-Authenticate token flow, which also
* works anonymously for public repos.
*/
import { getRegistryAuthHeader } from '../../docker';
/** Stop after this many pages — a guard against pathological repos, not a real limit. */
const MAX_PAGES = 50;
const PAGE_SIZE = 1000;
interface TagsListResponse {
tags?: string[];
}
/** Parse `</v2/repo/tags/list?last=x&n=1000>; rel="next"` into the next path. */
function nextPagePath(linkHeader: string | null): string | null {
const match = linkHeader?.match(/<([^>]+)>;\s*rel="next"/);
return match ? match[1] : null;
}
export async function fetchGenericV2Tags(
registry: string,
repo: string,
credentials?: { username: string; password: string } | null
): Promise<string[]> {
const base = `https://${registry}`;
const authHeader = await getRegistryAuthHeader(base, `repository:${repo}:pull`, credentials ?? undefined);
const headers: Record<string, string> = { Accept: 'application/json', 'User-Agent': 'Dockhand/1.0' };
if (authHeader) headers['Authorization'] = authHeader;
const tags: string[] = [];
let path: string | null = `/v2/${repo}/tags/list?n=${PAGE_SIZE}`;
for (let page = 0; page < MAX_PAGES && path; page++) {
const response = await fetch(`${base}${path}`, { headers });
if (!response.ok) break; // 4xx/5xx: stop with whatever we have.
const data = (await response.json()) as TagsListResponse;
tags.push(...(data.tags ?? []));
path = nextPagePath(response.headers.get('link'));
}
return tags;
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Parse a Docker image tag into its numeric version and the text around it, so
* two tags of the SAME image can be compared as versions.
*
* A tag is `<prefix><version><suffix>` where version is the first dotted-number
* run: `v1.25-alpine` -> prefix `v`, version `1.25`, suffix `-alpine`. The
* version becomes a plain numeric tuple (`[1, 25]`), which compares CalVer
* (`2024.1.3`) and SemVer (`1.2.3`) alike with no semver library and no special
* cases. Pure functions, no I/O.
*/
/** The first dotted-number run in a tag: 2 to 4 numeric segments. */
const VERSION_RE = /\d+\.\d+(?:\.\d+)?(?:\.\d+)?/;
export interface ParsedTag {
/** The matched version text, e.g. `1.25` or `2024.1.3`. */
version: string;
/** Text before the version, e.g. `v` in `v1.25`. */
prefix: string;
/** Text after the version, e.g. `-alpine` in `1.25-alpine`. This is the "flavor". */
suffix: string;
/** The version as a numeric tuple, e.g. `[1, 25]`. */
parts: number[];
}
/** Parse a tag into its version parts, or null when it has no version (`latest`, `stable`, a sha). */
export function parseTag(tag: string): ParsedTag | null {
const match = VERSION_RE.exec(tag);
if (!match) return null;
const version = match[0];
return {
version,
prefix: tag.slice(0, match.index),
suffix: tag.slice(match.index + version.length),
parts: version.split('.').map(Number)
};
}
/**
* Two prefixes are equivalent, treating `""` and `"v"` as the same — projects
* routinely add or drop the leading `v` between releases (`1.2.3` vs `v1.2.3`).
*/
export function prefixMatches(a: string, b: string): boolean {
const normalize = (p: string) => (p === 'v' ? '' : p);
return normalize(a) === normalize(b);
}
// A channel marker delimited by start/`-`/`.`, optionally followed by a number
// (`rc1`, `beta.2`) or another delimiter. `\b` won't do: `rc1` has no boundary
// between `c` and `1`.
const PRERELEASE_RE = /(^|[-.])(rc|beta|alpha|nightly|dev|pre|snapshot)(\d|[-.]|$)/i;
// A trailing commit hash, e.g. `-b2da6b90f` in searxng's `2026.8.16-b2da6b90f`.
// Requiring 7+ pure-hex chars keeps real build variants (`-alpine`, `-ls123`,
// `-ubuntu22.04`) intact - they aren't long hex runs - while collapsing the
// per-release hash so every hashed tag of one image shares a flavor and can be
// compared. Guarded by at least one letter so a plain numeric build (`-12345678`)
// isn't mistaken for a hash.
const COMMIT_HASH_RE = /[-.](?=[0-9a-f]*[a-f])[0-9a-f]{7,}$/i;
export function isPrerelease(tag: ParsedTag): boolean {
return PRERELEASE_RE.test(tag.suffix);
}
/**
* The "flavor" is the stable part of the suffix — the build variant like
* `-alpine` or `-ls123` — with any prerelease channel and any trailing commit
* hash stripped off. So `1.0-alpine` and `1.1-alpine` share the flavor `-alpine`,
* `1.0-rc1` / `1.0-rc2` share the flavor `` (they only differ in the prerelease),
* and `2026.8.15-a1b2c3d` / `2026.8.16-b2da6b9` share the flavor `` (they only
* differ in the per-release hash). This lets one hashed tag suggest the next
* while still never mixing `-alpine` with bare.
*/
export function flavorOf(tag: ParsedTag): string {
return tag.suffix
.replace(COMMIT_HASH_RE, '')
.replace(PRERELEASE_RE, (_m, delim: string) => (delim === '.' ? '' : delim))
.replace(/[-.]+$/, ''); // drop the delimiter the prerelease left dangling
}
/** A newer version is only offered within the SAME flavor. Exact match, prerelease-agnostic. */
export function flavorMatches(a: ParsedTag, b: ParsedTag): boolean {
return flavorOf(a) === flavorOf(b);
}
/** Compare two version tuples pairwise (missing segments count as 0). <0, 0, or >0. */
export function compareParts(a: ParsedTag, b: ParsedTag): number {
const length = Math.max(a.parts.length, b.parts.length);
for (let i = 0; i < length; i++) {
const diff = (a.parts[i] ?? 0) - (b.parts[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* List every version-looking tag an image's registry publishes, so the pure
* `findNewerVersionTag` can pick the newest. Routes to the right fetch strategy
* by host (Docker Hub API vs generic Registry v2), and caches per repo for 6h —
* the set of published tags changes slowly and this keeps registries un-hammered.
*
* GHCR note: ghcr.io works through the generic v2 path here. GitHub's Packages
* API returns versions newest-first (nicer for repos buried under git-hash tags),
* but it needs a token we don't yet store — a phase-2 optimization, not a gap.
*/
import { parseImageReference, isDockerHub } from '../registry/image-ref';
import { findRegistryCredentials } from '../docker';
import { fetchDockerHubTags } from './strategies/docker-hub';
import { fetchGenericV2Tags } from './strategies/generic-v2';
const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
interface CacheEntry {
tags: string[];
expiresAt: number;
}
// Keyed by `registry/repo`. Registry credentials are stored per-HOST (not per
// environment) in Dockhand's registries table, so the tag list for a given
// registry/repo is the same regardless of which env asked - the key needs no env
// dimension. A generic V2 registry that needs auth uses the stored credentials
// for that host (same lookup the digest check uses); an unauthenticated repo just
// works anonymously.
const cache = new Map<string, CacheEntry>();
// In-flight fetches keyed the same way, so a burst of same-image containers in
// one check pass (CONCURRENCY=20) collapses to ONE registry request per repo
// instead of one per container.
const inflight = new Map<string, Promise<string[]>>();
/**
* All tags published for the image behind `imageRef`, cached per repo. Returns
* `[]` on any registry failure — the caller treats "no tags" as "nothing newer".
*/
export async function listVersionTags(imageRef: string): Promise<string[]> {
const { registry, repo } = parseImageReference(imageRef);
const cacheKey = `${registry}/${repo}`;
const cached = cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) return cached.tags;
const pending = inflight.get(cacheKey);
if (pending) return pending;
const promise = (async () => {
let tags: string[] = [];
try {
if (isDockerHub(registry)) {
tags = await fetchDockerHubTags(repo);
} else {
const credentials = await findRegistryCredentials(registry).catch(() => null);
tags = await fetchGenericV2Tags(registry, repo, credentials);
}
} catch {
tags = [];
}
cache.set(cacheKey, { tags, expiresAt: Date.now() + CACHE_TTL_MS });
return tags;
})();
inflight.set(cacheKey, promise);
try {
return await promise;
} finally {
inflight.delete(cacheKey);
}
}
/** Test-only: clear the per-repo tag cache. */
export function __resetTagSourceCacheForTests(): void {
cache.clear();
inflight.clear();
}
+21 -4
View File
@@ -1845,13 +1845,27 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
const containers = await listContainers(true, envId);
const stacks = new Map<string, Set<string>>();
// Container IDs with pending image updates (populated by manual/scheduled update checks).
// Used to flag stacks that contain at least one outdated container.
// Container IDs with a pending DIGEST image update (the classic amber icon).
// A persisted row can also be a pure newer-version-tag (semver) suggestion with
// no digest update - those must NOT count as an "update available", so filter
// on hasImageUpdate. `newerVersionIds` drives the separate semver stack badge.
const pendingUpdateIds = new Set<string>();
const newerVersionIds = new Set<string>();
const newerVersionById = new Map<string, unknown>();
if (typeof envId === 'number') {
try {
const pending = await getPendingContainerUpdates(envId);
pending.forEach((p) => pendingUpdateIds.add(p.containerId));
pending.forEach((p) => {
if (p.hasImageUpdate) pendingUpdateIds.add(p.containerId);
if (p.newerVersion) {
newerVersionIds.add(p.containerId);
try {
newerVersionById.set(p.containerId, JSON.parse(p.newerVersion));
} catch {
// malformed row - skip the badge for this one
}
}
});
} catch {
// Non-fatal: stacks just won't show update markers
}
@@ -1914,7 +1928,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
exitCode: c.exitCode,
created: c.created,
labels: c.labels || {},
updateAvailable: pendingUpdateIds.has(c.id)
updateAvailable: pendingUpdateIds.has(c.id),
newerVersion: newerVersionById.get(c.id) ?? null
};
})
.sort((a, b) => {
@@ -1930,6 +1945,8 @@ export async function listComposeStacks(envId?: number | null): Promise<ComposeS
containerDetails,
updatesAvailable: stackContainers.some((c) => pendingUpdateIds.has(c.id)),
updateCount: stackContainers.filter((c) => pendingUpdateIds.has(c.id)).length,
// Newer-version-tag (semver) suggestions in this stack - drives the Tag badge.
newerVersionCount: stackContainers.filter((c) => newerVersionIds.has(c.id)).length,
status:
activeTotal === 0
? 'stopped'