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