From 1abaf2ce96686753da8bf2d62862359f688d4751 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Oct 2025 12:05:28 -0400 Subject: [PATCH] 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. --- api/src/unraid-api/config/api-config.test.ts | 84 ++++++++++++++++++- .../config/onboarding-tracker.module.ts | 43 +++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index 153c2ff54..dd8494097 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -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>; @@ -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' }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.module.ts b/api/src/unraid-api/config/onboarding-tracker.module.ts index ef6dfaaee..17b2a68c9 100644 --- a/api/src/unraid-api/config/onboarding-tracker.module.ts +++ b/api/src/unraid-api/config/onboarding-tracker.module.ts @@ -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 { + 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 { + 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);