Initial Dockge image update checker
All checks were successful
Build / test (push) Successful in 14s
All checks were successful
Build / test (push) Successful in 14s
This commit is contained in:
115
src/checker.js
Normal file
115
src/checker.js
Normal 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
105
src/compose-parser.js
Normal 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
59
src/docker-engine.js
Normal 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
49
src/image-ref.js
Normal 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
104
src/registry-client.js
Normal 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
94
src/server.js
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user