detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
17 files changed
+529
-144
No files matched your search
@@ -9,11 +9,15 @@
|
||||
* - dockhand.port.<hostPort>.url=<url> — Override the click URL for a specific published port
|
||||
* - dockhand.order=<int> — Controls display order within a stack (lower = first, default 0)
|
||||
* - dockhand.adopt=false — Prevent this stack from being adopted (any container in the stack)
|
||||
* - dockhand.version.pattern=regex:... — Override how the newer-version check reads
|
||||
* this image's tags, for non-standard tag schemes (named groups major/minor/patch)
|
||||
*
|
||||
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
|
||||
* The opt-out model means labels override DB settings (label wins).
|
||||
*/
|
||||
|
||||
import { compileVersionPattern } from './semver/tag-parser';
|
||||
|
||||
/** Recognized Dockhand label keys */
|
||||
export const DOCKHAND_LABELS = {
|
||||
UPDATE: 'dockhand.update',
|
||||
@@ -22,6 +26,7 @@ export const DOCKHAND_LABELS = {
|
||||
URL: 'dockhand.url',
|
||||
ORDER: 'dockhand.order',
|
||||
ADOPT: 'dockhand.adopt',
|
||||
VERSION_PATTERN: 'dockhand.version.pattern',
|
||||
} as const;
|
||||
|
||||
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
|
||||
@@ -69,6 +74,17 @@ export function isHiddenByLabel(labels: Record<string, string> | undefined | nul
|
||||
return value === true; // explicitly hidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile this container's `dockhand.version.pattern` label into a RegExp for the
|
||||
* newer-version check's override, or null when absent/invalid. A bad pattern
|
||||
* degrades to null (default parser), so a typo never breaks the update check.
|
||||
*/
|
||||
export function getVersionPatternOverride(
|
||||
labels: Record<string, string> | undefined | null
|
||||
): RegExp | null {
|
||||
return compileVersionPattern(getLabel(labels, DOCKHAND_LABELS.VERSION_PATTERN));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications should be suppressed for this container.
|
||||
* Returns true if dockhand.notify is explicitly set to false/no/0.
|
||||
|
||||
+42
-6
@@ -4868,12 +4868,48 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global newer-version-tag (semver) detection config. Per-env settings decide
|
||||
* WHEN/whether to check on a schedule; this decides HOW versions are read, and
|
||||
* applies to every check - scheduled and manual alike.
|
||||
*/
|
||||
export interface GlobalSemverConfig {
|
||||
enabled: boolean;
|
||||
maxBump: 'patch' | 'minor' | 'major';
|
||||
matchFlavor: boolean;
|
||||
includePrerelease: boolean;
|
||||
}
|
||||
|
||||
const GLOBAL_SEMVER_KEY = 'global_semver_check';
|
||||
const DEFAULT_SEMVER_CONFIG: GlobalSemverConfig = {
|
||||
enabled: false,
|
||||
maxBump: 'major',
|
||||
matchFlavor: true,
|
||||
includePrerelease: false
|
||||
};
|
||||
|
||||
export async function getGlobalSemverConfig(): Promise<GlobalSemverConfig> {
|
||||
const result = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
|
||||
if (!result[0]) return { ...DEFAULT_SEMVER_CONFIG };
|
||||
try {
|
||||
return { ...DEFAULT_SEMVER_CONFIG, ...JSON.parse(result[0].value) };
|
||||
} catch {
|
||||
return { ...DEFAULT_SEMVER_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
export async function setGlobalSemverConfig(config: GlobalSemverConfig): Promise<void> {
|
||||
const value = JSON.stringify(config);
|
||||
const existing = await db.select().from(settings).where(eq(settings.key, GLOBAL_SEMVER_KEY));
|
||||
if (existing.length > 0) {
|
||||
await db.update(settings)
|
||||
.set({ value, updatedAt: new Date().toISOString() })
|
||||
.where(eq(settings.key, GLOBAL_SEMVER_KEY));
|
||||
} else {
|
||||
await db.insert(settings).values({ key: GLOBAL_SEMVER_KEY, value });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEnvUpdateCheckSettings(envId: number): Promise<EnvUpdateCheckSettings | null> {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { ScheduleTrigger, VulnerabilityCriteria } from '../../db';
|
||||
import {
|
||||
getEnvUpdateCheckSettings,
|
||||
getGlobalSemverConfig,
|
||||
getEnvironment,
|
||||
createScheduleExecution,
|
||||
updateScheduleExecution,
|
||||
@@ -37,7 +38,7 @@ import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
|
||||
import { resolveBlockDecision } from './block-decision';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
|
||||
import { isUpdateDisabledByLabel, isHiddenByLabel, getVersionPatternOverride } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -110,12 +111,14 @@ 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;
|
||||
// Semver "newer version tag" detection is a global setting - it rides this
|
||||
// same pass when enabled, and the manual check reads the same config.
|
||||
const semverConfig = await getGlobalSemverConfig();
|
||||
const semverEnabled = semverConfig.enabled;
|
||||
const semverOptions = {
|
||||
maxBump: config.semverMaxBump ?? 'major',
|
||||
matchFlavor: config.semverMatchFlavor ?? true,
|
||||
includePrerelease: config.semverIncludePrerelease ?? false
|
||||
maxBump: semverConfig.maxBump,
|
||||
matchFlavor: semverConfig.matchFlavor,
|
||||
includePrerelease: semverConfig.includePrerelease
|
||||
} 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.
|
||||
@@ -221,7 +224,10 @@ export async function runEnvUpdateCheckJob(
|
||||
// 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);
|
||||
// A `dockhand.version.pattern` label lets a container teach the check
|
||||
// how to read its own non-standard tags (CalVer+hash, etc.).
|
||||
const versionPattern = getVersionPatternOverride(inspectData.Config?.Labels);
|
||||
const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }).catch(() => null);
|
||||
if (newer) {
|
||||
semverByContainer.set(container.id, newer);
|
||||
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
|
||||
|
||||
@@ -20,7 +20,9 @@ export async function checkNewerVersion(
|
||||
const { tag } = parseImageReference(imageRef);
|
||||
|
||||
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
||||
if (!parseTag(tag)) return null;
|
||||
// Parse with the same override find-newer will use, so a custom-scheme tag
|
||||
// isn't wrongly dismissed as floating before the registry fetch.
|
||||
if (!parseTag(tag, options.versionPattern ?? null)) return null;
|
||||
|
||||
const tags = await listVersionTags(imageRef);
|
||||
return findNewerVersionTag(tag, tags, options);
|
||||
|
||||
@@ -25,6 +25,12 @@ export interface FindNewerOptions {
|
||||
includePrerelease?: boolean;
|
||||
/** Require the same flavor/suffix (`-alpine`). Default true — the #1 noise-killer. */
|
||||
matchFlavor?: boolean;
|
||||
/**
|
||||
* A compiled `dockhand.version.pattern` override (from compileVersionPattern).
|
||||
* When set, both the current tag and every candidate are parsed with it, so an
|
||||
* image with a non-standard tag scheme can still be compared. Absent = default.
|
||||
*/
|
||||
versionPattern?: RegExp | null;
|
||||
}
|
||||
|
||||
export interface NewerVersion {
|
||||
@@ -65,15 +71,16 @@ export function findNewerVersionTag(
|
||||
allTags: string[],
|
||||
options: FindNewerOptions = {}
|
||||
): NewerVersion | null {
|
||||
const current = parseTag(currentTag);
|
||||
const { maxBump = 'major', includePrerelease = false, matchFlavor = true, versionPattern = null } = options;
|
||||
|
||||
const current = parseTag(currentTag, versionPattern);
|
||||
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) }))
|
||||
.map((tag) => ({ tag, parsed: parseTag(tag, versionPattern) }))
|
||||
.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))
|
||||
|
||||
@@ -23,8 +23,18 @@ export interface ParsedTag {
|
||||
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 {
|
||||
/**
|
||||
* Parse a tag into its version parts, or null when it has no version (`latest`,
|
||||
* `stable`, a sha). An optional `override` regex (from a `dockhand.version.pattern`
|
||||
* label) replaces the default detection for images whose tags don't match the
|
||||
* generic dotted-number shape (e.g. a CalVer-plus-hash like `2024.12.5-a1b2c3d`).
|
||||
* The override must capture numeric named groups `major`/`minor`/`patch` (patch
|
||||
* optional, plus any further `p4`/`p5`); those become `parts`. A tag the override
|
||||
* doesn't match is treated as a non-version (null) - same as a floating tag.
|
||||
*/
|
||||
export function parseTag(tag: string, override?: RegExp | null): ParsedTag | null {
|
||||
if (override) return parseWithOverride(tag, override);
|
||||
|
||||
const match = VERSION_RE.exec(tag);
|
||||
if (!match) return null;
|
||||
|
||||
@@ -37,6 +47,36 @@ export function parseTag(tag: string): ParsedTag | null {
|
||||
};
|
||||
}
|
||||
|
||||
// Numeric named groups the override may capture, in version order. Only `major`
|
||||
// is required; the rest fill in as present.
|
||||
const OVERRIDE_PART_GROUPS = ['major', 'minor', 'patch', 'p4', 'p5'] as const;
|
||||
|
||||
function parseWithOverride(tag: string, override: RegExp): ParsedTag | null {
|
||||
const match = override.exec(tag);
|
||||
if (!match || !match.groups) return null;
|
||||
|
||||
const parts: number[] = [];
|
||||
for (const name of OVERRIDE_PART_GROUPS) {
|
||||
const raw = match.groups[name];
|
||||
if (raw === undefined) break; // stop at the first absent segment
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) return null; // a captured non-number is not a version
|
||||
parts.push(n);
|
||||
}
|
||||
if (parts.length === 0) return null; // no `major` captured -> not a version
|
||||
|
||||
// The whole match is the "version"; prefix/suffix are the text around it so
|
||||
// flavor matching (e.g. `-alpine`) keeps working under an override too.
|
||||
const version = match[0];
|
||||
const index = match.index;
|
||||
return {
|
||||
version,
|
||||
prefix: tag.slice(0, index),
|
||||
suffix: tag.slice(index + version.length),
|
||||
parts
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
@@ -93,3 +133,37 @@ export function compareParts(a: ParsedTag, b: ParsedTag): number {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The `dockhand.version.pattern` label value must start with this scheme, leaving
|
||||
// room for future non-regex strategies (e.g. `calver:`) under the same label.
|
||||
const VERSION_PATTERN_SCHEME = 'regex:';
|
||||
const MAX_PATTERN_LENGTH = 300;
|
||||
// Nested quantifiers (`(a+)+`, `(a*)*`, `(a+)*`) are the classic catastrophic-
|
||||
// backtracking shape. Refuse them rather than run an attacker-supplied ReDoS on
|
||||
// every tag. A conservative reject: a group closed with a quantifier immediately
|
||||
// followed by another quantifier.
|
||||
const NESTED_QUANTIFIER_RE = /[+*}]\s*\)[+*?]|\([^)]*[+*][^)]*\)[+*]/;
|
||||
|
||||
/**
|
||||
* Compile a `dockhand.version.pattern` label value into a RegExp for parseTag's
|
||||
* override, or null when absent/invalid. Never throws - a bad pattern degrades to
|
||||
* the default parser (null), so a typo can't break the update check. Requires the
|
||||
* `regex:` scheme, a `major` named group, a sane length, and rejects obvious
|
||||
* catastrophic-backtracking shapes (the value comes from an untrusted image label).
|
||||
*/
|
||||
export function compileVersionPattern(value: string | undefined | null): RegExp | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed.startsWith(VERSION_PATTERN_SCHEME)) return null;
|
||||
|
||||
const source = trimmed.slice(VERSION_PATTERN_SCHEME.length);
|
||||
if (source.length === 0 || source.length > MAX_PATTERN_LENGTH) return null;
|
||||
if (!source.includes('(?<major>')) return null; // must yield at least a major
|
||||
if (NESTED_QUANTIFIER_RE.test(source)) return null; // ReDoS guard
|
||||
|
||||
try {
|
||||
return new RegExp(source);
|
||||
} catch {
|
||||
return null; // invalid regex -> fall back to the default parser
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user