UNPKG

@hashgraph/solo

Version:

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

374 lines 19.8 kB
// SPDX-License-Identifier: Apache-2.0 var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import * as fs from 'node:fs'; import { Listr } from 'listr2'; import * as path from 'node:path'; import { IllegalArgumentError } from './errors/illegal-argument-error.js'; import { MissingArgumentError } from './errors/missing-argument-error.js'; import { SoloError } from './errors/solo-error.js'; import * as constants from './constants.js'; import { Templates } from './templates.js'; import { Flags as flags } from '../commands/flags.js'; import * as Base64 from 'js-base64'; import chalk from 'chalk'; import { inject, injectable } from 'tsyringe-neo'; import { patchInject } from './dependency-injection/container-helper.js'; import { NamespaceName } from '../types/namespace/namespace-name.js'; import { ContainerReference } from '../integration/kube/resources/container/container-reference.js'; import { SecretType } from '../integration/kube/resources/secret/secret-type.js'; import { InjectTokens } from './dependency-injection/inject-tokens.js'; import { PathEx } from '../business/utils/path-ex.js'; import { PackageDownloader } from './package-downloader.js'; /** PlatformInstaller install platform code in the root-container of a network pod */ let PlatformInstaller = class PlatformInstaller { logger; k8Factory; configManager; packageDownloader; constructor(logger, k8Factory, configManager, packageDownloader) { this.logger = logger; this.k8Factory = k8Factory; this.configManager = configManager; this.packageDownloader = packageDownloader; this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name); this.k8Factory = patchInject(k8Factory, InjectTokens.K8Factory, this.constructor.name); this.configManager = patchInject(configManager, InjectTokens.ConfigManager, this.constructor.name); this.packageDownloader = patchInject(packageDownloader, InjectTokens.PackageDownloader, this.constructor.name); } _getNamespace() { const ns = this.configManager.getFlag(flags.namespace); if (!ns) { throw new MissingArgumentError('namespace is not set'); } return ns; } validatePlatformReleaseDir(releaseDirectory) { if (!releaseDirectory) { throw new MissingArgumentError('releaseDirectory is required'); } if (!fs.existsSync(releaseDirectory)) { throw new IllegalArgumentError('releaseDirectory does not exists', releaseDirectory); } const dataDirectory = `${releaseDirectory}/data`; const appsDirectory = `${releaseDirectory}/${constants.HEDERA_DATA_APPS_DIR}`; const libraryDirectory = `${releaseDirectory}/${constants.HEDERA_DATA_LIB_DIR}`; if (!fs.existsSync(dataDirectory)) { throw new IllegalArgumentError('releaseDirectory does not have data directory', releaseDirectory); } if (!fs.existsSync(appsDirectory)) { throw new IllegalArgumentError(`'${constants.HEDERA_DATA_APPS_DIR}' missing in '${releaseDirectory}'`, releaseDirectory); } if (!fs.existsSync(libraryDirectory)) { throw new IllegalArgumentError(`'${constants.HEDERA_DATA_LIB_DIR}' missing in '${releaseDirectory}'`, releaseDirectory); } const appsJarFiles = fs .readdirSync(appsDirectory) .filter((file) => file.endsWith('.jar')); if (appsJarFiles.length === 0) { throw new IllegalArgumentError(`No jar files found in '${constants.HEDERA_DATA_APPS_DIR}' in releaseDir: ${releaseDirectory}`, releaseDirectory); } const libraryJarFiles = fs .readdirSync(libraryDirectory) .filter((file) => file.endsWith('.jar')); if (libraryJarFiles.length === 0) { throw new IllegalArgumentError(`No jar files found in '${constants.HEDERA_DATA_LIB_DIR}' in releaseDir: ${releaseDirectory}`, releaseDirectory); } } async getPlatformRelease(stagingDirectory, tag) { if (!tag) { throw new MissingArgumentError('tag is required'); } // Download the platform zip client-side into {stagingDir}/build/ const buildDirectory = PathEx.join(stagingDirectory ?? constants.SOLO_CACHE_DIR, 'build'); if (!fs.existsSync(buildDirectory)) { fs.mkdirSync(buildDirectory, { recursive: true }); } const zipPath = await this.packageDownloader.fetchPlatform(tag, buildDirectory); // Ensure the checksum file is also present (fetchPlatform returns early on cache hit without re-downloading it) const checksumPath = PathEx.join(buildDirectory, `build-${tag}.sha384`); if (!fs.existsSync(checksumPath)) { const releaseDirectory = Templates.prepareReleasePrefix(tag); const checksumURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDirectory}/build-${tag}.sha384`; await this.packageDownloader.fetchFile(checksumURL, checksumPath); } return [zipPath, checksumPath]; } /** Fetch and extract platform code into the container */ async fetchPlatform(podReference, tag, zipPath, checksumPath, context) { if (!podReference) { throw new MissingArgumentError('podReference is required'); } if (!tag) { throw new MissingArgumentError('tag is required'); } if (!zipPath) { throw new IllegalArgumentError('zipPath is required'); } if (!checksumPath) { throw new IllegalArgumentError('checksumPath is required'); } try { // Upload zip and checksum to the container — extract-platform.sh expects them in HEDERA_USER_HOME_DIR await this.copyFiles(podReference, [zipPath, checksumPath], constants.HEDERA_USER_HOME_DIR, undefined, context); const scriptName = 'extract-platform.sh'; const sourcePath = PathEx.joinWithRealPath(constants.RESOURCES_DIR, scriptName); await this.copyFiles(podReference, [sourcePath], constants.HEDERA_USER_HOME_DIR, undefined, context); const extractScript = `${constants.HEDERA_USER_HOME_DIR}/${scriptName}`; // inside the container const containerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER); const k8Containers = this.k8Factory.getK8(context).containers(); const container = k8Containers.readByRef(containerReference); await container.execContainer('sync'); // ensure all writes are flushed before executing the script await container.execContainer(`chmod +x ${extractScript}`); await container.execContainer(`chown root:root ${extractScript}`); await container.execContainer([extractScript, tag]); return true; } catch (error) { const logFile = `${constants.HEDERA_HAPI_PATH}/output/extract-platform.log`; const response = await this.k8Factory .getK8(context) .containers() .readByRef(ContainerReference.of(podReference, constants.ROOT_CONTAINER)) .execContainer(['bash', '-c', `cat ${logFile} || echo "Log file not found or empty"`]); this.logger.showUser(`Log file content from ${logFile}:\n${response}`); const message = `failed to extract platform code in this pod '${podReference}' while using the '${context}' context: ${error.message}`; throw new SoloError(message, error); } } /** * Copy a list of files to a directory in the container * @param podReference - pod reference * @param sourceFiles - list of source files * @param destinationDirectory - destination directory * @param [container] - name of the container * @param [context] * @returns a list of paths of the copied files insider the container */ async copyFiles(podReference, sourceFiles, destinationDirectory, container = constants.ROOT_CONTAINER, context) { try { const containerReference = ContainerReference.of(podReference, container); const copiedFiles = []; // prepare the file mapping for (const sourcePath of sourceFiles) { if (!fs.existsSync(sourcePath)) { throw new SoloError(`file does not exist: ${sourcePath}`); } const k8Containers = this.k8Factory.getK8(context).containers(); if (!(await k8Containers.readByRef(containerReference).hasDir(destinationDirectory))) { await k8Containers.readByRef(containerReference).mkdir(destinationDirectory); } this.logger.debug(`Copying file into ${podReference.name}: ${sourcePath} -> ${destinationDirectory}`); await k8Containers.readByRef(containerReference).copyTo(sourcePath, destinationDirectory); const fileName = path.basename(sourcePath); copiedFiles.push(PathEx.join(destinationDirectory, fileName)); } return copiedFiles; } catch (error) { throw new SoloError(`failed to copy files: ${sourceFiles.join(', ')}, to ${podReference.name}:${destinationDirectory}: ${error.message}`, error); } } async copyGossipKeys(consensusNode, stagingDirectory, consensusNodes) { if (!consensusNode) { throw new MissingArgumentError('consensusNode is required'); } if (!stagingDirectory) { throw new MissingArgumentError('stagingDirectory is required'); } if (!consensusNodes || consensusNodes.length <= 0) { throw new MissingArgumentError('consensusNodes cannot be empty'); } try { // copy private keys for the node const sourceFiles = [ PathEx.joinWithRealPath(stagingDirectory, 'keys', Templates.renderGossipPemPrivateKeyFile(consensusNode.name)), ]; // copy all public keys for all nodes for (const consensusNode of consensusNodes) { sourceFiles.push(PathEx.joinWithRealPath(stagingDirectory, 'keys', Templates.renderGossipPemPublicKeyFile(consensusNode.name))); } const data = {}; for (const sourceFile of sourceFiles) { const fileContents = fs.readFileSync(sourceFile); const fileName = path.basename(sourceFile); // @ts-expect-error - Dynamic key assignment is intentional data[fileName] = Base64.encode(fileContents); } const secretCreated = await this.k8Factory .getK8(consensusNode.context) .secrets() .createOrReplace(NamespaceName.of(consensusNode.namespace), Templates.renderGossipKeySecretName(consensusNode.name), SecretType.OPAQUE, data, Templates.renderGossipKeySecretLabelObject(consensusNode.name)); if (!secretCreated) { throw new SoloError(`failed to create secret for gossip keys for node '${consensusNode.name}'`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const message = `failed to copy gossip keys to secret '${Templates.renderGossipKeySecretName(consensusNode.name)}': ${errorMessage}`; throw new SoloError(message, error); } } async copyTLSKeys(consensusNodes, stagingDirectory, contexts) { if (!consensusNodes || consensusNodes.length <= 0) { throw new MissingArgumentError('consensusNodes cannot be empty'); } if (!stagingDirectory) { throw new MissingArgumentError('stagingDirectory is required'); } try { const data = {}; for (const consensusNode of consensusNodes) { const sourceFiles = [ PathEx.joinWithRealPath(stagingDirectory, 'keys', Templates.renderTLSPemPrivateKeyFile(consensusNode.name)), PathEx.joinWithRealPath(stagingDirectory, 'keys', Templates.renderTLSPemPublicKeyFile(consensusNode.name)), ]; for (const sourceFile of sourceFiles) { const fileContents = fs.readFileSync(sourceFile); const fileName = path.basename(sourceFile); // @ts-expect-error - Dynamic key assignment is intentional data[fileName] = Base64.encode(fileContents); } } for (const context of contexts) { const secretCreated = await this.k8Factory .getK8(context) .secrets() .createOrReplace(this._getNamespace(), 'network-node-hapi-app-secrets', SecretType.OPAQUE, data); if (!secretCreated) { throw new SoloError('failed to create secret for TLS keys'); } } } catch (error) { throw new SoloError('failed to copy TLS keys to secret', error); } } async setPathPermission(podReference, destinationPath, mode = '0755', recursive = true, container = constants.ROOT_CONTAINER, context) { if (!podReference) { throw new MissingArgumentError('podReference is required'); } if (!destinationPath) { throw new MissingArgumentError('destPath is required'); } const containerReference = ContainerReference.of(podReference, container); const recursiveFlag = recursive ? '-R' : ''; const k8Containers = this.k8Factory.getK8(context).containers(); await k8Containers .readByRef(containerReference) .execContainer(['bash', '-c', `chown ${recursiveFlag} hedera:hedera ${destinationPath} 2>/dev/null || true`]); await k8Containers .readByRef(containerReference) .execContainer(['bash', '-c', `chmod ${recursiveFlag} ${mode} ${destinationPath} 2>/dev/null || true`]); return true; } async setPlatformDirPermissions(podReference, context) { if (!podReference) { throw new MissingArgumentError('podReference is required'); } try { const destinationPaths = [constants.HEDERA_HAPI_PATH, constants.HEDERA_HGCAPP_DIR]; for (const destinationPath of destinationPaths) { await this.setPathPermission(podReference, destinationPath, undefined, undefined, undefined, context); } return true; } catch (error) { throw new SoloError(`failed to set permission in '${podReference.name}'`, error); } } /** Return a list of task to perform node directory setup */ taskSetup(podReference, stagingDirectory, isGenesis, context) { return new Listr([ { title: 'Copy configuration files', task: async () => await this.copyConfigurationFiles(stagingDirectory, podReference, isGenesis, context), }, { title: 'Set file permissions', task: async () => await this.setPlatformDirPermissions(podReference, context), }, ], { concurrent: false, rendererOptions: { collapseSubtasks: false, }, }); } /** * Copy configuration files to the network consensus node pod * @param stagingDirectory - staging directory path * @param podReference - pod reference * @param isGenesis - true if this is `solo consensus node setup` and we are at genesis * @param context */ async copyConfigurationFiles(stagingDirectory, podReference, isGenesis, context) { if (isGenesis) { const genesisNetworkJson = [PathEx.joinWithRealPath(stagingDirectory, 'genesis-network.json')]; await this.copyFiles(podReference, genesisNetworkJson, `${constants.HEDERA_HAPI_PATH}/data/config`, undefined, context); } // TODO: temporarily disable this until we add logic to only set this when the user provides the node override gossip endpoints for each node they want to override // const nodeOverridesYaml = [PathEx.joinWithRealPath(stagingDirectory, constants.NODE_OVERRIDE_FILE)]; // await this.copyFiles(podReference, nodeOverridesYaml, `${constants.HEDERA_HAPI_PATH}/data/config`, undefined, context); } /** * Return a list of task to copy the node keys to the staging directory * * It assumes the staging directory has the following files and resources: * <li>${staging}/keys/s-public-<nodeAlias>.pem: private signing key for a node</li> * <li>${staging}/keys/s-private-<nodeAlias>.pem: public signing key for a node</li> * <li>${staging}/keys/a-public-<nodeAlias>.pem: private agreement key for a node</li> * <li>${staging}/keys/a-private-<nodeAlias>.pem: public agreement key for a node</li> * <li>${staging}/keys/hedera-<nodeAlias>.key: gRPC TLS key for a node</li> * <li>${staging}/keys/hedera-<nodeAlias>.crt: gRPC TLS cert for a node</li> * * @param stagingDirectory staging directory path * @param consensusNodes list of consensus nodes * @param contexts list of k8s contexts */ copyNodeKeys(stagingDirectory, consensusNodes, contexts) { const subTasks = [ { title: 'Copy TLS keys', task: async () => await this.copyTLSKeys(consensusNodes, stagingDirectory, contexts), }, ]; for (const consensusNode of consensusNodes) { subTasks.push({ title: `Node: ${chalk.yellow(consensusNode.name)}, cluster: ${chalk.yellow(consensusNode.context)}`, task: () => new Listr([ { title: 'Copy Gossip keys', task: async () => await this.copyGossipKeys(consensusNode, stagingDirectory, consensusNodes), }, ], { concurrent: false, rendererOptions: { collapseSubtasks: false, }, }), }); } return subTasks; } }; PlatformInstaller = __decorate([ injectable(), __param(0, inject(InjectTokens.SoloLogger)), __param(1, inject(InjectTokens.K8Factory)), __param(2, inject(InjectTokens.ConfigManager)), __param(3, inject(InjectTokens.PackageDownloader)), __metadata("design:paramtypes", [Object, Object, Function, PackageDownloader]) ], PlatformInstaller); export { PlatformInstaller }; //# sourceMappingURL=platform-installer.js.map