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) { function getImageUpdateMeta(container) {
const byC = imageUpdateByContainer.get(container.Id); const id = container?.Id || '';
if (byC) return byC; if (id) {
const img = container.Image || ''; const byC = imageUpdateByContainer.get(id);
const byI = imageUpdateByImage.get(img); if (byC) return byC;
if (byI) return { ...byI, image: img }; // 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 }; return { status: 'unknown', image: img };
} }
@@ -9723,13 +9740,24 @@ async function runImageUpdateCheck(opts = {}) {
const outdated = statuses.filter((s) => s.status === 'outdated').length; const outdated = statuses.filter((s) => s.status === 'outdated').length;
const updated = statuses.filter((s) => s.status === 'updated').length; const updated = statuses.filter((s) => s.status === 'updated').length;
const unknown = statuses.filter((s) => s.status === 'unknown' || s.status === 'skipped').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) { if (line) {
line.textContent = let text =
outdated > 0 outdated > 0
? `${outdated} image(s) have updates · ${updated} up to date · ${unknown} unknown/skipped` ? `${outdated} image(s) have updates · ${updated} up to date · ${unknown} unknown/skipped`
: statuses.length : 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'; : '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) { } catch (err) {
if (line) line.textContent = err?.message || 'Update check failed'; 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 * Compares local image RepoDigest(s) to the remote registry manifest digest
* registry manifest digest. Differing digests → update available. * for the same image:tag. Differing digests → update available.
* *
* Remote digests are fetched via Registry HTTP API V2 (no full pull). * Remote digests are fetched via Registry HTTP API V2 (no full pull).
* Results are cached to avoid hammering registries on every list refresh. * 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 https from 'https'
import http from 'http' import http from 'http'
@@ -54,7 +58,8 @@ const MANIFEST_ACCEPT = [
export function parseImageRef(raw) { export function parseImageRef(raw) {
if (!raw || typeof raw !== 'string') return null if (!raw || typeof raw !== 'string') return null
let s = raw.trim() 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) // Drop algorithm-only short ids (1264 hex)
if (/^[a-f0-9]{12,64}$/i.test(s)) return null if (/^[a-f0-9]{12,64}$/i.test(s)) return null
@@ -64,6 +69,7 @@ export function parseImageRef(raw) {
const at = s.lastIndexOf('@') const at = s.lastIndexOf('@')
digest = s.slice(at + 1) digest = s.slice(at + 1)
s = s.slice(0, at) s = s.slice(0, at)
if (!s) return null
} }
let tag = 'latest' let tag = 'latest'
@@ -75,6 +81,8 @@ export function parseImageRef(raw) {
s = s.slice(0, lastColon) s = s.slice(0, lastColon)
} }
if (!s) return null
const parts = s.split('/') const parts = s.split('/')
let registry = 'registry-1.docker.io' let registry = 'registry-1.docker.io'
let host = 'docker.io' let host = 'docker.io'
@@ -110,6 +118,8 @@ export function parseImageRef(raw) {
repository = s repository = s
} }
if (!repository) return null
// Local-only names (no registry path that could resolve) // Local-only names (no registry path that could resolve)
const isLocal = const isLocal =
host === 'localhost' || host === 'localhost' ||
@@ -132,13 +142,41 @@ export function parseImageRef(raw) {
/** /**
* @param {string} digest * @param {string} digest
*/ */
function normalizeDigest(digest) { export function normalizeDigest(digest) {
if (!digest) return null if (!digest) return null
const d = String(digest).trim() const d = String(digest).trim()
if (!d) return null if (!d) return null
return d.startsWith('sha256:') ? d : `sha256:${d}` 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 {string} url
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number }} opts * @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number }} opts
@@ -155,36 +193,87 @@ function httpRequest(url, opts = {}) {
return return
} }
const lib = u.protocol === 'http:' ? http : https const lib = u.protocol === 'http:' ? http : https
const req = lib.request( const port = u.port
{ ? Number(u.port)
protocol: u.protocol, : u.protocol === 'http:'
hostname: u.hostname, ? 80
port: u.port || (u.protocol === 'http:' ? 80 : 443), : 443
path: u.pathname + u.search, // bare-http1 / bare-tcp dial via `host` (hostname alone is ignored → localhost).
method: opts.method || 'GET', // Set both for Node + Bare. Disable keep-alive (agent:false) — bare-https
headers: opts.headers || {}, // often drops recycled sockets (CONNECTION_LOST) between token + manifest calls.
timeout: timeoutMs, const headers = {
}, Connection: 'close',
(res) => { ...(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 = [] const chunks = []
res.on('data', (c) => chunks.push(c)) res.on('data', (c) => chunks.push(c))
res.on('end', () => { res.on('end', () => {
const headers = {} const headersOut = {}
for (const [k, v] of Object.entries(res.headers)) { for (const [k, v] of Object.entries(res.headers || {})) {
headers[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? '') headersOut[String(k).toLowerCase()] = Array.isArray(v)
? v.join(', ')
: String(v ?? '')
} }
resolve({ finish(null, {
status: res.statusCode || 0, status: res.statusCode || 0,
headers, headers: headersOut,
body: Buffer.concat(chunks).toString('utf8'), body: Buffer.concat(chunks).toString('utf8'),
}) })
}) })
} res.on('error', (err) => finish(err))
) })
} catch (err) {
finish(err)
return
}
req.on('timeout', () => { 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() req.end()
}) })
} }
@@ -206,6 +295,7 @@ function parseWwwAuthenticate(header) {
/** /**
* @param {object} parsed * @param {object} parsed
* @param {{ username?: string, password?: string }|null} auth * @param {{ username?: string, password?: string }|null} auth
* @param {string} [wwwAuthHeader]
*/ */
async function getBearerToken(parsed, auth, wwwAuthHeader) { async function getBearerToken(parsed, auth, wwwAuthHeader) {
const params = parseWwwAuthenticate(wwwAuthHeader) || {} const params = parseWwwAuthenticate(wwwAuthHeader) || {}
@@ -245,6 +335,17 @@ async function getBearerToken(parsed, auth, wwwAuthHeader) {
return json.token || json.access_token || null 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. * Fetch remote manifest digest(s) for image:tag.
* Returns the index/list digest plus any platform child digests (multi-arch). * 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] } return { digest: d, related: [d] }
} }
const registryHost = parsed.registry.includes('://') const base = registryBaseUrl(parsed)
? parsed.registry // Keep slashes in repository path; only encode the tag/reference
: `https://${parsed.registry}`
// Prefer https; some local registries use http — try https first
const base = registryHost.startsWith('http') ? registryHost : `https://${registryHost}`
const path = `/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}` const path = `/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`
const url = new URL(path, base.endsWith('/') ? base : base + '/').toString() const url = new URL(path, base.endsWith('/') ? base : base + '/').toString()
/** @type {Record<string, string>} */
const headers = { const headers = {
Accept: MANIFEST_ACCEPT, Accept: MANIFEST_ACCEPT,
} }
@@ -275,34 +374,42 @@ export async function fetchRemoteDigest(parsed, auth = null) {
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64') 'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
} }
let res = await httpRequest(url, { method: 'GET', headers }) /**
* @param {string} method
if (res.status === 401 || res.status === 403) { * @param {Record<string, string>} hdrs
const token = await getBearerToken(parsed, auth, res.headers['www-authenticate']) */
if (token) { async function once(method, hdrs) {
headers.Authorization = `Bearer ${token}` return httpRequest(url, { method, headers: hdrs })
res = await httpRequest(url, { method: 'GET', headers })
}
} }
// 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) { if (res.status === 405) {
let head = await httpRequest(url, { method: 'HEAD', headers }) let head = await once('HEAD', headers)
if ((head.status === 401 || head.status === 403) && !headers.Authorization?.startsWith('Bearer')) { head = await withAuth('HEAD', head, headers)
const token = await getBearerToken(parsed, auth, head.headers['www-authenticate'])
if (token) {
headers.Authorization = `Bearer ${token}`
head = await httpRequest(url, { method: 'HEAD', headers })
}
}
if (head.status >= 200 && head.status < 300) { if (head.status >= 200 && head.status < 300) {
const d = const d =
head.headers['docker-content-digest'] || normalizeDigest(
head.headers['oci-content-digest'] head.headers['docker-content-digest'] || head.headers['oci-content-digest']
if (d) { ) || null
const n = normalizeDigest(d) if (d) return { digest: d, related: [d] }
return { digest: n, related: [n] }
}
} }
} }
@@ -328,16 +435,20 @@ export async function fetchRemoteDigest(parsed, auth = null) {
if (cd && !related.includes(cd)) related.push(cd) if (cd && !related.includes(cd)) related.push(cd)
} }
} }
// Single-platform manifest config digest is image ID territory — skip
} catch { } catch {
// not JSON // not JSON
} }
if (!digest) { if (!digest) {
const crypto = await import('crypto') // Last resort: content hash of the manifest body (matches some registry behaviors)
const hash = crypto.createHash('sha256').update(res.body).digest('hex') try {
digest = `sha256:${hash}` const crypto = await import('crypto')
related.unshift(digest) 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 } return { digest, related }
@@ -345,33 +456,68 @@ export async function fetchRemoteDigest(parsed, auth = null) {
/** /**
* Local digests from image inspect RepoDigests. * Local digests from image inspect RepoDigests.
* @param {string} image * Tries each ref (name, id) until one inspects successfully.
* @returns {Promise<{ id: string|null, digests: string[] }>} * @param {string|string[]} imageOrIds
* @returns {Promise<{ id: string|null, digests: string[], repoTags: string[] }>}
*/ */
export async function getLocalImageDigests(image) { export async function getLocalImageDigests(imageOrIds) {
try { const refs = Array.isArray(imageOrIds) ? imageOrIds : [imageOrIds]
const inspect = await docker.getImage(image).inspect() let lastErr = null
const digests = [] for (const ref of refs) {
for (const rd of inspect.RepoDigests || []) { if (!ref) continue
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
try { try {
const inspect = await docker.getImage(image).inspect() const inspect = await docker.getImage(ref).inspect()
return { id: inspect.Id || null, digests: [] } return {
} catch { id: inspect.Id || null,
throw err 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 {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 }>} * @returns {Promise<{ image: string, status: UpdateStatus, localDigest: string|null, remoteDigest: string|null, error: string|null, checkedAt: number }>}
*/ */
export async function checkImageUpdate(image, opts = {}) { export async function checkImageUpdate(image, opts = {}) {
@@ -433,19 +579,25 @@ export async function checkImageUpdate(image, opts = {}) {
} }
let localDigest = null let localDigest = null
let localDigests = [] /** @type {string[]} */
try { let localDigests = Array.isArray(opts.localDigests) ? [...opts.localDigests] : []
const local = await getLocalImageDigests(key) if (!localDigests.length) {
localDigests = local.digests 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 localDigest = localDigests[0] || null
} catch (err) {
logger.debug('local digest inspect failed', { image: key, error: err.message })
} }
// Resolve auth: explicit → vault match by host // Resolve auth: explicit → vault match by host
let auth = opts.auth || null let auth = opts.auth || null
if (!auth) { if (!auth) {
const found = vault.findCredentialForServer(parsed.host) || const found =
vault.findCredentialForServer(parsed.host) ||
vault.findCredentialForServer(parsed.registry) || vault.findCredentialForServer(parsed.registry) ||
vault.findCredentialForServer( vault.findCredentialForServer(
parsed.host === 'docker.io' ? 'https://index.docker.io/v1/' : parsed.host parsed.host === 'docker.io' ? 'https://index.docker.io/v1/' : parsed.host
@@ -458,9 +610,11 @@ export async function checkImageUpdate(image, opts = {}) {
try { try {
const remote = await fetchRemoteDigest(parsed, auth) const remote = await fetchRemoteDigest(parsed, auth)
const remoteDigest = remote.digest 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') let status = /** @type {UpdateStatus} */ ('unknown')
if (!localDigest && localDigests.length === 0) { if (!localDigests.length) {
// No RepoDigests — image may never have been pulled with digest tracking // No RepoDigests — image may never have been pulled with digest tracking
status = 'unknown' status = 'unknown'
} else if (localDigests.some((d) => remoteSet.has(d))) { } else if (localDigests.some((d) => remoteSet.has(d))) {
@@ -476,7 +630,10 @@ export async function checkImageUpdate(image, opts = {}) {
status, status,
localDigest, localDigest,
remoteDigest, 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(), checkedAt: Date.now(),
} }
cache.set(key, result) cache.set(key, result)
@@ -491,7 +648,7 @@ export async function checkImageUpdate(image, opts = {}) {
checkedAt: Date.now(), checkedAt: Date.now(),
} }
cache.set(key, result) 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 return result
} }
} }
@@ -499,19 +656,24 @@ export async function checkImageUpdate(image, opts = {}) {
/** /**
* Run checks with limited concurrency. * Run checks with limited concurrency.
* @param {string[]} images * @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 = {}) { export async function checkImageUpdates(images, opts = {}) {
const unique = [...new Set((images || []).map((i) => String(i || '').trim()).filter(Boolean))] const unique = [...new Set((images || []).map((i) => String(i || '').trim()).filter(Boolean))]
/** @type {Record<string, object>} */ /** @type {Record<string, object>} */
const results = {} const results = {}
let i = 0 let i = 0
const localMap = opts.localDigestsByImage || {}
async function worker() { async function worker() {
while (i < unique.length) { while (i < unique.length) {
const idx = i++ const idx = i++
const img = unique[idx] 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 = {}) { export async function checkContainerImageUpdates(opts = {}) {
const containers = await docker.listContainers({ all: opts.all !== false }) 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>} */ /** @type {Record<string, object>} */
const byContainer = {} const byContainer = {}
for (const c of containers) { for (const c of resolved) {
const img = c.Image const img = c.image
const r = byImage[img] const r = byImage[img]
if (r) { if (r) {
byContainer[c.Id] = { byContainer[c.id] = {
containerId: c.Id, containerId: c.id,
image: img, image: img,
status: r.status, status: r.status,
localDigest: r.localDigest, localDigest: r.localDigest,
+24 -1
View File
@@ -1,5 +1,10 @@
import test from 'brittle' 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) => { test('parseImageRef handles Docker Hub short names', (t) => {
const r = parseImageRef('nginx:alpine') 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('sha256:deadbeef'), null)
t.is(parseImageRef('a1b2c3d4e5f6'), null) t.is(parseImageRef('a1b2c3d4e5f6'), null)
t.is(parseImageRef(''), null) t.is(parseImageRef(''), null)
t.is(parseImageRef('<none>:<none>'), null)
}) })
test('parseImageRef defaults tag to latest', (t) => { 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.tag, 'latest')
t.is(r.repository, 'library/postgres') 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)
})