@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
286 lines • 12.8 kB
JavaScript
// 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 crypto from 'node:crypto';
import * as fs from 'node:fs';
import { pipeline as streamPipeline } from 'node:stream/promises';
import got from 'got';
import path from 'node:path';
import { DataValidationError } from './errors/data-validation-error.js';
import { SoloError } from './errors/solo-error.js';
import { IllegalArgumentError } from './errors/illegal-argument-error.js';
import { MissingArgumentError } from './errors/missing-argument-error.js';
import { ResourceNotFoundError } from './errors/resource-not-found-error.js';
import * as https from 'node:https';
import * as http from 'node:http';
import { Templates } from './templates.js';
import * as constants from './constants.js';
import { StatusCodes } from 'http-status-codes';
import { inject, injectable } from 'tsyringe-neo';
import { patchInject } from './dependency-injection/container-helper.js';
import { InjectTokens } from './dependency-injection/inject-tokens.js';
import { Duration } from './time/duration.js';
const URL_EXISTS_TIMEOUT_ENV = 'PACKAGE_DOWNLOADER_URL_EXISTS_TIMEOUT_MS';
const DOWNLOAD_CONNECT_TIMEOUT_ENV = 'PACKAGE_DOWNLOADER_DOWNLOAD_CONNECT_TIMEOUT_MS';
const DOWNLOAD_RESPONSE_TIMEOUT_ENV = 'PACKAGE_DOWNLOADER_DOWNLOAD_RESPONSE_TIMEOUT_MS';
const DEFAULT_URL_EXISTS_TIMEOUT = Duration.ofSeconds(5);
const DEFAULT_DOWNLOAD_CONNECT_TIMEOUT = Duration.ofSeconds(10);
const DEFAULT_DOWNLOAD_RESPONSE_TIMEOUT = Duration.ofMinutes(2);
let PackageDownloader = class PackageDownloader {
logger;
constructor(logger) {
this.logger = logger;
this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name);
}
resolveTimeout(name, fallback) {
const configuredValue = constants.getEnvironmentVariable(name);
if (!configuredValue) {
return fallback;
}
const milliseconds = Number(configuredValue);
if (!Number.isFinite(milliseconds) || milliseconds <= 0) {
this.logger.warn(`Invalid ${name} value '${configuredValue}', using default ${fallback.toMillis()}ms.`);
return fallback;
}
return Duration.ofMillis(milliseconds);
}
getUrlExistsTimeout() {
return this.resolveTimeout(URL_EXISTS_TIMEOUT_ENV, DEFAULT_URL_EXISTS_TIMEOUT);
}
getDownloadConnectTimeout() {
return this.resolveTimeout(DOWNLOAD_CONNECT_TIMEOUT_ENV, DEFAULT_DOWNLOAD_CONNECT_TIMEOUT);
}
getDownloadResponseTimeout() {
return this.resolveTimeout(DOWNLOAD_RESPONSE_TIMEOUT_ENV, DEFAULT_DOWNLOAD_RESPONSE_TIMEOUT);
}
isValidURL(url) {
try {
// attempt to parse to check URL format
const out = new URL(url);
return out.href !== undefined;
}
catch {
return false;
}
}
urlExists(url) {
return new Promise((resolve) => {
try {
this.logger.debug(`Checking URL: ${url}`);
// attempt to send a HEAD request to check URL exists
const timeout = this.getUrlExistsTimeout().toMillis();
const request = url.startsWith('http://')
? http.request(url, { method: 'HEAD', timeout, headers: { Connection: 'close' } })
: https.request(url, { method: 'HEAD', timeout, headers: { Connection: 'close' } });
request.on('response', (response) => {
const statusCode = response.statusCode;
this.logger.debug({
response: {
connectOptions: response['connect-options'],
statusCode: response.statusCode,
headers: response.headers,
},
});
request.destroy();
if ([StatusCodes.OK, StatusCodes.MOVED_TEMPORARILY, StatusCodes.MOVED_PERMANENTLY].includes(statusCode)) {
resolve(true);
}
resolve(false);
});
request.on('error', (error) => {
this.logger.error(error);
resolve(false);
request.destroy();
});
request.end(); // make the request
}
catch (error) {
this.logger.error(error);
resolve(false);
}
});
}
/**
* Fetch data from a URL and save the output to a file
*
* @param url - source file URL
* @param destinationPath - destination path for the downloaded file
*/
async fetchFile(url, destinationPath) {
if (!url) {
throw new IllegalArgumentError('package URL is required', url);
}
if (!destinationPath) {
throw new IllegalArgumentError('destination path is required', destinationPath);
}
if (!this.isValidURL(url)) {
throw new IllegalArgumentError(`package URL '${url}' is invalid`, url);
}
if (!(await this.urlExists(url))) {
throw new ResourceNotFoundError(`package URL '${url}' does not exist`, url);
}
try {
const connectTimeout = this.getDownloadConnectTimeout().toMillis();
const responseTimeout = this.getDownloadResponseTimeout().toMillis();
await streamPipeline(got.stream(url, {
followRedirect: true,
timeout: {
connect: connectTimeout,
response: responseTimeout,
},
}), fs.createWriteStream(destinationPath));
return destinationPath;
}
catch (error) {
throw new SoloError(`Error fetching file ${url}: ${error.message}`, error);
}
}
/**
* Compute hash of the file contents
* @param filePath - path of the file
* @param [algo] - hash algorithm
* @returns hex digest of the computed hash
* @throws {Error} - if the file cannot be read
*/
computeFileHash(filePath, algo = 'sha384') {
return new Promise((resolve, reject) => {
try {
this.logger.debug(`Computing checksum for '${filePath}' using algo '${algo}'`);
const checksum = crypto.createHash(algo);
const s = fs.createReadStream(filePath);
s.on('data', (d) => {
checksum.update(d);
});
s.on('end', () => {
const d = checksum.digest('hex');
this.logger.debug(`Computed checksum '${d}' for '${filePath}' using algo '${algo}'`);
resolve(d);
});
s.on('error', (error) => {
reject(error);
});
}
catch (error) {
reject(new SoloError('failed to compute checksum', error, { filePath, algo }));
}
});
}
/**
* Verifies that the checksum of the sourceFile matches with the contents of the checksumFile
*
* It throws error if the checksum doesn't match.
*
* @param sourceFile - path to the file for which checksum to be computed
* @param checksum - expected checksum
* @param [algo] - hash algorithm to be used to compute checksum
* @returns
* @throws {DataValidationError} - if the checksum doesn't match
*/
async verifyChecksum(sourceFile, checksum, algo = 'sha256') {
const computed = await this.computeFileHash(sourceFile, algo);
if (checksum !== computed) {
throw new DataValidationError('checksum', checksum, computed);
}
}
/**
* Fetch a remote package
* @param packageURL
* @param checksumDataOrURL - package checksum URL or checksum data
* @param destinationDirectory - a directory where the files should be downloaded to
* @param verifyChecksum - whether to verify checksum or not
* @param [algo] - checksum algo
* @param [force] - force download even if the file exists in the destinationDirectory
*/
async fetchPackage(packageURL, checksumDataOrURL, destinationDirectory, verifyChecksum = true, algo = 'sha256', force = false) {
if (!packageURL) {
throw new Error('package URL is required');
}
if (!checksumDataOrURL) {
throw new Error('checksum data or URL is required');
}
if (!destinationDirectory) {
throw new Error('destination directory path is required');
}
this.logger.debug(`Downloading package: ${packageURL}, checksum: ${checksumDataOrURL}`);
if (!fs.existsSync(destinationDirectory)) {
fs.mkdirSync(destinationDirectory, { recursive: true });
}
const packageFile = `${destinationDirectory}/${path.basename(packageURL)}`;
let checksumFile;
try {
if (fs.existsSync(packageFile) && !force) {
return packageFile;
}
let checksum;
if (verifyChecksum) {
if (this.isValidURL(checksumDataOrURL)) {
const checksumURL = checksumDataOrURL;
checksumFile = `${destinationDirectory}/${path.basename(checksumURL)}`;
await this.fetchFile(checksumURL, checksumFile);
// Then read its contents
const checksumData = fs.readFileSync(checksumFile).toString();
if (!checksumData) {
throw new SoloError(`unable to read checksum file: ${checksumFile}`);
}
checksum = checksumData.split(' ')[0];
}
else {
checksum = checksumDataOrURL;
}
}
await this.fetchFile(packageURL, packageFile);
if (verifyChecksum) {
await this.verifyChecksum(packageFile, checksum, algo);
}
return packageFile;
}
catch (error) {
if (checksumFile && fs.existsSync(checksumFile)) {
fs.rmSync(checksumFile);
}
if (fs.existsSync(packageFile)) {
fs.rmSync(packageFile);
}
throw new SoloError(error.message, error);
}
}
/**
* Fetch Hedera platform release artifact
*
* It fetches the build.zip file containing the release from a URL like: https://builds.hedera.com/node/software/v0.40/build-v0.40.4.zip
*
* @param tag - full semantic version e.g. v0.40.4
* @param destinationDirectory - directory where the artifact needs to be saved
* @param [force] - whether to download even if the file exists
* @returns full path to the downloaded file
*/
async fetchPlatform(tag, destinationDirectory, force = false) {
if (!tag) {
throw new MissingArgumentError('tag is required');
}
if (!destinationDirectory) {
throw new MissingArgumentError('destination directory path is required');
}
const releaseDirectory = Templates.prepareReleasePrefix(tag);
const packageURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDirectory}/build-${tag}.zip`;
const checksumURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDirectory}/build-${tag}.sha384`;
return await this.fetchPackage(packageURL, checksumURL, destinationDirectory, true, 'sha384', force);
}
};
PackageDownloader = __decorate([
injectable(),
__param(0, inject(InjectTokens.SoloLogger)),
__metadata("design:paramtypes", [Object])
], PackageDownloader);
export { PackageDownloader };
//# sourceMappingURL=package-downloader.js.map