Agent+MultiServer
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 7070
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Stackpulse Agent
|
||||
|
||||
Der Stackpulse Agent stellt Docker- und Host-Metadaten über eine REST-API bereit. Er verbindet sich lokal über `/var/run/docker.sock`, validiert alle Anfragen per `X-Agent-Token` und läuft standardmäßig auf Port `7070`.
|
||||
|
||||
## Start lokal
|
||||
1. Abhängigkeiten installieren: `npm install`
|
||||
2. Starten: `AGENT_TOKEN=<token> npm start`
|
||||
3. Optional: Port via `AGENT_PORT` anpassen.
|
||||
|
||||
## Als Docker-Container
|
||||
```
|
||||
docker build -t stackpulse-agent ./agent
|
||||
docker run -d \
|
||||
-p 7070:7070 \
|
||||
-e AGENT_TOKEN=<token> \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
stackpulse-agent
|
||||
```
|
||||
|
||||
Der Agent liest nur Docker-Daten und verändert keine Container oder Images.
|
||||
|
||||
## Stack-Image-Checks (CE-/BE-Parität)
|
||||
Endpoint: `GET /stack-images?checkUpdates=true|false`
|
||||
- Liefert pro Stack (Compose: `com.docker.compose.project`, Swarm: `com.docker.stack.namespace`) die verwendeten Images.
|
||||
- Optional `checkUpdates=true` für lokalen/remote Digest-Vergleich (`updateAvailable`).
|
||||
|
||||
### Portainer-Version
|
||||
- `GET /portainer/version` – liest den laufenden Portainer-Container (Image/Tag/Labels) aus, ermittelt lokale Digest und vergleicht mit Registry-Digest (Update-Indikator). Funktioniert ohne Portainer-API.
|
||||
|
||||
Optionale Registry-Creds (für private Registries):
|
||||
- `REGISTRY_TOKEN` (Bearer)
|
||||
- oder `REGISTRY_USERNAME` + `REGISTRY_PASSWORD` (Basic)
|
||||
|
||||
Hinweis: Für Swarm-Stacks muss der Agent Swarm-Services lesen können (`docker.listServices`). Ohne Swarm-Manager-Rolle bleibt die Swarm-Liste leer.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "stackpulse-agent",
|
||||
"version": "0.0.1",
|
||||
"description": "Stackpulse Agent for Docker metadata and update checks",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dockerode": "^4.0.2",
|
||||
"express": "^4.19.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const express = require('express');
|
||||
const Docker = require('dockerode');
|
||||
|
||||
const infoRoutes = require('./routes/info');
|
||||
const containerRoutes = require('./routes/containers');
|
||||
const imageRoutes = require('./routes/images');
|
||||
const stackRoutes = require('./routes/stacks');
|
||||
const portainerRoutes = require('./routes/portainer');
|
||||
|
||||
const AGENT_PORT = parseInt(process.env.AGENT_PORT || '7070', 10);
|
||||
const AGENT_TOKEN = process.env.AGENT_TOKEN;
|
||||
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
|
||||
|
||||
const app = express();
|
||||
const docker = new Docker({ socketPath: DOCKER_SOCKET });
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
if (!AGENT_TOKEN) {
|
||||
// Keep the server running to allow late injection, but warn loudly.
|
||||
console.warn('AGENT_TOKEN is not set. All requests will be rejected until it is configured.');
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const token = req.header('X-Agent-Token');
|
||||
|
||||
if (!token || token !== AGENT_TOKEN) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
app.use(infoRoutes(docker));
|
||||
app.use(containerRoutes(docker));
|
||||
app.use(imageRoutes(docker));
|
||||
app.use(stackRoutes(docker));
|
||||
app.use(portainerRoutes(docker));
|
||||
|
||||
app.use((req, res) => res.status(404).json({ error: 'Not found' }));
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(AGENT_PORT, () => {
|
||||
console.log(`Stackpulse Agent listening on port ${AGENT_PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
|
||||
module.exports = function containerRoutes(docker) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/containers', async (req, res, next) => {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const payload = containers.map((container) => ({
|
||||
id: container.Id,
|
||||
name: Array.isArray(container.Names) && container.Names.length > 0
|
||||
? container.Names[0].replace(/^\//, '')
|
||||
: null,
|
||||
status: container.Status,
|
||||
image: container.Image,
|
||||
ports: container.Ports,
|
||||
labels: container.Labels,
|
||||
}));
|
||||
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/stacks', async (req, res, next) => {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
|
||||
const stacks = containers.reduce((acc, container) => {
|
||||
const composeProject = container.Labels?.['com.docker.compose.project'];
|
||||
const swarmStack = container.Labels?.['com.docker.stack.namespace'];
|
||||
const stackName = composeProject || swarmStack || 'unassigned';
|
||||
|
||||
if (!acc[stackName]) {
|
||||
acc[stackName] = [];
|
||||
}
|
||||
|
||||
acc[stackName].push({
|
||||
id: container.Id,
|
||||
name: Array.isArray(container.Names) && container.Names.length > 0
|
||||
? container.Names[0].replace(/^\//, '')
|
||||
: null,
|
||||
status: container.Status,
|
||||
image: container.Image,
|
||||
ports: container.Ports,
|
||||
labels: container.Labels,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const payload = Object.entries(stacks).map(([name, stackContainers]) => ({
|
||||
name,
|
||||
containers: stackContainers,
|
||||
}));
|
||||
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/container/:id', async (req, res, next) => {
|
||||
try {
|
||||
const container = docker.getContainer(req.params.id);
|
||||
const inspect = await container.inspect();
|
||||
|
||||
res.json(inspect);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const express = require('express');
|
||||
const { checkImageUpdate, getLocalImageInfo } = require('../services/registryService');
|
||||
|
||||
module.exports = function imageRoutes(docker) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/images', async (req, res, next) => {
|
||||
try {
|
||||
const images = await docker.listImages();
|
||||
const payload = images.map((image) => ({
|
||||
repoTags: image.RepoTags || [],
|
||||
size: image.Size,
|
||||
id: image.Id,
|
||||
created: image.Created,
|
||||
}));
|
||||
|
||||
res.json(payload);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Accept full image name with slashes/colons: /image/check/<image> (URL-encoded)
|
||||
router.get('/image/check/*', async (req, res) => {
|
||||
const raw = req.params[0] || req.params.name || '';
|
||||
if (!raw) {
|
||||
return res.status(400).json({ error: 'invalid_image' });
|
||||
}
|
||||
const imageName = decodeURIComponent(raw);
|
||||
|
||||
try {
|
||||
const result = await checkImageUpdate(docker, imageName);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
res.status(status).json({ error: err.code || err.message || 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
const express = require('express');
|
||||
const os = require('os');
|
||||
|
||||
module.exports = function infoRoutes(docker) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/info', async (req, res, next) => {
|
||||
try {
|
||||
const [dockerVersion, containers, images] = await Promise.all([
|
||||
docker.version(),
|
||||
docker.listContainers({ all: true }),
|
||||
docker.listImages(),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
dockerVersion: dockerVersion.Version,
|
||||
hostname: os.hostname(),
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
containerCount: containers.length,
|
||||
imageCount: images.length,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/events', (req, res) => {
|
||||
res.json({ message: 'Events endpoint not implemented yet' });
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
const express = require('express');
|
||||
const { checkImageUpdate } = require('../services/registryService');
|
||||
|
||||
function extractTag(imageName) {
|
||||
const parts = imageName.split(':');
|
||||
return parts.length > 1 ? parts[parts.length - 1] : 'latest';
|
||||
}
|
||||
|
||||
function pickPortainerContainer(containers) {
|
||||
return containers.find((container) => {
|
||||
const labels = container.Labels || {};
|
||||
if (labels['io.portainer.container']) return true;
|
||||
const image = container.Image || '';
|
||||
return /portainer/i.test(image);
|
||||
});
|
||||
}
|
||||
|
||||
async function getPortainerVersionInfo(docker) {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const portainer = pickPortainerContainer(containers);
|
||||
if (!portainer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const imageName = portainer.Image;
|
||||
const tag = extractTag(imageName);
|
||||
let version = null;
|
||||
let localDigest = null;
|
||||
let remoteDigest = null;
|
||||
let updateAvailable = false;
|
||||
|
||||
try {
|
||||
const inspect = await docker.getImage(imageName).inspect();
|
||||
const labels = inspect?.Config?.Labels || {};
|
||||
version = labels['org.opencontainers.image.version'] || labels.version || tag;
|
||||
const repoDigests = inspect?.RepoDigests || [];
|
||||
localDigest = repoDigests.length ? repoDigests[0].split('@')[1] || repoDigests[0] : null;
|
||||
} catch {
|
||||
version = tag;
|
||||
localDigest = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await checkImageUpdate(docker, imageName);
|
||||
remoteDigest = info.remoteDigest;
|
||||
localDigest = info.localDigest || localDigest;
|
||||
updateAvailable = info.updateAvailable;
|
||||
} catch {
|
||||
remoteDigest = null;
|
||||
updateAvailable = false;
|
||||
}
|
||||
|
||||
return {
|
||||
containerId: portainer.Id,
|
||||
image: imageName,
|
||||
tag,
|
||||
version,
|
||||
localDigest,
|
||||
remoteDigest,
|
||||
updateAvailable
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function portainerVersionRoutes(docker) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/portainer/version', async (req, res, next) => {
|
||||
try {
|
||||
const info = await getPortainerVersionInfo(docker);
|
||||
if (!info) {
|
||||
return res.status(404).json({ error: 'Portainer container not found' });
|
||||
}
|
||||
res.json(info);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
const express = require('express');
|
||||
const { checkImageUpdate, getLocalImageInfo } = require('../services/registryService');
|
||||
|
||||
async function getComposeStacks(docker) {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const stacks = containers.reduce((acc, container) => {
|
||||
const composeProject = container.Labels?.['com.docker.compose.project'];
|
||||
if (!composeProject) return acc;
|
||||
|
||||
if (!acc[composeProject]) {
|
||||
acc[composeProject] = {
|
||||
name: composeProject,
|
||||
type: 'compose',
|
||||
images: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
acc[composeProject].images.set(container.Image, {
|
||||
source: 'container',
|
||||
name: container.Image
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(stacks);
|
||||
}
|
||||
|
||||
async function getSwarmStacks(docker) {
|
||||
try {
|
||||
const services = await docker.listServices();
|
||||
const stacks = services.reduce((acc, service) => {
|
||||
const stackNs = service.Spec?.Labels?.['com.docker.stack.namespace'];
|
||||
if (!stackNs) return acc;
|
||||
|
||||
if (!acc[stackNs]) {
|
||||
acc[stackNs] = {
|
||||
name: stackNs,
|
||||
type: 'swarm',
|
||||
images: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const image = service.Spec?.TaskTemplate?.ContainerSpec?.Image;
|
||||
if (image) {
|
||||
acc[stackNs].images.set(image, {
|
||||
source: service.Spec?.Name || 'service',
|
||||
name: image
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.values(stacks);
|
||||
} catch (err) {
|
||||
// Not a swarm or no permissions; just return empty
|
||||
if (err.statusCode === 503 || /This node is not a swarm manager/i.test(err.message || '')) {
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichImagesWithDigests(docker, imageEntries) {
|
||||
return Promise.all(imageEntries.map(async (entry) => {
|
||||
try {
|
||||
return await checkImageUpdate(docker, entry.name);
|
||||
} catch (err) {
|
||||
const status = err.status || 500;
|
||||
return {
|
||||
...entry,
|
||||
localDigest: null,
|
||||
remoteDigest: null,
|
||||
updateAvailable: false,
|
||||
error: err.code || err.message || `update_check_failed_${status}`
|
||||
};
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = function stackRoutes(docker) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/stack-images', async (req, res, next) => {
|
||||
const withChecks = req.query.checkUpdates === 'true';
|
||||
|
||||
try {
|
||||
const [composeStacks, swarmStacks] = await Promise.all([
|
||||
getComposeStacks(docker),
|
||||
getSwarmStacks(docker)
|
||||
]);
|
||||
|
||||
const combined = [...composeStacks, ...swarmStacks];
|
||||
|
||||
const result = await Promise.all(combined.map(async (stack) => {
|
||||
const images = Array.from(stack.images.values());
|
||||
if (!withChecks) {
|
||||
return { name: stack.name, type: stack.type, images };
|
||||
}
|
||||
|
||||
const enriched = await enrichImagesWithDigests(docker, images);
|
||||
return { name: stack.name, type: stack.type, images: enriched };
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Always return local/remote digests with error details when resolution fails
|
||||
router.get('/stack-images/status', async (req, res, next) => {
|
||||
try {
|
||||
const [composeStacks, swarmStacks] = await Promise.all([
|
||||
getComposeStacks(docker),
|
||||
getSwarmStacks(docker)
|
||||
]);
|
||||
|
||||
const combined = [...composeStacks, ...swarmStacks];
|
||||
|
||||
const result = await Promise.all(combined.map(async (stack) => {
|
||||
const images = Array.from(stack.images.values());
|
||||
const enriched = await enrichImagesWithDigests(docker, images);
|
||||
return { name: stack.name, type: stack.type, images: enriched };
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -0,0 +1,372 @@
|
||||
const DEFAULT_REGISTRY = 'dockerhub';
|
||||
const GHCR_HOST = 'ghcr.io';
|
||||
const DOCKERHUB_HOST = 'registry-1.docker.io';
|
||||
const ACCEPT_HEADERS = [
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json'
|
||||
];
|
||||
|
||||
function createError(status, code, message) {
|
||||
const err = new Error(message || code);
|
||||
err.status = status;
|
||||
err.code = code;
|
||||
return err;
|
||||
}
|
||||
|
||||
function detectRegistry(imageName = '') {
|
||||
if (imageName.startsWith(`${GHCR_HOST}/`)) return 'ghcr';
|
||||
if (!imageName.includes('/')) return 'dockerhub';
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
function splitImageIntoComponents(imageName) {
|
||||
if (!imageName) throw createError(400, 'invalid_image', 'Image name required');
|
||||
|
||||
const registryType = detectRegistry(imageName);
|
||||
let registryHost;
|
||||
let remainder = imageName;
|
||||
|
||||
if (registryType === 'ghcr') {
|
||||
registryHost = GHCR_HOST;
|
||||
remainder = imageName.replace(`${GHCR_HOST}/`, '');
|
||||
} else if (registryType === 'dockerhub') {
|
||||
registryHost = DOCKERHUB_HOST;
|
||||
if (!imageName.includes('/')) {
|
||||
remainder = `library/${imageName}`;
|
||||
} else if (imageName.startsWith('docker.io/')) {
|
||||
remainder = imageName.replace('docker.io/', '');
|
||||
} else if (imageName.startsWith(`${DOCKERHUB_HOST}/`)) {
|
||||
remainder = imageName.replace(`${DOCKERHUB_HOST}/`, '');
|
||||
}
|
||||
} else {
|
||||
const firstSlash = imageName.indexOf('/');
|
||||
registryHost = imageName.slice(0, firstSlash);
|
||||
remainder = imageName.slice(firstSlash + 1);
|
||||
}
|
||||
|
||||
const digestIndex = remainder.indexOf('@');
|
||||
let repoPart = remainder;
|
||||
let digest = null;
|
||||
if (digestIndex !== -1) {
|
||||
repoPart = remainder.slice(0, digestIndex);
|
||||
digest = remainder.slice(digestIndex + 1);
|
||||
}
|
||||
|
||||
const lastColon = repoPart.lastIndexOf(':');
|
||||
let tag = 'latest';
|
||||
let repository = repoPart;
|
||||
if (lastColon > -1 && repoPart.indexOf('/') < lastColon) {
|
||||
tag = repoPart.slice(lastColon + 1);
|
||||
repository = repoPart.slice(0, lastColon);
|
||||
}
|
||||
|
||||
return {
|
||||
registryType,
|
||||
registryHost,
|
||||
repository,
|
||||
tag,
|
||||
digest,
|
||||
fullImage: imageName
|
||||
};
|
||||
}
|
||||
|
||||
async function getLocalImageInfo(docker, imageName, preferredTag = null) {
|
||||
try {
|
||||
const image = docker.getImage(imageName);
|
||||
const inspect = await image.inspect();
|
||||
const repoTags = inspect?.RepoTags || [];
|
||||
const repoDigests = inspect?.RepoDigests || [];
|
||||
|
||||
let localTagFull = repoTags.length ? repoTags[0] : null;
|
||||
let localDigestFull = repoDigests.length ? repoDigests[0] : null;
|
||||
|
||||
// Fallback: search listImages for matching repoTag when digest is missing
|
||||
if (!localDigestFull) {
|
||||
const images = await docker.listImages();
|
||||
const matchByTag = images.find((img) => (img.RepoTags || []).includes(imageName));
|
||||
const normalizedRepo = imageName.includes(':') ? imageName.split(':')[0] : imageName;
|
||||
const matchByRepo = images.find((img) => (img.RepoTags || []).some((t) => t.startsWith(`${normalizedRepo}:`)));
|
||||
const candidate = matchByTag || matchByRepo;
|
||||
if (candidate) {
|
||||
localDigestFull = (candidate.RepoDigests || [])[0] || null;
|
||||
localTagFull = (candidate.RepoTags || [])[0] || localTagFull;
|
||||
}
|
||||
}
|
||||
|
||||
let localTag = preferredTag || null;
|
||||
if (!localTag) {
|
||||
localTag = localTagFull && localTagFull.includes(':')
|
||||
? localTagFull.split(':').pop()
|
||||
: null;
|
||||
}
|
||||
const localDigestHash = localDigestFull && localDigestFull.includes('@')
|
||||
? localDigestFull.split('@')[1]
|
||||
: localDigestFull;
|
||||
|
||||
return { localTag, localDigestFull, localDigestHash };
|
||||
} catch (err) {
|
||||
throw createError(404, 'image_not_found', `Local image not found: ${imageName}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildBasicAuthHeader() {
|
||||
const username = process.env.REGISTRY_USERNAME || process.env.GITHUB_USERNAME;
|
||||
const password = process.env.REGISTRY_PASSWORD || process.env.GITHUB_TOKEN || process.env.GITHUB_PAT;
|
||||
if (!username || !password) return {};
|
||||
const base = Buffer.from(`${username}:${password}`, 'utf8').toString('base64');
|
||||
return { Authorization: `Basic ${base}` };
|
||||
}
|
||||
|
||||
function buildBearerHeader() {
|
||||
const token = process.env.REGISTRY_TOKEN;
|
||||
if (!token) return {};
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function fetchRemoteManifest(registryHost, repository, reference, headers) {
|
||||
const url = `https://${registryHost}/v2/${repository}/manifests/${reference}`;
|
||||
const res = await fetch(url, { headers });
|
||||
const body = await res.text();
|
||||
return { res, body };
|
||||
}
|
||||
|
||||
async function fetchRegistryTokenDockerHub(repository) {
|
||||
const headers = {
|
||||
Accept: ACCEPT_HEADERS.join(', '),
|
||||
...buildBearerHeader(),
|
||||
...buildBasicAuthHeader(),
|
||||
};
|
||||
|
||||
const url = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw createError(res.status === 401 ? 403 : res.status, 'token_error', `Failed to fetch Docker Hub token: ${res.status} ${body || ''}`.trim());
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.token) {
|
||||
throw createError(403, 'token_error', 'Token missing in Docker Hub response');
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function fetchAnonymousTokenDockerHub(repository) {
|
||||
const headers = {
|
||||
Accept: ACCEPT_HEADERS.join(', ')
|
||||
};
|
||||
const url = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`;
|
||||
const res = await fetch(url, { headers });
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw createError(res.status === 401 ? 403 : res.status, 'token_error', `Failed to fetch Docker Hub token anonymously: ${res.status} ${body || ''}`.trim());
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.token) {
|
||||
throw createError(403, 'token_error', 'Token missing in Docker Hub anonymous response');
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
async function resolveRemoteDigestDockerHub(repository, tag, localDigestHash) {
|
||||
const accept = { Accept: ACCEPT_HEADERS.join(', ') };
|
||||
let token;
|
||||
try {
|
||||
token = await fetchRegistryTokenDockerHub(repository);
|
||||
} catch {
|
||||
token = null;
|
||||
}
|
||||
let headers = token ? { ...accept, Authorization: `Bearer ${token}` } : { ...accept };
|
||||
|
||||
let { res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, tag, headers);
|
||||
if (res.status === 404 && localDigestHash) {
|
||||
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, localDigestHash, headers));
|
||||
}
|
||||
|
||||
if ((res.status === 401 || res.status === 403) && !token) {
|
||||
try {
|
||||
const anonToken = await fetchAnonymousTokenDockerHub(repository);
|
||||
headers = { ...accept, Authorization: `Bearer ${anonToken}` };
|
||||
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, tag, headers));
|
||||
if (res.status === 404 && localDigestHash) {
|
||||
({ res, body } = await fetchRemoteManifest(DOCKERHUB_HOST, repository, localDigestHash, headers));
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw createError(404, 'tag_not_found', 'Tag not found on registry');
|
||||
if (res.status === 401 || res.status === 403) throw createError(403, 'auth_required', body || 'Unauthorized');
|
||||
throw createError(502, 'registry_error', `Registry responded with ${res.status} ${body || ''}`.trim());
|
||||
}
|
||||
|
||||
const digest = extractDigestFromManifest(body);
|
||||
if (!digest) throw createError(500, 'digest_missing', 'Could not extract digest from manifest');
|
||||
return digest;
|
||||
}
|
||||
|
||||
async function resolveRemoteDigestGeneric(registryHost, repository, tag, localDigestHash) {
|
||||
const accept = { Accept: ACCEPT_HEADERS.join(', ') };
|
||||
const headers = {
|
||||
...accept,
|
||||
...buildBearerHeader(),
|
||||
...buildBasicAuthHeader(),
|
||||
};
|
||||
|
||||
let { res, body } = await fetchRemoteManifest(registryHost, repository, tag, headers);
|
||||
if (res.status === 404 && localDigestHash) {
|
||||
({ res, body } = await fetchRemoteManifest(registryHost, repository, localDigestHash, headers));
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw createError(404, 'tag_not_found', 'Tag not found on registry');
|
||||
if (res.status === 401 || res.status === 403) throw createError(403, 'auth_required', body || 'Unauthorized');
|
||||
throw createError(502, 'registry_error', `Registry responded with ${res.status} ${body || ''}`.trim());
|
||||
}
|
||||
|
||||
const digest = extractDigestFromManifest(body);
|
||||
if (!digest) throw createError(500, 'digest_missing', 'Could not extract digest from manifest');
|
||||
return digest;
|
||||
}
|
||||
|
||||
async function resolveRemoteDigest(registryType, registryHost, repository, tag, localDigestHash) {
|
||||
const fetchDigestViaBuildx = async (ref) => {
|
||||
const { execFile } = require('child_process');
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('docker', ['buildx', 'imagetools', 'inspect', ref], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = stderr || err.message || 'docker buildx imagetools inspect failed';
|
||||
return reject(createError(502, 'registry_error', msg.trim()));
|
||||
}
|
||||
const match = stdout.match(/Digest:\s*(sha256:[a-fA-F0-9]+)/);
|
||||
if (match && match[1]) {
|
||||
return resolve(match[1]);
|
||||
}
|
||||
return reject(createError(500, 'digest_missing', 'Could not extract digest from imagetools inspect'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fetchRemoteDigestViaDocker = async (ref) => {
|
||||
const { execFile } = require('child_process');
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile('docker', ['manifest', 'inspect', ref], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
const msg = stderr || err.message || 'docker manifest inspect failed';
|
||||
return reject(createError(502, 'registry_error', msg.trim()));
|
||||
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(stdout);
|
||||
if (Array.isArray(json.manifests) && json.manifests.length > 0) {
|
||||
const candidate = json.manifests[0];
|
||||
if (candidate?.digest) {
|
||||
return resolve(candidate.digest);
|
||||
}
|
||||
}
|
||||
if (json?.config?.digest) {
|
||||
return resolve(json.config.digest);
|
||||
}
|
||||
if (Array.isArray(json?.layers) && json.layers.length > 0 && json.layers[0].digest) {
|
||||
return resolve(json.layers[0].digest);
|
||||
}
|
||||
} catch {
|
||||
// fall through to error
|
||||
}
|
||||
return reject(createError(500, 'digest_missing', 'Could not extract digest from docker manifest inspect'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (registryType === 'ghcr') {
|
||||
// Use docker buildx imagetools inspect for GHCR: tag first, then digest fallback
|
||||
const host = registryHost || GHCR_HOST;
|
||||
try {
|
||||
const digest = await fetchDigestViaBuildx(`${host}/${repository}:${tag}`);
|
||||
return digest;
|
||||
} catch (err) {
|
||||
if (localDigestHash) {
|
||||
return fetchDigestViaBuildx(`${host}/${repository}@${localDigestHash}`);
|
||||
}
|
||||
throw err.code ? err : createError(502, 'registry_error', err.message || 'GHCR lookup failed');
|
||||
}
|
||||
}
|
||||
if (registryType === 'dockerhub') {
|
||||
const host = 'docker.io';
|
||||
try {
|
||||
return await fetchDigestViaBuildx(`${host}/${repository}:${tag}`);
|
||||
} catch (err) {
|
||||
if (localDigestHash) {
|
||||
try {
|
||||
return await fetchDigestViaBuildx(`${host}/${repository}@${localDigestHash}`);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to manifest inspect/HTTP if buildx fails
|
||||
try {
|
||||
return await fetchRemoteDigestViaDocker(`${host}/${repository}:${tag}`);
|
||||
} catch (err) {
|
||||
if (localDigestHash) {
|
||||
try {
|
||||
return await fetchRemoteDigestViaDocker(`${host}/${repository}@${localDigestHash}`);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveRemoteDigestDockerHub(repository, tag, localDigestHash);
|
||||
}
|
||||
// generic
|
||||
try {
|
||||
return await fetchDigestViaBuildx(`${registryHost}/${repository}:${tag}`);
|
||||
} catch (err) {
|
||||
if (localDigestHash) {
|
||||
try {
|
||||
return await fetchDigestViaBuildx(`${registryHost}/${repository}@${localDigestHash}`);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
// fallback manifest path
|
||||
try {
|
||||
return await fetchRemoteDigestViaDocker(`${registryHost}/${repository}:${tag}`);
|
||||
} catch (err) {
|
||||
if (localDigestHash) {
|
||||
try {
|
||||
return await fetchRemoteDigestViaDocker(`${registryHost}/${repository}@${localDigestHash}`);
|
||||
} catch {
|
||||
// fall through to HTTP
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveRemoteDigestGeneric(registryHost, repository, tag, localDigestHash);
|
||||
}
|
||||
|
||||
async function checkImageUpdate(docker, imageName) {
|
||||
const { registryType, registryHost, repository, tag } = splitImageIntoComponents(imageName);
|
||||
const { localTag, localDigestHash } = await getLocalImageInfo(docker, imageName, tag);
|
||||
|
||||
const remoteDigest = await resolveRemoteDigest(registryType, registryHost, repository, tag, localDigestHash);
|
||||
|
||||
return {
|
||||
image: imageName,
|
||||
localTag,
|
||||
localDigest: localDigestHash,
|
||||
remoteDigest,
|
||||
updateAvailable: Boolean(localDigestHash && remoteDigest && localDigestHash !== remoteDigest)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
detectRegistry,
|
||||
splitImageIntoComponents,
|
||||
getLocalImageInfo,
|
||||
checkImageUpdate,
|
||||
resolveRemoteDigestDockerHub,
|
||||
resolveRemoteDigestGeneric
|
||||
};
|
||||
Reference in New Issue
Block a user