Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 23 additions & 0 deletions src/docker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
13 changes: 7 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -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;
Expand All @@ -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 };
Expand All @@ -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' });
Expand Down
11 changes: 10 additions & 1 deletion tests/docker.test.js
Original file line number Diff line number Diff line change
@@ -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'), {
Expand All @@ -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/);
Expand Down
Loading