Browse Source

feat(eclair): add ability to create a network with Eclair nodes

master
jamaljsr 5 years ago
parent
commit
a5f4394de9
  1. 4
      src/components/designer/default/DefaultSidebar.spec.tsx
  2. 6
      src/components/network/NewNetwork.spec.tsx
  3. 18
      src/components/network/NewNetwork.tsx
  4. 31
      src/lib/docker/composeFile.ts
  5. 6
      src/lib/docker/dockerService.spec.ts
  6. 6
      src/lib/docker/dockerService.ts
  7. 30
      src/lib/docker/nodeTemplates.ts
  8. 50
      src/lib/lightning/eclair/eclairService.ts
  9. 1
      src/lib/lightning/eclair/index.ts
  10. 4
      src/lib/lightning/lightningFactory.ts
  11. BIN
      src/resources/eclair.png
  12. 7
      src/shared/types.ts
  13. 3
      src/store/models/designer.spec.ts
  14. 1
      src/store/models/network.spec.ts
  15. 4
      src/store/models/network.ts
  16. 9
      src/utils/chart.ts
  17. 30
      src/utils/constants.ts
  18. 47
      src/utils/network.ts
  19. 1
      src/utils/tests/helpers.ts

4
src/components/designer/default/DefaultSidebar.spec.tsx

@ -87,7 +87,7 @@ describe('DefaultSidebar Component', () => {
const { getByText, getAllByText, getByRole } = renderComponent();
fireEvent.click(getByRole('switch'));
expect(getByText(`LND v0.8.0-beta`)).toBeInTheDocument();
expect(getAllByText('latest')).toHaveLength(3);
expect(getAllByText('latest')).toHaveLength(4);
});
it('should display the Image Updates Modal', async () => {
@ -106,7 +106,7 @@ describe('DefaultSidebar Component', () => {
const { queryByText, getAllByText, getByRole } = renderComponent();
expect(queryByText('c-lightning')).not.toBeInTheDocument();
fireEvent.click(getByRole('switch'));
expect(getAllByText('latest')).toHaveLength(2);
expect(getAllByText('latest')).toHaveLength(3);
});
it('should display custom images', () => {

6
src/components/network/NewNetwork.spec.tsx

@ -66,8 +66,9 @@ describe('NewNetwork component', () => {
it('should have the correct default nodes', () => {
mockOS.platform.mockReturnValue('darwin');
const { getByLabelText, queryByText } = renderComponent();
expect(getByLabelText('LND')).toHaveValue('2');
expect(getByLabelText('LND')).toHaveValue('1');
expect(getByLabelText('c-lightning')).toHaveValue('1');
expect(getByLabelText('Eclair')).toHaveValue('1');
expect(getByLabelText('Bitcoin Core')).toHaveValue('1');
expect(queryByText('My Test Image')).not.toBeInTheDocument();
});
@ -81,7 +82,8 @@ describe('NewNetwork component', () => {
mockOS.platform.mockReturnValue('win32');
const { getByLabelText, getByText } = renderComponent();
expect(getByLabelText('c-lightning')).toHaveValue('0');
expect(getByLabelText('LND')).toHaveValue('3');
expect(getByLabelText('LND')).toHaveValue('2');
expect(getByLabelText('Eclair')).toHaveValue('1');
expect(getByText('Not supported on Windows yet.')).toBeInTheDocument();
});

18
src/components/network/NewNetwork.tsx

@ -68,8 +68,9 @@ const NewNetwork: React.SFC = () => {
layout="vertical"
colon={false}
initialValues={{
lndNodes: isWindows() ? 3 : 2,
lndNodes: isWindows() ? 2 : 1,
clightningNodes: isWindows() ? 0 : 1,
eclairNodes: 1,
bitcoindNodes: 1,
customNodes: initialCustomValues,
}}
@ -102,7 +103,7 @@ const NewNetwork: React.SFC = () => {
)}
<Styled.Divider orientation="left">{l('managedLabel')}</Styled.Divider>
<Row gutter={16}>
<Col span={8}>
<Col span={6}>
<Form.Item
name="lndNodes"
label={dockerConfigs.LND.name}
@ -111,7 +112,7 @@ const NewNetwork: React.SFC = () => {
<InputNumber min={0} max={10} />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item
name="clightningNodes"
label={dockerConfigs['c-lightning'].name}
@ -121,7 +122,16 @@ const NewNetwork: React.SFC = () => {
<InputNumber min={0} max={10} disabled={isWindows()} />
</Form.Item>
</Col>
<Col span={8}>
<Col span={6}>
<Form.Item
name="eclairNodes"
label={dockerConfigs.eclair.name}
rules={[{ required: true, message: l('cmps.forms.required') }]}
>
<InputNumber min={0} max={10} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item
name="bitcoindNodes"
label={dockerConfigs.bitcoind.name}

31
src/lib/docker/composeFile.ts

@ -1,7 +1,13 @@
import { BitcoinNode, CLightningNode, CommonNode, LndNode } from 'shared/types';
import {
BitcoinNode,
CLightningNode,
CommonNode,
EclairNode,
LndNode,
} from 'shared/types';
import { bitcoinCredentials, dockerConfigs } from 'utils/constants';
import { getContainerName } from 'utils/network';
import { bitcoind, clightning, lnd } from './nodeTemplates';
import { bitcoind, clightning, eclair, lnd } from './nodeTemplates';
export interface ComposeService {
image: string;
@ -102,6 +108,27 @@ class ComposeFile {
this.content.services[name] = clightning(name, container, image, rest, p2p, command);
}
addEclair(node: EclairNode, backend: CommonNode) {
const { name, version, ports } = node;
const { rest, p2p } = ports;
const container = getContainerName(node);
// define the variable substitutions
const variables = {
name: node.name,
backendName: getContainerName(backend),
rpcUser: bitcoinCredentials.user,
rpcPass: bitcoinCredentials.pass,
};
// use the node's custom image or the default for the implementation
const image = node.docker.image || `${dockerConfigs.eclair.imageName}:${version}`;
// use the node's custom command or the default for the implementation
const nodeCommand = node.docker.command || dockerConfigs.eclair.command;
// replace the variables in the command
const command = this.mergeCommand(nodeCommand, variables);
// add the docker service
this.content.services[name] = eclair(name, container, image, rest, p2p, command);
}
private mergeCommand(command: string, variables: Record<string, string>) {
let merged = command;
Object.keys(variables).forEach(key => {

6
src/lib/docker/dockerService.spec.ts

@ -184,6 +184,7 @@ describe('DockerService', () => {
name: 'my network',
lndNodes: 1,
clightningNodes: 0,
eclairNodes: 0,
bitcoindNodes: 1,
repoState: defaultRepoState,
managedImages: testManagedImages,
@ -205,6 +206,7 @@ describe('DockerService', () => {
name: 'my network',
lndNodes: 0,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 1,
repoState: defaultRepoState,
managedImages: testManagedImages,
@ -221,7 +223,7 @@ describe('DockerService', () => {
});
it('should not save unknown lightning implementation', () => {
network.nodes.lightning[0].implementation = 'eclair';
network.nodes.lightning[0].implementation = 'unknown' as any;
dockerService.saveComposeFile(network);
expect(filesMock.write).toBeCalledWith(
expect.stringContaining('docker-compose.yml'),
@ -247,6 +249,7 @@ describe('DockerService', () => {
name: 'my network',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 1,
repoState: defaultRepoState,
managedImages: testManagedImages,
@ -293,6 +296,7 @@ describe('DockerService', () => {
name: 'my network',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 1,
repoState: defaultRepoState,
managedImages: testManagedImages,

6
src/lib/docker/dockerService.ts

@ -10,6 +10,7 @@ import {
BitcoinNode,
CLightningNode,
CommonNode,
EclairNode,
LightningNode,
LndNode,
} from 'shared/types';
@ -95,6 +96,11 @@ class DockerService implements DockerLibrary {
const backend = bitcoin.find(n => n.name === cln.backendName) || bitcoin[0];
file.addClightning(cln, backend);
}
if (node.implementation === 'eclair') {
const eclair = node as EclairNode;
const backend = bitcoin.find(n => n.name === eclair.backendName) || bitcoin[0];
file.addEclair(eclair, backend);
}
});
const yml = yaml.dump(file.content);

30
src/lib/docker/nodeTemplates.ts

@ -100,3 +100,33 @@ export const clightning = (
`${p2pPort}:9735`, // p2p
],
});
export const eclair = (
name: string,
container: string,
image: string,
restPort: number,
p2pPort: number,
command: string,
): ComposeService => ({
image,
container_name: container,
environment: {
USERID: '${USERID:-1000}',
GROUPID: '${GROUPID:-1000}',
},
hostname: name,
command: trimInside(command),
restart: 'always',
volumes: [
`./volumes/${dockerConfigs.eclair.volumeDirName}/${name}:/home/eclair/.eclair`,
],
expose: [
'8080', // REST
'9735', // p2p
],
ports: [
`${restPort}:8080`, // REST
`${p2pPort}:9735`, // p2p
],
});

50
src/lib/lightning/eclair/eclairService.ts

@ -0,0 +1,50 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { LightningNode } from 'shared/types';
import { LightningService } from 'types';
import * as PLN from '../types';
class EclairService implements LightningService {
getInfo(node: LightningNode): Promise<PLN.LightningNodeInfo> {
throw new Error(`getInfo is not implemented for ${node.implementation} nodes`);
}
getBalances(node: LightningNode): Promise<PLN.LightningNodeBalances> {
throw new Error(`getBalances is not implemented for ${node.implementation} nodes`);
}
getNewAddress(node: LightningNode): Promise<PLN.LightningNodeAddress> {
throw new Error(`getNewAddress is not implemented for ${node.implementation} nodes`);
}
getChannels(node: LightningNode): Promise<PLN.LightningNodeChannel[]> {
throw new Error(`getChannels is not implemented for ${node.implementation} nodes`);
}
getPeers(node: LightningNode): Promise<PLN.LightningNodePeer[]> {
throw new Error(`getPeers is not implemented for ${node.implementation} nodes`);
}
connectPeers(node: LightningNode, rpcUrls: string[]): Promise<void> {
throw new Error(`connectPeers is not implemented for ${node.implementation} nodes`);
}
openChannel(
from: LightningNode,
toRpcUrl: string,
amount: string,
): Promise<PLN.LightningNodeChannelPoint> {
throw new Error(`openChannel is not implemented for ${from.implementation} nodes`);
}
closeChannel(node: LightningNode, channelPoint: string): Promise<any> {
throw new Error(`closeChannel is not implemented for ${node.implementation} nodes`);
}
createInvoice(node: LightningNode, amount: number, memo?: string): Promise<string> {
throw new Error(`createInvoice is not implemented for ${node.implementation} nodes`);
}
payInvoice(
node: LightningNode,
invoice: string,
amount?: number,
): Promise<PLN.LightningNodePayReceipt> {
throw new Error(`payInvoice is not implemented for ${node.implementation} nodes`);
}
waitUntilOnline(node: LightningNode): Promise<void> {
return Promise.resolve();
}
}
export default new EclairService();

1
src/lib/lightning/eclair/index.ts

@ -0,0 +1 @@
export { default as eclairService } from './eclairService';

4
src/lib/lightning/lightningFactory.ts

@ -1,8 +1,8 @@
import { LightningNode } from 'shared/types';
import { clightningService } from 'lib/lightning/clightning';
import { eclairService } from 'lib/lightning/eclair';
import { lndService } from 'lib/lightning/lnd';
import { LightningService } from 'types';
import notImplementedService from './notImplementedService';
/**
* A factory class used to obtain a Lightning service based on
@ -18,7 +18,7 @@ class LightningFactory {
this._services = {
LND: lndService,
'c-lightning': clightningService,
eclair: notImplementedService,
eclair: eclairService,
};
}

BIN
src/resources/eclair.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

7
src/shared/types.ts

@ -51,6 +51,13 @@ export interface CLightningNode extends LightningNode {
};
}
export interface EclairNode extends LightningNode {
ports: {
rest: number;
p2p: number;
};
}
export interface BitcoinNode extends CommonNode {
type: 'bitcoin';
implementation: 'bitcoind' | 'btcd';

3
src/store/models/designer.spec.ts

@ -65,6 +65,7 @@ describe('Designer model', () => {
name: 'test',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 2,
customNodes: {},
});
@ -100,6 +101,7 @@ describe('Designer model', () => {
name: 'test 2',
lndNodes: 2,
clightningNodes: 0,
eclairNodes: 0,
bitcoindNodes: 1,
customNodes: {},
});
@ -351,6 +353,7 @@ describe('Designer model', () => {
name: 'test 3',
lndNodes: 0,
clightningNodes: 0,
eclairNodes: 0,
bitcoindNodes: 0,
customNodes: {},
});

1
src/store/models/network.spec.ts

@ -54,6 +54,7 @@ describe('Network model', () => {
name: 'test',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 1,
customNodes: {},
};

4
src/store/models/network.ts

@ -35,6 +35,7 @@ interface AddNetworkArgs {
name: string;
lndNodes: number;
clightningNodes: number;
eclairNodes: number;
bitcoindNodes: number;
customNodes: Record<string, number>;
}
@ -190,7 +191,7 @@ const networkModel: NetworkModel = {
addNetwork: thunk(
async (
actions,
{ name, lndNodes, clightningNodes, bitcoindNodes, customNodes },
{ name, lndNodes, clightningNodes, eclairNodes, bitcoindNodes, customNodes },
{ dispatch, getState, injections, getStoreState, getStoreActions },
) => {
const { dockerRepoState, computedManagedImages, settings } = getStoreState().app;
@ -209,6 +210,7 @@ const networkModel: NetworkModel = {
name,
lndNodes,
clightningNodes,
eclairNodes,
bitcoindNodes,
repoState: dockerRepoState,
managedImages: computedManagedImages,

9
src/utils/chart.ts

@ -3,9 +3,7 @@ import { BitcoinNode, LightningNode } from 'shared/types';
import { LightningNodeChannel } from 'lib/lightning/types';
import { LightningNodeMapping } from 'store/models/lightning';
import { Network } from 'types';
import btclogo from 'resources/bitcoin.svg';
import clightningLogo from 'resources/clightning.png';
import lndLogo from 'resources/lnd.png';
import { dockerConfigs } from './constants';
export interface LinkProperties {
type: 'backend' | 'pending-channel' | 'open-channel' | 'btcpeer';
@ -36,7 +34,6 @@ export const snap = (position: IPosition, config?: IConfig) =>
: position;
export const createLightningChartNode = (ln: LightningNode) => {
const logo = ln.implementation === 'c-lightning' ? clightningLogo : lndLogo;
const node: INode = {
id: ln.name,
type: 'lightning',
@ -49,7 +46,7 @@ export const createLightningChartNode = (ln: LightningNode) => {
size: { width: 200, height: 36 },
properties: {
status: ln.status,
icon: logo,
icon: dockerConfigs[ln.implementation].logo,
},
};
@ -78,7 +75,7 @@ export const createBitcoinChartNode = (btc: BitcoinNode) => {
size: { width: 200, height: 36 },
properties: {
status: btc.status,
icon: btclogo,
icon: dockerConfigs[btc.implementation].logo,
},
};

30
src/utils/constants.ts

@ -2,6 +2,7 @@ import { NodeImplementation } from 'shared/types';
import { DockerConfig, DockerRepoState } from 'types';
import bitcoindLogo from 'resources/bitcoin.svg';
import clightningLogo from 'resources/clightning.png';
import eclairLogo from 'resources/eclair.png';
import lndLogo from 'resources/lnd.png';
import packageJson from '../../package.json';
@ -126,12 +127,29 @@ export const dockerConfigs: Record<NodeImplementation, DockerConfig> = {
},
eclair: {
name: 'Eclair',
imageName: '',
logo: '',
imageName: 'polarlightning/eclair',
logo: eclairLogo,
platforms: ['mac', 'linux', 'windows'],
volumeDirName: 'eclair',
command: '',
variables: [],
command: [
'polar-eclair',
'--node-alias={{name}}',
'--server.port=9735',
'--api.enabled=true',
'--api.binging-ip=0.0.0.0',
'--api.port=8080',
'--api.password=eclairpw',
'--chain=regtest',
'--bitcoind.host={{backendName}}',
'--bitcoind.rpcport=18443',
'--bitcoind.rpcuser={{rpcUser}}',
'--bitcoind.rpcpassword={{rpcPass}}',
'--bitcoind.zmqblock=tcp://{{backendName}}:28334',
'--bitcoind.zmqtx=tcp://{{backendName}}:28335',
'--datadir=/home/eclair/.eclair',
].join('\n '),
// if vars are modified, also update composeFile.ts & the i18n strings for cmps.nodes.CommandVariables
variables: ['name', 'backendName', 'rpcUser', 'rpcPass'],
},
bitcoind: {
name: 'Bitcoin Core',
@ -203,8 +221,8 @@ export const defaultRepoState: DockerRepoState = {
versions: ['0.8.1', '0.8.0'],
},
eclair: {
latest: '',
versions: [],
latest: '0.3.3',
versions: ['0.3.3'],
},
bitcoind: {
latest: '0.19.1',

47
src/utils/network.ts

@ -8,6 +8,7 @@ import {
BitcoinNode,
CLightningNode,
CommonNode,
EclairNode,
LightningNode,
LndNode,
NodeImplementation,
@ -182,6 +183,41 @@ export const createCLightningNetworkNode = (
};
};
export const createEclairNetworkNode = (
network: Network,
version: string,
compatibility: DockerRepoImage['compatibility'],
docker: CommonNode['docker'],
status = Status.Stopped,
): EclairNode => {
const { bitcoin, lightning } = network.nodes;
const implementation: EclairNode['implementation'] = 'eclair';
const backends = filterCompatibleBackends(
implementation,
version,
compatibility,
bitcoin,
);
const id = lightning.length ? Math.max(...lightning.map(n => n.id)) + 1 : 0;
const name = getName(id);
return {
id,
networkId: network.id,
name: name,
type: 'lightning',
implementation,
version,
status,
// alternate between backend nodes
backendName: backends[id % backends.length].name,
ports: {
rest: BasePorts.LND.rest + id,
p2p: BasePorts.LND.p2p + id,
},
docker,
};
};
export const createBitcoindNetworkNode = (
network: Network,
version: string,
@ -224,6 +260,7 @@ export const createNetwork = (config: {
name: string;
lndNodes: number;
clightningNodes: number;
eclairNodes: number;
bitcoindNodes: number;
repoState: DockerRepoState;
managedImages: ManagedImage[];
@ -235,6 +272,7 @@ export const createNetwork = (config: {
name,
lndNodes,
clightningNodes,
eclairNodes,
bitcoindNodes,
repoState,
managedImages,
@ -293,7 +331,7 @@ export const createNetwork = (config: {
});
// add lightning nodes in an alternating pattern
range(Math.max(lndNodes, clightningNodes)).forEach(i => {
range(Math.max(lndNodes, clightningNodes, eclairNodes)).forEach(i => {
if (i < lndNodes) {
const { latest, compatibility } = repoState.images.LND;
const cmd = getImageCommand(managedImages, 'LND', latest);
@ -314,6 +352,13 @@ export const createNetwork = (config: {
),
);
}
if (i < eclairNodes) {
const { latest, compatibility } = repoState.images.eclair;
const cmd = getImageCommand(managedImages, 'eclair', latest);
lightning.push(
createEclairNetworkNode(network, latest, compatibility, dockerWrap(cmd), status),
);
}
});
return network;

1
src/utils/tests/helpers.ts

@ -42,6 +42,7 @@ export const getNetwork = (networkId = 1, name?: string, status?: Status): Netwo
name: name || 'my-test',
lndNodes: 2,
clightningNodes: 1,
eclairNodes: 0,
bitcoindNodes: 1,
status,
repoState: defaultRepoState,

Loading…
Cancel
Save