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