@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
300 lines • 18.1 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import { expect } from 'chai';
import { after, before, describe, it } from 'mocha';
import fs from 'node:fs';
import * as yaml from 'yaml';
import { Flags as flags } from '../../../src/commands/flags.js';
import { ProfileManager } from '../../../src/core/profile-manager.js';
import { getTemporaryDirectory, getTestCacheDirectory } from '../../test-utility.js';
import * as version from '../../../version.js';
import { container } from 'tsyringe-neo';
import { resetForTest } from '../../test-container.js';
import { Templates } from '../../../src/core/templates.js';
import { NamespaceName } from '../../../src/types/namespace/namespace-name.js';
import { InjectTokens } from '../../../src/core/dependency-injection/inject-tokens.js';
import { KubeConfig } from '@kubernetes/client-node';
import sinon from 'sinon';
import { PathEx } from '../../../src/business/utils/path-ex.js';
import * as constants from '../../../src/core/constants.js';
import { Address } from '../../../src/business/address/address.js';
function invokeExtractSavedEndpoint(manager, consensusNode, nodeSequence) {
const extractSavedEndpoint = manager.extractSavedEndpoint;
return extractSavedEndpoint.call(manager, consensusNode, nodeSequence);
}
describe('ProfileManager', () => {
let temporaryDirectory, configManager, profileManager, cacheDirectory;
const namespace = NamespaceName.of('test-namespace');
const deploymentName = 'deployment';
const kubeConfig = new KubeConfig();
kubeConfig.loadFromDefault();
const consensusNodes = [
{
name: 'node1',
nodeId: 1,
namespace: namespace.name,
cluster: kubeConfig.getCurrentCluster().name,
context: kubeConfig.getCurrentContext(),
dnsBaseDomain: 'cluster.local',
dnsConsensusNodePattern: 'network-{nodeAlias}-svc.{namespace}.svc',
fullyQualifiedDomainName: 'network-node1-svc.test-namespace.svc.cluster.local',
blockNodeMap: [],
externalBlockNodeMap: [],
},
{
name: 'node2',
nodeId: 2,
namespace: namespace.name,
cluster: kubeConfig.getCurrentCluster().name,
context: kubeConfig.getCurrentContext(),
dnsBaseDomain: 'cluster.local',
dnsConsensusNodePattern: 'network-{nodeAlias}-svc.{namespace}.svc',
fullyQualifiedDomainName: 'network-node2-svc.test-namespace.svc.cluster.local',
blockNodeMap: [],
externalBlockNodeMap: [],
},
{
name: 'node3',
nodeId: 3,
namespace: namespace.name,
cluster: kubeConfig.getCurrentCluster().name,
context: kubeConfig.getCurrentContext(),
dnsBaseDomain: 'cluster.local',
dnsConsensusNodePattern: 'network-{nodeAlias}-svc.{namespace}.svc',
fullyQualifiedDomainName: 'network-node3-svc.test-namespace.svc.cluster.local',
blockNodeMap: [],
externalBlockNodeMap: [],
},
];
let stagingDirectory = '';
before(async () => {
resetForTest(namespace.name);
temporaryDirectory = getTemporaryDirectory();
configManager = container.resolve(InjectTokens.ConfigManager);
profileManager = new ProfileManager(undefined, undefined, temporaryDirectory);
configManager.setFlag(flags.nodeAliasesUnparsed, 'node1,node2,node4');
configManager.setFlag(flags.cacheDir, getTestCacheDirectory('ProfileManager'));
configManager.setFlag(flags.releaseTag, version.HEDERA_PLATFORM_VERSION);
cacheDirectory = configManager.getFlag(flags.cacheDir);
configManager.setFlag(flags.apiPermissionProperties, flags.apiPermissionProperties.definition.defaultValue);
configManager.setFlag(flags.applicationEnv, flags.applicationEnv.definition.defaultValue);
configManager.setFlag(flags.applicationProperties, flags.applicationProperties.definition.defaultValue);
configManager.setFlag(flags.bootstrapProperties, flags.bootstrapProperties.definition.defaultValue);
configManager.setFlag(flags.log4j2Xml, flags.log4j2Xml.definition.defaultValue);
configManager.setFlag(flags.settingTxt, flags.settingTxt.definition.defaultValue);
stagingDirectory = Templates.renderStagingDir(configManager.getFlag(flags.cacheDir), configManager.getFlag(flags.releaseTag));
if (!fs.existsSync(stagingDirectory)) {
fs.mkdirSync(stagingDirectory, { recursive: true });
}
// @ts-expect-error - TS2339: to mock
profileManager.remoteConfig.getConsensusNodes = sinon.stub().returns(consensusNodes);
// @ts-expect-error - TS2339: to mock
profileManager.remoteConfig.configuration = {
// @ts-expect-error - TS2339: to mock
state: {},
versions: {
// @ts-expect-error - TS2339: to mock
consensusNode: version.HEDERA_PLATFORM_VERSION,
},
};
// @ts-expect-error - TS2339: to mock
profileManager.updateApplicationPropertiesForBlockNode = sinon.stub();
const localConfig = container.resolve(InjectTokens.LocalConfigRuntimeState);
await localConfig.load();
});
after(() => {
fs.rmSync(temporaryDirectory, { recursive: true });
});
describe('determine chart values', () => {
it('should determine Solo chart values', async () => {
configManager.setFlag(flags.namespace, 'test-namespace');
const resources = ['templates'];
for (const directoryName of resources) {
const sourceDirectory = PathEx.joinWithRealPath(PathEx.join('resources'), directoryName);
if (!fs.existsSync(sourceDirectory)) {
continue;
}
const destinationDirectory = PathEx.resolve(PathEx.join(cacheDirectory, directoryName));
if (!fs.existsSync(destinationDirectory)) {
fs.mkdirSync(destinationDirectory, { recursive: true });
}
fs.cpSync(sourceDirectory, destinationDirectory, { recursive: true });
}
const applicationPropertiesFile = PathEx.join(cacheDirectory, 'templates', constants.APPLICATION_PROPERTIES);
const valuesFileMapping = await profileManager.prepareValuesForSoloChart(consensusNodes, deploymentName, applicationPropertiesFile);
const valuesFile = Object.values(valuesFileMapping)[0];
expect(valuesFile).not.to.be.null;
expect(fs.existsSync(valuesFile)).to.be.ok;
// validate the yaml
const valuesYaml = yaml.parse(fs.readFileSync(valuesFile, 'utf8'));
expect(valuesYaml.hedera.nodes.length).to.equal(3);
});
it('prepareValuesForSoloChart should set the value of a key to the contents of a file', async () => {
configManager.setFlag(flags.namespace, 'test-namespace');
const file = PathEx.join(temporaryDirectory, 'application.env');
const fileContents = '# row 1\n# row 2\n# row 3';
fs.writeFileSync(file, fileContents);
configManager.setFlag(flags.applicationEnv, file);
const destinationFile = PathEx.join(stagingDirectory, 'templates', 'application.env');
const applicationPropertiesFile = PathEx.join(stagingDirectory, 'templates', constants.APPLICATION_PROPERTIES);
fs.cpSync(file, destinationFile, { force: true });
const cachedValuesFileMapping = await profileManager.prepareValuesForSoloChart(consensusNodes, deploymentName, applicationPropertiesFile);
const cachedValuesFile = Object.values(cachedValuesFileMapping)[0];
const valuesYaml = yaml.parse(fs.readFileSync(cachedValuesFile, 'utf8'));
expect(valuesYaml.hedera.configMaps.applicationEnv).to.equal(fileContents);
});
});
describe('prepareConfigText', () => {
it('should write and return the path to the config.txt file', async () => {
const destinationPath = PathEx.join(temporaryDirectory, 'staging');
fs.mkdirSync(destinationPath, { recursive: true });
});
});
describe('saved endpoint extraction', () => {
afterEach(() => {
sinon.restore();
});
it('reuses saved domainName endpoint from network.json', async () => {
const savedDomainName = 'network-node1-svc.test-namespace.svc.cluster.local';
const networkJsonContent = JSON.stringify({
nodeMetadata: [{ rosterEntry: { gossipEndpoint: [{ port: 50_211, domainName: savedDomainName }] } }],
});
const getK8Stub = sinon.stub().returns({
pods: () => ({
list: async () => [{ podReference: {} }],
}),
containers: () => ({
readByRef: () => ({
execContainer: async () => networkJsonContent,
}),
}),
});
sinon
.stub(profileManager.k8Factory, 'getK8')
.callsFake(getK8Stub);
const savedAddress = await invokeExtractSavedEndpoint(profileManager, consensusNodes[0], 0);
expect(savedAddress).to.not.be.undefined;
expect(savedAddress?.hostString()).to.equal(savedDomainName);
expect(savedAddress?.port).to.equal(50_211);
});
it('decodes saved ipAddressV4 and validates it against the expected node service', async () => {
const savedIpAddress = '10.1.2.3';
const encodedIpAddress = Buffer.from([10, 1, 2, 3]).toString('base64');
const networkJsonContent = JSON.stringify({
nodeMetadata: [{ rosterEntry: { gossipEndpoint: [{ port: 50_211, ipAddressV4: encodedIpAddress }] } }],
});
const serviceReadStub = sinon.stub().resolves({ spec: { clusterIP: savedIpAddress } });
const getK8Stub = sinon.stub().returns({
pods: () => ({
list: async () => [{ podReference: {} }],
}),
containers: () => ({
readByRef: () => ({
execContainer: async () => networkJsonContent,
}),
}),
services: () => ({
read: serviceReadStub,
}),
});
sinon
.stub(profileManager.k8Factory, 'getK8')
.callsFake(getK8Stub);
const savedAddress = await invokeExtractSavedEndpoint(profileManager, consensusNodes[0], 0);
expect(savedAddress).to.not.be.undefined;
expect(savedAddress?.hostString()).to.equal(savedIpAddress);
expect(serviceReadStub.calledOnce).to.equal(true);
expect(serviceReadStub.firstCall.args[1]).to.equal('network-node1-svc');
});
it('falls back to current external address when saved endpoint is not reusable', async () => {
const destinationPath = PathEx.join(temporaryDirectory, 'config-fallback');
fs.mkdirSync(destinationPath, { recursive: true });
const extractSavedEndpointStub = sinon
.stub(profileManager, 'extractSavedEndpoint')
.resolves();
const externalAddressStub = sinon
.stub(Address, 'getExternalAddress')
.resolves(new Address(50_211, 'fallback-node1.test'));
const nodeAccountMap = new Map([[consensusNodes[0].name, '0.0.3']]);
const configTxtPath = await profileManager.prepareConfigTxt(nodeAccountMap, [consensusNodes[0]], destinationPath, version.HEDERA_PLATFORM_VERSION, constants.HEDERA_APP_NAME, constants.HEDERA_CHAIN_ID, false);
expect(extractSavedEndpointStub.calledOnce).to.equal(true);
expect(externalAddressStub.calledOnce).to.equal(true);
expect(externalAddressStub.firstCall.args[3]).to.equal(false);
expect(fs.readFileSync(configTxtPath, 'utf8')).to.contain('fallback-node1.test, 50211');
});
});
describe('chainId updates', () => {
it('should update contracts.chainId in application.properties', async () => {
const applicationPropertiesPath = PathEx.join(temporaryDirectory, constants.APPLICATION_PROPERTIES);
fs.writeFileSync(applicationPropertiesPath, ['hedera.realm=0', 'contracts.chainId=295', 'hedera.shard=0'].join('\n') + '\n', 'utf8');
// @ts-expect-error to access private method
await profileManager.updateApplicationPropertiesWithChainId(applicationPropertiesPath, '296');
const updated = fs.readFileSync(applicationPropertiesPath, 'utf8');
expect(updated).to.contain('contracts.chainId=296');
expect(updated).not.to.contain('contracts.chainId=295');
});
it('should update contracts.chainId in bootstrap.properties', async () => {
const bootstrapPropertiesPath = PathEx.join(temporaryDirectory, 'bootstrap.properties');
fs.writeFileSync(bootstrapPropertiesPath, ['foo=bar', 'contracts.chainId=295', 'baz=qux'].join('\n') + '\n', 'utf8');
// @ts-expect-error to access private method
await profileManager.updateBoostrapPropertiesWithChainId(bootstrapPropertiesPath, '296');
const updated = fs.readFileSync(bootstrapPropertiesPath, 'utf8');
expect(updated).to.contain('contracts.chainId=296');
expect(updated).not.to.contain('contracts.chainId=295');
});
it('prepareStagingDirectory should update chainId in staged application.properties and bootstrap.properties', async () => {
const yamlRoot = {};
const nodeAliases = ['node1', 'node2', 'node3'];
const sourceDirectory = PathEx.join(temporaryDirectory, 'source-files');
fs.mkdirSync(sourceDirectory, { recursive: true });
const applicationPropertiesSourcePath = PathEx.join(sourceDirectory, constants.APPLICATION_PROPERTIES);
const bootstrapPropertiesSourcePath = PathEx.join(sourceDirectory, 'bootstrap.properties');
// eslint-disable-next-line unicorn/prevent-abbreviations
const applicationEnvSourcePath = PathEx.join(sourceDirectory, 'application.env');
const apiPermissionSourcePath = PathEx.join(sourceDirectory, 'api-permission.properties');
// eslint-disable-next-line unicorn/prevent-abbreviations
const log4j2SourcePath = PathEx.join(sourceDirectory, 'log4j2.xml');
const settingsSourcePath = PathEx.join(sourceDirectory, 'settings.txt');
fs.writeFileSync(applicationPropertiesSourcePath, ['hedera.realm=0', 'hedera.shard=0', 'contracts.chainId=295'].join('\n') + '\n', 'utf8');
fs.writeFileSync(bootstrapPropertiesSourcePath, ['contracts.chainId=295', 'some.other.value=true'].join('\n') + '\n', 'utf8');
fs.writeFileSync(applicationEnvSourcePath, 'ENV_ONE=value1\n', 'utf8');
fs.writeFileSync(apiPermissionSourcePath, 'dummy.permission=true\n', 'utf8');
fs.writeFileSync(log4j2SourcePath, '<Configuration />\n', 'utf8');
fs.writeFileSync(settingsSourcePath, 'swirld, 123\n', 'utf8');
configManager.setFlag(flags.applicationProperties, applicationPropertiesSourcePath);
configManager.setFlag(flags.bootstrapProperties, bootstrapPropertiesSourcePath);
configManager.setFlag(flags.applicationEnv, applicationEnvSourcePath);
configManager.setFlag(flags.apiPermissionProperties, apiPermissionSourcePath);
configManager.setFlag(flags.log4j2Xml, log4j2SourcePath);
configManager.setFlag(flags.settingTxt, settingsSourcePath);
configManager.setFlag(flags.chainId, '296');
// @ts-expect-error to access private property
sinon.stub(profileManager.accountManager, 'getNodeAccountMap').returns(new Map([
['node1', '0.0.3'],
['node2', '0.0.4'],
['node3', '0.0.5'],
]));
// @ts-expect-error to access private property
sinon.stub(profileManager.localConfig.configuration, 'realmForDeployment').returns(0);
// @ts-expect-error to access private property
sinon.stub(profileManager.localConfig.configuration, 'shardForDeployment').returns(0);
await profileManager.prepareStagingDirectory(consensusNodes, nodeAliases, yamlRoot, deploymentName, applicationPropertiesSourcePath, {
cacheDir: cacheDirectory,
releaseTag: version.HEDERA_PLATFORM_VERSION,
appName: 'HederaNode.jar',
chainId: '296',
});
const stagedApplicationPropertiesPath = PathEx.join(stagingDirectory, 'templates', constants.APPLICATION_PROPERTIES);
const stagedBootstrapPropertiesPath = PathEx.join(stagingDirectory, 'templates', 'bootstrap.properties');
const stagedApplicationProperties = fs.readFileSync(stagedApplicationPropertiesPath, 'utf8');
const stagedBootstrapProperties = fs.readFileSync(stagedBootstrapPropertiesPath, 'utf8');
expect(stagedApplicationProperties).to.contain('contracts.chainId=296');
expect(stagedApplicationProperties).not.to.contain('contracts.chainId=295');
expect(stagedBootstrapProperties).to.contain('contracts.chainId=296');
expect(stagedBootstrapProperties).not.to.contain('contracts.chainId=295');
expect(yamlRoot.hedera.configMaps.applicationProperties).to.contain('contracts.chainId=296');
expect(yamlRoot.hedera.configMaps.bootstrapProperties).to.contain('contracts.chainId=296');
sinon.restore();
});
});
});
//# sourceMappingURL=profile-manager.test.js.map