diff --git a/CHANGELOG.md b/CHANGELOG.md index 947ec49..35f5855 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Removed the privately operated telemetry receiver and internal statistics dashboard from the public source repository. Client-side opt-in telemetry remains unchanged. +- Fixed rollback and rollback disposal when a locally retained image digest belongs to a different repository alias. New rollback points retain the Docker image ID, while RC12 checkpoints can recover the image by digest. ## 0.9.0-rc.12 diff --git a/src/docker.js b/src/docker.js index c1e9187..ed055d1 100644 --- a/src/docker.js +++ b/src/docker.js @@ -74,6 +74,29 @@ export async function removeUnusedImage(imageReference) { return { imageId: image.Id, removed: true }; } +export function findImageIdByDigest(images, digest) { + if (!digest) return null; + return images.find(image => (image.RepoDigests || []).some(reference => reference.endsWith(`@${digest}`)))?.Id || null; +} + +export async function resolveRollbackImage(checkpoint) { + if (!checkpoint?.imageId && !checkpoint?.image) throw new Error('Rollback-Punkt enthält keine Image-Referenz'); + let missingImageError; + for (const reference of [checkpoint.imageId, checkpoint.image].filter(Boolean)) { + try { + const image = await dockerRequest('GET', `/images/${encodeURIComponent(reference)}/json`); + return image.Id; + } catch (error) { + if (!/Docker GET .*: 404 /.test(error.message)) throw error; + missingImageError = error; + } + } + const digest = checkpoint.image?.split('@')[1]; + const imageId = findImageIdByDigest(await dockerRequest('GET', '/images/json?all=1&digests=1'), digest); + if (imageId) return imageId; + throw missingImageError || new Error('Das lokale Rollback-Image ist nicht mehr vorhanden'); +} + export function digestReference(image, digest) { if (!digest) return null; const { registry, repository } = parseImage(image); diff --git a/src/server.js b/src/server.js index 3d9201d..edcae97 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,7 @@ import https from 'node:https'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { listContainers, replaceContainer, parseImage, localImageDigest, digestReference, tagImage, removeUnusedImage } from './docker.js'; +import { listContainers, replaceContainer, parseImage, localImageDigest, digestReference, tagImage, removeUnusedImage, resolveRollbackImage } from './docker.js'; import { inspectRemote } from './registry.js'; import { loadStore, getStore, saveStore, addEvent, setEventListener } from './store.js'; import { hashPassword, verifyPassword, createSession, readSession, destroySession, destroyUserSessions, sessionCookie, clearSessionCookie } from './auth.js'; @@ -171,7 +171,7 @@ async function scanAll(trigger = 'scheduled', actor = null) { if (!current) throw new Error('Container nicht mehr vorhanden'); const currentLocalDigest = await localImageDigest(current.imageId, current.image); if (currentLocalDigest === remote.currentDigest) return; - const rollback = { image: digestReference(current.image, currentLocalDigest), displayImage: current.image, createdAt: new Date().toISOString() }; + const rollback = { image: digestReference(current.image, currentLocalDigest), imageId: current.imageId, displayImage: current.image, createdAt: new Date().toISOString() }; if (!rollback.image) throw new Error('Rollback-Punkt konnte nicht erstellt werden'); addEvent({ type: 'update-started', container: c.name, image: c.image, result: 'success', message: 'Automatic update started' }); try { await replaceContainer(current.id, current.image); } @@ -377,7 +377,7 @@ async function api(req, res, url, session) { const parsed = parseImage(current.image); const target = data.target === 'latest' ? `${parsed.registry === 'docker.io' ? '' : `${parsed.registry}/`}${parsed.repository}:latest` : current.image; if (data.target === 'latest' && !(await inspectRemote(current.image)).latestExists) throw Object.assign(new Error('Tag latest existiert nicht'), { status: 409 }); const currentDigest = await localImageDigest(current.imageId, current.image); - const rollback = { image: digestReference(current.image, currentDigest), displayImage: current.image, createdAt: new Date().toISOString() }; + const rollback = { image: digestReference(current.image, currentDigest), imageId: current.imageId, displayImage: current.image, createdAt: new Date().toISOString() }; if (!rollback.image) throw Object.assign(new Error('Das aktuelle Image besitzt keinen auflösbaren Digest; Rollback-Punkt kann nicht erstellt werden'), { status: 409 }); addEvent({ type: 'update-started', actor: session.username, container: current.name, image: target, result: 'success', message: data.target === 'latest' ? 'Switch to latest started' : 'Manual update started' }); let replaced; @@ -400,11 +400,12 @@ async function api(req, res, url, session) { const result = await withContainerLock(selected.name, 'Rollback', async () => { const current = (await listContainers()).find(c => c.name === selected.name); if (!current) throw Object.assign(new Error('Container nicht mehr vorhanden'), { status: 404 }); const checkpoint = getStore().rollbacks?.[current.name]; if (!checkpoint?.image) throw Object.assign(new Error('Kein Rollback-Punkt vorhanden'), { status: 409 }); + const rollbackImage = await resolveRollbackImage(checkpoint); const displayTarget = checkpoint.displayImage && !checkpoint.displayImage.includes('@') ? checkpoint.displayImage : null; - if (displayTarget) await tagImage(checkpoint.image, displayTarget); + if (displayTarget) await tagImage(rollbackImage, displayTarget); addEvent({ type: 'rollback-started', actor: session.username, container: current.name, image: checkpoint.image, result: 'success' }); let replaced; - try { replaced = await replaceContainer(current.id, displayTarget || checkpoint.image, { pull: false }); } + try { replaced = await replaceContainer(current.id, displayTarget || rollbackImage, { pull: false }); } catch (error) { recordActionFailure('rollback-failed', session, current.name, checkpoint.image, error); throw error; } delete getStore().rollbacks[current.name]; getStore().lastUpdates[current.name] = { at: new Date().toISOString(), mode: 'manual', type: 'rollback', actor: session.username }; @@ -422,7 +423,7 @@ async function api(req, res, url, session) { const result = await withContainerLock(selected.name, 'Rollback verwerfen', async () => { const checkpoint = getStore().rollbacks?.[selected.name]; if (!checkpoint?.image) throw Object.assign(new Error('Kein Rollback-Punkt vorhanden'), { status: 409 }); - const removed = await removeUnusedImage(checkpoint.image); + const removed = await removeUnusedImage(await resolveRollbackImage(checkpoint)); delete getStore().rollbacks[selected.name]; saveStore(); addEvent({ type: 'rollback-discarded', actor: session.username, container: selected.name, image: checkpoint.image, result: 'success', message: 'Rollback-Punkt und altes Image entfernt' }); diff --git a/tests/docker.test.js b/tests/docker.test.js index a6d6de0..98f7aa2 100644 --- a/tests/docker.test.js +++ b/tests/docker.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseImage, digestReference, validateReplacement, reconcileImageDefaults, assertImageUnused, waitForContainerReady } from '../src/docker.js'; +import { parseImage, digestReference, validateReplacement, reconcileImageDefaults, assertImageUnused, findImageIdByDigest, waitForContainerReady } from '../src/docker.js'; test('parses short Docker Hub images', () => { assert.deepEqual(parseImage('redis:8.0'), { @@ -23,6 +23,15 @@ test('builds immutable rollback references from local digests', () => { assert.equal(digestReference('ghcr.io/example/app:v2', 'sha256:def'), 'ghcr.io/example/app@sha256:def'); }); +test('resolves an existing rollback image by digest even when its repository name changed', () => { + const images = [ + { Id: 'sha256:old-image', RepoDigests: ['lscr.io/linuxserver/heimdall@sha256:3572daf'] }, + { Id: 'sha256:other-image', RepoDigests: ['docker.io/library/redis@sha256:other'] }, + ]; + assert.equal(findImageIdByDigest(images, 'sha256:3572daf'), 'sha256:old-image'); + assert.equal(findImageIdByDigest(images, 'sha256:missing'), null); +}); + test('rejects unsafe container replacement modes', () => { assert.throws(() => validateReplacement({ HostConfig: { AutoRemove: true } }), /AutoRemove/); assert.throws(() => validateReplacement({ HostConfig: { NetworkMode: 'container:abc' } }), /Netzwerkmodus/);