commit 0e0a21f508bceb4a5536192316dc2ee189277639 Author: MrSphay Date: Thu May 14 17:35:56 2026 +0200 Initial Dockge image update checker 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); +});