@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
497 lines (439 loc) • 19.5 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
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 {type ConfigManager} from './config-manager.js';
import {type K8Factory} from '../integration/kube/k8-factory.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 {type SoloLogger} from './logging/solo-logger.js';
import {type NodeAlias} from '../types/aliases.js';
import {inject, injectable} from 'tsyringe-neo';
import {patchInject} from './dependency-injection/container-helper.js';
import {NamespaceName} from '../types/namespace/namespace-name.js';
import {type PodReference} from '../integration/kube/resources/pod/pod-reference.js';
import {ContainerReference} from '../integration/kube/resources/container/container-reference.js';
import {type ContainerName} from '../integration/kube/resources/container/container-name.js';
import {SecretType} from '../integration/kube/resources/secret/secret-type.js';
import {InjectTokens} from './dependency-injection/inject-tokens.js';
import {type ConsensusNode} from './model/consensus-node.js';
import {PathEx} from '../business/utils/path-ex.js';
import {PackageDownloader} from './package-downloader.js';
import {Containers} from '../integration/kube/resources/container/containers.js';
import {Container} from '../integration/kube/resources/container/container.js';
/** PlatformInstaller install platform code in the root-container of a network pod */
()
export class PlatformInstaller {
public constructor(
(InjectTokens.SoloLogger) private logger?: SoloLogger,
(InjectTokens.K8Factory) private k8Factory?: K8Factory,
(InjectTokens.ConfigManager) private configManager?: ConfigManager,
(InjectTokens.PackageDownloader) private 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);
}
private _getNamespace(): NamespaceName {
const ns: NamespaceName = this.configManager.getFlag<NamespaceName>(flags.namespace);
if (!ns) {
throw new MissingArgumentError('namespace is not set');
}
return ns;
}
public validatePlatformReleaseDir(releaseDirectory: string): void {
if (!releaseDirectory) {
throw new MissingArgumentError('releaseDirectory is required');
}
if (!fs.existsSync(releaseDirectory)) {
throw new IllegalArgumentError('releaseDirectory does not exists', releaseDirectory);
}
const dataDirectory: string = `${releaseDirectory}/data`;
const appsDirectory: string = `${releaseDirectory}/${constants.HEDERA_DATA_APPS_DIR}`;
const libraryDirectory: string = `${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: string[] = fs
.readdirSync(appsDirectory)
.filter((file: string): boolean => 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: string[] = fs
.readdirSync(libraryDirectory)
.filter((file: string): boolean => file.endsWith('.jar'));
if (libraryJarFiles.length === 0) {
throw new IllegalArgumentError(
`No jar files found in '${constants.HEDERA_DATA_LIB_DIR}' in releaseDir: ${releaseDirectory}`,
releaseDirectory,
);
}
}
public async getPlatformRelease(stagingDirectory: string, tag: string): Promise<string[]> {
if (!tag) {
throw new MissingArgumentError('tag is required');
}
// Download the platform zip client-side into {stagingDir}/build/
const buildDirectory: string = PathEx.join(stagingDirectory ?? constants.SOLO_CACHE_DIR, 'build');
if (!fs.existsSync(buildDirectory)) {
fs.mkdirSync(buildDirectory, {recursive: true});
}
const zipPath: string = 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: string = PathEx.join(buildDirectory, `build-${tag}.sha384`);
if (!fs.existsSync(checksumPath)) {
const releaseDirectory: string = Templates.prepareReleasePrefix(tag);
const checksumURL: string = `${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 */
public async fetchPlatform(
podReference: PodReference,
tag: string,
zipPath: string,
checksumPath: string,
context?: string,
) {
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: string = 'extract-platform.sh';
const sourcePath: string = PathEx.joinWithRealPath(constants.RESOURCES_DIR, scriptName);
await this.copyFiles(podReference, [sourcePath], constants.HEDERA_USER_HOME_DIR, undefined, context);
const extractScript: string = `${constants.HEDERA_USER_HOME_DIR}/${scriptName}`; // inside the container
const containerReference: ContainerReference = ContainerReference.of(podReference, constants.ROOT_CONTAINER);
const k8Containers: Containers = this.k8Factory.getK8(context).containers();
const container: 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: string = `${constants.HEDERA_HAPI_PATH}/output/extract-platform.log`;
const response: string = 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: string = `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
*/
public async copyFiles(
podReference: PodReference,
sourceFiles: string[],
destinationDirectory: string,
container: ContainerName = constants.ROOT_CONTAINER,
context?: string,
): Promise<string[]> {
try {
const containerReference: ContainerReference = ContainerReference.of(podReference, container);
const copiedFiles: string[] = [];
// prepare the file mapping
for (const sourcePath of sourceFiles) {
if (!fs.existsSync(sourcePath)) {
throw new SoloError(`file does not exist: ${sourcePath}`);
}
const k8Containers: Containers = 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: string = 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,
);
}
}
public async copyGossipKeys(
consensusNode: ConsensusNode,
stagingDirectory: string,
consensusNodes: ConsensusNode[],
): Promise<void> {
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: string[] = [
PathEx.joinWithRealPath(
stagingDirectory,
'keys',
Templates.renderGossipPemPrivateKeyFile(consensusNode.name as NodeAlias),
),
];
// copy all public keys for all nodes
for (const consensusNode of consensusNodes) {
sourceFiles.push(
PathEx.joinWithRealPath(
stagingDirectory,
'keys',
Templates.renderGossipPemPublicKeyFile(consensusNode.name as NodeAlias),
),
);
}
const data: Record<string, string> = {};
for (const sourceFile of sourceFiles) {
const fileContents: Buffer = fs.readFileSync(sourceFile);
const fileName: string = path.basename(sourceFile);
// @ts-expect-error - Dynamic key assignment is intentional
data[fileName] = Base64.encode(fileContents);
}
const secretCreated: boolean = await this.k8Factory
.getK8(consensusNode.context)
.secrets()
.createOrReplace(
NamespaceName.of(consensusNode.namespace),
Templates.renderGossipKeySecretName(consensusNode.name as NodeAlias),
SecretType.OPAQUE,
data,
Templates.renderGossipKeySecretLabelObject(consensusNode.name as NodeAlias),
);
if (!secretCreated) {
throw new SoloError(`failed to create secret for gossip keys for node '${consensusNode.name}'`);
}
} catch (error) {
const errorMessage: string = error instanceof Error ? error.message : String(error);
const message: string = `failed to copy gossip keys to secret '${Templates.renderGossipKeySecretName(consensusNode.name as NodeAlias)}': ${errorMessage}`;
throw new SoloError(message, error);
}
}
public async copyTLSKeys(
consensusNodes: ConsensusNode[],
stagingDirectory: string,
contexts: string[],
): Promise<void> {
if (!consensusNodes || consensusNodes.length <= 0) {
throw new MissingArgumentError('consensusNodes cannot be empty');
}
if (!stagingDirectory) {
throw new MissingArgumentError('stagingDirectory is required');
}
try {
const data: Record<string, string> = {};
for (const consensusNode of consensusNodes) {
const sourceFiles: string[] = [
PathEx.joinWithRealPath(
stagingDirectory,
'keys',
Templates.renderTLSPemPrivateKeyFile(consensusNode.name as NodeAlias),
),
PathEx.joinWithRealPath(
stagingDirectory,
'keys',
Templates.renderTLSPemPublicKeyFile(consensusNode.name as NodeAlias),
),
];
for (const sourceFile of sourceFiles) {
const fileContents: Buffer = fs.readFileSync(sourceFile);
const fileName: string = path.basename(sourceFile);
// @ts-expect-error - Dynamic key assignment is intentional
data[fileName] = Base64.encode(fileContents);
}
}
for (const context of contexts) {
const secretCreated: boolean = 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: unknown) {
throw new SoloError('failed to copy TLS keys to secret', error);
}
}
public async setPathPermission(
podReference: PodReference,
destinationPath: string,
mode: string = '0755',
recursive: boolean = true,
container: ContainerName = constants.ROOT_CONTAINER,
context?: string,
): Promise<boolean> {
if (!podReference) {
throw new MissingArgumentError('podReference is required');
}
if (!destinationPath) {
throw new MissingArgumentError('destPath is required');
}
const containerReference: ContainerReference = ContainerReference.of(podReference, container);
const recursiveFlag: string = recursive ? '-R' : '';
const k8Containers: Containers = 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;
}
public async setPlatformDirPermissions(podReference: PodReference, context?: string): Promise<boolean> {
if (!podReference) {
throw new MissingArgumentError('podReference is required');
}
try {
const destinationPaths: string[] = [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 */
public taskSetup(podReference: PodReference, stagingDirectory: string, isGenesis: boolean, context?: string): Listr {
return new Listr(
[
{
title: 'Copy configuration files',
task: async (): Promise<void> =>
await this.copyConfigurationFiles(stagingDirectory, podReference, isGenesis, context),
},
{
title: 'Set file permissions',
task: async (): Promise<boolean> => 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
*/
private async copyConfigurationFiles(
stagingDirectory: string,
podReference: PodReference,
isGenesis: boolean,
context?: string,
): Promise<void> {
if (isGenesis) {
const genesisNetworkJson: string[] = [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
*/
public copyNodeKeys(stagingDirectory: string, consensusNodes: ConsensusNode[], contexts: string[]): any[] {
const subTasks: any[] = [
{
title: 'Copy TLS keys',
task: async (): Promise<void> => 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;
}
}