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:
33
.codex/project.md
Normal file
33
.codex/project.md
Normal 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.
|
||||
26
.gitea/workflows/build.yml
Normal file
26
.gitea/workflows/build.yml
Normal 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
44
.gitignore
vendored
Normal 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
33
AGENTS.md
Normal 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
6
CHANGELOG.md
Normal 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
15
Dockerfile
Normal 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
79
README.md
Normal 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
11
SECURITY.md
Normal 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
13
compose.example.yaml
Normal 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
20
package.json
Normal 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
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;
|
||||
});
|
||||
35
test/compose-parser.test.js
Normal file
35
test/compose-parser.test.js
Normal 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
28
test/image-ref.test.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user