From 9b179372fb61e865643d5925c46688299cd6bf28 Mon Sep 17 00:00:00 2001 From: Ivo Yankov Date: Thu, 3 Oct 2024 14:28:12 +0300 Subject: [PATCH] feat: Separate network upgrade and freeze from node update/add/delete command (#628) Signed-off-by: Ivo Yankov --- .github/workflows/autogen/README.md | 7 +- .github/workflows/flow-build-application.yaml | 16 + .../workflows/flow-pull-request-checks.yaml | 19 + .github/workflows/templates/config.yaml | 2 + .github/workflows/zxc-code-analysis.yaml | 17 + .github/workflows/zxc-env-vars.yaml | 8 + README.md | 10 +- package.json | 1 + src/commands/account.mjs | 7 +- src/commands/cluster.mjs | 7 +- src/commands/index.mjs | 14 +- src/commands/init.mjs | 7 +- src/commands/mirror_node.mjs | 7 +- src/commands/network.mjs | 7 +- src/commands/node.mjs | 541 ++++-------------- src/commands/node/configs.mjs | 85 +++ src/commands/node/flags.mjs | 24 + src/commands/node/tasks.mjs | 373 ++++++++++++ src/commands/relay.mjs | 7 +- src/core/helpers.mjs | 36 ++ src/core/index.mjs | 6 +- src/core/task.mjs | 27 + src/core/yargs_command.mjs | 70 +++ test/e2e/commands/cluster.test.mjs | 2 +- test/e2e/commands/network.test.mjs | 2 +- test/e2e/commands/node_upgrade.test.mjs | 79 +++ test/e2e/commands/separate_node_add.test.mjs | 2 +- test/e2e/e2e_node_util.js | 2 +- test/test_add.mjs | 2 +- test/test_util.js | 2 +- test/unit/commands/init.test.mjs | 4 +- 31 files changed, 927 insertions(+), 466 deletions(-) create mode 100644 src/commands/node/configs.mjs create mode 100644 src/commands/node/flags.mjs create mode 100644 src/commands/node/tasks.mjs create mode 100644 src/core/task.mjs create mode 100644 src/core/yargs_command.mjs create mode 100644 test/e2e/commands/node_upgrade.test.mjs diff --git a/.github/workflows/autogen/README.md b/.github/workflows/autogen/README.md index c75fe5a1b..1d5232baf 100644 --- a/.github/workflows/autogen/README.md +++ b/.github/workflows/autogen/README.md @@ -7,6 +7,7 @@ The Solo autogen tool is used to add e2e test cases that need to be ran independ ## Usage from solo root directory: + ```bash cd .github/workflows/autogen npm install @@ -16,17 +17,20 @@ npm run autogen Use git to detect file changes and validate that they are correct. The templates need to be maintained, you can either make changes directly to the templates and then run the tool, or make changes in both the workflow yaml files and the templates. Should the templates fall out of sync, then you can update the templates so that when autogen runs again, the git diff will better match. + ```bash template.flow-build-application.yaml template.flow-pull-request-checks.yaml template.zxc-code-analysis.yaml template.zxc-env-vars.yaml - ``` +``` + For new e2e test jobs update the `/.github/workflows/templates/config.yaml`, adding a new item to the tests object with a name and jestPostfix attribute. NOTE: IntelliJ copy/paste will alter the escape sequences, you might have to manually type it in, clone a line, or use an external text editor. e.g.: + ```yaml - name: Mirror Node jestPostfix: --testRegex=\".*\\/e2e\\/commands\\/mirror_node\\.test\\.mjs\" @@ -36,6 +40,7 @@ e.g.: ## Development To run lint fix: + ```bash cd .github/workflows/autogen eslint --fix . diff --git a/.github/workflows/flow-build-application.yaml b/.github/workflows/flow-build-application.yaml index f1bc5fd58..dc92c1208 100644 --- a/.github/workflows/flow-build-application.yaml +++ b/.github/workflows/flow-build-application.yaml @@ -194,6 +194,19 @@ jobs: coverage-subdirectory: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-test-subdir }} coverage-report-name: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-coverage-report }} + e2e-node-upgrade-tests: + name: E2E Tests + if: ${{ github.event_name == 'push' || github.event.inputs.enable-e2e-tests == 'true' }} + uses: ./.github/workflows/zxc-e2e-test.yaml + needs: + - env-vars + - code-style + with: + custom-job-label: Node Upgrade + npm-test-script: test-${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} + coverage-subdirectory: ${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} + coverage-report-name: ${{ needs.env-vars.outputs.e2e-node-upgrade-coverage-report }} + e2e-relay-tests: name: E2E Tests if: ${{ github.event_name == 'push' || github.event.inputs.enable-e2e-tests == 'true' }} @@ -223,6 +236,7 @@ jobs: - e2e-node-update-tests - e2e-node-delete-tests - e2e-node-delete-separate-commands-tests + - e2e-node-upgrade-tests - e2e-relay-tests if: ${{ (github.event_name == 'push' || github.event.inputs.enable-unit-tests == 'true' || github.event.inputs.enable-e2e-tests == 'true') && !failure() && !cancelled() }} with: @@ -241,6 +255,7 @@ jobs: e2e-node-update-test-subdir: ${{ needs.env-vars.outputs.e2e-node-update-test-subdir }} e2e-node-delete-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-test-subdir }} e2e-node-delete-separate-commands-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-test-subdir }} + e2e-node-upgrade-test-subdir: ${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} e2e-relay-test-subdir: ${{ needs.env-vars.outputs.e2e-relay-test-subdir }} e2e-standard-coverage-report: ${{ needs.env-vars.outputs.e2e-standard-coverage-report }} e2e-mirror-node-coverage-report: ${{ needs.env-vars.outputs.e2e-mirror-node-coverage-report }} @@ -252,6 +267,7 @@ jobs: e2e-node-update-coverage-report: ${{ needs.env-vars.outputs.e2e-node-update-coverage-report }} e2e-node-delete-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-coverage-report }} e2e-node-delete-separate-commands-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-coverage-report }} + e2e-node-upgrade-coverage-report: ${{ needs.env-vars.outputs.e2e-node-upgrade-coverage-report }} e2e-relay-coverage-report: ${{ needs.env-vars.outputs.e2e-relay-coverage-report }} secrets: snyk-token: ${{ secrets.SNYK_TOKEN }} diff --git a/.github/workflows/flow-pull-request-checks.yaml b/.github/workflows/flow-pull-request-checks.yaml index 5987be835..b8e885038 100644 --- a/.github/workflows/flow-pull-request-checks.yaml +++ b/.github/workflows/flow-pull-request-checks.yaml @@ -192,6 +192,19 @@ jobs: coverage-subdirectory: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-test-subdir }} coverage-report-name: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-coverage-report }} + e2e-node-upgrade-tests: + name: E2E Tests + if: ${{ !cancelled() && always() }} + uses: ./.github/workflows/zxc-e2e-test.yaml + needs: + - env-vars + - code-style + with: + custom-job-label: Node Upgrade + npm-test-script: test-${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} + coverage-subdirectory: ${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} + coverage-report-name: ${{ needs.env-vars.outputs.e2e-node-upgrade-coverage-report }} + e2e-relay-tests: name: E2E Tests if: ${{ !cancelled() && always() }} @@ -221,6 +234,7 @@ jobs: - e2e-node-update-tests - e2e-node-delete-tests - e2e-node-delete-separate-commands-tests + - e2e-node-upgrade-tests - e2e-relay-tests if: ${{ github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }} with: @@ -237,6 +251,7 @@ jobs: e2e-node-update-test-subdir: ${{ needs.env-vars.outputs.e2e-node-update-test-subdir }} e2e-node-delete-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-test-subdir }} e2e-node-delete-separate-commands-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-test-subdir }} + e2e-node-upgrade-test-subdir: ${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} e2e-relay-test-subdir: ${{ needs.env-vars.outputs.e2e-relay-test-subdir }} e2e-standard-coverage-report: ${{ needs.env-vars.outputs.e2e-standard-coverage-report }} e2e-mirror-node-coverage-report: ${{ needs.env-vars.outputs.e2e-mirror-node-coverage-report }} @@ -248,6 +263,7 @@ jobs: e2e-node-update-coverage-report: ${{ needs.env-vars.outputs.e2e-node-update-coverage-report }} e2e-node-delete-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-coverage-report }} e2e-node-delete-separate-commands-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-coverage-report }} + e2e-node-upgrade-coverage-report: ${{ needs.env-vars.outputs.e2e-node-upgrade-coverage-report }} e2e-relay-coverage-report: ${{ needs.env-vars.outputs.e2e-relay-coverage-report }} secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} @@ -268,6 +284,7 @@ jobs: - e2e-node-update-tests - e2e-node-delete-tests - e2e-node-delete-separate-commands-tests + - e2e-node-upgrade-tests - e2e-relay-tests if: ${{ github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name }} with: @@ -284,6 +301,7 @@ jobs: e2e-node-update-test-subdir: ${{ needs.env-vars.outputs.e2e-node-update-test-subdir }} e2e-node-delete-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-test-subdir }} e2e-node-delete-separate-commands-test-subdir: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-test-subdir }} + e2e-node-upgrade-test-subdir: ${{ needs.env-vars.outputs.e2e-node-upgrade-test-subdir }} e2e-relay-test-subdir: ${{ needs.env-vars.outputs.e2e-relay-test-subdir }} e2e-standard-coverage-report: ${{ needs.env-vars.outputs.e2e-standard-coverage-report }} e2e-mirror-node-coverage-report: ${{ needs.env-vars.outputs.e2e-mirror-node-coverage-report }} @@ -295,6 +313,7 @@ jobs: e2e-node-update-coverage-report: ${{ needs.env-vars.outputs.e2e-node-update-coverage-report }} e2e-node-delete-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-coverage-report }} e2e-node-delete-separate-commands-coverage-report: ${{ needs.env-vars.outputs.e2e-node-delete-separate-commands-coverage-report }} + e2e-node-upgrade-coverage-report: ${{ needs.env-vars.outputs.e2e-node-upgrade-coverage-report }} e2e-relay-coverage-report: ${{ needs.env-vars.outputs.e2e-relay-coverage-report }} secrets: codacy-project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} diff --git a/.github/workflows/templates/config.yaml b/.github/workflows/templates/config.yaml index 1cca0b9c3..988a81063 100644 --- a/.github/workflows/templates/config.yaml +++ b/.github/workflows/templates/config.yaml @@ -23,5 +23,7 @@ tests: jestPostfix: --testRegex=\".*\\/e2e\\/commands\\/node_delete.*\\.test\\.mjs\" - name: Node Delete - Separate commands jestPostfix: --testRegex=\".*\\/e2e\\/commands\\/separate_node_delete.*\\.test\\.mjs\" + - name: Node Upgrade + jestPostfix: --testRegex=\".*\\/e2e\\/commands\\/node_upgrade.*\\.test\\.mjs\" - name: Relay jestPostfix: --testRegex=\".*\\/e2e\\/commands\\/relay\\.test\\.mjs\" diff --git a/.github/workflows/zxc-code-analysis.yaml b/.github/workflows/zxc-code-analysis.yaml index 9a73b59c6..e2a276bd4 100644 --- a/.github/workflows/zxc-code-analysis.yaml +++ b/.github/workflows/zxc-code-analysis.yaml @@ -105,6 +105,11 @@ on: type: string required: false default: "e2e-node-delete-separate-commands" + e2e-node-upgrade-test-subdir: + description: "E2E Node Upgrade Test Subdirectory:" + type: string + required: false + default: "e2e-node-upgrade" e2e-relay-test-subdir: description: "E2E Relay Test Subdirectory:" type: string @@ -160,6 +165,11 @@ on: type: string required: false default: "E2E Node Delete - Separate commands Tests Coverage Report" + e2e-node-upgrade-coverage-report: + description: "E2E Node Upgrade Coverage Report:" + type: string + required: false + default: "E2E Node Upgrade Tests Coverage Report" e2e-relay-coverage-report: description: "E2E Relay Coverage Report:" type: string @@ -285,6 +295,13 @@ jobs: name: ${{ inputs.e2e-node-delete-separate-commands-coverage-report }} path: 'coverage/${{ inputs.e2e-node-delete-separate-commands-test-subdir }}' + - name: Download E2E Node Upgrade Coverage Report + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + if: ${{ (inputs.enable-codecov-analysis || inputs.enable-codacy-coverage) && inputs.enable-e2e-coverage-report && !cancelled() && !failure() }} + with: + name: ${{ inputs.e2e-node-upgrade-coverage-report }} + path: 'coverage/${{ inputs.e2e-node-upgrade-test-subdir }}' + - name: Download E2E Relay Coverage Report uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 if: ${{ (inputs.enable-codecov-analysis || inputs.enable-codacy-coverage) && inputs.enable-e2e-coverage-report && !cancelled() && !failure() }} diff --git a/.github/workflows/zxc-env-vars.yaml b/.github/workflows/zxc-env-vars.yaml index 3e9fee003..460fe32eb 100644 --- a/.github/workflows/zxc-env-vars.yaml +++ b/.github/workflows/zxc-env-vars.yaml @@ -56,6 +56,9 @@ on: e2e-node-delete-separate-commands-test-subdir: description: "E2E Node Delete - Separate commands Test Subdirectory" value: ${{ jobs.env-vars.outputs.e2e_node_delete_separate_commands_test_subdir }} + e2e-node-upgrade-test-subdir: + description: "E2E Node Upgrade Test Subdirectory" + value: ${{ jobs.env-vars.outputs.e2e_node_upgrade_test_subdir }} e2e-relay-test-subdir: description: "E2E Relay Test Subdirectory" value: ${{ jobs.env-vars.outputs.e2e_relay_test_subdir }} @@ -89,6 +92,9 @@ on: e2e-node-delete-separate-commands-coverage-report: description: "E2E Node Delete - Separate commands Tests Coverage Report" value: ${{ jobs.env-vars.outputs.e2e_node_delete_separate_commands_coverage_report }} + e2e-node-upgrade-coverage-report: + description: "E2E Node Upgrade Tests Coverage Report" + value: ${{ jobs.env-vars.outputs.e2e_node_upgrade_coverage_report }} e2e-relay-coverage-report: description: "E2E Relay Tests Coverage Report" value: ${{ jobs.env-vars.outputs.e2e_relay_coverage_report }} @@ -112,6 +118,7 @@ jobs: e2e_node_update_test_subdir: e2e-node-update e2e_node_delete_test_subdir: e2e-node-delete e2e_node_delete_separate_commands_test_subdir: e2e-node-delete-separate-commands + e2e_node_upgrade_test_subdir: e2e-node-upgrade e2e_relay_test_subdir: e2e-relay e2e_standard_coverage_report: "E2E Standard Tests Coverage Report" e2e_mirror_node_coverage_report: "E2E Mirror Node Tests Coverage Report" @@ -123,6 +130,7 @@ jobs: e2e_node_update_coverage_report: "E2E Node Update Tests Coverage Report" e2e_node_delete_coverage_report: "E2E Node Delete Tests Coverage Report" e2e_node_delete_separate_commands_coverage_report: "E2E Node Delete - Separate commands Tests Coverage Report" + e2e_node_upgrade_coverage_report: "E2E Node Upgrade Tests Coverage Report" e2e_relay_coverage_report: "E2E Relay Tests Coverage Report" steps: - run: echo "Exposing environment variables to reusable workflows" diff --git a/README.md b/README.md index babfe25be..7b060a63a 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Then run the following command to set the kubectl context to the new cluster: ```bash kind create cluster -n "${SOLO_CLUSTER_NAME}" ``` + Example output ``` @@ -96,7 +97,6 @@ Have a nice day! 👋 You may now view pods in your cluster using `k9s -A` as below: - ``` Context: kind-solo <0> all Attach Delete | |/ _/ __ \______ @@ -187,13 +187,16 @@ Kubernetes Namespace : solo ✔ Generate gRPC TLS keys ✔ Finalize ``` + PEM key files are generated in `~/.solo/keys` directory. + ``` hedera-node1.crt hedera-node3.crt s-private-node1.pem s-public-node1.pem unused-gossip-pem hedera-node1.key hedera-node3.key s-private-node2.pem s-public-node2.pem unused-tls hedera-node2.crt hedera-node4.crt s-private-node3.pem s-public-node3.pem hedera-node2.key hedera-node4.key s-private-node4.pem s-public-node4.pem ``` + * Setup cluster with shared components ``` @@ -475,6 +478,7 @@ To set customized `settings.txt` file, edit the file `~/.solo/cache/templates/settings.txt` after `solo init` command. Then you can start customized built hedera network with the following command: + ``` solo node setup --local-build-path ,node1=,node2= @@ -484,12 +488,15 @@ solo node setup --local-build-path ,node1=,node1=,node2= --app PlatformTestingTool.jar --app-config # example: solo node setup --local-build-path ../hedera-services/platform-sdk/sdk/data,node1=../hedera-services/platform-sdk/sdk/data,node2=../hedera-services/platform-sdk/sdk/data --app PlatformTestingTool.jar --app-config ../hedera-services/platform-sdk/platform-apps/tests/PlatformTestingTool/src/main/resources/FCMFCQ-Basic-2.5k-5m.json ``` + ## Logs + You can find log for running solo command under the directory `~/.solo/logs/` The file `solo.log` contains the logs for the solo command. The file `hashgraph-sdk.log` contains the logs from Solo client when sending transactions to network nodes. @@ -499,6 +506,7 @@ The file `hashgraph-sdk.log` contains the logs from Solo client when sending tra NOTE: the hedera-services path referenced '../hedera-services/hedera-node/data' may need to be updated based on what directory you are currently in. This also assumes that you have done an assemble/build and the directory contents are up-to-date. Example 1: attach jvm debugger to a hedera node + ```bash ./test/e2e/setup-e2e.sh solo node keys --gossip-keys --tls-keys diff --git a/package.json b/package.json index 6b98bd795..181060679 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test-e2e-node-update": "NODE_OPTIONS=--experimental-vm-modules JEST_SUITE_NAME='Jest E2E Node Update Tests' JEST_JUNIT_OUTPUT_NAME='junit-e2e-node-update.xml' jest --runInBand --detectOpenHandles --forceExit --coverage --coverageDirectory='coverage/e2e-node-update' --testRegex=\".*\\/e2e\\/commands\\/node_update.*\\.test\\.mjs\"", "test-e2e-node-delete": "NODE_OPTIONS=--experimental-vm-modules JEST_SUITE_NAME='Jest E2E Node Delete Tests' JEST_JUNIT_OUTPUT_NAME='junit-e2e-node-delete.xml' jest --runInBand --detectOpenHandles --forceExit --coverage --coverageDirectory='coverage/e2e-node-delete' --testRegex=\".*\\/e2e\\/commands\\/node_delete.*\\.test\\.mjs\"", "test-e2e-node-delete-separate-commands": "NODE_OPTIONS=--experimental-vm-modules JEST_SUITE_NAME='Jest E2E Node Delete - Separate commands Tests' JEST_JUNIT_OUTPUT_NAME='junit-e2e-node-delete-separate-commands.xml' jest --runInBand --detectOpenHandles --forceExit --coverage --coverageDirectory='coverage/e2e-node-delete-separate-commands' --testRegex=\".*\\/e2e\\/commands\\/separate_node_delete.*\\.test\\.mjs\"", + "test-e2e-node-upgrade": "NODE_OPTIONS=--experimental-vm-modules JEST_SUITE_NAME='Jest E2E Node Upgrade Tests' JEST_JUNIT_OUTPUT_NAME='junit-e2e-node-upgrade.xml' jest --runInBand --detectOpenHandles --forceExit --coverage --coverageDirectory='coverage/e2e-node-upgrade' --testRegex=\".*\\/e2e\\/commands\\/node_upgrade.*\\.test\\.mjs\"", "test-e2e-relay": "NODE_OPTIONS=--experimental-vm-modules JEST_SUITE_NAME='Jest E2E Relay Tests' JEST_JUNIT_OUTPUT_NAME='junit-e2e-relay.xml' jest --runInBand --detectOpenHandles --forceExit --coverage --coverageDirectory='coverage/e2e-relay' --testRegex=\".*\\/e2e\\/commands\\/relay\\.test\\.mjs\"", "merge-clean": "rm -rf .nyc_output && mkdir .nyc_output && rm -rf coverage/lcov-report && rm -rf coverage/solo && rm coverage/*.*", "merge-e2e": "nyc merge ./coverage/e2e/ .nyc_output/coverage.json", diff --git a/src/commands/account.mjs b/src/commands/account.mjs index 62a28f785..6c6c07b7a 100644 --- a/src/commands/account.mjs +++ b/src/commands/account.mjs @@ -453,13 +453,10 @@ export class AccountCommand extends BaseCommand { /** * Return Yargs command definition for 'node' command - * @param {AccountCommand} accountCmd an instance of NodeCommand * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (accountCmd) { - if (!accountCmd || !(accountCmd instanceof AccountCommand)) { - throw new IllegalArgumentError('An instance of AccountCommand is required', accountCmd) - } + getCommandDefinition () { + const accountCmd = this return { command: 'account', desc: 'Manage Hedera accounts in solo network', diff --git a/src/commands/cluster.mjs b/src/commands/cluster.mjs index 0d6ea41f7..d270b0e34 100644 --- a/src/commands/cluster.mjs +++ b/src/commands/cluster.mjs @@ -222,13 +222,10 @@ export class ClusterCommand extends BaseCommand { /** * Return Yargs command definition for 'cluster' command - * @param {ClusterCommand} clusterCmd - an instance of ClusterCommand * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (clusterCmd) { - if (!clusterCmd || !(clusterCmd instanceof ClusterCommand)) { - throw new IllegalArgumentError('Invalid ClusterCommand instance') - } + getCommandDefinition () { + const clusterCmd = this return { command: 'cluster', desc: 'Manage fullstack testing cluster', diff --git a/src/commands/index.mjs b/src/commands/index.mjs index a181b6404..5cbd17f9a 100644 --- a/src/commands/index.mjs +++ b/src/commands/index.mjs @@ -39,13 +39,13 @@ function Initialize (opts) { const mirrorNodeCmd = new MirrorNodeCommand(opts) return [ - InitCommand.getCommandDefinition(initCmd), - ClusterCommand.getCommandDefinition(clusterCmd), - NetworkCommand.getCommandDefinition(networkCommand), - NodeCommand.getCommandDefinition(nodeCmd), - RelayCommand.getCommandDefinition(relayCmd), - AccountCommand.getCommandDefinition(accountCmd), - MirrorNodeCommand.getCommandDefinition(mirrorNodeCmd) + initCmd.getCommandDefinition(), + clusterCmd.getCommandDefinition(), + networkCommand.getCommandDefinition(), + nodeCmd.getCommandDefinition(), + relayCmd.getCommandDefinition(), + accountCmd.getCommandDefinition(), + mirrorNodeCmd.getCommandDefinition() ] } diff --git a/src/commands/init.mjs b/src/commands/init.mjs index 90748cbaa..916fdaaf7 100644 --- a/src/commands/init.mjs +++ b/src/commands/init.mjs @@ -144,13 +144,10 @@ export class InitCommand extends BaseCommand { /** * Return Yargs command definition for 'init' command - * @param {InitCommand} initCmd - an instance of InitCommand * @returns A object representing the Yargs command definition */ - static getCommandDefinition (initCmd) { - if (!initCmd || !(initCmd instanceof InitCommand)) { - throw new IllegalArgumentError('Invalid InitCommand') - } + getCommandDefinition () { + const initCmd = this return { command: 'init', desc: 'Initialize local environment and default flags', diff --git a/src/commands/mirror_node.mjs b/src/commands/mirror_node.mjs index 1e198d4ca..09ff5ae4d 100644 --- a/src/commands/mirror_node.mjs +++ b/src/commands/mirror_node.mjs @@ -448,13 +448,10 @@ export class MirrorNodeCommand extends BaseCommand { /** * Return Yargs command definition for 'mirror-mirror-node' command - * @param {MirrorNodeCommand} mirrorNodeCmd an instance of MirrorNodeCommand * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (mirrorNodeCmd) { - if (!mirrorNodeCmd || !(mirrorNodeCmd instanceof MirrorNodeCommand)) { - throw new IllegalArgumentError('Invalid MirrorNodeCommand instance', mirrorNodeCmd) - } + getCommandDefinition () { + const mirrorNodeCmd = this return { command: 'mirror-node', desc: 'Manage Hedera Mirror Node in solo network', diff --git a/src/commands/network.mjs b/src/commands/network.mjs index deaf88cce..da5be7a42 100644 --- a/src/commands/network.mjs +++ b/src/commands/network.mjs @@ -549,13 +549,10 @@ export class NetworkCommand extends BaseCommand { } /** - * @param {NetworkCommand} networkCmd * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (networkCmd) { - if (!networkCmd || !(networkCmd instanceof NetworkCommand)) { - throw new IllegalArgumentError('An instance of NetworkCommand is required', networkCmd) - } + getCommandDefinition () { + const networkCmd = this return { command: 'network', desc: 'Manage solo network deployment', diff --git a/src/commands/node.mjs b/src/commands/node.mjs index cf7f25183..320df7ee6 100644 --- a/src/commands/node.mjs +++ b/src/commands/node.mjs @@ -30,7 +30,7 @@ import { sleep, validatePath } from '../core/helpers.mjs' -import { constants, Templates, Zippy } from '../core/index.mjs' +import { constants, Templates, YargsCommand } from '../core/index.mjs' import { BaseCommand } from './base.mjs' import * as flags from './flags.mjs' import * as prompts from './prompts.mjs' @@ -39,14 +39,11 @@ import { AccountBalanceQuery, AccountId, AccountUpdateTransaction, - FileAppendTransaction, - FileUpdateTransaction, - FreezeTransaction, - FreezeType, PrivateKey, NodeCreateTransaction, NodeUpdateTransaction, NodeDeleteTransaction, + ServiceEndpoint, Timestamp } from '@hashgraph/sdk' import * as crypto from 'crypto' @@ -58,6 +55,9 @@ import { LOCAL_HOST } from '../core/constants.mjs' import { NodeStatusCodes, NodeStatusEnums } from '../core/enumerations.mjs' +import { NodeCommandTasks } from './node/tasks.mjs' +import { downloadGeneratedFilesConfigBuilder, prepareUpgradeConfigBuilder } from './node/configs.mjs' +import * as NodeFlags from './node/flags.mjs' /** * Defines the core functionalities of 'node' command @@ -86,6 +86,13 @@ export class NodeCommand extends BaseCommand { this.keytoolDepManager = opts.keytoolDepManager this.profileManager = opts.profileManager this._portForwards = [] + + this.tasks = new NodeCommandTasks({ + accountManager: opts.accountManager, + configManager: opts.configManager, + logger: opts.logger, + k8: opts.k8 + }) } /** @@ -409,30 +416,6 @@ export class NodeCommand extends BaseCommand { } } - /** - * Check if the network node pod is running - * @param {string} namespace - * @param {NodeAlias} nodeAlias - * @param {number} [maxAttempts] - * @param {number} [delay] - * @returns {Promise} - */ - async checkNetworkNodePod (namespace, nodeAlias, maxAttempts = 60, delay = 2000) { - nodeAlias = nodeAlias.trim() - const podName = Templates.renderNetworkPodName(nodeAlias) - - try { - await this.k8.waitForPods([constants.POD_PHASE_RUNNING], [ - 'fullstack.hedera.com/type=network-node', - `fullstack.hedera.com/node-name=${nodeAlias}` - ], 1, maxAttempts, delay) - - return podName - } catch (e) { - throw new SoloError(`no pod found for nodeAlias: ${nodeAlias}`, e) - } - } - /** * @param {string} namespace * @param {NodeAlias} nodeAlias @@ -548,39 +531,6 @@ export class NodeCommand extends BaseCommand { }) } - /** - * Return task for checking for all network node pods - * @param {any} ctx - * @param {TaskWrapper} task - * @param {NodeAliases} nodeAliases - * @returns {*} - */ - taskCheckNetworkNodePods (ctx, task, nodeAliases) { - if (!ctx.config) { - ctx.config = {} - } - - ctx.config.podNames = {} - - const subTasks = [] - for (const nodeAlias of nodeAliases) { - subTasks.push({ - title: `Check network pod: ${chalk.yellow(nodeAlias)}`, - task: async (ctx) => { - ctx.config.podNames[nodeAlias] = await this.checkNetworkNodePod(ctx.config.namespace, nodeAlias) - } - }) - } - - // setup the sub-tasks - return task.newListr(subTasks, { - concurrent: true, - rendererOptions: { - collapseSubtasks: false - } - }) - } - /** * Return task for checking for all network node pods * @param {any} ctx @@ -684,19 +634,6 @@ export class NodeCommand extends BaseCommand { }) } - /** - * Transfer some hbar to the node for staking purpose - * @param {NodeAliases} existingNodeAliases - * @return {Promise} - */ - async checkStakingTask (existingNodeAliases) { - const accountMap = getNodeAccountMap(existingNodeAliases) - for (const nodeAlias of existingNodeAliases) { - const accountId = accountMap.get(nodeAlias) - await this.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, accountId, 1) - } - } - /** * Task for repairing staging directory * @param ctx @@ -819,49 +756,6 @@ export class NodeCommand extends BaseCommand { } } - /** - * Identify existing network nodes and check if they are running - * @param {any} ctx - * @param {TaskWrapper} task - * @param config - */ - async identifyExistingNetworkNodes (ctx, task, config) { - config.existingNodeAliases = [] - config.serviceMap = await this.accountManager.getNodeServiceMap( - config.namespace) - for (/** @type {NetworkNodeServices} **/ const networkNodeServices of config.serviceMap.values()) { - config.existingNodeAliases.push(networkNodeServices.nodeAlias) - } - config.allNodeAliases = [...config.existingNodeAliases] - return this.taskCheckNetworkNodePods(ctx, task, config.existingNodeAliases) - } - - /** - * Download generated config files and key files from the network node - * @param config - */ - async downloadNodeGeneratedFiles (config) { - const node1FullyQualifiedPodName = Templates.renderNetworkPodName(config.existingNodeAliases[0]) - - // copy the config.txt file from the node1 upgrade directory - await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/config.txt`, config.stagingDir) - - // if directory data/upgrade/current/data/keys does not exist then use data/upgrade/current - let keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/data/keys` - if (!await this.k8.hasDir(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, keyDir)) { - keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current` - } - const signedKeyFiles = (await this.k8.listDir(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, keyDir)).filter(file => file.name.startsWith(constants.SIGNING_KEY_PREFIX)) - await this.k8.execContainer(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, ['bash', '-c', `mkdir -p ${constants.HEDERA_HAPI_PATH}/data/keys_backup && cp -r ${keyDir} ${constants.HEDERA_HAPI_PATH}/data/keys_backup/`]) - for (const signedKeyFile of signedKeyFiles) { - await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${keyDir}/${signedKeyFile.name}`, `${config.keysDir}`) - } - - if (await this.k8.hasFile(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/application.properties`)) { - await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/application.properties`, `${config.stagingDir}/templates`) - } - } - async initializeSetup (config, k8) { // compute other config parameters config.keysDir = path.join(validatePath(config.cacheDir), 'keys') @@ -1000,76 +894,43 @@ export class NodeCommand extends BaseCommand { return (new Uint8Array(decodedDers[0])) } - async prepareUpgradeZip (stagingDir) { - // we build a mock upgrade.zip file as we really don't need to upgrade the network - // also the platform zip file is ~80Mb in size requiring a lot of transactions since the max - // transaction size is 6Kb and in practice we need to send the file as 4Kb chunks. - // Note however that in DAB phase-2, we won't need to trigger this fake upgrade process - const zipper = new Zippy(this.logger) - const upgradeConfigDir = path.join(stagingDir, 'mock-upgrade', 'data', 'config') - if (!fs.existsSync(upgradeConfigDir)) { - fs.mkdirSync(upgradeConfigDir, { recursive: true }) - } - - // bump field hedera.config.version - const fileBytes = fs.readFileSync(path.join(stagingDir, 'templates', 'application.properties')) - const lines = fileBytes.toString().split('\n') - const newLines = [] - for (let line of lines) { - line = line.trim() - const parts = line.split('=') - if (parts.length === 2) { - if (parts[0] === 'hedera.config.version') { - let version = parseInt(parts[1]) - line = `hedera.config.version=${++version}` - } - newLines.push(line) - } - } - fs.writeFileSync(path.join(upgradeConfigDir, 'application.properties'), newLines.join('\n')) - - return await zipper.zip(path.join(stagingDir, 'mock-upgrade'), path.join(stagingDir, 'mock-upgrade.zip')) - } - /** - * @param {string} upgradeZipFile - * @param nodeClient - * @returns {Promise} + * @param {string} endpointType + * @param {string[]} endpoints + * @param {number} defaultPort + * @returns {ServiceEndpoint[]} */ - async uploadUpgradeZip (upgradeZipFile, nodeClient) { - // get byte value of the zip file - const zipBytes = fs.readFileSync(upgradeZipFile) - const zipHash = crypto.createHash('sha384').update(zipBytes).digest('hex') - this.logger.debug(`loaded upgrade zip file [ zipHash = ${zipHash} zipBytes.length = ${zipBytes.length}, zipPath = ${upgradeZipFile}]`) + prepareEndpoints (endpointType, endpoints, defaultPort) { + const ret = /** @typedef ServiceEndpoint **/[] + for (const endpoint of endpoints) { + const parts = endpoint.split(':') - // create a file upload transaction to upload file to the network - try { - let start = 0 - - while (start < zipBytes.length) { - const zipBytesChunk = new Uint8Array(zipBytes.subarray(start, constants.UPGRADE_FILE_CHUNK_SIZE)) - let fileTransaction = null - - if (start === 0) { - fileTransaction = new FileUpdateTransaction() - .setFileId(constants.UPGRADE_FILE_ID) - .setContents(zipBytesChunk) - } else { - fileTransaction = new FileAppendTransaction() - .setFileId(constants.UPGRADE_FILE_ID) - .setContents(zipBytesChunk) - } - const resp = await fileTransaction.execute(nodeClient) - const receipt = await resp.getReceipt(nodeClient) - this.logger.debug(`updated file ${constants.UPGRADE_FILE_ID} [chunkSize= ${zipBytesChunk.length}, txReceipt = ${receipt.toString()}]`) - - start += constants.UPGRADE_FILE_CHUNK_SIZE + let url = '' + let port = defaultPort + + if (parts.length === 2) { + url = parts[0].trim() + port = parts[1].trim() + } else if (parts.length === 1) { + url = parts[0] + } else { + throw new SoloError(`incorrect endpoint format. expected url:port, found ${endpoint}`) } - return zipHash - } catch (e) { - throw new SoloError(`failed to upload build.zip file: ${e.message}`, e) + if (endpointType.toUpperCase() === constants.ENDPOINT_TYPE_IP) { + ret.push(new ServiceEndpoint({ + port, + ipAddressV4: helpers.parseIpAddressToUint8Array(url) + })) + } else { + ret.push(new ServiceEndpoint({ + port, + domainName: url + })) + } } + + return ret } // List of Commands @@ -1135,10 +996,7 @@ export class NodeCommand extends BaseCommand { self.logger.debug('Initialized config', { config }) } }, - { - title: 'Identify network pods', - task: (ctx, task) => self.taskCheckNetworkNodePods(ctx, task, ctx.config.nodeAliases) - }, + this.tasks.identifyNetworkPods(), { title: 'Fetch platform software into network nodes', task: @@ -1203,13 +1061,7 @@ export class NodeCommand extends BaseCommand { } } }, - { - title: 'Identify existing network nodes', - task: async (ctx, task) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - return this.identifyExistingNetworkNodes(ctx, task, config) - } - }, + this.tasks.identifyExistingNodes(), { title: 'Starting nodes', task: (ctx, task) => { @@ -1304,10 +1156,7 @@ export class NodeCommand extends BaseCommand { } } }, - { - title: 'Identify network pods', - task: (ctx, task) => self.taskCheckNetworkNodePods(ctx, task, ctx.config.nodeAliases) - }, + this.tasks.identifyNetworkPods(), { title: 'Stopping nodes', task: (ctx, task) => { @@ -1510,10 +1359,7 @@ export class NodeCommand extends BaseCommand { self.logger.debug('Initialized config', ctx.config) } }, - { - title: 'Identify network pods', - task: (ctx, task) => self.taskCheckNetworkNodePods(ctx, task, ctx.config.nodeAliases) - }, + this.tasks.identifyNetworkPods(), { title: 'Dump network nodes saved state', task: @@ -1752,16 +1598,6 @@ export class NodeCommand extends BaseCommand { } } - getIdentifyExistingNetworkNodesTask (argv) { - return { - title: 'Identify existing network nodes', - task: async (ctx, task) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - return this.identifyExistingNetworkNodes(ctx, task, config) - } - } - } - getAddPrepareTasks (argv) { const self = this @@ -1775,7 +1611,7 @@ export class NodeCommand extends BaseCommand { } } }, - self.getIdentifyExistingNetworkNodesTask(argv), + this.tasks.identifyExistingNodes(), { title: 'Determine new node account number', task: (ctx) => { @@ -1904,21 +1740,8 @@ export class NodeCommand extends BaseCommand { ctx.grpcServiceEndpoints = helpers.prepareEndpoints(config.endpointType, endpoints, constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT) } }, - { - title: 'Prepare upgrade zip file for node upgrade process', - task: async (ctx) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - ctx.upgradeZipFile = await this.prepareUpgradeZip(config.stagingDir) - ctx.upgradeZipHash = await this.uploadUpgradeZip(ctx.upgradeZipFile, config.nodeClient) - } - }, - { - title: 'Check existing nodes staked amount', - task: async (ctx) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - await this.checkStakingTask(config.existingNodeAliases) - } - } + this.tasks.prepareUpgradeZip(), + this.tasks.checkExistingNodesStakedAmount() ] } @@ -1980,21 +1803,8 @@ export class NodeCommand extends BaseCommand { } } }, - { - title: 'Send prepare upgrade transaction', - task: async (ctx) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - await this.prepareUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - }, - { - title: 'Send freeze upgrade transaction', - task: async (ctx) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - await this.freezeUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - } - + this.tasks.sendPrepareUpgradeTransaction(), + this.tasks.sendFreezeUpgradeTransaction() ] } @@ -2002,13 +1812,7 @@ export class NodeCommand extends BaseCommand { const self = this return [ - { - title: 'Download generated files from an existing node', - task: async (ctx) => { - const config = /** @type {NodeAddConfigClass} **/ ctx.config - await this.downloadNodeGeneratedFiles(config) - } - }, + this.tasks.downloadNodeGeneratedFiles(), { title: 'Prepare staging directory', task: async (ctx, parentTask) => { @@ -2210,7 +2014,7 @@ export class NodeCommand extends BaseCommand { const executeTasks = this.getAddExecuteTasks(argv) const tasks = new Listr([ self.addInitializeTask(argv), - self.getIdentifyExistingNetworkNodesTask(argv), + this.tasks.identifyExistingNodes(), self.loadContextDataTask(argv, NodeCommand.ADD_CONTEXT_FILE, helpers.addLoadContextParser), ...executeTasks ], { @@ -2261,75 +2065,46 @@ export class NodeCommand extends BaseCommand { return true } - /** - * @param {PrivateKey|string} freezeAdminPrivateKey - * @param {Uint8Array|string} upgradeZipHash - * @param {NodeClient} client - * @returns {Promise} - */ - async prepareUpgradeNetworkNodes (freezeAdminPrivateKey, upgradeZipHash, client) { - try { - // transfer some tiny amount to the freeze admin account - await this.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, FREEZE_ADMIN_ACCOUNT, 100000) - - // query the balance - const balance = await new AccountBalanceQuery() - .setAccountId(FREEZE_ADMIN_ACCOUNT) - .execute(this.accountManager._nodeClient) - this.logger.debug(`Freeze admin account balance: ${balance.hbars}`) - - // set operator of freeze transaction as freeze admin account - client.setOperator(FREEZE_ADMIN_ACCOUNT, freezeAdminPrivateKey) + async prepareUpgrade (argv) { + argv = helpers.addFlagsToArgv(argv, NodeFlags.DEFAULT_FLAGS) + const action = helpers.commandActionBuilder([ + this.tasks.initialize(argv, prepareUpgradeConfigBuilder.bind(this)), + this.tasks.prepareUpgradeZip(), + this.tasks.sendPrepareUpgradeTransaction() + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }, 'Error in preparing node upgrade') - const prepareUpgradeTx = await new FreezeTransaction() - .setFreezeType(FreezeType.PrepareUpgrade) - .setFileId(constants.UPGRADE_FILE_ID) - .setFileHash(upgradeZipHash) - .freezeWith(client) - .execute(client) + await action(argv, this) + } - const prepareUpgradeReceipt = await prepareUpgradeTx.getReceipt(client) + async freezeUpgrade (argv) { + argv = helpers.addFlagsToArgv(argv, NodeFlags.DEFAULT_FLAGS) + const action = helpers.commandActionBuilder([ + this.tasks.initialize(argv, prepareUpgradeConfigBuilder.bind(this)), + this.tasks.prepareUpgradeZip(), + this.tasks.sendFreezeUpgradeTransaction() + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }, 'Error in executing node freeze upgrade') - this.logger.debug( - `sent prepare upgrade transaction [id: ${prepareUpgradeTx.transactionId.toString()}]`, - prepareUpgradeReceipt.status.toString() - ) - } catch (e) { - this.logger.error(`Error in prepare upgrade: ${e.message}`, e) - throw new SoloError(`Error in prepare upgrade: ${e.message}`, e) - } + await action(argv, this) } - /** - * @param {PrivateKey|string} freezeAdminPrivateKey - * @param {Uint8Array|string} upgradeZipHash - * @param {NodeClient} client - * @returns {Promise} - */ - async freezeUpgradeNetworkNodes (freezeAdminPrivateKey, upgradeZipHash, client) { - try { - const futureDate = new Date() - this.logger.debug(`Current time: ${futureDate}`) - - futureDate.setTime(futureDate.getTime() + 5000) // 5 seconds in the future - this.logger.debug(`Freeze time: ${futureDate}`) - - client.setOperator(FREEZE_ADMIN_ACCOUNT, freezeAdminPrivateKey) - const freezeUpgradeTx = await new FreezeTransaction() - .setFreezeType(FreezeType.FreezeUpgrade) - .setStartTimestamp(Timestamp.fromDate(futureDate)) - .setFileId(constants.UPGRADE_FILE_ID) - .setFileHash(upgradeZipHash) - .freezeWith(client) - .execute(client) + async downloadGeneratedFiles (argv) { + argv = helpers.addFlagsToArgv(argv, NodeFlags.DEFAULT_FLAGS) + const action = helpers.commandActionBuilder([ + this.tasks.initialize(argv, downloadGeneratedFilesConfigBuilder.bind(this)), + this.tasks.identifyExistingNodes(), + this.tasks.downloadNodeGeneratedFiles() + ], { + concurrent: false, + rendererOptions: constants.LISTR_DEFAULT_RENDERER_OPTION + }, 'Error in downloading generated files') - const freezeUpgradeReceipt = await freezeUpgradeTx.getReceipt(client) - this.logger.debug(`Upgrade frozen with transaction id: ${freezeUpgradeTx.transactionId.toString()}`, - freezeUpgradeReceipt.status.toString()) - } catch (e) { - this.logger.error(`Error in freeze upgrade: ${e.message}`, e) - throw new SoloError(`Error in freeze upgrade: ${e.message}`, e) - } + await action(argv, this) } /** @@ -2362,13 +2137,10 @@ export class NodeCommand extends BaseCommand { // Command Definition /** * Return Yargs command definition for 'node' command - * @param {NodeCommand} nodeCmd - an instance of NodeCommand * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (nodeCmd) { - if (!nodeCmd || !(nodeCmd instanceof NodeCommand)) { - throw new IllegalArgumentError('An instance of NodeCommand is required', nodeCmd) - } + getCommandDefinition () { + const nodeCmd = this return { command: 'node', desc: 'Manage Hedera platform node in solo network', @@ -2631,6 +2403,24 @@ export class NodeCommand extends BaseCommand { }) } }) + .command(new YargsCommand({ + command: 'prepare-upgrade', + description: 'Prepare the network for a Freeze Upgrade operation', + commandDef: nodeCmd, + handler: 'prepareUpgrade' + }, NodeFlags.DEFAULT_FLAGS)) + .command(new YargsCommand({ + command: 'freeze-upgrade', + description: 'Performs a Freeze Upgrade operation with on the network after it has been prepared with prepare-upgrade', + commandDef: nodeCmd, + handler: 'freezeUpgrade' + }, NodeFlags.DEFAULT_FLAGS)) + .command(new YargsCommand({ + command: 'download-generated-files', + description: 'Downloads the generated files from an existing node', + commandDef: nodeCmd, + handler: 'downloadGeneratedFiles' + }, NodeFlags.DEFAULT_FLAGS)) .demandCommand(1, 'Select a node command') } } @@ -2749,13 +2539,7 @@ export class NodeCommand extends BaseCommand { self.logger.debug('Initialized config', { config }) } }, - { - title: 'Identify existing network nodes', - task: async (ctx, task) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - return this.identifyExistingNetworkNodes(ctx, task, config) - } - }, + this.tasks.identifyExistingNodes(), { title: 'Prepare gossip endpoints', task: (ctx) => { @@ -2798,28 +2582,9 @@ export class NodeCommand extends BaseCommand { ctx.grpcServiceEndpoints = helpers.prepareEndpoints(config.endpointType, endpoints, constants.HEDERA_NODE_EXTERNAL_GOSSIP_PORT) } }, - { - title: 'Load node admin key', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - config.adminKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY) - } - }, - { - title: 'Prepare upgrade zip file for node upgrade process', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - ctx.upgradeZipFile = await this.prepareUpgradeZip(config.stagingDir) - ctx.upgradeZipHash = await this.uploadUpgradeZip(ctx.upgradeZipFile, config.nodeClient) - } - }, - { - title: 'Check existing nodes staked amount', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - await this.checkStakingTask(config.existingNodeAliases) - } - }, + this.tasks.loadAdminKey(), + this.tasks.prepareUpgradeZip(), + this.tasks.checkExistingNodesStakedAmount(), { title: 'Send node update transaction', task: async (ctx) => { @@ -2882,27 +2647,9 @@ export class NodeCommand extends BaseCommand { } } }, - { - title: 'Send prepare upgrade transaction', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - await this.prepareUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - }, - { - title: 'Download generated files from an existing node', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - await this.downloadNodeGeneratedFiles(config) - } - }, - { - title: 'Send freeze upgrade transaction', - task: async (ctx) => { - const config = /** @type {NodeUpdateConfigClass} **/ ctx.config - await this.freezeUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - }, + this.tasks.sendPrepareUpgradeTransaction(), + this.tasks.downloadNodeGeneratedFiles(), + this.tasks.sendFreezeUpgradeTransaction(), { title: 'Prepare staging directory', task: async (ctx, parentTask) => { @@ -3145,35 +2892,10 @@ export class NodeCommand extends BaseCommand { deletePrepareTasks (argv) { return [ this.deleteInitializeTask(argv), - { - title: 'Identify existing network nodes', - task: async (ctx, task) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - return this.identifyExistingNetworkNodes(ctx, task, config) - } - }, - { - title: 'Load node admin key', - task: async (ctx) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - config.adminKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY) - } - }, - { - title: 'Prepare upgrade zip file for node upgrade process', - task: async (ctx) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - ctx.upgradeZipFile = await this.prepareUpgradeZip(config.stagingDir) - ctx.upgradeZipHash = await this.uploadUpgradeZip(ctx.upgradeZipFile, config.nodeClient) - } - }, - { - title: 'Check existing nodes staked amount', - task: async (ctx) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - await this.checkStakingTask(config.existingNodeAliases) - } - } + this.tasks.identifyExistingNodes(), + this.tasks.loadAdminKey(), + this.tasks.prepareUpgradeZip(), + this.tasks.checkExistingNodesStakedAmount() ] } @@ -3181,13 +2903,7 @@ export class NodeCommand extends BaseCommand { const self = this return [ - { - title: 'Download generated files from an existing node', - task: async (ctx) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - await this.downloadNodeGeneratedFiles(config) - } - }, + this.tasks.downloadNodeGeneratedFiles(), { title: 'Prepare staging directory', task: async (ctx, parentTask) => { @@ -3306,6 +3022,7 @@ export class NodeCommand extends BaseCommand { deleteSubmitTransactionsTasks (argv) { return [ + { title: 'Send node delete transaction', task: async (ctx, task) => { @@ -3330,20 +3047,8 @@ export class NodeCommand extends BaseCommand { } } }, - { - title: 'Send prepare upgrade transaction', - task: async (ctx, task) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - await this.prepareUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - }, - { - title: 'Send freeze upgrade transaction', - task: async (ctx, task) => { - const config = /** @type {NodeDeleteConfigClass} **/ ctx.config - await this.freezeUpgradeNetworkNodes(config.freezeAdminPrivateKey, ctx.upgradeZipHash, config.nodeClient) - } - } + this.tasks.sendPrepareUpgradeTransaction(), + this.tasks.sendFreezeUpgradeTransaction() ] } diff --git a/src/commands/node/configs.mjs b/src/commands/node/configs.mjs new file mode 100644 index 000000000..8e420f57b --- /dev/null +++ b/src/commands/node/configs.mjs @@ -0,0 +1,85 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import * as flags from '../flags.mjs' +import { FREEZE_ADMIN_ACCOUNT } from '../../core/constants.mjs' + +export const PREPARE_UPGRADE_CONFIGS_NAME = 'prepareUpgradeConfig' +export const DOWNLOAD_GENERATED_FILES_CONFIGS_NAME = 'downloadGeneratedFilesConfig' + +export const prepareUpgradeConfigBuilder = async function (argv, ctx, task) { + /** + * @typedef {Object} NodePrepareUpgradeConfigClass + * -- flags -- + * @property {string} cacheDir + * @property {string} namespace + * @property {string} releaseTag + * -- extra args -- + * @property {string} freezeAdminPrivateKey + * @property {Object} nodeClient + * -- methods -- + * @property {getUnusedConfigs} getUnusedConfigs + */ + /** + * @callback getUnusedConfigs + * @returns {string[]} + */ + + const config = /** @type {NodePrepareUpgradeConfigClass} **/ this.getConfig( + PREPARE_UPGRADE_CONFIGS_NAME, argv.flags, [ + 'nodeClient', + 'freezeAdminPrivateKey' + ]) + + await this.initializeSetup(config, this.k8) + config.nodeClient = await this.accountManager.loadNodeClient(config.namespace) + + const accountKeys = await this.accountManager.getAccountKeysFromSecret(FREEZE_ADMIN_ACCOUNT, config.namespace) + config.freezeAdminPrivateKey = accountKeys.privateKey + + return config +} + +export const downloadGeneratedFilesConfigBuilder = async function (argv, ctx, task) { + /** + * @typedef {Object} NodeDownloadGeneratedFilesConfigClass + * -- flags -- + * @property {string} cacheDir + * @property {string} namespace + * @property {string} releaseTag + * -- extra args -- + * @property {string} freezeAdminPrivateKey + * @property {Object} nodeClient + * -- methods -- + * @property {getUnusedConfigs} getUnusedConfigs + */ + /** + * @callback getUnusedConfigs + * @returns {string[]} + */ + + const config = /** @type {NodePrepareUpgradeConfigClass} **/ this.getConfig( + DOWNLOAD_GENERATED_FILES_CONFIGS_NAME, argv.flags, [ + 'allNodeAliases', + 'existingNodeAliases', + 'serviceMap' + ]) + + config.existingNodeAliases = [] + await this.initializeSetup(config, this.k8) + + return config +} diff --git a/src/commands/node/flags.mjs b/src/commands/node/flags.mjs new file mode 100644 index 000000000..1cff65036 --- /dev/null +++ b/src/commands/node/flags.mjs @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import * as flags from '../flags.mjs' + +export const DEFAULT_FLAGS = { + requiredFlags: [], + requiredFlagsWithDisabledPrompt: [flags.namespace, flags.cacheDir, flags.releaseTag], + optionalFlags: [flags.devMode] +} diff --git a/src/commands/node/tasks.mjs b/src/commands/node/tasks.mjs new file mode 100644 index 000000000..bf3c1fec3 --- /dev/null +++ b/src/commands/node/tasks.mjs @@ -0,0 +1,373 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +'use strict' +import { constants, Templates, Task, Zippy } from '../../core/index.mjs' +import { FREEZE_ADMIN_ACCOUNT } from '../../core/constants.mjs' +import { + AccountBalanceQuery, + FileAppendTransaction, + FileUpdateTransaction, + FreezeTransaction, + FreezeType, PrivateKey, + Timestamp +} from '@hashgraph/sdk' +import { SoloError, IllegalArgumentError, MissingArgumentError } from '../../core/errors.mjs' +import * as prompts from '../prompts.mjs' +import path from 'path' +import fs from 'fs' +import crypto from 'crypto' +import { getNodeAccountMap } from '../../core/helpers.mjs' +import chalk from 'chalk' +import * as flags from '../flags.mjs' + +export class NodeCommandTasks { + /** + * @param {{logger: Logger, accountManager: AccountManager, configManager: ConfigManager}} opts + */ + constructor (opts) { + if (!opts || !opts.accountManager) throw new IllegalArgumentError('An instance of core/AccountManager is required', opts.accountManager) + if (!opts || !opts.configManager) throw new Error('An instance of core/ConfigManager is required') + if (!opts || !opts.logger) throw new Error('An instance of core/Logger is required') + if (!opts || !opts.k8) throw new Error('An instance of core/K8 is required') + + this.accountManager = opts.accountManager + this.configManager = opts.configManager + this.logger = opts.logger + this.k8 = /** @type {K8} **/ opts.k8 + } + + async _prepareUpgradeZip (stagingDir) { + // we build a mock upgrade.zip file as we really don't need to upgrade the network + // also the platform zip file is ~80Mb in size requiring a lot of transactions since the max + // transaction size is 6Kb and in practice we need to send the file as 4Kb chunks. + // Note however that in DAB phase-2, we won't need to trigger this fake upgrade process + const zipper = new Zippy(this.logger) + const upgradeConfigDir = path.join(stagingDir, 'mock-upgrade', 'data', 'config') + if (!fs.existsSync(upgradeConfigDir)) { + fs.mkdirSync(upgradeConfigDir, { recursive: true }) + } + + // bump field hedera.config.version + const fileBytes = fs.readFileSync(path.join(stagingDir, 'templates', 'application.properties')) + const lines = fileBytes.toString().split('\n') + const newLines = [] + for (let line of lines) { + line = line.trim() + const parts = line.split('=') + if (parts.length === 2) { + if (parts[0] === 'hedera.config.version') { + let version = parseInt(parts[1]) + line = `hedera.config.version=${++version}` + } + newLines.push(line) + } + } + fs.writeFileSync(path.join(upgradeConfigDir, 'application.properties'), newLines.join('\n')) + + return await zipper.zip(path.join(stagingDir, 'mock-upgrade'), path.join(stagingDir, 'mock-upgrade.zip')) + } + + /** + * @param {string} upgradeZipFile + * @param nodeClient + * @returns {Promise} + */ + async _uploadUpgradeZip (upgradeZipFile, nodeClient) { + // get byte value of the zip file + const zipBytes = fs.readFileSync(upgradeZipFile) + const zipHash = crypto.createHash('sha384').update(zipBytes).digest('hex') + this.logger.debug(`loaded upgrade zip file [ zipHash = ${zipHash} zipBytes.length = ${zipBytes.length}, zipPath = ${upgradeZipFile}]`) + + // create a file upload transaction to upload file to the network + try { + let start = 0 + + while (start < zipBytes.length) { + const zipBytesChunk = new Uint8Array(zipBytes.subarray(start, constants.UPGRADE_FILE_CHUNK_SIZE)) + let fileTransaction = null + + if (start === 0) { + fileTransaction = new FileUpdateTransaction() + .setFileId(constants.UPGRADE_FILE_ID) + .setContents(zipBytesChunk) + } else { + fileTransaction = new FileAppendTransaction() + .setFileId(constants.UPGRADE_FILE_ID) + .setContents(zipBytesChunk) + } + const resp = await fileTransaction.execute(nodeClient) + const receipt = await resp.getReceipt(nodeClient) + this.logger.debug(`updated file ${constants.UPGRADE_FILE_ID} [chunkSize= ${zipBytesChunk.length}, txReceipt = ${receipt.toString()}]`) + + start += constants.UPGRADE_FILE_CHUNK_SIZE + } + + return zipHash + } catch (e) { + throw new SoloError(`failed to upload build.zip file: ${e.message}`, e) + } + } + + prepareUpgradeZip () { + return new Task('Prepare upgrade zip file for node upgrade process', async (ctx, task) => { + const config = ctx.config + ctx.upgradeZipFile = await this._prepareUpgradeZip(config.stagingDir) + ctx.upgradeZipHash = await this._uploadUpgradeZip(ctx.upgradeZipFile, config.nodeClient) + }) + } + + loadAdminKey () { + return new Task('Load node admin key', async (ctx, task) => { + const config = ctx.config + config.adminKey = PrivateKey.fromStringED25519(constants.GENESIS_KEY) + }) + } + + checkExistingNodesStakedAmount () { + return new Task('Check existing nodes staked amount', async (ctx, task) => { + const config = ctx.config + + // Transfer some hbar to the node for staking purpose + const accountMap = getNodeAccountMap(config.existingNodeAliases) + for (const nodeAlias of config.existingNodeAliases) { + const accountId = accountMap.get(nodeAlias) + await this.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, accountId, 1) + } + }) + } + + /** + * @returns {Task} + */ + sendPrepareUpgradeTransaction () { + return new Task('Send prepare upgrade transaction', async (ctx, task) => { + const { upgradeZipHash } = ctx + const { nodeClient, freezeAdminPrivateKey } = ctx.config + try { + // transfer some tiny amount to the freeze admin account + await this.accountManager.transferAmount(constants.TREASURY_ACCOUNT_ID, FREEZE_ADMIN_ACCOUNT, 100000) + + // query the balance + const balance = await new AccountBalanceQuery() + .setAccountId(FREEZE_ADMIN_ACCOUNT) + .execute(nodeClient) + this.logger.debug(`Freeze admin account balance: ${balance.hbars}`) + + // set operator of freeze transaction as freeze admin account + nodeClient.setOperator(FREEZE_ADMIN_ACCOUNT, freezeAdminPrivateKey) + + const prepareUpgradeTx = await new FreezeTransaction() + .setFreezeType(FreezeType.PrepareUpgrade) + .setFileId(constants.UPGRADE_FILE_ID) + .setFileHash(upgradeZipHash) + .freezeWith(nodeClient) + .execute(nodeClient) + + const prepareUpgradeReceipt = await prepareUpgradeTx.getReceipt(nodeClient) + + this.logger.debug( + `sent prepare upgrade transaction [id: ${prepareUpgradeTx.transactionId.toString()}]`, + prepareUpgradeReceipt.status.toString() + ) + } catch (e) { + this.logger.error(`Error in prepare upgrade: ${e.message}`, e) + throw new SoloError(`Error in prepare upgrade: ${e.message}`, e) + } + }) + } + + /** + * @returns {Task} + */ + sendFreezeUpgradeTransaction () { + return new Task('Send freeze upgrade transaction', async (ctx, task) => { + const { upgradeZipHash } = ctx + const { freezeAdminPrivateKey, nodeClient } = ctx.config + try { + const futureDate = new Date() + this.logger.debug(`Current time: ${futureDate}`) + + futureDate.setTime(futureDate.getTime() + 5000) // 5 seconds in the future + this.logger.debug(`Freeze time: ${futureDate}`) + + nodeClient.setOperator(FREEZE_ADMIN_ACCOUNT, freezeAdminPrivateKey) + const freezeUpgradeTx = await new FreezeTransaction() + .setFreezeType(FreezeType.FreezeUpgrade) + .setStartTimestamp(Timestamp.fromDate(futureDate)) + .setFileId(constants.UPGRADE_FILE_ID) + .setFileHash(upgradeZipHash) + .freezeWith(nodeClient) + .execute(nodeClient) + + const freezeUpgradeReceipt = await freezeUpgradeTx.getReceipt(nodeClient) + this.logger.debug(`Upgrade frozen with transaction id: ${freezeUpgradeTx.transactionId.toString()}`, + freezeUpgradeReceipt.status.toString()) + } catch (e) { + this.logger.error(`Error in freeze upgrade: ${e.message}`, e) + throw new SoloError(`Error in freeze upgrade: ${e.message}`, e) + } + }) + } + + /** + * Download generated config files and key files from the network node + * @returns {Task} + */ + downloadNodeGeneratedFiles () { + return new Task('Download generated files from an existing node', async (ctx, task) => { + const config = ctx.config + const node1FullyQualifiedPodName = Templates.renderNetworkPodName(config.existingNodeAliases[0]) + + // copy the config.txt file from the node1 upgrade directory + await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/config.txt`, config.stagingDir) + + // if directory data/upgrade/current/data/keys does not exist then use data/upgrade/current + let keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/data/keys` + if (!await this.k8.hasDir(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, keyDir)) { + keyDir = `${constants.HEDERA_HAPI_PATH}/data/upgrade/current` + } + const signedKeyFiles = (await this.k8.listDir(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, keyDir)).filter(file => file.name.startsWith(constants.SIGNING_KEY_PREFIX)) + await this.k8.execContainer(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, ['bash', '-c', `mkdir -p ${constants.HEDERA_HAPI_PATH}/data/keys_backup && cp -r ${keyDir} ${constants.HEDERA_HAPI_PATH}/data/keys_backup/`]) + for (const signedKeyFile of signedKeyFiles) { + await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${keyDir}/${signedKeyFile.name}`, `${config.keysDir}`) + } + + if (await this.k8.hasFile(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/application.properties`)) { + await this.k8.copyFrom(node1FullyQualifiedPodName, constants.ROOT_CONTAINER, `${constants.HEDERA_HAPI_PATH}/data/upgrade/current/application.properties`, `${config.stagingDir}/templates`) + } + }) + } + + /** + * Return task for checking for all network node pods + * @param {any} ctx + * @param {TaskWrapper} task + * @param {string[]} nodeAliases + * @returns {*} + */ + taskCheckNetworkNodePods (ctx, task, nodeAliases) { + if (!ctx.config) { + ctx.config = {} + } + + ctx.config.podNames = {} + + const subTasks = [] + for (const nodeAlias of nodeAliases) { + subTasks.push({ + title: `Check network pod: ${chalk.yellow(nodeAlias)}`, + task: async (ctx) => { + ctx.config.podNames[nodeAlias] = await this.checkNetworkNodePod(ctx.config.namespace, nodeAlias) + } + }) + } + + // setup the sub-tasks + return task.newListr(subTasks, { + concurrent: true, + rendererOptions: { + collapseSubtasks: false + } + }) + } + + /** + * Check if the network node pod is running + * @param {string} namespace + * @param {string} nodeAlias + * @param {number} [maxAttempts] + * @param {number} [delay] + * @returns {Promise} + */ + async checkNetworkNodePod (namespace, nodeAlias, maxAttempts = 60, delay = 2000) { + nodeAlias = nodeAlias.trim() + const podName = Templates.renderNetworkPodName(nodeAlias) + + try { + await this.k8.waitForPods([constants.POD_PHASE_RUNNING], [ + 'fullstack.hedera.com/type=network-node', + `fullstack.hedera.com/node-name=${nodeAlias}` + ], 1, maxAttempts, delay) + + return podName + } catch (e) { + throw new SoloError(`no pod found for nodeAlias: ${nodeAlias}`, e) + } + } + + identifyExistingNodes () { + return new Task('Identify existing network nodes', async (ctx, task) => { + const config = ctx.config + config.existingNodeAliases = [] + config.serviceMap = await this.accountManager.getNodeServiceMap(config.namespace) + for (/** @type {NetworkNodeServices} **/ const networkNodeServices of config.serviceMap.values()) { + config.existingNodeAliases.push(networkNodeServices.nodeAlias) + } + config.allNodeAliases = [...config.existingNodeAliases] + return this.taskCheckNetworkNodePods(ctx, task, config.existingNodeAliases) + }) + } + + identifyNetworkPods () { + return new Task('Identify network pods', async (ctx, task) => { + return this.taskCheckNetworkNodePods(ctx, task, ctx.config.nodeAliases) + }) + } + + /** + * @param {Object} argv + * @param {Function} configInit + * @returns {Task} + */ + initialize (argv, configInit) { + const { requiredFlags, requiredFlagsWithDisabledPrompt, optionalFlags } = argv + const allRequiredFlags = [ + ...requiredFlags, + ...requiredFlagsWithDisabledPrompt + ] + + argv.flags = [ + ...requiredFlags, + ...requiredFlagsWithDisabledPrompt, + ...optionalFlags + ] + + return new Task('Initialize', async (ctx, task) => { + if (argv[flags.devMode.name]) { + this.logger.setDevMode(true) + } + + this.configManager.update(argv) + + // disable the prompts that we don't want to prompt the user for + prompts.disablePrompts(requiredFlagsWithDisabledPrompt) + await prompts.execute(task, this.configManager, requiredFlags) + + const config = await configInit(argv, ctx, task) + ctx.config = config + + for (const flag of allRequiredFlags) { + if (typeof config[flag.constName] === 'undefined') { + throw new MissingArgumentError(`No value set for required flag: ${flag.name}`, flag.name) + } + } + + this.logger.debug('Initialized config', { config }) + }) + } +} diff --git a/src/commands/relay.mjs b/src/commands/relay.mjs index e03cee48f..7cc9f7330 100644 --- a/src/commands/relay.mjs +++ b/src/commands/relay.mjs @@ -360,13 +360,10 @@ export class RelayCommand extends BaseCommand { } /** - * @param {RelayCommand} relayCmd * @returns {{command: string, desc: string, builder: Function}} */ - static getCommandDefinition (relayCmd) { - if (!relayCmd || !(relayCmd instanceof RelayCommand)) { - throw new MissingArgumentError('An instance of RelayCommand is required', relayCmd) - } + getCommandDefinition () { + const relayCmd = this return { command: 'relay', desc: 'Manage JSON RPC relays in solo network', diff --git a/src/core/helpers.mjs b/src/core/helpers.mjs index 32f83b9cb..efcf1066f 100644 --- a/src/core/helpers.mjs +++ b/src/core/helpers.mjs @@ -27,6 +27,7 @@ import { Templates } from './templates.mjs' import { HEDERA_HAPI_PATH, ROOT_CONTAINER, SOLO_LOGS_DIR } from './constants.mjs' import { constants } from './index.mjs' import { FileContentsQuery, FileId, PrivateKey, ServiceEndpoint } from '@hashgraph/sdk' +import { Listr } from 'listr2' import * as yaml from 'js-yaml' // cache current directory @@ -487,6 +488,41 @@ export function prepareEndpoints (endpointType, endpoints, defaultPort) { return ret } +export function commandActionBuilder (actionTasks, options, errorString = 'Error') { + /** + * @param {Object} argv + * @param {BaseCommand} commandDef + * @returns {Promise} + */ + return async function (argv, commandDef) { + const tasks = new Listr([ + ...actionTasks + ], options) + + try { + await tasks.run() + } catch (e) { + commandDef.logger.error(`${errorString}: ${e.message}`, e) + throw new SoloError(`${errorString}: ${e.message}`, e) + } finally { + await commandDef.close() + } + } +} + +/** + * Adds all the types of flags as properties on the provided argv object + * @param argv + * @param flags + * @returns {*} + */ +export function addFlagsToArgv (argv, flags) { + argv.requiredFlags = flags.requiredFlags + argv.requiredFlagsWithDisabledPrompt = flags.requiredFlagsWithDisabledPrompt + argv.optionalFlags = flags.optionalFlags + + return argv +} /** * Convert yaml file to object * @param yamlFile diff --git a/src/core/index.mjs b/src/core/index.mjs index 6a0ca8c4b..bef9c4be3 100644 --- a/src/core/index.mjs +++ b/src/core/index.mjs @@ -27,6 +27,8 @@ import { ConfigManager } from './config_manager.mjs' import { KeyManager } from './key_manager.mjs' import { Keytool } from './keytool.mjs' import { ProfileManager } from './profile_manager.mjs' +import { YargsCommand } from './yargs_command.mjs' +import { Task } from './task.mjs' import * as helpers from './helpers.mjs' // Expose components from the core module @@ -44,5 +46,7 @@ export { ConfigManager, KeyManager, Keytool, - ProfileManager + ProfileManager, + YargsCommand, + Task } diff --git a/src/core/task.mjs b/src/core/task.mjs new file mode 100644 index 000000000..5f60de68c --- /dev/null +++ b/src/core/task.mjs @@ -0,0 +1,27 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +'use strict' + +export class Task { + constructor (title, taskFunc) { + return { + title, + task: taskFunc + } + } +} diff --git a/src/core/yargs_command.mjs b/src/core/yargs_command.mjs new file mode 100644 index 000000000..074aa6e5b --- /dev/null +++ b/src/core/yargs_command.mjs @@ -0,0 +1,70 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +'use strict' +import * as commandFlags from '../commands/flags.mjs' +import { IllegalArgumentError } from './errors.mjs' + +export class YargsCommand { + /** + * @param {{command: string, description: string, commandDef: BaseCommand, handler: string}} opts + * @param {{requiredFlags: CommandFlag[], requiredFlagsWithDisabledPrompt: CommandFlag[], optionalFlags: CommandFlag[]}} flags + */ + constructor (opts = {}, flags = {}) { + const { command, description, commandDef, handler } = opts + const { requiredFlags, requiredFlagsWithDisabledPrompt, optionalFlags } = flags + + if (!command) throw new IllegalArgumentError('A string is required as the \'command\' property', command) + if (!description) throw new IllegalArgumentError('A string is required as the \'description\' property', description) + if (!requiredFlags) throw new IllegalArgumentError('An array of CommandFlag is required as the \'requiredFlags\' property', requiredFlags) + if (!requiredFlagsWithDisabledPrompt) throw new IllegalArgumentError('An array of CommandFlag is required as the \'requiredFlagsWithDisabledPrompt\' property', requiredFlagsWithDisabledPrompt) + if (!optionalFlags) throw new IllegalArgumentError('An array of CommandFlag is required as the \'optionalFlags\' property', optionalFlags) + if (!commandDef) throw new IllegalArgumentError('An instance of BaseCommand is required as the \'commandDef\' property', commandDef) + if (!handler) throw new IllegalArgumentError('A string is required as the \'handler\' property', handler) + + let commandNamespace = '' + if (commandDef.getCommandDefinition) { + const definition = commandDef.getCommandDefinition() + if (definition && definition.command) { + commandNamespace = commandDef.getCommandDefinition().command + } + } + + const allFlags = [ + ...requiredFlags, + ...requiredFlagsWithDisabledPrompt, + ...optionalFlags + ] + + return { + command, + desc: description, + builder: y => commandFlags.setCommandFlags(y, ...allFlags), + handler: argv => { + commandDef.logger.debug(`==== Running '${commandNamespace} ${command}' ===`) + commandDef.logger.debug(argv) + commandDef[handler](argv).then(r => { + commandDef.logger.debug(`==== Finished running '${commandNamespace} ${command}' ====`) + if (!r) process.exit(1) + }).catch(err => { + commandDef.logger.showUserError(err) + process.exit(1) + }) + } + } + } +} diff --git a/test/e2e/commands/cluster.test.mjs b/test/e2e/commands/cluster.test.mjs index 1b01de4b0..2c1e9ded6 100644 --- a/test/e2e/commands/cluster.test.mjs +++ b/test/e2e/commands/cluster.test.mjs @@ -23,6 +23,7 @@ import { it, jest } from '@jest/globals' +import { flags } from '../../../src/commands/index.mjs' import { bootstrapTestVariables, getDefaultArgv, @@ -33,7 +34,6 @@ import { constants, logging } from '../../../src/core/index.mjs' -import { flags } from '../../../src/commands/index.mjs' import { sleep } from '../../../src/core/helpers.mjs' import * as version from '../../../version.mjs' diff --git a/test/e2e/commands/network.test.mjs b/test/e2e/commands/network.test.mjs index ee7b92ff4..4ce385c24 100644 --- a/test/e2e/commands/network.test.mjs +++ b/test/e2e/commands/network.test.mjs @@ -23,6 +23,7 @@ import { expect, it } from '@jest/globals' +import { flags } from '../../../src/commands/index.mjs' import { bootstrapTestVariables, getDefaultArgv, @@ -32,7 +33,6 @@ import { import { constants } from '../../../src/core/index.mjs' -import { flags } from '../../../src/commands/index.mjs' import * as version from '../../../version.mjs' import { getNodeLogs, sleep } from '../../../src/core/helpers.mjs' import path from 'path' diff --git a/test/e2e/commands/node_upgrade.test.mjs b/test/e2e/commands/node_upgrade.test.mjs new file mode 100644 index 000000000..a28640f08 --- /dev/null +++ b/test/e2e/commands/node_upgrade.test.mjs @@ -0,0 +1,79 @@ +/** + * Copyright (C) 2024 Hedera Hashgraph, LLC + * + * Licensed under the Apache License, Version 2.0 (the ""License""); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an ""AS IS"" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @jest-environment steps + */ +import { afterAll, describe, expect, it } from '@jest/globals' +import { flags } from '../../../src/commands/index.mjs' +import { + bootstrapNetwork, + getDefaultArgv, + HEDERA_PLATFORM_VERSION_TAG +} from '../../test_util.js' +import { getNodeLogs } from '../../../src/core/helpers.mjs' +import { PREPARE_UPGRADE_CONFIGS_NAME, DOWNLOAD_GENERATED_FILES_CONFIGS_NAME } from '../../../src/commands/node/configs.mjs' + +describe('Node upgrade', () => { + const namespace = 'node-upgrade' + const argv = getDefaultArgv() + argv[flags.nodeAliasesUnparsed.name] = 'node1,node2,node3' + argv[flags.generateGossipKeys.name] = true + argv[flags.generateTlsKeys.name] = true + argv[flags.persistentVolumeClaims.name] = true + // set the env variable SOLO_FST_CHARTS_DIR if developer wants to use local FST charts + argv[flags.chartDirectory.name] = process.env.SOLO_FST_CHARTS_DIR ? process.env.SOLO_FST_CHARTS_DIR : undefined + argv[flags.releaseTag.name] = HEDERA_PLATFORM_VERSION_TAG + argv[flags.namespace.name] = namespace + + const upgradeArgv = getDefaultArgv() + + const bootstrapResp = bootstrapNetwork(namespace, argv) + const nodeCmd = bootstrapResp.cmd.nodeCmd + const accountCmd = bootstrapResp.cmd.accountCmd + const k8 = bootstrapResp.opts.k8 + + afterAll(async () => { + await getNodeLogs(k8, namespace) + await k8.deleteNamespace(namespace) + }, 600000) + + it('should succeed with init command', async () => { + const status = await accountCmd.init(argv) + expect(status).toBeTruthy() + }, 450000) + + it('should prepare network upgrade successfully', async () => { + await nodeCmd.prepareUpgrade(upgradeArgv) + expect(nodeCmd.getUnusedConfigs(PREPARE_UPGRADE_CONFIGS_NAME)).toEqual([ + flags.devMode.constName + ]) + }, 300000) + + it('should download generated files successfully', async () => { + await nodeCmd.downloadGeneratedFiles(upgradeArgv) + expect(nodeCmd.getUnusedConfigs(DOWNLOAD_GENERATED_FILES_CONFIGS_NAME)).toEqual([ + flags.devMode.constName, + 'allNodeAliases' + ]) + }, 300000) + + it('should upgrade all nodes on the network successfully', async () => { + await nodeCmd.freezeUpgrade(upgradeArgv) + expect(nodeCmd.getUnusedConfigs(PREPARE_UPGRADE_CONFIGS_NAME)).toEqual([ + flags.devMode.constName + ]) + await nodeCmd.accountManager.close() + }, 300000) +}) diff --git a/test/e2e/commands/separate_node_add.test.mjs b/test/e2e/commands/separate_node_add.test.mjs index 9f3fba440..90cabef31 100644 --- a/test/e2e/commands/separate_node_add.test.mjs +++ b/test/e2e/commands/separate_node_add.test.mjs @@ -15,6 +15,7 @@ * * @jest-environment steps */ +import { flags } from '../../../src/commands/index.mjs' import { accountCreationShouldSucceed, balanceQueryShouldSucceed, @@ -23,7 +24,6 @@ import { getNodeAliasesPrivateKeysHash, getTmpDir, HEDERA_PLATFORM_VERSION_TAG } from '../../test_util.js' -import { flags } from '../../../src/commands/index.mjs' import { getNodeLogs } from '../../../src/core/helpers.mjs' import { NodeCommand } from '../../../src/commands/node.mjs' diff --git a/test/e2e/e2e_node_util.js b/test/e2e/e2e_node_util.js index 16a50c959..6522e1b4c 100644 --- a/test/e2e/e2e_node_util.js +++ b/test/e2e/e2e_node_util.js @@ -133,7 +133,7 @@ export function e2eNodeKeyRefreshTest (testName, mode, releaseTag = HEDERA_PLATF function nodePodShouldBeRunning (nodeCmd, namespace, nodeAlias) { it(`${nodeAlias} should be running`, async () => { try { - await expect(nodeCmd.checkNetworkNodePod(namespace, + await expect(nodeCmd.tasks.checkNetworkNodePod(namespace, nodeAlias)).resolves.toBeTruthy() } catch (e) { nodeCmd.logger.showUserError(e) diff --git a/test/test_add.mjs b/test/test_add.mjs index 195665f86..e08982ae8 100644 --- a/test/test_add.mjs +++ b/test/test_add.mjs @@ -16,6 +16,7 @@ * @jest-environment steps */ import { afterAll, describe, expect, it } from '@jest/globals' +import { flags } from '../src/commands/index.mjs' import { accountCreationShouldSucceed, balanceQueryShouldSucceed, @@ -25,7 +26,6 @@ import { getTmpDir, HEDERA_PLATFORM_VERSION_TAG } from './test_util.js' -import { flags } from '../src/commands/index.mjs' import { getNodeLogs } from '../src/core/helpers.mjs' import { NodeCommand } from '../src/commands/node.mjs' diff --git a/test/test_util.js b/test/test_util.js index 5668ce1f9..1a75bd51e 100644 --- a/test/test_util.js +++ b/test/test_util.js @@ -20,7 +20,6 @@ import fs from 'fs' import os from 'os' import path from 'path' import { ClusterCommand } from '../src/commands/cluster.mjs' -import { flags } from '../src/commands/index.mjs' import { InitCommand } from '../src/commands/init.mjs' import { NetworkCommand } from '../src/commands/network.mjs' import { NodeCommand } from '../src/commands/node.mjs' @@ -43,6 +42,7 @@ import { PlatformInstaller, ProfileManager, Templates, Zippy } from '../src/core/index.mjs' +import { flags } from '../src/commands/index.mjs' import { AccountBalanceQuery, AccountCreateTransaction, Hbar, HbarUnit, diff --git a/test/unit/commands/init.test.mjs b/test/unit/commands/init.test.mjs index 5521348a1..ee51ef834 100644 --- a/test/unit/commands/init.test.mjs +++ b/test/unit/commands/init.test.mjs @@ -66,9 +66,9 @@ describe('InitCommand', () => { }, 20000) }) - describe('static', () => { + describe('methods', () => { it('command definition should return a valid command def', async () => { - const def = InitCommand.getCommandDefinition(initCmd) + const def = initCmd.getCommandDefinition() expect(def.name).not.toBeNull() expect(def.desc).not.toBeNull() expect(def.handler).not.toBeNull()