From 42672d8aeb228367a6ee597da6f819c2050dbbf3 Mon Sep 17 00:00:00 2001 From: maxym Date: Tue, 13 Jun 2023 17:41:13 +0100 Subject: [PATCH] feature: allow customisation of node config --- app/screens/node/Node.tsx | 2 +- app/vars/ipcConsts.ts | 1 - desktop/SmesherManager.ts | 105 ++---- desktop/main/NodeConfig.ts | 143 +++++++- desktop/main/reactions/spawnManagers.ts | 31 +- desktop/main/reactions/syncNodeConfig.ts | 2 +- desktop/main/sources/fetchDiscovery.ts | 25 +- desktop/main/startApp.ts | 4 +- jest.config.js | 2 +- shared/warning.ts | 1 + tests/fixtures/config.ts | 232 ++++++++++++ tests/nodeConfig.spec.ts | 448 +++++++++++++++++++++++ 12 files changed, 899 insertions(+), 97 deletions(-) create mode 100644 tests/fixtures/config.ts create mode 100644 tests/nodeConfig.spec.ts diff --git a/app/screens/node/Node.tsx b/app/screens/node/Node.tsx index e079ac075..b764aebaa 100644 --- a/app/screens/node/Node.tsx +++ b/app/screens/node/Node.tsx @@ -460,7 +460,7 @@ const Node = ({ history, location }: Props) => {
, ], ]; diff --git a/app/vars/ipcConsts.ts b/app/vars/ipcConsts.ts index a5be980bf..8841937aa 100644 --- a/app/vars/ipcConsts.ts +++ b/app/vars/ipcConsts.ts @@ -27,7 +27,6 @@ enum IpcChannel { SMESHER_START_SMESHING = 'SMESHER_START_SMESHING', SMESHER_STOP_SMESHING = 'SMESHER_STOP_SMESHING', SMESHER_GET_COINBASE = 'SMESHER_GET_COINBASE', - SMESHER_SET_COINBASE = 'SMESHER_SET_COINBASE', SMESHER_GET_MIN_GAS = 'SMESHER_GET_MIN_GAS', SMESHER_GET_ESTIMATED_REWARDS = 'SMESHER_GET_ESTIMATED_REWARDS', SMESHER_SET_SETUP_COMPUTE_PROVIDERS = 'SMESHER_SET_SETUP_COMPUTE_PROVIDERS', diff --git a/desktop/SmesherManager.ts b/desktop/SmesherManager.ts index 51b05af46..515d71d33 100644 --- a/desktop/SmesherManager.ts +++ b/desktop/SmesherManager.ts @@ -6,27 +6,29 @@ import { ipcConsts } from '../app/vars'; import { HexString, IPCSmesherStartupData, - NodeConfig, PostProvingOpts, PostSetupOpts, PostSetupState, PostSetupStatus, } from '../shared/types'; -import { configCodecByPath, delay } from '../shared/utils'; +import { delay } from '../shared/utils'; import SmesherService from './SmesherService'; import Logger from './logger'; -import { readFileAsync, writeFileAsync } from './utils'; import AbstractManager from './AbstractManager'; -import StoreService from './storeService'; -import { generateGenesisIDFromConfig } from './main/Networks'; import { safeSmeshingOpts } from './main/smeshingOpts'; -import { DEFAULT_SMESHING_BATCH_SIZE } from './main/constants'; +import { + loadCustomNodeConfig, + loadNodeConfig, + updateSmeshingOpts, +} from './main/NodeConfig'; import { deleteSmeshingMetadata, getSmeshingMetadata, updateSmeshingMetadata, } from './SmesherMetadataUtils'; +import { DEFAULT_SMESHING_BATCH_SIZE } from './main/constants'; + const checkDiskSpace = require('check-disk-space'); const logger = Logger({ className: 'SmesherService' }); @@ -34,37 +36,18 @@ const logger = Logger({ className: 'SmesherService' }); class SmesherManager extends AbstractManager { private smesherService: SmesherService; - private readonly configFilePath: string; - private genesisID: string; - constructor( - mainWindow: BrowserWindow, - configFilePath: string, - genesisID: string - ) { + private netName: string; + + constructor(mainWindow: BrowserWindow, genesisID: string, netName: string) { super(mainWindow); this.smesherService = new SmesherService(); this.smesherService.createService(); - this.configFilePath = configFilePath; this.genesisID = genesisID; + this.netName = netName; } - private loadConfig = async () => { - const fileContent = await readFileAsync(this.configFilePath, { - encoding: 'utf-8', - }); - return configCodecByPath(this.configFilePath).parse( - fileContent - ) as NodeConfig; - }; - - private writeConfig = async (config) => { - const data = configCodecByPath(this.configFilePath).stringify(config); - await writeFileAsync(this.configFilePath, data); - return true; - }; - unsubscribe = () => { this.smesherService.deactivateProgressStream(); this.smesherService.cancelStreams(); @@ -72,7 +55,7 @@ class SmesherManager extends AbstractManager { }; getSmeshingConfig = async () => { - const config = await this.loadConfig(); + const config = await loadNodeConfig(); return config.smeshing || {}; }; @@ -98,6 +81,14 @@ class SmesherManager extends AbstractManager { ); }; + setGenesisID = (id: HexString) => { + this.genesisID = id; + }; + + setNetName = (netName: string) => { + this.netName = netName; + }; + updateSmesherState = async () => { await this.sendSmesherSettingsAndStartupState(); await this.sendPostSetupProviders(); @@ -136,7 +127,7 @@ class SmesherManager extends AbstractManager { postSetupState, numLabelsWritten, } = await this.smesherService.getPostSetupStatus(); - const nodeConfig = await this.loadConfig(); + const nodeConfig = await loadNodeConfig(); const numUnits = nodeConfig.smeshing?.['smeshing-opts']?.['smeshing-opts-numunits'] || 0; const maxFileSize = @@ -169,7 +160,7 @@ class SmesherManager extends AbstractManager { sendSmesherConfig = async () => { // TODO: Merge with `sendSmesherSettingsAndStartupState` - const nodeConfig = await this.loadConfig(); + const nodeConfig = await loadNodeConfig(); if (nodeConfig.smeshing && nodeConfig.smeshing['smeshing-opts']) { const opts = nodeConfig.smeshing['smeshing-opts']; const freeSpace = await this.checkDiskSpace({ @@ -223,15 +214,6 @@ class SmesherManager extends AbstractManager { return this.selectPostFolder({ mainWindow: this.mainWindow }); }; const getCoinbase = () => this.smesherService.getCoinbase(); - const setCoinbase = async (_event, { coinbase }) => { - // TODO: Unused handler - const res = await this.smesherService.setCoinbase({ coinbase }); - const config = await this.loadConfig(); - config.smeshing = config.smeshing || {}; - config.smeshing['smeshing-coinbase'] = coinbase; - await this.writeConfig(config); - return res; - }; const getMinGas = () => this.smesherService.getMinGas(); const getEstimatedRewards = () => this.smesherService.getEstimatedRewards(); @@ -243,25 +225,16 @@ class SmesherManager extends AbstractManager { deleteFiles: deleteFiles || false, }); await this.clearSmesherMetadata(); - const config = await this.loadConfig(); - const genesisId = generateGenesisIDFromConfig(config); - if (deleteFiles) { - config.smeshing = {}; - } else { - config.smeshing = { - ...config.smeshing, - 'smeshing-start': false, - }; - } - const smeshingOpts = safeSmeshingOpts(config.smeshing, genesisId); - config.smeshing = smeshingOpts; - await this.writeConfig(config); - StoreService.set(`smeshing.${genesisId}`, smeshingOpts); + + await updateSmeshingOpts( + this.netName, + deleteFiles ? {} : { 'smeshing-start': false } + ); + return res?.error; } ); ipcMain.handle(ipcConsts.SMESHER_GET_COINBASE, getCoinbase); - ipcMain.handle(ipcConsts.SMESHER_SET_COINBASE, setCoinbase); ipcMain.handle(ipcConsts.SMESHER_GET_MIN_GAS, getMinGas); ipcMain.handle( ipcConsts.SMESHER_GET_ESTIMATED_REWARDS, @@ -272,7 +245,6 @@ class SmesherManager extends AbstractManager { ipcMain.removeHandler(ipcConsts.SMESHER_SELECT_POST_FOLDER); ipcMain.removeHandler(ipcConsts.SMESHER_STOP_SMESHING); ipcMain.removeHandler(ipcConsts.SMESHER_GET_COINBASE); - ipcMain.removeHandler(ipcConsts.SMESHER_SET_COINBASE); ipcMain.removeHandler(ipcConsts.SMESHER_GET_MIN_GAS); ipcMain.removeHandler(ipcConsts.SMESHER_GET_ESTIMATED_REWARDS); }; @@ -298,7 +270,7 @@ class SmesherManager extends AbstractManager { maxFileSize, } = postSetupOpts; const { nonces, threads } = provingOpts; - const prevOpts = StoreService.get(`smeshing.${genesisID}`); + const customNodeConfig = await loadCustomNodeConfig(this.netName); const opts = safeSmeshingOpts( { 'smeshing-coinbase': coinbase, @@ -308,25 +280,24 @@ class SmesherManager extends AbstractManager { 'smeshing-opts-numunits': numUnits, 'smeshing-opts-provider': provider, 'smeshing-opts-throttle': throttle, - 'smeshing-opts-compute-batch-size': R.pathOr( - DEFAULT_SMESHING_BATCH_SIZE, - ['smeshing-opts', 'smeshing-opts-compute-batch-size'], - prevOpts - ), }, 'smeshing-proving-opts': { 'smeshing-opts-proving-nonces': nonces, 'smeshing-opts-proving-threads': threads, + 'smeshing-opts-compute-batch-size': R.pathOr( + DEFAULT_SMESHING_BATCH_SIZE, + ['smeshing-opts', 'smeshing-opts-compute-batch-size'], + customNodeConfig?.smeshing || {} + ), }, 'smeshing-start': true, }, genesisID ); - StoreService.set(`smeshing.${genesisID}`, opts); - const config = await this.loadConfig(); - config.smeshing = opts; - return this.writeConfig(config); + await updateSmeshingOpts(this.netName, opts); + + return true; }; selectPostFolder = async ({ mainWindow }: { mainWindow: BrowserWindow }) => { diff --git a/desktop/main/NodeConfig.ts b/desktop/main/NodeConfig.ts index a0c906f64..0c25d19f0 100644 --- a/desktop/main/NodeConfig.ts +++ b/desktop/main/NodeConfig.ts @@ -1,16 +1,19 @@ -import { existsSync, promises as fs } from 'fs'; +import { existsSync } from 'fs'; +import fs from 'fs/promises'; +import path from 'path'; import * as TOML from '@iarna/toml'; import 'json-bigint-patch'; +import * as R from 'ramda'; import { NodeConfig } from '../../shared/types'; import Warning, { WarningType, WriteFilePermissionWarningKind, } from '../../shared/warning'; -import StoreService from '../storeService'; import { fetchNodeConfig } from '../utils'; -import { NODE_CONFIG_FILE } from './constants'; -import { safeSmeshingOpts } from './smeshingOpts'; +import StoreService from '../storeService'; +import { NODE_CONFIG_FILE, USERDATA_DIR } from './constants'; import { generateGenesisIDFromConfig } from './Networks'; +import { safeSmeshingOpts } from './smeshingOpts'; export const loadNodeConfig = async (): Promise => existsSync(NODE_CONFIG_FILE) @@ -21,33 +24,141 @@ export const loadNodeConfig = async (): Promise => ) : {}; -const loadSmeshingOpts = async (nodeConfig) => { - const id = generateGenesisIDFromConfig(nodeConfig); - const opts = StoreService.get(`smeshing.${id}`); - return safeSmeshingOpts(opts, id); +export const writeNodeConfig = async (config: NodeConfig): Promise => { + try { + await fs.writeFile(NODE_CONFIG_FILE, JSON.stringify(config), { + encoding: 'utf8', + }); + } catch (error: any) { + throw Warning.fromError( + WarningType.WriteFilePermission, + { + kind: WriteFilePermissionWarningKind.ConfigFile, + filePath: NODE_CONFIG_FILE, + }, + error + ); + } }; -export const downloadNodeConfig = async (networkConfigUrl: string) => { - const nodeConfig = await fetchNodeConfig(networkConfigUrl); - // Copy smeshing opts from previous node config or replace it with empty one - nodeConfig.smeshing = await loadSmeshingOpts(nodeConfig); +const getCustomNodeConfigName = (name: string) => + `node-config.${name.toLowerCase().replace(' ', '_')}.json`; + +export const loadCustomNodeConfig = async ( + netName: string +): Promise> => { + const customConfigPath = path.join( + USERDATA_DIR, + getCustomNodeConfigName(netName) + ); + + return existsSync(customConfigPath) + ? fs + .readFile(customConfigPath, { + encoding: 'utf8', + }) + .then((res) => JSON.parse(res)) + : {}; +}; +export const writeCustomNodeConfig = async ( + netName: string, + config: Partial +): Promise => { + const filePath = path.join(USERDATA_DIR, getCustomNodeConfigName(netName)); try { - await fs.writeFile(NODE_CONFIG_FILE, JSON.stringify(nodeConfig), { + await fs.writeFile(filePath, JSON.stringify(config, null, 2), { encoding: 'utf8', }); } catch (error: any) { throw Warning.fromError( WarningType.WriteFilePermission, { - kind: WriteFilePermissionWarningKind.ConfigFile, - filePath: NODE_CONFIG_FILE, + kind: WriteFilePermissionWarningKind.CustomConfigFile, + filePath, }, error ); } +}; + +export const saveSmeshingOptsInCustomConfig = async ( + netName: string, + opts: Partial +): Promise> => { + const customConfig = await loadCustomNodeConfig(netName); + + customConfig.smeshing = opts; + + await writeCustomNodeConfig(netName, customConfig); + + return customConfig; +}; + +const createCustomNodeConfig = async ( + netName: string, + genesisID: string +): Promise> => { + const opts: Partial | undefined = StoreService.get( + `smeshing.${genesisID}` + ); + + // migrate options from StoreService or place only default opts + const smeshingOpts = opts || safeSmeshingOpts(undefined, genesisID); // set default dir path + + const config = await saveSmeshingOptsInCustomConfig(netName, smeshingOpts); + + StoreService.remove(`smeshing.${genesisID}`); + + return config; +}; +export const loadOrCreateCustomConfig = async ( + netName: string, + genesisID: string +): Promise> => + existsSync(path.join(USERDATA_DIR, getCustomNodeConfigName(netName))) + ? loadCustomNodeConfig(netName) + : createCustomNodeConfig(netName, genesisID); + +export const updateSmeshingOpts = async ( + netName: string, + updateSmeshingOpts: Partial +): Promise> => { + const customNodeConfig = await loadCustomNodeConfig(netName); + const clientConfig = await loadNodeConfig(); + const smeshingOpts = { + ...(R.isEmpty(updateSmeshingOpts) ? {} : customNodeConfig.smeshing), // on delete, ignore customNodeConfig smehsing + ...(R.isEmpty(updateSmeshingOpts) // on delete , take default smeshing opts + ? safeSmeshingOpts(undefined, generateGenesisIDFromConfig(clientConfig)) + : {}), + ...updateSmeshingOpts, // apply update for other cases + }; + + const customConfig = await saveSmeshingOptsInCustomConfig( + netName, + smeshingOpts + ); + const mergedConfig = R.mergeLeft(customConfig, clientConfig); + + await writeNodeConfig(mergedConfig); + + return mergedConfig; +}; + +export const downloadNodeConfig = async ( + netName: string, + networkConfigUrl: string +) => { + const discoveryConfig = await fetchNodeConfig(networkConfigUrl); + const customNodeConfig = await loadOrCreateCustomConfig( + netName, + generateGenesisIDFromConfig(discoveryConfig) + ); + const mergedConfig = R.mergeLeft(customNodeConfig, discoveryConfig); + + await writeNodeConfig(mergedConfig); - return nodeConfig as NodeConfig; + return mergedConfig; }; export default { diff --git a/desktop/main/reactions/spawnManagers.ts b/desktop/main/reactions/spawnManagers.ts index 7ead11492..8fff8e08a 100644 --- a/desktop/main/reactions/spawnManagers.ts +++ b/desktop/main/reactions/spawnManagers.ts @@ -2,16 +2,16 @@ import { BrowserWindow } from 'electron'; import { distinctUntilChanged, from, + Observable, ReplaySubject, Subject, switchMap, withLatestFrom, } from 'rxjs'; -import { NodeConfig } from '../../../shared/types'; +import { Network, NodeConfig } from '../../../shared/types'; import { Managers } from '../app.types'; import { generateGenesisIDFromConfig } from '../Networks'; import SmesherManager from '../../SmesherManager'; -import { NODE_CONFIG_FILE } from '../constants'; import NodeManager from '../../NodeManager'; import WalletManager from '../../WalletManager'; @@ -19,18 +19,26 @@ let managers: Managers | null = null; const spawnManagers = async ( mainWindow: BrowserWindow, - genesisID: string + genesisID: string, + netName: string ): Promise => { if (!mainWindow) throw new Error('Cannot spawn managers: MainWindow not found'); + // init managers if (!managers) { - const smesher = new SmesherManager(mainWindow, NODE_CONFIG_FILE, genesisID); + const smesher = new SmesherManager(mainWindow, genesisID, netName); const node = new NodeManager(mainWindow, genesisID, smesher); const wallet = new WalletManager(mainWindow, node); managers = { smesher, node, wallet }; } else { + // update GenesisID and netName for instance + managers.smesher.setNetName(netName); + managers.smesher.setGenesisID(genesisID); + managers.node.setGenesisID(genesisID); + + // set up browser window managers.smesher.setBrowserWindow(mainWindow); managers.node.setBrowserWindow(mainWindow); managers.wallet.setBrowserWindow(mainWindow); @@ -42,14 +50,21 @@ const spawnManagers = async ( const spawnManagers$ = ( $nodeConfig: Subject, $managers: Subject, - $mainWindow: ReplaySubject + $mainWindow: ReplaySubject, + $currentNetwork: Observable ) => { const sub = $nodeConfig .pipe( distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)), - withLatestFrom($mainWindow), - switchMap(([nodeConfig, mainWindow]) => - from(spawnManagers(mainWindow, generateGenesisIDFromConfig(nodeConfig))) + withLatestFrom($mainWindow, $currentNetwork), + switchMap(([nodeConfig, mainWindow, currentNetwork]) => + from( + spawnManagers( + mainWindow, + generateGenesisIDFromConfig(nodeConfig), + currentNetwork?.netName || '' + ) + ) ) ) .subscribe((newManagers) => { diff --git a/desktop/main/reactions/syncNodeConfig.ts b/desktop/main/reactions/syncNodeConfig.ts index 26e01f2ff..b3a8b7259 100644 --- a/desktop/main/reactions/syncNodeConfig.ts +++ b/desktop/main/reactions/syncNodeConfig.ts @@ -32,7 +32,7 @@ export default ( filter(Boolean) ) ).pipe( - switchMap((net) => from(downloadNodeConfig(net.conf))), + switchMap((net) => from(downloadNodeConfig(net.netName, net.conf))), retry(5), delay(500), catchError((err: any) => { diff --git a/desktop/main/sources/fetchDiscovery.ts b/desktop/main/sources/fetchDiscovery.ts index 332036221..8c38b3407 100644 --- a/desktop/main/sources/fetchDiscovery.ts +++ b/desktop/main/sources/fetchDiscovery.ts @@ -12,7 +12,7 @@ import { switchMap, withLatestFrom, } from 'rxjs'; -import { of } from 'ramda'; +import { equals, of } from 'ramda'; import { ipcConsts } from '../../../app/vars'; import { Network, NodeConfig, Wallet } from '../../../shared/types'; import { @@ -22,6 +22,7 @@ import { } from '../Networks'; import { handleIPC, handlerResult, makeSubscription } from '../rx.utils'; import { fetchNodeConfig } from '../../utils'; +import { Managers } from '../app.types'; export const fromNetworkConfig = (net: Network) => from(fetchNodeConfig(net.conf)).pipe( @@ -80,6 +81,28 @@ export const listNetworksByRequest = ($networks: Subject) => (networks) => $networks.next(networks) ); +let cacheNodeConfig: NodeConfig | null = null; +export const listenNodeConfigAndRestartNode = ( + $nodeConfig: Observable, + $managers: Subject +) => + makeSubscription( + $nodeConfig.pipe(withLatestFrom($managers)), + ([nodeConfig, managers]) => { + (async () => { + if (equals(nodeConfig, cacheNodeConfig)) { + return; + } + + cacheNodeConfig = nodeConfig; + + if (managers.node.isNodeRunning()) { + await managers.node.restartNode(); + } + })(); + } + ); + export const listPublicApisByRequest = ($wallet: Subject) => makeSubscription( handleIPC( diff --git a/desktop/main/startApp.ts b/desktop/main/startApp.ts index c4ec71fdf..9d48ee90e 100644 --- a/desktop/main/startApp.ts +++ b/desktop/main/startApp.ts @@ -18,6 +18,7 @@ import { fetchDiscoveryEach, listPublicApisByRequest, listNetworksByRequest, + listenNodeConfigAndRestartNode, } from './sources/fetchDiscovery'; import spawnManagers from './reactions/spawnManagers'; import syncNodeConfig from './reactions/syncNodeConfig'; @@ -163,7 +164,7 @@ const startApp = (): AppStore => { // List of unsubscribe functions const unsubs = [ // Spawn managers (and handle unsubscribing) - spawnManagers($nodeConfig, $managers, $mainWindow), + spawnManagers($nodeConfig, $managers, $mainWindow, $currentNetwork), // On changing network -> update node config syncNodeConfig( $currentNetwork, @@ -251,6 +252,7 @@ const startApp = (): AppStore => { handleOpenDashboard($mainWindow, $currentNetwork), collectWarnings($managers, $warnings), sendWarningsToRenderer($warnings, $mainWindow), + listenNodeConfigAndRestartNode($nodeConfig, $managers), handleBenchmarksIpc($mainWindow, $nodeConfig), ]; diff --git a/jest.config.js b/jest.config.js index 91cd4e3dc..18588ead4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -3,6 +3,6 @@ module.exports = { preset: 'ts-jest', runner: '@jest-runner/electron/main', testEnvironment: 'node', - testRegex: '(/tests/.*|(\\.|/)(test|spec))\\.ts$', + testMatch: ['**/?(*.)+(spec|test).ts'], verbose: true, }; diff --git a/shared/warning.ts b/shared/warning.ts index 0b37a17c5..c5109442f 100644 --- a/shared/warning.ts +++ b/shared/warning.ts @@ -9,6 +9,7 @@ export enum WriteFilePermissionWarningKind { Logger = 'Logger', ConfigFile = 'ConfigFile', WalletFile = 'WalletFile', + CustomConfigFile = 'CustomConfigFile', } interface WarningTypeOptions { diff --git a/tests/fixtures/config.ts b/tests/fixtures/config.ts new file mode 100644 index 000000000..66079cc10 --- /dev/null +++ b/tests/fixtures/config.ts @@ -0,0 +1,232 @@ +export const defaultDiscoveryConfig = { + api: { + 'grpc-public-services': [ + 'debug', + 'global', + 'mesh', + 'node', + 'transaction', + 'activation', + ], + 'grpc-public-listener': '0.0.0.0:9092', + 'grpc-private-services': ['smesher', 'admin'], + 'grpc-private-listener': '0.0.0.0:9093', + 'grpc-json-listener': '0.0.0.0:9094', + }, + preset: 'testnet', + p2p: { + 'disable-reuseport': true, + bootnodes: [], + }, + main: { + 'layer-duration': '5m', + 'layers-per-epoch': 576, + 'tick-size': 1036800, + 'block-gas-limit': 1000000, + 'eligibility-confidence-param': 100, + 'poet-server': [], + }, + genesis: { + 'genesis-time': '2023-05-31T20:00:00.498Z', + 'genesis-extra-data': 'testnet-05', + }, + poet: { + 'phase-shift': '12h', + 'cycle-gap': '12h', + 'grace-period': '1h', + 'retry-delay': '10s', + }, + logging: { + nipostBuilder: 'debug', + post: 'debug', + }, + post: { + 'post-labels-per-unit': 536870912, + 'post-max-numunits': 1000, + 'post-min-numunits': 4, + 'post-k1': 26, + 'post-k2': 37, + 'post-k3': 37, + 'post-k2pow-difficulty': 89157696150400, + 'post-pow-difficulty': + '001bf647612f3696000000000000000000000000000000000000000000000000', + }, + hare: { + 'hare-wakeup-delta': '25s', + 'hare-round-duration': '25s', + 'hare-limit-iterations': 4, + 'hare-committee-size': 200, + }, + tortoise: { + 'tortoise-zdist': 2, + 'tortoise-hdist': 2, + 'tortoise-window-size': 10000, + 'tortoise-delay-layers': 100, + }, + beacon: { + 'beacon-grace-period-duration': '10m', + 'beacon-proposal-duration': '4m', + 'beacon-first-voting-round-duration': '30m', + 'beacon-rounds-number': 200, + 'beacon-voting-round-duration': '4m', + 'beacon-weak-coin-round-duration': '4m', + }, + bootstrap: { + 'bootstrap-url': 'https://bootstrap.spacemesh.network/testnet-05', + }, + recovery: { + 'recovery-uri': + 'https://recovery.spacemesh.network/testnet-05/snapshot-6264', + 'recovery-layer': 6270, + 'preserve-own-atx': false, + }, +}; + +export const defaultDiscoveryConfigDevnet = { + ...defaultDiscoveryConfig, + genesis: { + 'genesis-time': '2023-05-29T15:00:00.498Z', + 'genesis-extra-data': 'devnet-401-short', + }, +}; + +export const defaultNodeConfig = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': '/Users/dir/post/7f8f332c', + }, + }, + api: { + 'grpc-public-services': [ + 'debug', + 'global', + 'mesh', + 'node', + 'transaction', + 'activation', + ], + 'grpc-public-listener': '0.0.0.0:9092', + 'grpc-private-services': ['smesher', 'admin'], + 'grpc-private-listener': '0.0.0.0:9093', + 'grpc-json-listener': '0.0.0.0:9094', + }, + preset: 'testnet', + p2p: { + 'disable-reuseport': true, + bootnodes: [ + '/dns4/testnet-05-bootnode-0.spacemesh.network/tcp/5000/p2p/12D3KooWQMAAL9nkgXJgTM2psJLMbWRgZLjAxJozx7dNkKYczs2V', + '/dns4/testnet-05-bootnode-1.spacemesh.network/tcp/5000/p2p/12D3KooWF2bhnqsnu2UjxJGZs8KCbzvd91vnud29KaG9rzTNFH78', + '/dns4/testnet-05-bootnode-2.spacemesh.network/tcp/5000/p2p/12D3KooWQKosE9LZraMfFPg9QRYyKwxT4ipsar37oT6wRy8WqWos', + '/dns4/testnet-05-bootnode-3.spacemesh.network/tcp/5000/p2p/12D3KooWKMQ5j15x2gzfXfX7uisss7uwkPnqJyoHcfyiYfHgSo3F', + '/dns4/testnet-05-bootnode-4.spacemesh.network/tcp/5000/p2p/12D3KooWE1YEa3roqamhPt3CNiVRBzS7Lbv8HRbByE7fpSyLAZis', + '/dns4/testnet-05-bootnode-5.spacemesh.network/tcp/5000/p2p/12D3KooWFCqyEner8hSnBmyQCkcxkuHjwY5BhHTaRYmGuNC1SQET', + '/dns4/testnet-05-bootnode-6.spacemesh.network/tcp/5000/p2p/12D3KooWA96fvi2pQaQHpS2cpahDJCyQSsBW6A4KwHCPXsDVuiEJ', + '/dns4/testnet-05-bootnode-7.spacemesh.network/tcp/5000/p2p/12D3KooWRvAVZrK3EURQBh1wdMPRDgjtowU84div3YxWYKuGDaFU', + '/dns4/testnet-05-bootnode-8.spacemesh.network/tcp/5000/p2p/12D3KooWL3VGmZ4xgT1J5KnVJz7b6wxAUUFAVqNNRURKFfWNXLE4', + '/dns4/testnet-05-bootnode-9.spacemesh.network/tcp/5000/p2p/12D3KooWCfadA8PUva7eRn1GzdLhnfELRLu81s1na6JbdYivwBgs', + ], + }, + main: { + 'layer-duration': '5m', + 'layers-per-epoch': 576, + 'tick-size': 1036800, + 'block-gas-limit': 1000000, + 'eligibility-confidence-param': 100, + 'poet-server': [ + 'https://testnet-05-poet-0.spacemesh.network', + 'https://testnet-05-poet-1.spacemesh.network', + 'https://testnet-05-poet-2.spacemesh.network', + 'https://poet-11.spacemesh.network', + ], + }, + genesis: { + 'genesis-time': '2023-05-31T20:00:00.498Z', + 'genesis-extra-data': 'testnet-05', + }, + poet: { + 'phase-shift': '12h', + 'cycle-gap': '12h', + 'grace-period': '1h', + 'retry-delay': '10s', + }, + logging: { + nipostBuilder: 'debug', + post: 'debug', + }, + post: { + 'post-labels-per-unit': 536870912, + 'post-max-numunits': 1000, + 'post-min-numunits': 4, + 'post-k1': 26, + 'post-k2': 37, + 'post-k3': 37, + 'post-k2pow-difficulty': 89157696150400, + 'post-pow-difficulty': + '001bf647612f3696000000000000000000000000000000000000000000000000', + }, + hare: { + 'hare-wakeup-delta': '25s', + 'hare-round-duration': '25s', + 'hare-limit-iterations': 4, + 'hare-committee-size': 200, + }, + tortoise: { + 'tortoise-zdist': 2, + 'tortoise-hdist': 2, + 'tortoise-window-size': 10000, + 'tortoise-delay-layers': 100, + }, + beacon: { + 'beacon-grace-period-duration': '10m', + 'beacon-proposal-duration': '4m', + 'beacon-first-voting-round-duration': '30m', + 'beacon-rounds-number': 200, + 'beacon-voting-round-duration': '4m', + 'beacon-weak-coin-round-duration': '4m', + }, + bootstrap: { + 'bootstrap-url': 'https://bootstrap.spacemesh.network/testnet-05', + }, + recovery: { + 'recovery-uri': + 'https://recovery.spacemesh.network/testnet-05/snapshot-6264', + 'recovery-layer': 6270, + 'preserve-own-atx': false, + }, +}; + +export const customConfigWithNoSpecifiedLogging = { + logging: { + nipostBuilder: 'info', + }, +}; + +export const smeshingOptsFromStoreService = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': '/Users/max/post/data', + 'smeshing-opts-maxfilesize': 2147483648, + 'smeshing-opts-numunits': 4, + 'smeshing-opts-provider': 0, + 'smeshing-opts-throttle': false, + 'smeshing-opts-compute-batch-size': 1048576, + }, + 'smeshing-coinbase': 'stest1qqqqqqpx76g4dg297uqu6jaxaz0d4yfxjy5ufacpztqwf', + 'smeshing-proving-opts': { + 'smeshing-opts-proving-nonces': 288, + 'smeshing-opts-proving-threads': 9, + }, + 'smeshing-start': true, + }, +}; + +export const defaultSmeshingOpts = { + 'smeshing-opts': { + 'smeshing-opts-datadir': '/Users/max/post/data', + }, +}; + +export const defaultNodeConfigWithInitSmeshing = { + ...defaultNodeConfig, + smeshing: smeshingOptsFromStoreService, +}; diff --git a/tests/nodeConfig.spec.ts b/tests/nodeConfig.spec.ts new file mode 100644 index 000000000..10c814bca --- /dev/null +++ b/tests/nodeConfig.spec.ts @@ -0,0 +1,448 @@ +/* eslint-disable global-require */ + +import { + customConfigWithNoSpecifiedLogging, + defaultDiscoveryConfig, + defaultDiscoveryConfigDevnet, + defaultNodeConfigWithInitSmeshing, + smeshingOptsFromStoreService, +} from './fixtures/config'; + +beforeEach(() => { + jest.mock('fs/promises'); + jest.mock('fs'); + jest.mock('electron-store'); +}); + +afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); +}); + +describe('NodeConfig.ts', () => { + describe('download node config', () => { + it('download fresh node config and create custom config', async () => { + // mock discovery config + jest.mock('electron-fetch', () => + jest.fn().mockResolvedValue({ + text: () => JSON.stringify(defaultDiscoveryConfig), + }) + ); + + // mock StoreService , nothing to migrate + jest.mock( + '../desktop/storeService', + jest.fn().mockImplementation(() => ({ + get: jest.fn().mockImplementation(() => undefined), + remove: jest.fn().mockImplementation(() => {}), + })) + ); + + const fsPromise = require('fs/promises'); + const { downloadNodeConfig } = require('../desktop/main/NodeConfig'); + + const netName = 'testnet-5'; + const resultConfig = await downloadNodeConfig( + netName, + 'https://testnet-5.spacemesh.io' + ); + + const smeshingOptsResult = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': + resultConfig.smeshing['smeshing-opts']['smeshing-opts-datadir'], + }, + }, + }; + + // check config after download + expect(resultConfig).toEqual({ + ...defaultDiscoveryConfig, + ...smeshingOptsResult, + }); + + // write opts for custom config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingOptsResult, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultDiscoveryConfig, + ...smeshingOptsResult, + }), + { encoding: 'utf8' } + ); + }); + + it('download fresh node config and migrate Store service', async () => { + // mock discovery config + jest.mock('electron-fetch', () => + jest.fn().mockResolvedValue({ + text: () => JSON.stringify(defaultDiscoveryConfig), + }) + ); + + // mock StoreService , to check migration + jest.mock( + '../desktop/storeService', + jest.fn().mockImplementation(() => ({ + get: jest + .fn() + .mockImplementation(() => smeshingOptsFromStoreService.smeshing), + remove: jest.fn().mockImplementation(() => {}), + })) + ); + + const fsPromise = require('fs/promises'); + const { downloadNodeConfig } = require('../desktop/main/NodeConfig'); + + const netName = 'testnet-5'; + const resultConfig = await downloadNodeConfig( + netName, + 'https://testnet-5.spacemesh.io' + ); + + // check config after download + expect(resultConfig).toEqual({ + ...defaultDiscoveryConfig, + ...smeshingOptsFromStoreService, + }); + + // write opts for custom config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingOptsFromStoreService, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultDiscoveryConfig, + ...smeshingOptsFromStoreService, + }), + { encoding: 'utf8' } + ); + }); + + it('download config for testnet-5 and download config for testnet-6 should rewrite config file', async () => { + // mock discovery config + jest.mock('electron-fetch', () => + jest + .fn() + .mockResolvedValueOnce({ + text: () => JSON.stringify(defaultDiscoveryConfig), + }) + .mockResolvedValueOnce({ + text: () => JSON.stringify(defaultDiscoveryConfigDevnet), + }) + ); + + // mock StoreService + jest.mock( + '../desktop/storeService', + jest.fn().mockImplementation(() => ({ + get: jest.fn().mockImplementation(() => undefined), + remove: jest.fn().mockImplementation(() => {}), + })) + ); + + const fsPromise = require('fs/promises'); + const { downloadNodeConfig } = require('../desktop/main/NodeConfig'); + + const resultConfigTestNet = await downloadNodeConfig( + 'testnet-5', + 'https://testnet-5.spacemesh.io' + ); + + const smeshingOptsResultTestNet = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': + resultConfigTestNet.smeshing['smeshing-opts'][ + 'smeshing-opts-datadir' + ], + }, + }, + }; + + // check config after download + expect(resultConfigTestNet).toEqual({ + ...defaultDiscoveryConfig, + ...smeshingOptsResultTestNet, + }); + + const resultConfigDevnet = await downloadNodeConfig( + 'Standalone Network', + 'https://devent-000.spacemesh.io' + ); + + const smeshingOptsResultDevnet = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': + resultConfigDevnet.smeshing['smeshing-opts'][ + 'smeshing-opts-datadir' + ], + }, + }, + }; + + // check config after download + expect(resultConfigDevnet).toEqual({ + ...defaultDiscoveryConfigDevnet, + ...smeshingOptsResultDevnet, + }); + + // check datadir not equal for 2 configs and check gensisID is different + expect( + resultConfigTestNet.smeshing['smeshing-opts']['smeshing-opts-datadir'] + ).not.toEqual( + resultConfigDevnet.smeshing['smeshing-opts']['smeshing-opts-datadir'] + ); + + // write opts for custom config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingOptsResultTestNet, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config for testnet-5 + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultDiscoveryConfig, + ...smeshingOptsResultTestNet, + }), + { encoding: 'utf8' } + ); + + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.standalone_network.json'), + JSON.stringify(smeshingOptsResultDevnet, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config for standalone_network + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultDiscoveryConfigDevnet, + ...smeshingOptsResultDevnet, + }), + { encoding: 'utf8' } + ); + }); + }); + + describe('update smeshing opts for node config', () => { + it('expect to delete smeshing opts, update custom config', async () => { + const fsPromises = require('fs/promises'); + const nodeConfigHelpers = require('../desktop/main/NodeConfig'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadCustomNodeConfig = jest + .fn() + .mockImplementation(() => smeshingOptsFromStoreService); + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadNodeConfig = jest + .fn() + .mockImplementation(() => defaultNodeConfigWithInitSmeshing); + + // eslint-disable-next-line import/no-named-as-default-member + const resultConfigTestNet = await nodeConfigHelpers.updateSmeshingOpts( + 'testnet-5', + {} + ); + + const smeshingAfterReset = { + smeshing: { + 'smeshing-opts': { + 'smeshing-opts-datadir': + resultConfigTestNet.smeshing['smeshing-opts'][ + 'smeshing-opts-datadir' + ], + }, + }, + }; + + expect(resultConfigTestNet).toEqual({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingAfterReset, + }); + + // write opts for custom config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingAfterReset, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingAfterReset, + }), + { encoding: 'utf8' } + ); + }); + + it('expect to stop smeshing, on propriate input for updateSmeshingOpts', async () => { + const fsPromises = require('fs/promises'); + const nodeConfigHelpers = require('../desktop/main/NodeConfig'); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadCustomNodeConfig = jest + .fn() + .mockImplementation(() => smeshingOptsFromStoreService); + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadNodeConfig = jest + .fn() + .mockImplementation(() => defaultNodeConfigWithInitSmeshing); + + // eslint-disable-next-line import/no-named-as-default-member + const resultConfigTestNet = await nodeConfigHelpers.updateSmeshingOpts( + 'testnet-5', + { 'smeshing-start': false } + ); + + const smeshingOptsAfterReset = { + smeshing: { + ...smeshingOptsFromStoreService.smeshing, + ...{ 'smeshing-start': false }, + }, + }; + + expect(resultConfigTestNet).toEqual({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingOptsAfterReset, + }); + + // write opts for custom config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingOptsAfterReset, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingOptsAfterReset, + }), + { encoding: 'utf8' } + ); + }); + }); + + describe('merge node config and custom config', () => { + it('expect to rewrite whole section if 1 property defined in custom config for smeshing update', async () => { + const fsPromises = require('fs/promises'); + const nodeConfigHelpers = require('../desktop/main/NodeConfig'); + const customConfigWithInitedSmeshingOptsAndRewriteLogging = { + ...smeshingOptsFromStoreService, + ...customConfigWithNoSpecifiedLogging, + }; + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadCustomNodeConfig = jest + .fn() + .mockImplementation( + () => customConfigWithInitedSmeshingOptsAndRewriteLogging + ); + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadNodeConfig = jest + .fn() + .mockImplementation(() => defaultNodeConfigWithInitSmeshing); + + // eslint-disable-next-line import/no-named-as-default-member + const resultConfigTestNet = await nodeConfigHelpers.updateSmeshingOpts( + 'testnet-5', + { 'smeshing-start': false } + ); + + const smeshingOptsAfterReset = { + smeshing: { + ...customConfigWithInitedSmeshingOptsAndRewriteLogging.smeshing, + ...{ 'smeshing-start': false }, + }, + logging: customConfigWithInitedSmeshingOptsAndRewriteLogging.logging, + }; + + expect(resultConfigTestNet).toEqual({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingOptsAfterReset, + }); + + // write opts for custom config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.testnet-5.json'), + JSON.stringify(smeshingOptsAfterReset, null, 2), + { encoding: 'utf8' } + ); + + // write merged config, custom config + discovery config + expect(fsPromises.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultNodeConfigWithInitSmeshing, + ...smeshingOptsAfterReset, + }), + { encoding: 'utf8' } + ); + }); + + it('expect to rewrite whole section for node-config if only 1 property defined in custom config', async () => { + // mock discovery config + jest.mock('electron-fetch', () => + jest.fn().mockResolvedValue({ + text: () => JSON.stringify(defaultDiscoveryConfig), + }) + ); + + const fs = require('fs'); + const fsPromise = require('fs/promises'); + const nodeConfigHelpers = require('../desktop/main/NodeConfig'); + + fs.existsSync = jest.fn().mockImplementation(() => true); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars,import/no-named-as-default-member + nodeConfigHelpers.loadCustomNodeConfig = jest + .fn() + .mockImplementation(() => customConfigWithNoSpecifiedLogging); + + const netName = 'testnet-5'; + // eslint-disable-next-line import/no-named-as-default-member + const resultConfig = await nodeConfigHelpers.downloadNodeConfig( + netName, + 'https://testnet-5.spacemesh.io' + ); + + // check config after download + expect(resultConfig).toEqual({ + ...defaultDiscoveryConfig, + ...customConfigWithNoSpecifiedLogging, + }); + // write merged config + expect(fsPromise.writeFile).toHaveBeenCalledWith( + expect.stringContaining('Electron/node-config.json'), + JSON.stringify({ + ...defaultDiscoveryConfig, + ...customConfigWithNoSpecifiedLogging, + }), + { encoding: 'utf8' } + ); + }); + }); +});