Fix image update checks against registries on Bare.
Release rolling / release (push) Has been cancelled
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:
@@ -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 (id) {
|
||||||
|
const byC = imageUpdateByContainer.get(id);
|
||||||
if (byC) return byC;
|
if (byC) return byC;
|
||||||
const img = container.Image || '';
|
// 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);
|
const byI = imageUpdateByImage.get(img);
|
||||||
if (byI) return { ...byI, image: 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';
|
||||||
|
|||||||
@@ -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 (12–64 hex)
|
// Drop algorithm-only short ids (12–64 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)
|
||||||
|
: 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,
|
protocol: u.protocol,
|
||||||
|
host: u.hostname,
|
||||||
hostname: u.hostname,
|
hostname: u.hostname,
|
||||||
port: u.port || (u.protocol === 'http:' ? 80 : 443),
|
servername: u.hostname,
|
||||||
|
port,
|
||||||
path: u.pathname + u.search,
|
path: u.pathname + u.search,
|
||||||
method: opts.method || 'GET',
|
method: opts.method || 'GET',
|
||||||
headers: opts.headers || {},
|
headers,
|
||||||
|
agent: false,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
},
|
}
|
||||||
(res) => {
|
|
||||||
|
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))
|
||||||
)
|
|
||||||
req.on('timeout', () => {
|
|
||||||
req.destroy(new Error('Registry request timed out'))
|
|
||||||
})
|
})
|
||||||
req.on('error', reject)
|
} catch (err) {
|
||||||
|
finish(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
try {
|
||||||
|
req.destroy(new Error('Registry request timed out'))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
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
|
||||||
|
* @param {Record<string, string>} hdrs
|
||||||
|
*/
|
||||||
|
async function once(method, hdrs) {
|
||||||
|
return httpRequest(url, { method, headers: hdrs })
|
||||||
|
}
|
||||||
|
|
||||||
if (res.status === 401 || res.status === 403) {
|
/**
|
||||||
|
* 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'])
|
const token = await getBearerToken(parsed, auth, res.headers['www-authenticate'])
|
||||||
if (token) {
|
if (!token) return res
|
||||||
headers.Authorization = `Bearer ${token}`
|
const next = { ...hdrs, Authorization: `Bearer ${token}` }
|
||||||
res = await httpRequest(url, { method: 'GET', headers })
|
return once(method, next)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some registries only allow HEAD for digest
|
// 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) {
|
||||||
|
// Last resort: content hash of the manifest body (matches some registry behaviors)
|
||||||
|
try {
|
||||||
const crypto = await import('crypto')
|
const crypto = await import('crypto')
|
||||||
const hash = crypto.createHash('sha256').update(res.body).digest('hex')
|
const hash = crypto.createHash('sha256').update(res.body).digest('hex')
|
||||||
digest = `sha256:${hash}`
|
digest = `sha256:${hash}`
|
||||||
related.unshift(digest)
|
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) {
|
||||||
|
const refs = Array.isArray(imageOrIds) ? imageOrIds : [imageOrIds]
|
||||||
|
let lastErr = null
|
||||||
|
for (const ref of refs) {
|
||||||
|
if (!ref) continue
|
||||||
try {
|
try {
|
||||||
const inspect = await docker.getImage(image).inspect()
|
const inspect = await docker.getImage(ref).inspect()
|
||||||
const digests = []
|
return {
|
||||||
for (const rd of inspect.RepoDigests || []) {
|
id: inspect.Id || null,
|
||||||
const d = String(rd).split('@')[1]
|
digests: digestsFromInspect(inspect),
|
||||||
const n = normalizeDigest(d)
|
repoTags: Array.isArray(inspect.RepoTags) ? inspect.RepoTags : [],
|
||||||
if (n && !digests.includes(n)) digests.push(n)
|
|
||||||
}
|
}
|
||||||
return { id: inspect.Id || null, digests }
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Image may only exist as container ImageID
|
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 {
|
try {
|
||||||
const inspect = await docker.getImage(image).inspect()
|
const local = await getLocalImageDigests(candidates)
|
||||||
return { id: inspect.Id || null, digests: [] }
|
digests = local.digests
|
||||||
|
repoTags = local.repoTags
|
||||||
|
resolvedId = local.id || imageId
|
||||||
} catch {
|
} catch {
|
||||||
throw err
|
// 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,7 +579,9 @@ export async function checkImageUpdate(image, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let localDigest = null
|
let localDigest = null
|
||||||
let localDigests = []
|
/** @type {string[]} */
|
||||||
|
let localDigests = Array.isArray(opts.localDigests) ? [...opts.localDigests] : []
|
||||||
|
if (!localDigests.length) {
|
||||||
try {
|
try {
|
||||||
const local = await getLocalImageDigests(key)
|
const local = await getLocalImageDigests(key)
|
||||||
localDigests = local.digests
|
localDigests = local.digests
|
||||||
@@ -441,11 +589,15 @@ export async function checkImageUpdate(image, opts = {}) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.debug('local digest inspect failed', { image: key, error: err.message })
|
logger.debug('local digest inspect failed', { image: key, error: err.message })
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
localDigest = localDigests[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user