@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
311 lines • 19.3 kB
JavaScript
// 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