Fix image update checks against registries on Bare.
Release rolling / release (push) Has been cancelled

Registry HTTP now sets host/hostname, disables keep-alive, and times out cleanly; resolve container image tags/digests more reliably and surface check errors in the UI.
This commit is contained in:
Raven Scott
2026-07-15 15:31:46 -04:00
parent e9e78f52c9
commit 42f066fa47
3 changed files with 355 additions and 104 deletions
+35 -7
View File
@@ -9630,11 +9630,28 @@ function imageUpdateIndicatorHtml(status, meta = {}) {
}
function getImageUpdateMeta(container) {
const byC = imageUpdateByContainer.get(container.Id);
if (byC) return byC;
const img = container.Image || '';
const byI = imageUpdateByImage.get(img);
if (byI) return { ...byI, image: img };
const id = container?.Id || '';
if (id) {
const byC = imageUpdateByContainer.get(id);
if (byC) return byC;
// Match short ids / prefix (Docker sometimes shortens client-side copies)
if (id.length >= 12) {
for (const [cid, info] of imageUpdateByContainer) {
if (cid === id || cid.startsWith(id) || id.startsWith(cid)) return info;
}
}
}
const img = container?.Image || '';
if (img) {
const byI = imageUpdateByImage.get(img);
if (byI) return { ...byI, image: img };
// Resolved name may differ slightly (e.g. library/ prefix) — substring match last resort
for (const [name, info] of imageUpdateByImage) {
if (name === img || name.endsWith(`/${img}`) || img.endsWith(`/${name}`)) {
return { ...info, image: name };
}
}
}
return { status: 'unknown', image: img };
}
@@ -9723,13 +9740,24 @@ async function runImageUpdateCheck(opts = {}) {
const outdated = statuses.filter((s) => s.status === 'outdated').length;
const updated = statuses.filter((s) => s.status === 'updated').length;
const unknown = statuses.filter((s) => s.status === 'unknown' || s.status === 'skipped').length;
const withErr = statuses.filter((s) => s.error).slice(0, 2);
if (line) {
line.textContent =
let text =
outdated > 0
? `${outdated} image(s) have updates · ${updated} up to date · ${unknown} unknown/skipped`
: statuses.length
? `All checked images up to date (${updated}) · ${unknown} unknown/skipped`
? updated > 0 && unknown === 0
? `All checked images up to date (${updated})`
: `${updated} up to date · ${unknown} unknown/skipped`
: 'No images to check';
if (withErr.length) {
text += `${withErr.map((s) => s.error).join('; ')}`;
}
line.textContent = text;
line.title = statuses
.filter((s) => s.error)
.map((s) => `${s.image}: ${s.error}`)
.join('\n') || text;
}
} catch (err) {
if (line) line.textContent = err?.message || 'Update check failed';
+296 -96
View File
@@ -1,11 +1,15 @@
/**
* Image update detection (style).
* Image update detection.
*
* Compares the first local RepoDigest for an image:tag with the remote
* registry manifest digest. Differing digests → update available.
* Compares local image RepoDigest(s) to the remote registry manifest digest
* for the same image:tag. Differing digests → update available.
*
* Remote digests are fetched via Registry HTTP API V2 (no full pull).
* Results are cached to avoid hammering registries on every list refresh.
*
* Runtime notes:
* - bare-http1/bare-tcp use `host` (not `hostname`) for dialing; we set both.
* - Keep-alive is disabled for registry calls — bare-https can hang up mid-pipeline.
*/
import https from 'https'
import http from 'http'
@@ -54,7 +58,8 @@ const MANIFEST_ACCEPT = [
export function parseImageRef(raw) {
if (!raw || typeof raw !== 'string') return null
let s = raw.trim()
if (!s || s === '<none>' || s.startsWith('sha256:')) return null
if (!s || s === '<none>' || s === '<none>:<none>') return null
if (s.startsWith('sha256:')) return null
// Drop algorithm-only short ids (1264 hex)
if (/^[a-f0-9]{12,64}$/i.test(s)) return null
@@ -64,6 +69,7 @@ export function parseImageRef(raw) {
const at = s.lastIndexOf('@')
digest = s.slice(at + 1)
s = s.slice(0, at)
if (!s) return null
}
let tag = 'latest'
@@ -75,6 +81,8 @@ export function parseImageRef(raw) {
s = s.slice(0, lastColon)
}
if (!s) return null
const parts = s.split('/')
let registry = 'registry-1.docker.io'
let host = 'docker.io'
@@ -110,6 +118,8 @@ export function parseImageRef(raw) {
repository = s
}
if (!repository) return null
// Local-only names (no registry path that could resolve)
const isLocal =
host === 'localhost' ||
@@ -132,13 +142,41 @@ export function parseImageRef(raw) {
/**
* @param {string} digest
*/
function normalizeDigest(digest) {
export function normalizeDigest(digest) {
if (!digest) return null
const d = String(digest).trim()
if (!d) return null
return d.startsWith('sha256:') ? d : `sha256:${d}`
}
/**
* Extract sha256 digests from image inspect RepoDigests.
* @param {{ RepoDigests?: string[] }|null|undefined} inspect
* @returns {string[]}
*/
export function digestsFromInspect(inspect) {
const digests = []
for (const rd of inspect?.RepoDigests || []) {
const part = String(rd).includes('@') ? String(rd).split('@')[1] : String(rd)
const n = normalizeDigest(part)
if (n && !digests.includes(n)) digests.push(n)
}
return digests
}
/**
* Prefer a named tag from image inspect (skip <none>).
* @param {{ RepoTags?: string[] }|null|undefined} inspect
* @returns {string|null}
*/
export function preferredRepoTag(inspect) {
for (const t of inspect?.RepoTags || []) {
const s = String(t || '')
if (s && s !== '<none>:<none>' && !s.startsWith('sha256:')) return s
}
return null
}
/**
* @param {string} url
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number }} opts
@@ -155,36 +193,87 @@ function httpRequest(url, opts = {}) {
return
}
const lib = u.protocol === 'http:' ? http : https
const req = lib.request(
{
protocol: u.protocol,
hostname: u.hostname,
port: u.port || (u.protocol === 'http:' ? 80 : 443),
path: u.pathname + u.search,
method: opts.method || 'GET',
headers: opts.headers || {},
timeout: timeoutMs,
},
(res) => {
const port = u.port
? Number(u.port)
: u.protocol === 'http:'
? 80
: 443
// bare-http1 / bare-tcp dial via `host` (hostname alone is ignored → localhost).
// Set both for Node + Bare. Disable keep-alive (agent:false) — bare-https
// often drops recycled sockets (CONNECTION_LOST) between token + manifest calls.
const headers = {
Connection: 'close',
...(opts.headers || {}),
}
const reqOpts = {
protocol: u.protocol,
host: u.hostname,
hostname: u.hostname,
servername: u.hostname,
port,
path: u.pathname + u.search,
method: opts.method || 'GET',
headers,
agent: false,
timeout: timeoutMs,
}
let settled = false
const finish = (err, val) => {
if (settled) return
settled = true
clearTimeout(timer)
if (err) reject(err)
else resolve(val)
}
const timer = setTimeout(() => {
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
finish(new Error('Registry request timed out'))
}, timeoutMs)
let req
try {
req = lib.request(reqOpts, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const headers = {}
for (const [k, v] of Object.entries(res.headers)) {
headers[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? '')
const headersOut = {}
for (const [k, v] of Object.entries(res.headers || {})) {
headersOut[String(k).toLowerCase()] = Array.isArray(v)
? v.join(', ')
: String(v ?? '')
}
resolve({
finish(null, {
status: res.statusCode || 0,
headers,
headers: headersOut,
body: Buffer.concat(chunks).toString('utf8'),
})
})
}
)
res.on('error', (err) => finish(err))
})
} catch (err) {
finish(err)
return
}
req.on('timeout', () => {
req.destroy(new Error('Registry request timed out'))
try {
req.destroy(new Error('Registry request timed out'))
} catch {
// ignore
}
})
req.on('error', reject)
req.on('error', (err) => finish(err))
try {
if (typeof req.setTimeout === 'function') req.setTimeout(timeoutMs)
} catch {
// ignore
}
req.end()
})
}
@@ -206,6 +295,7 @@ function parseWwwAuthenticate(header) {
/**
* @param {object} parsed
* @param {{ username?: string, password?: string }|null} auth
* @param {string} [wwwAuthHeader]
*/
async function getBearerToken(parsed, auth, wwwAuthHeader) {
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
@@ -245,6 +335,17 @@ async function getBearerToken(parsed, auth, wwwAuthHeader) {
return json.token || json.access_token || null
}
/**
* Build registry base URL for a parsed image ref.
* @param {ReturnType<typeof parseImageRef>} parsed
*/
function registryBaseUrl(parsed) {
const registryHost = parsed.registry.includes('://')
? parsed.registry
: `https://${parsed.registry}`
return registryHost.startsWith('http') ? registryHost : `https://${registryHost}`
}
/**
* Fetch remote manifest digest(s) for image:tag.
* Returns the index/list digest plus any platform child digests (multi-arch).
@@ -259,14 +360,12 @@ export async function fetchRemoteDigest(parsed, auth = null) {
return { digest: d, related: [d] }
}
const registryHost = parsed.registry.includes('://')
? parsed.registry
: `https://${parsed.registry}`
// Prefer https; some local registries use http — try https first
const base = registryHost.startsWith('http') ? registryHost : `https://${registryHost}`
const base = registryBaseUrl(parsed)
// Keep slashes in repository path; only encode the tag/reference
const path = `/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`
const url = new URL(path, base.endsWith('/') ? base : base + '/').toString()
/** @type {Record<string, string>} */
const headers = {
Accept: MANIFEST_ACCEPT,
}
@@ -275,34 +374,42 @@ export async function fetchRemoteDigest(parsed, auth = null) {
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
let res = await httpRequest(url, { method: 'GET', headers })
if (res.status === 401 || res.status === 403) {
const token = await getBearerToken(parsed, auth, res.headers['www-authenticate'])
if (token) {
headers.Authorization = `Bearer ${token}`
res = await httpRequest(url, { method: 'GET', headers })
}
/**
* @param {string} method
* @param {Record<string, string>} hdrs
*/
async function once(method, hdrs) {
return httpRequest(url, { method, headers: hdrs })
}
// Some registries only allow HEAD for digest
/**
* Authenticate on 401/403 and retry once.
* @param {string} method
* @param {{ status: number, headers: Record<string,string>, body: string }} res
* @param {Record<string, string>} hdrs
*/
async function withAuth(method, res, hdrs) {
if (res.status !== 401 && res.status !== 403) return res
const token = await getBearerToken(parsed, auth, res.headers['www-authenticate'])
if (!token) return res
const next = { ...hdrs, Authorization: `Bearer ${token}` }
return once(method, next)
}
// Prefer GET (reliable digest header + body for multi-arch children).
// HEAD is used as a fallback when GET is rejected (405) or body lacks digests.
let res = await once('GET', headers)
res = await withAuth('GET', res, headers)
if (res.status === 405) {
let head = await httpRequest(url, { method: 'HEAD', headers })
if ((head.status === 401 || head.status === 403) && !headers.Authorization?.startsWith('Bearer')) {
const token = await getBearerToken(parsed, auth, head.headers['www-authenticate'])
if (token) {
headers.Authorization = `Bearer ${token}`
head = await httpRequest(url, { method: 'HEAD', headers })
}
}
let head = await once('HEAD', headers)
head = await withAuth('HEAD', head, headers)
if (head.status >= 200 && head.status < 300) {
const d =
head.headers['docker-content-digest'] ||
head.headers['oci-content-digest']
if (d) {
const n = normalizeDigest(d)
return { digest: n, related: [n] }
}
normalizeDigest(
head.headers['docker-content-digest'] || head.headers['oci-content-digest']
) || null
if (d) return { digest: d, related: [d] }
}
}
@@ -328,16 +435,20 @@ export async function fetchRemoteDigest(parsed, auth = null) {
if (cd && !related.includes(cd)) related.push(cd)
}
}
// Single-platform manifest config digest is image ID territory — skip
} catch {
// not JSON
}
if (!digest) {
const crypto = await import('crypto')
const hash = crypto.createHash('sha256').update(res.body).digest('hex')
digest = `sha256:${hash}`
related.unshift(digest)
// Last resort: content hash of the manifest body (matches some registry behaviors)
try {
const crypto = await import('crypto')
const hash = crypto.createHash('sha256').update(res.body).digest('hex')
digest = `sha256:${hash}`
related.unshift(digest)
} catch {
throw new Error('Registry response missing docker-content-digest')
}
}
return { digest, related }
@@ -345,33 +456,68 @@ export async function fetchRemoteDigest(parsed, auth = null) {
/**
* Local digests from image inspect RepoDigests.
* @param {string} image
* @returns {Promise<{ id: string|null, digests: string[] }>}
* Tries each ref (name, id) until one inspects successfully.
* @param {string|string[]} imageOrIds
* @returns {Promise<{ id: string|null, digests: string[], repoTags: string[] }>}
*/
export async function getLocalImageDigests(image) {
try {
const inspect = await docker.getImage(image).inspect()
const digests = []
for (const rd of inspect.RepoDigests || []) {
const d = String(rd).split('@')[1]
const n = normalizeDigest(d)
if (n && !digests.includes(n)) digests.push(n)
}
return { id: inspect.Id || null, digests }
} catch (err) {
// Image may only exist as container ImageID
export async function getLocalImageDigests(imageOrIds) {
const refs = Array.isArray(imageOrIds) ? imageOrIds : [imageOrIds]
let lastErr = null
for (const ref of refs) {
if (!ref) continue
try {
const inspect = await docker.getImage(image).inspect()
return { id: inspect.Id || null, digests: [] }
} catch {
throw err
const inspect = await docker.getImage(ref).inspect()
return {
id: inspect.Id || null,
digests: digestsFromInspect(inspect),
repoTags: Array.isArray(inspect.RepoTags) ? inspect.RepoTags : [],
}
} catch (err) {
lastErr = err
}
}
if (lastErr) throw lastErr
return { id: null, digests: [], repoTags: [] }
}
/**
* Resolve a registry-checkable image name for a container list entry.
* Docker sometimes returns only an image id in `Image` when tags are gone.
* @param {{ Image?: string, ImageID?: string, Id?: string }} container
* @returns {Promise<{ image: string, digests: string[], imageId: string|null }>}
*/
export async function resolveContainerImage(container) {
const listed = String(container?.Image || '').trim()
const imageId = container?.ImageID || null
const candidates = [listed, imageId].filter(Boolean)
let digests = []
let repoTags = []
let resolvedId = imageId
try {
const local = await getLocalImageDigests(candidates)
digests = local.digests
repoTags = local.repoTags
resolvedId = local.id || imageId
} catch {
// image may have been removed
}
// Prefer a parseable name for registry lookup
let image = listed
if (!parseImageRef(image)) {
const tag = preferredRepoTag({ RepoTags: repoTags })
if (tag) image = tag
}
// If still unparseable but listed looks like repo without tag issues, keep it
return { image, digests, imageId: resolvedId }
}
/**
* @param {string} image
* @param {{ force?: boolean, auth?: { username: string, password: string }|null }} [opts]
* @param {{ force?: boolean, auth?: { username: string, password: string }|null, localDigests?: string[] }} [opts]
* @returns {Promise<{ image: string, status: UpdateStatus, localDigest: string|null, remoteDigest: string|null, error: string|null, checkedAt: number }>}
*/
export async function checkImageUpdate(image, opts = {}) {
@@ -433,19 +579,25 @@ export async function checkImageUpdate(image, opts = {}) {
}
let localDigest = null
let localDigests = []
try {
const local = await getLocalImageDigests(key)
localDigests = local.digests
/** @type {string[]} */
let localDigests = Array.isArray(opts.localDigests) ? [...opts.localDigests] : []
if (!localDigests.length) {
try {
const local = await getLocalImageDigests(key)
localDigests = local.digests
localDigest = localDigests[0] || null
} catch (err) {
logger.debug('local digest inspect failed', { image: key, error: err.message })
}
} else {
localDigest = localDigests[0] || null
} catch (err) {
logger.debug('local digest inspect failed', { image: key, error: err.message })
}
// Resolve auth: explicit → vault match by host
let auth = opts.auth || null
if (!auth) {
const found = vault.findCredentialForServer(parsed.host) ||
const found =
vault.findCredentialForServer(parsed.host) ||
vault.findCredentialForServer(parsed.registry) ||
vault.findCredentialForServer(
parsed.host === 'docker.io' ? 'https://index.docker.io/v1/' : parsed.host
@@ -458,9 +610,11 @@ export async function checkImageUpdate(image, opts = {}) {
try {
const remote = await fetchRemoteDigest(parsed, auth)
const remoteDigest = remote.digest
const remoteSet = new Set(remote.related || [remoteDigest])
const remoteSet = new Set(
(remote.related || []).filter(Boolean).concat(remoteDigest ? [remoteDigest] : [])
)
let status = /** @type {UpdateStatus} */ ('unknown')
if (!localDigest && localDigests.length === 0) {
if (!localDigests.length) {
// No RepoDigests — image may never have been pulled with digest tracking
status = 'unknown'
} else if (localDigests.some((d) => remoteSet.has(d))) {
@@ -476,7 +630,10 @@ export async function checkImageUpdate(image, opts = {}) {
status,
localDigest,
remoteDigest,
error: null,
error:
status === 'unknown' && !localDigests.length
? 'No local RepoDigest (image may be local-built or loaded without registry metadata)'
: null,
checkedAt: Date.now(),
}
cache.set(key, result)
@@ -491,7 +648,7 @@ export async function checkImageUpdate(image, opts = {}) {
checkedAt: Date.now(),
}
cache.set(key, result)
logger.debug('image update check failed', { image: key, error: err.message })
logger.warn('image update check failed', { image: key, error: err.message })
return result
}
}
@@ -499,19 +656,24 @@ export async function checkImageUpdate(image, opts = {}) {
/**
* Run checks with limited concurrency.
* @param {string[]} images
* @param {{ force?: boolean, auth?: object|null }} [opts]
* @param {{ force?: boolean, auth?: object|null, localDigestsByImage?: Record<string, string[]> }} [opts]
*/
export async function checkImageUpdates(images, opts = {}) {
const unique = [...new Set((images || []).map((i) => String(i || '').trim()).filter(Boolean))]
/** @type {Record<string, object>} */
const results = {}
let i = 0
const localMap = opts.localDigestsByImage || {}
async function worker() {
while (i < unique.length) {
const idx = i++
const img = unique[idx]
results[img] = await checkImageUpdate(img, opts)
results[img] = await checkImageUpdate(img, {
force: opts.force,
auth: opts.auth,
localDigests: localMap[img],
})
}
}
@@ -526,17 +688,55 @@ export async function checkImageUpdates(images, opts = {}) {
*/
export async function checkContainerImageUpdates(opts = {}) {
const containers = await docker.listContainers({ all: opts.all !== false })
const images = containers.map((c) => c.Image).filter(Boolean)
const byImage = await checkImageUpdates(images, { force: opts.force })
/** @type {string[]} */
const images = []
/** @type {Record<string, string[]>} */
const localDigestsByImage = {}
/** @type {Array<{ id: string, image: string }>} */
const resolved = []
// Resolve names + digests with limited concurrency
let idx = 0
async function resolveWorker() {
while (idx < containers.length) {
const c = containers[idx++]
try {
const r = await resolveContainerImage(c)
const image = r.image || c.Image
if (image) {
images.push(image)
if (r.digests?.length) {
const prev = localDigestsByImage[image] || []
for (const d of r.digests) {
if (!prev.includes(d)) prev.push(d)
}
localDigestsByImage[image] = prev
}
}
resolved.push({ id: c.Id, image })
} catch {
resolved.push({ id: c.Id, image: c.Image })
if (c.Image) images.push(c.Image)
}
}
}
const rn = Math.min(MAX_CONCURRENT, Math.max(1, containers.length))
await Promise.all(Array.from({ length: rn }, () => resolveWorker()))
const byImage = await checkImageUpdates(images, {
force: opts.force,
localDigestsByImage,
})
/** @type {Record<string, object>} */
const byContainer = {}
for (const c of containers) {
const img = c.Image
for (const c of resolved) {
const img = c.image
const r = byImage[img]
if (r) {
byContainer[c.Id] = {
containerId: c.Id,
byContainer[c.id] = {
containerId: c.id,
image: img,
status: r.status,
localDigest: r.localDigest,
+24 -1
View File
@@ -1,5 +1,10 @@
import test from 'brittle'
import { parseImageRef } from '../server/services/image-updates.js'
import {
parseImageRef,
normalizeDigest,
digestsFromInspect,
preferredRepoTag,
} from '../server/services/image-updates.js'
test('parseImageRef handles Docker Hub short names', (t) => {
const r = parseImageRef('nginx:alpine')
@@ -38,6 +43,7 @@ test('parseImageRef digests and bare ids', (t) => {
t.is(parseImageRef('sha256:deadbeef'), null)
t.is(parseImageRef('a1b2c3d4e5f6'), null)
t.is(parseImageRef(''), null)
t.is(parseImageRef('<none>:<none>'), null)
})
test('parseImageRef defaults tag to latest', (t) => {
@@ -45,3 +51,20 @@ test('parseImageRef defaults tag to latest', (t) => {
t.is(r.tag, 'latest')
t.is(r.repository, 'library/postgres')
})
test('normalizeDigest and digestsFromInspect', (t) => {
t.is(normalizeDigest('sha256:abc'), 'sha256:abc')
t.is(normalizeDigest('abc'), 'sha256:abc')
t.is(normalizeDigest(''), null)
const digests = digestsFromInspect({
RepoDigests: [
'nginx@sha256:1111111111111111111111111111111111111111111111111111111111111111',
'docker.io/library/nginx@sha256:2222222222222222222222222222222222222222222222222222222222222222',
],
})
t.is(digests.length, 2)
t.ok(digests[0].startsWith('sha256:'))
t.is(preferredRepoTag({ RepoTags: ['<none>:<none>', 'nginx:alpine'] }), 'nginx:alpine')
t.is(preferredRepoTag({ RepoTags: [] }), null)
})