Browse Source

Refactor and modularize export/import code

feat: add import/export network functionality

Modularize import/export network and zip/unzip functionality. We also
add some tests.
master
Torkel Rogstad 5 years ago
committed by jamaljsr
parent
commit
189c9c0a32
  1. 3
      .eslintignore
  2. 4
      package.json
  3. 8
      src/__mocks__/electron.js
  4. 11
      src/components/common/NavMenu.tsx
  5. 7
      src/components/home/GetStarted.tsx
  6. 82
      src/components/network/ImportNetwork.tsx
  7. 58
      src/components/network/NetworkView.spec.tsx
  8. 58
      src/components/network/NetworkView.tsx
  9. 8
      src/i18n/locales/en-US.json
  10. 30
      src/store/models/network.spec.ts
  11. 47
      src/store/models/network.ts
  12. 149
      src/utils/files.ts
  13. 30
      src/utils/network.spec.ts
  14. 93
      src/utils/network.ts
  15. 1
      src/utils/tests/resources/bar.txt
  16. 1
      src/utils/tests/resources/baz/qux.ts
  17. 3
      src/utils/tests/resources/foo.json
  18. BIN
      src/utils/tests/resources/test.zip
  19. BIN
      src/utils/tests/resources/zipped-network.zip
  20. 106
      src/utils/zip.spec.ts
  21. 157
      src/utils/zip.ts
  22. 3
      tsconfig.json

3
.eslintignore

@ -7,3 +7,6 @@
# compiled by tsc from /src/electron/
/public
# rest resources
/src/utils/resources

4
package.json

@ -41,7 +41,6 @@
"dependencies": {
"@radar/lnrpc": "0.9.1-beta",
"@types/detect-port": "1.1.0",
"archiver": "^3.1.1",
"detect-port": "1.3.0",
"docker-compose": "0.23.3",
"dockerode": "3.1.0",
@ -50,7 +49,6 @@
"electron-log": "4.0.7",
"electron-window-state": "5.0.3",
"shell-env": "3.0.0",
"unzipper": "^0.10.8",
"xterm": "4.4.0",
"xterm-addon-fit": "0.3.0"
},
@ -85,6 +83,7 @@
"@typescript-eslint/eslint-plugin": "2.23.0",
"@typescript-eslint/parser": "2.23.0",
"antd": "4.0.2",
"archiver": "^3.1.1",
"babel-plugin-emotion": "10.0.29",
"babel-plugin-import": "1.13.0",
"bitcoin-core": "3.0.0",
@ -145,6 +144,7 @@
"ts-node": "8.6.2",
"typescript": "3.8.3",
"utf-8-validate": "5.0.2",
"unzipper": "^0.10.8",
"wait-on": "4.0.1",
"webpack": "4.41.5",
"ws": "7.2.2"

8
src/__mocks__/electron.js

@ -1,9 +1,17 @@
import { tmpdir } from 'os';
import { join } from 'path';
module.exports = {
remote: {
app: {
getPath: p => `ELECTRON_PATH[${p}]`,
getLocale: () => 'en-US',
},
dialog: {
showSaveDialog: async () => ({
filePath: join(tmpdir(), 'polar-saved-network.zip'),
}),
},
process: {
env: {},
},

11
src/components/common/NavMenu.tsx

@ -1,5 +1,10 @@
import React from 'react';
import { HddOutlined, MenuOutlined, PlusOutlined } from '@ant-design/icons';
import {
HddOutlined,
ImportOutlined,
MenuOutlined,
PlusOutlined,
} from '@ant-design/icons';
import styled from '@emotion/styled';
import { Dropdown, Menu } from 'antd';
import { usePrefixedTranslation } from 'hooks';
@ -14,6 +19,10 @@ const Styled = {
font-size: 1.2rem;
color: #fff;
`,
ImportIcon: styled(ImportOutlined)`
font-size: 1.2rem;
color: #fff;
`,
};
const NavMenu: React.FC = () => {

7
src/components/home/GetStarted.tsx

@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
import styled from '@emotion/styled';
import { Button } from 'antd';
import { usePrefixedTranslation } from 'hooks';
import { NETWORK_NEW, NETWORK_IMPORT } from 'components/routing';
import { NETWORK_NEW } from 'components/routing';
import logobw from 'resources/logo_bw.png';
const Styled = {
@ -40,11 +40,6 @@ const GetStarted: React.FC = () => {
{l('createBtn')}
</Button>
</Link>
<Styled.ImportLink to={NETWORK_IMPORT}>
<Button type="default" size="middle">
{l('importBtn')}
</Button>
</Styled.ImportLink>
</Styled.GetStarted>
);
};

82
src/components/network/ImportNetwork.tsx

@ -1,19 +1,15 @@
import React, { useState } from 'react';
import styled from '@emotion/styled';
import { Button, Upload, Card, PageHeader, Spin, Modal } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import log from 'electron-log';
import { ThemeColors } from 'theme/colors';
import { UploadOutlined } from '@ant-design/icons';
import styled from '@emotion/styled';
import { Button, Card, PageHeader, Upload } from 'antd';
import { RcFile } from 'antd/lib/upload';
import { usePrefixedTranslation } from 'hooks';
import { useTheme } from 'hooks/useTheme';
import { HOME } from 'components/routing';
import { useStoreActions, useStoreState } from 'store';
import { usePrefixedTranslation } from 'hooks';
import { RcFile } from 'antd/lib/upload';
import { getNetworkFromZip } from 'utils/network';
import fsExtra from 'fs-extra';
import { promises as fs } from 'fs';
import { join } from 'path';
import { dataPath } from 'utils/config';
import { ThemeColors } from 'theme/colors';
import { importNetworkFromZip } from 'utils/network';
import { HOME } from 'components/routing';
const Styled = {
PageHeader: styled(PageHeader)<{ colors: ThemeColors['pageHeader'] }>`
@ -32,15 +28,11 @@ const Styled = {
width: 200px;
}
`,
Spin: styled(Spin)<{ visible: boolean }>`
position: absolute;
display: ${visible => (visible ? 'inherit' : 'none')};
`,
};
const ImportNetwork: React.SFC = () => {
const [file, setFile] = useState<RcFile | undefined>();
const { navigateTo } = useStoreActions(s => s.app);
const { navigateTo, notify } = useStoreActions(s => s.app);
const { l } = usePrefixedTranslation('cmps.network.ImportNetwork');
const networkActions = useStoreActions(s => s.network);
const designerActions = useStoreActions(s => s.designer);
@ -80,51 +72,33 @@ const ImportNetwork: React.SFC = () => {
<p className="ant-upload-text">{l('fileDraggerArea')}</p>
</Upload.Dragger>
<Styled.ButtonContainer>
<Button onClick={() => setFile(undefined)} disabled={file === undefined}>
<Button onClick={() => setFile(undefined)} disabled={!file}>
{l('removeButton')}
</Button>
<Button
onClick={async () => {
if (!file) {
throw Error('File was undefined in import submit function!');
}
const maxId = networks
.map(n => n.id)
.reduce((max, curr) => Math.max(max, curr), 0);
const newId = maxId + 1;
try {
const [newNetwork, chart] = await importNetworkFromZip(
// if file is undefined, export button is disabled
// so we can be sure that this assertions is OK
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
file!.path,
networks,
);
const [newNetwork, chart, unzippedFilesDirectory] = await getNetworkFromZip(
file.path,
newId,
);
networkActions.add(newNetwork);
designerActions.setChart({ chart, id: newNetwork.id });
await networkActions.save();
const newNetworkDirectory = join(dataPath, 'networks', newId.toString());
await fs.mkdir(newNetworkDirectory, { recursive: true });
const thingsToCopy = ['docker-compose.yml', 'volumes'];
await Promise.all(
thingsToCopy.map(path =>
fsExtra.copy(
join(unzippedFilesDirectory, path),
join(newNetworkDirectory, path),
),
),
);
networkActions.add(newNetwork);
designerActions.setChart({ chart, id: newId });
await networkActions.save();
log.info('imported', newNetwork);
Modal.success({
title: 'Imported network',
content: `Imported network '${newNetwork.name}' successfully.`,
onOk: () => navigateTo(HOME),
});
log.info('imported', newNetwork);
notify({ message: l('importSuccess') });
navigateTo(HOME);
} catch (error) {
notify({ message: '', error });
}
}}
type="primary"
disabled={file === undefined}
disabled={!file}
>
{l('importButton')}
</Button>

58
src/components/network/NetworkView.spec.tsx

@ -1,6 +1,6 @@
import React from 'react';
import fsExtra from 'fs-extra';
import { fireEvent, wait, waitForElement } from '@testing-library/dom';
import { fireEvent, wait } from '@testing-library/dom';
import { act } from '@testing-library/react';
import { createMemoryHistory } from 'history';
import { Status } from 'shared/types';
@ -11,7 +11,6 @@ import {
injections,
lightningServiceMock,
renderWithProviders,
suppressConsoleErrors,
testCustomImages,
} from 'utils/tests';
import NetworkView from './NetworkView';
@ -260,49 +259,24 @@ describe('NetworkView Component', () => {
});
});
describe('delete network', () => {
it('should show the confirm modal', async () => {
const { getByLabelText, getByText, findByText } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more'));
fireEvent.click(await findByText('Delete'));
expect(
getByText('Are you sure you want to delete this network?'),
).toBeInTheDocument();
expect(getByText('Yes')).toBeInTheDocument();
expect(getByText('Cancel')).toBeInTheDocument();
});
describe('export network', () => {
beforeEach(jest.useFakeTimers);
afterEach(jest.useRealTimers);
it('should fail to export a running network', async () => {
const { primaryBtn, getByText, getByLabelText } = renderComponent('1');
expect(primaryBtn).toHaveTextContent('Start');
fireEvent.click(primaryBtn);
// should change to stopped after some time
await wait(() => {
expect(primaryBtn).toHaveTextContent('Stop');
});
it('should delete the network', async () => {
const { getByLabelText, getByText, findByText, network } = renderComponent(
'1',
Status.Started,
);
fireEvent.mouseOver(getByLabelText('more'));
fireEvent.click(await findByText('Delete'));
fireEvent.click(await findByText('Yes'));
// wait for the error notification to be displayed
await waitForElement(() => getByLabelText('check-circle'));
expect(
getByText("The network 'test network' and its data has been deleted!"),
).toBeInTheDocument();
expect(fsMock.remove).toBeCalledWith(expect.stringContaining(network.path));
});
await wait(() => jest.runOnlyPendingTimers());
it('should display an error if the delete fails', async () => {
// antd Modal.confirm logs a console error when onOk fails
// this suppresses those errors from being displayed in test runs
await suppressConsoleErrors(async () => {
fsMock.remove = jest.fn().mockRejectedValue(new Error('cannot delete'));
const { getByLabelText, getByText, findByText, store } = renderComponent('1');
fireEvent.mouseOver(getByLabelText('more'));
fireEvent.click(await findByText('Delete'));
fireEvent.click(await findByText('Yes'));
// wait for the error notification to be displayed
await waitForElement(() => getByLabelText('close-circle'));
expect(getByText('cannot delete')).toBeInTheDocument();
expect(store.getState().network.networks).toHaveLength(1);
expect(store.getState().designer.allCharts[1]).toBeDefined();
});
fireEvent.click(getByText('Export'));
await wait(() => jest.runOnlyPendingTimers());
expect(getByText('Cannot export a running network')).toBeInTheDocument();
});
});
});

58
src/components/network/NetworkView.tsx

@ -1,10 +1,7 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
import { useAsyncCallback } from 'react-async-hook';
import path from 'path';
import { promises as fs } from 'fs';
import { Redirect, RouteComponentProps } from 'react-router';
import { info } from 'electron-log';
import * as electron from 'electron';
import styled from '@emotion/styled';
import { Alert, Button, Empty, Input, Modal, PageHeader } from 'antd';
import { usePrefixedTranslation } from 'hooks';
@ -17,9 +14,6 @@ import { StatusTag } from 'components/common';
import NetworkDesigner from 'components/designer/NetworkDesigner';
import { HOME } from 'components/routing';
import NetworkActions from './NetworkActions';
import { Network } from 'types';
import * as files from 'utils/files';
import { dataPath } from 'utils/config';
const Styled = {
Empty: styled(Empty)`
@ -61,7 +55,6 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
const theme = useTheme();
const { networks } = useStoreState(s => s.network);
const { allCharts } = useStoreState(s => s.designer);
const networkId = parseInt(match.params.id || '');
const network = networks.find(n => n.id === networkId);
@ -73,7 +66,7 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
const { navigateTo, notify } = useStoreActions(s => s.app);
const { clearActiveId } = useStoreActions(s => s.designer);
const { getInfo } = useStoreActions(s => s.bitcoind);
const { toggle, rename, remove } = useStoreActions(s => s.network);
const { toggle, rename, remove, exportNetwork } = useStoreActions(s => s.network);
const toggleAsync = useAsyncCallback(toggle);
const renameAsync = useAsyncCallback(async (payload: { id: number; name: string }) => {
try {
@ -84,39 +77,6 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
}
});
/**
* If user didn't cancel the process, returns the destination of the generated Zip
*/
const exportNetwork = async (network: Network): Promise<string | undefined> => {
info('exporting network', network);
// make sure the volumes directory is created, otherwise the zipping wil throw
// the volumes directory is not present if the network has never been started
await fs.mkdir(path.join(dataPath, 'networks', network.id.toString(), 'volumes'), {
recursive: true,
});
const zipped = await files.zipNetwork(network, allCharts[network.id]);
const options: electron.SaveDialogOptions = {
title: 'title',
defaultPath: path.basename(zipped),
properties: ['promptToCreate', 'createDirectory'],
} as any; // types are broken, but 'properties' allow us to customize how the dialog performs
const { filePath: zipDestination } = await electron.remote.dialog.showSaveDialog(
options,
);
// user aborted dialog
if (!zipDestination) {
return;
}
await fs.copyFile(zipped, zipDestination);
info('exported network to', zipDestination);
return zipDestination;
};
const showRemoveModal = (networkId: number, name: string) => {
Modal.confirm({
title: l('deleteTitle'),
@ -210,14 +170,23 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
}}
onDeleteClick={() => showRemoveModal(network.id, network.name)}
onExportClick={async () => {
const readyToExport = [Status.Error, Status.Stopped].includes(
network.status,
);
if (!readyToExport) {
notify({
message: l('notReadyToExport'),
error: Error(l('notReadyToExportDescription')),
});
return;
}
const destination = await exportNetwork(network);
if (!destination) {
return;
}
Modal.success({
title: 'Exported network',
content: `Exported network '${network.name}' successfully. Saved the zip file to ${destination}.`,
notify({
message: l('exportSuccess', { name: network.name, destination }),
});
}}
/>
@ -225,7 +194,6 @@ const NetworkView: React.FC<RouteComponentProps<MatchParams>> = ({ match }) => {
/>
);
}
const missingImages = getMissingImages(network, dockerImages);
const showNotice =
[Status.Stopped, Status.Starting].includes(network.status) &&

8
src/i18n/locales/en-US.json

@ -231,7 +231,6 @@
"cmps.home.DetectDockerModal.checkAgain": "Check Again",
"cmps.home.GetStarted.title": "Let's get started!",
"cmps.home.GetStarted.createBtn": "Create a Lightning Network",
"cmps.home.GetStarted.importBtn": "Import a Lightning Network",
"cmps.home.Home.initError": "Unable to initialize the application state",
"cmps.home.NetworkCard.lightningNodes": "Lightning Nodes",
"cmps.home.NetworkCard.bitcoinNodes": "Bitcoin Nodes",
@ -258,10 +257,14 @@
"cmps.network.NetworkView.deleteSuccess": "The network '{{name}}' and its data has been deleted!",
"cmps.network.NetworkView.deleteError": "Unable to delete the network",
"cmps.network.NetworkView.getInfoError": "Failed to fetch the bitcoin block height",
"cmps.network.NetworkView.exportSuccess": "Exported '{{name}}'. Saved the zip file to {{destination}}",
"cmps.network.NetworkView.notReadyToExport": "Cannot export a running network",
"cmps.network.NetworkView.notReadyToExportDescription": "Make sure the network is completely stopped before exporting it.",
"cmps.network.ImportNetwork.title": "Import a pre-defined Lightning Network",
"cmps.network.ImportNetwork.fileDraggerArea": "Click or drag ZIP file to this area to import",
"cmps.network.ImportNetwork.removeButton": "Remove",
"cmps.network.ImportNetwork.importButton": "Import",
"cmps.network.ImportNetwork.importSuccess": "Imported network '{{name}}' successfully",
"cmps.network.NewNetwork.title": "Create a new Lightning Network",
"cmps.network.NewNetwork.nameLabel": "Network Name",
"cmps.network.NewNetwork.namePhldr": "My Lightning Simnet",
@ -345,5 +348,6 @@
"store.models.network.renameErr": "The network name '{{name}}' is not valid",
"store.models.network.removeLastErr": "Cannot remove the only bitcoin node",
"store.models.network.removeCompatErr": "There are no other compatible backends for {{lnName}} to connect to. You must remove the {{lnName}} node first",
"utils.network.backendCompatError": "This network does not contain a Bitcoin Core v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}"
"utils.network.backendCompatError": "This network does not contain a Bitcoin Core v{{requiredVersion}} (or lower) node which is required for {{implementation}} v{{version}}",
"utils.network.importClightningWindows": "Importing networks with c-lightning nodes is not supported on Windows"
}

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

@ -1,4 +1,5 @@
import * as log from 'electron-log';
import { pathExists } from 'fs-extra';
import { wait } from '@testing-library/react';
import detectPort from 'detect-port';
import { createStore } from 'easy-peasy';
@ -816,4 +817,33 @@ describe('Network model', () => {
await expect(updateAdvancedOptions({ node, command: '' })).rejects.toThrow();
});
});
describe('Export', () => {
let exportedZip: string | undefined;
afterEach(async () => {
if (!exportedZip) {
return;
}
await files.rm(exportedZip);
});
it('should export a network', async () => {
jest.setTimeout(1000 * 60 * 60);
const { network: networkActions } = store.getActions();
const network = getNetwork();
await networkActions.addNetwork({
name: 'test',
lndNodes: 1,
clightningNodes: 2,
bitcoindNodes: 1,
});
exportedZip = await networkActions.exportNetwork(network);
expect(exportedZip).toBeDefined();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const exists = await pathExists(exportedZip!);
expect(exists).toBeTruthy();
});
});
});

47
src/store/models/network.ts

@ -1,5 +1,7 @@
import { remote, SaveDialogOptions } from 'electron';
import { info } from 'electron-log';
import { join } from 'path';
import { copyFile, ensureDir } from 'fs-extra';
import { basename, join } from 'path';
import { push } from 'connected-react-router';
import { Action, action, Computed, computed, Thunk, thunk } from 'easy-peasy';
import {
@ -11,6 +13,7 @@ import {
} from 'shared/types';
import { CustomImage, Network, StoreInjections } from 'types';
import { initChartFromNetwork } from 'utils/chart';
import { dataPath } from 'utils/config';
import { APP_VERSION } from 'utils/constants';
import { rm } from 'utils/files';
import {
@ -21,6 +24,7 @@ import {
filterCompatibleBackends,
getOpenPorts,
OpenPorts,
zipNetwork,
} from 'utils/network';
import { prefixTranslation } from 'utils/translate';
import { NETWORK_VIEW } from 'components/routing';
@ -112,6 +116,17 @@ export interface NetworkModel {
Promise<void>
>;
remove: Thunk<NetworkModel, number, StoreInjections, RootModel, Promise<void>>;
/**
* If user didn't cancel the process, returns the destination of the generated Zip
*/
exportNetwork: Thunk<
NetworkModel,
Network,
StoreInjections,
RootModel,
Promise<string | undefined>
>;
}
const networkModel: NetworkModel = {
@ -592,6 +607,36 @@ const networkModel: NetworkModel = {
await actions.save();
await getStoreActions().app.clearAppCache();
}),
exportNetwork: thunk(async (_, network, { getStoreState }) => {
info('exporting network', network);
const {
designer: { allCharts },
} = getStoreState();
// make sure the volumes directory is created, otherwise the zipping wil throw
// the volumes directory is not present if the network has never been started
await ensureDir(join(dataPath, 'networks', network.id.toString(), 'volumes'));
const zipped = await zipNetwork(network, allCharts[network.id]);
const options: SaveDialogOptions = {
title: 'title',
defaultPath: basename(zipped),
properties: ['promptToCreate', 'createDirectory'],
} as any; // types are broken, but 'properties' allow us to customize how the dialog performs
const { filePath: zipDestination } = await remote.dialog.showSaveDialog(options);
// user aborted dialog
if (!zipDestination) {
return;
}
await copyFile(zipped, zipDestination);
info('exported network to', zipDestination);
return zipDestination;
}),
};
export default networkModel;

149
src/utils/files.ts

@ -1,15 +1,7 @@
import { outputFile, pathExists, readFile, remove } from 'fs-extra';
import unzipper from 'unzipper';
import fs, { promises as fsPromises } from 'fs';
import path from 'path';
import log from 'electron-log';
import os from 'os';
import archiver from 'archiver';
import { isAbsolute, join } from 'path';
import { waitFor } from './async';
import { dataPath } from './config';
import { Network } from 'types';
import { IChart } from '@mrblenny/react-flow-chart';
const abs = (path: string): string => (isAbsolute(path) ? path : join(dataPath, path));
@ -41,147 +33,6 @@ export const exists = async (filePath: string): Promise<boolean> =>
*/
export const rm = async (path: string): Promise<void> => await remove(abs(path));
/**
* Adds a raw string into the ZIP archive
*
* @param archive ZIP archive to add the file to
* @param content content to add into archive
* @param nameInArchive name of file in archive
*/
const addStringToZip = async (
archive: archiver.Archiver,
content: string,
nameInArchive: string,
) => {
return archive.append(content, { name: nameInArchive });
};
/**
* Adds a file to the given ZIP archive. We read the file into
* memory and then append it to the ZIP archive. There appears
* to be issues with using Archiver.js with Electron/Webpack,
* so that's why we have to do it in a somewhat inefficient way.
*
* Related issues:
* * https://github.com/archiverjs/node-archiver/issues/349
* * https://github.com/archiverjs/node-archiver/issues/403
* * https://github.com/archiverjs/node-archiver/issues/174
*
* @param archive ZIP archive to add the file to
* @param filePath file to add, absolute path
* @param nameInArchive name of file in archive
*/
const addFileToZip = async (
archive: archiver.Archiver,
filePath: string,
nameInArchive: string,
) => {
return archive.append(await fsPromises.readFile(filePath), { name: nameInArchive });
};
// Generate a sequence of all regular files inside the given directory
async function* getFiles(dir: string): AsyncGenerator<string> {
const dirents = await fsPromises.readdir(dir, { withFileTypes: true });
for (const dirent of dirents) {
const res = path.resolve(dir, dirent.name);
if (dirent.isDirectory()) {
yield* getFiles(res);
} else if (dirent.isFile()) {
yield res;
}
}
}
/**
* Add the given path to the archive. If it's a file we add it directly, it it is a directory
* we recurse over all the files within that directory
*
* @param archive ZIP archive to add the file to
* @param filePath file to add, absolute path
*/
const addFileOrDirectoryToZip = async (archive: archiver.Archiver, filePath: string) => {
const pathExists = await exists(filePath);
if (!pathExists) {
throw Error(`cannot zip nonexistant path: ${filePath}`);
}
const isDir = await fsPromises.lstat(filePath).then(res => res.isDirectory());
if (isDir) {
log.info('Adding directory to zip file:', filePath);
for await (const file of getFiles(filePath)) {
// a typical file might look like this:
// /home/user/.polar/networks/1/volumes/bitcoind/backend1/regtest/mempool.dat
// after applying this transformation, we end up with:
// volumes/bitcoind/backend1/regtest/mempool.dat
const nameInArchive = path.join(
path.basename(filePath),
file.slice(filePath.length),
);
await addFileToZip(archive, file, nameInArchive);
}
} else {
return addFileToZip(archive, filePath, path.basename(filePath));
}
};
/**
* Archive the given network into a folder with the following content:
*
* ```
* docker-compose.yml // compose file for network
* volumes // directory with all data files needed by nodes
* network.json // serialized network object
* chart.json // serialized chart object
* ```
*
* @return Path of created `.zip` file
*/
export const zipNetwork = async (network: Network, chart: IChart): Promise<string> => {
return new Promise(async (resolve, reject) => {
const zipPath = join(os.tmpdir(), `polar-${network.name}.zip`);
const output = fs.createWriteStream(zipPath);
const archive = archiver('zip');
// finished
archive.on('finish', () => resolve(zipPath));
archive.on('error', err => {
log.error(`got error when zipping ${zipPath}:`, err);
reject(err);
});
archive.on('warning', warning => {
log.warn(`got warning when zipping ${zipPath}:`, warning);
reject(warning);
});
// pipe all zipped data to the output
archive.pipe(output);
const paths = ['docker-compose.yml', 'volumes'];
await Promise.all([
addStringToZip(archive, JSON.stringify(network), 'network.json'),
addStringToZip(archive, JSON.stringify(chart), 'chart.json'),
...paths.map(p => addFileOrDirectoryToZip(archive, path.join(network.path, p))),
]);
// we've added all files, tell this to the archive so it can emit the 'close' event
// once all streams have finished
archive.finalize();
});
};
/**
* Unzips `zip` into `destination`
*/
export const unzip = (zip: string, destination: string): Promise<void> => {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(zip).pipe(unzipper.Extract({ path: destination }));
stream.on('close', resolve);
stream.on('error', reject);
});
};
/**
* Returns a promise that will ressolve when the file exists or the timeout expires
* @param filePath the path to the file. either absolute or relative to the app's data dir

30
src/utils/network.spec.ts

@ -1,12 +1,22 @@
import { join } from 'path';
import detectPort from 'detect-port';
import { LndNode, NodeImplementation, Status } from 'shared/types';
import { Network } from 'types';
import { defaultRepoState } from './constants';
import { getImageCommand, getOpenPortRange, getOpenPorts, OpenPorts } from './network';
import {
getImageCommand,
getNetworkFromZip,
getOpenPortRange,
getOpenPorts,
OpenPorts,
} from './network';
import { getNetwork, testManagedImages } from './tests';
const mockDetectPort = detectPort as jest.Mock;
// use the real deal
jest.mock('fs-extra', () => jest.requireActual('fs-extra'));
describe('Network Utils', () => {
describe('getImageCommand', () => {
it('should return the commands for managed images', () => {
@ -159,4 +169,22 @@ describe('Network Utils', () => {
expect(ports[lnd2.name].rest).toBe(lnd2.ports.rest + 1);
});
});
describe('getNetworkFromZip', () => {
it('reads zipped-network.zip', async () => {
const newId = 90;
const [network] = await getNetworkFromZip(
join(__dirname, 'tests', 'resources', 'zipped-network.zip'),
newId,
);
expect(network.id).toBe(newId);
expect(network.nodes.bitcoin).toHaveLength(1);
expect(network.nodes.lightning).toHaveLength(3);
});
it('throws on non-existent zip', async () => {
await expect(getNetworkFromZip('nonexistent', 9)).rejects.toThrow();
});
});
});

93
src/utils/network.ts

@ -1,9 +1,10 @@
import { debug } from 'electron-log';
import { promises as fs } from 'fs';
import { copy } from 'fs-extra';
import { basename, join } from 'path';
import { IChart } from '@mrblenny/react-flow-chart';
import detectPort from 'detect-port';
import os from 'os';
import os, { tmpdir } from 'os';
import {
BitcoinNode,
CLightningNode,
@ -20,13 +21,14 @@ import {
ManagedImage,
Network,
} from 'types';
import * as files from 'utils/files';
import { dataPath, networksPath, nodePath } from './config';
import { BasePorts, DOCKER_REPO } from './constants';
import { getName } from './names';
import { range } from './numbers';
import { isVersionCompatible } from './strings';
import { isWindows } from './system';
import { prefixTranslation } from './translate';
import { unzip, zip } from './zip';
const { l } = prefixTranslation('utils.network');
@ -287,10 +289,7 @@ export const getNetworkFromZip = async (
newId: number,
): Promise<[Network, IChart, string]> => {
const destination = join(os.tmpdir(), basename(zip, '.zip'));
await files.unzip(zip, destination).catch(err => {
console.error(`Could not unzip ${zip} into ${destination}:`, err);
throw err;
});
await unzip(zip, destination);
const [network, chart] = await Promise.all([
readNetwork(join(destination, 'network.json'), newId),
@ -300,6 +299,88 @@ export const getNetworkFromZip = async (
return [network, chart, destination];
};
/**
* Given a zip file and the existing networks in the app,
* unpack the zipped files and save them to the correct
* locations.
*
* The caller is responsible for persisting the network
* and chart to the store.
*/
export const importNetworkFromZip = async (
zipPath: string,
existingNetworks: Network[],
): Promise<[Network, IChart]> => {
const maxId = existingNetworks
.map(n => n.id)
.reduce((max, curr) => Math.max(max, curr), 0);
const newId = maxId + 1;
const [newNetwork, chart, unzippedFilesDirectory] = await getNetworkFromZip(
zipPath,
newId,
);
const networkHasCLightning = newNetwork.nodes.lightning.some(
n => n.implementation === 'c-lightning',
);
if (isWindows() && networkHasCLightning) {
throw Error(l('importClightningWindows'));
}
const newNetworkDirectory = join(dataPath, 'networks', newId.toString());
await fs.mkdir(newNetworkDirectory, { recursive: true });
const thingsToCopy = ['docker-compose.yml', 'volumes'];
await Promise.all(
thingsToCopy.map(path =>
copy(join(unzippedFilesDirectory, path), join(newNetworkDirectory, path)),
),
);
return [newNetwork, chart];
};
const sanitizeFileName = (name: string): string => {
const withoutSpaces = name.replace(/\s/g, '-'); // replace all whitespace with hyphens
// remove all character which could lead to either unpleasant or
// invalid file names
return withoutSpaces.replace(/[^0-9a-zA-Z-._]/g, '');
};
/**
* Archive the given network into a folder with the following content:
*
* ```
* docker-compose.yml // compose file for network
* volumes // directory with all data files needed by nodes
* network.json // serialized network object
* chart.json // serialized chart object
* ```
*
* @return Path of created `.zip` file
*/
export const zipNetwork = async (network: Network, chart: IChart): Promise<string> => {
const destination = join(tmpdir(), `polar-${sanitizeFileName(network.name)}.zip`);
await zip({
destination,
objects: [
{
name: 'network.json',
object: network,
},
{
name: 'chart.json',
object: chart,
},
],
paths: [join(network.path, 'docker-compose.yml'), join(network.path, 'volumes')],
});
return destination;
};
export const createNetwork = (config: {
id: number;
name: string;

1
src/utils/tests/resources/bar.txt

@ -0,0 +1 @@
bar

1
src/utils/tests/resources/baz/qux.ts

@ -0,0 +1 @@
console.log('qux');

3
src/utils/tests/resources/foo.json

@ -0,0 +1,3 @@
{
"foo": 2
}

BIN
src/utils/tests/resources/test.zip

Binary file not shown.

BIN
src/utils/tests/resources/zipped-network.zip

Binary file not shown.

106
src/utils/zip.spec.ts

@ -0,0 +1,106 @@
import { promises as fs } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { unzip, zip } from './zip';
jest.mock('fs-extra', () => jest.requireActual('fs-extra'));
describe('unzip', () => {
it('unzips test.zip', async () => {
const destination = join(tmpdir(), 'zip-test-' + Date.now());
await unzip(join(__dirname, 'tests', 'resources', 'test.zip'), destination);
const entries = await fs.readdir(destination, { withFileTypes: true });
expect(entries.map(e => e.name)).toContain('foo.json');
expect(entries.map(e => e.name)).toContain('bar.txt');
expect(entries.map(e => e.name)).toContain('baz');
const fooFile = entries.find(e => e.name === 'foo.json');
const barFile = entries.find(e => e.name === 'bar.txt');
const bazDir = entries.find(e => e.name === 'baz');
expect(fooFile).toBeDefined();
expect(barFile).toBeDefined();
expect(bazDir).toBeDefined();
expect(fooFile?.isFile()).toBeTruthy();
expect(barFile?.isFile()).toBeTruthy();
expect(bazDir?.isDirectory()).toBeTruthy();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const bazEntries = await fs.readdir(join(destination, bazDir!.name), {
withFileTypes: true,
});
expect(bazEntries).toHaveLength(1);
expect(bazEntries.map(e => e.name)).toContain('qux.ts');
const qux = await fs.readFile(join(destination, 'baz', 'qux.ts'));
expect(qux.toString('utf-8')).toBe("console.log('qux');\n");
const bar = await fs.readFile(join(destination, 'bar.txt'));
expect(bar.toString('utf-8')).toBe('bar\n');
const foo = await fs.readFile(join(destination, 'foo.json'));
expect(foo.toString('utf-8')).toBe(JSON.stringify({ foo: 2 }, null, 4) + '\n');
});
});
describe.only('zip', () => {
it('zips objects', async () => {
const objects: Array<{ name: string; object: any }> = [
{
name: 'firstObject',
object: 2,
},
{
name: 'secondObject',
object: { baz: 'baz' },
},
{
name: 'thirdObject',
object: [2, { foo: 'foo' }, false],
},
];
const zipped = join(tmpdir(), `zip-test-${Date.now()}.zip`);
await zip({
destination: zipped,
objects,
paths: [],
});
const unzipped = join(tmpdir(), `zip-test-${Date.now()}`);
await unzip(zipped, unzipped);
for (const obj of objects) {
const read = await fs
.readFile(join(unzipped, obj.name))
.then(read => JSON.parse(read.toString('utf-8')));
expect(read).toEqual(obj.object);
}
});
it('zips paths', async () => {
const files = [
join(__dirname, 'tests', 'resources', 'bar.txt'),
join(__dirname, 'tests', 'resources', 'foo.json'),
join(__dirname, 'tests', 'resources', 'baz'),
];
const zipped = join(tmpdir(), `zip-test-${Date.now()}.zip`);
await zip({ destination: zipped, objects: [], paths: files });
const unzipped = join(tmpdir(), `zip-test-${Date.now()}`);
await unzip(zipped, unzipped);
const entries = await fs.readdir(unzipped, { withFileTypes: true });
const bar = entries.find(e => e.name === 'bar.txt');
const baz = entries.find(e => e.name === 'baz');
const foo = entries.find(e => e.name === 'foo.json');
expect(bar?.isFile()).toBeTruthy();
expect(baz?.isDirectory()).toBeTruthy();
expect(foo?.isFile()).toBeTruthy();
});
});

157
src/utils/zip.ts

@ -0,0 +1,157 @@
import { error, info, warn } from 'electron-log';
import fs from 'fs';
import { pathExists } from 'fs-extra';
import { basename, join, resolve } from 'path';
import archiver from 'archiver';
import unzipper from 'unzipper';
/**
* Unzips `zip` into `destination`
*/
export const unzip = (zip: string, destination: string): Promise<void> => {
return new Promise(async (resolve, reject) => {
try {
const exists = await pathExists(zip);
if (!exists) {
throw Error(`${zip} does not exist!`);
}
const stream = fs
.createReadStream(zip)
.pipe(unzipper.Extract({ path: destination }));
stream.on('close', resolve);
stream.on('error', err => {
error(`Could not unzip ${zip} into ${destination}:`, err);
reject(err);
});
} catch (err) {
reject(err);
}
});
};
interface ZipArgs {
/** The destination of the generated zip */
destination: string;
objects: Array<{
/** Object to serialize (with `JSON.stringify`) and store in the zip */
object: any;
/** Name of this object in the generated zip */
name: string;
}>;
/** Files or folders to include */
paths: string[];
}
/**
* Adds a raw string into the ZIP archive
*
* @param archive ZIP archive to add the file to
* @param content content to add into archive
* @param nameInArchive name of file in archive
*/
const addStringToZip = (
archive: archiver.Archiver,
content: string,
nameInArchive: string,
): void => {
try {
archive.append(content, { name: nameInArchive });
} catch (err) {
error(`Could not add ${nameInArchive} to zip: ${err}`);
throw err;
}
};
/**
* Adds a file to the given ZIP archive. We read the file into
* memory and then append it to the ZIP archive. There appears
* to be issues with using Archiver.js with Electron/Webpack,
* so that's why we have to do it in a somewhat inefficient way.
*
* Related issues:
* * https://github.com/archiverjs/node-archiver/issues/349
* * https://github.com/archiverjs/node-archiver/issues/403
* * https://github.com/archiverjs/node-archiver/issues/174
*
* @param archive ZIP archive to add the file to
* @param filePath file to add, absolute path
* @param nameInArchive name of file in archive
*/
const addFileToZip = async (
archive: archiver.Archiver,
filePath: string,
nameInArchive: string,
) => {
return archive.append(await fs.promises.readFile(filePath), { name: nameInArchive });
};
// Generate a sequence of all regular files inside the given directory
async function* getFiles(dir: string): AsyncGenerator<string> {
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const res = resolve(dir, entry.name);
if (entry.isDirectory()) {
yield* getFiles(res);
} else if (entry.isFile()) {
yield res;
}
}
}
/**
* Add the given path to the archive. If it's a file we add it directly, it it is a directory
* we recurse over all the files within that directory
*
* @param archive ZIP archive to add the file to
* @param filePath file to add, absolute path
*/
const addFileOrDirectoryToZip = async (archive: archiver.Archiver, filePath: string) => {
const isDir = await fs.promises.lstat(filePath).then(res => res.isDirectory());
if (isDir) {
info('Adding directory to zip file:', filePath);
for await (const file of getFiles(filePath)) {
// a typical file might look like this:
// /home/user/.polar/networks/1/volumes/bitcoind/backend1/regtest/mempool.dat
// after applying this transformation, we end up with:
// volumes/bitcoind/backend1/regtest/mempool.dat
const nameInArchive = join(basename(filePath), file.slice(filePath.length));
await addFileToZip(archive, file, nameInArchive);
}
} else {
return addFileToZip(archive, filePath, basename(filePath));
}
};
export const zip = ({ destination, objects, paths }: ZipArgs): Promise<void> =>
new Promise(async (resolve, reject) => {
const output = fs.createWriteStream(destination);
const archive = archiver('zip');
// finished
archive.on('finish', () => resolve());
archive.on('error', err => {
error(`got error when zipping ${destination}:`, err);
reject(err);
});
archive.on('warning', warning => {
warn(`got warning when zipping ${destination}:`, warning);
reject(warning);
});
// pipe all zipped data to the output
archive.pipe(output);
const pathPromises = paths.map(p => addFileOrDirectoryToZip(archive, p));
for (const obj of objects) {
addStringToZip(archive, JSON.stringify(obj.object), obj.name);
}
await Promise.all(pathPromises);
// we've added all files, tell this to the archive so it can emit the 'close' event
// once all streams have finished
archive.finalize();
});

3
tsconfig.json

@ -16,5 +16,6 @@
"jsx": "preserve",
"baseUrl": "src"
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/utils/tests/resources"]
}

Loading…
Cancel
Save