mirror of
https://github.com/unraid/api.git
synced 2026-01-01 14:10:10 -06:00
feat: rollback if patch exists before applying
This commit is contained in:
@@ -4,7 +4,6 @@ import { access, readFile, unlink, writeFile } from 'fs/promises';
|
|||||||
import { basename, dirname, join } from 'path';
|
import { basename, dirname, join } from 'path';
|
||||||
|
|
||||||
import { applyPatch, parsePatch, reversePatch } from 'diff';
|
import { applyPatch, parsePatch, reversePatch } from 'diff';
|
||||||
import { patch } from 'semver';
|
|
||||||
|
|
||||||
export interface ShouldApplyWithReason {
|
export interface ShouldApplyWithReason {
|
||||||
shouldApply: boolean;
|
shouldApply: boolean;
|
||||||
@@ -28,11 +27,16 @@ export abstract class FileModification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async savePatch(patchResult: string): Promise<void> {
|
private async savePatch(patchResult: string): Promise<void> {
|
||||||
const patchFile = this.getPatchFilePath(this.filePath);
|
const patchFilePath = this.getPatchFilePath(this.filePath);
|
||||||
await writeFile(patchFile, patchResult, 'utf8');
|
await writeFile(patchFilePath, patchResult, 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadSavedPatch(targetFile: string): Promise<string | null> {
|
/**
|
||||||
|
* Loads the applied patch for the target file if it exists
|
||||||
|
* @param targetFile - The path to the file to be patched
|
||||||
|
* @returns The patch contents if it exists (targetFile.patch), null otherwise
|
||||||
|
*/
|
||||||
|
private async loadPatchedFilePatch(targetFile: string): Promise<string | null> {
|
||||||
const patchFile = this.getPatchFilePath(targetFile);
|
const patchFile = this.getPatchFilePath(targetFile);
|
||||||
try {
|
try {
|
||||||
await access(patchFile, constants.R_OK);
|
await access(patchFile, constants.R_OK);
|
||||||
@@ -80,9 +84,13 @@ export abstract class FileModification {
|
|||||||
await writeFile(this.filePath, results);
|
await writeFile(this.filePath, results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default implementation of apply that uses the patch
|
|
||||||
async apply(): Promise<void> {
|
async apply(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
const savedPatch = await this.loadPatchedFilePatch(this.filePath);
|
||||||
|
if (savedPatch) {
|
||||||
|
// Rollback the saved patch before applying the new patch
|
||||||
|
await this.rollback();
|
||||||
|
}
|
||||||
// First attempt to apply the patch that was generated
|
// First attempt to apply the patch that was generated
|
||||||
const staticPatch = await this.getPregeneratedPatch();
|
const staticPatch = await this.getPregeneratedPatch();
|
||||||
if (staticPatch) {
|
if (staticPatch) {
|
||||||
@@ -104,12 +112,11 @@ export abstract class FileModification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update rollback to use the shared utility
|
|
||||||
async rollback(): Promise<void> {
|
async rollback(): Promise<void> {
|
||||||
let patch: string;
|
let patch: string;
|
||||||
|
|
||||||
// Try to load saved patch first
|
// Try to load saved patch first
|
||||||
const savedPatch = await this.loadSavedPatch(this.filePath);
|
const savedPatch = await this.loadPatchedFilePatch(this.filePath);
|
||||||
if (savedPatch) {
|
if (savedPatch) {
|
||||||
this.logger.debug(`Using saved patch file for ${this.id}`);
|
this.logger.debug(`Using saved patch file for ${this.id}`);
|
||||||
patch = savedPatch;
|
patch = savedPatch;
|
||||||
@@ -141,15 +148,37 @@ export abstract class FileModification {
|
|||||||
await access(patchFile, constants.W_OK);
|
await access(patchFile, constants.W_OK);
|
||||||
await unlink(patchFile);
|
await unlink(patchFile);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors when trying to delete the patch file
|
this.logger.warn(`Failed to delete patch file for ${this.id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default implementation that can be overridden if needed
|
// Default implementation that can be overridden if needed
|
||||||
async shouldApply(): Promise<ShouldApplyWithReason> {
|
async shouldApply(): Promise<ShouldApplyWithReason> {
|
||||||
return {
|
try {
|
||||||
shouldApply: true,
|
if (!this.filePath || !this.id) {
|
||||||
reason: 'Default behavior is to always apply modifications',
|
throw new Error('Invalid file modification configuration');
|
||||||
};
|
}
|
||||||
|
|
||||||
|
const fileExists = await access(this.filePath, constants.R_OK | constants.W_OK)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (!fileExists) {
|
||||||
|
return {
|
||||||
|
shouldApply: false,
|
||||||
|
reason: `Target file ${this.filePath} does not exist or is not accessible`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
shouldApply: true,
|
||||||
|
reason: 'Default behavior is to apply modifications if the file exists',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Failed to check if file ${this.filePath} should be applied: ${err}`);
|
||||||
|
return {
|
||||||
|
shouldApply: false,
|
||||||
|
reason: `Failed to check if file ${this.filePath} should be applied: ${err}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1738614986599
|
1738681678475
|
||||||
@@ -1 +1 @@
|
|||||||
1738614986764
|
1738681678771
|
||||||
@@ -1 +1 @@
|
|||||||
1738614987214
|
1738681679144
|
||||||
@@ -2,7 +2,7 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { readFile, writeFile } from 'fs/promises';
|
import { readFile, writeFile } from 'fs/promises';
|
||||||
import { basename, resolve } from 'path';
|
import { basename, resolve } from 'path';
|
||||||
|
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification';
|
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification';
|
||||||
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification';
|
import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification';
|
||||||
@@ -10,13 +10,14 @@ import DefaultPageLayoutModification from '@app/unraid-api/unraid-file-modifier/
|
|||||||
import { LogRotateModification } from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification';
|
import { LogRotateModification } from '@app/unraid-api/unraid-file-modifier/modifications/log-rotate.modification';
|
||||||
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification';
|
import NotificationsPageModification from '@app/unraid-api/unraid-file-modifier/modifications/notifications-page.modification';
|
||||||
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification';
|
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification';
|
||||||
|
import { afterEach } from 'node:test';
|
||||||
|
|
||||||
interface ModificationTestCase {
|
interface ModificationTestCase {
|
||||||
ModificationClass: new (...args: ConstructorParameters<typeof FileModification>) => FileModification;
|
ModificationClass: new (...args: ConstructorParameters<typeof FileModification>) => FileModification;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}
|
}
|
||||||
|
let patcher: FileModification;
|
||||||
const testCases: ModificationTestCase[] = [
|
const testCases: ModificationTestCase[] = [
|
||||||
{
|
{
|
||||||
ModificationClass: DefaultPageLayoutModification,
|
ModificationClass: DefaultPageLayoutModification,
|
||||||
@@ -80,7 +81,7 @@ async function testModification(testCase: ModificationTestCase) {
|
|||||||
const filePath = resolve(__dirname, `../__fixtures__/downloaded/${fileName}`);
|
const filePath = resolve(__dirname, `../__fixtures__/downloaded/${fileName}`);
|
||||||
const originalContent = await downloadOrRetrieveOriginalFile(filePath, testCase.fileUrl);
|
const originalContent = await downloadOrRetrieveOriginalFile(filePath, testCase.fileUrl);
|
||||||
const logger = new Logger();
|
const logger = new Logger();
|
||||||
const patcher = await new testCase.ModificationClass(logger);
|
patcher = await new testCase.ModificationClass(logger);
|
||||||
const originalPath = patcher.filePath;
|
const originalPath = patcher.filePath;
|
||||||
// @ts-expect-error - Ignore for testing purposes
|
// @ts-expect-error - Ignore for testing purposes
|
||||||
patcher.filePath = filePath;
|
patcher.filePath = filePath;
|
||||||
@@ -103,8 +104,43 @@ async function testModification(testCase: ModificationTestCase) {
|
|||||||
await expect(revertedContent).toMatch(originalContent);
|
await expect(revertedContent).toMatch(originalContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testInvalidModification(testCase: ModificationTestCase) {
|
||||||
|
const mockLogger = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
patcher = new testCase.ModificationClass(mockLogger as unknown as Logger);
|
||||||
|
|
||||||
|
// @ts-expect-error - Testing invalid pregenerated patches
|
||||||
|
patcher.getPregeneratedPatch = vi.fn().mockResolvedValue('I AM NOT A VALID PATCH');
|
||||||
|
|
||||||
|
const path = patcher.filePath;
|
||||||
|
const filePath = resolve(__dirname, `../__fixtures__/downloaded/${testCase.fileName}`);
|
||||||
|
|
||||||
|
// @ts-expect-error - Testing invalid pregenerated patches
|
||||||
|
patcher.filePath = filePath;
|
||||||
|
await patcher.apply();
|
||||||
|
|
||||||
|
expect(mockLogger.error.mock.calls[0][0]).toContain(`Failed to apply static patch to ${filePath}`);
|
||||||
|
|
||||||
|
expect(mockLogger.error.mock.calls.length).toBe(1);
|
||||||
|
await patcher.rollback();
|
||||||
|
}
|
||||||
|
|
||||||
describe('File modifications', () => {
|
describe('File modifications', () => {
|
||||||
test.each(testCases)(`$fileName modifier correctly applies to fresh install`, async (testCase) => {
|
test.each(testCases)(`$fileName modifier correctly applies to fresh install`, async (testCase) => {
|
||||||
await testModification(testCase);
|
await testModification(testCase);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each(testCases)(`$fileName modifier correctly handles invalid content`, async (testCase) => {
|
||||||
|
await testInvalidModification(testCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await patcher?.rollback();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user