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.
765 lines
22 KiB
JavaScript
765 lines
22 KiB
JavaScript
/**
|
||
* Image update detection.
|
||
*
|
||
* 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'
|
||
import { docker } from './docker.js'
|
||
import * as vault from '../core/registry-vault.js'
|
||
import logger from '../utils/logger.js'
|
||
|
||
/** @typedef {'updated'|'outdated'|'unknown'|'skipped'} UpdateStatus */
|
||
|
||
const CACHE_TTL_MS = Math.max(
|
||
30_000,
|
||
Number(process.env.PEARDOCK_UPDATE_CACHE_MS) || 5 * 60_000
|
||
)
|
||
const CHECK_TIMEOUT_MS = Math.min(
|
||
30_000,
|
||
Number(process.env.PEARDOCK_UPDATE_TIMEOUT_MS) || 12_000
|
||
)
|
||
const MAX_CONCURRENT = Math.min(8, Number(process.env.PEARDOCK_UPDATE_CONCURRENCY) || 4)
|
||
|
||
/** @type {Map<string, { status: UpdateStatus, localDigest?: string|null, remoteDigest?: string|null, error?: string|null, checkedAt: number, image: string }>} */
|
||
const cache = new Map()
|
||
|
||
const MANIFEST_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',
|
||
'application/vnd.docker.distribution.manifest.v1+json',
|
||
].join(', ')
|
||
|
||
/**
|
||
* Parse a Docker image reference into registry / repository / tag.
|
||
* @param {string} raw
|
||
* @returns {{
|
||
* raw: string,
|
||
* registry: string,
|
||
* host: string,
|
||
* repository: string,
|
||
* tag: string,
|
||
* digest: string|null,
|
||
* isOfficial: boolean,
|
||
* isLocal: boolean,
|
||
* isPinned: boolean,
|
||
* }|null}
|
||
*/
|
||
export function parseImageRef(raw) {
|
||
if (!raw || typeof raw !== 'string') return null
|
||
let s = raw.trim()
|
||
if (!s || s === '<none>' || s === '<none>:<none>') return null
|
||
if (s.startsWith('sha256:')) return null
|
||
|
||
// Drop algorithm-only short ids (12–64 hex)
|
||
if (/^[a-f0-9]{12,64}$/i.test(s)) return null
|
||
|
||
let digest = null
|
||
if (s.includes('@')) {
|
||
const at = s.lastIndexOf('@')
|
||
digest = s.slice(at + 1)
|
||
s = s.slice(0, at)
|
||
if (!s) return null
|
||
}
|
||
|
||
let tag = 'latest'
|
||
// Tag is after last ":" that is not part of host:port
|
||
const lastColon = s.lastIndexOf(':')
|
||
const lastSlash = s.lastIndexOf('/')
|
||
if (lastColon > lastSlash && lastColon > 0) {
|
||
tag = s.slice(lastColon + 1)
|
||
s = s.slice(0, lastColon)
|
||
}
|
||
|
||
if (!s) return null
|
||
|
||
const parts = s.split('/')
|
||
let registry = 'registry-1.docker.io'
|
||
let host = 'docker.io'
|
||
let repository = s
|
||
let isOfficial = false
|
||
|
||
const first = parts[0]
|
||
const firstIsRegistry =
|
||
first.includes('.') ||
|
||
first.includes(':') ||
|
||
first === 'localhost' ||
|
||
first === 'registry-1.docker.io'
|
||
|
||
if (parts.length === 1) {
|
||
// nginx → library/nginx on Docker Hub
|
||
repository = `library/${parts[0]}`
|
||
isOfficial = true
|
||
} else if (!firstIsRegistry && parts.length === 2) {
|
||
// user/app on Docker Hub
|
||
repository = s
|
||
} else if (firstIsRegistry) {
|
||
host = first.split(':')[0]
|
||
registry =
|
||
first === 'docker.io' || first === 'index.docker.io'
|
||
? 'registry-1.docker.io'
|
||
: first
|
||
repository = parts.slice(1).join('/')
|
||
if (registry === 'registry-1.docker.io' && !repository.includes('/')) {
|
||
repository = `library/${repository}`
|
||
isOfficial = true
|
||
}
|
||
} else {
|
||
repository = s
|
||
}
|
||
|
||
if (!repository) return null
|
||
|
||
// Local-only names (no registry path that could resolve)
|
||
const isLocal =
|
||
host === 'localhost' ||
|
||
registry.startsWith('localhost') ||
|
||
/^127\./.test(host)
|
||
|
||
return {
|
||
raw,
|
||
registry,
|
||
host,
|
||
repository,
|
||
tag: digest ? digest : tag,
|
||
digest,
|
||
isOfficial,
|
||
isLocal,
|
||
isPinned: Boolean(digest),
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param {string} 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
|
||
* @returns {Promise<{ status: number, headers: Record<string,string>, body: string }>}
|
||
*/
|
||
function httpRequest(url, opts = {}) {
|
||
const timeoutMs = opts.timeoutMs ?? CHECK_TIMEOUT_MS
|
||
return new Promise((resolve, reject) => {
|
||
let u
|
||
try {
|
||
u = new URL(url)
|
||
} catch (err) {
|
||
reject(err)
|
||
return
|
||
}
|
||
const lib = u.protocol === 'http:' ? http : https
|
||
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 headersOut = {}
|
||
for (const [k, v] of Object.entries(res.headers || {})) {
|
||
headersOut[String(k).toLowerCase()] = Array.isArray(v)
|
||
? v.join(', ')
|
||
: String(v ?? '')
|
||
}
|
||
finish(null, {
|
||
status: res.statusCode || 0,
|
||
headers: headersOut,
|
||
body: Buffer.concat(chunks).toString('utf8'),
|
||
})
|
||
})
|
||
res.on('error', (err) => finish(err))
|
||
})
|
||
} 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()
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Parse WWW-Authenticate: Bearer realm="...",service="...",scope="..."
|
||
* @param {string} header
|
||
*/
|
||
function parseWwwAuthenticate(header) {
|
||
if (!header || !/bearer/i.test(header)) return null
|
||
const params = {}
|
||
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) {
|
||
params[m[1].toLowerCase()] = m[2]
|
||
}
|
||
if (!params.realm) return null
|
||
return params
|
||
}
|
||
|
||
/**
|
||
* @param {object} parsed
|
||
* @param {{ username?: string, password?: string }|null} auth
|
||
* @param {string} [wwwAuthHeader]
|
||
*/
|
||
async function getBearerToken(parsed, auth, wwwAuthHeader) {
|
||
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
|
||
let realm = params.realm
|
||
let service = params.service
|
||
let scope = params.scope
|
||
|
||
// Docker Hub anonymous token fallback
|
||
if (!realm && (parsed.host === 'docker.io' || parsed.registry === 'registry-1.docker.io')) {
|
||
realm = 'https://auth.docker.io/token'
|
||
service = 'registry.docker.io'
|
||
scope = `repository:${parsed.repository}:pull`
|
||
}
|
||
if (!realm) return null
|
||
|
||
const u = new URL(realm)
|
||
if (service) u.searchParams.set('service', service)
|
||
if (scope) u.searchParams.set('scope', scope)
|
||
else u.searchParams.set('scope', `repository:${parsed.repository}:pull`)
|
||
|
||
const headers = { Accept: 'application/json' }
|
||
if (auth?.username && auth?.password) {
|
||
headers.Authorization =
|
||
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
|
||
}
|
||
|
||
const res = await httpRequest(u.toString(), { headers })
|
||
if (res.status < 200 || res.status >= 300) {
|
||
throw new Error(`Token request failed (${res.status})`)
|
||
}
|
||
let json
|
||
try {
|
||
json = JSON.parse(res.body)
|
||
} catch {
|
||
throw new Error('Invalid token response')
|
||
}
|
||
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).
|
||
* @param {ReturnType<typeof parseImageRef>} parsed
|
||
* @param {{ username?: string, password?: string }|null} auth
|
||
* @returns {Promise<{ digest: string, related: string[] }>}
|
||
*/
|
||
export async function fetchRemoteDigest(parsed, auth = null) {
|
||
if (!parsed) throw new Error('Invalid image ref')
|
||
if (parsed.isPinned && parsed.digest) {
|
||
const d = normalizeDigest(parsed.digest)
|
||
return { digest: d, related: [d] }
|
||
}
|
||
|
||
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,
|
||
}
|
||
if (auth?.username && auth?.password) {
|
||
headers.Authorization =
|
||
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
|
||
}
|
||
|
||
/**
|
||
* @param {string} method
|
||
* @param {Record<string, string>} hdrs
|
||
*/
|
||
async function once(method, hdrs) {
|
||
return httpRequest(url, { method, headers: hdrs })
|
||
}
|
||
|
||
/**
|
||
* 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 once('HEAD', headers)
|
||
head = await withAuth('HEAD', head, headers)
|
||
if (head.status >= 200 && head.status < 300) {
|
||
const d =
|
||
normalizeDigest(
|
||
head.headers['docker-content-digest'] || head.headers['oci-content-digest']
|
||
) || null
|
||
if (d) return { digest: d, related: [d] }
|
||
}
|
||
}
|
||
|
||
if (res.status < 200 || res.status >= 300) {
|
||
throw new Error(`Registry returned ${res.status} for ${parsed.repository}:${parsed.tag}`)
|
||
}
|
||
|
||
let digest =
|
||
normalizeDigest(
|
||
res.headers['docker-content-digest'] || res.headers['oci-content-digest']
|
||
) || null
|
||
|
||
/** @type {string[]} */
|
||
const related = []
|
||
if (digest) related.push(digest)
|
||
|
||
// Parse manifest list / index for platform digests (local RepoDigests often point at these)
|
||
try {
|
||
const body = JSON.parse(res.body)
|
||
if (Array.isArray(body.manifests)) {
|
||
for (const m of body.manifests) {
|
||
const cd = normalizeDigest(m.digest)
|
||
if (cd && !related.includes(cd)) related.push(cd)
|
||
}
|
||
}
|
||
} catch {
|
||
// not JSON
|
||
}
|
||
|
||
if (!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 }
|
||
}
|
||
|
||
/**
|
||
* Local digests from image inspect RepoDigests.
|
||
* 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(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(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, 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 = {}) {
|
||
const key = String(image || '').trim()
|
||
if (!key) {
|
||
return {
|
||
image: key,
|
||
status: 'unknown',
|
||
localDigest: null,
|
||
remoteDigest: null,
|
||
error: 'empty image',
|
||
checkedAt: Date.now(),
|
||
}
|
||
}
|
||
|
||
const cached = cache.get(key)
|
||
if (!opts.force && cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
|
||
return { ...cached, image: key }
|
||
}
|
||
|
||
const parsed = parseImageRef(key)
|
||
if (!parsed) {
|
||
const result = {
|
||
image: key,
|
||
status: /** @type {UpdateStatus} */ ('skipped'),
|
||
localDigest: null,
|
||
remoteDigest: null,
|
||
error: 'Not a registry image reference',
|
||
checkedAt: Date.now(),
|
||
}
|
||
cache.set(key, result)
|
||
return result
|
||
}
|
||
|
||
if (parsed.isPinned) {
|
||
const result = {
|
||
image: key,
|
||
status: /** @type {UpdateStatus} */ ('updated'),
|
||
localDigest: normalizeDigest(parsed.digest),
|
||
remoteDigest: normalizeDigest(parsed.digest),
|
||
error: null,
|
||
checkedAt: Date.now(),
|
||
}
|
||
cache.set(key, result)
|
||
return result
|
||
}
|
||
|
||
if (parsed.isLocal) {
|
||
const result = {
|
||
image: key,
|
||
status: /** @type {UpdateStatus} */ ('skipped'),
|
||
localDigest: null,
|
||
remoteDigest: null,
|
||
error: 'Local registry skipped',
|
||
checkedAt: Date.now(),
|
||
}
|
||
cache.set(key, result)
|
||
return result
|
||
}
|
||
|
||
let localDigest = null
|
||
/** @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
|
||
}
|
||
|
||
// Resolve auth: explicit → vault match by host
|
||
let auth = opts.auth || null
|
||
if (!auth) {
|
||
const found =
|
||
vault.findCredentialForServer(parsed.host) ||
|
||
vault.findCredentialForServer(parsed.registry) ||
|
||
vault.findCredentialForServer(
|
||
parsed.host === 'docker.io' ? 'https://index.docker.io/v1/' : parsed.host
|
||
)
|
||
if (found) {
|
||
auth = { username: found.username, password: found.password }
|
||
}
|
||
}
|
||
|
||
try {
|
||
const remote = await fetchRemoteDigest(parsed, auth)
|
||
const remoteDigest = remote.digest
|
||
const remoteSet = new Set(
|
||
(remote.related || []).filter(Boolean).concat(remoteDigest ? [remoteDigest] : [])
|
||
)
|
||
let status = /** @type {UpdateStatus} */ ('unknown')
|
||
if (!localDigests.length) {
|
||
// No RepoDigests — image may never have been pulled with digest tracking
|
||
status = 'unknown'
|
||
} else if (localDigests.some((d) => remoteSet.has(d))) {
|
||
status = 'updated'
|
||
} else if (localDigests.length && remoteDigest) {
|
||
status = 'outdated'
|
||
} else {
|
||
status = 'unknown'
|
||
}
|
||
|
||
const result = {
|
||
image: key,
|
||
status,
|
||
localDigest,
|
||
remoteDigest,
|
||
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)
|
||
return result
|
||
} catch (err) {
|
||
const result = {
|
||
image: key,
|
||
status: /** @type {UpdateStatus} */ ('unknown'),
|
||
localDigest,
|
||
remoteDigest: null,
|
||
error: err.message || 'Registry check failed',
|
||
checkedAt: Date.now(),
|
||
}
|
||
cache.set(key, result)
|
||
logger.warn('image update check failed', { image: key, error: err.message })
|
||
return result
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Run checks with limited concurrency.
|
||
* @param {string[]} images
|
||
* @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, {
|
||
force: opts.force,
|
||
auth: opts.auth,
|
||
localDigests: localMap[img],
|
||
})
|
||
}
|
||
}
|
||
|
||
const n = Math.min(MAX_CONCURRENT, Math.max(1, unique.length))
|
||
await Promise.all(Array.from({ length: n }, () => worker()))
|
||
return results
|
||
}
|
||
|
||
/**
|
||
* Collect unique image refs from running/all containers and check.
|
||
* @param {{ force?: boolean, all?: boolean }} [opts]
|
||
*/
|
||
export async function checkContainerImageUpdates(opts = {}) {
|
||
const containers = await docker.listContainers({ all: opts.all !== false })
|
||
|
||
/** @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 resolved) {
|
||
const img = c.image
|
||
const r = byImage[img]
|
||
if (r) {
|
||
byContainer[c.id] = {
|
||
containerId: c.id,
|
||
image: img,
|
||
status: r.status,
|
||
localDigest: r.localDigest,
|
||
remoteDigest: r.remoteDigest,
|
||
error: r.error,
|
||
checkedAt: r.checkedAt,
|
||
}
|
||
}
|
||
}
|
||
|
||
return {
|
||
byImage,
|
||
byContainer,
|
||
checkedAt: Date.now(),
|
||
cacheTtlMs: CACHE_TTL_MS,
|
||
}
|
||
}
|
||
|
||
export function clearUpdateCache() {
|
||
cache.clear()
|
||
}
|
||
|
||
export function getUpdateCacheSize() {
|
||
return cache.size
|
||
}
|