From 1b6ed34df1851973f27410637f9c45535fdffa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bernard=20Stojanovi=C4=87?= Date: Fri, 9 Apr 2021 07:18:09 +0200 Subject: [PATCH] Docker images config from GitHub (#532) * refactor saga to get docker image name from config * implement image picker dropdown * fix isCurrentOrNewerVersion * refactor dropdown and add verified badge * refactor getAvailableClientReleases --- .../ConfigureBeaconNode.tsx | 27 +++++- src/renderer/components/Dropdown/Dropdown.tsx | 23 +++-- .../components/Dropdown/dropdown.scss | 6 ++ src/renderer/ducks/beacon/sagas.ts | 31 +++---- src/renderer/services/eth2/client/defaults.ts | 39 +++++---- .../eth2/client/lighthouse/defaults.ts | 3 + .../services/eth2/client/teku/defaults.ts | 3 + src/renderer/services/utils/githubReleases.ts | 46 ++++++++++ test/unit/utils/githubReleases.spec.ts | 33 +++++++ types/githubRelease.dto.ts | 87 +++++++++++++++++++ 10 files changed, 256 insertions(+), 42 deletions(-) create mode 100644 src/renderer/services/utils/githubReleases.ts create mode 100644 test/unit/utils/githubReleases.spec.ts create mode 100644 types/githubRelease.dto.ts diff --git a/src/renderer/components/ConfigureBeaconNode/ConfigureBeaconNode.tsx b/src/renderer/components/ConfigureBeaconNode/ConfigureBeaconNode.tsx index ffa098e95..9eb7e3d64 100644 --- a/src/renderer/components/ConfigureBeaconNode/ConfigureBeaconNode.tsx +++ b/src/renderer/components/ConfigureBeaconNode/ConfigureBeaconNode.tsx @@ -1,4 +1,4 @@ -import React, {FormEvent, useRef, useState} from "react"; +import React, {FormEvent, useEffect, useRef, useState} from "react"; import path from "path"; import {networksList} from "../../services/eth2/networks"; import {ButtonPrimary} from "../Button/ButtonStandard"; @@ -8,6 +8,7 @@ import {remote} from "electron"; import {getConfig} from "../../../config/config"; import {getDefaultsForClient} from "../../services/eth2/client/defaults"; import {Accordion} from "../Accordion/Accordion"; +import {getAvailableClientReleases} from "../../services/utils/githubReleases"; export interface IConfigureBNSubmitOptions { network: string; @@ -18,6 +19,7 @@ export interface IConfigureBNSubmitOptions { libp2pPort: string; rpcPort: string; memory: string; + image: string; } interface IConfigureBNProps { @@ -33,6 +35,15 @@ export const ConfigureBeaconNode: React.FunctionComponent = ( const [clientIndex, setClientIndex] = useState(0); const defaults = getDefaultsForClient(props.clientName); + const [images, setImages] = useState([]); + useEffect(() => { + getAvailableClientReleases(clients[clientIndex]).then((imageList) => { + setImages(imageList); + setImageIndex(imageList.length - 1); + }); + }, [clientIndex]); + const [imageIndex, setImageIndex] = useState(0); + const defaultChainDataDir = path.join(getConfig(remote.app).storage.dataDir, "beacon"); const [chainDataDir, setChainDataDir] = useState(defaultChainDataDir); @@ -61,6 +72,7 @@ export const ConfigureBeaconNode: React.FunctionComponent = ( memory, network: networksList[networkIndex], client: clients[clientIndex], + image: images[imageIndex], }); }; @@ -117,6 +129,19 @@ export const ConfigureBeaconNode: React.FunctionComponent = ( setShowAdvanced(!showAdvanced)}> +
+
+

Image

+

(recommended: {images[images.length - 1]})

+
+ +
+

Chain data location

diff --git a/src/renderer/components/Dropdown/Dropdown.tsx b/src/renderer/components/Dropdown/Dropdown.tsx index e86a3b6bd..8c25b5a29 100644 --- a/src/renderer/components/Dropdown/Dropdown.tsx +++ b/src/renderer/components/Dropdown/Dropdown.tsx @@ -1,17 +1,24 @@ import * as React from "react"; import {useState} from "react"; +import cgLogo from "../../assets/ico/app_icon.png"; export interface IDropdownProps { options: Array | {[id: number]: string}; current: number; label?: string; onChange?: (selected: number) => void; + verifiedIndex?: number; } -export const Dropdown: React.FunctionComponent = (props: IDropdownProps) => { +export const Dropdown: React.FunctionComponent = ({ + options, + current, + label, + onChange, + verifiedIndex, +}) => { const [visible, setVisible] = useState("none"); - - const options = Array.isArray(props.options) ? {...props.options} : props.options; + options = Array.isArray(options) ? {...options} : options; function showHide(): void { visible === "none" ? setVisible("block") : setVisible("none"); @@ -22,7 +29,6 @@ export const Dropdown: React.FunctionComponent = (props: IDropdo } function onOptionClick(key: number): void { - const {onChange} = props; if (onChange) { onChange(key); showHide(); @@ -36,19 +42,20 @@ export const Dropdown: React.FunctionComponent = (props: IDropdo onClick={(): void => onOptionClick(key)} className={`dropdown-item ${visible} - ${key === props.current ? "selected" : ""}`}> + ${key === current ? "selected" : ""}`}> {options[key]} + {verifiedIndex === key && ChainGuardian Logo}
); } return (
- {props.label &&

{props.label}

} + {label &&

{label}

}
hide()} className='dropdown-screen'>
-
props.onChange && showHide()} className='dropdown-selected'> -
{props.options[props.current]}
+
onChange && showHide()} className='dropdown-selected'> +
{options[current]}
diff --git a/src/renderer/components/Dropdown/dropdown.scss b/src/renderer/components/Dropdown/dropdown.scss index bdb8b5eff..1c9997160 100644 --- a/src/renderer/components/Dropdown/dropdown.scss +++ b/src/renderer/components/Dropdown/dropdown.scss @@ -69,6 +69,12 @@ z-index: 100; &.selected{ background-color: $neutral-600; } + + img { + height: 18px; + margin-left: 10px; + margin-bottom: -3px; + } } .dropdown-item:first-child{ border-radius:8px 8px 0 0; diff --git a/src/renderer/ducks/beacon/sagas.ts b/src/renderer/ducks/beacon/sagas.ts index afb80b59b..81865cddb 100644 --- a/src/renderer/ducks/beacon/sagas.ts +++ b/src/renderer/ducks/beacon/sagas.ts @@ -15,7 +15,6 @@ import { select, SelectEffect, } from "redux-saga/effects"; -import {getNetworkConfig} from "../../services/eth2/networks"; import {liveProcesses} from "../../services/utils/cmd"; import { cancelDockerPull, @@ -45,7 +44,7 @@ import {ValidatorBeaconNodes} from "../../models/validatorBeaconNodes"; import {createNotification} from "../notification/actions"; import {computeEpochAtSlot} from "@chainsafe/lodestar-beacon-state-transition"; import {ValidatorStatus} from "../../constants/validatorStatus"; -import {cgLogger, createLogger, getBeaconLogfileFromURL} from "../../../main/logger"; +import {cgLogger, createLogger, getBeaconLogfileFromURL, mainLogger} from "../../../main/logger"; import {setInitialBeacons} from "../settings/actions"; import {DockerRegistry} from "../../services/docker/docker-registry"; import {CgEth2ApiClient, readBeaconChainNetwork} from "../../services/eth2/client/module"; @@ -66,30 +65,25 @@ export function* pullDockerImage( return effect !== undefined ? false : pullSuccess; } catch (e) { - const message = e.stderr.includes("daemon is not running") - ? "Seems Docker is offline, start it and try again" - : "Error while pulling Docker image, try again later"; - yield put(createNotification({title: message, source: "pullDockerImage"})); + if (e.stderr) { + const message = e.stderr.includes("daemon is not running") + ? "Seems Docker is offline, start it and try again" + : "Error while pulling Docker image, try again later"; + yield put(createNotification({title: message, source: "pullDockerImage"})); + cgLogger.error(e.stderr); + } else { + yield put(createNotification({title: e.message, source: "pullDockerImage"})); + mainLogger.error(e.message); + } yield put(endDockerImagePull()); - cgLogger.error(e.stderr); return false; } } function* startLocalBeaconSaga({ - payload: {network, client, chainDataDir, eth1Url, discoveryPort, libp2pPort, rpcPort, memory}, + payload: {network, client, chainDataDir, eth1Url, discoveryPort, libp2pPort, rpcPort, memory, image}, meta: {onComplete}, }: ReturnType): Generator { - const image = ((): string => { - switch (client) { - case "teku": - return process.env.DOCKER_TEKU_IMAGE; - case "lighthouse": - return process.env.DOCKER_LIGHTHOUSE_IMAGE; - default: - return getNetworkConfig(network).dockerConfig.image; - } - })(); const pullSuccess = yield call(pullDockerImage, image); const ports = [ @@ -116,6 +110,7 @@ function* startLocalBeaconSaga({ memory, eth1Url, chainDataDir, + image, }), )).getParams().name, network, diff --git a/src/renderer/services/eth2/client/defaults.ts b/src/renderer/services/eth2/client/defaults.ts index 25c1940e3..020bf8bc7 100644 --- a/src/renderer/services/eth2/client/defaults.ts +++ b/src/renderer/services/eth2/client/defaults.ts @@ -3,6 +3,9 @@ export interface IDefaultBeaconNodeConfig { libp2pPort: number; discoveryPort: number; memory: string; + owner: string; + repo: string; + dockerImage: string; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -18,21 +21,24 @@ export interface IAllDefaults { } export const getAllDefaults = (): IAllDefaults => { - const context = require.context(".", true, /\/(.*)\/(.*)defaults.ts/); - return context - .keys() - .map((path) => ({ - name: path.split("/")[1], - beacon: context(path).beaconNode, - validator: context(path).validator, - })) - .reduce( - (prev, {name, beacon, validator}) => ({ - ...prev, - [name]: {beacon, validator}, - }), - {}, - ); + if (process.env.NODE_ENV !== "test") { + const context = require.context(".", true, /\/(.*)\/(.*)defaults.ts/); + return context + .keys() + .map((path) => ({ + name: path.split("/")[1], + beacon: context(path).beaconNode, + validator: context(path).validator, + })) + .reduce( + (prev, {name, beacon, validator}) => ({ + ...prev, + [name]: {beacon, validator}, + }), + {}, + ); + } + return {}; }; const defaults = getAllDefaults(); @@ -44,6 +50,9 @@ export const getDefaultsForClient = (clientName: string): IDefaults => libp2pPort: 9000, discoveryPort: 9000, memory: "3500m", + owner: "none", + repo: "none", + dockerImage: "none", }, validator: {}, }; diff --git a/src/renderer/services/eth2/client/lighthouse/defaults.ts b/src/renderer/services/eth2/client/lighthouse/defaults.ts index 7d6d69ff3..15c8d5b6e 100644 --- a/src/renderer/services/eth2/client/lighthouse/defaults.ts +++ b/src/renderer/services/eth2/client/lighthouse/defaults.ts @@ -5,6 +5,9 @@ export const beaconNode: IDefaultBeaconNodeConfig = { libp2pPort: 9000, discoveryPort: 9000, memory: "3500m", + owner: "sigp", + repo: "lighthouse", + dockerImage: process.env.DOCKER_LIGHTHOUSE_IMAGE, }; export const validator: IDefaultValidatorConfig = {}; diff --git a/src/renderer/services/eth2/client/teku/defaults.ts b/src/renderer/services/eth2/client/teku/defaults.ts index 7d6d69ff3..40e60ea63 100644 --- a/src/renderer/services/eth2/client/teku/defaults.ts +++ b/src/renderer/services/eth2/client/teku/defaults.ts @@ -5,6 +5,9 @@ export const beaconNode: IDefaultBeaconNodeConfig = { libp2pPort: 9000, discoveryPort: 9000, memory: "3500m", + owner: "ConsenSys", + repo: "teku", + dockerImage: process.env.DOCKER_TEKU_IMAGE, }; export const validator: IDefaultValidatorConfig = {}; diff --git a/src/renderer/services/utils/githubReleases.ts b/src/renderer/services/utils/githubReleases.ts new file mode 100644 index 000000000..3b9fd6e33 --- /dev/null +++ b/src/renderer/services/utils/githubReleases.ts @@ -0,0 +1,46 @@ +import axios, {AxiosResponse} from "axios"; +import {IGithubRelease} from "../../../../types/githubRelease.dto"; +import {getDefaultsForClient} from "../eth2/client/defaults"; + +const gitHubReposInstance = axios.create({ + baseURL: "https://api.github.com/repos/", + timeout: 1000, +}); + +export const getReleases = (owner: string, repo: string): Promise> => + gitHubReposInstance.get(`/${owner}/${repo}/releases`); + +export const isCurrentOrNewerVersion = (current: string, comparingWith: string): boolean => { + if (current === comparingWith) return true; + + const currentFragments = current.replace(/[^\d.-]/g, "").split("."); + const comparingWithFragments = comparingWith.replace(/[^\d.-]/g, "").split("."); + + const length = + currentFragments.length > comparingWithFragments.length + ? currentFragments.length + : comparingWithFragments.length; + for (let i = 0; i < length; i++) { + if ((Number(currentFragments[i]) || 0) === (Number(comparingWithFragments[i]) || 0)) continue; + return (Number(comparingWithFragments[i]) || 0) > (Number(currentFragments[i]) || 0); + } + return true; +}; + +export const getAvailableClientReleases = async (client: string): Promise => { + const { + beacon: {dockerImage, owner, repo}, + } = getDefaultsForClient(client); + const response = await getReleases(owner, repo); + const currentTag = dockerImage.split(":")[1]; + return ( + response.data + .filter( + // eslint-disable-next-line camelcase, @typescript-eslint/camelcase + ({tag_name, draft, prerelease}) => + !draft && !prerelease && isCurrentOrNewerVersion(currentTag, tag_name), + ) + // eslint-disable-next-line camelcase, @typescript-eslint/camelcase + .map(({tag_name}) => dockerImage.split(":")[0] + ":" + tag_name) + ); +}; diff --git a/test/unit/utils/githubReleases.spec.ts b/test/unit/utils/githubReleases.spec.ts new file mode 100644 index 000000000..355a6fafd --- /dev/null +++ b/test/unit/utils/githubReleases.spec.ts @@ -0,0 +1,33 @@ +import {isCurrentOrNewerVersion} from "../../../src/renderer/services/utils/githubReleases"; + +describe("GitHub Releases", () => { + describe("isCurrentOrNewerVersion", () => { + it("test with 'v' prefix", () => { + expect(isCurrentOrNewerVersion("v1.3.2", "v1.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("v1.3.2", "v1.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("v1.3.2", "v2.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("v1.3.2", "v2.1.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("v1.3", "v1.3.0")).toBeTruthy(); + expect(isCurrentOrNewerVersion("v1.3", "v1.3.3")).toBeTruthy(); + + expect(isCurrentOrNewerVersion("v1.3.2", "v1.3.1")).toBeFalsy(); + expect(isCurrentOrNewerVersion("v1.4.2", "v1.3.11")).toBeFalsy(); + expect(isCurrentOrNewerVersion("v1.3.2", "v1.3")).toBeFalsy(); + expect(isCurrentOrNewerVersion("v1.3.2", "v0.2.1")).toBeFalsy(); + }); + + it("test without v prefix", () => { + expect(isCurrentOrNewerVersion("1.3.2", "1.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("1.3.2", "1.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("1.3.2", "2.3.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("1.3.2", "2.1.2")).toBeTruthy(); + expect(isCurrentOrNewerVersion("1.3", "1.3.0")).toBeTruthy(); + expect(isCurrentOrNewerVersion("1.3", "1.3.3")).toBeTruthy(); + + expect(isCurrentOrNewerVersion("1.3.2", "1.3.1")).toBeFalsy(); + expect(isCurrentOrNewerVersion("1.4.2", "1.3.11")).toBeFalsy(); + expect(isCurrentOrNewerVersion("1.3.2", "1.3")).toBeFalsy(); + expect(isCurrentOrNewerVersion("1.3.2", "0.2.1")).toBeFalsy(); + }); + }); +}); diff --git a/types/githubRelease.dto.ts b/types/githubRelease.dto.ts new file mode 100644 index 000000000..e04f4a6f2 --- /dev/null +++ b/types/githubRelease.dto.ts @@ -0,0 +1,87 @@ +export interface IGithubRelease { + url: string; + // eslint-disable-next-line camelcase + html_url: string; + // eslint-disable-next-line camelcase + assets_url: string; + // eslint-disable-next-line camelcase + upload_url: string; + // eslint-disable-next-line camelcase + tarball_url: string; + // eslint-disable-next-line camelcase + zipball_url: string; + id: number; + // eslint-disable-next-line camelcase + node_id: string; + // eslint-disable-next-line camelcase + tag_name: string; + // eslint-disable-next-line camelcase + target_commitish: string; + name: string; + body: string; + draft: boolean; + prerelease: boolean; + // eslint-disable-next-line camelcase + created_at: string; + // eslint-disable-next-line camelcase + published_at: string; + author: IAuthor; + assets?: IAssetsEntity[]; +} + +export interface IAuthor { + login: string; + id: number; + // eslint-disable-next-line camelcase + node_id: string; + // eslint-disable-next-line camelcase + avatar_url: string; + // eslint-disable-next-line camelcase + gravatar_id: string; + url: string; + // eslint-disable-next-line camelcase + html_url: string; + // eslint-disable-next-line camelcase + followers_url: string; + // eslint-disable-next-line camelcase + following_url: string; + // eslint-disable-next-line camelcase + gists_url: string; + // eslint-disable-next-line camelcase + starred_url: string; + // eslint-disable-next-line camelcase + subscriptions_url: string; + // eslint-disable-next-line camelcase + organizations_url: string; + // eslint-disable-next-line camelcase + repos_url: string; + // eslint-disable-next-line camelcase + events_url: string; + // eslint-disable-next-line camelcase + received_events_url: string; + type: string; + // eslint-disable-next-line camelcase + site_admin: boolean; +} + +export interface IAssetsEntity { + url: string; + // eslint-disable-next-line camelcase + browser_download_url: string; + id: number; + // eslint-disable-next-line camelcase + node_id: string; + name: string; + label: string; + state: string; + // eslint-disable-next-line camelcase + content_type: string; + size: number; + // eslint-disable-next-line camelcase + download_count: number; + // eslint-disable-next-line camelcase + created_at: string; + // eslint-disable-next-line camelcase + updated_at: string; + uploader: IAuthor; +}