Browse Source

feat(bitcoind): add ability to remove a bitcoind node

master
jamaljsr 5 years ago
parent
commit
e83888a192
  1. 7
      TODO.md
  2. 19
      src/components/designer/bitcoind/ActionsTab.tsx
  3. 0
      src/components/designer/bitcoind/actions/MineBlocksInput.spec.tsx
  4. 0
      src/components/designer/bitcoind/actions/MineBlocksInput.tsx
  5. 102
      src/components/designer/bitcoind/actions/RemoveNode.spec.tsx
  6. 50
      src/components/designer/bitcoind/actions/RemoveNode.tsx
  7. 10
      src/i18n/locales/en-US.json
  8. 19
      src/store/models/bitcoind.ts
  9. 77
      src/store/models/network.ts
  10. 6
      src/utils/chart.ts

7
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

19
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<Props> = ({ node }) => {
const { l } = usePrefixedTranslation('cmps.designer.bitcoind.ActionsTab');
if (node.status !== Status.Started) {
return <>{l('notStarted')}</>;
}
return (
<>
{node.status === Status.Started && (
<>
<MineBlocksInput node={node} />
<OpenTerminalButton node={node} />
<Styled.Spacer />
</>
)}
<RemoveNode node={node} />
</>
);
};

0
src/components/designer/bitcoind/MineBlocksInput.spec.tsx → src/components/designer/bitcoind/actions/MineBlocksInput.spec.tsx

0
src/components/designer/bitcoind/MineBlocksInput.tsx → src/components/designer/bitcoind/actions/MineBlocksInput.tsx

102
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<DockerLibrary>;
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 = <RemoveNode node={node} />;
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();
});
});
});

50
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<Props> = ({ 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: (
<>
<p>{l('confirmText')}</p>
{node.status === Status.Started && <p>{l('restartText')}</p>}
</>
),
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 (
<Form.Item label={l('title')} colon={false}>
<Button type="danger" block ghost onClick={showRemoveModal}>
{l('btnText')}
</Button>
</Form.Item>
);
};
export default RemoveNode;

10
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",

19
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<BitcoindModel, { node: BitcoinNode; chainInfo: ChainInfo }>;
setWalletinfo: Action<BitcoindModel, { node: BitcoinNode; walletInfo: WalletInfo }>;
getInfo: Thunk<BitcoindModel, BitcoinNode, StoreInjections>;
mine: Thunk<BitcoindModel, { blocks: number; node: BitcoinNode }, StoreInjections>;
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);
}),
};

77
src/store/models/network.ts

@ -62,6 +62,12 @@ export interface NetworkModel {
Promise<LightningNode | BitcoinNode>
>;
removeNode: Thunk<NetworkModel, { node: LightningNode }, StoreInjections, RootModel>;
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);
// 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);
}
},
),

6
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

Loading…
Cancel
Save