feat: pm2 fully working

This commit is contained in:
Eli Bosley
2024-10-23 18:50:34 -04:00
parent b9cedb70ff
commit b20f69c208
9 changed files with 78 additions and 224 deletions

View File

@@ -5,7 +5,13 @@
"script": "npm",
"args": "run boot:unraid",
"log": "/var/log/unraid-api/unraid-api.log",
"exec_mode": "fork"
"exec_mode": "fork",
"ignore_watch": [
"node_modules",
"src",
".env.*",
"myservers.cfg"
]
}
]
}

View File

@@ -1,31 +0,0 @@
import { beforeEach, expect, test, vi } from 'vitest';
// Preloading imports for faster tests
import '@app/cli/commands/restart';
import '@app/cli/commands/start';
import '@app/cli/commands/stop';
beforeEach(() => {
vi.resetAllMocks();
});
test('calls stop and then start', async () => {
vi.mock('@app/cli/commands/start');
vi.mock('@app/cli/commands/stop');
// Call restart
const { restart } = await import('@app/cli/commands/restart');
const { start } = await import('@app/cli/commands/start');
const { stop } = await import('@app/cli/commands/stop');
await restart();
// Check stop was called
expect(vi.mocked(stop).mock.calls.length).toBe(1);
// Check start was called
expect(vi.mocked(start).mock.calls.length).toBe(1);
// Check stop was called first
expect(vi.mocked(stop).mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(start).mock.invocationCallOrder[0]
);
});

View File

@@ -1,78 +0,0 @@
import { expect, test, vi } from 'vitest';
import { stop } from '@app/cli/commands/stop';
import { spawn } from 'node:child_process';
import { cliLogger } from '@app/core/log';
import { sleep } from '@app/core/utils/misc/sleep';
import { getAllUnraidApiPids } from '@app/cli/get-unraid-api-pid';
import path from 'node:path';
const spawnUnraidApiTestDaemon = (difficulty: 'easy' | 'hard') => {
const pathToJsFile = difficulty === 'easy' ? '../../setup/child-process-easy-to-kill.js' : '../../setup/child-process-hard-to-kill.js';
// Spawn child
console.log('Spawning child process at', path.join(import.meta.dirname, pathToJsFile));
const child = spawn('node', [path.join(import.meta.dirname, pathToJsFile)], {
// In the parent set the tracking environment variable
env: Object.assign(process.env, { _DAEMONIZE_PROCESS: '1' }),
stdio: 'ignore',
detached: true,
});
// Convert process into daemon
child.unref();
cliLogger.debug('Daemonized successfully!');
};
test('It stops successfully (easy)', async () => {
spawnUnraidApiTestDaemon('easy');
spawnUnraidApiTestDaemon('easy');
await sleep(100);
const { cliLogger } = await import('@app/core/log');
const loggerSpy = vi.spyOn(cliLogger, 'info');
const pids = await getAllUnraidApiPids();
expect(pids.length).toBe(2);
await stop();
const pids2 = await getAllUnraidApiPids();
expect(pids2.length).toBe(0);
expect(loggerSpy).toHaveBeenNthCalledWith(1,
'Stopping %s unraid-api process(es)...',
2,
);
});
test('It stops successfully (easy and hard)', async () => {
spawnUnraidApiTestDaemon('easy');
spawnUnraidApiTestDaemon('easy');
spawnUnraidApiTestDaemon('hard');
spawnUnraidApiTestDaemon('hard');
await sleep(100);
const { cliLogger } = await import('@app/core/log');
const loggerSpy = vi.spyOn(cliLogger, 'info');
const pids = await getAllUnraidApiPids();
expect(pids.length).toBe(4);
await stop();
const pids2 = await getAllUnraidApiPids();
expect(pids2.length).toBe(0);
expect(loggerSpy).toHaveBeenNthCalledWith(1,
'Stopping %s unraid-api process(es)...',
4,
);
expect(loggerSpy).toHaveBeenNthCalledWith(2,
'Stopping %s unraid-api process(es)...',
2,
);
expect(loggerSpy).toHaveBeenNthCalledWith(3,
'Stopping %s unraid-api process(es)...',
2,
);
expect(loggerSpy).toHaveBeenNthCalledWith(4, 'Process did not exit cleanly, forcing shutdown', expect.any(Error));
}, 15_000);

View File

@@ -1,7 +1,7 @@
import ipRegex from 'ip-regex';
import readLine from 'readline';
import { setEnv } from '@app/cli/set-env';
import { getUnraidApiPid } from '@app/cli/get-unraid-api-pid';
import { isUnraidApiRunning } from '@app/core/utils/pm2/unraid-api-running';
import { cliLogger } from '@app/core/log';
import { getters, store } from '@app/store';
import { stdout } from 'process';
@@ -18,12 +18,8 @@ import { API_VERSION } from '@app/environment';
import { loadStateFiles } from '@app/store/modules/emhttp';
import { ApolloClient, ApolloQueryResult, NormalizedCacheObject } from '@apollo/client/core';
type CloudQueryResult = NonNullable<
ApolloQueryResult<getCloudQuery>['data']['cloud']
>;
type ServersQueryResultServer = NonNullable<
ApolloQueryResult<getServersQuery>['data']['servers']
>[0];
type CloudQueryResult = NonNullable<ApolloQueryResult<getCloudQuery>['data']['cloud']>;
type ServersQueryResultServer = NonNullable<ApolloQueryResult<getServersQuery>['data']['servers']>[0];
type Verbosity = '' | '-v' | '-vv';
@@ -128,8 +124,7 @@ export const getServersData = async ({
const hashUrlRegex = () => /(.*)([a-z0-9]{40})(.*)/g;
export const anonymiseOrigins = (origins?: string[]): string[] => {
const originsWithoutSocks =
origins?.filter((url) => !url.endsWith('.sock')) ?? [];
const originsWithoutSocks = origins?.filter((url) => !url.endsWith('.sock')) ?? [];
return originsWithoutSocks
.map((origin) =>
origin
@@ -138,29 +133,17 @@ export const anonymiseOrigins = (origins?: string[]): string[] => {
// Replace ipv4 address using . separator with "IPV4ADDRESS"
.replace(ipRegex(), 'IPV4ADDRESS')
// Replace ipv4 address using - separator with "IPV4ADDRESS"
.replace(
new RegExp(ipRegex().toString().replace('\\.', '-')),
'/IPV4ADDRESS'
)
.replace(new RegExp(ipRegex().toString().replace('\\.', '-')), '/IPV4ADDRESS')
// Report WAN port
.replace(
`:${getters.config().remote.wanport || 443}`,
':WANPORT'
)
.replace(`:${getters.config().remote.wanport || 443}`, ':WANPORT')
)
.filter(Boolean);
};
const getAllowedOrigins = (
cloud: CloudQueryResult | null,
v: Verbosity
): string[] | null => {
const getAllowedOrigins = (cloud: CloudQueryResult | null, v: Verbosity): string[] | null => {
switch (v) {
case '-vv':
return (
cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ??
[]
);
return cloud?.allowedOrigins.filter((url) => !url.endsWith('.sock')) ?? [];
case '-v':
return anonymiseOrigins(cloud?.allowedOrigins ?? []);
default:
@@ -168,37 +151,23 @@ const getAllowedOrigins = (
}
};
const getReadableCloudDetails = (
reportObject: ReportObject,
v: Verbosity
): string => {
const error = reportObject.cloud.error
? `\n ERROR [${reportObject.cloud.error}]`
: '';
const status = reportObject.cloud.status
? reportObject.cloud.status
: 'disconnected';
const ip =
reportObject.cloud.ip && v !== ''
? `\n IP: [${reportObject.cloud.ip}]`
: '';
const getReadableCloudDetails = (reportObject: ReportObject, v: Verbosity): string => {
const error = reportObject.cloud.error ? `\n ERROR [${reportObject.cloud.error}]` : '';
const status = reportObject.cloud.status ? reportObject.cloud.status : 'disconnected';
const ip = reportObject.cloud.ip && v !== '' ? `\n IP: [${reportObject.cloud.ip}]` : '';
return `
STATUS: [${status}] ${ip} ${error}`;
};
const getReadableMinigraphDetails = (reportObject: ReportObject): string => {
const statusLine = `STATUS: [${reportObject.minigraph.status}]`;
const errorLine = reportObject.minigraph.error
? ` ERROR: [${reportObject.minigraph.error}]`
: null;
const errorLine = reportObject.minigraph.error ? ` ERROR: [${reportObject.minigraph.error}]` : null;
const timeoutLine = reportObject.minigraph.timeout
? ` TIMEOUT: [${(reportObject.minigraph.timeout || 1) / 1_000}s]`
: null; // 1 in case of divide by zero
return `
${statusLine}${errorLine ? `\n${errorLine}` : ''}${
timeoutLine ? `\n${timeoutLine}` : ''
}`;
${statusLine}${errorLine ? `\n${errorLine}` : ''}${timeoutLine ? `\n${timeoutLine}` : ''}`;
};
// Convert server to string output
@@ -211,10 +180,7 @@ const serverToString = (v: Verbosity) => (server: ServersQueryResultServer) =>
: ''
}`;
const getReadableServerDetails = (
reportObject: ReportObject,
v: Verbosity
): string => {
const getReadableServerDetails = (reportObject: ReportObject, v: Verbosity): string => {
if (!reportObject.servers) {
return '';
}
@@ -232,9 +198,7 @@ const getReadableServerDetails = (
return `
SERVERS:
ONLINE: ${reportObject.servers.online.map(serverToString(v)).join(',')}
OFFLINE: ${reportObject.servers.offline
.map(serverToString(v))
.join(',')}${invalid}`;
OFFLINE: ${reportObject.servers.offline.map(serverToString(v)).join(',')}${invalid}`;
};
const getReadableAllowedOrigins = (reportObject: ReportObject): string => {
@@ -258,7 +222,7 @@ const getVerbosity = (argv: string[]): Verbosity => {
return '';
};
export const report = async (...argv: string[]) => {
// Check if the user has raw output enabled
const rawOutput = argv.includes('--raw');
@@ -290,7 +254,7 @@ export const report = async (...argv: string[]) => {
const v = getVerbosity(argv);
// Find all processes called "unraid-api" which aren't this process
const unraidApiPid = await getUnraidApiPid();
const unraidApiRunning = await isUnraidApiRunning();
// Load my servers config file into store
await store.dispatch(loadConfigFile());
@@ -316,43 +280,37 @@ export const report = async (...argv: string[]) => {
const reportObject: ReportObject = {
os: {
serverName: emhttp.var.name,
version: emhttp.var.version
version: emhttp.var.version,
},
api: {
version: API_VERSION,
status: unraidApiPid ? 'running' : 'stopped',
environment:
process.env.ENVIRONMENT ??
'THIS_WILL_BE_REPLACED_WHEN_BUILT',
status: unraidApiRunning ? 'running' : 'stopped',
environment: process.env.ENVIRONMENT ?? 'THIS_WILL_BE_REPLACED_WHEN_BUILT',
nodeVersion: process.version,
},
apiKey: isApiKeyValid ? 'valid' : cloud?.apiKey.error ?? 'invalid',
...(servers ? { servers } : {}),
myServers: {
status: config?.remote?.username
? 'authenticated'
: 'signed out',
status: config?.remote?.username ? 'authenticated' : 'signed out',
...(config?.remote?.username
? { myServersUsername: config?.remote?.username?.includes('@') ? 'REDACTED' : config?.remote.username }
? {
myServersUsername: config?.remote?.username?.includes('@')
? 'REDACTED'
: config?.remote.username,
}
: {}),
},
minigraph: {
status: cloud?.minigraphql.status ?? MinigraphStatus.PRE_INIT,
timeout: cloud?.minigraphql.timeout ?? null,
error:
cloud?.minigraphql.error ?? !cloud?.minigraphql.status
? 'API Disconnected'
: null,
cloud?.minigraphql.error ?? !cloud?.minigraphql.status ? 'API Disconnected' : null,
},
cloud: {
status: cloud?.cloud.status ?? 'error',
...(cloud?.cloud.error ? { error: cloud.cloud.error } : {}),
...(cloud?.cloud.status === 'ok'
? { ip: cloud.cloud.ip ?? 'NO_IP' }
: {}),
...(getAllowedOrigins(cloud, v)
? { allowedOrigins: getAllowedOrigins(cloud, v) }
: {}),
...(cloud?.cloud.status === 'ok' ? { ip: cloud.cloud.ip ?? 'NO_IP' } : {}),
...(getAllowedOrigins(cloud, v) ? { allowedOrigins: getAllowedOrigins(cloud, v) } : {}),
},
};
@@ -365,8 +323,8 @@ export const report = async (...argv: string[]) => {
if (jsonReport) {
stdout.write(JSON.stringify(reportObject) + '\n');
stdoutLogger.close();
return reportObject;
stdoutLogger.close();
return reportObject;
} else {
// Generate the actual report
const report = `
@@ -383,9 +341,7 @@ MY_SERVERS: ${reportObject.myServers.status}${
: ''
}
CLOUD: ${getReadableCloudDetails(reportObject, v)}
MINI-GRAPH: ${getReadableMinigraphDetails(
reportObject
)}${getReadableServerDetails(
MINI-GRAPH: ${getReadableMinigraphDetails(reportObject)}${getReadableServerDetails(
reportObject,
v
)}${getReadableAllowedOrigins(reportObject)}
@@ -400,9 +356,7 @@ MINI-GRAPH: ${getReadableMinigraphDetails(
console.log({ error });
if (error instanceof Error) {
cliLogger.trace(error);
stdoutLogger.write(
`\nFailed generating report with "${error.message}"\n`
);
stdoutLogger.write(`\nFailed generating report with "${error.message}"\n`);
return;
}

View File

@@ -7,6 +7,6 @@ import { execSync } from 'child_process';
export const start = async () => {
cliLogger.info('Starting unraid-api@v%s', API_VERSION);
execSync('pm2 start ecosystem.config.json --update-env', { env: process.env, stdio: 'inherit' });
execSync(`pm2 start ecosystem.config.json --update-env`, { env: process.env, stdio: 'inherit' });
// Start API
};

View File

@@ -1,19 +1,5 @@
import prettyMs from 'pretty-ms';
import pidUsage from 'pidusage';
import { cliLogger } from '@app/core/log';
import { getUnraidApiPid } from '@app/cli/get-unraid-api-pid';
import { setEnv } from '@app/cli/set-env';
import { execSync } from 'child_process';
export const status = async () => {
setEnv('LOG_TYPE', 'raw');
// Find all processes called "unraid-api" which aren't this process
const unraidApiPid = await getUnraidApiPid();
if (!unraidApiPid) {
cliLogger.info('Found no running processes.');
return;
}
const stats = await pidUsage(unraidApiPid);
cliLogger.info(`API has been running for ${prettyMs(stats.elapsed)} and is in "${process.env.ENVIRONMENT ?? 'ERR: Unknown Environment'}" mode!`);
execSync('pm2 status unraid-api', { stdio: 'inherit' });
};

View File

@@ -1,27 +1,18 @@
import { copyFile, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { cliLogger } from '@app/core/log';
import { getUnraidApiPid } from '@app/cli/get-unraid-api-pid';
import { setEnv } from '@app/cli/set-env';
import { getters } from '@app/store';
import { start } from '@app/cli/commands/start';
import { stop } from '@app/cli/commands/stop';
export const switchEnv = async () => {
setEnv('LOG_TYPE', 'raw');
const paths = getters.paths();
const basePath = paths['unraid-api-base'];
const envFlashFilePath = paths['myservers-env'];
const envFile = await readFile(envFlashFilePath, 'utf-8').catch(() => '');
let shouldStartAfterRunning = false;
if (await getUnraidApiPid()) {
cliLogger.info('unraid-api is running, stopping...');
// Stop Running Process
await stop();
shouldStartAfterRunning = true;
}
await stop();
cliLogger.debug(
'Checking %s for current ENV, found %s',
@@ -70,11 +61,5 @@ export const switchEnv = async () => {
await copyFile(source, destination);
cliLogger.info('Now using %s', newEnv);
if (shouldStartAfterRunning) {
cliLogger.debug('Restarting unraid-api');
// Start Process
await start();
} else {
cliLogger.info('Run "unraid-api start" to start the API.');
}
await start();
};

View File

@@ -4,6 +4,7 @@ import { type Flags, mainOptions, options, args } from '@app/cli/options';
import { setEnv } from '@app/cli/set-env';
import { env } from '@app/dotenv';
import { getters } from '@app/store';
import { execSync } from 'child_process';
const command = mainOptions.command as unknown as string;
@@ -46,11 +47,13 @@ export const main = async (...argv: string[]) => {
start: import('@app/cli/commands/start').then((pkg) => pkg.start),
stop: import('@app/cli/commands/stop').then((pkg) => pkg.stop),
restart: import('@app/cli/commands/restart').then((pkg) => pkg.restart),
logs: async () => execSync('pm2 logs unraid-api --lines 200', { stdio: 'inherit' }),
'switch-env': import('@app/cli/commands/switch-env').then((pkg) => pkg.switchEnv),
version: import('@app/cli/commands/version').then((pkg) => pkg.version),
status: import('@app/cli/commands/status').then((pkg) => pkg.status),
report: import('@app/cli/commands/report').then((pkg) => pkg.report),
'validate-token': import('@app/cli/commands/validate-token').then((pkg) => pkg.validateToken),
};
// Unknown command

View File

@@ -0,0 +1,29 @@
import pm2 from 'pm2';
export const isUnraidApiRunning = async (): Promise<boolean | undefined> => {
return new Promise((resolve, reject) => {
pm2.connect(function (err) {
if (err) {
console.error(err);
reject('Could not connect to pm2');
}
pm2.describe('unraid-api', function (err, processDescription) {
console.log(err)
if (err || processDescription.length === 0) {
console.log(false); // Service not found or error occurred
resolve(false);
} else {
const isOnline = processDescription?.[0]?.pm2_env?.status === 'online';
console.log(isOnline); // Output true if online, false otherwise
resolve(isOnline);
}
pm2.disconnect();
});
});
});
};