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": [
{
"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"
}
]
}

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
await startStoreSync();
await setupLogRotation();
// Load my servers config file into store
await store.dispatch(loadConfigFile());

View File

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

View File

@@ -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<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> {
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(), [

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 { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [ScheduleModule.forRoot()],
providers: [LogCleanupService, WriteFlashFileService],
providers: [WriteFlashFileService],
})
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 {
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');

View File

@@ -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
'<?php include "$docroot/plugins/dynamix.my.servers/include/sso-login.php"; ?>';
// 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<void> {
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 {

View File

@@ -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<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', () => {
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 { 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<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;
}
};