UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

311 lines 19.3 kB
// SPDX-License-Identifier: Apache-2.0 import { BaseCommandTest } from './base-command-test.js'; import { Flags } from '../../../../src/commands/flags.js'; import { main } from '../../../../src/index.js'; import { Duration } from '../../../../src/core/time/duration.js'; import { NamespaceName } from '../../../../src/types/namespace/namespace-name.js'; import { InjectTokens } from '../../../../src/core/dependency-injection/inject-tokens.js'; import { sleep } from '../../../../src/core/helpers.js'; import http from 'node:http'; import { expect } from 'chai'; import { container } from 'tsyringe-neo'; import { MirrorCommandDefinition } from '../../../../src/commands/command-definitions/mirror-command-definition.js'; import * as constants from '../../../../src/core/constants.js'; import fs from 'node:fs'; import { ShellRunner } from '../../../../src/core/shell-runner.js'; import { ConsensusNodeTest } from './consensus-node-test.js'; import { Repository } from '../../../../src/integration/helm/model/repository.js'; import { Chart } from '../../../../src/integration/helm/model/chart.js'; import { InstallChartOptionsBuilder } from '../../../../src/integration/helm/model/install/install-chart-options-builder.js'; import { ContainerReference } from '../../../../src/integration/kube/resources/container/container-reference.js'; import { ContainerName } from '../../../../src/integration/kube/resources/container/container-name.js'; import { PodName } from '../../../../src/integration/kube/resources/pod/pod-name.js'; import { PodReference } from '../../../../src/integration/kube/resources/pod/pod-reference.js'; export class MirrorNodeTest extends BaseCommandTest { static soloMirrorNodeDeployArgv(testName, deployment, clusterReference, pinger, valuesFile) { const { newArgv, argvPushGlobalFlags, optionFromFlag } = MirrorNodeTest; const argv = newArgv(); argv.push(MirrorCommandDefinition.COMMAND_NAME, MirrorCommandDefinition.NODE_SUBCOMMAND_NAME, MirrorCommandDefinition.NODE_ADD, optionFromFlag(Flags.deployment), deployment, optionFromFlag(Flags.clusterRef), clusterReference, optionFromFlag(Flags.enableIngress)); if (pinger) { argv.push(optionFromFlag(Flags.pinger)); } if (valuesFile) { argv.push(optionFromFlag(Flags.valuesFile), valuesFile); } argvPushGlobalFlags(argv, testName, true, true); return argv; } static soloMirrorNodeDestroyArgv(testName, deployment, clusterReference) { const { newArgv, argvPushGlobalFlags, optionFromFlag } = MirrorNodeTest; const argv = newArgv(); argv.push(MirrorCommandDefinition.COMMAND_NAME, MirrorCommandDefinition.NODE_SUBCOMMAND_NAME, MirrorCommandDefinition.NODE_DESTROY, optionFromFlag(Flags.deployment), deployment, optionFromFlag(Flags.clusterRef), clusterReference, optionFromFlag(Flags.force), optionFromFlag(Flags.quiet), optionFromFlag(Flags.devMode)); argvPushGlobalFlags(argv, testName, false, true); return argv; } static async forwardRestServicePort(contexts, namespace) { const k8Factory = container.resolve(InjectTokens.K8Factory); const lastContext = contexts?.length ? contexts[contexts?.length - 1] : undefined; const k8 = k8Factory.getK8(lastContext); const mirrorNodeRestPods = await k8 .pods() .list(namespace, ['app.kubernetes.io/name=rest', 'app.kubernetes.io/component=rest']); expect(mirrorNodeRestPods).to.have.lengthOf(1); const portForwarder = await k8 .pods() .readByReference(mirrorNodeRestPods[0].podReference) .portForward(5551, 5551); await sleep(Duration.ofSeconds(2)); return portForwarder; } static async stopPortForward(contexts, portForwarder) { const k8Factory = container.resolve(InjectTokens.K8Factory); const k8 = k8Factory.getK8(contexts[contexts.length]); // eslint-disable-next-line unicorn/no-null await k8.pods().readByReference(null).stopPortForward(portForwarder); } static async verifyMirrorNodeDeployWasSuccessful(contexts, namespace, testLogger, createdAccountIds, consensusNodesCount) { const portForwarder = await MirrorNodeTest.forwardRestServicePort(contexts, namespace); try { const queryUrl = 'http://localhost:5551/api/v1/network/nodes'; let received = false; // wait until the transaction reached consensus and retrievable from the mirror node API while (!received) { const request = http.request(queryUrl, { method: 'GET', timeout: 100, headers: { Connection: 'close' } }, (response) => { response.setEncoding('utf8'); response.on('data', (chunk) => { // convert chunk to json object const object = JSON.parse(chunk); expect(object.nodes?.length, `expect there to be ${consensusNodesCount} nodes in the mirror node's copy of the address book`).to.equal(consensusNodesCount); expect(object.nodes[0].service_endpoints?.length, 'expect there to be at least one service endpoint').to.be.greaterThan(0); received = true; }); }); request.on('error', (error) => { testLogger.debug(`problem with request: ${error.message}`, error); }); request.end(); // make the request await sleep(Duration.ofSeconds(2)); } for (const accountId of createdAccountIds) { const accountQueryUrl = `http://localhost:5551/api/v1/accounts/${accountId}`; received = false; // wait until the transaction reached consensus and retrievable from the mirror node API while (!received) { const request = http.request(accountQueryUrl, { method: 'GET', timeout: 100, headers: { Connection: 'close' } }, (response) => { response.setEncoding('utf8'); response.on('data', (chunk) => { // convert chunk to json object const object = JSON.parse(chunk); expect(object.account, 'expect the created account to exist in the mirror nodes copy of the accounts').to.equal(accountId); received = true; }); }); request.on('error', (error) => { testLogger.debug(`problem with request: ${error.message}`, error); }); request.end(); // make the request await sleep(Duration.ofSeconds(2)); } await sleep(Duration.ofSeconds(1)); } } finally { if (portForwarder) { await MirrorNodeTest.stopPortForward(contexts, portForwarder); } } } static async verifyPingerStatus(contexts, namespace, pingerIsEnabled) { const portForwarder = await MirrorNodeTest.forwardRestServicePort(contexts, namespace); try { const transactionsEndpoint = 'http://localhost:5551/api/v1/transactions'; // force to fetch new data instead of using cache const fetchOptions = { cache: 'no-cache', headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate', Pragma: 'no-cache', Expires: '0', }, }; const firstResponse = await fetch(transactionsEndpoint, fetchOptions); // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstData = await firstResponse.json(); console.log('\r::group::Mirror node verify pinger status first data'); console.log(`firstData = ${JSON.stringify(firstData, undefined, 2)}`); console.log('\r::endgroup::'); await sleep(Duration.ofSeconds(15)); const secondResponse = await fetch(transactionsEndpoint, fetchOptions); // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondData = await secondResponse.json(); console.log('\r::group::Mirror node verify pinger status second data'); console.log(`secondData = ${JSON.stringify(secondData, undefined, 2)}`); console.log('\r::endgroup::'); expect(firstData.transactions).to.not.be.undefined; expect(firstData.transactions.length).to.be.gt(0); expect(secondData.transactions).to.not.be.undefined; expect(secondData.transactions.length).to.be.gt(0); if (pingerIsEnabled) { // Compare snapshots as sets so the check is resilient when the top row remains the same. const firstSnapshotTxIds = new Set(firstData.transactions .map((transaction) => transaction?.transaction_id) .filter((transactionId) => !!transactionId)); const secondSnapshotHasNewTx = secondData.transactions.some((transaction) => { const transactionId = transaction?.transaction_id; return !!transactionId && !firstSnapshotTxIds.has(transactionId); }); expect(secondSnapshotHasNewTx, 'expected second mirror snapshot to include at least one new transaction id when pinger is enabled').to.equal(true); } else { expect(firstData.transactions[0]).to.deep.equal(secondData.transactions[0]); } } finally { if (portForwarder) { await MirrorNodeTest.stopPortForward(contexts, portForwarder); } } } static add(options) { const { testName, testLogger, deployment, contexts, namespace, clusterReferenceNameArray, createdAccountIds, consensusNodesCount, pinger, valuesFile, } = options; const { soloMirrorNodeDeployArgv, verifyMirrorNodeDeployWasSuccessful, verifyPingerStatus } = MirrorNodeTest; it(`${testName}: mirror node add`, async () => { await main(soloMirrorNodeDeployArgv(testName, deployment, clusterReferenceNameArray[1], pinger, valuesFile)); await verifyMirrorNodeDeployWasSuccessful(contexts, namespace, testLogger, createdAccountIds, consensusNodesCount); await verifyPingerStatus(contexts, namespace, pinger); }).timeout(Duration.ofMinutes(10).toMillis()); } static destroy(options) { const { testName, deployment, clusterReferenceNameArray } = options; const { soloMirrorNodeDestroyArgv } = MirrorNodeTest; it(`${testName}: mirror node destroy`, async () => { await main(soloMirrorNodeDestroyArgv(testName, deployment, clusterReferenceNameArray[1])); }).timeout(Duration.ofMinutes(5).toMillis()); } static postgresPassword = 'XXXXXXX'; static postgresUsername = 'postgres'; static postgresReadonlyUsername = 'readonlyuser'; static postgresReadonlyPassword = 'XXXXXXXX'; static postgresHostFqdn = 'my-postgresql.database.svc.cluster.local'; static nameSpace = 'database'; static postgresName = 'my-postgresql'; static postgresContainerName = `${this.postgresName}-0`; static postgresMirrorNodeDatabaseName = 'mirror_node'; static getPostgresContainer(k8) { return k8 .containers() .readByRef(ContainerReference.of(PodReference.of(NamespaceName.of(this.nameSpace), PodName.of(this.postgresContainerName)), ContainerName.of('postgresql'))); } /** * Grants the readonly role to mirror_rest so the REST service can SELECT from tables * created by Flyway migrations after V1.0. * * The importer's V1.0__Init.sql creates mirror_rest without the readonly role, so it * has no access to any table added after that migration. The init.sh script sets default * privileges that automatically grant SELECT on new tables to the readonly role; granting * readonly to mirror_rest propagates those privileges. * * This must be called after main() returns (importer pod ready = migrations complete = * mirror_rest exists) and before verifyMirrorNodeDeployWasSuccessful. */ static async grantReadonlyRoleToMirrorRestUser(k8) { // Use a dollar-quoted block so the grant is safe even if mirror_rest already has the role. const grantSql = "DO $grant$ BEGIN IF EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'readonly') " + "AND EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'mirror_rest') " + 'THEN GRANT readonly TO mirror_rest; END IF; END $grant$;'; const postgresContainer = MirrorNodeTest.getPostgresContainer(k8); await postgresContainer.execContainer([ 'env', `PGPASSWORD=${MirrorNodeTest.postgresPassword}`, 'psql', '-U', MirrorNodeTest.postgresUsername, '-d', MirrorNodeTest.postgresMirrorNodeDatabaseName, '-c', grantSql, ]); } static deployWithExternalDatabase(options) { const { testName, testLogger, deployment, contexts, namespace, clusterReferenceNameArray, createdAccountIds, consensusNodesCount, pinger, valuesFile, } = options; const { soloMirrorNodeDeployArgv, verifyMirrorNodeDeployWasSuccessful, verifyPingerStatus, optionFromFlag } = MirrorNodeTest; it(`${testName}: mirror node deploy with external database`, async () => { const argv = soloMirrorNodeDeployArgv(testName, deployment, clusterReferenceNameArray[1], pinger, valuesFile); process.env.USE_MIRROR_NODE_LEGACY_RELEASE_NAME = 'true'; // Add external database flags argv.push(optionFromFlag(Flags.enableIngress), optionFromFlag(Flags.useExternalDatabase), optionFromFlag(Flags.externalDatabaseHost), this.postgresHostFqdn, optionFromFlag(Flags.externalDatabaseOwnerUsername), this.postgresUsername, optionFromFlag(Flags.externalDatabaseOwnerPassword), this.postgresPassword, optionFromFlag(Flags.externalDatabaseReadonlyUsername), this.postgresReadonlyUsername, optionFromFlag(Flags.externalDatabaseReadonlyPassword), this.postgresReadonlyPassword); await main(argv); // The importer's V1.0__Init.sql migration creates the mirror_rest user without the readonly // role, so it lacks SELECT on tables created after V1.0 (e.g. entity, transaction, node). // Grant the readonly role now (after importer pod is ready = migrations are complete). const k8Factory = container.resolve(InjectTokens.K8Factory); const k8 = k8Factory.getK8(contexts[1]); await MirrorNodeTest.grantReadonlyRoleToMirrorRestUser(k8); await verifyMirrorNodeDeployWasSuccessful(contexts, namespace, testLogger, createdAccountIds, consensusNodesCount); await verifyPingerStatus(contexts, namespace, pinger); }).timeout(Duration.ofMinutes(10).toMillis()); it('Enable port-forward for mirror node gRPC', async () => { const k8Factory = container.resolve(InjectTokens.K8Factory); const k8 = k8Factory.getK8(contexts[1]); const mirrorNodePods = await k8 .pods() .list(namespace, ['app.kubernetes.io/name=grpc', 'app.kubernetes.io/component=grpc']); const mirrorNodePod = mirrorNodePods[0]; await k8.pods().readByReference(mirrorNodePod.podReference).portForward(5600, 5600); }); } static installPostgres(options) { const { contexts } = options; it('should install postgres chart', async () => { const k8Factory = container.resolve(InjectTokens.K8Factory); k8Factory.getK8(contexts[1]).contexts().updateCurrent(contexts[1]); const helm = container.resolve(InjectTokens.Helm); await helm.addRepository(new Repository('postgresql-helm', 'https://leverages.github.io/helm')); await helm.installChart('my-postgresql', new Chart('postgresql', 'postgresql-helm'), InstallChartOptionsBuilder.builder() .set(['deploymentType=local', `postgresql.auth.password=${this.postgresPassword}`]) .namespace(this.nameSpace) .createNamespace(true) .kubeContext(contexts[1]) .build()); const k8 = k8Factory.getK8(contexts[1]); await k8 .pods() .waitForReadyStatus(NamespaceName.of(this.nameSpace), ['app.kubernetes.io/name=postgresql'], constants.PODS_READY_MAX_ATTEMPTS, constants.PODS_READY_DELAY); const initScriptPath = 'scripts/external-database/init.sh'; // check if initScriptPath exist, otherwise throw error if (!fs.existsSync(initScriptPath)) { throw new Error(`Init script not found at path: ${initScriptPath}`); } const postgresContainer = MirrorNodeTest.getPostgresContainer(k8); await postgresContainer.copyTo(initScriptPath, '/tmp'); await postgresContainer.execContainer(['chmod', '+x', '/tmp/init.sh']); await postgresContainer.execContainer([ '/bin/bash', '/tmp/init.sh', this.postgresUsername, this.postgresReadonlyUsername, this.postgresReadonlyPassword, ]); }).timeout(Duration.ofMinutes(2).toMillis()); } static pullAddressBook(options) { const { consensusNodesCount } = options; it('should pull address book from mirror node', async () => { const srv = await MirrorNodeTest.forwardRestServicePort(options.contexts, options.namespace); const stdOut = await new ShellRunner().run(`curl http://localhost:${srv}/api/v1/network/nodes`); const addressBook = JSON.parse(stdOut.join('')); expect(addressBook.nodes.length).to.be.greaterThan(0); // Validate first alpha node (always node1, node_id=0). const alphaNode = addressBook.nodes.find((node) => node.node_id === 0); expect(alphaNode.grpc_proxy_endpoint.domain_name).to.equal(ConsensusNodeTest.alphaClusterGrpcWebAddress); expect(alphaNode.grpc_proxy_endpoint.port).to.equal(ConsensusNodeTest.baseGrpcWebPort); // Validate first beta node (node_id = ceil(N/2), i.e. the first node in cluster-beta). const alphaCount = Math.ceil(consensusNodesCount / 2); const betaNode = addressBook.nodes.find((node) => node.node_id === alphaCount); expect(betaNode.grpc_proxy_endpoint.domain_name).to.equal(ConsensusNodeTest.betaClusterGrpcWebAddress); expect(betaNode.grpc_proxy_endpoint.port).to.equal(ConsensusNodeTest.baseGrpcWebPort + alphaCount); await MirrorNodeTest.stopPortForward(options.contexts, srv); }); } } //# sourceMappingURL=mirror-node-test.js.map