From 5de3f07a33feb89c72ea363bfb2305e4de46fefc Mon Sep 17 00:00:00 2001 From: tabarra <1808295+tabarra@users.noreply.github.com> Date: Wed, 16 Oct 2024 22:11:35 -0300 Subject: [PATCH] feat(core): introduced update rollout strategies --- core/components/UpdateChecker.ts | 168 ++++++++++++++++--------------- core/updateChangelog.ts | 109 ++++++++++++++++++++ core/updateRollout.test.ts | 65 ++++++++++++ core/updateRollout.ts | 70 +++++++++++++ 4 files changed, 329 insertions(+), 83 deletions(-) create mode 100644 core/updateChangelog.ts create mode 100644 core/updateRollout.test.ts create mode 100644 core/updateRollout.ts diff --git a/core/components/UpdateChecker.ts b/core/components/UpdateChecker.ts index 6dd0d52ed..720843c8c 100644 --- a/core/components/UpdateChecker.ts +++ b/core/components/UpdateChecker.ts @@ -1,5 +1,5 @@ const modulename = 'UpdateChecker'; -import semver from 'semver'; +import semver, { ReleaseType } from 'semver'; import { z } from "zod"; import got from '@core/extras/got.js'; import { txEnv } from '@core/globalData'; @@ -7,28 +7,48 @@ import consoleFactory from '@extras/console'; import { UpdateDataType } from '@shared/otherTypes'; import TxAdmin from '@core/txAdmin'; import { UpdateAvailableEventType } from '@shared/socketioTypes'; +import { queryChangelogApi } from '@core/updateChangelog'; +import { getUpdateRolloutDelay } from '@core/updateRollout'; const console = consoleFactory(modulename); -//Schemas -const txVersion = z.string().refine( - (x) => x !== '0.0.0', - { message: 'must not be 0.0.0' } -); -const changelogRespSchema = z.object({ - recommended: z.coerce.number().positive(), - recommended_download: z.string().url(), - recommended_txadmin: txVersion, - optional: z.coerce.number().positive(), - optional_download: z.string().url(), - optional_txadmin: txVersion, - latest: z.coerce.number().positive(), - latest_download: z.string().url(), - latest_txadmin: txVersion, - critical: z.coerce.number().positive(), - critical_download: z.string().url(), - critical_txadmin: txVersion, -}); +type CachedDelayType = { + ts: number, + diceRoll: number, +} + +/** + * Creates a cache string. + */ +const createCacheString = (delayData: CachedDelayType) => { + return `${delayData.ts},${delayData.diceRoll}`; +} + + +/** + * Parses the cached string. + * Format: "ts,diceRoll" + */ +const parseCacheString = (raw: any) => { + if (typeof raw !== 'string' || !raw) return; + const [ts, diceRoll] = raw.split(','); + const obj = { + ts: parseInt(ts), + diceRoll: parseInt(diceRoll), + } satisfies CachedDelayType; + if (isNaN(obj.ts) || isNaN(obj.diceRoll)) return; + return obj; +} + + +/** + * Rolls dice, gets integer between 0 and 100 + */ +const rollDice = () => { + return Math.floor(Math.random() * 101); +} + +const DELAY_CACHE_KEY = 'updateDelay'; export default class UpdateChecker { @@ -53,77 +73,59 @@ export default class UpdateChecker { * Check for txAdmin and FXServer updates */ async checkChangelog() { - //GET changelog data - let apiResponse: z.infer; - try { - //perform request - cache busting every ~1.4h - const osTypeApiUrl = (txEnv.isWindows) ? 'win32' : 'linux'; - const cacheBuster = Math.floor(Date.now() / 5_000_000); - const reqUrl = `https://changelogs-live.fivem.net/api/changelog/versions/${osTypeApiUrl}/server?${cacheBuster}`; - const resp = await got(reqUrl).json() - apiResponse = changelogRespSchema.parse(resp); - } catch (error) { - console.verbose.warn(`Failed to retrieve FXServer/txAdmin update data with error: ${(error as Error).message}`); - return; + const updates = await queryChangelogApi(); + if (!updates) return; + + //If fxserver, don't print anything, just update the data + if (updates.fxs) { + this.fxsUpdateData = { + version: updates.fxs.version, + isImportant: updates.fxs.isImportant, + } } - //Checking txAdmin version - try { - const isOutdated = semver.lt(txEnv.txAdminVersion, apiResponse.latest_txadmin); - if (isOutdated) { - const semverDiff = semver.diff(txEnv.txAdminVersion, apiResponse.latest_txadmin); - if (semverDiff === 'patch') { - console.warn('This version of txAdmin is outdated.'); - console.warn('A patch (bug fix) update is available for txAdmin.'); - console.warn('If you are experiencing any kind of issue, please update now.'); - console.warn('For more information: https://discord.gg/uAmsGa2'); - this.txaUpdateData = { - version: apiResponse.latest_txadmin, - isImportant: false, - }; - } else { - console.error('This version of txAdmin is outdated.'); - console.error('Please update as soon as possible.'); - console.error('For more information: https://discord.gg/uAmsGa2'); - this.txaUpdateData = { - version: apiResponse.latest_txadmin, - isImportant: true, - }; + //If txAdmin update, check for delay before printing + if (updates.txa) { + //Setup delay data + const currTs = Date.now(); + let delayData: CachedDelayType; + const rawCache = this.#txAdmin.persistentCache.get(DELAY_CACHE_KEY); + const cachedData = parseCacheString(rawCache); + if (cachedData) { + delayData = cachedData; + } else { + delayData = { + diceRoll: rollDice(), + ts: currTs, } + this.#txAdmin.persistentCache.set(DELAY_CACHE_KEY, createCacheString(delayData)); } - } catch (error) { - console.verbose.warn('Error checking for txAdmin updates. Enable verbosity for more information.'); - console.verbose.dir(error); - } - //Checking FXServer version - try { - if (txEnv.fxServerVersion < apiResponse.critical) { - if (apiResponse.critical > apiResponse.recommended) { - this.fxsUpdateData = { - version: apiResponse.critical.toString(), - isImportant: true, - } + //Get the delay + const notifDelayDays = getUpdateRolloutDelay( + updates.txa.semverDiff, + txEnv.txAdminVersion.includes('-'), + delayData.diceRoll + ); + const notifDelayMs = notifDelayDays * 24 * 60 * 60 * 1000; + console.verbose.debug(`Update available, notification delayed by: ${notifDelayDays} day(s).`); + if (currTs - delayData.ts >= notifDelayMs) { + this.#txAdmin.persistentCache.delete(DELAY_CACHE_KEY); + this.txaUpdateData = { + version: updates.txa.version, + isImportant: updates.txa.isImportant, + } + if (updates.txa.isImportant) { + console.error('This version of txAdmin is outdated.'); + console.error('Please update as soon as possible.'); + console.error('For more information: https://discord.gg/uAmsGa2'); } else { - this.fxsUpdateData = { - version: apiResponse.recommended.toString(), - isImportant: true, - } + console.warn('This version of txAdmin is outdated.'); + console.warn('A patch (bug fix) update is available for txAdmin.'); + console.warn('If you are experiencing any kind of issue, please update now.'); + console.warn('For more information: https://discord.gg/uAmsGa2'); } - } else if (txEnv.fxServerVersion < apiResponse.recommended) { - this.fxsUpdateData = { - version: apiResponse.recommended.toString(), - isImportant: true, - }; - } else if (txEnv.fxServerVersion < apiResponse.optional) { - this.fxsUpdateData = { - version: apiResponse.optional.toString(), - isImportant: false, - }; } - } catch (error) { - console.warn('Error checking for FXServer updates. Enable verbosity for more information.'); - console.verbose.dir(error); } //Sending event to the UI diff --git a/core/updateChangelog.ts b/core/updateChangelog.ts new file mode 100644 index 000000000..709d5a31b --- /dev/null +++ b/core/updateChangelog.ts @@ -0,0 +1,109 @@ +//FIXME: after efactor, move to the correct path (maybe drop the 'update' prefix?) +const modulename = 'UpdateChecker'; +import semver, { ReleaseType } from 'semver'; +import { z } from "zod"; +import got from '@core/extras/got.js'; +import { txEnv } from '@core/globalData'; +import consoleFactory from '@extras/console'; +import { UpdateDataType } from '@shared/otherTypes'; +import TxAdmin from '@core/txAdmin'; +import { UpdateAvailableEventType } from '@shared/socketioTypes'; +const console = consoleFactory(modulename); + + +//Schemas +const txVersion = z.string().refine( + (x) => x !== '0.0.0', + { message: 'must not be 0.0.0' } +); +const changelogRespSchema = z.object({ + recommended: z.coerce.number().positive(), + recommended_download: z.string().url(), + recommended_txadmin: txVersion, + optional: z.coerce.number().positive(), + optional_download: z.string().url(), + optional_txadmin: txVersion, + latest: z.coerce.number().positive(), + latest_download: z.string().url(), + latest_txadmin: txVersion, + critical: z.coerce.number().positive(), + critical_download: z.string().url(), + critical_txadmin: txVersion, +}); + +//Types +type DetailedUpdateDataType = { + semverDiff: ReleaseType; + version: string; + isImportant: boolean; +}; + +export const queryChangelogApi = async () => { + //GET changelog data + let apiResponse: z.infer; + try { + //perform request - cache busting every ~1.4h + const osTypeApiUrl = (txEnv.isWindows) ? 'win32' : 'linux'; + const cacheBuster = Math.floor(Date.now() / 5_000_000); + const reqUrl = `https://changelogs-live.fivem.net/api/changelog/versions/${osTypeApiUrl}/server?${cacheBuster}`; + const resp = await got(reqUrl).json() + apiResponse = changelogRespSchema.parse(resp); + } catch (error) { + console.verbose.warn(`Failed to retrieve FXServer/txAdmin update data with error: ${(error as Error).message}`); + return; + } + + //Checking txAdmin version + let txaUpdateData: DetailedUpdateDataType | undefined; + try { + const isOutdated = semver.lt(txEnv.txAdminVersion, apiResponse.latest_txadmin); + if (isOutdated) { + const semverDiff = semver.diff(txEnv.txAdminVersion, apiResponse.latest_txadmin) ?? 'patch'; + const isImportant = (semverDiff === 'major' || semverDiff === 'minor'); + txaUpdateData = { + semverDiff, + isImportant, + version: apiResponse.latest_txadmin, + }; + } + } catch (error) { + console.verbose.warn('Error checking for txAdmin updates.'); + console.verbose.dir(error); + } + + //Checking FXServer version + let fxsUpdateData: UpdateDataType | undefined; + try { + if (txEnv.fxServerVersion < apiResponse.critical) { + if (apiResponse.critical > apiResponse.recommended) { + fxsUpdateData = { + version: apiResponse.critical.toString(), + isImportant: true, + } + } else { + fxsUpdateData = { + version: apiResponse.recommended.toString(), + isImportant: true, + } + } + } else if (txEnv.fxServerVersion < apiResponse.recommended) { + fxsUpdateData = { + version: apiResponse.recommended.toString(), + isImportant: true, + }; + } else if (txEnv.fxServerVersion < apiResponse.optional) { + fxsUpdateData = { + version: apiResponse.optional.toString(), + isImportant: false, + }; + } + } catch (error) { + console.warn('Error checking for FXServer updates.'); + console.verbose.dir(error); + } + + return { + txa: txaUpdateData, + fxs: fxsUpdateData, + }; +}; diff --git a/core/updateRollout.test.ts b/core/updateRollout.test.ts new file mode 100644 index 000000000..a76e5ff2d --- /dev/null +++ b/core/updateRollout.test.ts @@ -0,0 +1,65 @@ +import { it, expect, suite } from 'vitest'; +import { getUpdateRolloutDelay } from './updateRollout'; + +suite('getReleaseRolloutDelay', () => { + const fnc = getUpdateRolloutDelay; + + it('should handle invalid input', () => { + expect(fnc('minor', false, -1)).toBe(0); + expect(fnc('minor', false, 150)).toBe(0); + expect(fnc('aaaaaaa' as any, false, 5)).toBe(7); + }); + + it('should return 0 delay for 100% immediate pre-release update', () => { + expect(fnc('minor', true, 0)).toBe(0); + expect(fnc('minor', true, 50)).toBe(0); + expect(fnc('minor', true, 100)).toBe(0); + expect(fnc('major', true, 0)).toBe(0); + expect(fnc('major', true, 50)).toBe(0); + expect(fnc('major', true, 100)).toBe(0); + expect(fnc('patch', true, 0)).toBe(0); + expect(fnc('patch', true, 50)).toBe(0); + expect(fnc('patch', true, 100)).toBe(0); + expect(fnc('prepatch', true, 0)).toBe(0); + expect(fnc('prepatch', true, 50)).toBe(0); + expect(fnc('prepatch', true, 100)).toBe(0); + }); + + it('should return correct delay for major release based on dice roll', () => { + // First tier (5%) + let delay = fnc('major', false, 3); + expect(delay).toBe(0); + + // Second tier (5% < x <= 20%) + delay = fnc('major', false, 10); + expect(delay).toBe(2); + + // Third tier (remaining 80%) + delay = fnc('major', false, 50); + expect(delay).toBe(7); + }); + + it('should return correct delay for minor release based on dice roll', () => { + // First tier (10%) + let delay = fnc('minor', false, 5); + expect(delay).toBe(0); + + // Second tier (10% < x <= 40%) + delay = fnc('minor', false, 20); + expect(delay).toBe(2); + + // Third tier (remaining 60%) + delay = fnc('minor', false, 80); + expect(delay).toBe(4); + }); + + it('should return 0 delay for patch release for all dice rolls', () => { + const delay = fnc('patch', false, 50); + expect(delay).toBe(0); + }); + + it('should return 7-day delay for stable to pre-release', () => { + const delay = fnc('prerelease', false, 50); + expect(delay).toBe(7); + }); +}); diff --git a/core/updateRollout.ts b/core/updateRollout.ts new file mode 100644 index 000000000..2e6740b18 --- /dev/null +++ b/core/updateRollout.ts @@ -0,0 +1,70 @@ +//FIXME: after efactor, move to the correct path +import type { ReleaseType } from 'semver'; + +type RolloutStrategyType = { + pct: number, + delay: number +}[]; + + +/** + * Returns the delay in days for the update rollout based on the release type and the dice roll. + */ +export const getUpdateRolloutDelay = ( + releaseDiff: ReleaseType, + isCurrentPreRelease: boolean, + diceRoll: number, +): number => { + //Sanity check diceRoll + if (diceRoll < 0 || diceRoll > 100) { + return 0; + } + + let rolloutStrategy: RolloutStrategyType; + if (isCurrentPreRelease) { + // If you are on beta, it's probably really important to update immediately + rolloutStrategy = [ + { pct: 100, delay: 0 }, + ]; + } else if (releaseDiff === 'major') { + // 5% immediate rollout + // 20% after 2 days + // 100% after 7 days + rolloutStrategy = [ + { pct: 5, delay: 0 }, + { pct: 15, delay: 2 }, + { pct: 80, delay: 7 }, + ]; + } else if (releaseDiff === 'minor') { + // 10% immediate rollout + // 40% after 2 day + // 100% after 4 days + rolloutStrategy = [ + { pct: 10, delay: 0 }, + { pct: 30, delay: 2 }, + { pct: 60, delay: 4 }, + ]; + } else if (releaseDiff === 'patch') { + // Immediate rollout to everyone, probably correcting bugs + rolloutStrategy = [ + { pct: 100, delay: 0 }, + ]; + } else { + // Update notification from stable to pre-release should not happen, delay 7 days + rolloutStrategy = [ + { pct: 100, delay: 7 }, + ]; + } + + // Implement strategy based on diceRoll + let cumulativePct = 0; + for (const tier of rolloutStrategy) { + cumulativePct += tier.pct; + if (diceRoll <= cumulativePct) { + return tier.delay; + } + } + + // Default delay if somehow no tier is matched (which shouldn't happen) + return 0; +};