Initial Dockge image update checker
All checks were successful
Build / test (push) Successful in 14s

This commit is contained in:
MrSphay
2026-05-14 17:35:56 +02:00
commit 0e0a21f508
18 changed files with 869 additions and 0 deletions

115
src/checker.js Normal file
View File

@@ -0,0 +1,115 @@
import { readdir } from "node:fs/promises";
import path from "node:path";
import { findComposeFile, parseComposeFile } from "./compose-parser.js";
import { DockerEngine } from "./docker-engine.js";
import { digestMatchesRepoDigests, parseImageRef } from "./image-ref.js";
import { RegistryClient } from "./registry-client.js";
export class UpdateChecker {
constructor(options = {}) {
this.stacksDir = options.stacksDir || process.env.STACKS_DIR || "/opt/stacks";
this.docker = options.docker || new DockerEngine(options);
this.registry = options.registry || new RegistryClient();
}
async listStacks() {
const entries = await readdir(this.stacksDir, { withFileTypes: true });
const stacks = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const stackDir = path.join(this.stacksDir, entry.name);
const composeFile = await findComposeFile(stackDir);
if (composeFile) {
stacks.push({
name: entry.name,
dir: stackDir,
composeFile,
});
}
}
return stacks.sort((a, b) => a.name.localeCompare(b.name));
}
async checkAll(filterStack = null) {
const stacks = await this.listStacks();
const selectedStacks = filterStack ? stacks.filter((stack) => stack.name === filterStack) : stacks;
const results = [];
for (const stack of selectedStacks) {
const images = await parseComposeFile(stack.composeFile);
for (const serviceImage of images) {
results.push(await this.checkServiceImage(stack, serviceImage));
}
}
return {
checkedAt: new Date().toISOString(),
stacksDir: this.stacksDir,
summary: summarize(results),
results,
};
}
async checkServiceImage(stack, serviceImage) {
const base = {
stack: stack.name,
service: serviceImage.service,
image: serviceImage.image,
composeFile: stack.composeFile,
line: serviceImage.line,
status: "unknown",
updateAvailable: null,
localDigests: [],
remoteDigest: null,
error: null,
};
try {
const imageRef = parseImageRef(serviceImage.image);
const [localImage, remoteDigest] = await Promise.all([
this.docker.inspectImage(serviceImage.image),
this.registry.getManifestDigest(imageRef),
]);
if (!localImage) {
return {
...base,
status: "local_image_not_found",
updateAvailable: null,
remoteDigest,
normalizedImage: imageRef.registryRef,
};
}
const localDigests = localImage?.RepoDigests || [];
const isCurrent = digestMatchesRepoDigests(remoteDigest, localDigests, imageRef);
return {
...base,
status: isCurrent ? "current" : "update_available",
updateAvailable: !isCurrent,
localImageId: localImage?.Id || null,
localDigests,
remoteDigest,
normalizedImage: imageRef.registryRef,
};
} catch (error) {
return {
...base,
status: "error",
error: error.message,
};
}
}
}
export function summarize(results) {
return {
total: results.length,
updatesAvailable: results.filter((result) => result.updateAvailable === true).length,
unknown: results.filter((result) => result.status === "unknown").length,
errors: results.filter((result) => result.status === "error").length,
};
}

105
src/compose-parser.js Normal file
View File

@@ -0,0 +1,105 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export function composeFileNames() {
return [...COMPOSE_FILES];
}
export async function findComposeFile(stackDir) {
for (const fileName of COMPOSE_FILES) {
const candidate = path.join(stackDir, fileName);
try {
await readFile(candidate, "utf8");
return candidate;
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
}
return null;
}
export function parseComposeImages(content) {
const results = [];
const lines = String(content).split(/\r?\n/);
const serviceStack = [];
let inServices = false;
let servicesIndent = -1;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}
const indent = line.length - line.trimStart().length;
if (/^services\s*:/.test(trimmed)) {
inServices = true;
servicesIndent = indent;
serviceStack.length = 0;
continue;
}
if (inServices && indent <= servicesIndent && /^[A-Za-z0-9_.-]+\s*:/.test(trimmed)) {
inServices = false;
serviceStack.length = 0;
}
if (!inServices) {
continue;
}
const keyMatch = trimmed.match(/^["']?([A-Za-z0-9_.-]+)["']?\s*:\s*(.*)$/);
if (!keyMatch) {
continue;
}
const [, key, value] = keyMatch;
serviceStack[indent] = key;
for (const knownIndent of Object.keys(serviceStack).map(Number)) {
if (knownIndent > indent) {
delete serviceStack[knownIndent];
}
}
if (key !== "image") {
continue;
}
const serviceIndent = Math.max(...Object.keys(serviceStack).map(Number).filter((knownIndent) => knownIndent < indent));
const service = Number.isFinite(serviceIndent) ? serviceStack[serviceIndent] : "unknown";
const image = cleanScalar(value);
if (image) {
results.push({
service,
image,
line: index + 1,
});
}
}
return results;
}
export async function parseComposeFile(filePath) {
const content = await readFile(filePath, "utf8");
return parseComposeImages(content);
}
function cleanScalar(value) {
return String(value || "")
.split(/\s+#/)[0]
.trim()
.replace(/^["']|["']$/g, "");
}

59
src/docker-engine.js Normal file
View File

@@ -0,0 +1,59 @@
import http from "node:http";
export class DockerEngine {
constructor(options = {}) {
this.socketPath = options.socketPath || process.env.DOCKER_SOCKET || "/var/run/docker.sock";
this.host = options.host || process.env.DOCKER_HOST || null;
}
async inspectImage(image) {
return this.requestJson(`/images/${encodeURIComponent(image)}/json`);
}
async requestJson(path) {
const response = await this.request(path);
if (response.statusCode === 404) {
return null;
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw new Error(`Docker API ${path} failed with HTTP ${response.statusCode}: ${response.body}`);
}
return JSON.parse(response.body);
}
request(path) {
return new Promise((resolve, reject) => {
const options = this.requestOptions(path);
const req = http.request(options, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => {
resolve({ statusCode: res.statusCode || 0, headers: res.headers, body });
});
});
req.on("error", reject);
req.end();
});
}
requestOptions(path) {
if (this.host?.startsWith("tcp://")) {
const url = new URL(this.host);
return {
hostname: url.hostname,
port: url.port || 2375,
path,
method: "GET",
};
}
return {
socketPath: this.socketPath,
path,
method: "GET",
};
}
}

49
src/image-ref.js Normal file
View File

@@ -0,0 +1,49 @@
export function parseImageRef(input) {
const raw = String(input || "").trim();
if (!raw) {
throw new Error("image reference is empty");
}
const [withoutDigest] = raw.split("@", 1);
const lastSlash = withoutDigest.lastIndexOf("/");
const lastColon = withoutDigest.lastIndexOf(":");
const hasTag = lastColon > lastSlash;
const tag = hasTag ? withoutDigest.slice(lastColon + 1) : "latest";
const nameWithoutTag = hasTag ? withoutDigest.slice(0, lastColon) : withoutDigest;
const firstSegment = nameWithoutTag.split("/")[0];
const hasRegistry = firstSegment.includes(".") || firstSegment.includes(":") || firstSegment === "localhost";
const registry = hasRegistry ? firstSegment : "registry-1.docker.io";
let repository = hasRegistry ? nameWithoutTag.slice(firstSegment.length + 1) : nameWithoutTag;
if (registry === "registry-1.docker.io" && !repository.includes("/")) {
repository = `library/${repository}`;
}
return {
original: raw,
registry,
repository,
tag,
registryRef: `${registry}/${repository}:${tag}`,
repositoryRef: `${registry}/${repository}`,
};
}
export function digestMatchesRepoDigests(remoteDigest, repoDigests = [], imageRef) {
if (!remoteDigest) {
return false;
}
const candidates = new Set([
`${imageRef.repositoryRef}@${remoteDigest}`,
`${imageRef.repository}@${remoteDigest}`,
]);
return repoDigests.some((digest) => {
if (digest === remoteDigest || digest.endsWith(`@${remoteDigest}`)) {
return true;
}
return candidates.has(digest);
});
}

104
src/registry-client.js Normal file
View File

@@ -0,0 +1,104 @@
const MANIFEST_ACCEPT = [
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.docker.distribution.manifest.v2+json",
].join(", ");
export class RegistryClient {
constructor(fetchImpl = globalThis.fetch) {
this.fetch = fetchImpl;
}
async getManifestDigest(imageRef) {
const url = this.manifestUrl(imageRef);
let response = await this.fetch(url, {
method: "HEAD",
headers: {
Accept: MANIFEST_ACCEPT,
},
});
if (response.status === 405) {
response = await this.fetch(url, {
method: "GET",
headers: {
Accept: MANIFEST_ACCEPT,
},
});
}
if (response.status === 401) {
const token = await this.getBearerToken(response.headers.get("www-authenticate"), imageRef);
response = await this.fetch(url, {
method: "HEAD",
headers: {
Accept: MANIFEST_ACCEPT,
Authorization: `Bearer ${token}`,
},
});
if (response.status === 405) {
response = await this.fetch(url, {
method: "GET",
headers: {
Accept: MANIFEST_ACCEPT,
Authorization: `Bearer ${token}`,
},
});
}
}
if (!response.ok) {
throw new Error(`Registry check failed for ${imageRef.original}: HTTP ${response.status}`);
}
const digest = response.headers.get("docker-content-digest");
if (!digest) {
throw new Error(`Registry did not return a Docker-Content-Digest header for ${imageRef.original}`);
}
return digest;
}
manifestUrl(imageRef) {
return `https://${imageRef.registry}/v2/${imageRef.repository}/manifests/${encodeURIComponent(imageRef.tag)}`;
}
async getBearerToken(wwwAuthenticate, imageRef) {
const challenge = parseBearerChallenge(wwwAuthenticate);
const tokenUrl = new URL(challenge.realm);
if (challenge.service) {
tokenUrl.searchParams.set("service", challenge.service);
}
tokenUrl.searchParams.set("scope", challenge.scope || `repository:${imageRef.repository}:pull`);
const response = await this.fetch(tokenUrl);
if (!response.ok) {
throw new Error(`Registry token request failed for ${imageRef.original}: HTTP ${response.status}`);
}
const body = await response.json();
const token = body.token || body.access_token;
if (!token) {
throw new Error(`Registry token response did not include a token for ${imageRef.original}`);
}
return token;
}
}
export function parseBearerChallenge(header) {
if (!header || !header.toLowerCase().startsWith("bearer ")) {
throw new Error("Registry did not return a bearer authentication challenge");
}
const params = {};
const input = header.slice("bearer ".length);
const matcher = /([a-zA-Z_]+)="([^"]*)"/g;
let match;
while ((match = matcher.exec(input)) !== null) {
params[match[1]] = match[2];
}
if (!params.realm) {
throw new Error("Registry bearer challenge did not include a realm");
}
return params;
}

94
src/server.js Normal file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env node
import http from "node:http";
import { UpdateChecker } from "./checker.js";
const port = Number(process.env.PORT || 8080);
const intervalSeconds = Number(process.env.CHECK_INTERVAL_SECONDS || 3600);
async function main() {
const checker = new UpdateChecker();
const [command] = process.argv.slice(2);
if (command === "check") {
const report = await checker.checkAll(readArg("--stack"));
if (process.argv.includes("--json")) {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
return;
}
printHumanReport(report);
return;
}
let cache = null;
let cacheError = null;
async function refresh() {
try {
cache = await checker.checkAll();
cacheError = null;
} catch (error) {
cacheError = error;
}
}
await refresh();
setInterval(refresh, Math.max(30, intervalSeconds) * 1000).unref();
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname === "/healthz") {
sendJson(res, 200, { ok: true, cacheReady: Boolean(cache), cacheError: cacheError?.message || null });
return;
}
if (url.pathname === "/api/stacks") {
sendJson(res, 200, { stacks: await checker.listStacks() });
return;
}
if (url.pathname === "/api/check") {
const stack = url.searchParams.get("stack");
const report = stack ? await checker.checkAll(stack) : cache || await checker.checkAll();
sendJson(res, 200, report);
return;
}
sendJson(res, 404, { error: "not found" });
} catch (error) {
sendJson(res, 500, { error: error.message });
}
});
server.listen(port, () => {
process.stdout.write(`dockge-image-update-checker listening on :${port}\n`);
});
}
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(`${JSON.stringify(body, null, 2)}\n`);
}
function readArg(name) {
const index = process.argv.indexOf(name);
if (index === -1) {
return null;
}
return process.argv[index + 1] || null;
}
function printHumanReport(report) {
for (const result of report.results) {
const marker = result.updateAvailable ? "UPDATE" : result.status.toUpperCase();
process.stdout.write(`${marker} ${result.stack}/${result.service} ${result.image}\n`);
}
}
main().catch((error) => {
process.stderr.write(`${error.stack || error.message}\n`);
process.exitCode = 1;
});