Agent+MultiServer

This commit is contained in:
2025-11-25 16:02:00 +00:00
parent 9b25cb807a
commit fc3ed57a40
26 changed files with 3233 additions and 338 deletions
+12
View File
@@ -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"]
+34
View File
@@ -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.
+13
View File
@@ -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"
}
}
+49
View File
@@ -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}`);
});
+76
View File
@@ -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;
};
+41
View File
@@ -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;
};
+33
View File
@@ -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;
};
+80
View File
@@ -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;
};
+135
View File
@@ -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;
};
+372
View File
@@ -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
};