@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
237 lines (207 loc) • 7.91 kB
text/typescript
/**
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import {pipeline as streamPipeline} from 'node:stream/promises';
import got from 'got';
import path from 'path';
import {
DataValidationError,
SoloError,
IllegalArgumentError,
MissingArgumentError,
ResourceNotFoundError,
} from './errors.js';
import * as https from 'https';
import * as http from 'http';
import {Templates} from './templates.js';
import * as constants from './constants.js';
import {type SoloLogger} from './logging.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';
()
export class PackageDownloader {
constructor((InjectTokens.SoloLogger) public readonly logger?: SoloLogger) {
this.logger = patchInject(logger, InjectTokens.SoloLogger, this.constructor.name);
}
isValidURL(url: string) {
try {
// attempt to parse to check URL format
const out = new URL(url);
return out.href !== undefined;
} catch {
return false;
}
}
urlExists(url: string) {
const self = this;
return new Promise<boolean>(resolve => {
try {
self.logger.debug(`Checking URL: ${url}`);
// attempt to send a HEAD request to check URL exists
const req = url.startsWith('http://')
? http.request(url, {method: 'HEAD', timeout: 100, headers: {Connection: 'close'}})
: https.request(url, {method: 'HEAD', timeout: 100, headers: {Connection: 'close'}});
req.on('response', r => {
const statusCode = r.statusCode;
self.logger.debug({
response: {
// @ts-ignore
connectOptions: r['connect-options'],
statusCode: r.statusCode,
headers: r.headers,
},
});
req.destroy();
if ([StatusCodes.OK, StatusCodes.MOVED_TEMPORARILY].includes(statusCode)) {
resolve(true);
}
resolve(false);
});
req.on('error', err => {
self.logger.error(err);
resolve(false);
req.destroy();
});
req.end(); // make the request
} catch (e: Error | any) {
self.logger.error(e);
resolve(false);
}
});
}
/**
* Fetch data from a URL and save the output to a file
*
* @param url - source file URL
* @param destPath - destination path for the downloaded file
*/
async fetchFile(url: string, destPath: string) {
if (!url) {
throw new IllegalArgumentError('package URL is required', url);
}
if (!destPath) {
throw new IllegalArgumentError('destination path is required', destPath);
}
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 {
await streamPipeline(got.stream(url, {followRedirect: true}), fs.createWriteStream(destPath));
return destPath;
} catch (e: Error | any) {
throw new SoloError(`Error fetching file ${url}: ${e.message}`, e);
}
}
/**
* 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(this: any, filePath: string, algo = 'sha384') {
const self = this;
return new Promise<string>((resolve, reject) => {
try {
self.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 as crypto.BinaryLike);
});
s.on('end', () => {
const d = checksum.digest('hex');
self.logger.debug(`Computed checksum '${d}' for '${filePath}' using algo '${algo}'`);
resolve(d);
});
s.on('error', e => {
reject(e);
});
} catch (e: Error | any) {
reject(new SoloError('failed to compute checksum', e, {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: string, checksum: string, 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 checksumURL - package checksum URL
* @param destDir - a directory where the files should be downloaded to
* @param [algo] - checksum algo
* @param [force] - force download even if the file exists in the destDir
*/
async fetchPackage(packageURL: string, checksumURL: string, destDir: string, algo = 'sha256', force = false) {
if (!packageURL) throw new Error('package URL is required');
if (!checksumURL) throw new Error('checksum URL is required');
if (!destDir) throw new Error('destination directory path is required');
this.logger.debug(`Downloading package: ${packageURL}, checksum: ${checksumURL}`);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, {recursive: true});
}
const packageFile = `${destDir}/${path.basename(packageURL)}`;
const checksumFile = `${destDir}/${path.basename(checksumURL)}`;
try {
if (fs.existsSync(packageFile) && !force) {
return packageFile;
}
await this.fetchFile(checksumURL, checksumFile);
const checksumData = fs.readFileSync(checksumFile).toString();
if (!checksumData) throw new SoloError(`unable to read checksum file: ${checksumFile}`);
const checksum = checksumData.split(' ')[0];
await this.fetchFile(packageURL, packageFile);
await this.verifyChecksum(packageFile, checksum, algo);
return packageFile;
} catch (e: Error | any) {
if (fs.existsSync(checksumFile)) {
fs.rmSync(checksumFile);
}
if (fs.existsSync(packageFile)) {
fs.rmSync(packageFile);
}
throw new SoloError(e.message, e);
}
}
/**
* 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 destDir - 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: string, destDir: string, force = false) {
if (!tag) throw new MissingArgumentError('tag is required');
if (!destDir) {
throw new MissingArgumentError('destination directory path is required');
}
const releaseDir = Templates.prepareReleasePrefix(tag);
const downloadDir = `${destDir}/${releaseDir}`;
const packageURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDir}/build-${tag}.zip`;
const checksumURL = `${constants.HEDERA_BUILDS_URL}/node/software/${releaseDir}/build-${tag}.sha384`;
return await this.fetchPackage(packageURL, checksumURL, downloadDir, 'sha384', force);
}
}