From 0e0a21f508bceb4a5536192316dc2ee189277639 Mon Sep 17 00:00:00 2001 From: MrSphay Date: Thu, 14 May 2026 17:35:56 +0200 Subject: [PATCH] Initial Dockge image update checker --- .codex/project.md | 33 +++++++++++ .gitea/workflows/build.yml | 26 ++++++++ .gitignore | 44 ++++++++++++++ AGENTS.md | 33 +++++++++++ CHANGELOG.md | 6 ++ Dockerfile | 15 +++++ README.md | 79 +++++++++++++++++++++++++ SECURITY.md | 11 ++++ compose.example.yaml | 13 ++++ package.json | 20 +++++++ src/checker.js | 115 ++++++++++++++++++++++++++++++++++++ src/compose-parser.js | 105 ++++++++++++++++++++++++++++++++ src/docker-engine.js | 59 ++++++++++++++++++ src/image-ref.js | 49 +++++++++++++++ src/registry-client.js | 104 ++++++++++++++++++++++++++++++++ src/server.js | 94 +++++++++++++++++++++++++++++ test/compose-parser.test.js | 35 +++++++++++ test/image-ref.test.js | 28 +++++++++ 18 files changed, 869 insertions(+) create mode 100644 .codex/project.md create mode 100644 .gitea/workflows/build.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 compose.example.yaml create mode 100644 package.json create mode 100644 src/checker.js create mode 100644 src/compose-parser.js create mode 100644 src/docker-engine.js create mode 100644 src/image-ref.js create mode 100644 src/registry-client.js create mode 100644 src/server.js create mode 100644 test/compose-parser.test.js create mode 100644 test/image-ref.test.js diff --git a/.codex/project.md b/.codex/project.md new file mode 100644 index 0000000..4309ab8 --- /dev/null +++ b/.codex/project.md @@ -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. diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml new file mode 100644 index 0000000..c9c9115 --- /dev/null +++ b/.gitea/workflows/build.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1f2074 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cd8c47c --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d1bd300 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34238c5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..da8b831 --- /dev/null +++ b/README.md @@ -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. + +

-----------------------------------------------------

+ +## 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": [] +} +``` + +

-----------------------------------------------------

+ +## 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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..0795c94 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/compose.example.yaml b/compose.example.yaml new file mode 100644 index 0000000..3068312 --- /dev/null +++ b/compose.example.yaml @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..b21c5fd --- /dev/null +++ b/package.json @@ -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" +} diff --git a/src/checker.js b/src/checker.js new file mode 100644 index 0000000..a31ea49 --- /dev/null +++ b/src/checker.js @@ -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, + }; +} diff --git a/src/compose-parser.js b/src/compose-parser.js new file mode 100644 index 0000000..216f611 --- /dev/null +++ b/src/compose-parser.js @@ -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, ""); +} diff --git a/src/docker-engine.js b/src/docker-engine.js new file mode 100644 index 0000000..a79c38a --- /dev/null +++ b/src/docker-engine.js @@ -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", + }; + } +} diff --git a/src/image-ref.js b/src/image-ref.js new file mode 100644 index 0000000..0564d19 --- /dev/null +++ b/src/image-ref.js @@ -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); + }); +} diff --git a/src/registry-client.js b/src/registry-client.js new file mode 100644 index 0000000..6ca23f9 --- /dev/null +++ b/src/registry-client.js @@ -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; +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..16632b7 --- /dev/null +++ b/src/server.js @@ -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; +}); diff --git a/test/compose-parser.test.js b/test/compose-parser.test.js new file mode 100644 index 0000000..24fb2f1 --- /dev/null +++ b/test/compose-parser.test.js @@ -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 }, + ]); +}); diff --git a/test/image-ref.test.js b/test/image-ref.test.js new file mode 100644 index 0000000..df211b9 --- /dev/null +++ b/test/image-ref.test.js @@ -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); +});