feat: rollback if patch exists before applying

This commit is contained in:
Eli Bosley
2025-02-04 10:15:08 -05:00
parent 0cb0fc9881
commit a12181a5e0
5 changed files with 83 additions and 18 deletions

View File

@@ -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}`,
};
}
} }
} }

View File

@@ -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();
});
}); });