feat: configure PM2 on startup

This commit is contained in:
Eli Bosley
2025-01-28 12:20:10 -05:00
parent bddda823e1
commit b73623e72a
12 changed files with 123 additions and 106 deletions

View File

@@ -1,13 +1,13 @@
{ {
"$schema": "https://json.schemastore.org/pm2-ecosystem",
"apps": [ "apps": [
{ {
"name": "unraid-api", "name": "unraid-api",
"script": "./dist/main.js", "script": "./dist/main.js",
"cwd": "/usr/local/unraid-api", "cwd": "/usr/local/unraid-api",
"log": "/var/log/unraid-api/unraid-api.log",
"exec_mode": "fork", "exec_mode": "fork",
"wait_ready": true, "wait_ready": true,
"listen_timeout": 30000, "listen_timeout": 15000,
"max_restarts": 10, "max_restarts": 10,
"min_uptime": 10000, "min_uptime": 10000,
"ignore_watch": [ "ignore_watch": [
@@ -15,7 +15,8 @@
"src", "src",
".env.*", ".env.*",
"myservers.cfg" "myservers.cfg"
] ],
"log": "/var/log/unraid-api/unraid-api.log"
} }
] ]
} }

View File

@@ -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' }
);
}
};

View File

@@ -59,8 +59,6 @@ try {
// Must occur before config is loaded to ensure that the handler can fix broken configs // Must occur before config is loaded to ensure that the handler can fix broken configs
await startStoreSync(); await startStoreSync();
await setupLogRotation();
// Load my servers config file into store // Load my servers config file into store
await store.dispatch(loadConfigFile()); await store.dispatch(loadConfigFile());

View File

@@ -1,5 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { levels, LogLevel } from '@app/core/log';
import { LOG_LEVEL } from '@app/environment';
@Injectable() @Injectable()
export class LogService { export class LogService {
private logger = console; private logger = console;
@@ -8,23 +11,41 @@ export class LogService {
this.logger.clear(); 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 { log(message: string): void {
this.logger.log(message); if (this.shouldLog('info')) {
this.logger.log(message);
}
} }
info(message: string): void { info(message: string): void {
this.logger.info(message); if (this.shouldLog('info')) {
this.logger.info(message);
}
} }
warn(message: string): void { warn(message: string): void {
this.logger.warn(message); if (this.shouldLog('warn')) {
this.logger.warn(message);
}
} }
error(message: string, trace: string = ''): void { 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 { debug(message: any, ...optionalParams: any[]): void {
this.logger.debug(message, ...optionalParams); if (this.shouldLog('debug')) {
this.logger.debug(message, ...optionalParams);
}
} }
} }

View File

@@ -1,8 +1,12 @@
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { execa } from 'execa'; import { execa } from 'execa';
import { Command, CommandRunner, Option } from 'nest-commander'; import { Command, CommandRunner, Option } from 'nest-commander';
import type { LogLevel } from '@app/core/log';
import { ECOSYSTEM_PATH, PM2_PATH } from '@app/consts'; 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'; import { LogService } from '@app/unraid-api/cli/log.service';
interface StartCommandOptions { interface StartCommandOptions {
@@ -15,18 +19,47 @@ export class StartCommand extends CommandRunner {
super(); super();
} }
async configurePm2(): Promise<void> {
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<void> { async run(_: string[], options: StartCommandOptions): Promise<void> {
this.logger.info('Starting the Unraid API'); this.logger.info('Starting the Unraid API');
// Update PM2 first if necessary await this.configurePm2();
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);
}
const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : ''; const envLog = options['log-level'] ? `LOG_LEVEL=${options['log-level']}` : '';
const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [ const { stderr, stdout } = await execa(`${envLog} ${PM2_PATH}`.trim(), [

View File

@@ -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 { WriteFlashFileService } from '@app/unraid-api/cron/write-flash-file.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@Module({ @Module({
imports: [ScheduleModule.forRoot()], imports: [ScheduleModule.forRoot()],
providers: [LogCleanupService, WriteFlashFileService], providers: [WriteFlashFileService],
}) })
export class CronModule {} export class CronModule {}

View File

@@ -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);
}
}
}

View File

@@ -4,9 +4,9 @@ import { readFile, writeFile } from 'fs/promises';
import { import {
FileModification, FileModification,
FileModificationService,
ShouldApplyWithReason, ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; } 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 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; 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'); const fileContent = await readFile(AUTH_REQUEST_FILE, 'utf8');
if (fileContent.includes('$arrWhitelist')) { if (fileContent.includes('$arrWhitelist')) {
FileModificationService.backupFile(AUTH_REQUEST_FILE, true); backupFile(AUTH_REQUEST_FILE, true);
this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`); this.logger.debug(`Backup of ${AUTH_REQUEST_FILE} created.`);
const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n'); const filesToAddString = FILES_TO_ADD.map((file) => ` '${file}',`).join('\n');

View File

@@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises';
import { import {
FileModification, FileModification,
FileModificationService,
ShouldApplyWithReason, ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service'; } from '@app/unraid-api/unraid-file-modifier/unraid-file-modifier.service';
import { backupFile, restoreFile } from '@app/utils';
export default class SSOFileModification implements FileModification { export default class SSOFileModification implements FileModification {
id: string = 'sso'; id: string = 'sso';
@@ -47,9 +47,9 @@ function verifyUsernamePasswordAndSSO(string $username, string $password): bool
'<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>'; '<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
// Restore the original file if exists // Restore the original file if exists
await FileModificationService.restoreFile(this.loginFilePath, false); await restoreFile(this.loginFilePath, false);
// Backup the original content // Backup the original content
await FileModificationService.backupFile(this.loginFilePath, true); await backupFile(this.loginFilePath, true);
// Read the file content // Read the file content
let fileContent = await readFile(this.loginFilePath, 'utf-8'); 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.'); this.logger.log('Login Function replaced successfully.');
} }
async rollback(): Promise<void> { async rollback(): Promise<void> {
const restored = await FileModificationService.restoreFile(this.loginFilePath, false); const restored = await restoreFile(this.loginFilePath, false);
if (restored) { if (restored) {
this.logger.debug('SSO login file restored.'); this.logger.debug('SSO login file restored.');
} else { } else {

View File

@@ -1,5 +1,4 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 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 AuthRequestModification from '@app/unraid-api/unraid-file-modifier/modifications/auth-request.modification';
import SSOFileModification from '@app/unraid-api/unraid-file-modifier/modifications/sso.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<void> => {
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<boolean> => {
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;
}
};
} }

View File

@@ -16,4 +16,9 @@ describe('FileModificationService', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); expect(service).toBeDefined();
}); });
it('should load modifications', async () => {
const mods = await service.loadModifications();
expect(mods).toHaveLength(2);
})
}); });

View File

@@ -1,10 +1,9 @@
import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, ExecutionContext, Logger, UnauthorizedException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { copyFile, unlink } from 'node:fs/promises';
import strftime from 'strftime'; import strftime from 'strftime';
import { UserSchema } from '@app/graphql/generated/api/operations';
import { UserAccount } from './graphql/generated/api/types'; import { UserAccount } from './graphql/generated/api/types';
import { FastifyRequest } from './types/fastify'; import { FastifyRequest } from './types/fastify';
@@ -245,3 +244,40 @@ export function handleAuthError(
throw new UnauthorizedException(`${operation}: ${errorMessage}`); 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<void> => {
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<boolean> => {
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;
}
};