From b73623e72a223c57585059a2cc40c344035c974f Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Tue, 28 Jan 2025 12:20:10 -0500 Subject: [PATCH] feat: configure PM2 on startup --- api/ecosystem.config.json | 7 +-- api/src/core/logrotate/setup-logrotate.ts | 22 -------- api/src/index.ts | 2 - api/src/unraid-api/cli/log.service.ts | 31 +++++++++-- api/src/unraid-api/cli/start.command.ts | 53 +++++++++++++++---- api/src/unraid-api/cron/cron.module.ts | 3 +- .../unraid-api/cron/log-cleanup.service.ts | 18 ------- .../auth-request.modification.ts | 4 +- .../modifications/sso.modification.ts | 8 +-- .../unraid-file-modifier.service.ts | 36 ------------- .../unraid-file-modifier.spec.ts | 5 ++ api/src/utils.ts | 40 +++++++++++++- 12 files changed, 123 insertions(+), 106 deletions(-) delete mode 100644 api/src/core/logrotate/setup-logrotate.ts delete mode 100644 api/src/unraid-api/cron/log-cleanup.service.ts diff --git a/api/ecosystem.config.json b/api/ecosystem.config.json index ee7ab00f0..8cb5960c3 100644 --- a/api/ecosystem.config.json +++ b/api/ecosystem.config.json @@ -1,13 +1,13 @@ { + "$schema": "https://json.schemastore.org/pm2-ecosystem", "apps": [ { "name": "unraid-api", "script": "./dist/main.js", "cwd": "/usr/local/unraid-api", - "log": "/var/log/unraid-api/unraid-api.log", "exec_mode": "fork", "wait_ready": true, - "listen_timeout": 30000, + "listen_timeout": 15000, "max_restarts": 10, "min_uptime": 10000, "ignore_watch": [ @@ -15,7 +15,8 @@ "src", ".env.*", "myservers.cfg" - ] + ], + "log": "/var/log/unraid-api/unraid-api.log" } ] } \ No newline at end of file diff --git a/api/src/core/logrotate/setup-logrotate.ts b/api/src/core/logrotate/setup-logrotate.ts deleted file mode 100644 index 577030cdf..000000000 --- a/api/src/core/logrotate/setup-logrotate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { writeFile } from 'fs/promises'; - -import { fileExists } from '@app/core/utils/files/file-exists'; - -export const setupLogRotation = async () => { - if (await fileExists('/etc/logrotate.d/unraid-api')) { - return; - } else { - await writeFile( - '/etc/logrotate.d/unraid-api', - ` -/var/log/unraid-api/*.log { - rotate 1 - missingok - size 5M - su root root -} -`, - { mode: '644' } - ); - } -}; diff --git a/api/src/index.ts b/api/src/index.ts index 3d6e70875..2e4f55710 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -59,8 +59,6 @@ try { // Must occur before config is loaded to ensure that the handler can fix broken configs await startStoreSync(); - await setupLogRotation(); - // Load my servers config file into store await store.dispatch(loadConfigFile()); diff --git a/api/src/unraid-api/cli/log.service.ts b/api/src/unraid-api/cli/log.service.ts index e725aeab3..ad1f5424e 100644 --- a/api/src/unraid-api/cli/log.service.ts +++ b/api/src/unraid-api/cli/log.service.ts @@ -1,5 +1,8 @@ import { Injectable } from '@nestjs/common'; +import { levels, LogLevel } from '@app/core/log'; +import { LOG_LEVEL } from '@app/environment'; + @Injectable() export class LogService { private logger = console; @@ -8,23 +11,41 @@ export class LogService { this.logger.clear(); } + shouldLog(level: LogLevel): boolean { + const logLevelsLowToHigh = levels; + return ( + logLevelsLowToHigh.indexOf(level) >= + logLevelsLowToHigh.indexOf(LOG_LEVEL.toLowerCase() as LogLevel) + ); + } + log(message: string): void { - this.logger.log(message); + if (this.shouldLog('info')) { + this.logger.log(message); + } } info(message: string): void { - this.logger.info(message); + if (this.shouldLog('info')) { + this.logger.info(message); + } } warn(message: string): void { - this.logger.warn(message); + if (this.shouldLog('warn')) { + this.logger.warn(message); + } } error(message: string, trace: string = ''): void { - this.logger.error(message, trace); + if (this.shouldLog('error')) { + this.logger.error(message, trace); + } } debug(message: any, ...optionalParams: any[]): void { - this.logger.debug(message, ...optionalParams); + if (this.shouldLog('debug')) { + this.logger.debug(message, ...optionalParams); + } } } diff --git a/api/src/unraid-api/cli/start.command.ts b/api/src/unraid-api/cli/start.command.ts index 037f6ed20..7f9975a9b 100644 --- a/api/src/unraid-api/cli/start.command.ts +++ b/api/src/unraid-api/cli/start.command.ts @@ -1,8 +1,12 @@ +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; + import { execa } from 'execa'; import { Command, CommandRunner, Option } from 'nest-commander'; +import type { LogLevel } from '@app/core/log'; import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; -import { levels, type LogLevel } from '@app/core/log'; +import { levels } from '@app/core/log'; import { LogService } from '@app/unraid-api/cli/log.service'; interface StartCommandOptions { @@ -15,18 +19,47 @@ export class StartCommand extends CommandRunner { super(); } + async configurePm2(): Promise { + if (existsSync('/tmp/pm2-configured')) { + return; + } + // Write a temp file when first started to prevent needing to run this again + // Install PM2 Logrotate + await execa(PM2_PATH, ['install', 'pm2-logrotate']) + .then(({ stdout }) => { + this.logger.debug(stdout); + }) + .catch(({ stderr }) => { + this.logger.error('PM2 Logrotate Error: ' + stderr); + }); + // Now set logrotate options + await execa(PM2_PATH, ['set', 'pm2-logrotate:retain', '2']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Set Error: ' + stderr)); + await execa(PM2_PATH, ['set', 'pm2-logrotate:compress', 'true']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Compress Error: ' + stderr)); + await execa(PM2_PATH, ['set', 'pm2-logrotate:max_size', '1M']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Logrotate Max Size Error: ' + stderr)); + + // PM2 Save Settings + await execa(PM2_PATH, ['set', 'pm2:autodump', 'true']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Autodump Error: ' + stderr)); + + // Update PM2 + await execa(PM2_PATH, ['update']) + .then(({ stdout }) => this.logger.debug(stdout)) + .catch(({ stderr }) => this.logger.error('PM2 Update Error: ' + stderr)); + + await writeFile('/tmp/pm2-configured', 'true'); + } + async run(_: string[], options: StartCommandOptions): Promise { this.logger.info('Starting the Unraid API'); - // Update PM2 first if necessary - const { stderr: updateErr, stdout: updateOut } = await execa(PM2_PATH, ['update']); - if (updateOut) { - this.logger.log(updateOut); - } - if (updateErr) { - this.logger.error('PM2 Update Error: ' + updateErr); - process.exit(1); - } + await this.configurePm2(); const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [ diff --git a/api/src/unraid-api/cron/cron.module.ts b/api/src/unraid-api/cron/cron.module.ts index 4d02c334d..e1b2ed984 100644 --- a/api/src/unraid-api/cron/cron.module.ts +++ b/api/src/unraid-api/cron/cron.module.ts @@ -1,10 +1,9 @@ -import { LogCleanupService } from '@app/unraid-api/cron/log-cleanup.service'; import { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service'; import { Module } from '@nestjs/common'; import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ScheduleModule.forRoot()], - providers: [LogCleanupService, WriteFlashFileService], + providers: [WriteFlashFileService], }) export class CronModule {} diff --git a/api/src/unraid-api/cron/log-cleanup.service.ts b/api/src/unraid-api/cron/log-cleanup.service.ts deleted file mode 100644 index ce54315b6..000000000 --- a/api/src/unraid-api/cron/log-cleanup.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; -import { execa } from 'execa'; - -@Injectable() -export class LogCleanupService { - private readonly logger = new Logger(LogCleanupService.name); - - @Cron('0 * * * *') - async handleCron() { - try { - this.logger.debug('Running logrotate'); - await execa(`/usr/sbin/logrotate`, ['/etc/logrotate.conf']); - } catch (error) { - this.logger.error(error); - } - } -} diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts index 30b5b1de8..4dc24b71e 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/auth-request.modification.ts @@ -4,9 +4,9 @@ import { readFile, writeFile } from 'fs/promises'; import { FileModification, - FileModificationService, ShouldApplyWithReason, } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +import { backupFile } from '@app/utils'; const AUTH_REQUEST_FILE = '/usr/local/emhttp/auth-request.php' as const; const WEB_COMPS_DIR = '/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components/_nuxt/' as const; @@ -34,7 +34,7 @@ export default class AuthRequestModification implements FileModification { const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8'); if (fileContent.includes('$arrWhitelist')) { - FileModificationService.backupFile(AUTH_REQUEST_FILE, true); + backupFile(AUTH_REQUEST_FILE, true); this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts index 0de6063d0..bf4b60592 100644 --- a/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/modifications/sso.modification.ts @@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises'; import { FileModification, - FileModificationService, ShouldApplyWithReason, } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; +import { backupFile, restoreFile } from '@app/utils'; export default class SSOFileModification implements FileModification { id: string = 'sso'; @@ -47,9 +47,9 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool ''; // Restore the original file if exists - await FileModificationService.restoreFile(this.loginFilePath, false); + await restoreFile(this.loginFilePath, false); // Backup the original content - await FileModificationService.backupFile(this.loginFilePath, true); + await backupFile(this.loginFilePath, true); // Read the file content let fileContent = await readFile(this.loginFilePath, 'utf-8'); @@ -73,7 +73,7 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool this.logger.log('Login Function replaced successfully.'); } async rollback(): Promise { - const restored = await FileModificationService.restoreFile(this.loginFilePath, false); + const restored = await restoreFile(this.loginFilePath, false); if (restored) { this.logger.debug('SSO login file restored.'); } else { diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts index 77dd45341..9efa40475 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; -import { copyFile, unlink } from 'node:fs/promises'; import AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification'; import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.modification'; @@ -122,40 +121,5 @@ export class UnraidFileModificationService implements OnModuleInit, OnModuleDest } } - /** - * Helper method to allow backing up a single file to a .bak file. - * @param path the file to backup, creates a .bak file in the same directory - * @throws Error if the file cannot be copied - */ - public static backupFile = async (path: string, throwOnMissing = true): Promise => { - try { - const backupPath = path + '.bak'; - await copyFile(path, backupPath); - } catch (err) { - if (throwOnMissing) { - throw new Error(`File does not exist: ${path}`); - } - } - }; - /** - * - * @param path Path to original (not .bak) file - * @param throwOnMissing Whether to throw an error if the backup file does not exist - * @throws Error if the backup file does not exist and throwOnMissing is true - * @returns boolean indicating whether the restore was successful - */ - public static restoreFile = async (path: string, throwOnMissing = true): Promise => { - const backupPath = path + '.bak'; - try { - await copyFile(backupPath, path); - await unlink(backupPath); - return true; - } catch { - if (throwOnMissing) { - throw new Error(`Backup file does not exist: ${backupPath}`); - } - return false; - } - }; } diff --git a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts index ee3f7719c..5b9d9e66a 100644 --- a/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts +++ b/api/src/unraid-api/unraid-file-modifier/unraid-file-modifier.spec.ts @@ -16,4 +16,9 @@ describe('FileModificationService', () => { it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should load modifications', async () => { + const mods = await service.loadModifications(); + expect(mods).toHaveLength(2); + }) }); diff --git a/api/src/utils.ts b/api/src/utils.ts index 989e1b8c3..bd95520b8 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,10 +1,9 @@ import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; +import { copyFile, unlink } from 'node:fs/promises'; import strftime from 'strftime'; -import { UserSchema } from '@app/graphql/generated/api/operations'; - import { UserAccount } from './graphql/generated/api/types'; import { FastifyRequest } from './types/fastify'; @@ -245,3 +244,40 @@ export function handleAuthError( throw new UnauthorizedException(`${operation}: ${errorMessage}`); } + +/** + * Helper method to allow backing up a single file to a .bak file. + * @param path the file to backup, creates a .bak file in the same directory + * @throws Error if the file cannot be copied + */ +export const backupFile = async (path: string, throwOnMissing = true): Promise => { + try { + const backupPath = path + '.bak'; + await copyFile(path, backupPath); + } catch (err) { + if (throwOnMissing) { + throw new Error(`File does not exist: ${path}`); + } + } +}; + +/** + * + * @param path Path to original (not .bak) file + * @param throwOnMissing Whether to throw an error if the backup file does not exist + * @throws Error if the backup file does not exist and throwOnMissing is true + * @returns boolean indicating whether the restore was successful + */ +export const restoreFile = async (path: string, throwOnMissing = true): Promise => { + const backupPath = path + '.bak'; + try { + await copyFile(backupPath, path); + await unlink(backupPath); + return true; + } catch { + if (throwOnMissing) { + throw new Error(`Backup file does not exist: ${backupPath}`); + } + return false; + } +};