mirror of
https://github.com/unraid/api.git
synced 2026-01-08 01:29:49 -06:00
feat(onboarding): implement upgrade marker for version tracking
- Added functionality to read and write an upgrade marker file, allowing the OnboardingTracker to manage version states more effectively. - Enhanced the OnboardingTracker to infer the last tracked version from the upgrade marker, improving the onboarding experience during version upgrades. - Updated tests to verify the correct behavior of the upgrade marker handling, ensuring that version information is accurately persisted and retrieved. This update enhances the onboarding process by providing a reliable mechanism for tracking version upgrades, improving user experience during transitions between versions.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { access, readdir, readFile } from 'fs/promises';
|
||||
import { access, readdir, readFile, writeFile as writeFileFs } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import type { ApiConfig } from '@unraid/shared/services/api-config.js';
|
||||
@@ -9,7 +9,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js';
|
||||
import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
|
||||
import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js';
|
||||
import {
|
||||
OnboardingTracker,
|
||||
UPGRADE_MARKER_PATH,
|
||||
} from '@app/unraid-api/config/onboarding-tracker.module.js';
|
||||
import { ActivationOnboardingStepId } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
|
||||
|
||||
vi.mock('@app/core/utils/files/file-exists.js', () => ({
|
||||
@@ -24,6 +27,7 @@ vi.mock('fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEmhttpState = { var: { regState: 'PRO' } } as any;
|
||||
@@ -43,6 +47,7 @@ vi.mock('atomically', () => ({
|
||||
const mockReadFile = vi.mocked(readFile);
|
||||
const mockReaddir = vi.mocked(readdir);
|
||||
const mockAccess = vi.mocked(access);
|
||||
const mockWriteFileFs = vi.mocked(writeFileFs);
|
||||
const mockAtomicWriteFile = vi.mocked(atomicWriteFile);
|
||||
type ReaddirResult = Awaited<ReturnType<typeof readdir>>;
|
||||
|
||||
@@ -144,6 +149,8 @@ describe('OnboardingTracker', () => {
|
||||
mockReaddir.mockResolvedValue([] as unknown as ReaddirResult);
|
||||
mockAccess.mockResolvedValue(undefined);
|
||||
mockAtomicWriteFile.mockReset();
|
||||
mockWriteFileFs.mockReset();
|
||||
mockWriteFileFs.mockResolvedValue(undefined);
|
||||
|
||||
mockEmhttpState.var.regState = 'PRO';
|
||||
mockPathsState.activationBase = '/activation';
|
||||
@@ -154,12 +161,17 @@ describe('OnboardingTracker', () => {
|
||||
if (filePath === versionFilePath) {
|
||||
return 'version="7.2.0-beta.3.4"\n';
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.2.0-beta.3.4', 'utf8');
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', '7.2.0-beta.3.4');
|
||||
expect(setMock).toHaveBeenCalledWith('store.emhttp.var.version', '7.2.0-beta.3.4');
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', undefined);
|
||||
@@ -192,12 +204,17 @@ describe('OnboardingTracker', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '6.12.0', 'utf8');
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', '6.12.0');
|
||||
expect(setMock).toHaveBeenCalledWith('store.emhttp.var.version', '6.12.0');
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', '6.12.0');
|
||||
@@ -221,6 +238,9 @@ describe('OnboardingTracker', () => {
|
||||
if (filePath === '/etc/unraid-version') {
|
||||
return 'version="7.3.0"\n';
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
@@ -228,6 +248,7 @@ describe('OnboardingTracker', () => {
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', '7.3.0');
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.3.0', 'utf8');
|
||||
});
|
||||
|
||||
it('keeps previous version available to signal upgrade until shutdown', async () => {
|
||||
@@ -242,12 +263,17 @@ describe('OnboardingTracker', () => {
|
||||
completedSteps: {},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.1.0', 'utf8');
|
||||
|
||||
const snapshot = await tracker.getUpgradeSnapshot();
|
||||
expect(snapshot.currentVersion).toBe('7.1.0');
|
||||
expect(snapshot.lastTrackedVersion).toBe('7.0.0');
|
||||
@@ -277,6 +303,40 @@ describe('OnboardingTracker', () => {
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', undefined);
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.completedSteps', {});
|
||||
expect(mockAtomicWriteFile).not.toHaveBeenCalled();
|
||||
expect(mockWriteFileFs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses upgrade marker when tracker version matches current version', async () => {
|
||||
mockReadFile.mockImplementation(async (filePath) => {
|
||||
if (filePath === versionFilePath) {
|
||||
return 'version="7.2.0"\n';
|
||||
}
|
||||
if (filePath === trackerPath) {
|
||||
return JSON.stringify({
|
||||
lastTrackedVersion: '7.2.0',
|
||||
updatedAt: '2025-01-01T00:00:00.000Z',
|
||||
completedSteps: {},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
return '7.1.0';
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
const snapshot = await tracker.getUpgradeSnapshot();
|
||||
expect(snapshot.currentVersion).toBe('7.2.0');
|
||||
expect(snapshot.lastTrackedVersion).toBe('7.1.0');
|
||||
expect(snapshot.steps.map((step) => step.id)).toEqual([
|
||||
ActivationOnboardingStepId.WELCOME,
|
||||
ActivationOnboardingStepId.TIMEZONE,
|
||||
ActivationOnboardingStepId.PLUGINS,
|
||||
]);
|
||||
expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', '7.1.0');
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.2.0', 'utf8');
|
||||
});
|
||||
|
||||
it('still surfaces onboarding steps when version is unavailable', async () => {
|
||||
@@ -285,6 +345,8 @@ describe('OnboardingTracker', () => {
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).not.toHaveBeenCalled();
|
||||
|
||||
const snapshot = await tracker.getUpgradeSnapshot();
|
||||
expect(snapshot.currentVersion).toBeUndefined();
|
||||
expect(snapshot.steps.map((step) => step.id)).toEqual([
|
||||
@@ -307,12 +369,17 @@ describe('OnboardingTracker', () => {
|
||||
completedSteps: {},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.2.0', 'utf8');
|
||||
|
||||
expect(configStore['store.emhttp.var.version']).toBe('7.2.0');
|
||||
expect(configStore['onboardingTracker.lastTrackedVersion']).toBe('6.12.0');
|
||||
|
||||
@@ -371,12 +438,17 @@ describe('OnboardingTracker', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.0.1', 'utf8');
|
||||
|
||||
const snapshot = await tracker.getUpgradeSnapshot();
|
||||
expect(snapshot.currentVersion).toBe('7.0.1');
|
||||
expect(snapshot.lastTrackedVersion).toBe('7.0.0');
|
||||
@@ -405,12 +477,17 @@ describe('OnboardingTracker', () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const tracker = new OnboardingTracker(configService);
|
||||
await tracker.onApplicationBootstrap();
|
||||
|
||||
expect(mockWriteFileFs).toHaveBeenCalledWith(UPGRADE_MARKER_PATH, '7.0.0', 'utf8');
|
||||
|
||||
const snapshot = await tracker.getUpgradeSnapshot();
|
||||
expect(snapshot.currentVersion).toBe('7.0.0');
|
||||
expect(snapshot.lastTrackedVersion).toBe('6.12.0');
|
||||
@@ -434,6 +511,9 @@ describe('OnboardingTracker', () => {
|
||||
completedSteps: {},
|
||||
});
|
||||
}
|
||||
if (filePath === UPGRADE_MARKER_PATH) {
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
}
|
||||
throw Object.assign(new Error('Not found'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
OnApplicationShutdown,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { readFile, writeFile as writeFileFs } from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
import { writeFile } from 'atomically';
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
const TRACKER_FILE_NAME = 'onboarding-tracker.json';
|
||||
const CONFIG_PREFIX = 'onboardingTracker';
|
||||
const DEFAULT_OS_VERSION_FILE_PATH = '/etc/unraid-version';
|
||||
export const UPGRADE_MARKER_PATH = '/tmp/unraid-onboarding-last-version';
|
||||
|
||||
@Injectable()
|
||||
export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationShutdown {
|
||||
@@ -56,14 +57,27 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS
|
||||
this.state = {};
|
||||
this.sessionLastTrackedVersion = undefined;
|
||||
this.syncConfig(undefined);
|
||||
await this.writeUpgradeMarker(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerVersion = await this.readUpgradeMarker();
|
||||
const previousState = await this.readTrackerState();
|
||||
this.state = previousState ?? {};
|
||||
this.sessionLastTrackedVersion = previousState?.lastTrackedVersion;
|
||||
let inferredLastTrackedVersion = previousState?.lastTrackedVersion;
|
||||
|
||||
if (
|
||||
markerVersion &&
|
||||
markerVersion !== this.currentVersion &&
|
||||
(inferredLastTrackedVersion == null || inferredLastTrackedVersion === this.currentVersion)
|
||||
) {
|
||||
inferredLastTrackedVersion = markerVersion;
|
||||
}
|
||||
|
||||
this.sessionLastTrackedVersion = inferredLastTrackedVersion;
|
||||
|
||||
this.syncConfig(this.currentVersion);
|
||||
await this.writeUpgradeMarker(this.currentVersion);
|
||||
}
|
||||
|
||||
async onApplicationShutdown() {
|
||||
@@ -162,6 +176,31 @@ export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationS
|
||||
this.state = (await this.readTrackerState()) ?? {};
|
||||
}
|
||||
|
||||
private async readUpgradeMarker(): Promise<string | undefined> {
|
||||
try {
|
||||
const contents = await readFile(UPGRADE_MARKER_PATH, 'utf8');
|
||||
const version = contents.trim();
|
||||
return version.length > 0 ? version : undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
this.logger.debug(error, `Unable to read upgrade marker at ${UPGRADE_MARKER_PATH}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeUpgradeMarker(version: string | undefined): Promise<void> {
|
||||
try {
|
||||
if (!version) {
|
||||
return;
|
||||
}
|
||||
await writeFileFs(UPGRADE_MARKER_PATH, version, 'utf8');
|
||||
} catch (error) {
|
||||
this.logger.warn(error, 'Failed to persist onboarding upgrade marker');
|
||||
}
|
||||
}
|
||||
|
||||
private completedStepsForSteps(steps: UpgradeStepState[]): ActivationOnboardingStepId[] {
|
||||
const completedEntries =
|
||||
this.state.completedSteps ?? ({} as Record<ActivationOnboardingStepId, CompletedStepState>);
|
||||
|
||||
Reference in New Issue
Block a user