From 7920cb6918e608e37d19ae118f4c0d64af19b4e3 Mon Sep 17 00:00:00 2001 From: Lenin Mehedy Date: Fri, 10 Nov 2023 05:50:07 +1100 Subject: [PATCH] feat(cli): implement node setup command (#491) Signed-off-by: Lenin Mehedy --- .github/workflows/zxc-compile-code.yaml | 2 +- dev/gateway-api/fst-gateway.yaml | 4 +- fullstack-network-manager/README.md | 38 +- fullstack-network-manager/package-lock.json | 9 + fullstack-network-manager/package.json | 1 + .../resources/templates/config.template | 62 +++ .../resources/templates/hedera.key | 52 +++ .../resources/templates/log4j2.xml | 318 +++++++++++++++ .../templates/node-keys/private-node0.pfx | Bin 0 -> 7732 bytes .../templates/node-keys/private-node1.pfx | Bin 0 -> 9897 bytes .../templates/node-keys/private-node2.pfx | Bin 0 -> 9897 bytes .../templates/node-keys/private-node3.pfx | Bin 0 -> 9897 bytes .../resources/templates/node-keys/public.pfx | Bin 0 -> 11030 bytes .../properties/api-permission.properties | 70 ++++ .../properties/application.properties | 1 + .../templates/properties/bootstrap.properties | 9 + .../resources/templates/settings.txt | 22 + .../src/commands/base.mjs | 5 +- .../src/commands/chart.mjs | 2 +- .../src/commands/cluster.mjs | 7 +- .../src/commands/flags.mjs | 35 ++ .../src/commands/index.mjs | 3 + .../src/commands/init.mjs | 63 ++- .../src/commands/node.mjs | 204 ++++++++++ .../src/core/constants.mjs | 23 +- fullstack-network-manager/src/core/errors.mjs | 12 + fullstack-network-manager/src/core/index.mjs | 15 +- .../src/core/kubectl.mjs | 77 ++++ .../src/core/logging.mjs | 21 +- .../src/core/package_downloader.mjs | 68 +++- .../src/core/platform_installer.mjs | 375 ++++++++++++++++++ .../src/core/shell_runner.mjs | 10 +- .../src/core/templates.mjs | 24 ++ fullstack-network-manager/src/core/zippy.mjs | 78 ++++ fullstack-network-manager/src/index.mjs | 10 +- fullstack-network-manager/test/data/.empty | 0 .../e2e/core/package_downloader_e2e.test.js | 14 +- .../e2e/core/platform_installer_e2e.test.js | 168 ++++++++ .../test/unit/commands/base.test.js | 6 +- .../test/unit/core/package_downloader.test.js | 6 +- .../test/unit/core/platform_installer.test.js | 118 ++++++ .../test/unit/core/zippy.test.js | 47 +++ 42 files changed, 1895 insertions(+), 84 deletions(-) create mode 100644 fullstack-network-manager/resources/templates/config.template create mode 100755 fullstack-network-manager/resources/templates/hedera.key create mode 100644 fullstack-network-manager/resources/templates/log4j2.xml create mode 100644 fullstack-network-manager/resources/templates/node-keys/private-node0.pfx create mode 100644 fullstack-network-manager/resources/templates/node-keys/private-node1.pfx create mode 100644 fullstack-network-manager/resources/templates/node-keys/private-node2.pfx create mode 100644 fullstack-network-manager/resources/templates/node-keys/private-node3.pfx create mode 100644 fullstack-network-manager/resources/templates/node-keys/public.pfx create mode 100644 fullstack-network-manager/resources/templates/properties/api-permission.properties create mode 100644 fullstack-network-manager/resources/templates/properties/application.properties create mode 100644 fullstack-network-manager/resources/templates/properties/bootstrap.properties create mode 100644 fullstack-network-manager/resources/templates/settings.txt create mode 100644 fullstack-network-manager/src/commands/node.mjs create mode 100644 fullstack-network-manager/src/core/platform_installer.mjs create mode 100644 fullstack-network-manager/src/core/templates.mjs create mode 100644 fullstack-network-manager/src/core/zippy.mjs create mode 100644 fullstack-network-manager/test/data/.empty create mode 100644 fullstack-network-manager/test/e2e/core/platform_installer_e2e.test.js create mode 100644 fullstack-network-manager/test/unit/core/platform_installer.test.js create mode 100644 fullstack-network-manager/test/unit/core/zippy.test.js diff --git a/.github/workflows/zxc-compile-code.yaml b/.github/workflows/zxc-compile-code.yaml index 3cb442aca..16d333f6b 100644 --- a/.github/workflows/zxc-compile-code.yaml +++ b/.github/workflows/zxc-compile-code.yaml @@ -150,7 +150,7 @@ jobs: run: | npm i npm test - npm run test-e2e +# npm run test-e2e # tracked by #https://github.com/hashgraph/full-stack-testing/issues/501 # This step tests the Helm chart direct mode of operation which uses the ubi8-init-java17 image. - name: Helm Chart Test (Direct Install) diff --git a/dev/gateway-api/fst-gateway.yaml b/dev/gateway-api/fst-gateway.yaml index 37cbf78ae..c38ba5abe 100644 --- a/dev/gateway-api/fst-gateway.yaml +++ b/dev/gateway-api/fst-gateway.yaml @@ -15,10 +15,10 @@ spec: listeners: - name: http-debug protocol: HTTP - port: 80 + port: 3100 - name: tcp-debug protocol: TCP - port: 9000 + port: 3101 allowedRoutes: kinds: - kind: TCPRoute diff --git a/fullstack-network-manager/README.md b/fullstack-network-manager/README.md index 60b7d07c0..0148cd838 100644 --- a/fullstack-network-manager/README.md +++ b/fullstack-network-manager/README.md @@ -13,29 +13,6 @@ fullstack-network-manager is a CLI tool to manage and deploy a Hedera Network us - Run `fsnetman` from a terminal as shown below ``` ❯ fsnetman -Usage: fsnetman [options] - -Commands: - fsnetman init Initialize local environment - fsnetman cluster Manager FST cluster - -Options: - -h, --help Show help [boolean] - -v, --version Show version number [boolean] - -Select a command -``` -## Develop -- In order to support ES6 modules with `jest`, set an env variable `export NODE_OPTIONS=--experimental-vm-modules >> ~/.zshrc` - - If you are using Intellij and would like to use debugger tools, you will need to enable `--experimental-vm-modules` for `Jest`. - - `Run->Edit Configurations->Edit Configuration Templates->Jest` and then set `--experimental-vm-modules` in `Node Options`. -- Run `npm i` to install the required packages -- Run `npm link` to install `fsnetman` as the CLI (you need to do it once) -- Run `npm test` or `npm run test` to run the unit tests -- Run `npm run test-e2e` to run the long-running integration tests -- Run `npm run fsnetman` to access the CLI as shown below: -``` -❯ npm run fsnetman Usage: fsnetman [options] @@ -49,6 +26,19 @@ Options: -v, --version Show version number [boolean] Select a command - ``` +## Develop +- In order to support ES6 modules with `jest`, set an env variable `export NODE_OPTIONS=--experimental-vm-modules >> ~/.zshrc` + - If you are using Intellij and would like to use debugger tools, you will need to enable `--experimental-vm-modules` for `Jest`. + - `Run->Edit Configurations->Edit Configuration Templates->Jest` and then set `--experimental-vm-modules` in `Node Options`. +- Run `npm i` to install the required packages +- Run `npm link` to install `fsnetman` as the CLI + - Note: you need to do it once. If `fsnetman` already exists in your path, you will need to remove it first. + - Alternative way would be to run `npm run fsnetman -- ` +- Run `npm test` or `npm run test` to run the unit tests +- Run `npm run test-e2e` to run the long-running integration tests +- Run `fsnetman` to access the CLI as shown above. +- Note that debug logs are stored at `~/.fsnetman/logs/fst.log`. So you may use `tail -f ~/.fsnetman/logs/fst.log | jq + ` in a separate terminal to keep an eye on the logs. + diff --git a/fullstack-network-manager/package-lock.json b/fullstack-network-manager/package-lock.json index dd4c37210..0cf2fe7bf 100644 --- a/fullstack-network-manager/package-lock.json +++ b/fullstack-network-manager/package-lock.json @@ -13,6 +13,7 @@ "linux" ], "dependencies": { + "adm-zip": "^0.5.10", "chalk": "^5.3.0", "esm": "^3.2.25", "figlet": "^1.6.0", @@ -1289,6 +1290,14 @@ "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", "dev": true }, + "node_modules/adm-zip": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.10.tgz", + "integrity": "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==", + "engines": { + "node": ">=6.0" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", diff --git a/fullstack-network-manager/package.json b/fullstack-network-manager/package.json index c85a4f26f..0d7154888 100644 --- a/fullstack-network-manager/package.json +++ b/fullstack-network-manager/package.json @@ -22,6 +22,7 @@ "author": "Lenin Mehedy", "license": "Apache2.0", "dependencies": { + "adm-zip": "^0.5.10", "chalk": "^5.3.0", "esm": "^3.2.25", "figlet": "^1.6.0", diff --git a/fullstack-network-manager/resources/templates/config.template b/fullstack-network-manager/resources/templates/config.template new file mode 100644 index 000000000..142933e96 --- /dev/null +++ b/fullstack-network-manager/resources/templates/config.template @@ -0,0 +1,62 @@ +# ====================================================================================================================== +# Address book Format Description +# ====================================================================================================================== +# Address book format varies across versions since it evolved over time. As of July 27, 2023 the below formats were +# relevant for recent versions. Latest version is available in the file: hedera-services/hedera-node/config.txt +# +# - v.0.39.* (or before) format: +# Fields: address, , , , , , , , +# Example: address, 0, node0, 1, 10.128.0.27, 50111, 35.223.93.31, 30124, 0.0.3 +# +# - v.0.4* format: +# Fields: address, , , , , , , , , +# Example: address, 0, n0, node0, 1, 10.128.0.27, 50111, 35.223.93.31, 30124, 0.0.3 +# +# - v.0.41* (onward) we need to append the below formatted line with next node ID after the list of "address" lines +#
+# nextNodeId, +# +# Field descriptions: +# =========================== +# NODE_ID: This increments for each node and starts from 0. +# NEXT_NODE_ID: The id for the next node (i.e. last node ID + 1) +# NODE_NICK_NAME: This is a string (alphanumeric). e.g. node0 +# NODE_NAME: This is a string (alphanumeric). e.g. node0 or n0 +# NODE_STAKE_AMOUNT: A long value. e.g. 1 or a larger number +# INTERNAL_IP: This is the pod IP +# INTERNAL_GOSSIP_PORT: Default gossip port is 50111. So use the exposed port that is mapped to 50111 in container. +# EXTERNAL_IP: This is the service IP +# EXTERNAL_GOSSIP_PORT: Default gossip port is 50111. This is usually same as INTERNAL_GOSSIP_PORT unless mapped differently. +# ACCOUNT_ID: Must start from 0.0.3 +# + +# Account restrictions: +# =========================== +# All ACCOUNT_ID should start from 0.0.3 because of restricted accounts as below: +# - 0.0.0 restricted and not usable +# - 0.0.1 minting account and not usable +# - 0.0.2 treasury account +# +# Default Ports +# =========================== +# We only need to specify the gossip port (INTERNAL_GOSSIP_PORT, EXTERNAL_GOSSIP_PORT). Below are some details on other +# ports that a node may expose: +# - 50111: gossip port +# - 50211: grpc non-tls (for platform services) +# - 50212: grpc tls (for platform services) +# +# IP Address +# =========================== +# When deploying in a kubernetes cluster, we need to use the following IP mapping: +# - INTERNAL_IP: This should be the Pod IP exposing gossip port (i.e. 50111) +# - EXTERNAL_IP: This should be the cluster IP of the service exposing gossip port (i.e. 50111) +# +# +# Example config.txt (for v0.4* onward) +# =========================== +# swirld, 123 +# app, HederaNode.jar +# address, 0, node0, node0, 1, 10.244.0.197, 56789, 10.96.61.84, 50111, 0.0.0 +# address, 1, node1, node1, 1, 10.244.0.198, 56789, 10.96.163.93, 50111, 0.0.1 +# nextNodeId, 2 +# ====================================================================================================================== diff --git a/fullstack-network-manager/resources/templates/hedera.key b/fullstack-network-manager/resources/templates/hedera.key new file mode 100755 index 000000000..5ec7333d0 --- /dev/null +++ b/fullstack-network-manager/resources/templates/hedera.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +TJ2llEjO54rp2QqDlW1mxbmZwuj3ppQ= +-----END PRIVATE KEY----- diff --git a/fullstack-network-manager/resources/templates/log4j2.xml b/fullstack-network-manager/resources/templates/log4j2.xml new file mode 100644 index 000000000..590949c2a --- /dev/null +++ b/fullstack-network-manager/resources/templates/log4j2.xml @@ -0,0 +1,318 @@ + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5p %-4L %c{1} - %m{nolookups}%n + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-5p %-4L %c{1} - %m{nolookups}%n + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-8sn %-5p %-16marker <%t> %c{1}: %msg{nolookups}%n + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %-8sn %-5p %-16marker <%t> %c{1}: %msg{nolookups}%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fullstack-network-manager/resources/templates/node-keys/private-node0.pfx b/fullstack-network-manager/resources/templates/node-keys/private-node0.pfx new file mode 100644 index 0000000000000000000000000000000000000000..47587c9f170be4b2eab17780d55c81d7c87d167e GIT binary patch literal 7732 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 Q{lh{^+iN`UKc3wF4+`E!tN;K2 literal 0 HcmV?d00001 diff --git a/fullstack-network-manager/resources/templates/node-keys/private-node1.pfx b/fullstack-network-manager/resources/templates/node-keys/private-node1.pfx new file mode 100644 index 0000000000000000000000000000000000000000..9ff147feea5480f5bd37ca3d341e978cc7357f2f GIT binary patch literal 9897 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 NA_QHj@83pP{|{(IAgKTV literal 0 HcmV?d00001 diff --git a/fullstack-network-manager/resources/templates/node-keys/private-node3.pfx b/fullstack-network-manager/resources/templates/node-keys/private-node3.pfx new file mode 100644 index 0000000000000000000000000000000000000000..a9347851e4cc40896f18d0d5f6af15d04dfb290d GIT binary patch literal 9897 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 Q>Q(XO&E7}q6#@b$5HnjxX8-^I literal 0 HcmV?d00001 diff --git a/fullstack-network-manager/resources/templates/properties/api-permission.properties b/fullstack-network-manager/resources/templates/properties/api-permission.properties new file mode 100644 index 000000000..8a33c31d4 --- /dev/null +++ b/fullstack-network-manager/resources/templates/properties/api-permission.properties @@ -0,0 +1,70 @@ +# Crypto +createAccount=0-* +cryptoTransfer=0-* +updateAccount=0-* +cryptoGetBalance=0-* +getAccountInfo=0-* +cryptoDelete=0-* +getAccountRecords=0-* +getTxRecordByTxID=0-* +getTransactionReceipts=0-* +approveAllowances=0-* +deleteAllowances=0-* +# File +createFile=0-* +updateFile=0-* +deleteFile=0-* +appendContent=0-* +getFileContent=0-* +getFileInfo=0-* +# Contract +createContract=0-* +updateContract=0-* +contractCallMethod=0-* +getContractInfo=0-* +contractCallLocalMethod=0-* +contractGetBytecode=0-* +getTxRecordByContractID=0-* +deleteContract=0-* +# Consensus +createTopic=0-* +updateTopic=0-* +deleteTopic=0-* +submitMessage=0-* +getTopicInfo=0-* +# Ethereum +ethereumTransaction=0-* +# Scheduling +scheduleCreate=0-* +scheduleSign=0-* +scheduleDelete=0-* +scheduleGetInfo=0-* +# Token +tokenCreate=0-* +tokenFreezeAccount=0-* +tokenUnfreezeAccount=0-* +tokenGrantKycToAccount=0-* +tokenRevokeKycFromAccount=0-* +tokenDelete=0-* +tokenMint=0-* +tokenBurn=0-* +tokenAccountWipe=0-* +tokenUpdate=0-* +tokenGetInfo=0-* +tokenGetNftInfo=0-* +tokenGetNftInfos=0-* +tokenGetAccountNftInfos=0-* +tokenAssociateToAccount=0-* +tokenDissociateFromAccount=0-* +tokenFeeScheduleUpdate=0-* +tokenPause=0-* +tokenUnpause=0-* +# Network +getVersionInfo=0-* +networkGetExecutionTime=2-50 +systemDelete=2-59 +systemUndelete=2-60 +freeze=2-58 +getAccountDetails=2-50 +# Util +utilPrng=0-* diff --git a/fullstack-network-manager/resources/templates/properties/application.properties b/fullstack-network-manager/resources/templates/properties/application.properties new file mode 100644 index 000000000..4994eb276 --- /dev/null +++ b/fullstack-network-manager/resources/templates/properties/application.properties @@ -0,0 +1 @@ +autoRenew.targetTypes= diff --git a/fullstack-network-manager/resources/templates/properties/bootstrap.properties b/fullstack-network-manager/resources/templates/properties/bootstrap.properties new file mode 100644 index 000000000..74d317b61 --- /dev/null +++ b/fullstack-network-manager/resources/templates/properties/bootstrap.properties @@ -0,0 +1,9 @@ +ledger.id=0x01 +netty.mode=DEV +contracts.chainId=298 +hedera.recordStream.logPeriod=1 +balances.exportPeriodSecs=400 +files.maxSizeKb=2048 +hedera.recordStream.compressFilesOnCreation=true +balances.compressOnCreation=true +contracts.maxNumWithHapiSigsAccess=0 diff --git a/fullstack-network-manager/resources/templates/settings.txt b/fullstack-network-manager/resources/templates/settings.txt new file mode 100644 index 000000000..462e60ab5 --- /dev/null +++ b/fullstack-network-manager/resources/templates/settings.txt @@ -0,0 +1,22 @@ +checkSignedStateFromDisk, 1 +csvFileName, MainNetStats +csvOutputFolder, data/stats +doUpnp, false +enableEventStreaming, false +eventsLogDir, /opt/hgcapp/eventsStreams +eventsLogPeriod, 5 +maxEventQueueForCons, 1000 +maxOutgoingSyncs, 8 +numConnections, 1000 +reconnect.active, 1 +reconnect.asyncStreamTimeoutMilliseconds, 60000 +reconnect.reconnectWindowSeconds, -1 +showInternalStats, 1 +state.saveStatePeriod, 900 +state.signedStateDisk, 5 +throttle7extra, 0.5 +useLoopbackIp, false +waitAtStartup, false +jasperDb.iteratorInputBufferBytes, 16777216 +prometheusEndpointEnabled, true +transactionMaxBytes, 6144 diff --git a/fullstack-network-manager/src/commands/base.mjs b/fullstack-network-manager/src/commands/base.mjs index 41a20a6a1..1eafa33b7 100644 --- a/fullstack-network-manager/src/commands/base.mjs +++ b/fullstack-network-manager/src/commands/base.mjs @@ -142,12 +142,13 @@ export class BaseCommand extends ShellRunner { } constructor(opts) { - super(opts); - + if (!opts || !opts.logger) throw new Error('An instance of core/Logger is required') if (!opts || !opts.kind) throw new Error('An instance of core/Kind is required') if (!opts || !opts.helm) throw new Error('An instance of core/Helm is required') if (!opts || !opts.kubectl) throw new Error('An instance of core/Kubectl is required') + super(opts.logger); + this.kind = opts.kind this.helm = opts.helm this.kubectl = opts.kubectl diff --git a/fullstack-network-manager/src/commands/chart.mjs b/fullstack-network-manager/src/commands/chart.mjs index 535319ca5..e92f9e3b6 100644 --- a/fullstack-network-manager/src/commands/chart.mjs +++ b/fullstack-network-manager/src/commands/chart.mjs @@ -106,7 +106,7 @@ export class ChartCommand extends BaseCommand { } }) - .demand(1, 'Select a chart command') + .demandCommand(1, 'Select a chart command') } } } diff --git a/fullstack-network-manager/src/commands/cluster.mjs b/fullstack-network-manager/src/commands/cluster.mjs index d6b261230..0082e8e81 100644 --- a/fullstack-network-manager/src/commands/cluster.mjs +++ b/fullstack-network-manager/src/commands/cluster.mjs @@ -2,7 +2,6 @@ import * as core from '../core/index.mjs' import * as flags from './flags.mjs' import {BaseCommand} from "./base.mjs"; import chalk from "chalk"; -import {Kind} from "../core/kind.mjs"; /** * Define the core functionalities of 'cluster' command @@ -90,6 +89,8 @@ export class ClusterCommand extends BaseCommand { this.logger.showUser(chalk.green('OK'), `namespace '${namespace}' already exists`) } + // TODO: kubectl config set-context --current --namespace="${NAMESPACE}" + this.showList("namespaces", await this.getNameSpaces()) return true @@ -284,7 +285,7 @@ export class ClusterCommand extends BaseCommand { desc: 'Setup cluster with shared components', builder: yargs => { yargs.option('cluster-name', flags.clusterNameFlag) - yargs.option('namespace', flags.namespaceFlag) + yargs.option('namespace', flags.defaultNamespaceFlag) yargs.option('prometheus-stack', flags.deployPrometheusStack) yargs.option('minio', flags.deployMinio) yargs.option('envoy-gateway', flags.deployEnvoyGateway) @@ -303,7 +304,7 @@ export class ClusterCommand extends BaseCommand { } }) - .demand(1, 'Select a cluster command') + .demandCommand(1, 'Select a cluster command') } } } diff --git a/fullstack-network-manager/src/commands/flags.mjs b/fullstack-network-manager/src/commands/flags.mjs index 9ae60d7e5..aefc554b5 100644 --- a/fullstack-network-manager/src/commands/flags.mjs +++ b/fullstack-network-manager/src/commands/flags.mjs @@ -15,6 +15,13 @@ export const namespaceFlag = { type: 'string' } +export const defaultNamespaceFlag = { + describe: 'Namespace', + default: 'default', + alias: 's', + type: 'string' +} + export const deployMirrorNode = { describe: 'Deploy mirror node', default: true, @@ -74,3 +81,31 @@ export const deployCertManagerCRDs = { alias: 'd', type: 'boolean' } + +export const platformReleaseTag = { + describe: 'Platform release tag (e.g. v0.42.4, fetch build-.zip from https://builds.hedera.com)', + default: "", + alias: 't', + type: 'string' +} + +export const platformReleaseDir = { + describe: `Platform release cache dir (containing release directories named as v.. e.g. v0.42)`, + default: core.constants.FST_CACHE_DIR, + alias: 'd', + type: 'string' +} + +export const nodeIDs = { + describe: 'Comma separated node IDs (empty means all nodes)', + default: "", + alias: 'i', + type: 'string' +} + +export const force= { + describe: 'Force actions even if those can be skipped', + default: false, + alias: 'f', + type: 'boolean' +} diff --git a/fullstack-network-manager/src/commands/index.mjs b/fullstack-network-manager/src/commands/index.mjs index 78c14bf01..ad1d70def 100644 --- a/fullstack-network-manager/src/commands/index.mjs +++ b/fullstack-network-manager/src/commands/index.mjs @@ -1,6 +1,7 @@ import {ClusterCommand} from "./cluster.mjs"; import {InitCommand} from "./init.mjs"; import {ChartCommand} from "./chart.mjs" +import {NodeCommand} from "./node.mjs" /* * Return a list of Yargs command builder to be exposed through CLI @@ -10,11 +11,13 @@ function Initialize(opts) { const initCmd = new InitCommand(opts) const clusterCmd = new ClusterCommand(opts) const chartCmd = new ChartCommand(opts) + const nodeCmd = new NodeCommand(opts) return [ InitCommand.getCommandDefinition(initCmd), ClusterCommand.getCommandDefinition(clusterCmd), ChartCommand.getCommandDefinition(chartCmd), + NodeCommand.getCommandDefinition(nodeCmd), ] } diff --git a/fullstack-network-manager/src/commands/init.mjs b/fullstack-network-manager/src/commands/init.mjs index 36dfac593..b9f53be32 100644 --- a/fullstack-network-manager/src/commands/init.mjs +++ b/fullstack-network-manager/src/commands/init.mjs @@ -1,37 +1,74 @@ import {BaseCommand} from "./base.mjs"; import * as core from "../core/index.mjs" import chalk from "chalk"; +import {constants} from "../core/index.mjs"; +import * as fs from 'fs' +import {FullstackTestingError} from "../core/errors.mjs"; /** * Defines the core functionalities of 'init' command */ export class InitCommand extends BaseCommand { + /** + * Setup home directories + * @param dirs a list of directories that need to be created in sequence + * @returns {Promise} + */ + async setupHomeDirectory(dirs = [ + constants.FST_HOME_DIR, + constants.FST_LOGS_DIR, + constants.FST_CACHE_DIR, + ]) { + const self = this + + try { + dirs.forEach(dirPath => { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath) + } + self.logger.showUser(chalk.green(`OK: setup directory: ${dirPath}`)) + }) + } catch (e) { + this.logger.error(e) + throw new FullstackTestingError(e.message, e) + } + } + /** * Executes the init CLI command * @returns {Promise} */ async init() { - const deps = [ - core.constants.HELM, - core.constants.KIND, - core.constants.KUBECTL, - ] - - const status = await this.checkDependencies(deps) - if (!status) { - return false - } + try { + await this.setupHomeDirectory() - this.logger.showUser(chalk.green("OK: All required dependencies are found: %s"), chalk.yellow(deps)) + const deps = [ + core.constants.HELM, + core.constants.KIND, + core.constants.KUBECTL, + ] - return status + const status = await this.checkDependencies(deps) + if (!status) { + return false + } + + this.logger.showUser(chalk.green("OK: All required dependencies are found: %s"), chalk.yellow(deps)) + + + + return status + } catch (e) { + this.logger.showUserError(e) + return false + } } /** * Return Yargs command definition for 'init' command * @param initCmd an instance of InitCommand */ - static getCommandDefinition(initCmd){ + static getCommandDefinition(initCmd) { return { command: "init", desc: "Perform dependency checks and initialize local environment", diff --git a/fullstack-network-manager/src/commands/node.mjs b/fullstack-network-manager/src/commands/node.mjs new file mode 100644 index 000000000..fc12ef1fc --- /dev/null +++ b/fullstack-network-manager/src/commands/node.mjs @@ -0,0 +1,204 @@ +import {BaseCommand} from "./base.mjs"; +import * as flags from "./flags.mjs"; +import { + DataValidationError, + FullstackTestingError, + IllegalArgumentError, + MissingArgumentError +} from "../core/errors.mjs"; +import {constants, PackageDownloader, Templates} from "../core/index.mjs"; +import chalk from "chalk"; +import * as fs from "fs"; + +/** + * Defines the core functionalities of 'node' command + */ +export class NodeCommand extends BaseCommand { + constructor(opts) { + super(opts); + + if (!opts || !opts.downloader) throw new IllegalArgumentError('An instance of core/PackageDowner is required', opts.downloader) + if (!opts || !opts.platformInstaller) throw new IllegalArgumentError('An instance of core/PlatformInstaller is required', opts.platformInstaller) + + this.downloader = opts.downloader + this.plaformInstaller = opts.platformInstaller + } + + /** + * Check if pods are running or not + * @param namespace + * @param nodeIds + * @param timeout + * @returns {Promise} + */ + async checkNetworkNodePods(namespace, nodeIds = [], timeout = '300s') { + return new Promise(async (resolve, reject) => { + try { + let podNames = [] + if (nodeIds && nodeIds.length > 0) { + for (let nodeId of nodeIds) { + nodeId = nodeId.trim() + const podName = Templates.renderNetworkPodName(nodeId) + + await this.kubectl.wait('pod', + `--for=jsonpath='{.status.phase}'=Running`, + `-l fullstack.hedera.com/type=network-node`, + `-l fullstack.hedera.com/node-name=${nodeId}`, + `--timeout=${timeout}`, + `-n "${namespace}"` + ) + + podNames.push(podName) + } + } else { + nodeIds = [] + let output = await this.kubectl.get('pods', + `-l fullstack.hedera.com/type=network-node`, + '--no-headers', + `-o custom-columns=":metadata.name"`, + `-n "${namespace}"` + ) + output.forEach(podName => { + nodeIds.push(Templates.extractNodeIdFromPodName(podName)) + podNames.push(podName) + }) + } + + resolve({podNames: podNames, nodeIDs: nodeIds}) + } catch (e) { + reject(new FullstackTestingError(`Error on detecting pods for nodes (${nodeIds}): ${e.message}`)) + } + + }) + } + + async setup(argv) { + const self = this + if (!argv.releaseTag && !argv.releaseDir) throw new MissingArgumentError('release-tag or release-dir argument is required') + + const namespace = argv.namespace + const force = argv.force + const releaseTag = argv.releaseTag + const releaseDir = argv.releaseDir + + try { + self.logger.showUser(constants.LOG_GROUP_DIVIDER) + + const releasePrefix = Templates.prepareReleasePrefix(releaseTag) + let buildZipFile = `${releaseDir}/${releasePrefix}/build-${releaseTag}.zip` + const stagingDir = `${releaseDir}/${releasePrefix}/staging/${releaseTag}` + const nodeIDsArg = argv.nodeIds ? argv.nodeIds.split(',') : [] + + fs.mkdirSync(stagingDir, {recursive: true}) + + // pre-check + let {podNames, nodeIDs} = await this.checkNetworkNodePods(namespace, nodeIDsArg) + + // fetch platform build-.zip file + if (force || !fs.existsSync(buildZipFile)) { + self.logger.showUser(chalk.cyan('>>'), `Fetching Platform package 'build-${releaseTag}.zip' from '${constants.HEDERA_BUILDS_URL}' ...`) + buildZipFile = await this.downloader.fetchPlatform(releaseTag, releaseDir) + } else { + self.logger.showUser(chalk.cyan('>>'), `Found Platform package in cache: build-${releaseTag}.zip`) + } + self.logger.showUser(chalk.green('OK'), `Platform package: ${buildZipFile}`) + + // prepare staging + await this.plaformInstaller.prepareStaging(nodeIDs, stagingDir, releaseTag, force) + + // setup + for (const podName of podNames) { + await self.plaformInstaller.install(podName, buildZipFile, stagingDir, force); + } + } catch (e) { + self.logger.showUserError(e) + } + + return false + } + + async start(argv) { + + } + + async stop(argv) { + + } + + /** + * Return Yargs command definition for 'node' command + * @param nodeCmd an instance of NodeCommand + */ + static getCommandDefinition(nodeCmd) { + return { + command: "node", + desc: "Manage a FST node running Hedera platform", + builder: yargs => { + return yargs + .command({ + command: 'setup', + desc: 'Setup node with a specific version of Hedera platform', + builder: yargs => { + yargs.option('namespace', flags.namespaceFlag) + yargs.option('node-ids', flags.nodeIDs) + yargs.option('release-tag', flags.platformReleaseTag) + yargs.option('release-dir', flags.platformReleaseDir) + yargs.option('force', flags.force) + }, + handler: argv => { + nodeCmd.logger.debug("==== Running 'node setup' ===") + nodeCmd.logger.debug(argv) + + nodeCmd.setup(argv).then(r => { + nodeCmd.logger.debug("==== Finished running `node setup`====") + + if (!r) process.exit(1) + }) + + } + }) + .command({ + command: 'start', + desc: 'Start a node running Hedera platform', + builder: yargs => { + yargs.option('node-ids', flags.nodeIDs) + }, + handler: argv => { + console.log("here") + nodeCmd.logger.showUser('here2') + nodeCmd.logger.debug("==== Running 'node start' ===") + nodeCmd.logger.debug(argv) + + nodeCmd.start(argv).then(r => { + nodeCmd.logger.debug("==== Finished running `node start`====") + + if (!r) process.exit(1) + }) + + } + }) + .command({ + command: 'stop', + desc: 'stop a node running Hedera platform', + builder: yargs => { + yargs.option('node-ids', flags.nodeIDs) + }, + handler: argv => { + nodeCmd.logger.debug("==== Running 'node stop' ===") + nodeCmd.logger.debug(argv) + + nodeCmd.stop(argv).then(r => { + nodeCmd.logger.debug("==== Finished running `node stop`====") + + if (!r) process.exit(1) + }) + + } + }) + .demandCommand(1, 'Select a node command') + } + } + } +} + + diff --git a/fullstack-network-manager/src/core/constants.mjs b/fullstack-network-manager/src/core/constants.mjs index 66534324a..56234a69c 100644 --- a/fullstack-network-manager/src/core/constants.mjs +++ b/fullstack-network-manager/src/core/constants.mjs @@ -1,9 +1,13 @@ import {dirname, normalize} from "path" import {fileURLToPath} from "url" +import chalk from "chalk"; // directory of this fle const CUR_FILE_DIR = dirname(fileURLToPath(import.meta.url)) const USER = `${process.env.USER}` +const FST_HOME_DIR = `${process.env.HOME}/.fsnetman` +const HGCAPP_DIR = "/opt/hgcapp" + export const constants = { USER: `${USER}`, CLUSTER_NAME: `fst`, @@ -13,6 +17,21 @@ export const constants = { KIND: 'kind', KUBECTL: 'kubectl', CWD: process.cwd(), - FST_HOME_DIR: process.env.HOME + "/.fsnetman", - RESOURCES_DIR: normalize(CUR_FILE_DIR + "/../../resources") + FST_HOME_DIR: FST_HOME_DIR, + FST_LOGS_DIR: `${FST_HOME_DIR}/logs`, + FST_CACHE_DIR: `${FST_HOME_DIR}/cache`, + RESOURCES_DIR: normalize(CUR_FILE_DIR + "/../../resources"), + HGCAPP_DIR: HGCAPP_DIR, + HGCAPP_SERVICES_HEDERA_PATH: `${HGCAPP_DIR}/services-hedera`, + HAPI_PATH: `${HGCAPP_DIR}/services-hedera/HapiApp2.0`, + ROOT_CONTAINER: 'root-container', + DATA_APPS_DIR: 'data/apps', + DATA_LIB_DIR: 'data/lib', + HEDERA_USER_HOME_DIR: '/home/hedera', + HEDERA_APP_JAR: 'HederaNode.jar', + HEDERA_NODE_DEFAULT_STAKE_AMOUNT: 1, + HEDERA_BUILDS_URL: 'https://builds.hedera.com', + LOG_STATUS_PROGRESS: chalk.cyan('>>'), + LOG_STATUS_DONE: chalk.green('OK'), + LOG_GROUP_DIVIDER: chalk.yellow('----------------------------------------------------------------------------') } diff --git a/fullstack-network-manager/src/core/errors.mjs b/fullstack-network-manager/src/core/errors.mjs index be876db04..5e2d932ba 100644 --- a/fullstack-network-manager/src/core/errors.mjs +++ b/fullstack-network-manager/src/core/errors.mjs @@ -36,6 +36,18 @@ export class ResourceNotFoundError extends FullstackTestingError { } } +export class MissingArgumentError extends FullstackTestingError { + /** + * Create a custom error for missing argument scenario + * + * @param message error message + * @param cause source error (if any) + */ + constructor(message, cause = {}) { + super(message, cause); + } +} + export class IllegalArgumentError extends FullstackTestingError { /** * Create a custom error for illegal argument scenario diff --git a/fullstack-network-manager/src/core/index.mjs b/fullstack-network-manager/src/core/index.mjs index f1c97b607..112810621 100644 --- a/fullstack-network-manager/src/core/index.mjs +++ b/fullstack-network-manager/src/core/index.mjs @@ -4,6 +4,19 @@ import {Kind} from './kind.mjs' import {Helm} from './helm.mjs' import {Kubectl} from "./kubectl.mjs"; import {PackageDownloader} from "./package_downloader.mjs"; +import {PlatformInstaller} from "./platform_installer.mjs"; +import {Zippy} from "./zippy.mjs"; +import {Templates} from "./templates.mjs"; // Expose components from the core module -export {logging, constants, Kind, Helm, Kubectl, PackageDownloader} +export { + logging, + constants, + Kind, + Helm, + Kubectl, + PackageDownloader, + PlatformInstaller, + Zippy, + Templates, +} diff --git a/fullstack-network-manager/src/core/kubectl.mjs b/fullstack-network-manager/src/core/kubectl.mjs index 1ffe5bdb3..689d1e9ea 100644 --- a/fullstack-network-manager/src/core/kubectl.mjs +++ b/fullstack-network-manager/src/core/kubectl.mjs @@ -1,4 +1,5 @@ import {ShellRunner} from "./shell_runner.mjs"; +import {FullstackTestingError} from "./errors.mjs"; export class Kubectl extends ShellRunner { /** @@ -69,4 +70,80 @@ export class Kubectl extends ShellRunner { async getNamespace(...args) { return this.run(this.prepareCommand('get', 'ns', ...args)) } + + /** + * Get pod IP of a pod + * @param podName name of the pod + * @returns {Promise} console output as an array of strings + */ + async getPodIP(podName) { + return new Promise(async (resolve, reject) => { + try { + const output = await this.run(this.prepareCommand('get', 'pod', podName, `-o jsonpath='{.status.podIP}'`)) + if (output) resolve(output[0].trim()) + reject(new FullstackTestingError(`No resource found for ${podName}`)) + } catch (e) { + reject(new FullstackTestingError(`error on detecting IP for pod ${podName}`, e)) + } + }) + } + + /** + * Get cluster IP of a service + * @param svcName name of the service + * @returns {Promise} console output as an array of strings + */ + async getClusterIP(svcName) { + return new Promise(async (resolve, reject) => { + try { + const output = await this.run(this.prepareCommand('get', 'svc', svcName, `-o jsonpath='{.spec.clusterIP}'`)) + if (output) resolve(output[0].trim()) + reject(new FullstackTestingError(`No resource found for ${svcName}`)) + } catch (e) { + reject(new FullstackTestingError(`error on detecting cluster IP for svc ${svcName}`, e)) + } + }) + } + + + /** + * Invoke `kubectl wait` command + * @param resource a kubernetes resource type (e.g. pod | svc etc.) + * @param args args of the command + * @returns {Promise} console output as an array of strings + */ + async wait(resource, ...args) { + return this.run(this.prepareCommand('wait', resource, ...args)) + } + + /** + * Invoke `kubectl exec` command + * @param pod a kubernetes pod name + * @param args args of the command + * @returns {Promise} console output as an array of strings + */ + async exec(pod, ...args) { + return this.run(this.prepareCommand('exec', pod, ...args)) + } + + /** + * Invoke bash command within a container + * @param pod a kubernetes pod name + * @param container name of the container within the pod + * @param bashScript bash script to be run within the container (e.g 'ls -la /opt/hgcapp') + * @returns {Promise} console output as an array of strings + */ + async execContainer(pod, container, bashScript) { + return this.exec(pod, `-c ${container} -- `, `bash -c "${bashScript}"`) + } + + /** + * Invoke `kubectl cp` command + * @param pod a kubernetes pod name + * @param args args of the command + * @returns {Promise} console output as an array of strings + */ + async copy(pod, ...args) { + return this.run(this.prepareCommand('cp', ...args)) + } } \ No newline at end of file diff --git a/fullstack-network-manager/src/core/logging.mjs b/fullstack-network-manager/src/core/logging.mjs index a4270f699..99989cfb6 100644 --- a/fullstack-network-manager/src/core/logging.mjs +++ b/fullstack-network-manager/src/core/logging.mjs @@ -70,7 +70,7 @@ const Logger = class { // - Write all logs with importance level of `error` or less to `error.log` // - Write all logs with importance level of `info` or less to `fst.log` // - new winston.transports.File({filename: `${constants.FST_HOME_DIR}/logs/fst.log`}), + new winston.transports.File({filename: `${constants.FST_LOGS_DIR}/fst.log`}), // new winston.transports.File({filename: constants.TMP_DIR + "/logs/error.log", level: 'error'}), // new winston.transports.Console({format: customFormat}) ], @@ -93,9 +93,26 @@ const Logger = class { showUser(msg, ...args) { console.log(util.format(msg, ...args)) } + showUserError(err) { - console.log(chalk.red(err.message)) + this.error(err.message, err) + + console.log(chalk.red('ERROR: ')) console.log(err.stack) + + if (err.cause) { + console.log(chalk.red('Caused by: ')) + let depth = 0 + let cause = err.cause + while (cause !== undefined && depth < 10) { + if (cause.stack) { + console.log(chalk.red('|-'), cause.stack) + } + + cause = cause.cause + depth += 1 + } + } } critical(msg, ...args) { diff --git a/fullstack-network-manager/src/core/package_downloader.mjs b/fullstack-network-manager/src/core/package_downloader.mjs index 51c93cccc..70851a54d 100644 --- a/fullstack-network-manager/src/core/package_downloader.mjs +++ b/fullstack-network-manager/src/core/package_downloader.mjs @@ -4,6 +4,8 @@ import {pipeline as streamPipeline} from 'node:stream/promises'; import got from 'got'; import {DataValidationError, FullstackTestingError, IllegalArgumentError, ResourceNotFoundError} from "./errors.mjs"; import * as https from "https"; +import {Templates} from "./templates.mjs"; +import {constants} from "./constants.mjs"; export class PackageDownloader { /** @@ -36,8 +38,17 @@ export class PackageDownloader { const req = https.request(url, {method: 'HEAD', timeout: 100, headers: {"Connection": 'close'}}) req.on('response', r => { - self.logger.debug(r.headers) - if (r.statusCode === 200) { + const statusCode = r.statusCode + self.logger.debug({ + response: { + connectOptions: r['connect-options'], + statusCode: r.statusCode, + headers: r.headers, + } + + }) + + if (statusCode === 200) { return resolve(true) } @@ -64,6 +75,8 @@ export class PackageDownloader { * @param destPath destination path for the downloaded file */ async fetchFile(url, destPath) { + const self = this + if (!url) throw new IllegalArgumentError('source file URL is required', url) if (!destPath) throw new IllegalArgumentError('destination path is required', destPath) if (!this.isValidURL(url)) { @@ -82,7 +95,8 @@ export class PackageDownloader { ) resolve(destPath) } catch (e) { - reject(new ResourceNotFoundError(`failed to download file: ${url}`, url, {url, destPath})) + self.logger.error(e) + reject(new ResourceNotFoundError(e.message, url, e)) } }) } @@ -95,8 +109,11 @@ export class PackageDownloader { * @throws Error if the file cannot be read */ async computeFileHash(filePath, algo = 'sha384') { + const self = this + return new Promise((resolve, reject) => { try { + self.logger.debug(`Computing checksum for '${filePath}' using algo '${algo}'`) const checksum = crypto.createHash(algo); const s = fs.createReadStream(filePath) s.on('data', function (d) { @@ -104,10 +121,11 @@ export class PackageDownloader { }); s.on('end', function () { const d = checksum.digest('hex'); + self.logger.debug(`Computed checksum '${d}' for '${filePath}' using algo '${algo}'`) resolve(d) }) } catch (e) { - reject(new FullstackTestingError('failed to compute file hash', e, {filePath, algo})) + reject(new FullstackTestingError('failed to compute checksum', e, {filePath, algo})) } }) } @@ -134,29 +152,47 @@ export class PackageDownloader { * * @param tag full semantic version e.g. v0.40.4 * @param destDir directory where the artifact needs to be saved + * @param force whether to download even if the file exists * @returns {Promise} full path to the downloaded file */ - async fetchPlatform(tag, destDir) { - const parsed = tag.split('.') - if (parsed.length < 3) throw new Error(`tag (${tag}) must include major, minor and patch fields (e.g. v0.40.4)`) + async fetchPlatform(tag, destDir, force = false) { + const self = this + const releaseDir = Templates.prepareReleasePrefix(tag) + if (!destDir) throw new Error('destination directory path is required') + if (!fs.existsSync(destDir)) { + throw new IllegalArgumentError(`destDir (${destDir}) does not exist`, destDir) + } else if(!fs.statSync(destDir).isDirectory()) { + throw new IllegalArgumentError(`destDir (${destDir}) is not a directory`, destDir) + } + + const downloadDir = `${destDir}/${releaseDir}` + const packageURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDir}/build-${tag}.zip` + const packageFile = `${downloadDir}/build-${tag}.zip` + const checksumURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDir}/build-${tag}.sha384` + const checksumPath = `${downloadDir}/build-${tag}.sha384` + return new Promise(async (resolve, reject) => { try { - const releaseDir = `${parsed[0]}.${parsed[1]}` - const packageURL = `https://builds.hedera.com/node/software/${releaseDir}/build-${tag}.zip` - const packagePath = `${destDir}/build-${tag}.zip` - const checksumURL = `https://builds.hedera.com/node/software/${releaseDir}/build-${tag}.sha384` - const checksumPath = `${destDir}/build-${tag}.sha384` + if (fs.existsSync(packageFile) && !force) { + resolve(packageFile) + return + } + + if (!fs.existsSync(downloadDir)) { + fs.mkdirSync(downloadDir, {recursive: true}) + } - await this.fetchFile(packageURL, packagePath) + await this.fetchFile(packageURL, packageFile) await this.fetchFile(checksumURL, checksumPath) const checksum = fs.readFileSync(checksumPath).toString().split(" ")[0] - await this.verifyChecksum(packagePath, checksum) - resolve(packagePath) + await this.verifyChecksum(packageFile, checksum) + resolve(packageFile) } catch (e) { - reject(new FullstackTestingError(`failed to fetch platform artifacts: ${e.message}`, e, {tag, destDir})) + self.logger.error(e) + reject(new FullstackTestingError(e.message, e, {tag, destDir})) } }) } diff --git a/fullstack-network-manager/src/core/platform_installer.mjs b/fullstack-network-manager/src/core/platform_installer.mjs new file mode 100644 index 000000000..e48181a33 --- /dev/null +++ b/fullstack-network-manager/src/core/platform_installer.mjs @@ -0,0 +1,375 @@ +import {DataValidationError, FullstackTestingError, IllegalArgumentError, MissingArgumentError} from "./errors.mjs"; +import chalk from "chalk"; +import * as fs from "fs"; +import {constants} from "./constants.mjs"; +import {Templates} from "./templates.mjs"; +import * as path from "path"; + +/** + * PlatformInstaller install platform code in the root-container of a network pod + */ +export class PlatformInstaller { + + constructor(logger, kubectl) { + if (!logger) throw new MissingArgumentError("an instance of core/Logger is required") + if (!kubectl) throw new MissingArgumentError("an instance of core/Kubectl is required") + + this.logger = logger + this.kubectl = kubectl + } + + async setupHapiDirectories(podName, containerName = constants.ROOT_CONTAINER) { + const self = this + + if (!podName) throw new MissingArgumentError('podName is required') + return new Promise(async (resolve, reject) => { + try { + // reset HAPI_PATH + await this.kubectl.execContainer(podName, containerName, `rm -rf ${constants.HGCAPP_SERVICES_HEDERA_PATH}`) + + const paths = [ + `${constants.HAPI_PATH}/data/keys`, + `${constants.HAPI_PATH}/data/config`, + ] + + for (const p of paths) { + await this.kubectl.execContainer(podName, containerName, `mkdir -p ${p}`) + } + + await this.setPathPermission(podName, constants.HGCAPP_SERVICES_HEDERA_PATH) + + resolve(true) + } catch (e) { + reject(new FullstackTestingError(`failed to setup directories in pod '${podName}' at ${constants.HAPI_PATH}`, e)) + } + }) + } + + async validatePlatformReleaseDir(releaseDir) { + if (!releaseDir) throw new MissingArgumentError('releaseDir is required') + if (!fs.existsSync(releaseDir)) { + throw new IllegalArgumentError('releaseDir does not exists', releaseDir) + } + + const dataDir = `${releaseDir}/data` + const appsDir = `${releaseDir}/${constants.DATA_APPS_DIR}` + const libDir = `${releaseDir}/${constants.DATA_LIB_DIR}` + + if (!fs.existsSync(dataDir)) { + throw new IllegalArgumentError('releaseDir does not have data directory', releaseDir) + } + + if (!fs.existsSync(appsDir)) { + throw new IllegalArgumentError(`'${constants.DATA_APPS_DIR}' missing in '${releaseDir}'`, releaseDir) + } + + if (!fs.existsSync(libDir)) { + throw new IllegalArgumentError(`'${constants.DATA_LIB_DIR}' missing in '${releaseDir}'`, releaseDir) + } + + if (!fs.statSync(`${releaseDir}/data/apps`).isEmpty()) { + throw new IllegalArgumentError(`'${constants.DATA_APPS_DIR}' is empty in releaseDir: ${releaseDir}`, releaseDir) + } + + if (!fs.statSync(`${releaseDir}/data/lib`).isEmpty()) { + throw new IllegalArgumentError(`'${constants.DATA_LIB_DIR}' is empty in releaseDir: ${releaseDir}`, releaseDir) + } + } + + async copyPlatform(podName, buildZipFile) { + const self = this + if (!podName) throw new MissingArgumentError('podName is required') + if (!buildZipFile) throw new MissingArgumentError('buildZipFile is required') + if (!fs.statSync(buildZipFile).isFile()) throw new IllegalArgumentError('buildZipFile does not exists', buildZipFile) + + return new Promise(async (resolve, reject) => { + try { + await this.kubectl.copy(podName, + buildZipFile, + `${podName}:${constants.HEDERA_USER_HOME_DIR}`, + '-c root-container', + ) + + await this.setupHapiDirectories(podName) + await this.kubectl.execContainer(podName, constants.ROOT_CONTAINER, + `cd ${constants.HAPI_PATH} && jar xvf /home/hedera/build-*`) + + resolve(true) + } catch (e) { + resolve(new FullstackTestingError('failed to copy platform code into pods', e)) + } + }) + } + + async copyFiles(podName, srcFiles, destDir, container = constants.ROOT_CONTAINER) { + const self = this + return new Promise(async (resolve, reject) => { + try { + for (const srcPath of srcFiles) { + self.logger.debug(`Copying files into ${podName}: ${srcPath} -> ${destDir}`) + await this.kubectl.copy(podName, + srcPath, + `${podName}:${destDir}`, + `-c ${container}`, + ) + } + + const fileList = await this.kubectl.execContainer(podName, container, `ls ${destDir}`) + + // create full path + const fullPaths = [] + fileList.forEach(filePath => fullPaths.push(`${destDir}/${filePath}`)) + + resolve(fullPaths) + } catch (e) { + reject(new FullstackTestingError(`failed to copy files to pod '${podName}'`, e)) + } + }) + } + + async copyGossipKeys(podName, stagingDir) { + const self = this + + if (!podName) throw new MissingArgumentError('podName is required') + if (!stagingDir) throw new MissingArgumentError('stagingDir is required') + + return new Promise(async (resolve, reject) => { + try { + const keysDir = `${constants.HAPI_PATH}/data/keys` + const nodeId = Templates.extractNodeIdFromPodName(podName) + const srcFiles = [ + `${stagingDir}/templates/node-keys/private-${nodeId}.pfx`, + `${stagingDir}/templates/node-keys/public.pfx`, + ] + + resolve(await self.copyFiles(podName, srcFiles, keysDir)) + } catch (e) { + reject(new FullstackTestingError(`failed to copy gossip keys to pod '${podName}'`, e)) + } + }) + } + + async copyPlatformConfigFiles(podName, stagingDir) { + const self = this + + if (!podName) throw new MissingArgumentError('podName is required') + if (!stagingDir) throw new MissingArgumentError('stagingDir is required') + + return new Promise(async (resolve, reject) => { + try { + const srcFilesSet1 = [ + `${stagingDir}/config.txt`, + `${stagingDir}/templates/log4j2.xml`, + `${stagingDir}/templates/settings.txt`, + ] + + const fileList1 = await self.copyFiles(podName, srcFilesSet1, constants.HAPI_PATH) + + const srcFilesSet2 = [ + `${stagingDir}/templates/properties/api-permission.properties`, + `${stagingDir}/templates/properties/application.properties`, + `${stagingDir}/templates/properties/bootstrap.properties`, + ] + + const fileList2 = await self.copyFiles(podName, srcFilesSet2, `${constants.HAPI_PATH}/data/config`) + + resolve(fileList1.concat(fileList2)) + } catch (e) { + reject(new FullstackTestingError(`failed to copy config files to pod '${podName}'`, e)) + } + }) + } + + async copyTLSKeys(podName, stagingDir) { + const self = this + + if (!podName) throw new MissingArgumentError('podName is required') + if (!stagingDir) throw new MissingArgumentError('stagingDir is required') + + return new Promise(async (resolve, reject) => { + try { + const destDir = constants.HAPI_PATH + const srcFiles = [ + `${stagingDir}/templates/hedera.key`, + `${stagingDir}/templates/hedera.crt`, + ] + + resolve(await self.copyFiles(podName, srcFiles, destDir)) + } catch (e) { + reject(new FullstackTestingError(`failed to copy TLS keys to pod '${podName}'`, e)) + } + }) + } + + async setPathPermission(podName, destPath, mode = '0755', recursive = true, container = constants.ROOT_CONTAINER) { + const self = this + if (!podName) throw new MissingArgumentError('podName is required') + if (!destPath) throw new MissingArgumentError('destPath is required') + + return new Promise(async (resolve, reject) => { + try { + const recursiveFlag = recursive ? '-R' : '' + await this.kubectl.execContainer(podName, container, `chown ${recursiveFlag} hedera:hedera ${destPath}`) + await this.kubectl.execContainer(podName, container, `chmod ${recursiveFlag} ${mode} ${destPath}`) + resolve(true) + } catch (e) { + reject(new FullstackTestingError(`failed to set permission in '${podName}': ${destPath}`, e)) + } + }) + } + + async setPlatformDirPermissions(podName) { + const self = this + if (!podName) throw new MissingArgumentError('podName is required') + + return new Promise(async (resolve, reject) => { + try { + const destPaths = [ + constants.HAPI_PATH + ] + + for (const destPath of destPaths) { + await self.setPathPermission(podName, destPath) + } + + resolve(true) + } catch (e) { + reject(new FullstackTestingError(`failed to set permission in '${podName}': ${destPath}`, e)) + } + }) + } + + /** + * Prepares config.txt file for the node + * @param nodeIDs node IDs + * @param destPath path where config.txt should be written + * @param releaseTag release tag e.g. v0.42.0 + * @param template path to the confit.template file + * @returns {Promise} + */ + async prepareConfigTxt(nodeIDs, destPath, releaseTag, template = `${constants.RESOURCES_DIR}/templates/config.template`) { + const self = this + + if (!nodeIDs || nodeIDs.length === 0) throw new MissingArgumentError('list of node IDs is required') + if (!destPath) throw new MissingArgumentError('destPath is required') + if (!template) throw new MissingArgumentError('config templatePath is required') + if (!releaseTag) throw new MissingArgumentError('release tag is required') + + if (!fs.existsSync(path.dirname(destPath))) throw new IllegalArgumentError(`destPath does not exist: ${destPath}`, destPath) + if (!fs.existsSync(template)) throw new IllegalArgumentError(`config templatePath does not exist: ${template}`, destPath) + + const accountIdPrefix = process.env.FST_NODE_ACCOUNT_ID_PREFIX || '0.0' + const accountIdStart = process.env.FST_NODE_ACCOUNT_ID_START || '3' + const internalPort = process.env.FST_NODE_INTERNAL_GOSSIP_PORT || '50111' + const externalPort = process.env.FST_NODE_EXTERNAL_GOSSIP_PORT || '50111' + const ledgerName = process.env.FST_LEDGER_NAME || constants.CLUSTER_NAME + const appName = process.env.FST_HEDERA_APP_NAME || constants.HEDERA_APP_JAR + const nodeStakeAmount = process.env.FST_NODE_DEFAULT_STAKE_AMOUNT || constants.HEDERA_NODE_DEFAULT_STAKE_AMOUNT + + const releaseTagParts = releaseTag.split(".") + if (releaseTagParts.length !== 3) throw new FullstackTestingError(`release tag must have form v.., found ${releaseTagParts}`, 'v..', releaseTag) + const minorVersion = parseInt(releaseTagParts[1], 10) + + + return new Promise(async (resolve, reject) => { + try { + const configLines = [] + configLines.push(`swirld, ${ledgerName}`) + configLines.push(`app, ${appName}`) + + let nodeSeq = 0 + let accountIdSeq = parseInt(accountIdStart, 10) + for (const nodeId of nodeIDs) { + const podName = Templates.renderNetworkPodName(nodeId) + const svcName = Templates.renderNetworkSvcName(nodeId) + + const nodeName = nodeId + const nodeNickName = nodeId + + const internalIP = await self.kubectl.getPodIP(podName) + const externalIP = await self.kubectl.getClusterIP(svcName) + + const account = `${accountIdPrefix}.${accountIdSeq}` + if (minorVersion >= 40) { + configLines.push(`address, ${nodeSeq}, ${nodeNickName}, ${nodeName}, ${nodeStakeAmount}, ${internalIP}, ${internalPort}, ${externalIP}, ${externalPort}, ${account}`) + } else { + configLines.push(`address, ${nodeSeq}, ${nodeName}, ${nodeStakeAmount}, ${internalIP}, ${internalPort}, ${externalIP}, ${externalPort}, ${account}`) + } + + nodeSeq += 1 + accountIdSeq += 1 + } + + if (minorVersion >= 41) { + configLines.push(`nextNodeId, ${nodeSeq}`) + } + + fs.writeFileSync(destPath, configLines.join("\n")) + + resolve(configLines) + } catch (e) { + reject(new FullstackTestingError('failed to generate config.txt', e)) + } + + }) + } + + async prepareStaging(nodeIDs, stagingDir, releaseTag, force = false) { + const self = this + return new Promise(async (resolve, reject) => { + try { + if (!fs.existsSync(stagingDir)) { + fs.mkdirSync(stagingDir, {recursive: true}) + } + + const configTxtPath = `${stagingDir}/config.txt` + + // copy a templates from fsnetman resources directory + fs.cpSync(`${constants.RESOURCES_DIR}/templates/`, `${stagingDir}/templates`, {recursive: true}) + + // prepare address book + await this.prepareConfigTxt(nodeIDs, configTxtPath, releaseTag, `${stagingDir}/templates/config.template`) + self.logger.showUser(chalk.green('OK'), `Prepared config.txt: ${configTxtPath}`) + + resolve(true) + } catch (e) { + reject(new FullstackTestingError('failed to preparing staging area', e)) + } + }) + } + + async install(podName, buildZipFile, stagingDir, force = false, homeDir = constants.FST_HOME_DIR) { + const self = this + return new Promise(async (resolve, reject) => { + try { + self.logger.showUser(constants.LOG_GROUP_DIVIDER) + self.logger.showUser(chalk.cyan(`Installing platform to ${podName}`)) + + self.logger.showUser(constants.LOG_STATUS_PROGRESS, `[POD=${podName}] Copying platform: ${buildZipFile} ...`) + await this.copyPlatform(podName, buildZipFile) + self.logger.showUser(constants.LOG_STATUS_DONE, `[POD=${podName}] Copied platform into network-node: ${buildZipFile}`) + + self.logger.showUser(constants.LOG_STATUS_PROGRESS, `[POD=${podName}] Copying gossip keys ...`) + await this.copyGossipKeys(podName, stagingDir) + self.logger.showUser(constants.LOG_STATUS_DONE, `[POD=${podName}] Copied gossip keys`) + + self.logger.showUser(constants.LOG_STATUS_PROGRESS, `[POD=${podName}] Copying TLS keys ...`) + await this.copyTLSKeys(podName, stagingDir) + self.logger.showUser(constants.LOG_STATUS_DONE, `[POD=${podName}] Copied TLS keys`) + + self.logger.showUser(constants.LOG_STATUS_PROGRESS, `[POD=${podName}] Copying auxiliary config files ...`) + await this.copyPlatformConfigFiles(podName, stagingDir) + self.logger.showUser(constants.LOG_STATUS_DONE, `[POD=${podName}] Copied auxiliary config keys`) + + self.logger.showUser(constants.LOG_STATUS_PROGRESS, `[POD=${podName}] Setting file permissions ...`) + await this.setPlatformDirPermissions(podName) + self.logger.showUser(constants.LOG_STATUS_DONE, `[POD=${podName}] Set file permissions`) + + resolve(true) + } catch (e) { + self.logger.showUserError(e) + reject(e) + } + }) + } +} \ No newline at end of file diff --git a/fullstack-network-manager/src/core/shell_runner.mjs b/fullstack-network-manager/src/core/shell_runner.mjs index 9e09e8e8e..eb9eb50fb 100644 --- a/fullstack-network-manager/src/core/shell_runner.mjs +++ b/fullstack-network-manager/src/core/shell_runner.mjs @@ -2,9 +2,9 @@ import {spawn} from "child_process"; import chalk from "chalk"; export class ShellRunner { - constructor(opts) { - if (!opts || !opts.logger === undefined) throw new Error("An instance of core/Logger is required") - this.logger = opts.logger + constructor(logger) { + if (!logger ) throw new Error("An instance of core/Logger is required") + this.logger = logger } /** @@ -26,7 +26,7 @@ export class ShellRunner { const items = d.toString().split(/\r?\n/) items.forEach(item => { if (item) { - output.push(item) + output.push(item.trim()) } }) }) @@ -36,7 +36,7 @@ export class ShellRunner { const items = d.toString().split(/\r?\n/) items.forEach(item => { if (item) { - errOutput.push(item) + errOutput.push(item.trim()) } }) }) diff --git a/fullstack-network-manager/src/core/templates.mjs b/fullstack-network-manager/src/core/templates.mjs new file mode 100644 index 000000000..232c1fc94 --- /dev/null +++ b/fullstack-network-manager/src/core/templates.mjs @@ -0,0 +1,24 @@ +import {DataValidationError} from "./errors.mjs"; + +export class Templates { + static renderNetworkPodName(nodeId) { + return `network-${nodeId}-0` + } + + static renderNetworkSvcName(nodeId) { + return `network-${nodeId}-svc` + } + + static extractNodeIdFromPodName(podName) { + const parts = podName.split('-') + if (parts.length !== 3) throw new DataValidationError(`pod name is malformed : ${podName}`, 3, parts.length) + return parts[1].trim() + } + + static prepareReleasePrefix(tag) { + const parsed = tag.split('.') + if (parsed.length < 3) throw new Error(`tag (${tag}) must include major, minor and patch fields (e.g. v0.40.4)`) + return `${parsed[0]}.${parsed[1]}` + } + +} diff --git a/fullstack-network-manager/src/core/zippy.mjs b/fullstack-network-manager/src/core/zippy.mjs new file mode 100644 index 000000000..cc76f3310 --- /dev/null +++ b/fullstack-network-manager/src/core/zippy.mjs @@ -0,0 +1,78 @@ +import {FullstackTestingError, IllegalArgumentError, MissingArgumentError} from "./errors.mjs"; +import fs from "fs"; +import AdmZip from "adm-zip"; +import chalk from "chalk"; +import path from "path"; + +export class Zippy { + constructor(logger) { + if (!logger ) throw new Error("An instance of core/Logger is required") + this.logger = logger + } + + /** + * Zip a file or directory + * @param srcPath path to a file or directory + * @param destPath path to the output zip file + * @returns {Promise} + */ + async zip(srcPath, destPath, verbose = false) { + const self = this + + if (!srcPath) throw new MissingArgumentError('srcPath is required') + if (!destPath) throw new MissingArgumentError('destPath is required') + if (!destPath.endsWith('.zip')) throw new MissingArgumentError('destPath must be a path to a zip file') + + return new Promise(async (resolve, reject) => { + try { + const zip = AdmZip('', {}) + + const stat = fs.statSync(srcPath) + if (stat.isDirectory()) { + zip.addLocalFolder(srcPath, '') + } else { + zip.addFile(path.basename(srcPath), fs.readFileSync(srcPath), '', stat) + } + + await zip.writeZipPromise(destPath, {overwrite: true}) + + resolve(destPath) + } catch (e) { + reject(new FullstackTestingError(`failed to unzip ${srcPath}: ${e.message}`, e)) + } + }) + } + + async unzip(srcPath, destPath, verbose = false) { + const self = this + + if (!srcPath) throw new MissingArgumentError('srcPath is required') + if (!destPath) throw new MissingArgumentError('destPath is required') + + if (!fs.existsSync(srcPath)) throw new IllegalArgumentError('srcPath does not exists', srcPath) + + return new Promise((resolve, reject) => { + try { + const zip = AdmZip(srcPath, {readEntries: true}) + + zip.getEntries().forEach(function (zipEntry) { + if (verbose) { + self.logger.debug(`Extracting file: ${zipEntry.entryName} -> ${destPath}/${zipEntry.entryName} ...`, { + src: zipEntry.entryName, + dst: `${destPath}/${zipEntry.entryName}` + }) + } + + zip.extractEntryTo(zipEntry, destPath, true, true, true, zipEntry.entryName) + if (verbose) { + self.logger.showUser(chalk.green('OK'), `Extracted: ${zipEntry.entryName} -> ${destPath}/${zipEntry.entryName}`) + } + }); + + resolve(destPath) + } catch (e) { + reject(new FullstackTestingError(`failed to unzip ${srcPath}: ${e.message}`, e)) + } + }) + } +} diff --git a/fullstack-network-manager/src/index.mjs b/fullstack-network-manager/src/index.mjs index f13457434..0daae75f5 100644 --- a/fullstack-network-manager/src/index.mjs +++ b/fullstack-network-manager/src/index.mjs @@ -5,15 +5,19 @@ import * as core from './core/index.mjs' export function main(argv) { const logger = core.logging.NewLogger('debug') - const kind = new core.Kind({logger: logger}) - const helm = new core.Helm({logger: logger}) - const kubectl= new core.Kubectl({logger: logger}) + const kind = new core.Kind(logger) + const helm = new core.Helm(logger) + const kubectl= new core.Kubectl(logger) + const downloader = new core.PackageDownloader(logger) + const platformInstaller = new core.PlatformInstaller(logger, kubectl) const opts = { logger: logger, kind: kind, helm: helm, kubectl: kubectl, + downloader: downloader, + platformInstaller: platformInstaller, } logger.debug("Constants: %s", JSON.stringify(core.constants)) diff --git a/fullstack-network-manager/test/data/.empty b/fullstack-network-manager/test/data/.empty new file mode 100644 index 000000000..e69de29bb diff --git a/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js b/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js index cb41fa661..c6189c0ba 100644 --- a/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js +++ b/fullstack-network-manager/test/e2e/core/package_downloader_e2e.test.js @@ -4,23 +4,27 @@ import {PackageDownloader} from "../../../src/core/package_downloader.mjs"; import * as fs from 'fs' import * as path from "path"; import * as os from "os"; +import {Templates} from "../../../src/core/index.mjs"; describe('PackageDownloaderE2E', () => { const testLogger = core.logging.NewLogger('debug') const downloader = new PackageDownloader(testLogger) it('should succeed with a valid Hedera release tag', async () => { - let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); - let tag = 'v0.42.5' - let destPath = `${tmpDir}/build-${tag}.zip` + const tag = 'v0.42.5' + const releasePrefix = Templates.prepareReleasePrefix(tag) + + const destPath = `${tmpDir}/${releasePrefix}/build-${tag}.zip` await expect(downloader.fetchPlatform(tag, tmpDir)).resolves.toBe(destPath) expect(fs.existsSync(destPath)).toBeTruthy() testLogger.showUser(destPath) // remove the downloaded files to reduce disk usage - fs.rmSync(`${tmpDir}/build-${tag}.zip`) - fs.rmSync(`${tmpDir}/build-${tag}.sha384`) + fs.rmSync(`${tmpDir}/v0.42/build-${tag}.zip`) + fs.rmSync(`${tmpDir}/v0.42/build-${tag}.sha384`) + fs.rmdirSync(`${tmpDir}/v0.42`) fs.rmdirSync(tmpDir) }, 100000) }) diff --git a/fullstack-network-manager/test/e2e/core/platform_installer_e2e.test.js b/fullstack-network-manager/test/e2e/core/platform_installer_e2e.test.js new file mode 100644 index 000000000..63ab3783b --- /dev/null +++ b/fullstack-network-manager/test/e2e/core/platform_installer_e2e.test.js @@ -0,0 +1,168 @@ +import {beforeAll, beforeEach, describe, expect, it, test} from "@jest/globals"; +import * as core from "../../../src/core/index.mjs"; +import {PackageDownloader, PlatformInstaller} from "../../../src/core/index.mjs"; +import * as fs from 'fs' +import * as path from "path"; +import * as os from "os"; +import {constants} from "../../../src/core/index.mjs"; +import {MissingArgumentError} from "../../../src/core/errors.mjs"; + +describe('PackageInstallerE2E', () => { + const testLogger = core.logging.NewLogger('debug') + const kubectl = new core.Kubectl(testLogger) + const installer = new PlatformInstaller(testLogger, kubectl) + const downloader = new PackageDownloader(testLogger) + const podName = 'network-node0-0' + const packageTag = 'v0.42.5' + let packageFile = '' + let releaseDir = '' + + describe('setupHapiDirectories', () => { + it('should succeed with valid pod', async () => { + expect.assertions(1) + try { + await expect(installer.setupHapiDirectories(podName)).resolves.toBeTruthy() + } catch (e) { + console.error(e) + expect(e).toBeNull() + } + }) + }) + + describe('copyPlatform', () => { + it('should succeed with valid tag and pod', async () => { + expect.assertions(1) + try { + const tmpDir = 'test/data/tmp' + if(!fs.existsSync(tmpDir)) { + fs.mkdirSync(tmpDir); + } + packageFile = await downloader.fetchPlatform(packageTag, tmpDir) + await expect(installer.copyPlatform(podName, packageFile, true)).resolves.toBeTruthy() + const outputs = await kubectl.execContainer(podName, core.constants.ROOT_CONTAINER, `ls -la ${core.constants.HAPI_PATH}`) + testLogger.showUser(outputs) + } catch (e) { + console.error(e) + expect(e).toBeNull() + } + }) + }) + + describe('prepareConfigTxt', () => { + it('should succeed in generating config.txt', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + const configPath = `${tmpDir}/config.txt` + const nodeIDs = ['node0', 'node1', 'node2'] + const releaseTag = 'v0.42.0' + + const configLines = await installer.prepareConfigTxt(nodeIDs, configPath, releaseTag) + + // verify format is correct + expect(configLines.length).toBe(5) + expect(configLines[0]).toBe(`swirld, ${constants.CLUSTER_NAME}`) + expect(configLines[1]).toBe(`app, ${constants.HEDERA_APP_JAR}`) + expect(configLines[2]).toContain('address, 0, node0, node0, 1') + expect(configLines[3]).toContain('address, 1, node1, node1, 1') + expect(configLines[4]).toContain('address, 2, node2, node2, 1') + + // verify the file exists + expect(fs.existsSync(configPath)).toBeTruthy() + const fileContents = fs.readFileSync(configPath).toString() + + // verify file content matches + expect(fileContents).toBe(configLines.join("\n")) + + fs.rmdirSync(tmpDir, {recursive: true}) + }) + }) + + describe('prepareStaging', () => { + it('should succeed in preparing staging area', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + const configPath = `${tmpDir}/config.txt` + const nodeIDs = ['node0', 'node1', 'node2'] + const releaseTag = 'v0.42.0' + + await expect(installer.prepareStaging(nodeIDs, tmpDir, releaseTag)).resolves.toBeTruthy() + + // verify the config.txt exists + expect(fs.existsSync(configPath)).toBeTruthy() + + // verify copy of local-node data is at staging area + expect(fs.existsSync(`${tmpDir}/templates`)).toBeTruthy() + + fs.rmdirSync(tmpDir, {recursive: true}) + }) + }) + + describe('copyGossipKeys', () => { + it('should succeed to copy gossip keys for node0', async () => { + const stagingDir = `${constants.RESOURCES_DIR}` // just use the resource directory rather than creating staging area + const podName = `network-node0-0` + await installer.setupHapiDirectories(podName) + + const fileList = await installer.copyGossipKeys(podName, stagingDir ) + expect(fileList.length).toBe(2) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/keys/private-node0.pfx`) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/keys/public.pfx`) + }) + + it('should succeed to copy gossip keys for node1', async () => { + const stagingDir = `${constants.RESOURCES_DIR}` // just use the resource directory rather than creating staging area + const podName = `network-node1-0` + await installer.setupHapiDirectories(podName) + + const fileList = await installer.copyGossipKeys(podName, stagingDir ) + expect(fileList.length).toBe(2) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/keys/private-node1.pfx`) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/keys/public.pfx`) + }) + }) + + describe('copyTLSKeys', () => { + it('should succeed to copy TLS keys for node0', async () => { + const stagingDir = `${constants.RESOURCES_DIR}` // just use the resource directory rather than creating staging area + const podName = `network-node0-0` + await installer.setupHapiDirectories(podName) + + const fileList = await installer.copyTLSKeys(podName, stagingDir ) + expect(fileList.length).toBe(3) // [data , hedera.crt, hedera.key] + expect(fileList.length).toBeGreaterThanOrEqual(2) + expect(fileList).toContain(`${constants.HAPI_PATH}/hedera.crt`) + expect(fileList).toContain(`${constants.HAPI_PATH}/hedera.key`) + }) + + it('should succeed to copy TLS keys for node1', async () => { + const stagingDir = `${constants.RESOURCES_DIR}` // just use the resource directory rather than creating staging area + const podName = `network-node1-0` + await installer.setupHapiDirectories(podName) + + const fileList = await installer.copyTLSKeys(podName, stagingDir ) + expect(fileList.length).toBe(3) // [data , hedera.crt, hedera.key] + expect(fileList).toContain(`${constants.HAPI_PATH}/hedera.crt`) + expect(fileList).toContain(`${constants.HAPI_PATH}/hedera.key`) + }) + }) + + describe('copyPlatformConfigFiles', () => { + it('should succeed to copy platform config files for node0', async () => { + const podName = `network-node0-0` + await installer.setupHapiDirectories(podName) + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + const nodeIDs = ['node0'] + const releaseTag = 'v0.42.0' + await installer.prepareStaging(nodeIDs, tmpDir, releaseTag) + + const fileList = await installer.copyPlatformConfigFiles(podName, tmpDir) + expect(fileList.length).toBeGreaterThanOrEqual(6) + expect(fileList).toContain(`${constants.HAPI_PATH}/config.txt`) + expect(fileList).toContain(`${constants.HAPI_PATH}/log4j2.xml`) + expect(fileList).toContain(`${constants.HAPI_PATH}/settings.txt`) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/config/api-permission.properties`) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/config/application.properties`) + expect(fileList).toContain(`${constants.HAPI_PATH}/data/config/bootstrap.properties`) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + }) +}) diff --git a/fullstack-network-manager/test/unit/commands/base.test.js b/fullstack-network-manager/test/unit/commands/base.test.js index 1ecb110f8..b791a0c77 100644 --- a/fullstack-network-manager/test/unit/commands/base.test.js +++ b/fullstack-network-manager/test/unit/commands/base.test.js @@ -7,9 +7,9 @@ import {Kind} from "../../../src/core/kind.mjs"; const testLogger = logging.NewLogger("debug") describe('BaseCommand', () => { - const kind = new Kind({logger: testLogger}) - const helm = new Helm({logger: testLogger}) - const kubectl = new Kubectl({logger: testLogger}) + const kind = new Kind(testLogger) + const helm = new Helm(testLogger) + const kubectl = new Kubectl(testLogger) const baseCmd = new BaseCommand({ logger: testLogger, kind: kind, diff --git a/fullstack-network-manager/test/unit/core/package_downloader.test.js b/fullstack-network-manager/test/unit/core/package_downloader.test.js index 7e529badf..49b93f488 100644 --- a/fullstack-network-manager/test/unit/core/package_downloader.test.js +++ b/fullstack-network-manager/test/unit/core/package_downloader.test.js @@ -96,7 +96,9 @@ describe('PackageDownloader', () => { let tag = 'v0.40.0-INVALID' try { - await downloader.fetchPlatform(tag, os.tmpdir()) + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); + await downloader.fetchPlatform(tag, tmpDir) + fs.rmdirSync(tmpDir, {recursive: true}) } catch (e) { expect(e.cause).not.toBeNull() expect(e.cause).toBeInstanceOf(ResourceNotFoundError) @@ -107,7 +109,9 @@ describe('PackageDownloader', () => { expect.assertions(1) try { + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'downloader-')); await downloader.fetchPlatform('INVALID', os.tmpdir()) + fs.rmdirSync(tmpDir, {recursive: true}) } catch (e) { expect(e.message).toContain('must include major, minor and patch fields') } diff --git a/fullstack-network-manager/test/unit/core/platform_installer.test.js b/fullstack-network-manager/test/unit/core/platform_installer.test.js new file mode 100644 index 000000000..2fa27f9be --- /dev/null +++ b/fullstack-network-manager/test/unit/core/platform_installer.test.js @@ -0,0 +1,118 @@ +import {describe, expect, it, test} from "@jest/globals"; +import * as core from "../../../src/core/index.mjs"; +import {PlatformInstaller} from "../../../src/core/index.mjs"; +import * as fs from 'fs' +import * as path from "path"; +import * as os from "os"; +import { + IllegalArgumentError, + MissingArgumentError, +} from "../../../src/core/errors.mjs"; +describe('PackageInstaller', () => { + const testLogger = core.logging.NewLogger('debug') + const kubectl = new core.Kubectl(testLogger) + const installer = new PlatformInstaller(testLogger, kubectl) + + describe('validatePlatformReleaseDir', () => { + it('should fail for missing path', async () => { + expect.assertions(1) + await expect(installer.validatePlatformReleaseDir('')).rejects.toThrow(MissingArgumentError) + }) + + it('should fail for invalid path', async () => { + expect.assertions(1) + await expect(installer.validatePlatformReleaseDir('/INVALID')).rejects.toThrow(IllegalArgumentError) + }) + + it('should fail if directory does not have data/apps directory', async () => { + expect.assertions(1) + + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}`, {recursive: true}) + await expect(installer.validatePlatformReleaseDir(tmpDir)).rejects.toThrow(IllegalArgumentError) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + + it('should fail if directory does not have data/libs directory', async () => { + expect.assertions(1) + + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}`, {recursive: true}) + await expect(installer.validatePlatformReleaseDir(tmpDir)).rejects.toThrow(IllegalArgumentError) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + + it('should fail if directory does not have data/app directory is empty', async () => { + expect.assertions(1) + + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}`, {recursive: true}) + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}`, {recursive: true}) + fs.writeFileSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}/test.jar`, '') + await expect(installer.validatePlatformReleaseDir()).rejects.toThrow(MissingArgumentError) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + + it('should fail if directory does not have data/libs directory is empty', async () => { + expect.assertions(1) + + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}`, {recursive: true}) + fs.writeFileSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}/app.jar`, '') + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}`, {recursive: true}) + await expect(installer.validatePlatformReleaseDir()).rejects.toThrow(MissingArgumentError) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + + it('should succeed with non-empty data/apps and data/libs directory', async () => { + expect.assertions(1) + + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}`, {recursive: true}) + fs.writeFileSync(`${tmpDir}/${core.constants.DATA_APPS_DIR}/app.jar`, '') + fs.mkdirSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}`, {recursive: true}) + fs.writeFileSync(`${tmpDir}/${core.constants.DATA_LIB_DIR}/lib-1.jar`, '') + await expect(installer.validatePlatformReleaseDir()).rejects.toThrow(MissingArgumentError) + fs.rmdirSync(tmpDir, {recursive: true}) + }) + }) + + describe('extractPlatform', () => { + it('should fail for missing pod name', async () => { + expect.assertions(1) + await expect(installer.copyPlatform('', os.tmpdir())).rejects.toThrow(MissingArgumentError) + }) + it('should fail for missing buildZipFile path', async () => { + expect.assertions(1) + await expect(installer.copyPlatform('network-node0-0', '' )).rejects.toThrow(MissingArgumentError) + }) + }) + + describe('prepareConfigTxt', () => { + it('should fail for missing nodeIDs', async () => { + await expect(installer.prepareConfigTxt([],'./test', '0.42.0' )).rejects.toThrow(MissingArgumentError) + }) + + it('should fail for missing destPath', async () => { + await expect(installer.prepareConfigTxt(['node0'],'', '0.42.0' )).rejects.toThrow(MissingArgumentError) + }) + + it('should fail for missing release tag', async () => { + await expect(installer.prepareConfigTxt(['node0'],`${os.tmpdir()}/config.txt`, '' )).rejects.toThrow(MissingArgumentError) + }) + + it('should fail for invalid destPath', async () => { + await expect(installer.prepareConfigTxt(['node0'],'/INVALID/config.txt', '0.42.0' )).rejects.toThrow(IllegalArgumentError) + }) + }) + + describe('copyGossipKeys', () => { + it('should fail for missing podName', async () => { + await expect(installer.copyGossipKeys('', os.tmpdir())).rejects.toThrow(MissingArgumentError) + }) + + it('should fail for missing stagingDir path', async () => { + await expect(installer.copyGossipKeys('network-node0-0', '')).rejects.toThrow(MissingArgumentError) + }) + }) +}) diff --git a/fullstack-network-manager/test/unit/core/zippy.test.js b/fullstack-network-manager/test/unit/core/zippy.test.js new file mode 100644 index 000000000..0d081a906 --- /dev/null +++ b/fullstack-network-manager/test/unit/core/zippy.test.js @@ -0,0 +1,47 @@ +import {describe, expect, it, test} from "@jest/globals"; +import * as core from "../../../src/core/index.mjs"; +import {FullstackTestingError, IllegalArgumentError, MissingArgumentError} from "../../../src/core/errors.mjs"; +import os from "os"; +import fs from "fs"; +import path from "path"; +import {Zippy} from "../../../src/core/zippy.mjs"; +describe('Zippy', () => { + const testLogger = core.logging.NewLogger('debug') + const zippy = new Zippy(testLogger) + + describe('unzip', () => { + it('should fail if source file is missing', async () => { + expect.assertions(1) + await expect(zippy.unzip('', '')).rejects.toThrow(MissingArgumentError) + }) + + it('should fail if destination file is missing', async () => { + expect.assertions(1) + await expect(zippy.unzip('', '')).rejects.toThrow(MissingArgumentError) + }) + + it('should fail if source file is invalid', async () => { + expect.assertions(1) + await expect(zippy.unzip('/INVALID', os.tmpdir())).rejects.toThrow(IllegalArgumentError) + }) + + it('should fail for a directory', async () => { + expect.assertions(1) + await expect(zippy.unzip('test/data', os.tmpdir())).rejects.toThrow(FullstackTestingError) + }) + + it('should fail for a non-zip file', async () => { + expect.assertions(1) + await expect(zippy.unzip('test/data/test.txt', os.tmpdir())).rejects.toThrow(FullstackTestingError) + }) + + it('should succeed for valid inputs', async () => { + let tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'installer-')); + const zipFile = `${tmpDir}/test.zip` + const unzippedFile = `${tmpDir}/unzipped` + await expect(zippy.zip('test/data/.empty', zipFile)).resolves.toBe(zipFile) + await expect(zippy.unzip(zipFile, unzippedFile, true)).resolves.toBe(unzippedFile) + fs.rmSync(tmpDir, {recursive: true, force: true}); // not very safe! + }) + }) +})