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

33
.codex/project.md Normal file
View File

@@ -0,0 +1,33 @@
# Project
Name: dockge-image-update-checker
Description: Companion service that checks Dockge stack images for newer registry digests.
Stack: Node.js 20+, Docker, Docker Compose
## Commands
```bash
npm test
npm run build
npm run check
```
## Runtime
Environment variables:
- `STACKS_DIR`: Dockge stacks directory. Defaults to `/opt/stacks`.
- `PORT`: HTTP port. Defaults to `8080`.
- `CHECK_INTERVAL_SECONDS`: background refresh interval. Defaults to `3600`.
- `DOCKER_SOCKET`: Docker socket path. Defaults to `/var/run/docker.sock`.
- `DOCKER_HOST`: optional `tcp://host:port` Docker API endpoint.
## Artifact
Container image from `Dockerfile`.
## Notes
Dockge does not currently expose a stable documented plugin API. This repository is structured as a read-only companion service that can run beside Dockge and inspect the same stack directory plus Docker Engine metadata.

View File

@@ -0,0 +1,26 @@
name: Build
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Test
run: npm test
- name: Build check
run: npm run build

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# Dependencies
node_modules/
vendor/
.venv/
venv/
__pycache__/
# Build outputs
dist/
build/
out/
release/
target/
bin/
obj/
# Logs and temporary files
*.log
*.tmp
*.temp
.cache/
.turbo/
.vite/
# Local environment and secrets
.env
.env.*
!.env.example
*.pem
*.key
*.pfx
*.p12
*.crt
*.cer
*.token
secrets/
# OS and editor files
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*.swo

33
AGENTS.md Normal file
View File

@@ -0,0 +1,33 @@
# Agent Instructions
## Project
dockge-image-update-checker: Companion service that checks Dockge stack images for newer registry digests.
## Repository Rules
- Prefer small, dependency-light changes. The service intentionally uses Node built-ins for Docker Engine and registry access.
- Keep stack scanning read-only. Updating or redeploying stacks is out of scope unless explicitly requested.
- Do not commit secrets, `.env` files, private keys, certificates, registry tokens, or Gitea tokens.
- Check `git status --short` before editing and before finishing. Preserve unrelated user changes.
- Keep `.codex/project.md` aligned with command and architecture changes.
## Commands
```bash
npm test
npm run build
npm run check
```
## Security Notes
- The service needs read access to the Dockge stacks directory and the Docker socket.
- Registry credentials are not stored by the service. Public registry checks use anonymous token flows.
- Mounting `/var/run/docker.sock` is powerful. Run this only on hosts where that operational tradeoff is acceptable.
## Finish Checklist
- `git diff --check` passes.
- `npm test` passes.
- `npm run build` passes.

6
CHANGELOG.md Normal file
View File

@@ -0,0 +1,6 @@
# Changelog
## 0.1.0
- Initial read-only Dockge companion service.
- Added stack compose scanning, registry digest checks, Docker Engine digest comparison, HTTP API, Dockerfile, tests, and Gitea build workflow.

15
Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
COPY src ./src
ENV NODE_ENV=production
ENV PORT=8080
ENV STACKS_DIR=/opt/stacks
ENV CHECK_INTERVAL_SECONDS=3600
EXPOSE 8080
CMD ["node", "src/server.js"]

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# Dockge Image Update Checker
Companion service for Dockge that checks images used by stack compose files against registry manifest digests and reports whether the locally used image digest is behind.
Dockge already has an update button per stack. This project focuses on the missing scheduled/read-only check: it tells you which stacks likely have image updates available without redeploying anything.
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Features
- Scans Dockge stack folders for `compose.yaml`, `compose.yml`, `docker-compose.yaml`, and `docker-compose.yml`.
- Extracts `image:` references from services.
- Queries OCI/Docker Registry v2 manifests and compares remote digests to Docker Engine `RepoDigests`.
- Exposes a small HTTP API for dashboards, notifications, or future Dockge integration.
- Runs without package dependencies.
- Does not pull images, restart containers, or modify compose files.
## API
```text
GET /healthz
GET /api/stacks
GET /api/check
GET /api/check?stack=example
```
`/api/check` returns JSON with one result per service image:
```json
{
"checkedAt": "2026-05-14T12:00:00.000Z",
"stacksDir": "/opt/stacks",
"summary": {
"total": 2,
"updatesAvailable": 1,
"unknown": 0,
"errors": 0
},
"results": []
}
```
<p align="center"><img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/rainbow.png" alt="-----------------------------------------------------" width="100%"></p>
## Docker Compose
```yaml
services:
dockge-image-update-checker:
build: .
container_name: dockge-image-update-checker
restart: unless-stopped
ports:
- "8080:8080"
environment:
STACKS_DIR: /opt/stacks
CHECK_INTERVAL_SECONDS: 3600
volumes:
- /opt/stacks:/opt/stacks:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
```
## Local Development
```bash
npm test
npm run build
npm start
```
For a one-shot JSON check:
```bash
STACKS_DIR=/opt/stacks npm run check
```
## Status
This is a first working implementation. It is intentionally read-only and registry support starts with public registries that follow the standard bearer-token flow.

11
SECURITY.md Normal file
View File

@@ -0,0 +1,11 @@
# Security
Report security issues privately to the repository owner.
## Runtime Access
This service reads Dockge stack files and Docker Engine image metadata. Mounting the Docker socket gives broad host-level power to any process with write access to it, so deploy this service only in trusted environments and keep the socket mount read-only at the container level.
## Secrets
Do not commit registry credentials, Gitea tokens, `.env` files, private keys, certificates, or Docker config files.

13
compose.example.yaml Normal file
View File

@@ -0,0 +1,13 @@
services:
dockge-image-update-checker:
build: .
container_name: dockge-image-update-checker
restart: unless-stopped
ports:
- "8080:8080"
environment:
STACKS_DIR: /opt/stacks
CHECK_INTERVAL_SECONDS: 3600
volumes:
- /opt/stacks:/opt/stacks:ro
- /var/run/docker.sock:/var/run/docker.sock:ro

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "dockge-image-update-checker",
"version": "0.1.0",
"description": "Companion service that checks Dockge stack images for newer registry digests.",
"type": "module",
"main": "src/server.js",
"bin": {
"dockge-image-update-checker": "src/server.js"
},
"scripts": {
"start": "node src/server.js",
"check": "node src/server.js check --json",
"test": "node --test",
"build": "node --check src/server.js && node --check src/checker.js && node --check src/registry-client.js"
},
"engines": {
"node": ">=20"
},
"license": "MIT"
}

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;
});

View File

@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseComposeImages } from "../src/compose-parser.js";
test("extracts service images from compose content", () => {
const compose = `
services:
web:
image: nginx:1.27
api:
build: .
image: "ghcr.io/example/api:main" # comment
networks:
default:
`;
assert.deepEqual(parseComposeImages(compose), [
{ service: "web", image: "nginx:1.27", line: 4 },
{ service: "api", image: "ghcr.io/example/api:main", line: 7 },
]);
});
test("ignores top-level image keys outside services", () => {
const compose = `
x-template:
image: ignored
services:
worker:
image: alpine
`;
assert.deepEqual(parseComposeImages(compose), [
{ service: "worker", image: "alpine", line: 6 },
]);
});

28
test/image-ref.test.js Normal file
View File

@@ -0,0 +1,28 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { digestMatchesRepoDigests, parseImageRef } from "../src/image-ref.js";
test("parses Docker Hub shorthand image references", () => {
assert.deepEqual(parseImageRef("nginx:1.27"), {
original: "nginx:1.27",
registry: "registry-1.docker.io",
repository: "library/nginx",
tag: "1.27",
registryRef: "registry-1.docker.io/library/nginx:1.27",
repositoryRef: "registry-1.docker.io/library/nginx",
});
});
test("parses custom registry image references", () => {
assert.deepEqual(parseImageRef("ghcr.io/example/app:main").registryRef, "ghcr.io/example/app:main");
});
test("defaults missing tags to latest", () => {
assert.equal(parseImageRef("redis").tag, "latest");
});
test("matches remote digests against repo digests", () => {
const imageRef = parseImageRef("nginx:latest");
assert.equal(digestMatchesRepoDigests("sha256:abc", ["registry-1.docker.io/library/nginx@sha256:abc"], imageRef), true);
assert.equal(digestMatchesRepoDigests("sha256:def", ["registry-1.docker.io/library/nginx@sha256:abc"], imageRef), false);
});