< Summary - Envilder CLI

Information
Class: src/envilder/core/application/pushEnvToSecrets/PushEnvToSecretsCommandHandler.ts
Assembly: Default
File(s): src/envilder/core/application/pushEnvToSecrets/PushEnvToSecretsCommandHandler.ts
Tag: 561_41292037308
Line coverage
96%
Covered lines: 77
Uncovered lines: 3
Coverable lines: 80
Total lines: 246
Line coverage: 96.2%
Branch coverage
85%
Covered branches: 36
Total branches: 42
Branch coverage: 85.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/application/pushEnvToSecrets/PushEnvToSecretsCommandHandler.ts

#LineLine coverage
 1import { inject, injectable } from 'inversify';
 2import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 3import type { ILogger } from '../../domain/ports/ILogger.js';
 4import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 5import type { IVariableStore } from '../../domain/ports/IVariableStore.js';
 6import { TYPES } from '../../types.js';
 7import type { PushEnvToSecretsCommand } from './PushEnvToSecretsCommand.js';
 8
 9@injectable()
 1210export class PushEnvToSecretsCommandHandler {
 11  constructor(
 12    @inject(TYPES.ISecretProvider)
 9113    private readonly secretProvider: ISecretProvider,
 14    @inject(TYPES.IVariableStore)
 9115    private readonly variableStore: IVariableStore,
 9116    @inject(TYPES.ILogger) private readonly logger: ILogger,
 17  ) {}
 18
 19  /**
 20   * Handles the PushEnvToSecretsCommand which imports environment variables
 21   * from a local file and pushes them to the secret store.
 22   * Uses a map file to determine the secret path for each environment variable.
 23   *
 24   * @param command - The PushEnvToSecretsCommand containing mapPath and envFilePath
 25   */
 26  async handle(command: PushEnvToSecretsCommand): Promise<void> {
 1327    try {
 1328      this.logger.info(
 29        `Starting push operation from '${command.envFilePath}' using map '${command.mapPath}'`,
 30      );
 1331      const config = await this.loadConfiguration(command);
 1332      const validatedPaths = this.validateAndGroupByPath(config);
 1333      await this.pushParametersToStore(validatedPaths);
 34
 735      this.logger.info(
 36        `Successfully pushed environment variables from '${command.envFilePath}' to secret store.`,
 37      );
 38    } catch (error) {
 639      const errorMessage = this.getErrorMessage(error);
 640      this.logger.error(`Failed to push environment file: ${errorMessage}`);
 641      throw error;
 42    }
 43  }
 44
 45  private async loadConfiguration(command: PushEnvToSecretsCommand): Promise<{
 46    paramMap: Record<string, string>;
 47    envVariables: Record<string, string>;
 48  }> {
 1349    this.logger.info(`Loading parameter map from '${command.mapPath}'`);
 1350    const paramMap = await this.variableStore.getMapping(command.mapPath);
 51
 1352    this.logger.info(
 53      `Loading environment variables from '${command.envFilePath}'`,
 54    );
 1355    const envVariables = await this.variableStore.getEnvironment(
 56      command.envFilePath,
 57    );
 58
 1359    this.logger.info(
 60      `Found ${Object.keys(paramMap).length} parameter mappings in map file`,
 61    );
 1362    this.logger.info(
 63      `Found ${Object.keys(envVariables).length} environment variables in env file`,
 64    );
 65
 1366    return { paramMap, envVariables };
 67  }
 68
 69  /**
 70   * Validates and groups environment variables by secret path.
 71   * Ensures that all variables pointing to the same secret path have the same value.
 72   * Returns a map of secret path to value.
 73   */
 74  private validateAndGroupByPath(config: {
 75    paramMap: Record<string, string>;
 76    envVariables: Record<string, string>;
 77  }): Map<string, { value: string; sourceKeys: string[] }> {
 1378    const { paramMap, envVariables } = config;
 1379    const pathToValueMap = new Map<
 80      string,
 81      { value: string; sourceKeys: string[] }
 82    >();
 83
 1384    for (const [envKey, secretPath] of Object.entries(paramMap)) {
 2085      const envValue = envVariables[envKey];
 86
 2087      if (envValue === undefined) {
 188        this.logger.warn(
 89          `Warning: Environment variable ${envKey} not found in environment file`,
 90        );
 191        continue;
 92      }
 93
 1994      const existing = pathToValueMap.get(secretPath);
 1995      if (existing) {
 296        if (existing.value !== envValue) {
 197          const existingMasked = EnvironmentVariable.secret(
 98            existing.sourceKeys[0],
 99            existing.value,
 100          ).maskedValue;
 1101          const newMasked = EnvironmentVariable.secret(
 102            envKey,
 103            envValue,
 104          ).maskedValue;
 1105          throw new Error(
 106            `Conflicting values for secret path '${EnvironmentVariable.maskSecretPath(secretPath)}': ` +
 107              `'${existing.sourceKeys[0]}' has value '${existingMasked}' ` +
 108              `but '${envKey}' has value '${newMasked}'`,
 109          );
 110        }
 1111        existing.sourceKeys.push(envKey);
 112      } else {
 17113        pathToValueMap.set(secretPath, {
 114          value: envValue,
 115          sourceKeys: [envKey],
 116        });
 117      }
 118    }
 119
 12120    const uniquePaths = pathToValueMap.size;
 12121    const totalVariables = Object.keys(paramMap).length;
 12122    this.logger.info(
 123      `Validated ${totalVariables} environment variables mapping to ${uniquePaths} unique secrets`,
 124    );
 125
 12126    return pathToValueMap;
 127  }
 128
 129  private async pushParametersToStore(
 130    pathToValueMap: Map<string, { value: string; sourceKeys: string[] }>,
 131  ): Promise<void> {
 12132    const pathsToProcess = Array.from(pathToValueMap.keys());
 12133    this.logger.info(`Processing ${pathsToProcess.length} unique secrets`);
 134
 135    // Process secrets in parallel with retry logic for throttling errors
 12136    const parameterProcessingPromises = Array.from(
 137      pathToValueMap.entries(),
 138    ).map(([secretPath, { value, sourceKeys }]) => {
 16139      return this.retryWithBackoff(() =>
 25140        this.pushParameter(secretPath, value, sourceKeys),
 141      );
 142    });
 143
 12144    await Promise.all(parameterProcessingPromises);
 145  }
 146
 147  private async pushParameter(
 148    secretPath: string,
 149    value: string,
 150    sourceKeys: string[],
 151  ): Promise<void> {
 25152    const envVariable = EnvironmentVariable.secret(sourceKeys[0], value);
 25153    await this.secretProvider.setSecret(secretPath, value);
 154
 155    const keysDescription =
 11156      sourceKeys.length > 1 ? sourceKeys.join(', ') : sourceKeys[0];
 157
 25158    this.logger.info(
 159      `Pushed ${keysDescription}=${envVariable.maskedValue} to secret store at path ${EnvironmentVariable.maskSecretPath
 160    );
 161  }
 162
 163  /**
 164   * Retries an async operation with exponential backoff and jitter.
 165   * Handles throttling errors from cloud providers.
 166   */
 167  private async retryWithBackoff<T>(
 168    operation: () => Promise<T>,
 169    maxRetries = 5,
 170    baseDelayMs = 100,
 171  ): Promise<T> {
 172    let lastError: unknown;
 173
 16174    for (let attempt = 0; attempt <= maxRetries; attempt++) {
 25175      try {
 25176        return await operation();
 177      } catch (error) {
 14178        lastError = error;
 179
 180        const isThrottlingError =
 14181          typeof error === 'object' &&
 182          error !== null &&
 183          (('name' in error &&
 184            (error.name === 'TooManyUpdates' ||
 185              error.name === 'ThrottlingException' ||
 186              error.name === 'TooManyRequestsException')) ||
 187            ('statusCode' in error && error.statusCode === 429));
 188
 14189        if (!isThrottlingError || attempt === maxRetries) {
 5190          throw error;
 191        }
 192
 9193        const exponentialDelay = baseDelayMs * 2 ** attempt;
 9194        const jitter = Math.random() * exponentialDelay * 0.5; // 0-50% jitter
 9195        const delayMs = exponentialDelay + jitter;
 196
 9197        await new Promise((resolve) => setTimeout(resolve, delayMs));
 198      }
 199    }
 200
 9201    throw lastError;
 202  }
 203
 204  private getErrorMessage(error: unknown): string {
 6205    if (error instanceof Error) {
 2206      return error.message;
 207    }
 208
 4209    if (typeof error === 'string') {
 1210      return error;
 211    }
 212
 3213    if (error === null) {
 0214      return 'Unknown error (null)';
 215    }
 216
 3217    if (error === undefined) {
 1218      return 'Unknown error (undefined)';
 219    }
 220
 2221    if (typeof error === 'object') {
 2222      const awsError = error as {
 223        name?: string;
 224        message?: string;
 225        code?: string;
 226      };
 2227      if (awsError.name) {
 1228        return awsError.message
 229          ? `${awsError.name}: ${awsError.message}`
 230          : awsError.name;
 231      }
 232
 1233      const safeFields: string[] = [];
 1234      if (awsError.code) safeFields.push(`code: ${awsError.code}`);
 1235      if (awsError.message) safeFields.push(`message: ${awsError.message}`);
 236
 1237      if (safeFields.length > 0) {
 1238        return `Object error (${safeFields.join(', ')})`;
 239      }
 240
 0241      return `Object error: ${Object.keys(error as object).join(', ')}`;
 242    }
 243
 0244    return `Unknown error: ${String(error)}`;
 245  }
 246}