From e83888a1929b15179a33a47189829e36d08822ca Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Thu, 12 Dec 2019 18:10:11 -0500 Subject: [PATCH] feat(bitcoind): add ability to remove a bitcoind node --- TODO.md | 7 ++ .../designer/bitcoind/ActionsTab.tsx | 19 ++-- .../{ => actions}/MineBlocksInput.spec.tsx | 0 .../{ => actions}/MineBlocksInput.tsx | 0 .../bitcoind/actions/RemoveNode.spec.tsx | 102 ++++++++++++++++++ .../designer/bitcoind/actions/RemoveNode.tsx | 50 +++++++++ src/i18n/locales/en-US.json | 10 +- src/store/models/bitcoind.ts | 19 +++- src/store/models/network.ts | 79 +++++++++++++- src/utils/chart.ts | 6 +- 10 files changed, 274 insertions(+), 18 deletions(-) rename src/components/designer/bitcoind/{ => actions}/MineBlocksInput.spec.tsx (100%) rename src/components/designer/bitcoind/{ => actions}/MineBlocksInput.tsx (100%) create mode 100644 src/components/designer/bitcoind/actions/RemoveNode.spec.tsx create mode 100644 src/components/designer/bitcoind/actions/RemoveNode.tsx diff --git a/TODO.md b/TODO.md index 82429c4..b9652cc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,12 @@ # TODO List +- refactor network model removeNode -> removeLightningNode +- update chart to reflect new backend connection +- add new versions of LND and bitcoind +- add toggle to default sidebar to hide/show older versions +- display compatibility warnings with LND + bitcoind +- connect bitcoin peers after they all start up + Small Stuff - fix pasting text in terminal diff --git a/src/components/designer/bitcoind/ActionsTab.tsx b/src/components/designer/bitcoind/ActionsTab.tsx index 1c1a14d..d117944 100644 --- a/src/components/designer/bitcoind/ActionsTab.tsx +++ b/src/components/designer/bitcoind/ActionsTab.tsx @@ -1,28 +1,31 @@ import React from 'react'; -import { usePrefixedTranslation } from 'hooks'; +import styled from '@emotion/styled'; import { BitcoinNode, Status } from 'shared/types'; import { OpenTerminalButton } from 'components/terminal'; -import MineBlocksInput from './MineBlocksInput'; +import MineBlocksInput from './actions/MineBlocksInput'; +import RemoveNode from './actions/RemoveNode'; + +const Styled = { + Spacer: styled.div` + height: 48px; + `, +}; interface Props { node: BitcoinNode; } const ActionsTab: React.FC = ({ node }) => { - const { l } = usePrefixedTranslation('cmps.designer.bitcoind.ActionsTab'); - - if (node.status !== Status.Started) { - return <>{l('notStarted')}; - } - return ( <> {node.status === Status.Started && ( <> + )} + ); }; diff --git a/src/components/designer/bitcoind/MineBlocksInput.spec.tsx b/src/components/designer/bitcoind/actions/MineBlocksInput.spec.tsx similarity index 100% rename from src/components/designer/bitcoind/MineBlocksInput.spec.tsx rename to src/components/designer/bitcoind/actions/MineBlocksInput.spec.tsx diff --git a/src/components/designer/bitcoind/MineBlocksInput.tsx b/src/components/designer/bitcoind/actions/MineBlocksInput.tsx similarity index 100% rename from src/components/designer/bitcoind/MineBlocksInput.tsx rename to src/components/designer/bitcoind/actions/MineBlocksInput.tsx diff --git a/src/components/designer/bitcoind/actions/RemoveNode.spec.tsx b/src/components/designer/bitcoind/actions/RemoveNode.spec.tsx new file mode 100644 index 0000000..a25524a --- /dev/null +++ b/src/components/designer/bitcoind/actions/RemoveNode.spec.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { fireEvent, waitForElement } from '@testing-library/dom'; +import { Status } from 'shared/types'; +import { DockerLibrary } from 'types'; +import { initChartFromNetwork } from 'utils/chart'; +import { + getNetwork, + injections, + lightningServiceMock, + renderWithProviders, + suppressConsoleErrors, +} from 'utils/tests'; +import RemoveNode from './RemoveNode'; + +const dockerServiceMock = injections.dockerService as jest.Mocked; + +describe('RemoveNode', () => { + const renderComponent = (status?: Status) => { + const network = getNetwork(1, 'test network', status); + if (status === Status.Error) { + network.nodes.lightning.forEach(n => (n.errorMsg = 'test-error')); + } + const initialState = { + network: { + networks: [network], + }, + designer: { + allCharts: { + 1: initChartFromNetwork(network), + }, + activeId: 1, + }, + }; + const node = network.nodes.bitcoin[0]; + const cmp = ; + const result = renderWithProviders(cmp, { initialState }); + return { + ...result, + node, + }; + }; + + beforeEach(() => { + lightningServiceMock.getChannels.mockResolvedValue([]); + }); + + it('should show the remove node modal', async () => { + const { getByText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + expect( + getByText('Are you sure you want to remove alice from the network?'), + ).toBeInTheDocument(); + expect(getByText('Yes')).toBeInTheDocument(); + expect(getByText('Cancel')).toBeInTheDocument(); + }); + + it('should remove the node with the network stopped', async () => { + const { getByText, getAllByText, getByLabelText } = renderComponent(Status.Stopped); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + // antd creates two modals in the DOM for some silly reason. Need to click one + fireEvent.click(getAllByText('Yes')[0]); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('icon: check-circle-o')); + expect( + getByText('The node alice have been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.removeNode).toBeCalledTimes(1); + }); + + it('should remove the node with the network started', async () => { + const { getByText, getAllByText, getByLabelText } = renderComponent(Status.Started); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + // antd creates two modals in the DOM for some silly reason. Need to click one + fireEvent.click(getAllByText('Yes')[0]); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('icon: check-circle-o')); + expect( + getByText('The node alice have been removed from the network'), + ).toBeInTheDocument(); + expect(dockerServiceMock.removeNode).toBeCalledTimes(1); + }); + + it('should display an error if removing the node fails', async () => { + // antd Modal.confirm logs a console error when onOk fails + // this supresses those errors from being displayed in test runs + await suppressConsoleErrors(async () => { + dockerServiceMock.removeNode.mockRejectedValue(new Error('test error')); + const { getByText, getAllByText, getByLabelText } = renderComponent(); + expect(getByText('Remove')).toBeInTheDocument(); + fireEvent.click(getByText('Remove')); + // antd creates two modals in the DOM for some silly reason. Need to click one + fireEvent.click(getAllByText('Yes')[0]); + // wait for the error notification to be displayed + await waitForElement(() => getByLabelText('icon: close-circle-o')); + expect(getByText('Unable to remove the node')).toBeInTheDocument(); + expect(getByText('test error')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/designer/bitcoind/actions/RemoveNode.tsx b/src/components/designer/bitcoind/actions/RemoveNode.tsx new file mode 100644 index 0000000..4d4497f --- /dev/null +++ b/src/components/designer/bitcoind/actions/RemoveNode.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Button, Form, Modal } from 'antd'; +import { usePrefixedTranslation } from 'hooks'; +import { BitcoinNode, Status } from 'shared/types'; +import { useStoreActions } from 'store'; + +interface Props { + node: BitcoinNode; +} + +const RemoveNode: React.FC = ({ node }) => { + const { l } = usePrefixedTranslation('cmps.designer.bitcoind.actions.RemoveNode'); + const { notify } = useStoreActions(s => s.app); + const { removeBitcoinNode } = useStoreActions(s => s.network); + + const showRemoveModal = () => { + const { name } = node; + Modal.confirm({ + title: l('confirmTitle', { name }), + content: ( + <> +

{l('confirmText')}

+ {node.status === Status.Started &&

{l('restartText')}

} + + ), + okText: l('confirmBtn'), + okType: 'danger', + cancelText: l('cancelBtn'), + onOk: async () => { + try { + await removeBitcoinNode({ node }); + notify({ message: l('success', { name }) }); + } catch (error) { + notify({ message: l('error'), error }); + throw error; + } + }, + }); + }; + + return ( + + + + ); +}; + +export default RemoveNode; diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index b3b3d0f..2cdb30c 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -11,7 +11,15 @@ "cmps.common.OpenTerminalButton.title": "Terminal", "cmps.common.OpenTerminalButton.btn": "Launch", "cmps.common.OpenTerminalButton.info": "Run '{{cmd}}' commands directly on the node", - "cmps.designer.bitcoind.ActionsTab.notStarted": "Node needs to be started to perform actions on it", + "cmps.designer.bitcoind.actions.RemoveNode.title": "Remove Node From Network", + "cmps.designer.bitcoind.actions.RemoveNode.btnText": "Remove", + "cmps.designer.bitcoind.actions.RemoveNode.confirmTitle": "Are you sure you want to remove {{name}} from the network?", + "cmps.designer.bitcoind.actions.RemoveNode.confirmText": "Any lightning nodes using this node as a backend will be connected to another bitcoin node.", + "cmps.designer.bitcoind.actions.RemoveNode.restartText": "The network will be restarted to connect LN nodes to another backend and bitcoin nodes to other peers. This can take up to a minute to complete.", + "cmps.designer.bitcoind.actions.RemoveNode.confirmBtn": "Yes", + "cmps.designer.bitcoind.actions.RemoveNode.cancelBtn": "Cancel", + "cmps.designer.bitcoind.actions.RemoveNode.success": "The node {{name}} have been removed from the network", + "cmps.designer.bitcoind.actions.RemoveNode.error": "Unable to remove the node", "cmps.designer.bitcoind.BitcoinDetails.info": "Info", "cmps.designer.bitcoind.BitcoinDetails.connect": "Connect", "cmps.designer.bitcoind.BitcoinDetails.actions": "Actions", diff --git a/src/store/models/bitcoind.ts b/src/store/models/bitcoind.ts index c2bb559..a44187d 100644 --- a/src/store/models/bitcoind.ts +++ b/src/store/models/bitcoind.ts @@ -1,8 +1,10 @@ import { ChainInfo, WalletInfo } from 'bitcoin-core'; import { Action, action, Thunk, thunk } from 'easy-peasy'; -import { BitcoinNode } from 'shared/types'; +import { BitcoinNode, Status } from 'shared/types'; import { StoreInjections } from 'types'; +import { delay } from 'utils/async'; import { prefixTranslation } from 'utils/translate'; +import { RootModel } from './'; const { l } = prefixTranslation('store.models.bitcoind'); @@ -21,7 +23,12 @@ export interface BitcoindModel { setChainInfo: Action; setWalletinfo: Action; getInfo: Thunk; - mine: Thunk; + mine: Thunk< + BitcoindModel, + { blocks: number; node: BitcoinNode }, + StoreInjections, + RootModel + >; } const bitcoindModel: BitcoindModel = { @@ -47,11 +54,17 @@ const bitcoindModel: BitcoindModel = { const walletInfo = await injections.bitcoindService.getWalletInfo(node.ports.rpc); actions.setWalletinfo({ node, walletInfo }); }), - mine: thunk(async (actions, { blocks, node }, { injections }) => { + mine: thunk(async (actions, { blocks, node }, { injections, getStoreState }) => { if (blocks < 0) { throw new Error(l('mineError')); } await injections.bitcoindService.mine(blocks, node.ports.rpc); + await delay(500); + // update info for all bitcoin nodes + const network = getStoreState().network.networkById(node.networkId); + await Promise.all( + network.nodes.bitcoin.filter(n => n.status === Status.Started).map(actions.getInfo), + ); await actions.getInfo(node); }), }; diff --git a/src/store/models/network.ts b/src/store/models/network.ts index a21980f..5c5d02c 100644 --- a/src/store/models/network.ts +++ b/src/store/models/network.ts @@ -62,6 +62,12 @@ export interface NetworkModel { Promise >; removeNode: Thunk; + removeBitcoinNode: Thunk< + NetworkModel, + { node: BitcoinNode }, + StoreInjections, + RootModel + >; setStatus: Action< NetworkModel, { id: number; status: Status; only?: string; all?: boolean; error?: Error } @@ -173,19 +179,84 @@ const networkModel: NetworkModel = { const networks = getState().networks; const network = networks.find(n => n.id === node.networkId); if (!network) throw new Error(l('networkByIdErr', { networkId: node.networkId })); + // remove the node from the network network.nodes.lightning = network.nodes.lightning.filter(n => n !== node); + // remove the node's data from the lightning redux state getStoreActions().lightning.removeNode(node.name); - await injections.dockerService.removeNode(network, node); + // remove the node rom the running docker network + if (network.status === Status.Started) { + await injections.dockerService.removeNode(network, node); + } + // clear cached RPC data + if (node.implementation === 'LND') getStoreActions().app.clearAppCache(); + // remove the node from the chart's redux state getStoreActions().designer.removeNode(node.name); + // update the network in the redux state and save to disk actions.setNetworks([...networks]); await actions.save(); + // delete the docker volume data from disk const volumeDir = node.implementation.toLocaleLowerCase().replace('-', ''); rm(join(network.path, 'volumes', volumeDir, node.name)); - if (node.implementation === 'LND') { - getStoreActions().app.clearAppCache(); + // sync the chart + if (network.status === Status.Started) { + await getStoreActions().designer.syncChart(network); } + }, + ), + removeBitcoinNode: thunk( + async (actions, { node }, { getState, injections, getStoreActions }) => { + const networks = getState().networks; + const network = networks.find(n => n.id === node.networkId); + if (!network) throw new Error(l('networkByIdErr', { networkId: node.networkId })); + const { dockerService, lightningFactory } = injections; + const { bitcoind, designer } = getStoreActions(); + const { bitcoin, lightning } = network.nodes; + + if (bitcoin.length === 1) throw new Error('Cannot remove the only bitcoin node'); + const index = bitcoin.indexOf(node); + // update LN nodes to use a different backend + lightning + .filter(n => n.backendName === node.name) + .forEach(n => (n.backendName = bitcoin[0].name)); + + // bitcoin nodes are peer'd with the nodes immediately before and after. if the + // node being removed is in between two nodes, then connect those two nodes + // together. Otherwise, the network will be operating on two different chains + if (index > 0 && index < bitcoin.length - 1) { + // make prev & next nodes peers + const [prev, curr, next] = bitcoin.slice(index - 1, index + 2); + // remove curr and add next to prev peers + prev.peers = [...prev.peers.filter(p => p !== curr.name), next.name]; + // remove curr and add prev to next peers + next.peers = [prev.name, ...next.peers.filter(p => p !== curr.name)]; + } + // remove the node from the network + network.nodes.bitcoin = bitcoin.filter(n => n !== node); + // remove the node's data from the bitcoind redux state + bitcoind.removeNode(node.name); + if (network.status === Status.Started) { + // restart the whole network if it is running + await actions.stop(network.id); + await dockerService.saveComposeFile(network); + await actions.start(network.id); + } else { + // save compose file if the network is not running + await dockerService.saveComposeFile(network); + } + // remove the node from the chart's redux state + designer.removeNode(node.name); + // update the network in the redux state and save to disk + actions.setNetworks([...networks]); + await actions.save(); + // delete the docker volume data from disk + const volumeDir = node.implementation.toLocaleLowerCase().replace('-', ''); + rm(join(network.path, 'volumes', volumeDir, node.name)); if (network.status === Status.Started) { - getStoreActions().designer.syncChart(network); + // wait for the LN nodes to come back online then update the chart + await Promise.all( + lightning.map(n => lightningFactory.getService(n).waitUntilOnline(n)), + ); + await designer.syncChart(network); } }, ), diff --git a/src/utils/chart.ts b/src/utils/chart.ts index fcc7c68..e58de71 100644 --- a/src/utils/chart.ts +++ b/src/utils/chart.ts @@ -222,7 +222,8 @@ export const updateChartFromNodes = ( // don't remove links for existing channels if (createdLinkIds.includes(linkId)) return; // don't remove links to bitcoin nodes - if (linkId.endsWith('-backend')) return; + const { type } = links[linkId].properties; + if (['backend', 'btcpeer'].includes(type)) return; // delete all other links delete links[linkId]; }); @@ -231,7 +232,8 @@ export const updateChartFromNodes = ( Object.values(nodes).forEach(node => { Object.keys(node.ports).forEach(portId => { // don't remove special ports - if (['empty-left', 'empty-right', 'backend'].includes(portId)) return; + const special = ['empty-left', 'empty-right', 'backend', 'peer-left', 'peer-right']; + if (special.includes(portId)) return; // don't remove ports for existing channels if (createdLinkIds.includes(portId)) return; // delete all other ports