@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
504 lines (407 loc) • 17.7 kB
text/typescript
// SPDX-License-Identifier: Apache-2.0
import {SoloError} from '../core/errors/solo-error.js';
import * as constants from '../core/constants.js';
import {BaseCommand} from './base.js';
import {Flags as flags} from './flags.js';
import {type AnyListrContext, type ArgvStruct} from '../types/aliases.js';
import {type ClusterReferenceName, type Context, type SoloListr, type SoloListrTask} from '../types/index.js';
import {inject, injectable} from 'tsyringe-neo';
import {type CommandFlag, type CommandFlags} from '../types/flag-types.js';
import {ImageCacheHandlerBuilder} from '../integration/cache/impl/image-cache-handler-builder.js';
import {ImageCacheHandler} from '../integration/cache/impl/image-cache-handler.js';
import {InjectTokens} from '../core/dependency-injection/inject-tokens.js';
import {patchInject} from '../core/dependency-injection/container-helper.js';
import {CachedItem} from '../integration/cache/models/impl/cached-item.js';
import {ArtifactHealthResult} from '../integration/cache/models/impl/artifact-health-result.js';
import fs from 'node:fs/promises';
import {Stats} from 'node:fs';
import {type ContainerEngineClient} from '../integration/container-engine/container-engine-client.js';
import {CacheImageTargetTemplateRenderer} from '../integration/cache/impl/cache-image-target-template-renderer.js';
import {PathEx} from '../business/utils/path-ex.js';
import {CacheImageTemplateValues} from '../integration/cache/models/impl/cache-image-template-values.js';
import * as version from '../../version.js';
import {DefaultCacheImageTemplateResolver} from '../integration/cache/impl/default-cache-image-template-resolver.js';
import {CacheTarget} from '../integration/cache/models/impl/cache-target.js';
interface CachePullConfigClass {
imageCacheHandler: ImageCacheHandler;
results: readonly CachedItem[];
edgeEnabled: boolean;
}
interface CachePullContext {
config: CachePullConfigClass;
}
interface CacheLoadConfigClass {
imageCacheHandler: ImageCacheHandler;
clusterReference: ClusterReferenceName;
context: Context;
}
interface CacheLoadContext {
config: CacheLoadConfigClass;
}
interface CacheClearConfigClass {
imageCacheHandler: ImageCacheHandler;
}
interface CacheClearContext {
config: CacheClearConfigClass;
}
interface CacheStatusConfigClass {
imageCacheHandler: ImageCacheHandler;
clusterReference: ClusterReferenceName;
context: Context;
clusterName: string;
}
interface CacheStatusContext {
config: CacheStatusConfigClass;
}
interface CacheListConfigClass {
imageCacheHandler: ImageCacheHandler;
}
interface CacheListContext {
config: CacheListConfigClass;
}
export class CacheCommand extends BaseCommand {
public static readonly CACHE_NOT_MATERIALIZED_ERROR_MESSAGE: string =
'Cache image targets have not been materialized yet. Run `solo cache image pull` first.';
public constructor(
private containerEngineClient?: ContainerEngineClient,
) {
super();
this.containerEngineClient = patchInject(
containerEngineClient,
InjectTokens.ContainerEngineClient,
this.constructor.name,
);
}
public async close(): Promise<void> {}
// ------ Flags ------ //
public static readonly PULL_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.cacheDir, flags.devMode, flags.edgeEnabled],
};
public static readonly LOAD_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.cacheDir, flags.devMode, flags.clusterRef],
};
public static readonly LIST_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.cacheDir, flags.devMode],
};
public static readonly CLEAR_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.cacheDir, flags.devMode],
};
public static readonly STATUS_FLAGS_LIST: CommandFlags = {
required: [],
optional: [flags.quiet, flags.cacheDir, flags.devMode, flags.clusterRef],
};
// ----- Handlers ------- //
public async pull(argv: ArgvStruct): Promise<boolean> {
const tasks: SoloListr<CachePullContext> = this.taskList.newTaskList(
[
{
title: 'Initialize',
task: async (context_, task): Promise<void> => {
this.configManager.update(argv);
flags.disablePrompts(CacheCommand.PULL_FLAGS_LIST.optional);
const allFlags: CommandFlag[] = [
...CacheCommand.PULL_FLAGS_LIST.required,
...CacheCommand.PULL_FLAGS_LIST.optional,
];
await this.configManager.executePrompt(task, allFlags);
const edgeEnabled: boolean = this.configManager.getFlag(flags.edgeEnabled);
const renderedYamlPath: string = await this.renderImageTargetsFile(edgeEnabled);
context_.config = {
imageCacheHandler: await this.buildImageCacheHandlerFromYaml(renderedYamlPath),
results: [],
edgeEnabled,
};
},
},
this.pullAndCacheContainerImages(),
this.showUserMessages(),
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
undefined,
'cache image pull',
);
if (tasks.isRoot()) {
try {
await tasks.run();
} catch (error) {
throw new SoloError(`Error pulling cache: ${error.message}`, error);
}
} else {
this.taskList.registerCloseFunction(async (): Promise<void> => {});
}
return true;
}
public async load(argv: ArgvStruct): Promise<boolean> {
const tasks: SoloListr<CacheLoadContext> = this.taskList.newTaskList(
[
{
title: 'Initialize',
task: async (context_, task): Promise<void> => {
await this.localConfig.load();
this.configManager.update(argv);
flags.disablePrompts(CacheCommand.LOAD_FLAGS_LIST.optional);
const allFlags: CommandFlag[] = [
...CacheCommand.LOAD_FLAGS_LIST.required,
...CacheCommand.LOAD_FLAGS_LIST.optional,
];
await this.configManager.executePrompt(task, allFlags);
const clusterReference: ClusterReferenceName = this.getClusterReference();
const context: Context = this.getClusterContext(clusterReference);
const cacheDirectory: string = this.configManager.getFlag(flags.cacheDir);
context_.config = {
imageCacheHandler: await this.buildImageCacheHandlerFromRenderedFile(cacheDirectory),
clusterReference,
context,
};
},
},
this.loadImagesIntoCluster(),
this.showUserMessages(),
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
undefined,
'cache image load',
);
if (tasks.isRoot()) {
try {
await tasks.run();
} catch (error) {
throw new SoloError(`Error loading from cache: ${error.message}`, error);
}
} else {
this.taskList.registerCloseFunction(async (): Promise<void> => {});
}
return true;
}
public async list(): Promise<boolean> {
const tasks: SoloListr<CacheListContext> = this.taskList.newTaskList(
[
{
title: 'List cached images',
task: async (context_): Promise<void> => {
const cacheDirectory: string = this.configManager.getFlag(flags.cacheDir);
const config: CacheListConfigClass = {
imageCacheHandler: await this.buildImageCacheHandlerFromRenderedFile(cacheDirectory),
};
context_.config = config;
const cachedItems: readonly CachedItem[] = await config.imageCacheHandler.list();
try {
this.logger.showList(
`Cached images: [${cachedItems.length}]`,
cachedItems.map((item): string => `${item.target.name}:${item.target.version}`),
);
} catch {
this.logger.warn('No cache manifest found');
}
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
undefined,
'cache image list',
);
await tasks.run();
return true;
}
public async clear(): Promise<boolean> {
const tasks: SoloListr<CacheClearContext> = this.taskList.newTaskList(
[
{
title: 'Clear image cache',
task: async (context_): Promise<void> => {
const cacheDirectory: string = this.configManager.getFlag(flags.cacheDir);
const renderedYamlPath: string = this.getRenderedImageTargetsFilePath(cacheDirectory);
try {
const config: CacheClearConfigClass = {
imageCacheHandler: await this.buildImageCacheHandlerFromRenderedFile(cacheDirectory),
};
context_.config = config;
await config.imageCacheHandler.clear();
} catch (error) {
if (
!(error instanceof SoloError) ||
error.message !== CacheCommand.CACHE_NOT_MATERIALIZED_ERROR_MESSAGE
) {
throw error;
}
}
await fs.rm(renderedYamlPath, {force: true});
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
undefined,
'cache image clear',
);
await tasks.run();
return true;
}
public async status(argv: ArgvStruct): Promise<boolean> {
const tasks: SoloListr<CacheStatusContext> = this.taskList.newTaskList(
[
{
title: 'Check cache status',
task: async (context_, task): Promise<void> => {
this.configManager.update(argv);
flags.disablePrompts(CacheCommand.STATUS_FLAGS_LIST.optional);
const allFlags: CommandFlag[] = [
...CacheCommand.STATUS_FLAGS_LIST.required,
...CacheCommand.STATUS_FLAGS_LIST.optional,
];
await this.configManager.executePrompt(task, allFlags);
const cacheDirectory: string = this.configManager.getFlag(flags.cacheDir);
const config: CacheStatusConfigClass = {
imageCacheHandler: await this.buildImageCacheHandlerFromRenderedFile(cacheDirectory),
} as CacheStatusConfigClass;
const clusterReference: ClusterReferenceName | undefined = this.configManager.getFlag(flags.clusterRef);
if (clusterReference) {
await this.localConfig.load();
const context: Context = this.getClusterContext(clusterReference);
config.clusterReference = clusterReference;
config.context = context;
config.clusterName = this.prepareClusterName(this.k8Factory.getK8(context).clusters().readCurrent());
} else {
try {
config.clusterName = this.prepareClusterName(this.k8Factory.default().clusters().readCurrent());
} catch {
// Best effort only. Local cache status should still work.
}
}
context_.config = config;
const items: readonly ArtifactHealthResult[] = await config.imageCacheHandler.healthcheck();
const cachedItems: readonly CachedItem[] = await config.imageCacheHandler.list();
const expectedTargets: readonly CacheTarget[] = await config.imageCacheHandler.resolveRequiredArtifacts();
const expectedImages: string[] = expectedTargets.map(
(target): string => `${target.name}:${target.version}`,
);
const missingImages: string[] = items
.filter((item): boolean => !item.healthy)
.map((item): string => `${item.target.name}:${item.target.version}`);
let totalBytes: number = 0;
for (const item of cachedItems) {
try {
const stat: Stats = await fs.stat(item.localPath);
totalBytes += stat.size;
} catch {
// missing files are already reflected by healthcheck
}
}
const totalSizeMb: string = (totalBytes / (1024 * 1024)).toFixed(2);
this.logger.showUser(`Cached images: ${items.length}`);
this.logger.showUser(`Total size: ${totalSizeMb} MB`);
this.logger.showUser(`Healthy: ${missingImages.length === 0}`);
if (missingImages.length > 0) {
this.logger.showList('Missing cache archives', missingImages);
}
if (!config.clusterName) {
this.logger.showUser('Cluster images: unavailable');
return;
}
try {
const clusterImages: readonly string[] = await this.containerEngineClient.listLoadedImagesInCluster(
config.clusterName,
);
const clusterImageSet: Set<string> = new Set(clusterImages);
const expectedImageSet: Set<string> = new Set(expectedImages);
const loadedExpectedImages: string[] = expectedImages.filter((image: string): boolean =>
clusterImageSet.has(image),
);
const missingInCluster: string[] = expectedImages.filter((image): boolean => !clusterImageSet.has(image));
const additionalClusterImages: string[] = clusterImages.filter(
(image): boolean => !expectedImageSet.has(image),
);
this.logger.showUser(
`Cluster loaded expected images: ${loadedExpectedImages.length}/${expectedImages.length}`,
);
if (missingInCluster.length > 0) {
this.logger.showList('Expected but not loaded in cluster', missingInCluster);
}
if (additionalClusterImages.length > 0) {
this.logger.showList('Additional images loaded in cluster', additionalClusterImages);
}
} catch (error) {
const message: string = error instanceof Error ? error.message : String(error);
this.logger.showUser(`Cluster images: failed to inspect (${message})`);
}
},
},
],
constants.LISTR_DEFAULT_OPTIONS.DEFAULT,
undefined,
'cache image status',
);
await tasks.run();
return true;
}
// ------ Tasks ------ //
private pullAndCacheContainerImages(): SoloListrTask<CachePullContext> {
return {
title: 'Pull and cache container images',
task: async ({config: {imageCacheHandler}}, task): Promise<SoloListr<AnyListrContext>> => {
return task.newListr(await imageCacheHandler.pull(), constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY);
},
};
}
private loadImagesIntoCluster(): SoloListrTask<CacheLoadContext> {
return {
title: 'Load images into cluster',
task: async ({config: {imageCacheHandler, context}}, task): Promise<SoloListr<CacheLoadContext>> => {
const subTasks: SoloListrTask<CacheLoadContext>[] = [];
const newTasks: SoloListrTask<CacheLoadContext>[] = await imageCacheHandler.load(
this.prepareClusterName(this.k8Factory.getK8(context).clusters().readCurrent()),
);
subTasks.push(...newTasks);
return task.newListr(subTasks, constants.LISTR_DEFAULT_OPTIONS.WITH_CONCURRENCY);
},
};
}
private showUserMessages(): SoloListrTask<AnyListrContext> {
return {
title: 'Show user messages',
skip: (): boolean => this.oneShotState.isActive(),
task: (): void => {
this.logger.showAllMessageGroups();
},
};
}
// ------ Helpers ------ //
private prepareClusterName(clusterReference: ClusterReferenceName): string {
return clusterReference.startsWith('kind-') ? clusterReference.replace('kind-', '') : clusterReference;
}
private getRenderedImageTargetsFilePath(cacheDirectory: string): string {
return PathEx.join(cacheDirectory, 'config', CacheImageTargetTemplateRenderer.RENDERED_FILE_NAME);
}
private async renderImageTargetsFile(edgeEnabled: boolean): Promise<string> {
const cacheDirectory: string = this.configManager.getFlag(flags.cacheDir);
const renderedConfigDirectory: string = PathEx.join(cacheDirectory, 'config');
return new CacheImageTargetTemplateRenderer(
new DefaultCacheImageTemplateResolver(
new CacheImageTemplateValues(
edgeEnabled ? version.MIRROR_NODE_EDGE_VERSION : version.MIRROR_NODE_VERSION,
edgeEnabled ? version.BLOCK_NODE_EDGE_VERSION : version.BLOCK_NODE_VERSION,
edgeEnabled ? version.HEDERA_JSON_RPC_RELAY_EDGE_VERSION : version.HEDERA_JSON_RPC_RELAY_VERSION,
edgeEnabled ? version.EXPLORER_EDGE_VERSION : version.EXPLORER_VERSION,
version.MINIO_OPERATOR_VERSION,
edgeEnabled ? version.HEDERA_PLATFORM_EDGE_VERSION : version.HEDERA_PLATFORM_VERSION,
),
),
).renderToFile(constants.SOLO_CACHE_IMAGES_TARGET_FILE, renderedConfigDirectory);
}
private async buildImageCacheHandlerFromYaml(filePath: string): Promise<ImageCacheHandler> {
return ImageCacheHandlerBuilder.fromYaml(filePath).engine(this.containerEngineClient).build();
}
private async buildImageCacheHandlerFromRenderedFile(cacheDirectory: string): Promise<ImageCacheHandler> {
const renderedYamlPath: string = this.getRenderedImageTargetsFilePath(cacheDirectory);
try {
await fs.access(renderedYamlPath);
} catch {
throw new SoloError(CacheCommand.CACHE_NOT_MATERIALIZED_ERROR_MESSAGE);
}
return this.buildImageCacheHandlerFromYaml(renderedYamlPath);
}
}