Switch Docker update tracking to Portainer API (all endpoints)
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+59
-40
@@ -15,9 +15,8 @@ function fromCache<T>(key: string, ttlMs: number): T | null {
|
||||
function toCache<T>(key: string, data: T) { cache[key] = { data, ts: Date.now() } }
|
||||
|
||||
/* ────────────────────────────────────────────────────────────────────────────
|
||||
Docker image updates
|
||||
Docker image updates via Portainer
|
||||
─────────────────────────────────────────────────────────────────────────── */
|
||||
const DOCKER_SOCKET = '/var/run/docker.sock'
|
||||
const HUB_TTL = 60 * 60 * 1000 // 1 hour
|
||||
|
||||
interface ContainerInfo {
|
||||
@@ -27,10 +26,14 @@ interface ContainerInfo {
|
||||
repoDigest: string | null
|
||||
upToDate: boolean | null // null = couldn't determine
|
||||
registry: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
function portainerHeaders() {
|
||||
return { 'X-API-Key': process.env.PORTAINER_TOKEN ?? '' }
|
||||
}
|
||||
|
||||
function parseImage(fullImage: string): { registry: string; name: string; tag: string } {
|
||||
// Strip digest if present
|
||||
const [imgPart] = fullImage.split('@')
|
||||
const colonIdx = imgPart.lastIndexOf(':')
|
||||
const slashIdx = imgPart.indexOf('/')
|
||||
@@ -42,12 +45,10 @@ function parseImage(fullImage: string): { registry: string; name: string; tag: s
|
||||
img = imgPart.slice(0, colonIdx)
|
||||
}
|
||||
|
||||
// Detect registry
|
||||
const parts = img.split('/')
|
||||
const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':')
|
||||
const registry = isCustomRegistry ? parts[0] : 'docker.io'
|
||||
|
||||
// Normalise name for Docker Hub (no namespace → library/...)
|
||||
let name = isCustomRegistry ? parts.slice(1).join('/') : img
|
||||
if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}`
|
||||
|
||||
@@ -92,48 +93,66 @@ async function getLatestDigest(image: string, tag: string): Promise<string | nul
|
||||
}
|
||||
|
||||
router.get('/docker', async (_req, res) => {
|
||||
const host = process.env.PORTAINER_HOST
|
||||
if (!host) {
|
||||
res.status(503).json({ error: 'PORTAINER_HOST not configured' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// List running containers
|
||||
const containersRes = await axios.get('http://localhost/v1.41/containers/json', {
|
||||
socketPath: DOCKER_SOCKET,
|
||||
timeout: 5000,
|
||||
})
|
||||
const headers = portainerHeaders()
|
||||
|
||||
// Get all endpoints
|
||||
const endpointsRes = await axios.get(`${host}/api/endpoints`, { headers, timeout: 8000 })
|
||||
const endpoints: { Id: number; Name: string }[] = endpointsRes.data
|
||||
|
||||
const containers: ContainerInfo[] = []
|
||||
|
||||
for (const c of containersRes.data) {
|
||||
const rawImage: string = c.Image ?? ''
|
||||
const containerName: string = (c.Names?.[0] ?? '').replace(/^\//, '')
|
||||
|
||||
// Get repo digests from image inspect
|
||||
let repoDigest: string | null = null
|
||||
for (const endpoint of endpoints) {
|
||||
let endpointContainers: Record<string, unknown>[] = []
|
||||
try {
|
||||
const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, {
|
||||
socketPath: DOCKER_SOCKET,
|
||||
timeout: 5000,
|
||||
const cRes = await axios.get(
|
||||
`${host}/api/endpoints/${endpoint.Id}/docker/containers/json`,
|
||||
{ headers, timeout: 8000 }
|
||||
)
|
||||
endpointContainers = cRes.data
|
||||
} catch { continue }
|
||||
|
||||
for (const c of endpointContainers) {
|
||||
const rawImage = (c.Image as string) ?? ''
|
||||
const containerName = ((c.Names as string[])?.[0] ?? '').replace(/^\//, '')
|
||||
|
||||
// Get repo digests via image inspect
|
||||
let repoDigest: string | null = null
|
||||
try {
|
||||
const imgRes = await axios.get(
|
||||
`${host}/api/endpoints/${endpoint.Id}/docker/images/${c.ImageID}/json`,
|
||||
{ headers, timeout: 8000 }
|
||||
)
|
||||
const digests: string[] = imgRes.data.RepoDigests ?? []
|
||||
const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0]))
|
||||
repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const { registry, name, tag } = parseImage(rawImage)
|
||||
const isDockerHub = registry === 'docker.io'
|
||||
|
||||
let upToDate: boolean | null = null
|
||||
if (isDockerHub && repoDigest) {
|
||||
const latest = await getLatestDigest(name, tag)
|
||||
if (latest) upToDate = latest === repoDigest
|
||||
}
|
||||
|
||||
containers.push({
|
||||
name: containerName,
|
||||
image: rawImage.split(':')[0].split('/').slice(-1)[0],
|
||||
tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest',
|
||||
repoDigest,
|
||||
upToDate,
|
||||
registry,
|
||||
endpoint: endpoint.Name,
|
||||
})
|
||||
const digests: string[] = imgRes.data.RepoDigests ?? []
|
||||
const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0]))
|
||||
repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const { registry, name, tag } = parseImage(rawImage)
|
||||
const isDockerHub = registry === 'docker.io'
|
||||
|
||||
let upToDate: boolean | null = null
|
||||
if (isDockerHub && repoDigest) {
|
||||
const latest = await getLatestDigest(name, tag)
|
||||
if (latest) upToDate = latest === repoDigest
|
||||
}
|
||||
|
||||
containers.push({
|
||||
name: containerName,
|
||||
image: rawImage.split(':')[0].split('/').slice(-1)[0], // short name
|
||||
tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest',
|
||||
repoDigest,
|
||||
upToDate,
|
||||
registry,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ containers })
|
||||
|
||||
Reference in New Issue
Block a user