From e9727be33fbd20dc677d61512f8dc10e508e26d9 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 27 Aug 2024 17:51:19 +0700 Subject: [PATCH] feat(dashmate): doctor analysis --- packages/dashmate/src/commands/doctor.js | 259 ++++-------------- packages/dashmate/src/createDIContainer.js | 6 + packages/dashmate/src/docker/DockerCompose.js | 23 +- .../src/docker/getServiceListFactory.js | 2 +- packages/dashmate/src/doctor/Samples.js | 58 ++++ .../dashmate/src/doctor/archiveSamples.js | 58 ++++ packages/dashmate/src/doctor/report.js | 91 ------ .../dashmate/src/doctor/unarchiveSamples.js | 72 +++++ .../tasks/doctor/analyseSamplesTaskFactory.js | 102 +++++++ .../tasks/doctor/collectSamplesTaskFactory.js | 230 ++++++++++++++++ .../tasks/doctor/prescriptionTaskFactory.js | 55 ++++ 11 files changed, 655 insertions(+), 301 deletions(-) create mode 100644 packages/dashmate/src/doctor/Samples.js create mode 100644 packages/dashmate/src/doctor/archiveSamples.js delete mode 100644 packages/dashmate/src/doctor/report.js create mode 100644 packages/dashmate/src/doctor/unarchiveSamples.js create mode 100644 packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js create mode 100644 packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js create mode 100644 packages/dashmate/src/listr/tasks/doctor/prescriptionTaskFactory.js diff --git a/packages/dashmate/src/commands/doctor.js b/packages/dashmate/src/commands/doctor.js index c66e18c754d..cb58215ee19 100644 --- a/packages/dashmate/src/commands/doctor.js +++ b/packages/dashmate/src/commands/doctor.js @@ -2,28 +2,11 @@ import process from 'process'; import { Flags } from '@oclif/core'; import { Listr } from 'listr2'; import chalk from 'chalk'; +import archiveSamples from '../doctor/archiveSamples.js'; +import unarchiveSamples from '../doctor/unarchiveSamples.js'; import ConfigBaseCommand from '../oclif/command/ConfigBaseCommand.js'; -import Report from '../doctor/report.js'; -import { DASHMATE_VERSION } from '../constants.js'; -import obfuscateConfig from '../config/obfuscateConfig.js'; +import Samples from '../doctor/Samples.js'; import MuteOneLineError from '../oclif/errors/MuteOneLineError.js'; -import hideString from '../util/hideString.js'; -import obfuscateObjectRecursive from '../util/obfuscateObjectRecursive.js'; - -/** - * - * @param {string} url - * @return {Promise} - */ -async function fetchTextOrError(url) { - try { - const response = await fetch(url); - - return await response.text(); - } catch (e) { - return e.toString(); - } -} export default class DoctorCommand extends ConfigBaseCommand { static description = 'Dashmate node diagnostic. Bring your node to a doctor'; @@ -31,225 +14,81 @@ export default class DoctorCommand extends ConfigBaseCommand { static flags = { ...ConfigBaseCommand.flags, verbose: Flags.boolean({ char: 'v', description: 'use verbose mode for output', default: false }), + samples: Flags.string({ char: 's', description: 'path to the samples archive', default: '' }), }; /** * @param {Object} args * @param {Object} flags - * @param createRpcClient - * @param {DockerCompose} dockerCompose - * @param {getConnectionHost} getConnectionHost * @param {Config} config - * @param createTenderdashRpcClient - * @param getServiceList - * @param getOperatingSystemInfo + * @param {analyseSamplesTask} analyseSamplesTask + * @param {collectSamplesTask} collectSamplesTask + * @param {prescriptionTask} prescriptionTask * @return {Promise} */ async runWithDependencies( args, - { verbose: isVerbose }, - createRpcClient, - dockerCompose, - getConnectionHost, + { + verbose: isVerbose, + samples: samplesFile, + }, config, - createTenderdashRpcClient, - getServiceList, - getOperatingSystemInfo, + analyseSamplesTask, + collectSamplesTask, + prescriptionTask, ) { const tasks = new Listr( [ { + title: 'Collecting samples', + enabled: () => !samplesFile, + task: async () => collectSamplesTask(config), + }, + { + title: 'Analyzing samples', + task: async () => analyseSamplesTask(config), + }, + { + title: 'Prescription', + task: prescriptionTask, + options: { + persistentOutput: true, + }, + }, + { + title: 'Archive samples', + enabled: () => !samplesFile, task: async (ctx, task) => { const agreement = await task.prompt({ type: 'toggle', name: 'confirm', - header: chalk` Dashmate is going to collect all necessary debug data from the node to create a report, including: + header: chalk` Do you want to create an archive of already collected data for further investigation? + + The archive will include: - System information - The node configuration - Service logs, metrics and status - Collected data will contain only anonymous information. All sensitive data like private keys or passwords is obfuscated. + Collected data will not contain only private information which is already not available publicly. + All sensitive data like private keys or passwords is obfuscated. - The report will be created as an TAR archive in {bold.cyanBright ${process.cwd()}} - You can use the report to analyze your node condition yourself or send it to the Dash Core Group ({underline.cyanBright support@dash.org}) in case you need help.\n`, - message: 'Create a report?', + The archive will compressed with TAR/GZIP and placed to {bold.cyanBright ${process.cwd()}} + You can use it to analyze your node condition yourself or send it to the Dash Core Group ({underline.cyanBright support@dash.org}) in case you need help.\n`, + message: 'Archive samples?', enabled: 'Yes', disabled: 'No', }); if (!agreement) { - throw new Error('Operation is cancelled'); - } - - ctx.report = new Report(); - }, - }, - { - title: 'System information', - task: async (ctx) => { - const osInfo = await getOperatingSystemInfo(); - - ctx.report.setSystemInfo(osInfo); - }, - }, - { - title: 'The node configuration', - task: async (ctx) => { - ctx.report.setDashmateVersion(DASHMATE_VERSION); - ctx.report.setDashmateConfig(obfuscateConfig(config)); - }, - }, - { - title: 'Core status', - task: async (ctx) => { - const rpcClient = createRpcClient({ - port: config.get('core.rpc.port'), - user: 'dashmate', - pass: config.get('core.rpc.users.dashmate.password'), - host: await getConnectionHost(config, 'core', 'core.rpc.host'), - }); - - const coreCalls = [ - rpcClient.getBestChainLock(), - rpcClient.quorum('listextended'), - rpcClient.getBlockchainInfo(), - rpcClient.getPeerInfo(), - ]; - - if (config.get('core.masternode.enable')) { - coreCalls.push(rpcClient.masternode('status')); - } - - const [ - getBestChainLock, - quorums, - getBlockchainInfo, - getPeerInfo, - masternodeStatus, - ] = (await Promise.allSettled(coreCalls)).map((e) => e.value?.result || e.reason); - - ctx.report.setServiceInfo('core', 'bestChainLock', getBestChainLock); - ctx.report.setServiceInfo('core', 'quorums', quorums); - ctx.report.setServiceInfo('core', 'blockchainInfo', getBlockchainInfo); - ctx.report.setServiceInfo('core', 'peerInfo', getPeerInfo); - ctx.report.setServiceInfo('core', 'masternodeStatus', masternodeStatus); - }, - }, - { - title: 'Tenderdash status', - enabled: () => config.get('platform.enable'), - task: async (ctx) => { - const tenderdashRPCClient = createTenderdashRpcClient({ - host: config.get('platform.drive.tenderdash.rpc.host'), - port: config.get('platform.drive.tenderdash.rpc.port'), - }); + task.skip(); - // Tenderdash requires to pass all params, so we use basic fetch - async function fetchValidators() { - const url = `http://${config.get('platform.drive.tenderdash.rpc.host')}:${config.get('platform.drive.tenderdash.rpc.port')}/validators?request_quorum_info=true`; - const response = await fetch(url, 'GET'); - return response.json(); + return; } - const [ - status, - genesis, - peers, - abciInfo, - consensusState, - validators, - ] = await Promise.allSettled([ - tenderdashRPCClient.request('status', []), - tenderdashRPCClient.request('genesis', []), - tenderdashRPCClient.request('net_info', []), - tenderdashRPCClient.request('abci_info', []), - tenderdashRPCClient.request('dump_consensus_state', []), - fetchValidators(), - ]); - - ctx.report.setServiceInfo('drive_tenderdash', 'status', status); - ctx.report.setServiceInfo('drive_tenderdash', 'validators', validators); - ctx.report.setServiceInfo('drive_tenderdash', 'genesis', genesis); - ctx.report.setServiceInfo('drive_tenderdash', 'peers', peers); - ctx.report.setServiceInfo('drive_tenderdash', 'abciInfo', abciInfo); - ctx.report.setServiceInfo('drive_tenderdash', 'consensusState', consensusState); - }, - }, - { - title: 'Metrics', - enabled: () => config.get('platform.enable'), - task: async (ctx, task) => { - if (config.get('platform.drive.tenderdash.metrics.enabled')) { - // eslint-disable-next-line no-param-reassign - task.output = 'Reading Tenderdash metrics'; - - const url = `http://${config.get('platform.drive.tenderdash.rpc.host')}:${config.get('platform.drive.tenderdash.rpc.port')}/metrics`; - - const result = fetchTextOrError(url); - - ctx.report.setServiceInfo('drive_tenderdash', 'metrics', result); - } - - if (config.get('platform.drive.abci.metrics.enabled')) { - // eslint-disable-next-line no-param-reassign - task.output = 'Reading Drive metrics'; - - const url = `http://${config.get('platform.drive.abci.rpc.host')}:${config.get('platform.drive.abci.rpc.port')}/metrics`; - - const result = fetchTextOrError(url); - - ctx.report.setServiceInfo('drive_abci', 'metrics', result); - } - - if (config.get('platform.gateway.metrics.enabled')) { - // eslint-disable-next-line no-param-reassign - task.output = 'Reading Gateway metrics'; - - const url = `http://${config.get('platform.gateway.metrics.host')}:${config.get('platform.gateway.metrics.port')}/metrics`; - - const result = fetchTextOrError(url); - - ctx.report.setServiceInfo('gateway', 'metrics', result); - } - }, - }, - { - title: 'Logs', - task: async (ctx, task) => { - const services = await getServiceList(config); - - // eslint-disable-next-line no-param-reassign - task.output = `Pulling logs from ${services.map((e) => e.name)}`; - - await Promise.all( - services.map(async (service) => { - const [inspect, logs] = (await Promise.allSettled([ - dockerCompose.inspectService(config, service.name), - dockerCompose.logs(config, [service.name]), - ])).map((e) => e.value || e.reason); - - // Hide username & external ip from logs - logs.out = logs.out.replaceAll(process.env.USER, hideString(process.env.USER)); - logs.err = logs.err.replaceAll(process.env.USER, hideString(process.env.USER)); - - // Hide username & external ip from inspect - obfuscateObjectRecursive(inspect, (_field, value) => (typeof value === 'string' - ? value.replaceAll(process.env.USER, hideString(process.env.USER)) : value)); - - ctx.report.setServiceInfo(service.name, 'stdOut', logs.out); - ctx.report.setServiceInfo(service.name, 'stdErr', logs.err); - ctx.report.setServiceInfo(service.name, 'dockerInspect', inspect); - }), - ); - }, - }, - { - title: 'Create an archive', - task: async (ctx, task) => { const archivePath = process.cwd(); - await ctx.report.archive(archivePath); + await archiveSamples(ctx.samples, archivePath); // eslint-disable-next-line no-param-reassign task.output = chalk`Saved to {bold.cyanBright ${archivePath}/dashmate-report-${ctx.report.date.toISOString()}.tar.gz}`; @@ -264,15 +103,25 @@ export default class DoctorCommand extends ConfigBaseCommand { rendererOptions: { clearOutput: false, showTimer: isVerbose, - bottomBar: true, + // bottomBar: true, removeEmptyLines: false, + collapse: false, }, }, ); + let samples; + if (samplesFile) { + samples = unarchiveSamples(samplesFile); + } else { + samples = new Samples(); + } + try { await tasks.run({ isVerbose, + samples, + problems: [], }); } catch (e) { throw new MuteOneLineError(e); diff --git a/packages/dashmate/src/createDIContainer.js b/packages/dashmate/src/createDIContainer.js index e478ffdf1d1..3fae72ac0cb 100644 --- a/packages/dashmate/src/createDIContainer.js +++ b/packages/dashmate/src/createDIContainer.js @@ -113,6 +113,9 @@ import writeConfigTemplatesFactory from './templates/writeConfigTemplatesFactory import importCoreDataTaskFactory from './listr/tasks/setup/regular/importCoreDataTaskFactory.js'; import verifySystemRequirementsTaskFactory from './listr/tasks/setup/regular/verifySystemRequirementsTaskFactory.js'; +import analyseSamplesTaskFactory from './listr/tasks/doctor/analyseSamplesTaskFactory.js'; +import collectSamplesTaskFactory from './listr/tasks/doctor/collectSamplesTaskFactory.js'; +import prescriptionTaskFactory from './listr/tasks/doctor/prescriptionTaskFactory.js'; /** * @param {Object} [options] @@ -306,6 +309,9 @@ export default async function createDIContainer(options = {}) { importCoreDataTask: asFunction(importCoreDataTaskFactory).singleton(), verifySystemRequirementsTask: asFunction(verifySystemRequirementsTaskFactory) .singleton(), + analyseSamplesTask: asFunction(analyseSamplesTaskFactory).singleton(), + collectSamplesTask: asFunction(collectSamplesTaskFactory).singleton(), + prescriptionTask: asFunction(prescriptionTaskFactory).singleton(), }); /** diff --git a/packages/dashmate/src/docker/DockerCompose.js b/packages/dashmate/src/docker/DockerCompose.js index 7826b325938..c9231128be1 100644 --- a/packages/dashmate/src/docker/DockerCompose.js +++ b/packages/dashmate/src/docker/DockerCompose.js @@ -41,6 +41,11 @@ export default class DockerCompose { */ #isDockerSetupVerified = false; + /** + * @type {Error} + */ + #dockerVerifiicationError; + /** * @type {HomeDir} */ @@ -499,14 +504,24 @@ export default class DockerCompose { */ async throwErrorIfNotInstalled() { if (this.#isDockerSetupVerified) { - return; + if (this.#dockerVerifiicationError) { + throw this.#dockerVerifiicationError; + } else { + return; + } } - this.#isDockerSetupVerified = true; + try { + await this.throwErrorIfDockerIsNotInstalled(); - await this.throwErrorIfDockerIsNotInstalled(); + await this.throwErrorIfDockerComposeIsNotInstalled(); + } catch (e) { + this.#dockerVerifiicationError = e; - await this.throwErrorIfDockerComposeIsNotInstalled(); + throw e; + } finally { + this.#isDockerSetupVerified = true; + } } /** diff --git a/packages/dashmate/src/docker/getServiceListFactory.js b/packages/dashmate/src/docker/getServiceListFactory.js index 7fee42f5fc3..eff5fac7574 100644 --- a/packages/dashmate/src/docker/getServiceListFactory.js +++ b/packages/dashmate/src/docker/getServiceListFactory.js @@ -13,7 +13,7 @@ export default function getServiceListFactory(generateEnvs, getConfigProfiles) { /** * Returns list of services and corresponding docker images from the config * - * @typedef {getServiceList} + * @typedef {function} getServiceList * @param {Config} config * @return {Object[]} */ diff --git a/packages/dashmate/src/doctor/Samples.js b/packages/dashmate/src/doctor/Samples.js new file mode 100644 index 00000000000..8ba06a4206e --- /dev/null +++ b/packages/dashmate/src/doctor/Samples.js @@ -0,0 +1,58 @@ +export default class Samples { + date; + + systemInfo = {}; + + #dashmateVersion = null; + + #dashmateConfig = null; + + #services = {}; + + constructor() { + this.date = new Date(); + } + + setSystemInfo(systemInfo) { + this.systemInfo = systemInfo; + } + + getSystemInfo() { + return this.systemInfo; + } + + setDashmateVersion(version) { + this.#dashmateVersion = version; + } + + getDashmateVersion() { + return this.#dashmateVersion; + } + + setDashmateConfig(config) { + this.#dashmateConfig = config; + } + + getDashmateConfig() { + return this.#dashmateConfig; + } + + setServiceInfo(service, key, data) { + this.#services[service] = { + ...(this.#services[service] ?? {}), + [key]: data, + }; + } + + getServices() { + return this.#services; + } + + getServiceInfo(service) { + return this.#services[service]; + } + + getServiceInfo(service, key) { + return this.#services[service]?.[key]; + } +} diff --git a/packages/dashmate/src/doctor/archiveSamples.js b/packages/dashmate/src/doctor/archiveSamples.js new file mode 100644 index 00000000000..b2c3db50cfc --- /dev/null +++ b/packages/dashmate/src/doctor/archiveSamples.js @@ -0,0 +1,58 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import { create } from 'tar'; + +function writeSampleFile(archiveDir, service, filename, data) { + const serviceDir = path.join(archiveDir, service ?? ''); + + let buffer; + let filetype; + + const dataType = typeof data; + + if (dataType === 'string') { + buffer = data; + filetype = '.txt'; + } else { + buffer = JSON.stringify(data, null, 2); + filetype = '.json'; + } + + if (!fs.existsSync(serviceDir)) { + fs.mkdirSync(serviceDir); + } + + fs.writeFileSync(path.join(serviceDir, `${filename}${filetype}`), buffer, 'utf8'); +} + +/** + * @param {Samples} samples + * @param {string} folderPath + */ +export default async function archiveSamples(samples, folderPath) { + const tempDir = os.tmpdir(); + const archiveName = `dashmate-report-${this.date.toISOString()}`; + const archiveDir = path.join(tempDir, archiveName); + + writeSampleFile(archiveDir, null, 'systemInfo', samples.getSystemInfo()); + writeSampleFile(archiveDir, null, 'dashmateConfig', samples.getDashmateConfig()); + writeSampleFile(archiveDir, null, 'dashmateVersion', samples.getDashmateVersion()); + + for (const [serviceName, service] of Object.entries(samples.getServices())) { + for (const [key, data] of Object.entries(service)) { + if (data !== undefined && data !== null) { + writeSampleFile(archiveDir, serviceName, key, data); + } + } + } + + await create( + { + cwd: archiveDir, + gzip: true, + file: path.join(folderPath, `${archiveName}.tar.gz`), + }, + ['.'], + ); +} diff --git a/packages/dashmate/src/doctor/report.js b/packages/dashmate/src/doctor/report.js deleted file mode 100644 index ea5618d42ef..00000000000 --- a/packages/dashmate/src/doctor/report.js +++ /dev/null @@ -1,91 +0,0 @@ -import os from 'os'; -import path from 'path'; -import fs from 'fs'; -import { create } from 'tar'; - -export default class Report { - date; - - #systemInfo = {}; - - #dashmateVersion = null; - - #dashmateConfig = null; - - #services = {}; - - constructor() { - this.date = new Date(); - } - - setSystemInfo(systemInfo) { - this.#systemInfo = systemInfo; - } - - setDashmateVersion(version) { - this.#dashmateVersion = version; - } - - setDashmateConfig(config) { - this.#dashmateConfig = config; - } - - setServiceInfo(service, key, data) { - this.#services[service] = { - ...(this.#services[service] ?? {}), - [key]: data, - }; - } - - #writeReportFile(reportDir, service, filename, data) { - const serviceDir = path.join(reportDir, service ?? ''); - - let buffer; - let filetype; - - const dataType = typeof data; - - if (dataType === 'string') { - buffer = data; - filetype = '.txt'; - } else { - buffer = JSON.stringify(data, null, 2); - filetype = '.json'; - } - - if (!fs.existsSync(serviceDir)) { - fs.mkdirSync(serviceDir); - } - - fs.writeFileSync(path.join(serviceDir, `${filename}${filetype}`), buffer, 'utf8'); - } - - async archive(folderPath) { - const tempDir = os.tmpdir(); - const reportName = `dashmate-report-${this.date.toISOString()}`; - const reportDir = path.join(tempDir, reportName); - - this.#writeReportFile(reportDir, null, 'systemInfo', this.#systemInfo); - this.#writeReportFile(reportDir, null, 'dashmateConfig', this.#dashmateConfig); - this.#writeReportFile(reportDir, null, 'dashmateVersion', this.#dashmateVersion); - - for (const service of Object.keys(this.#services)) { - for (const dataKey of Object.keys(this.#services[service])) { - const data = this.#services[service][dataKey]; - - if (data !== undefined && data !== null) { - this.#writeReportFile(reportDir, service, dataKey, data); - } - } - } - - await create( - { - cwd: reportDir, - gzip: true, - file: path.join(folderPath, `${reportName}.tar.gz`), - }, - ['.'], - ); - } -} diff --git a/packages/dashmate/src/doctor/unarchiveSamples.js b/packages/dashmate/src/doctor/unarchiveSamples.js new file mode 100644 index 00000000000..04a29d68b6a --- /dev/null +++ b/packages/dashmate/src/doctor/unarchiveSamples.js @@ -0,0 +1,72 @@ +import os from 'os'; +import path from 'path'; +import fs from 'fs'; +import { extract } from 'tar'; +import Samples from './Samples.js'; + +function readSampleFile(filePath) { + const data = fs.readFileSync(filePath, 'utf8'); + const ext = path.extname(filePath); + + if (ext === '.json') { + return JSON.parse(data); + } + + return data; +} + +/** + * @param {string} archiveFilePath + * @returns {Samples} + */ +export default async function unarchiveSamples(archiveFilePath) { + if (!fs.existsSync(archiveFilePath)) { + throw new Error(`Archive file with samples not found: ${archiveFilePath}`); + } + + const samples = new Samples(); + + const tempDir = os.tmpdir(); + const extractDir = path.join(tempDir, archiveFilePath); + + await extract({ + file: archiveFilePath, + cwd: extractDir, + }); + + samples.setSystemInfo(readSampleFile(path.join(extractDir, 'systemInfo.json'))); + samples.setDashmateConfig(readSampleFile(path.join(extractDir, 'dashmateConfig.json'))); + samples.setDashmateVersion(readSampleFile(path.join(extractDir, 'dashmateVersion.json'))); + + const servicesDir = path.join(extractDir, 'services'); + if (fs.existsSync(servicesDir)) { + const serviceNames = fs.readdirSync(servicesDir); + + for (const serviceName of serviceNames) { + const serviceDir = path.join(servicesDir, serviceName); + + if (!fs.statSync(serviceDir).isDirectory()) { + continue; + } + + const files = fs.readdirSync(serviceDir); + + for (const file of files) { + const filePath = path.join(serviceDir, file); + + const ext = path.extname(file); + if (ext !== '.txt' && ext !== '.json' && !fs.statSync(filePath).isDirectory()) { + continue; + } + + const data = readSampleFile(filePath); + const key = path.basename(file, ext); + samples.setServiceInfo(serviceName, key, data); + } + } + } + + fs.rmSync(extractDir, { recursive: true }); + + return samples; +} diff --git a/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js new file mode 100644 index 00000000000..dd0b445101a --- /dev/null +++ b/packages/dashmate/src/listr/tasks/doctor/analyseSamplesTaskFactory.js @@ -0,0 +1,102 @@ +import chalk from 'chalk'; +import { Listr } from 'listr2'; + +/** + * + * @param {DockerCompose} dockerCompose + * @param {getServiceList} getServiceList + * @return {analyseSamplesTask} + */ +export default function analyseSamplesTaskFactory(dockerCompose, getServiceList) { + /** + * @typedef {function} analyseSamplesTask + * @param config + * @return {Listr} + */ + function analyseSamplesTask(config) { + return new Listr([ + { + title: 'Docker is started', + task: async (ctx, task) => { + try { + await dockerCompose.throwErrorIfNotInstalled(); + } catch (e) { + ctx.problems.push(e.message); + ctx.skipOthers = true; + + throw new Error(task.title); + } + }, + }, + { + title: 'Services are started', + skip: (ctx) => ctx.skipOthers, + task: async (ctx) => { + const services = getServiceList(config); + + ctx.servicesNotStarted = []; + ctx.servicesFailed = []; + ctx.servicesOOMKilled = []; + + return new Listr( + services.map((service) => { + return { + title: service.title, + task: () => { + const dockerInspect = ctx.samples.getServiceInfo(service.name, 'dockerInspect'); + + if (dockerInspect.message) { + ctx.servicesNotStarted.push({ + service, + message: dockerInspect.message, + }); + } else if (dockerInspect.State.Restarting) { + // TODO: ctx.servicesFailed + //dockerInspect.State.Started = dockerInspect.State.Started ?? false; + } else { + return; + } + + throw new Error(service.title); + }, + }; + }), + { + exitOnError: false, + }, + ); + }, + }, + { + skip: (ctx) => ctx.skipOthers, + task: async (ctx) => { + if (ctx.servicesNotStarted.length > 0) { + let problem; + if (ctx.servicesNotStarted.length === 1) { + problem = chalk`Service ${ctx.servicesNotStarted[0].service.title} isn't started.` + } else { + problem = chalk`Services ${ctx.servicesNotStarted.map((e) => e.service.title).join(', ')} aren't started.` + } + + problem += chalk`\n\nTry {bold.blueBright dashmate start --force} to make sure all services are started`; + + ctx.problems.push(problem); + } + }, + }, + // TODO: dont have priavate ky to sign + { + title: 'Services are started', + skip: (ctx) => ctx.skipOthers, + task: async (ctx, task) => { + + // TODO: metrics enabled but admin is not + }, + }, + ], { + exitOnError: false, + }); + } + + return analyseSamplesTask; +} diff --git a/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js new file mode 100644 index 00000000000..e0d217fadce --- /dev/null +++ b/packages/dashmate/src/listr/tasks/doctor/collectSamplesTaskFactory.js @@ -0,0 +1,230 @@ +import { Listr } from 'listr2'; +import process from 'process'; +import obfuscateConfig from '../../../config/obfuscateConfig.js'; +import { DASHMATE_VERSION } from '../../../constants.js'; +import hideString from '../../../util/hideString.js'; +import obfuscateObjectRecursive from '../../../util/obfuscateObjectRecursive.js'; + +/** + * + * @param {string} url + * @return {Promise} + */ +async function fetchTextOrError(url) { + try { + const response = await fetch(url); + + return await response.text(); + } catch (e) { + return e.toString(); + } +} + +/** + * @param {DockerCompose} dockerCompose + * @param {createRpcClient} createRpcClient + * @param {getConnectionHost} getConnectionHost + * @param {createTenderdashRpcClient} createTenderdashRpcClient + * @param {getServiceList} getServiceList + * @param {getOperatingSystemInfo} getOperatingSystemInfo + * @return {collectSamplesTask} + */ +export default function collectSamplesTaskFactory( + dockerCompose, + createRpcClient, + getConnectionHost, + createTenderdashRpcClient, + getServiceList, + getOperatingSystemInfo, +) { + /** + * @typedef {function} collectSamplesTask + * @param config + * @return {Listr} + */ + function collectSamplesTask(config) { + return new Listr( + [ + { + title: 'System information', + task: async (ctx) => { + const osInfo = await getOperatingSystemInfo(); + + ctx.samples.setSystemInfo(osInfo); + }, + }, + { + title: 'The node configuration', + task: async (ctx) => { + ctx.samples.setDashmateVersion(DASHMATE_VERSION); + ctx.samples.setDashmateConfig(obfuscateConfig(config)); + }, + }, + { + title: 'Core status', + task: async (ctx) => { + const rpcClient = createRpcClient({ + port: config.get('core.rpc.port'), + user: 'dashmate', + pass: config.get('core.rpc.users.dashmate.password'), + host: await getConnectionHost(config, 'core', 'core.rpc.host'), + }); + + const coreCalls = [ + rpcClient.getBestChainLock(), + rpcClient.quorum('listextended'), + rpcClient.getBlockchainInfo(), + rpcClient.getPeerInfo(), + ]; + + if (config.get('core.masternode.enable')) { + coreCalls.push(rpcClient.masternode('status')); + } + + const [ + getBestChainLock, + quorums, + getBlockchainInfo, + getPeerInfo, + masternodeStatus, + ] = (await Promise.allSettled(coreCalls)) + .map((e) => e.value?.result || e.reason); + + ctx.samples.setServiceInfo('core', 'bestChainLock', getBestChainLock); + ctx.samples.setServiceInfo('core', 'quorums', quorums); + ctx.samples.setServiceInfo('core', 'blockchainInfo', getBlockchainInfo); + ctx.samples.setServiceInfo('core', 'peerInfo', getPeerInfo); + ctx.samples.setServiceInfo('core', 'masternodeStatus', masternodeStatus); + }, + }, + { + title: 'Tenderdash status', + enabled: () => config.get('platform.enable'), + task: async (ctx) => { + const tenderdashRPCClient = createTenderdashRpcClient({ + host: config.get('platform.drive.tenderdash.rpc.host'), + port: config.get('platform.drive.tenderdash.rpc.port'), + }); + + // Tenderdash requires to pass all params, so we use basic fetch + async function fetchValidators() { + const url = `http://${config.get('platform.drive.tenderdash.rpc.host')}:${config.get('platform.drive.tenderdash.rpc.port')}/validators?request_quorum_info=true`; + const response = await fetch(url, 'GET'); + return response.json(); + } + + const [ + status, + genesis, + peers, + abciInfo, + consensusState, + validators, + ] = await Promise.allSettled([ + tenderdashRPCClient.request('status', []), + tenderdashRPCClient.request('genesis', []), + tenderdashRPCClient.request('net_info', []), + tenderdashRPCClient.request('abci_info', []), + tenderdashRPCClient.request('dump_consensus_state', []), + fetchValidators(), + ]); + + ctx.samples.setServiceInfo('drive_tenderdash', 'status', status); + ctx.samples.setServiceInfo('drive_tenderdash', 'validators', validators); + ctx.samples.setServiceInfo('drive_tenderdash', 'genesis', genesis); + ctx.samples.setServiceInfo('drive_tenderdash', 'peers', peers); + ctx.samples.setServiceInfo('drive_tenderdash', 'abciInfo', abciInfo); + ctx.samples.setServiceInfo('drive_tenderdash', 'consensusState', consensusState); + }, + }, + { + title: 'Metrics', + enabled: () => config.get('platform.enable'), + task: async (ctx, task) => { + if (config.get('platform.drive.tenderdash.metrics.enabled')) { + // eslint-disable-next-line no-param-reassign + task.output = 'Reading Tenderdash metrics'; + + const url = `http://${config.get('platform.drive.tenderdash.rpc.host')}:${config.get('platform.drive.tenderdash.rpc.port')}/metrics`; + + const result = fetchTextOrError(url); + + ctx.samples.setServiceInfo('drive_tenderdash', 'metrics', result); + } + + if (config.get('platform.drive.abci.metrics.enabled')) { + // eslint-disable-next-line no-param-reassign + task.output = 'Reading Drive metrics'; + + const url = `http://${config.get('platform.drive.abci.rpc.host')}:${config.get('platform.drive.abci.rpc.port')}/metrics`; + + const result = fetchTextOrError(url); + + ctx.samples.setServiceInfo('drive_abci', 'metrics', result); + } + + if (config.get('platform.gateway.metrics.enabled')) { + // eslint-disable-next-line no-param-reassign + task.output = 'Reading Gateway metrics'; + + const url = `http://${config.get('platform.gateway.metrics.host')}:${config.get('platform.gateway.metrics.port')}/metrics`; + + const result = fetchTextOrError(url); + + ctx.samples.setServiceInfo('gateway', 'metrics', result); + } + }, + }, + { + title: 'Logs', + task: async (ctx, task) => { + const services = await getServiceList(config); + + // eslint-disable-next-line no-param-reassign + task.output = `Pulling logs from ${services.map((e) => e.name)}`; + + await Promise.all( + services.map(async (service) => { + const [inspect, logs] = (await Promise.allSettled([ + dockerCompose.inspectService(config, service.name), + dockerCompose.logs(config, [service.name]), + ])).map((e) => e.value || e.reason); + + if (logs?.out) { + // Hide username & external ip from logs + logs.out = logs.out.replaceAll( + process.env.USER, + hideString(process.env.USER), + ); + } + + if (logs?.err) { + logs.err = logs.err.replaceAll( + process.env.USER, + hideString(process.env.USER), + ); + } + + // Hide username & external ip from inspect + obfuscateObjectRecursive(inspect, (_field, value) => ( + typeof value === 'string' + ? value.replaceAll( + process.env.USER, + hideString(process.env.USER), + ) + : value + )); + + ctx.samples.setServiceInfo(service.name, 'stdOut', logs?.out); + ctx.samples.setServiceInfo(service.name, 'stdErr', logs?.err); + ctx.samples.setServiceInfo(service.name, 'dockerInspect', inspect); + }), + ); + }, + }, + ], + ); + } + + return collectSamplesTask; +} diff --git a/packages/dashmate/src/listr/tasks/doctor/prescriptionTaskFactory.js b/packages/dashmate/src/listr/tasks/doctor/prescriptionTaskFactory.js new file mode 100644 index 00000000000..3128d4df958 --- /dev/null +++ b/packages/dashmate/src/listr/tasks/doctor/prescriptionTaskFactory.js @@ -0,0 +1,55 @@ +import chalk from 'chalk'; + +export default function prescriptionTaskFactory() { + /** + * @param {Context} ctx + * @param {Task} task + * @return {Promise} + */ + async function prescriptionTask(ctx, task) { + if (ctx.problems.length === 0) { + // eslint-disable-next-line no-param-reassign + task.output = chalk`The doctor didn't find any problems with your node. + If issues still persist, please contact the Dash Core Group ({underline.cyanBright support@dash.org})`; + + return; + } + + const problemsString = ctx.problems.map((problem, index) => { + const indentedProblem = problem.split('\n') + .map((line, i) => { + if (i === 0) { + return line; + } + + return ' '.repeat(7) + line; + }).join('\n'); + + return `${index + 1}. ${indentedProblem}`; + }).join('\n\n'); + + const plural = ctx.problems.length > 1 ? 's' : ''; + const header = chalk` {bold.red ${ctx.problems.length} problem${plural}} found: + + ${problemsString} + + You can try to fix the problems or contact the Dash Core Group ({underline.cyanBright support@dash.org}) + `; + + await task.prompt({ + type: 'confirm', + header, + message: 'Press any key to continue...', + default: ' ', + separator: () => '', + format: () => '', + initial: true, + isTrue: () => true, + }); + + // eslint-disable-next-line no-param-reassign + task.output = `\n${header}`; + } + + return prescriptionTask; +}