refactor: remove usages of singletons and optimize redis connections count

This commit is contained in:
Nicolas Meienberger 2023-09-05 22:11:35 +02:00 committed by Nicolas Meienberger
parent fd7f9d810a
commit 779f7951d9
22 changed files with 143 additions and 271 deletions

View file

@ -1,5 +1,5 @@
module.exports = {
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsdoc', 'jsx-a11y', 'testing-library', 'jest-dom'],
plugins: ['@typescript-eslint', 'import', 'react', 'jest', 'jsx-a11y', 'testing-library', 'jest-dom'],
extends: [
'plugin:@typescript-eslint/recommended',
'next/core-web-vitals',
@ -10,7 +10,6 @@ module.exports = {
'plugin:import/typescript',
'prettier',
'plugin:react/recommended',
'plugin:jsdoc/recommended',
'plugin:jsx-a11y/recommended',
],
parser: '@typescript-eslint/parser',
@ -53,8 +52,6 @@ module.exports = {
'no-underscore-dangle': 0,
'arrow-body-style': 0,
'class-methods-use-this': 0,
'jsdoc/require-returns': 0,
'jsdoc/tag-lines': 0,
'import/extensions': [
'error',
'ignorePackages',

View file

@ -126,7 +126,6 @@
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-jest-dom": "^5.0.1",
"eslint-plugin-jsdoc": "^46.3.0",
"eslint-plugin-jsx-a11y": "^6.6.1",
"eslint-plugin-react": "^7.31.10",
"eslint-plugin-react-hooks": "^4.6.0",

View file

@ -108,7 +108,7 @@ export const startWorker = async () => {
return { success, stdout: message };
},
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 } },
{ connection: { host: '127.0.0.1', port: 6379, password: getEnv().redisPassword, connectTimeout: 60000 }, removeOnComplete: { count: 200 }, removeOnFail: { count: 500 } },
);
worker.on('ready', () => {
@ -124,6 +124,6 @@ export const startWorker = async () => {
});
worker.on('error', async (e) => {
fileLogger.error(`Worker error: ${e}`);
fileLogger.debug(`Worker error: ${e}`);
});
};

View file

@ -8,12 +8,9 @@ import { fileLogger } from '../logger/file-logger';
const execAsync = promisify(exec);
const composeUp = async (args: string[]) => {
fileLogger.info(`Running docker compose with args ${args.join(' ')}`);
const { stdout, stderr } = await execAsync(`docker compose ${args.join(' ')}`);
if (stderr) {
fileLogger.error(stderr);
}
return { stdout, stderr };
};

View file

@ -34,6 +34,10 @@ export const newLogger = (id: string, logsFolder: string) => {
);
exceptionHandlers = [new transports.File({ filename: path.join(logsFolder, 'error.log') })];
if (process.env.NODE_ENV !== 'production') {
tr.push(new transports.Console({ level: 'debug' }));
}
return createLogger({
level: 'debug',
format: combine(

View file

@ -281,9 +281,6 @@ importers:
eslint-plugin-jest-dom:
specifier: ^5.0.1
version: 5.0.1(@testing-library/dom@9.3.1)(eslint@8.43.0)
eslint-plugin-jsdoc:
specifier: ^46.3.0
version: 46.3.0(eslint@8.43.0)
eslint-plugin-jsx-a11y:
specifier: ^6.6.1
version: 6.7.1(eslint@8.43.0)
@ -968,15 +965,6 @@ packages:
unescape-js: 1.1.4
dev: true
/@es-joy/jsdoccomment@0.39.4:
resolution: {integrity: sha512-Jvw915fjqQct445+yron7Dufix9A+m9j1fCJYlCo1FWlRvTxa3pjJelxdSTdaLWcTwRU6vbL+NYjO4YuNIS5Qg==}
engines: {node: '>=16'}
dependencies:
comment-parser: 1.3.1
esquery: 1.5.0
jsdoc-type-pratt-parser: 4.0.0
dev: true
/@esbuild-kit/cjs-loader@2.4.2:
resolution: {integrity: sha512-BDXFbYOJzT/NBEtp71cvsrGPwGAMGRB/349rwKuoxNSiKjPraNNnlK6MIIabViCjqZugu6j+xeMDlEkWdHHJSg==}
dependencies:
@ -4103,11 +4091,6 @@ packages:
resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==}
dev: false
/are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
dev: true
/are-we-there-yet@2.0.0:
resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==}
engines: {node: '>=10'}
@ -4472,11 +4455,6 @@ packages:
base64-js: 1.5.1
ieee754: 1.2.1
/builtin-modules@3.3.0:
resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==}
engines: {node: '>=6'}
dev: true
/bullmq@4.5.0:
resolution: {integrity: sha512-pG0teKyP45jtyXIH0MBglxDfy1NCsdXf95YGzJEZTycl9eE1oXHJlr8/RlhVfIA+XCYEnEQD36u0OINGrxaqeQ==}
dependencies:
@ -4792,11 +4770,6 @@ packages:
engines: {node: '>= 6'}
dev: true
/comment-parser@1.3.1:
resolution: {integrity: sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==}
engines: {node: '>= 12.0.0'}
dev: true
/component-emitter@1.3.0:
resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==}
dev: true
@ -5734,26 +5707,6 @@ packages:
- typescript
dev: true
/eslint-plugin-jsdoc@46.3.0(eslint@8.43.0):
resolution: {integrity: sha512-nfSvsR8YJRZyKrWwcXPSQyQC8jllfdEjcRhTXFr7RxfB5Wyl7AxrfjCUz72WwalkXMF4u+R6F/oDoW46ah69HQ==}
engines: {node: '>=16'}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
dependencies:
'@es-joy/jsdoccomment': 0.39.4
are-docs-informative: 0.0.2
comment-parser: 1.3.1
debug: 4.3.4
escape-string-regexp: 4.0.0
eslint: 8.43.0
esquery: 1.5.0
is-builtin-module: 3.2.1
semver: 7.5.3
spdx-expression-parse: 3.0.1
transitivePeerDependencies:
- supports-color
dev: true
/eslint-plugin-jsx-a11y@6.7.1(eslint@8.43.0):
resolution: {integrity: sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==}
engines: {node: '>=4.0'}
@ -6816,13 +6769,6 @@ packages:
engines: {node: '>=4'}
dev: false
/is-builtin-module@3.2.1:
resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==}
engines: {node: '>=6'}
dependencies:
builtin-modules: 3.3.0
dev: true
/is-callable@1.2.7:
resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==}
engines: {node: '>= 0.4'}
@ -7604,11 +7550,6 @@ packages:
argparse: 2.0.1
dev: true
/jsdoc-type-pratt-parser@4.0.0:
resolution: {integrity: sha512-YtOli5Cmzy3q4dP26GraSOeAhqecewG04hoO8DY56CH4KJ9Fvv5qKWUCCo3HZob7esJQHCv6/+bnTy72xZZaVQ==}
engines: {node: '>=12.0.0'}
dev: true
/jsdom@20.0.3:
resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==}
engines: {node: '>=14'}
@ -10139,21 +10080,6 @@ packages:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
dev: false
/spdx-exceptions@2.3.0:
resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==}
dev: true
/spdx-expression-parse@3.0.1:
resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==}
dependencies:
spdx-exceptions: 2.3.0
spdx-license-ids: 3.0.12
dev: true
/spdx-license-ids@3.0.12:
resolution: {integrity: sha512-rr+VVSXtRhO4OHbXUiAF7xW3Bo9DuuF6C5jH+q/x15j2jniycgKbxU09Hr0WqlSLUs4i4ltHGXqTe7VHclYWyA==}
dev: true
/split2@4.1.0:
resolution: {integrity: sha512-VBiJxFkxiXRlUIeyMQi8s4hgvKCSjtknJv/LVYbrgALPwf5zSKmEwV9Lst25AkvMDnvxODugjdl6KZgwKM1WYQ==}
engines: {node: '>= 10.x'}

View file

@ -6,7 +6,7 @@ import { getAuthedPageProps, getMessagesPageProps } from '../page-helpers';
import englishMessages from '../../messages/en.json';
import frenchMessages from '../../messages/fr-FR.json';
const cache = new TipiCache();
const cache = new TipiCache('page-helpers.test.ts');
afterAll(async () => {
await cache.close();

View file

@ -5,7 +5,7 @@ import { getCookie } from 'cookies-next';
import { TipiCache } from '@/server/core/TipiCache';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
const cache = new TipiCache();
const cache = new TipiCache('getAuthedPageProps');
const sessionId = ctx.req.headers['x-session-id'];
const userId = await cache.get(`session:${sessionId}`);
await cache.close();

View file

@ -13,15 +13,13 @@ import fs from 'fs-extra';
* @param {NextApiResponse} res - The response
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const cache = new TipiCache();
const authService = new AuthQueries(db);
const sessionId = req.headers['x-session-id'];
const cache = new TipiCache('certificate');
const userId = await cache.get(`session:${sessionId}`);
const user = await authService.getUserById(Number(userId));
await cache.close();
const user = await authService.getUserById(Number(userId));
if (user?.operator) {
const filePath = `${getConfig().rootFolder}/traefik/tls/cert.pem`;

View file

@ -11,7 +11,7 @@ export const generateSessionId = (prefix: string) => {
};
export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
const cache = new TipiCache();
const cache = new TipiCache('setSession');
setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false });

View file

@ -27,13 +27,12 @@ const createContextInner = async (opts: CreateContextOptions) => ({
* @param {CreateNextContextOptions} opts - options
*/
export const createContext = async (opts: CreateNextContextOptions) => {
const cache = new TipiCache();
const { req, res } = opts;
const sessionId = req.headers['x-session-id'] as string;
const cache = new TipiCache('createContext');
const userId = await cache.get(`session:${sessionId}`);
await cache.close();
return createContextInner({

View file

@ -1,25 +0,0 @@
import fs from 'fs-extra';
import { EventDispatcher } from '.';
const WATCH_FILE = '/runtipi/state/events';
jest.mock('fs-extra');
beforeEach(async () => {
await fs.promises.mkdir('/runtipi/state', { recursive: true });
await fs.promises.mkdir('/app/logs', { recursive: true });
await fs.promises.writeFile(WATCH_FILE, '');
await fs.promises.writeFile('/app/logs/123.log', 'test');
EventDispatcher.clear();
});
describe('EventDispatcher - dispatchEvent', () => {
it('should dispatch an event in the queue', () => {});
});
describe('EventDispatcher - dispatchEventAsync', () => {
it('Should dispatch an event and wait for it to finish', async () => {});
it('Should dispatch an event and wait for it to finish with error', async () => {});
});

View file

@ -1,24 +1,25 @@
/* eslint-disable vars-on-top */
import { Queue, QueueEvents } from 'bullmq';
import { eventResultSchema, eventSchema, SystemEvent } from '@runtipi/shared';
import { getConfig } from '@/server/core/TipiConfig';
import { Logger } from '../Logger';
declare global {
// eslint-disable-next-line no-var
var EventDispatcher: EventDispatcher | undefined;
}
class EventDispatcher {
private static instance: EventDispatcher | null;
export class EventDispatcher {
private queue;
private queueEvents;
constructor() {
private instanceId: string;
private timeout: NodeJS.Timeout;
constructor(reference: string) {
this.queue = new Queue('events', { connection: { host: getConfig().REDIS_HOST, port: 6379, password: getConfig().redisPassword } });
this.queueEvents = new QueueEvents('events', { connection: { host: getConfig().REDIS_HOST, port: 6379, password: getConfig().redisPassword } });
this.instanceId = `${getConfig().version}-${Date.now()}`;
this.timeout = setTimeout(() => {
Logger.debug(`Redis connection is running for more than 30 seconds. Consider closing it. reference: ${reference}`);
}, 30000);
}
public async cleanRepeatableJobs() {
@ -33,13 +34,6 @@ class EventDispatcher {
}
}
public static getInstance(): EventDispatcher {
if (!EventDispatcher.instance) {
EventDispatcher.instance = new EventDispatcher();
}
return EventDispatcher.instance;
}
private generateJobId(event: SystemEvent) {
return [event.type, Date.now()].join('_');
}
@ -62,13 +56,11 @@ class EventDispatcher {
* @returns {Promise<{ success: boolean; stdout?: string }>} - Promise that resolves when the event is done
*/
public async dispatchEventAsync(event: SystemEvent): Promise<{ success: boolean; stdout?: string }> {
Logger.info(`Dispatching event ${JSON.stringify(event)}`);
Logger.info(`Dispatching event ${JSON.stringify(event)}. Instance: ${this.instanceId}`);
try {
const job = await this.dispatchEvent(event);
const result = await job.waitUntilFinished(this.queueEvents, 1000 * 60 * 5);
// const isFailed = await job.isFailed();
return eventResultSchema.parse(result);
} catch (e) {
Logger.error(`Event failed: ${e}`);
@ -91,8 +83,10 @@ class EventDispatcher {
this.queue.add(jobid, eventSchema.parse(event), { repeat: { pattern: cronExpression } });
}
public async close() {
await this.queue.close();
await this.queueEvents.close();
clearTimeout(this.timeout);
}
}
export const EventDispatcherInstance = global.EventDispatcher || EventDispatcher.getInstance();
global.EventDispatcher = EventDispatcherInstance;

View file

@ -1 +1 @@
export { EventDispatcherInstance as EventDispatcher } from './EventDispatcher';
export { EventDispatcher } from './EventDispatcher';

View file

@ -5,11 +5,11 @@ import { getConfig } from '../TipiConfig';
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
export class TipiCache {
private static instance: TipiCache;
private client: RedisClientType;
constructor() {
private timeout: NodeJS.Timeout;
constructor(reference: string) {
const client = createClient({
url: `redis://${getConfig().REDIS_HOST}:6379`,
password: getConfig().redisPassword,
@ -20,14 +20,10 @@ export class TipiCache {
});
this.client = client as RedisClientType;
}
public static getInstance(): TipiCache {
if (!TipiCache.instance) {
TipiCache.instance = new TipiCache();
}
return TipiCache.instance;
this.timeout = setTimeout(() => {
Logger.debug(`Redis connection is running for more than 30 seconds. Consider closing it. reference: ${reference}`);
}, 30000);
}
private async getClient(): Promise<RedisClientType> {
@ -70,6 +66,7 @@ export class TipiCache {
}
public async close() {
clearTimeout(this.timeout);
return this.client.quit();
}

View file

@ -3,6 +3,7 @@ import waitForExpect from 'wait-for-expect';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
import { waitUntilFinishedMock } from '@/tests/server/jest.setup';
import { AppServiceClass } from './apps.service';
import { EventDispatcher } from '../../core/EventDispatcher';
import { getAllApps, getAppById, updateApp, createAppConfig, insertApp } from '../../tests/apps.factory';
@ -11,6 +12,7 @@ import { setConfig } from '../../core/TipiConfig';
let db: TestDatabase;
let AppsService: AppServiceClass;
const TEST_SUITE = 'appsservice';
const dispatcher = new EventDispatcher(TEST_SUITE);
beforeAll(async () => {
db = await createDatabase(TEST_SUITE);
@ -19,11 +21,12 @@ beforeAll(async () => {
beforeEach(async () => {
await clearDatabase(db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
dispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
});
afterAll(async () => {
await closeDatabase(db);
await dispatcher.close();
});
describe('Install app', () => {
@ -45,26 +48,22 @@ describe('Install app', () => {
it('Should start app if already installed', async () => {
// arrange
const appConfig = createAppConfig();
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// act
await AppsService.installApp(appConfig.id, {});
await AppsService.installApp(appConfig.id, {});
const app = await getAppById(appConfig.id, db);
// assert
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([{ appid: appConfig.id, command: 'install', form: {}, type: 'app' }]);
expect(spy.mock.calls[1]).toEqual([{ appid: appConfig.id, command: 'start', form: {}, type: 'app' }]);
spy.mockRestore();
expect(app?.status).toBe('running');
});
it('Should delete app if install script fails', async () => {
// arrange
const appConfig = createAppConfig();
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
// act
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
await expect(AppsService.installApp(appConfig.id, {})).rejects.toThrow('server-messages.errors.app-failed-to-install');
const app = await getAppById(appConfig.id, db);
@ -177,16 +176,15 @@ describe('Uninstall app', () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// act
await AppsService.uninstallApp(appConfig.id);
waitUntilFinishedMock.mockResolvedValueOnce({ success: true, stdout: 'test' });
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
await expect(AppsService.uninstallApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-uninstall');
const app = await getAppById(appConfig.id, db);
// assert
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([{ appid: appConfig.id, command: 'stop', form: {}, type: 'app' }]);
expect(spy.mock.calls[1]).toEqual([{ appid: appConfig.id, command: 'uninstall', form: {}, type: 'app' }]);
spy.mockRestore();
expect(app?.status).toBe('stopped');
});
it('Should throw if app is not installed', async () => {
@ -198,7 +196,7 @@ describe('Uninstall app', () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
await updateApp(appConfig.id, { status: 'updating' }, db);
// act & assert
@ -209,18 +207,17 @@ describe('Uninstall app', () => {
});
describe('Start app', () => {
it('Should correctly dispatch start event', async () => {
it('Should correctly start app', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({}, appConfig, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
await insertApp({ status: 'stopped' }, appConfig, db);
// act
await AppsService.startApp(appConfig.id);
const app = await getAppById(appConfig.id, db);
// assert
expect(spy.mock.lastCall).toEqual([{ appid: appConfig.id, command: 'start', form: {}, type: 'app' }]);
spy.mockRestore();
expect(app?.status).toBe('running');
});
it('Should throw if app is not installed', async () => {
@ -231,23 +228,21 @@ describe('Start app', () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// act
await AppsService.startApp(appConfig.id);
expect(spy.mock.calls.length).toBe(1);
await AppsService.startApp(appConfig.id);
const app = await getAppById(appConfig.id, db);
// assert
expect(spy.mock.calls.length).toBe(2);
spy.mockRestore();
expect(app?.status).toBe('running');
});
it('Should throw if start script fails', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'stopped' }, appConfig, db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
// act & assert
await expect(AppsService.startApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-start');
@ -257,17 +252,17 @@ describe('Start app', () => {
});
describe('Stop app', () => {
it('Should correctly dispatch stop event', async () => {
it('Should correctly stop app', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// act
await AppsService.stopApp(appConfig.id);
const app = await getAppById(appConfig.id, db);
// assert
expect(spy.mock.lastCall).toEqual([{ appid: appConfig.id, command: 'stop', form: {}, type: 'app' }]);
expect(app?.status).toBe('stopped');
});
it('Should throw if app is not installed', async () => {
@ -278,7 +273,7 @@ describe('Stop app', () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
// act & assert
await expect(AppsService.stopApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-stop');
@ -488,7 +483,7 @@ describe('Update app', () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({}, appConfig, db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'error' });
// act & assert
await expect(AppsService.updateApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-update');
@ -537,29 +532,11 @@ describe('installedApps', () => {
});
describe('startAllApps', () => {
it('should start all apps with status RUNNING', async () => {
// arrange
const appConfig = createAppConfig({});
const appConfig2 = createAppConfig({});
const appConfig3 = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
await insertApp({ status: 'running' }, appConfig2, db);
await insertApp({ status: 'stopped' }, appConfig3, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// act
await AppsService.startAllApps();
// assert
expect(spy.mock.calls.length).toBe(2);
});
it('should put status to STOPPED if start script fails', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'stopped' }, appConfig, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
const spy = jest.spyOn(dispatcher, 'dispatchEventAsync');
spy.mockResolvedValueOnce({ success: false, stdout: 'error' });
// act

View file

@ -5,9 +5,9 @@ import { TranslatedError } from '@/server/utils/errors';
import { Database } from '@/server/db';
import { castAppConfig } from '@/client/modules/Apps/helpers/castAppConfig';
import { AppInfo } from '@runtipi/shared';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { checkAppRequirements, getAvailableApps, getAppInfo, getUpdateInfo } from './apps.helpers';
import { getConfig } from '../../core/TipiConfig';
import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import { notEmpty } from '../../common/typescript.helpers';
@ -42,12 +42,14 @@ export class AppServiceClass {
// Update all apps with status different than running or stopped to stopped
await this.queries.updateAppsByStatusNotIn(['running', 'stopped', 'missing'], { status: 'stopped' });
const eventDispatcher = new EventDispatcher('startAllApps');
await Promise.all(
apps.map(async (app) => {
try {
await this.queries.updateApp(app.id, { status: 'starting' });
EventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) }).then(({ success }) => {
eventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) }).then(({ success }) => {
if (success) {
this.queries.updateApp(app.id, { status: 'running' });
} else {
@ -60,6 +62,8 @@ export class AppServiceClass {
}
}),
);
await eventDispatcher.close();
}
/**
@ -76,7 +80,9 @@ export class AppServiceClass {
}
await this.queries.updateApp(appName, { status: 'starting' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: appName, form: castAppConfig(app.config) });
const eventDispatcher = new EventDispatcher('startApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: appName, form: castAppConfig(app.config) });
await eventDispatcher.close();
if (success) {
await this.queries.updateApp(appName, { status: 'running' });
@ -145,7 +151,9 @@ export class AppServiceClass {
await this.queries.createApp({ id, status: 'installing', config: form, version: appInfo.tipi_version, exposed: exposed || false, domain: domain || null });
// Run script
const { success, stdout } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form });
const eventDispatcher = new EventDispatcher('installApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form });
await eventDispatcher.close();
if (!success) {
await this.queries.deleteApp(id);
@ -213,7 +221,9 @@ export class AppServiceClass {
}
}
const { success } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'generate_env', appid: id, form });
const eventDispatcher = new EventDispatcher('updateAppConfig');
const { success } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'generate_env', appid: id, form });
await eventDispatcher.close();
if (success) {
const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form });
@ -239,7 +249,9 @@ export class AppServiceClass {
// Run script
await this.queries.updateApp(id, { status: 'stopping' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) });
const eventDispatcher = new EventDispatcher('stopApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) });
await eventDispatcher.close();
if (success) {
await this.queries.updateApp(id, { status: 'stopped' });
@ -271,7 +283,9 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'uninstalling' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) });
const eventDispatcher = new EventDispatcher('uninstallApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) });
await eventDispatcher.close();
if (!success) {
await this.queries.updateApp(id, { status: 'stopped' });
@ -320,7 +334,9 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'updating' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync({ type: 'app', command: 'update', appid: id, form: castAppConfig(app.config) });
const eventDispatcher = new EventDispatcher('updateApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'update', appid: id, form: castAppConfig(app.config) });
await eventDispatcher.close();
if (success) {
const appInfo = getAppInfo(app.id, app.status);

View file

@ -17,7 +17,7 @@ let AuthService: AuthServiceClass;
let database: TestDatabase;
const TEST_SUITE = 'authservice';
const cache = new TipiCache();
const cache = new TipiCache('auth.service.test.ts');
beforeAll(async () => {
setConfig('jwtSecret', 'test');

View file

@ -22,11 +22,8 @@ type UsernamePasswordInput = {
export class AuthServiceClass {
private queries;
private cache;
constructor(p: Database) {
this.queries = new AuthQueries(p);
this.cache = new TipiCache();
}
/**
@ -51,8 +48,10 @@ export class AuthServiceClass {
}
if (user.totpEnabled) {
const cache = new TipiCache('login');
const totpSessionId = generateSessionId('otp');
await this.cache.set(totpSessionId, user.id.toString());
await cache.set(totpSessionId, user.id.toString());
await cache.close();
return { totpSessionId };
}
@ -73,7 +72,9 @@ export class AuthServiceClass {
*/
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
const { totpSessionId, totpCode } = params;
const userId = await this.cache.get(totpSessionId);
const cache = new TipiCache('verifyTotp');
const userId = await cache.get(totpSessionId);
await cache.close();
if (!userId) {
throw new TranslatedError('server-messages.errors.totp-session-not-found');
@ -265,7 +266,9 @@ export class AuthServiceClass {
* @returns {Promise<boolean>} - Returns true if the session token is removed successfully
*/
public logout = async (sessionId: string): Promise<boolean> => {
await this.cache.del(`session:${sessionId}`);
const cache = new TipiCache('logout');
await cache.del(`session:${sessionId}`);
await cache.close();
return true;
};
@ -343,13 +346,13 @@ export class AuthServiceClass {
*
* @param {number} userId - The user ID
*/
private destroyAllSessionsByUserId = async (userId: number) => {
const sessions = await this.cache.getByPrefix(`session:${userId}:`);
private destroyAllSessionsByUserId = async (userId: number, cache: TipiCache) => {
const sessions = await cache.getByPrefix(`session:${userId}:`);
await Promise.all(
sessions.map(async (session) => {
await this.cache.del(session.key);
if (session.val) await this.cache.del(session.val);
await cache.del(session.key);
if (session.val) await cache.del(session.val);
}),
);
};
@ -379,7 +382,9 @@ export class AuthServiceClass {
const hash = await argon2.hash(newPassword);
await this.queries.updateUser(user.id, { password: hash });
await this.destroyAllSessionsByUserId(user.id);
const cache = new TipiCache('changePassword');
await this.destroyAllSessionsByUserId(user.id, cache);
await cache.close();
return true;
};

View file

@ -3,25 +3,29 @@ import { setupServer } from 'msw/node';
import fs from 'fs-extra';
import semver from 'semver';
import { faker } from '@faker-js/faker';
import { EventDispatcher } from '../../core/EventDispatcher';
import { setConfig } from '../../core/TipiConfig';
import { TipiCache } from '../../core/TipiCache';
import { SystemServiceClass } from '.';
jest.mock('redis');
const SystemService = new SystemServiceClass();
const server = setupServer();
const cache = new TipiCache();
const cache = new TipiCache('system.service.test');
afterAll(async () => {
server.close();
await cache.close();
});
beforeAll(() => {
server.listen();
});
beforeEach(async () => {
await setConfig('demoMode', false);
jest.mock('fs-extra');
jest.resetModules();
jest.resetAllMocks();
await cache.del('latestVersion');
server.resetHandlers();
});
describe('Test: systemInfo', () => {
@ -69,21 +73,6 @@ describe('Test: systemInfo', () => {
});
describe('Test: getVersion', () => {
beforeAll(() => {
server.listen();
});
beforeEach(async () => {
server.resetHandlers();
await cache.del('latestVersion');
});
afterAll(async () => {
server.close();
jest.restoreAllMocks();
await cache.close();
});
it('It should return version with body', async () => {
// Arrange
const body = faker.lorem.words(10);
@ -142,9 +131,6 @@ describe('Test: getVersion', () => {
describe('Test: restart', () => {
it('Should return true', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
// Act
const restart = await SystemService.restart();

View file

@ -1,10 +1,10 @@
import { z } from 'zod';
import axios from 'redaxios';
import { TranslatedError } from '@/server/utils/errors';
import { EventDispatcher } from '@/server/core/EventDispatcher';
import { TipiCache } from '@/server/core/TipiCache';
import { readJsonFile } from '../../common/fs.helpers';
import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import { TipiCache } from '../../core/TipiCache';
import * as TipiConfig from '../../core/TipiConfig';
const SYSTEM_STATUS = ['UPDATING', 'RESTARTING', 'RUNNING'] as const;
@ -27,21 +27,13 @@ const systemInfoSchema = z.object({
});
export class SystemServiceClass {
private cache;
private dispatcher;
constructor() {
this.cache = new TipiCache();
this.dispatcher = EventDispatcher;
}
/**
* Get the current and latest version of Tipi
*
* @returns {Promise<{ current: string; latest: string }>} The current and latest version
*/
public getVersion = async () => {
const cache = new TipiCache('getVersion');
try {
const { seePreReleaseVersions } = TipiConfig.getConfig();
@ -51,8 +43,8 @@ export class SystemServiceClass {
return { current: TipiConfig.getConfig().version, latest: data[0]?.tag_name, body: data[0]?.body };
}
let version = await this.cache.get('latestVersion');
let body = await this.cache.get('latestVersionBody');
let version = await cache.get('latestVersion');
let body = await cache.get('latestVersionBody');
if (!version) {
const { data } = await axios.get<{ tag_name: string; body: string }>('https://api.github.com/repos/meienberger/runtipi/releases/latest');
@ -60,14 +52,16 @@ export class SystemServiceClass {
version = data.tag_name;
body = data.body;
await this.cache.set('latestVersion', version || '', 60 * 60);
await this.cache.set('latestVersionBody', body || '', 60 * 60);
await cache.set('latestVersion', version || '', 60 * 60);
await cache.set('latestVersionBody', body || '', 60 * 60);
}
return { current: TipiConfig.getConfig().version, latest: version, body };
} catch (e) {
Logger.error(e);
return { current: TipiConfig.getConfig().version, latest: undefined };
} finally {
await cache.close();
}
};
@ -93,7 +87,9 @@ export class SystemServiceClass {
}
TipiConfig.setConfig('status', 'RESTARTING');
this.dispatcher.dispatchEvent({ type: 'system', command: 'restart' });
const dispatcher = new EventDispatcher('restart');
dispatcher.dispatchEvent({ type: 'system', command: 'restart' });
await dispatcher.close();
return true;
};

View file

@ -1,6 +1,6 @@
import fs from 'fs-extra';
import { fromPartial } from '@total-typescript/shoehorn';
import { EventDispatcher } from '../../src/server/core/EventDispatcher';
import { Job } from 'bullmq';
global.fetch = jest.fn();
// Mock global location
@ -13,15 +13,24 @@ jest.mock('vitest', () => ({
vi: jest,
}));
export const waitUntilFinishedMock = jest.fn().mockResolvedValue({ success: true, stdout: '' });
jest.mock('bullmq', () => ({
Queue: jest.fn().mockImplementation(() => ({
add: jest.fn(),
add: jest.fn(() => {
const job: Job = fromPartial({
waitUntilFinished: waitUntilFinishedMock,
});
return Promise.resolve(job);
}),
getRepeatableJobs: jest.fn().mockResolvedValue([]),
removeRepeatableByKey: jest.fn(),
obliterate: jest.fn(),
close: jest.fn(),
})),
QueueEvents: jest.fn().mockImplementation(() => ({
on: jest.fn(),
close: jest.fn(),
})),
}));
@ -41,6 +50,7 @@ jest.mock('../../src/server/core/Logger', () => ({
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
@ -49,7 +59,3 @@ jest.mock('next/config', () => () => ({
...process.env,
},
}));
afterAll(() => {
EventDispatcher.clear();
});