Files
peardock/server/services/image-updates.js
T
Raven Scott 42f066fa47
Release rolling / release (push) Has been cancelled
Fix image update checks against registries on Bare.
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.
2026-07-15 15:31:46 -04:00

765 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 (1264 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
}