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:
Eli Bosley
2025-10-15 12:05:28 -04:00
parent b5215141c5
commit 1abaf2ce96
2 changed files with 123 additions and 4 deletions

View File

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

View File

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