Include nodemon config in build artifact

This commit is contained in:
Eli Bosley
2025-11-19 20:43:37 -05:00
parent 31af99e52f
commit e5e77321da
34 changed files with 433 additions and 1225 deletions

View File

@@ -1,22 +0,0 @@
{
"$schema": "https://json.schemastore.org/pm2-ecosystem",
"apps": [
{
"name": "unraid-api",
"script": "./dist/main.js",
"cwd": "/usr/local/unraid-api",
"exec_mode": "fork",
"wait_ready": true,
"listen_timeout": 15000,
"max_restarts": 10,
"min_uptime": 10000,
"watch": false,
"interpreter": "/usr/local/bin/node",
"ignore_watch": ["node_modules", "src", ".env.*", "myservers.cfg"],
"out_file": "/var/log/graphql-api.log",
"error_file": "/var/log/graphql-api.log",
"merge_logs": true,
"kill_timeout": 10000
}
]
}

View File

@@ -1673,8 +1673,8 @@ type PackageVersions {
"""npm version"""
npm: String
"""pm2 version"""
pm2: String
"""nodemon version"""
nodemon: String
"""Git version"""
git: String

View File

@@ -1257,7 +1257,7 @@ type Versions {
openssl: String
perl: String
php: String
pm2: String
nodemon: String
postfix: String
postgresql: String
python: String

18
api/nodemon.json Normal file
View File

@@ -0,0 +1,18 @@
{
"watch": [
"dist/main.js",
"myservers.cfg"
],
"ignore": [
"node_modules",
"src",
".env.*"
],
"exec": "node ./dist/main.js",
"signal": "SIGTERM",
"ext": "js,json",
"restartable": "rs",
"env": {
"NODE_ENV": "production"
}
}

View File

@@ -137,7 +137,7 @@
"pino": "9.9.0",
"pino-http": "10.5.0",
"pino-pretty": "13.1.1",
"pm2": "6.0.8",
"nodemon": "3.1.10",
"reflect-metadata": "^0.1.14",
"rxjs": "7.8.2",
"semver": "7.7.2",
@@ -203,7 +203,6 @@
"eslint-plugin-no-relative-import-paths": "1.6.1",
"eslint-plugin-prettier": "5.5.4",
"jiti": "2.5.1",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"rollup-plugin-node-externals": "8.1.0",
"supertest": "7.1.4",

View File

@@ -7,7 +7,7 @@ import { exit } from 'process';
import type { PackageJson } from 'type-fest';
import { $, cd } from 'zx';
import { getDeploymentVersion } from './get-deployment-version.js';
import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js';
type ApiPackageJson = PackageJson & {
version: string;
@@ -94,7 +94,7 @@ try {
await writeFile('./deploy/pack/package.json', JSON.stringify(parsedPackageJson, null, 4));
// Copy necessary files to the pack directory
await $`cp -r dist README.md .env.* ecosystem.config.json ./deploy/pack/`;
await $`cp -r dist README.md .env.* nodemon.json ./deploy/pack/`;
// Change to the pack directory and install dependencies
cd('./deploy/pack');

View File

@@ -1,5 +0,0 @@
/* eslint-disable no-undef */
// Dummy process for PM2 testing
setInterval(() => {
// Keep process alive
}, 1000);

View File

@@ -1,222 +0,0 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execa } from 'execa';
import pm2 from 'pm2';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running.js';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const PROJECT_ROOT = join(__dirname, '../../../../..');
const DUMMY_PROCESS_PATH = join(__dirname, 'dummy-process.js');
const CLI_PATH = join(PROJECT_ROOT, 'dist/cli.js');
const TEST_PROCESS_NAME = 'test-unraid-api';
// Shared PM2 connection state
let pm2Connected = false;
// Helper to ensure PM2 connection is established
async function ensurePM2Connection() {
if (pm2Connected) return;
return new Promise<void>((resolve, reject) => {
pm2.connect((err) => {
if (err) {
reject(err);
return;
}
pm2Connected = true;
resolve();
});
});
}
// Helper to delete specific test processes (lightweight, reuses connection)
async function deleteTestProcesses() {
if (!pm2Connected) {
// No connection, nothing to clean up
return;
}
const deletePromise = new Promise<void>((resolve) => {
// Delete specific processes we might have created
const processNames = ['unraid-api', TEST_PROCESS_NAME];
let deletedCount = 0;
const deleteNext = () => {
if (deletedCount >= processNames.length) {
resolve();
return;
}
const processName = processNames[deletedCount];
pm2.delete(processName, () => {
// Ignore errors, process might not exist
deletedCount++;
deleteNext();
});
};
deleteNext();
});
const timeoutPromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), 3000); // 3 second timeout
});
return Promise.race([deletePromise, timeoutPromise]);
}
// Helper to ensure PM2 is completely clean (heavy cleanup with daemon kill)
async function cleanupAllPM2Processes() {
// First delete test processes if we have a connection
if (pm2Connected) {
await deleteTestProcesses();
}
return new Promise<void>((resolve) => {
// Always connect fresh for daemon kill (in case we weren't connected)
pm2.connect((err) => {
if (err) {
// If we can't connect, assume PM2 is not running
pm2Connected = false;
resolve();
return;
}
// Kill the daemon to ensure fresh state
pm2.killDaemon(() => {
pm2.disconnect();
pm2Connected = false;
// Small delay to let PM2 fully shutdown
setTimeout(resolve, 500);
});
});
});
}
describe.skipIf(!!process.env.CI)('PM2 integration tests', () => {
beforeAll(async () => {
// Set PM2_HOME to use home directory for testing (not /var/log)
process.env.PM2_HOME = join(homedir(), '.pm2');
// Build the CLI if it doesn't exist (only for CLI tests)
if (!existsSync(CLI_PATH)) {
console.log('Building CLI for integration tests...');
try {
await execa('pnpm', ['build'], {
cwd: PROJECT_ROOT,
stdio: 'inherit',
timeout: 120000, // 2 minute timeout for build
});
} catch (error) {
console.error('Failed to build CLI:', error);
throw new Error(
'Cannot run CLI integration tests without built CLI. Run `pnpm build` first.'
);
}
}
// Only do a full cleanup once at the beginning
await cleanupAllPM2Processes();
}, 150000); // 2.5 minute timeout for setup
afterAll(async () => {
// Only do a full cleanup once at the end
await cleanupAllPM2Processes();
});
afterEach(async () => {
// Lightweight cleanup after each test - just delete our test processes
await deleteTestProcesses();
}, 5000); // 5 second timeout for cleanup
describe('isUnraidApiRunning function', () => {
it('should return false when PM2 is not running the unraid-api process', async () => {
const result = await isUnraidApiRunning();
expect(result).toBe(false);
});
it('should return true when PM2 has unraid-api process running', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start a dummy process with the name 'unraid-api'
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
resolve();
}
);
});
// Give PM2 time to start the process
await new Promise((resolve) => setTimeout(resolve, 2000));
const result = await isUnraidApiRunning();
expect(result).toBe(true);
}, 30000);
it('should return false when unraid-api process is stopped', async () => {
// Ensure PM2 connection
await ensurePM2Connection();
// Start and then stop the process
await new Promise<void>((resolve, reject) => {
pm2.start(
{
script: DUMMY_PROCESS_PATH,
name: 'unraid-api',
},
(startErr) => {
if (startErr) return reject(startErr);
// Stop the process after starting
setTimeout(() => {
pm2.stop('unraid-api', (stopErr) => {
if (stopErr) return reject(stopErr);
resolve();
});
}, 1000);
}
);
});
await new Promise((resolve) => setTimeout(resolve, 1000));
const result = await isUnraidApiRunning();
expect(result).toBe(false);
}, 30000);
it('should handle PM2 connection errors gracefully', async () => {
// Disconnect PM2 first to ensure we're testing fresh connection
await new Promise<void>((resolve) => {
pm2.disconnect();
pm2Connected = false;
setTimeout(resolve, 100);
});
// Set an invalid PM2_HOME to force connection failure
const originalPM2Home = process.env.PM2_HOME;
process.env.PM2_HOME = '/invalid/path/that/does/not/exist';
const result = await isUnraidApiRunning();
expect(result).toBe(false);
// Restore original PM2_HOME
if (originalPM2Home) {
process.env.PM2_HOME = originalPM2Home;
} else {
delete process.env.PM2_HOME;
}
}, 15000); // 15 second timeout to allow for the Promise.race timeout
});
});

View File

@@ -0,0 +1,54 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
describe('isUnraidApiRunning (nodemon pid detection)', () => {
let tempDir: string;
let pidPath: string;
beforeAll(() => {
tempDir = mkdtempSync(join(tmpdir(), 'unraid-api-'));
pidPath = join(tempDir, 'nodemon.pid');
});
afterAll(() => {
rmSync(tempDir, { recursive: true, force: true });
});
afterEach(() => {
vi.resetModules();
});
async function loadIsRunning() {
vi.doMock('@app/environment.js', async () => {
const actual =
await vi.importActual<typeof import('@app/environment.js')>('@app/environment.js');
return { ...actual, NODEMON_PID_PATH: pidPath };
});
const module = await import('@app/core/utils/process/unraid-api-running.js');
return module.isUnraidApiRunning;
}
it('returns false when pid file is missing', async () => {
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(false);
});
it('returns true when a live pid is recorded', async () => {
writeFileSync(pidPath, `${process.pid}`);
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(true);
});
it('returns false when pid file is invalid', async () => {
writeFileSync(pidPath, 'not-a-number');
const isUnraidApiRunning = await loadIsRunning();
expect(await isUnraidApiRunning()).toBe(false);
});
});

View File

@@ -17,7 +17,7 @@ const nullDestination = pino.destination({
export const logDestination =
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
// Since process output is piped directly to the log file, we should not colorize stdout
// to avoid ANSI escape codes in the log file
const stream = SUPPRESS_LOGS
? nullDestination
@@ -25,7 +25,7 @@ const stream = SUPPRESS_LOGS
? pretty({
singleLine: true,
hideObject: false,
colorize: false, // No colors since PM2 writes stdout to file
colorize: false, // No colors since logs are written directly to file
colorizeObjects: false,
levelFirst: false,
ignore: 'hostname,pid',

View File

@@ -1,40 +0,0 @@
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
const { PM2_HOME } = await import('@app/environment.js');
// Set PM2_HOME if not already set
if (!process.env.PM2_HOME) {
process.env.PM2_HOME = PM2_HOME;
}
const pm2Module = await import('pm2');
const pm2 = pm2Module.default || pm2Module;
const pm2Promise = new Promise<boolean>((resolve) => {
pm2.connect(function (err) {
if (err) {
// Don't reject here, resolve with false since we can't connect to PM2
resolve(false);
return;
}
// Now try to describe unraid-api specifically
pm2.describe('unraid-api', function (err, processDescription) {
if (err || processDescription.length === 0) {
// Service not found or error occurred
resolve(false);
} else {
const isOnline = processDescription?.[0]?.pm2_env?.status === 'online';
resolve(isOnline);
}
pm2.disconnect();
});
});
});
const timeoutPromise = new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 10000); // 10 second timeout
});
return Promise.race([pm2Promise, timeoutPromise]);
};

View File

@@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { NODEMON_PID_PATH } from '@app/environment.js';
export const isUnraidApiRunning = async (): Promise<boolean> => {
if (!(await fileExists(NODEMON_PID_PATH))) {
return false;
}
const pidText = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
const pid = Number.parseInt(pidText, 10);
if (Number.isNaN(pid)) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};

View File

@@ -98,13 +98,22 @@ export const MOTHERSHIP_GRAPHQL_LINK = process.env.MOTHERSHIP_GRAPHQL_LINK
? 'https://staging.mothership.unraid.net/ws'
: 'https://mothership.unraid.net/ws';
export const PM2_HOME = process.env.PM2_HOME ?? '/var/log/.pm2';
export const PM2_PATH = join(import.meta.dirname, '../../', 'node_modules', 'pm2', 'bin', 'pm2');
export const ECOSYSTEM_PATH = join(import.meta.dirname, '../../', 'ecosystem.config.json');
export const PATHS_LOGS_DIR =
process.env.PATHS_LOGS_DIR ?? process.env.LOGS_DIR ?? '/var/log/unraid-api';
export const PATHS_LOGS_FILE = process.env.PATHS_LOGS_FILE ?? '/var/log/graphql-api.log';
export const NODEMON_PATH = join(
import.meta.dirname,
'../../',
'node_modules',
'nodemon',
'bin',
'nodemon.js'
);
export const NODEMON_CONFIG_PATH = join(import.meta.dirname, '../../', 'nodemon.json');
export const NODEMON_PID_PATH = process.env.NODEMON_PID_PATH ?? '/var/run/unraid-api/nodemon.pid';
export const UNRAID_API_CWD = process.env.UNRAID_API_CWD ?? join(import.meta.dirname, '../../');
export const PATHS_CONFIG_MODULES =
process.env.PATHS_CONFIG_MODULES ?? '/boot/config/plugins/dynamix.my.servers/configs';

View File

@@ -26,10 +26,10 @@ const mockApiReportService = {
generateReport: vi.fn(),
};
// Mock PM2 check
// Mock process manager check
const mockIsUnraidApiRunning = vi.fn().mockResolvedValue(true);
vi.mock('@app/core/utils/pm2/unraid-api-running.js', () => ({
vi.mock('@app/core/utils/process/unraid-api-running.js', () => ({
isUnraidApiRunning: () => mockIsUnraidApiRunning(),
}));
@@ -50,7 +50,7 @@ describe('ReportCommand', () => {
// Clear mocks
vi.clearAllMocks();
// Reset PM2 mock to default
// Reset nodemon mock to default
mockIsUnraidApiRunning.mockResolvedValue(true);
});
@@ -150,7 +150,7 @@ describe('ReportCommand', () => {
// Reset mocks
vi.clearAllMocks();
// Test with API running but PM2 check returns true
// Test with API running but status check returns true
mockIsUnraidApiRunning.mockResolvedValue(true);
await reportCommand.report();
expect(mockApiReportService.generateReport).toHaveBeenCalledWith(true);

View File

@@ -4,7 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js';
import { LegacyConfigModule } from '@app/unraid-api/config/legacy-config.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
@@ -21,7 +21,7 @@ import { UnraidFileModifierModule } from '@app/unraid-api/unraid-file-modifier/u
PluginCliModule.register(),
UnraidFileModifierModule,
],
providers: [LogService, PM2Service, ApiKeyService, DependencyService, ApiReportService],
providers: [LogService, NodemonService, ApiKeyService, DependencyService, ApiReportService],
exports: [ApiReportService, LogService, ApiKeyService],
})
export class CliServicesModule {}

View File

@@ -13,6 +13,7 @@ import { DeveloperCommand } from '@app/unraid-api/cli/developer/developer.comman
import { DeveloperQuestions } from '@app/unraid-api/cli/developer/developer.questions.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { LogsCommand } from '@app/unraid-api/cli/logs.command.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import {
InstallPluginCommand,
ListPluginCommand,
@@ -20,7 +21,6 @@ import {
RemovePluginCommand,
} from '@app/unraid-api/cli/plugins/plugin.command.js';
import { RemovePluginQuestionSet } from '@app/unraid-api/cli/plugins/remove-plugin.questions.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { ReportCommand } from '@app/unraid-api/cli/report.command.js';
import { RestartCommand } from '@app/unraid-api/cli/restart.command.js';
import { SSOCommand } from '@app/unraid-api/cli/sso/sso.command.js';
@@ -64,7 +64,7 @@ const DEFAULT_PROVIDERS = [
DeveloperQuestions,
DeveloperToolsService,
LogService,
PM2Service,
NodemonService,
ApiKeyService,
DependencyService,
ApiReportService,

View File

@@ -559,6 +559,17 @@ export type CpuLoad = {
percentUser: Scalars['Float']['output'];
};
export type CpuPackages = Node & {
__typename?: 'CpuPackages';
id: Scalars['PrefixedID']['output'];
/** Power draw per package (W) */
power: Array<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** Total CPU package power draw (W) */
totalPower: Scalars['Float']['output'];
};
export type CpuUtilization = Node & {
__typename?: 'CpuUtilization';
/** CPU load for each core */
@@ -869,6 +880,7 @@ export type InfoCpu = Node & {
manufacturer?: Maybe<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +897,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1531,14 +1545,14 @@ export type PackageVersions = {
nginx?: Maybe<Scalars['String']['output']>;
/** Node.js version */
node?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
/** npm version */
npm?: Maybe<Scalars['String']['output']>;
/** OpenSSL version */
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {
@@ -2053,6 +2067,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};

View File

@@ -1,6 +1,6 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
interface LogsOptions {
lines: number;
@@ -8,7 +8,7 @@ interface LogsOptions {
@Command({ name: 'logs', description: 'View logs' })
export class LogsCommand extends CommandRunner {
constructor(private readonly pm2: PM2Service) {
constructor(private readonly nodemon: NodemonService) {
super();
}
@@ -20,13 +20,6 @@ export class LogsCommand extends CommandRunner {
async run(_: string[], options?: LogsOptions): Promise<void> {
const lines = options?.lines ?? 100;
await this.pm2.run(
{ tag: 'PM2 Logs', stdio: 'inherit' },
'logs',
'unraid-api',
'--lines',
lines.toString(),
'--raw'
);
await this.nodemon.logs(lines);
}
}

View File

@@ -0,0 +1,99 @@
import { createWriteStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import { execa } from 'execa';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
vi.mock('node:fs', () => ({
createWriteStream: vi.fn(() => ({ pipe: vi.fn() })),
}));
vi.mock('node:fs/promises');
vi.mock('execa', () => ({ execa: vi.fn() }));
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn().mockResolvedValue(false),
}));
vi.mock('@app/environment.js', () => ({
NODEMON_CONFIG_PATH: '/etc/unraid-api/nodemon.json',
NODEMON_PATH: '/usr/bin/nodemon',
NODEMON_PID_PATH: '/var/run/unraid-api/nodemon.pid',
PATHS_LOGS_DIR: '/var/log/unraid-api',
PATHS_LOGS_FILE: '/var/log/graphql-api.log',
UNRAID_API_CWD: '/usr/local/unraid-api',
}));
describe('NodemonService', () => {
const logger = {
trace: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as NodemonService['logger'];
const mockMkdir = vi.mocked(fs.mkdir);
const mockWriteFile = vi.mocked(fs.writeFile);
const mockRm = vi.mocked(fs.rm);
beforeEach(() => {
vi.clearAllMocks();
mockMkdir.mockResolvedValue(undefined);
mockWriteFile.mockResolvedValue(undefined as unknown as void);
mockRm.mockResolvedValue(undefined as unknown as void);
vi.mocked(fileExists).mockResolvedValue(false);
});
it('ensures directories needed by nodemon exist', async () => {
const service = new NodemonService(logger);
await service.ensureNodemonDependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledWith('/var/run/unraid-api', { recursive: true });
});
it('starts nodemon and writes pid file', async () => {
const service = new NodemonService(logger);
const stdout = { pipe: vi.fn() };
const stderr = { pipe: vi.fn() };
const unref = vi.fn();
vi.mocked(execa).mockReturnValue({
pid: 123,
stdout,
stderr,
unref,
} as unknown as ReturnType<typeof execa>);
await service.start({ env: { LOG_LEVEL: 'DEBUG' } });
expect(execa).toHaveBeenCalledWith(
'/usr/bin/nodemon',
['--config', '/etc/unraid-api/nodemon.json', '--quiet'],
{
cwd: '/usr/local/unraid-api',
env: expect.objectContaining({ LOG_LEVEL: 'DEBUG' }),
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
}
);
expect(createWriteStream).toHaveBeenCalledWith('/var/log/graphql-api.log', { flags: 'a' });
expect(stdout.pipe).toHaveBeenCalled();
expect(stderr.pipe).toHaveBeenCalled();
expect(unref).toHaveBeenCalled();
expect(mockWriteFile).toHaveBeenCalledWith('/var/run/unraid-api/nodemon.pid', '123');
expect(logger.info).toHaveBeenCalledWith('Started nodemon (pid 123)');
});
it('returns not running when pid file is missing', async () => {
const service = new NodemonService(logger);
vi.mocked(fileExists).mockResolvedValue(false);
const result = await service.status();
expect(result).toBe(false);
expect(logger.info).toHaveBeenCalledWith('unraid-api is not running (no pid file).');
});
});

View File

@@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import { createWriteStream } from 'node:fs';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { execa } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import {
NODEMON_CONFIG_PATH,
NODEMON_PATH,
NODEMON_PID_PATH,
PATHS_LOGS_DIR,
PATHS_LOGS_FILE,
UNRAID_API_CWD,
} from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
type StartOptions = {
env?: Record<string, string | undefined>;
};
type StopOptions = {
/** When true, uses SIGKILL instead of SIGTERM */
force?: boolean;
/** Suppress warnings when there is no pid file */
quiet?: boolean;
};
@Injectable()
export class NodemonService {
constructor(private readonly logger: LogService) {}
async ensureNodemonDependencies() {
try {
await mkdir(PATHS_LOGS_DIR, { recursive: true });
await mkdir(dirname(NODEMON_PID_PATH), { recursive: true });
} catch (error) {
this.logger.error(
`Failed to fully ensure nodemon dependencies: ${error instanceof Error ? error.message : error}`
);
}
}
private async getStoredPid(): Promise<number | null> {
if (!(await fileExists(NODEMON_PID_PATH))) return null;
const contents = (await readFile(NODEMON_PID_PATH, 'utf-8')).trim();
const pid = Number.parseInt(contents, 10);
return Number.isNaN(pid) ? null : pid;
}
private async isPidRunning(pid: number): Promise<boolean> {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async start(options: StartOptions = {}) {
await this.ensureNodemonDependencies();
await this.stop({ quiet: true });
const env = { ...process.env, ...options.env } as Record<string, string>;
const logStream = createWriteStream(PATHS_LOGS_FILE, { flags: 'a' });
const nodemonProcess = execa(NODEMON_PATH, ['--config', NODEMON_CONFIG_PATH, '--quiet'], {
cwd: UNRAID_API_CWD,
env,
detached: true,
stdio: ['ignore', 'pipe', 'pipe'],
});
nodemonProcess.stdout?.pipe(logStream);
nodemonProcess.stderr?.pipe(logStream);
nodemonProcess.unref();
if (nodemonProcess.pid) {
await writeFile(NODEMON_PID_PATH, `${nodemonProcess.pid}`);
this.logger.info(`Started nodemon (pid ${nodemonProcess.pid})`);
} else {
this.logger.error('Failed to determine nodemon pid.');
}
}
async stop(options: StopOptions = {}) {
const pid = await this.getStoredPid();
if (!pid) {
if (!options.quiet) {
this.logger.warn('No nodemon pid file found. Nothing to stop.');
}
return;
}
const signal: NodeJS.Signals = options.force ? 'SIGKILL' : 'SIGTERM';
try {
process.kill(pid, signal);
this.logger.trace(`Sent ${signal} to nodemon (pid ${pid})`);
} catch (error) {
this.logger.error(`Failed to stop nodemon (pid ${pid}): ${error}`);
} finally {
await rm(NODEMON_PID_PATH, { force: true });
}
}
async restart(options: StartOptions = {}) {
await this.stop({ quiet: true });
await this.start(options);
}
async status(): Promise<boolean> {
const pid = await this.getStoredPid();
if (!pid) {
this.logger.info('unraid-api is not running (no pid file).');
return false;
}
const running = await this.isPidRunning(pid);
if (running) {
this.logger.info(`unraid-api is running under nodemon (pid ${pid}).`);
} else {
this.logger.warn(`Found nodemon pid file (${pid}) but the process is not running.`);
await rm(NODEMON_PID_PATH, { force: true });
}
return running;
}
async logs(lines = 100) {
const { stdout } = await execa('tail', ['-n', `${lines}`, PATHS_LOGS_FILE]);
this.logger.log(stdout);
}
}

View File

@@ -1,76 +0,0 @@
import * as fs from 'node:fs/promises';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
vi.mock('node:fs/promises');
vi.mock('execa');
vi.mock('@app/core/utils/files/file-exists.js', () => ({
fileExists: vi.fn().mockResolvedValue(false),
}));
vi.mock('@app/environment.js', () => ({
PATHS_LOGS_DIR: '/var/log/unraid-api',
PM2_HOME: '/var/log/.pm2',
PM2_PATH: '/path/to/pm2',
ECOSYSTEM_PATH: '/path/to/ecosystem.config.json',
SUPPRESS_LOGS: false,
LOG_LEVEL: 'info',
}));
describe('PM2Service', () => {
let pm2Service: PM2Service;
let logService: LogService;
const mockMkdir = vi.mocked(fs.mkdir);
beforeEach(() => {
vi.clearAllMocks();
logService = {
trace: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
log: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
} as unknown as LogService;
pm2Service = new PM2Service(logService);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('ensurePm2Dependencies', () => {
it('should create logs directory and log that PM2 will handle its own directory', async () => {
mockMkdir.mockResolvedValue(undefined);
await pm2Service.ensurePm2Dependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledTimes(1); // Only logs directory, not PM2_HOME
expect(logService.trace).toHaveBeenCalledWith(
'PM2_HOME will be created at /var/log/.pm2 when PM2 daemon starts'
);
});
it('should log error but not throw when logs directory creation fails', async () => {
mockMkdir.mockRejectedValue(new Error('Disk full'));
await expect(pm2Service.ensurePm2Dependencies()).resolves.not.toThrow();
expect(logService.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to fully ensure PM2 dependencies: Disk full')
);
});
it('should handle mkdir with recursive flag for nested logs path', async () => {
mockMkdir.mockResolvedValue(undefined);
await pm2Service.ensurePm2Dependencies();
expect(mockMkdir).toHaveBeenCalledWith('/var/log/unraid-api', { recursive: true });
expect(mockMkdir).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -1,134 +0,0 @@
import { Injectable } from '@nestjs/common';
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import type { Options, Result, ResultPromise } from 'execa';
import { execa, ExecaError } from 'execa';
import { fileExists } from '@app/core/utils/files/file-exists.js';
import { PATHS_LOGS_DIR, PM2_HOME, PM2_PATH } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
type CmdContext = Options & {
/** A tag for logging & debugging purposes. Should represent the operation being performed. */
tag: string;
/** Default: false.
*
* When true, results will not be automatically handled and logged.
* The caller must handle desired effects, such as logging, error handling, etc.
*/
raw?: boolean;
};
@Injectable()
export class PM2Service {
constructor(private readonly logger: LogService) {}
// Type Overload: if raw is true, return an execa ResultPromise (which is a Promise with extra properties)
/**
* Executes a PM2 command with the specified context and arguments.
* Handles logging automatically (stdout -> trace, stderr -> error), unless the `raw` flag is
* set to true, in which case the caller must handle desired effects.
*
* @param context - Execa Options for command execution, such as a unique tag for logging
* and whether the result should be handled raw.
* @param args - The arguments to pass to the PM2 command.
* @returns ResultPromise\<@param context\> When raw is true
* @returns Promise\<Result\> When raw is false
*/
run<T extends CmdContext>(context: T & { raw: true }, ...args: string[]): ResultPromise<T>;
run(context: CmdContext & { raw?: false }, ...args: string[]): Promise<Result>;
async run(context: CmdContext, ...args: string[]) {
const { tag, raw, ...execOptions } = context;
// Default to true to match execa's default behavior
execOptions.extendEnv ??= true;
execOptions.shell ??= 'bash';
// Ensure /usr/local/bin is in PATH for Node.js
const currentPath = execOptions.env?.PATH || process.env.PATH || '/usr/bin:/bin:/usr/sbin:/sbin';
const needsPathUpdate = !currentPath.includes('/usr/local/bin');
const finalPath = needsPathUpdate ? `/usr/local/bin:${currentPath}` : currentPath;
// Always ensure PM2_HOME is set in the environment for every PM2 command
execOptions.env = {
...execOptions.env,
PM2_HOME,
...(needsPathUpdate && { PATH: finalPath }),
};
const pm2Args = args.some((arg) => arg === '--no-color') ? args : ['--no-color', ...args];
const runCommand = () => execa(PM2_PATH, pm2Args, execOptions satisfies Options);
if (raw) {
return runCommand();
}
return runCommand()
.then((result) => {
this.logger.trace(result.stdout);
return result;
})
.catch((result: Result) => {
this.logger.error(`PM2 error occurred from tag "${tag}": ${result.stdout}\n`);
return result;
});
}
/**
* Deletes the PM2 dump file.
*
* This method removes the PM2 dump file located at `~/.pm2/dump.pm2` by default.
* It logs a message indicating that the PM2 dump has been cleared.
*
* @returns A promise that resolves once the dump file is removed.
*/
async deleteDump(dumpFile = join(PM2_HOME, 'dump.pm2')) {
await rm(dumpFile, { force: true });
this.logger.trace('PM2 dump cleared.');
}
async forceKillPm2Daemon() {
try {
// Find all PM2 daemon processes and kill them
const pids = (await execa('pgrep', ['-i', 'PM2'])).stdout.split('\n').filter(Boolean);
if (pids.length > 0) {
await execa('kill', ['-9', ...pids]);
this.logger.trace(`Killed PM2 daemon processes: ${pids.join(', ')}`);
}
} catch (err) {
if (err instanceof ExecaError && err.exitCode === 1) {
this.logger.trace('No PM2 daemon processes found.');
} else {
this.logger.error(`Error force killing PM2 daemon: ${err}`);
}
}
}
async deletePm2Home() {
if ((await fileExists(PM2_HOME)) && (await fileExists(join(PM2_HOME, 'pm2.log')))) {
await rm(PM2_HOME, { recursive: true, force: true });
this.logger.trace('PM2 home directory cleared.');
} else {
this.logger.trace('PM2 home directory does not exist.');
}
}
/**
* Ensures that the dependencies necessary for PM2 to start and operate are present.
* Creates PM2_HOME directory with proper permissions if it doesn't exist.
*/
async ensurePm2Dependencies() {
try {
// Create logs directory
await mkdir(PATHS_LOGS_DIR, { recursive: true });
// PM2 automatically creates and manages its home directory when the daemon starts
this.logger.trace(`PM2_HOME will be created at ${PM2_HOME} when PM2 daemon starts`);
} catch (error) {
// Log error but don't throw - let PM2 fail with its own error messages if the setup is incomplete
this.logger.error(
`Failed to fully ensure PM2 dependencies: ${error instanceof Error ? error.message : error}. PM2 may encounter issues during operation.`
);
}
}
}

View File

@@ -33,9 +33,9 @@ export class ReportCommand extends CommandRunner {
async report(): Promise<string | void> {
try {
// Check if API is running
const { isUnraidApiRunning } = await import('@app/core/utils/pm2/unraid-api-running.js');
const { isUnraidApiRunning } = await import('@app/core/utils/process/unraid-api-running.js');
const apiRunning = await isUnraidApiRunning().catch((err) => {
this.logger.debug('failed to get PM2 state with error: ' + err);
this.logger.debug('failed to check nodemon state with error: ' + err);
return false;
});

View File

@@ -2,9 +2,9 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import type { LogLevel } from '@app/core/log.js';
import { levels } from '@app/core/log.js';
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
import { LOG_LEVEL } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
export interface LogLevelOptions {
logLevel?: LogLevel;
@@ -22,7 +22,7 @@ export function parseLogLevelOption(val: string, allowedLevels: string[] = [...l
export class RestartCommand extends CommandRunner {
constructor(
private readonly logger: LogService,
private readonly pm2: PM2Service
private readonly nodemon: NodemonService
) {
super();
}
@@ -30,23 +30,9 @@ export class RestartCommand extends CommandRunner {
async run(_?: string[], options: LogLevelOptions = {}): Promise<void> {
try {
this.logger.info('Restarting the Unraid API...');
const env = { LOG_LEVEL: options.logLevel };
const { stderr, stdout } = await this.pm2.run(
{ tag: 'PM2 Restart', raw: true, extendEnv: true, env },
'restart',
ECOSYSTEM_PATH,
'--update-env',
'--mini-list'
);
if (stderr) {
this.logger.error(stderr.toString());
process.exit(1);
} else if (stdout) {
this.logger.info(stdout.toString());
} else {
this.logger.info('Unraid API restarted');
}
const env = { LOG_LEVEL: options.logLevel?.toUpperCase() };
await this.nodemon.restart({ env });
this.logger.info('Unraid API restarted');
} catch (error) {
if (error instanceof Error) {
this.logger.error(error.message);

View File

@@ -3,46 +3,23 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import type { LogLevel } from '@app/core/log.js';
import type { LogLevelOptions } from '@app/unraid-api/cli/restart.command.js';
import { levels } from '@app/core/log.js';
import { ECOSYSTEM_PATH, LOG_LEVEL } from '@app/environment.js';
import { LOG_LEVEL } from '@app/environment.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
import { parseLogLevelOption } from '@app/unraid-api/cli/restart.command.js';
@Command({ name: 'start', description: 'Start the Unraid API' })
export class StartCommand extends CommandRunner {
constructor(
private readonly logger: LogService,
private readonly pm2: PM2Service
private readonly nodemon: NodemonService
) {
super();
}
async cleanupPM2State() {
await this.pm2.ensurePm2Dependencies();
await this.pm2.run({ tag: 'PM2 Stop' }, 'stop', ECOSYSTEM_PATH);
await this.pm2.run({ tag: 'PM2 Update' }, 'update');
await this.pm2.deleteDump();
await this.pm2.run({ tag: 'PM2 Delete' }, 'delete', ECOSYSTEM_PATH);
}
async run(_: string[], options: LogLevelOptions): Promise<void> {
this.logger.info('Starting the Unraid API');
await this.cleanupPM2State();
const env = { LOG_LEVEL: options.logLevel };
const { stderr, stdout } = await this.pm2.run(
{ tag: 'PM2 Start', raw: true, extendEnv: true, env },
'start',
ECOSYSTEM_PATH,
'--update-env',
'--mini-list'
);
if (stdout) {
this.logger.log(stdout.toString());
}
if (stderr) {
this.logger.error(stderr.toString());
process.exit(1);
}
await this.nodemon.start({ env: { LOG_LEVEL: options.logLevel?.toUpperCase() } });
}
@Option({

View File

@@ -1,18 +1,13 @@
import { Command, CommandRunner } from 'nest-commander';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
@Command({ name: 'status', description: 'Check status of unraid-api service' })
export class StatusCommand extends CommandRunner {
constructor(private readonly pm2: PM2Service) {
constructor(private readonly nodemon: NodemonService) {
super();
}
async run(): Promise<void> {
await this.pm2.run(
{ tag: 'PM2 Status', stdio: 'inherit', raw: true },
'status',
'unraid-api',
'--mini-list'
);
await this.nodemon.status();
}
}

View File

@@ -1,41 +1,28 @@
import { Command, CommandRunner, Option } from 'nest-commander';
import { ECOSYSTEM_PATH } from '@app/environment.js';
import { PM2Service } from '@app/unraid-api/cli/pm2.service.js';
import { NodemonService } from '@app/unraid-api/cli/nodemon.service.js';
interface StopCommandOptions {
delete: boolean;
force: boolean;
}
@Command({
name: 'stop',
description: 'Stop the Unraid API',
})
export class StopCommand extends CommandRunner {
constructor(private readonly pm2: PM2Service) {
constructor(private readonly nodemon: NodemonService) {
super();
}
@Option({
flags: '-d, --delete',
description: 'Delete the PM2 home directory',
flags: '-f, --force',
description: 'Forcefully stop the API process',
})
parseDelete(): boolean {
parseForce(): boolean {
return true;
}
async run(_: string[], options: StopCommandOptions = { delete: false }) {
if (options.delete) {
await this.pm2.run({ tag: 'PM2 Kill', stdio: 'inherit' }, 'kill', '--no-autorestart');
await this.pm2.forceKillPm2Daemon();
await this.pm2.deletePm2Home();
} else {
await this.pm2.run(
{ tag: 'PM2 Delete', stdio: 'inherit' },
'delete',
ECOSYSTEM_PATH,
'--no-autorestart',
'--mini-list'
);
}
async run(_: string[], options: StopCommandOptions = { force: false }) {
await this.nodemon.stop({ force: options.force });
}
}

View File

@@ -25,8 +25,8 @@ export class PackageVersions {
@Field(() => String, { nullable: true, description: 'npm version' })
npm?: string;
@Field(() => String, { nullable: true, description: 'pm2 version' })
pm2?: string;
@Field(() => String, { nullable: true, description: 'nodemon version' })
nodemon?: string;
@Field(() => String, { nullable: true, description: 'Git version' })
git?: string;

View File

@@ -3,6 +3,7 @@ import { ResolveField, Resolver } from '@nestjs/graphql';
import { versions } from 'systeminformation';
import { getPackageJson } from '@app/environment.js';
import {
CoreVersions,
InfoVersions,
@@ -34,7 +35,7 @@ export class VersionsResolver {
openssl: softwareVersions.openssl,
node: softwareVersions.node,
npm: softwareVersions.npm,
pm2: softwareVersions.pm2,
nodemon: getPackageJson().dependencies?.nodemon,
git: softwareVersions.git,
nginx: softwareVersions.nginx,
php: softwareVersions.php,

View File

@@ -140,16 +140,6 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {
apiLogger.info('Server listening on %s', result);
}
// This 'ready' signal tells pm2 that the api has started.
// PM2 documents this as Graceful Start or Clean Restart.
// See https://pm2.keymetrics.io/docs/usage/signals-clean-restart/
if (process.send) {
process.send('ready');
} else {
apiLogger.warn(
'Warning: process.send is unavailable. This will affect IPC communication with PM2.'
);
}
apiLogger.info('Nest Server is now listening');
return app;

607
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1523,8 +1523,8 @@ export type PackageVersions = {
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {

View File

@@ -559,6 +559,17 @@ export type CpuLoad = {
percentUser: Scalars['Float']['output'];
};
export type CpuPackages = Node & {
__typename?: 'CpuPackages';
id: Scalars['PrefixedID']['output'];
/** Power draw per package (W) */
power: Array<Scalars['Float']['output']>;
/** Temperature per package (°C) */
temp: Array<Scalars['Float']['output']>;
/** Total CPU package power draw (W) */
totalPower: Scalars['Float']['output'];
};
export type CpuUtilization = Node & {
__typename?: 'CpuUtilization';
/** CPU load for each core */
@@ -869,6 +880,7 @@ export type InfoCpu = Node & {
manufacturer?: Maybe<Scalars['String']['output']>;
/** CPU model */
model?: Maybe<Scalars['String']['output']>;
packages: CpuPackages;
/** Number of physical processors */
processors?: Maybe<Scalars['Int']['output']>;
/** CPU revision */
@@ -885,6 +897,8 @@ export type InfoCpu = Node & {
stepping?: Maybe<Scalars['Int']['output']>;
/** Number of CPU threads */
threads?: Maybe<Scalars['Int']['output']>;
/** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */
topology: Array<Array<Array<Scalars['Int']['output']>>>;
/** CPU vendor */
vendor?: Maybe<Scalars['String']['output']>;
/** CPU voltage */
@@ -1531,14 +1545,14 @@ export type PackageVersions = {
nginx?: Maybe<Scalars['String']['output']>;
/** Node.js version */
node?: Maybe<Scalars['String']['output']>;
/** nodemon version */
nodemon?: Maybe<Scalars['String']['output']>;
/** npm version */
npm?: Maybe<Scalars['String']['output']>;
/** OpenSSL version */
openssl?: Maybe<Scalars['String']['output']>;
/** PHP version */
php?: Maybe<Scalars['String']['output']>;
/** pm2 version */
pm2?: Maybe<Scalars['String']['output']>;
};
export type ParityCheck = {
@@ -2053,6 +2067,7 @@ export type Subscription = {
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
systemMetricsMemory: MemoryUtilization;
upsUpdates: UpsDevice;
};

View File

@@ -1,2 +1,2 @@
export * from './fragment-masking';
export * from './gql';
export * from "./fragment-masking";
export * from "./gql";