mirror of
https://github.com/unraid/api.git
synced 2025-12-31 13:39:52 -06:00
feat: configure PM2 on startup
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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' }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(), [
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user