detect newer version tags for pinned images and show them as a badge with release notes
This commit is contained in:
12 files changed
+456
-45
No files matched your search
@@ -24,7 +24,8 @@ import { resolveNanoCpusConflict, resolvePodmanUsernsMode } from './hostconfig-r
|
||||
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, fetchRegistryToken } from './registry-auth';
|
||||
import { encodeRegistryAuth, fetchRegistryToken, isSafeRegistryHost } from './registry-auth';
|
||||
import { classifyManifest, type ArtifactKind } from './semver/manifest-artifact';
|
||||
import { isSystemContainer, classifyEmptyDigestImage, localDigestIsIndexChild } from './scheduler/tasks/update-utils';
|
||||
import { deepDiff } from '../utils/diff.js';
|
||||
import { getInstanceId } from './backups/identity';
|
||||
@@ -3129,15 +3130,22 @@ export async function findRegistryCredentials(registryHost: string): Promise<{ u
|
||||
*/
|
||||
async function getRegistryBearerToken(registry: string, repo: string): Promise<string | null> {
|
||||
try {
|
||||
const hostSafety = isSafeRegistryHost(registry);
|
||||
if (!hostSafety.ok) {
|
||||
console.error(`[Registry] Refusing token request for ${registry}: ${hostSafety.reason}`);
|
||||
return null;
|
||||
}
|
||||
const registryUrl = `https://${registry}`;
|
||||
|
||||
// Look up stored credentials for this registry
|
||||
const credentials = await findRegistryCredentials(registry);
|
||||
|
||||
// Step 1: Challenge request to /v2/
|
||||
// Do not follow redirects on the challenge; a 3xx is treated as a non-401 below.
|
||||
const challengeResponse = await fetch(`${registryUrl}/v2/`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' },
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// If 200, no auth needed
|
||||
@@ -3258,12 +3266,19 @@ export async function getRegistryAuthHeader(
|
||||
try {
|
||||
// Parse URL to extract host (V2 API is always at the host root)
|
||||
const parsed = parseRegistryUrl(registryUrl);
|
||||
const hostSafety = isSafeRegistryHost(parsed.host);
|
||||
if (!hostSafety.ok) {
|
||||
console.error(`[Registry] Refusing auth challenge for ${parsed.host}: ${hostSafety.reason}`);
|
||||
return null;
|
||||
}
|
||||
const apiBaseUrl = `${parsed.protocol}://${parsed.host}`;
|
||||
|
||||
// Step 1: Challenge request to /v2/ (always at registry root, not under org path)
|
||||
// Step 1: Challenge request to /v2/ (always at registry root, not under org path).
|
||||
// Do not follow redirects; a 3xx is treated as a non-401 below.
|
||||
const challengeResponse = await fetch(`${apiBaseUrl}/v2/`, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' }
|
||||
headers: { 'User-Agent': 'Dockhand/1.0' },
|
||||
redirect: 'manual'
|
||||
});
|
||||
|
||||
// If 200, no auth needed
|
||||
@@ -3638,6 +3653,7 @@ export async function harborSearchRepositories(
|
||||
export async function getRegistryManifestDigest(imageName: string): Promise<string | null> {
|
||||
try {
|
||||
const { registry, repo, tag } = parseImageReference(imageName);
|
||||
if (!isSafeRegistryHost(registry).ok) return null;
|
||||
const token = await getRegistryBearerToken(registry, repo);
|
||||
const manifestUrl = `https://${registry}/v2/${repo}/manifests/${tag}`;
|
||||
|
||||
@@ -3679,6 +3695,54 @@ export async function getRegistryManifestDigest(imageName: string): Promise<stri
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify what a `registry/repo:tag` actually is (image vs Helm chart vs other
|
||||
* OCI artifact) by GET-ing its manifest, and return its manifest digest from the
|
||||
* same response. Used by the semver check so a Helm chart tag isn't offered as a
|
||||
* newer version, and so the offered image tag can be shown/copied digest-pinned
|
||||
* without a second request. Returns kind:'image' on any failure (fail-open - never
|
||||
* hide a real update because a probe failed); digest is null when unavailable.
|
||||
* One request per call; the caller probes only the candidate tag.
|
||||
*/
|
||||
export async function getTagArtifactKind(
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string
|
||||
): Promise<{ kind: ArtifactKind; digest: string | null }> {
|
||||
try {
|
||||
if (!isSafeRegistryHost(registry).ok) return { kind: 'image', digest: null };
|
||||
const token = await getRegistryBearerToken(registry, repo);
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Dockhand/1.0',
|
||||
'Accept': [
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json'
|
||||
].join(', ')
|
||||
};
|
||||
if (token) headers['Authorization'] = token;
|
||||
|
||||
const res = await fetch(`https://${registry}/v2/${repo}/manifests/${tag}`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000)
|
||||
});
|
||||
if (!res.ok) {
|
||||
await drainResponse(res);
|
||||
return { kind: 'image', digest: null };
|
||||
}
|
||||
const digest = res.headers.get('Docker-Content-Digest');
|
||||
const topMediaType = res.headers.get('Content-Type');
|
||||
const body = (await res.json().catch(() => null)) as
|
||||
| { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] }
|
||||
| null;
|
||||
return { kind: classifyManifest(body, topMediaType), digest };
|
||||
} catch {
|
||||
return { kind: 'image', digest: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A multi-arch tag is a manifest list / OCI image index: the list has one digest,
|
||||
* and each per-architecture child manifest has its own. `docker pull` usually records
|
||||
@@ -3699,6 +3763,7 @@ async function localDigestMatchesRegistryChild(
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { registry, repo, tag } = parseImageReference(imageName);
|
||||
if (!isSafeRegistryHost(registry).ok) return false;
|
||||
const token = await getRegistryBearerToken(registry, repo);
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Dockhand/1.0',
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
removeTempImage,
|
||||
tagImage,
|
||||
inspectImage,
|
||||
getTagArtifactKind,
|
||||
} from '../../docker';
|
||||
import type { ImageEnvLabels } from '../../container-env-merge';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
@@ -227,7 +228,7 @@ export async function runEnvUpdateCheckJob(
|
||||
// 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);
|
||||
const newer = await checkNewerVersion(imageName, { ...semverOptions, versionPattern }, getTagArtifactKind).catch(() => null);
|
||||
if (newer) {
|
||||
semverByContainer.set(container.id, newer);
|
||||
await log(` NEWER VERSION: ${newer.tag} (${newer.bump})`);
|
||||
|
||||
@@ -6,18 +6,33 @@
|
||||
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';
|
||||
import { findNewerVersionTag, findNewerImageTag, type FindNewerOptions, type NewerVersion } from './find-newer';
|
||||
import type { ArtifactKind } from './manifest-artifact';
|
||||
|
||||
/** Probe a single tag's artifact kind + manifest digest. Injected so check.ts stays unit-testable. */
|
||||
export type TagKindProbe = (
|
||||
registry: string,
|
||||
repo: string,
|
||||
tag: string
|
||||
) => Promise<{ kind: ArtifactKind; digest: string | null }>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* When `probeTagKind` is supplied, the chosen candidate is verified to be a real
|
||||
* container image, not a Helm chart / other OCI artifact published into the same
|
||||
* repo (monorepos do this). A non-image target is dropped and the next-highest
|
||||
* version is tried. Only the candidate is probed. A probe failure is treated as
|
||||
* "is an image" (fail-open) so a registry hiccup never hides a real update.
|
||||
*/
|
||||
export async function checkNewerVersion(
|
||||
imageRef: string,
|
||||
options: FindNewerOptions = {}
|
||||
options: FindNewerOptions = {},
|
||||
probeTagKind?: TagKindProbe
|
||||
): Promise<NewerVersion | null> {
|
||||
const { tag } = parseImageReference(imageRef);
|
||||
const { registry, repo, tag } = parseImageReference(imageRef);
|
||||
|
||||
// Floating tag -> nothing to compare, and we skip the registry call entirely.
|
||||
// Parse with the same override find-newer will use, so a custom-scheme tag
|
||||
@@ -25,5 +40,20 @@ export async function checkNewerVersion(
|
||||
if (!parseTag(tag, options.versionPattern ?? null)) return null;
|
||||
|
||||
const tags = await listVersionTags(imageRef);
|
||||
return findNewerVersionTag(tag, tags, options);
|
||||
|
||||
if (!probeTagKind) return findNewerVersionTag(tag, tags, options);
|
||||
|
||||
return findNewerImageTag(
|
||||
tag,
|
||||
tags,
|
||||
async (candidate) => {
|
||||
try {
|
||||
const { kind, digest } = await probeTagKind(registry, repo, candidate);
|
||||
return { ok: kind === 'image', digest };
|
||||
} catch {
|
||||
return { ok: true }; // fail-open: never hide a real update on a probe error.
|
||||
}
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,8 @@ export interface NewerVersion {
|
||||
bump: VersionBump;
|
||||
/** Every newer version between current and target (exclusive of current, inclusive of target), ordered. */
|
||||
skipped: string[];
|
||||
/** The target tag's manifest digest (`sha256:...`), when probed. Lets the UI show/copy the new tag digest-pinned. */
|
||||
digest?: string;
|
||||
}
|
||||
|
||||
/** Which segment first differs decides the bump: [0]=major, [1]=minor, else patch. */
|
||||
@@ -122,3 +124,35 @@ export function findNewerVersionTag(
|
||||
skipped: deduped.map((c) => c.tag)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like findNewerVersionTag, but verifies the chosen target is a real container
|
||||
* image before offering it - dropping any tag that turns out to be a Helm chart or
|
||||
* other non-image OCI artifact published into the same repo, and re-picking the
|
||||
* next-highest version. `probe` returns `{ ok, digest }` where ok=true means a
|
||||
* runnable image; the digest (when present) is attached to the result so the UI can
|
||||
* offer the new tag digest-pinned. Async + injected so this stays pure/testable.
|
||||
*
|
||||
* Only the chosen candidate is probed, not every tag. `maxSkips` bounds the extra
|
||||
* probes so a repo full of artifacts can't fan out unbounded. `probe` failures
|
||||
* should resolve to `{ ok: true }` (fail-open) at the call site so a probe error
|
||||
* never hides a real update.
|
||||
*/
|
||||
export async function findNewerImageTag(
|
||||
currentTag: string,
|
||||
allTags: string[],
|
||||
probe: (tag: string) => Promise<{ ok: boolean; digest?: string | null }>,
|
||||
options: FindNewerOptions = {},
|
||||
maxSkips = 5
|
||||
): Promise<NewerVersion | null> {
|
||||
const excluded = new Set<string>();
|
||||
for (let i = 0; i <= maxSkips; i++) {
|
||||
const pool = excluded.size ? allTags.filter((t) => !excluded.has(t)) : allTags;
|
||||
const newer = findNewerVersionTag(currentTag, pool, options);
|
||||
if (!newer) return null;
|
||||
const { ok, digest } = await probe(newer.tag);
|
||||
if (ok) return digest ? { ...newer, digest } : newer;
|
||||
excluded.add(newer.tag);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Classify a registry manifest as a runnable container image vs a non-image OCI
|
||||
* artifact (Helm chart, SBOM, signature, ...). Monorepos increasingly publish
|
||||
* Helm charts into the SAME repo as their images (e.g. `maximhq/bifrost` ships
|
||||
* chart tag `2.1.29` alongside image tag `v1.6.11`), so the semver check must not
|
||||
* offer a chart tag as a "newer version" of an image.
|
||||
*
|
||||
* Pure: takes the already-parsed manifest JSON, no I/O. The fetch lives in
|
||||
* docker.ts; this just reads the media types.
|
||||
*/
|
||||
|
||||
export type ArtifactKind = 'image' | 'chart' | 'other';
|
||||
|
||||
/** config.mediaType values that mean "this is a real container image config". */
|
||||
const IMAGE_CONFIG_TYPES = new Set([
|
||||
'application/vnd.oci.image.config.v1+json',
|
||||
'application/vnd.docker.container.image.v1+json'
|
||||
]);
|
||||
|
||||
/** Helm's OCI chart config media type. */
|
||||
const HELM_CONFIG_TYPE = 'application/vnd.cncf.helm.config.v1+json';
|
||||
|
||||
/** Manifest LIST / image INDEX media types - a multi-arch image points here. */
|
||||
const INDEX_TYPES = new Set([
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json'
|
||||
]);
|
||||
|
||||
/** Single-image manifest media types. */
|
||||
const IMAGE_MANIFEST_TYPES = new Set([
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a manifest body (parsed JSON). `topMediaType` is the manifest's own
|
||||
* media type when known (from the response Content-Type or a `mediaType` field);
|
||||
* an image index has no `config`, so it is recognized by that type alone.
|
||||
*
|
||||
* - A manifest INDEX / manifest LIST is always a multi-arch IMAGE.
|
||||
* - Otherwise the `config.mediaType` decides: Helm config -> chart, image config
|
||||
* -> image, anything else -> other (SBOM, signature, unknown artifact).
|
||||
* - Unknown / missing info defaults to 'image' (fail-open: never hide a real
|
||||
* update just because a registry omitted a field).
|
||||
*/
|
||||
export function classifyManifest(
|
||||
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
|
||||
topMediaType?: string | null
|
||||
): ArtifactKind {
|
||||
if (!manifest) return 'image';
|
||||
|
||||
const top = manifest.mediaType || topMediaType || '';
|
||||
if (INDEX_TYPES.has(top) || Array.isArray(manifest.manifests)) return 'image';
|
||||
|
||||
const cfg = manifest.config?.mediaType;
|
||||
if (cfg === HELM_CONFIG_TYPE) return 'chart';
|
||||
if (cfg && IMAGE_CONFIG_TYPES.has(cfg)) return 'image';
|
||||
|
||||
// A recognized single-image manifest type with no/other config is still an image.
|
||||
if (IMAGE_MANIFEST_TYPES.has(top)) return 'image';
|
||||
|
||||
// A config media type we don't recognize, on a non-index manifest, is a
|
||||
// non-image artifact (chart variants, SBOM, cosign signature, ...).
|
||||
if (cfg) return 'other';
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
/** True only for a runnable container image (multi-arch index or single image). */
|
||||
export function isRunnableImage(
|
||||
manifest: { mediaType?: string; config?: { mediaType?: string }; manifests?: unknown[] } | null | undefined,
|
||||
topMediaType?: string | null
|
||||
): boolean {
|
||||
return classifyManifest(manifest, topMediaType) === 'image';
|
||||
}
|
||||
Reference in new issue
Block a user