Skip to content

Commit

Permalink
feat(core): introduced update rollout strategies
Browse files Browse the repository at this point in the history
  • Loading branch information
tabarra committed Oct 17, 2024
1 parent 93f8d30 commit 5de3f07
Show file tree
Hide file tree
Showing 4 changed files with 329 additions and 83 deletions.
168 changes: 85 additions & 83 deletions core/components/UpdateChecker.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,54 @@
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';
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 {
Expand All @@ -53,77 +73,59 @@ export default class UpdateChecker {
* Check for txAdmin and FXServer updates
*/
async checkChangelog() {
//GET changelog data
let apiResponse: z.infer<typeof changelogRespSchema>;
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
Expand Down
109 changes: 109 additions & 0 deletions core/updateChangelog.ts
Original file line number Diff line number Diff line change
@@ -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<typeof changelogRespSchema>;
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,
};
};
65 changes: 65 additions & 0 deletions core/updateRollout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading

0 comments on commit 5de3f07

Please sign in to comment.