< Summary - Envilder CLI

Information
Class: src/envilder/core/application/pullSecretsToEnv/PullSecretsToEnvCommandHandler.ts
Assembly: Default
File(s): src/envilder/core/application/pullSecretsToEnv/PullSecretsToEnvCommandHandler.ts
Tag: 427_29134414720
Line coverage
100%
Covered lines: 59
Uncovered lines: 0
Coverable lines: 59
Total lines: 234
Line coverage: 100%
Branch coverage
100%
Covered branches: 18
Total branches: 18
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/application/pullSecretsToEnv/PullSecretsToEnvCommandHandler.ts

#LineLine coverage
 1import { inject, injectable } from 'inversify';
 2import pc from 'picocolors';
 3import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 4import {
 5  ExpiredCredentialsError,
 6  SecretsFetchError,
 7  SsoSessionExpiredError,
 8} from '../../domain/errors/DomainErrors.js';
 9import type { ILogger } from '../../domain/ports/ILogger.js';
 10import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 11import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 12import { describeError } from '../../infrastructure/describeError.js';
 13import { TYPES } from '../../types.js';
 14import type { PullSecretsToEnvCommand } from './PullSecretsToEnvCommand.js';
 15
 16type ResolvedOutcome = { status: 'resolved'; envVar: string; masked: string };
 17type WarningOutcome = {
 18  status: 'warning';
 19  envVar: string;
 20  path: string;
 21  reason: 'not-found' | 'empty';
 22};
 23type ErrorOutcome = {
 24  status: 'error';
 25  envVar: string;
 26  path: string;
 27  reason: string;
 28};
 29type SecretOutcome = ResolvedOutcome | WarningOutcome | ErrorOutcome;
 30
 31@injectable()
 832export class PullSecretsToEnvCommandHandler {
 833  private static readonly LABEL_WIDTH = 20;
 834  private static readonly RULE = pc.yellow('\u2501'.repeat(60));
 35
 36  constructor(
 37    @inject(TYPES.ISecretProvider)
 3238    private readonly secretProvider: ISecretProvider,
 39    @inject(TYPES.IVariableStore)
 3240    private readonly variableStore: IVariableStore,
 3241    @inject(TYPES.ILogger) private readonly logger: ILogger,
 42  ) {}
 43
 44  /**
 45   * Handles the PullSecretsToEnvCommand which orchestrates the process of fetching
 46   * environment variable values from a secret store and writing them to a local environment file.
 47   *
 48   * @param command - The PullSecretsToEnvCommand containing mapPath and envFilePath
 49   */
 50  async handle(command: PullSecretsToEnvCommand): Promise<void> {
 51    const { requestVariables, currentVariables } =
 1452      await this.loadVariables(command);
 1453    const { variables, resolvedCount, totalCount } = await this.envild(
 54      requestVariables,
 55      currentVariables,
 56    );
 957    await this.saveEnvFile(command.envFilePath, variables);
 58
 959    this.logger.info(
 60      PullSecretsToEnvCommandHandler.buildSummary(
 61        resolvedCount,
 62        totalCount,
 63        command.envFilePath,
 64      ),
 65    );
 66  }
 67
 68  private async loadVariables(command: PullSecretsToEnvCommand): Promise<{
 69    requestVariables: Record<string, string>;
 70    currentVariables: Record<string, string>;
 71  }> {
 1472    const requestVariables = await this.variableStore.getMapping(
 73      command.mapPath,
 74    );
 1475    const currentVariables = await this.variableStore.getEnvironment(
 76      command.envFilePath,
 77    );
 78
 1479    return { requestVariables, currentVariables };
 80  }
 81
 82  private async saveEnvFile(
 83    envFilePath: string,
 84    variables: Record<string, string>,
 85  ): Promise<void> {
 986    await this.variableStore.saveEnvironment(envFilePath, variables);
 87  }
 88
 89  private async envild(
 90    paramMap: Record<string, string>,
 91    existingEnvVariables: Record<string, string>,
 92  ): Promise<{
 93    variables: Record<string, string>;
 94    resolvedCount: number;
 95    totalCount: number;
 96  }> {
 1497    const outcomes = await Promise.all(
 98      Object.entries(paramMap).map(([envVar, secretName]) =>
 1699        this.processSecret(envVar, secretName, existingEnvVariables),
 100      ),
 101    );
 102
 12103    const resolved = outcomes.filter(
 14104      (outcome): outcome is ResolvedOutcome => outcome.status === 'resolved',
 105    );
 12106    const warnings = outcomes.filter(
 14107      (outcome): outcome is WarningOutcome => outcome.status === 'warning',
 108    );
 12109    const errors = outcomes.filter(
 14110      (outcome): outcome is ErrorOutcome => outcome.status === 'error',
 111    );
 112
 12113    this.logSecretsSection(resolved, warnings);
 114
 12115    if (errors.length > 0) {
 3116      throw new SecretsFetchError(
 3117        errors.map((error) => ({
 118          envVar: error.envVar,
 119          path: error.path,
 120          reason: error.reason,
 121        })),
 122      );
 123    }
 124
 9125    return {
 126      variables: existingEnvVariables,
 127      resolvedCount: resolved.length,
 128      totalCount: Object.keys(paramMap).length,
 129    };
 130  }
 131
 132  private async processSecret(
 133    envVar: string,
 134    secretName: string,
 135    existingEnvVariables: Record<string, string>,
 136  ): Promise<SecretOutcome> {
 16137    try {
 16138      const value = await this.secretProvider.getSecret(secretName);
 11139      if (value === undefined) {
 1140        return {
 141          status: 'warning',
 142          envVar,
 143          path: secretName,
 144          reason: 'not-found',
 145        };
 146      }
 10147      if (value === '') {
 3148        return { status: 'warning', envVar, path: secretName, reason: 'empty' };
 149      }
 150
 7151      existingEnvVariables[envVar] = value;
 7152      const masked = new EnvironmentVariable(envVar, value, true).maskedValue;
 153
 7154      return { status: 'resolved', envVar, masked };
 155    } catch (error) {
 5156      if (
 157        error instanceof ExpiredCredentialsError ||
 158        error instanceof SsoSessionExpiredError
 159      ) {
 2160        throw error;
 161      }
 3162      const maskedPath = EnvironmentVariable.maskSecretPath(secretName);
 3163      const reason = PullSecretsToEnvCommandHandler.describeErrorReason(
 164        error,
 165        maskedPath,
 166      );
 3167      return { status: 'error', envVar, path: maskedPath, reason };
 168    }
 169  }
 170
 171  private static describeErrorReason(
 172    error: unknown,
 173    maskedPath: string,
 174  ): string {
 3175    const message = describeError(error);
 3176    const duplicatedPrefix = `${maskedPath}: `;
 3177    return message.startsWith(duplicatedPrefix)
 178      ? message.slice(duplicatedPrefix.length)
 179      : message;
 180  }
 181
 182  private logSecretsSection(
 183    resolved: ResolvedOutcome[],
 184    warnings: WarningOutcome[],
 185  ): void {
 12186    if (resolved.length === 0 && warnings.length === 0) {
 2187      return;
 188    }
 189
 10190    this.logger.info(`\n${pc.bold(pc.yellow('\u{1FA99}  RESOLVING SECRETS'))}`);
 10191    this.logger.info(PullSecretsToEnvCommandHandler.RULE);
 10192    for (const outcome of resolved) {
 7193      this.logger.info(
 194        `  ${pc.green('\u2713 ')}${pc.bold(
 195          PullSecretsToEnvCommandHandler.pad(outcome.envVar),
 196        )}${pc.dim('\u2192 ')}${pc.dim(outcome.masked)}`,
 197      );
 198    }
 10199    this.logWarnings(warnings);
 200  }
 201
 202  private logWarnings(warnings: WarningOutcome[]): void {
 10203    for (const outcome of warnings) {
 4204      const maskedPath = EnvironmentVariable.maskSecretPath(outcome.path);
 4205      if (outcome.reason === 'not-found') {
 1206        this.logger.warn(
 207          `  ${pc.red('\u2717 ')}${pc.bold(
 208            pc.red(PullSecretsToEnvCommandHandler.pad(outcome.envVar)),
 209          )} ${pc.red(`secret not found (path: ${maskedPath}) \u2014 skipped`)}`,
 210        );
 1211        continue;
 212      }
 3213      this.logger.warn(
 214        `  ${pc.yellow('\u26A0 ')}${pc.bold(
 215          PullSecretsToEnvCommandHandler.pad(outcome.envVar),
 216        )} ${pc.dim(`no value found (path: ${maskedPath}) \u2014 skipped`)}`,
 217      );
 218    }
 219  }
 220
 221  private static pad(name: string): string {
 11222    return name.padEnd(PullSecretsToEnvCommandHandler.LABEL_WIDTH);
 223  }
 224
 225  private static buildSummary(
 226    resolvedCount: number,
 227    totalCount: number,
 228    envFilePath: string,
 229  ): string {
 9230    return `\n${pc.bold(pc.green('\u2B50 LEVEL CLEARED'))}${pc.dim(
 231      `  \u2014  ${resolvedCount}/${totalCount} secrets loaded \u00B7 `,
 232    )}${pc.bold(envFilePath)}${pc.dim(' written')}\n`;
 233  }
 234}