< Summary - Envilder Core (TypeScript)

Information
Class: src/envilder/core/application/pushEnvToSecrets/PushEnvToSecretsCommandHandler.ts
Assembly: Default
File(s): src/envilder/core/application/pushEnvToSecrets/PushEnvToSecretsCommandHandler.ts
Tag: 151_24479375065
Line coverage
96%
Covered lines: 77
Uncovered lines: 3
Coverable lines: 80
Total lines: 245
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()
 810export class PushEnvToSecretsCommandHandler {
 11  constructor(
 12    @inject(TYPES.ISecretProvider)
 3013    private readonly secretProvider: ISecretProvider,
 14    @inject(TYPES.IVariableStore)
 3015    private readonly variableStore: IVariableStore,
 3016    @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> {
 1227    try {
 1228      this.logger.info(
 29        `Starting push operation from '${command.envFilePath}' using map '${command.mapPath}'`,
 30      );
 1231      const config = await this.loadConfiguration(command);
 1232      const validatedPaths = this.validateAndGroupByPath(config);
 1233      await this.pushParametersToStore(validatedPaths);
 34
 635      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  }> {
 1249    this.logger.info(`Loading parameter map from '${command.mapPath}'`);
 1250    const paramMap = await this.variableStore.getMapping(command.mapPath);
 51
 1252    this.logger.info(
 53      `Loading environment variables from '${command.envFilePath}'`,
 54    );
 1255    const envVariables = await this.variableStore.getEnvironment(
 56      command.envFilePath,
 57    );
 58
 1259    this.logger.info(
 60      `Found ${Object.keys(paramMap).length} parameter mappings in map file`,
 61    );
 1262    this.logger.info(
 63      `Found ${Object.keys(envVariables).length} environment variables in env file`,
 64    );
 65
 1266    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[] }> {
 1278    const { paramMap, envVariables } = config;
 1279    const pathToValueMap = new Map<
 80      string,
 81      { value: string; sourceKeys: string[] }
 82    >();
 83
 1284    for (const [envKey, secretPath] of Object.entries(paramMap)) {
 1985      const envValue = envVariables[envKey];
 86
 1987      if (envValue === undefined) {
 188        this.logger.warn(
 89          `Warning: Environment variable ${envKey} not found in environment file`,
 90        );
 191        continue;
 92      }
 93
 1894      const existing = pathToValueMap.get(secretPath);
 1895      if (existing) {
 296        if (existing.value !== envValue) {
 197          const existingMasked = new EnvironmentVariable(
 98            existing.sourceKeys[0],
 99            existing.value,
 100            true,
 101          ).maskedValue;
 1102          const newMasked = new EnvironmentVariable(envKey, envValue, true)
 103            .maskedValue;
 1104          throw new Error(
 105            `Conflicting values for secret path '${secretPath}': ` +
 106              `'${existing.sourceKeys[0]}' has value '${existingMasked}' ` +
 107              `but '${envKey}' has value '${newMasked}'`,
 108          );
 109        }
 1110        existing.sourceKeys.push(envKey);
 111      } else {
 16112        pathToValueMap.set(secretPath, {
 113          value: envValue,
 114          sourceKeys: [envKey],
 115        });
 116      }
 117    }
 118
 11119    const uniquePaths = pathToValueMap.size;
 11120    const totalVariables = Object.keys(paramMap).length;
 11121    this.logger.info(
 122      `Validated ${totalVariables} environment variables mapping to ${uniquePaths} unique secrets`,
 123    );
 124
 11125    return pathToValueMap;
 126  }
 127
 128  private async pushParametersToStore(
 129    pathToValueMap: Map<string, { value: string; sourceKeys: string[] }>,
 130  ): Promise<void> {
 11131    const pathsToProcess = Array.from(pathToValueMap.keys());
 11132    this.logger.info(`Processing ${pathsToProcess.length} unique secrets`);
 133
 134    // Process secrets in parallel with retry logic for throttling errors
 11135    const parameterProcessingPromises = Array.from(
 136      pathToValueMap.entries(),
 137    ).map(([secretPath, { value, sourceKeys }]) => {
 15138      return this.retryWithBackoff(() =>
 24139        this.pushParameter(secretPath, value, sourceKeys),
 140      );
 141    });
 142
 11143    await Promise.all(parameterProcessingPromises);
 144  }
 145
 146  private async pushParameter(
 147    secretPath: string,
 148    value: string,
 149    sourceKeys: string[],
 150  ): Promise<void> {
 24151    const envVariable = new EnvironmentVariable(sourceKeys[0], value, true);
 24152    await this.secretProvider.setSecret(secretPath, value);
 153
 154    const keysDescription =
 10155      sourceKeys.length > 1 ? `${sourceKeys.join(', ')}` : sourceKeys[0];
 156
 24157    this.logger.info(
 158      `Pushed ${keysDescription}=${envVariable.maskedValue} to secret store at path ${secretPath}`,
 159    );
 160  }
 161
 162  /**
 163   * Retries an async operation with exponential backoff and jitter.
 164   * Handles throttling errors from cloud providers.
 165   */
 166  private async retryWithBackoff<T>(
 167    operation: () => Promise<T>,
 168    maxRetries = 5,
 169    baseDelayMs = 100,
 170  ): Promise<T> {
 171    let lastError: unknown;
 172
 15173    for (let attempt = 0; attempt <= maxRetries; attempt++) {
 24174      try {
 24175        return await operation();
 176      } catch (error) {
 14177        lastError = error;
 178
 179        const isThrottlingError =
 14180          typeof error === 'object' &&
 181          error !== null &&
 182          (('name' in error &&
 183            (error.name === 'TooManyUpdates' ||
 184              error.name === 'ThrottlingException' ||
 185              error.name === 'TooManyRequestsException')) ||
 186            ('statusCode' in error && error.statusCode === 429));
 187
 14188        if (!isThrottlingError || attempt === maxRetries) {
 5189          throw error;
 190        }
 191
 9192        const exponentialDelay = baseDelayMs * 2 ** attempt;
 9193        const jitter = Math.random() * exponentialDelay * 0.5; // 0-50% jitter
 9194        const delayMs = exponentialDelay + jitter;
 195
 9196        await new Promise((resolve) => setTimeout(resolve, delayMs));
 197      }
 198    }
 199
 9200    throw lastError;
 201  }
 202
 203  private getErrorMessage(error: unknown): string {
 6204    if (error instanceof Error) {
 2205      return error.message;
 206    }
 207
 4208    if (typeof error === 'string') {
 1209      return error;
 210    }
 211
 3212    if (error === null) {
 0213      return 'Unknown error (null)';
 214    }
 215
 3216    if (error === undefined) {
 1217      return 'Unknown error (undefined)';
 218    }
 219
 2220    if (typeof error === 'object') {
 2221      const awsError = error as {
 222        name?: string;
 223        message?: string;
 224        code?: string;
 225      };
 2226      if (awsError.name) {
 1227        return awsError.message
 228          ? `${awsError.name}: ${awsError.message}`
 229          : awsError.name;
 230      }
 231
 1232      const safeFields: string[] = [];
 1233      if (awsError.code) safeFields.push(`code: ${awsError.code}`);
 1234      if (awsError.message) safeFields.push(`message: ${awsError.message}`);
 235
 1236      if (safeFields.length > 0) {
 1237        return `Object error (${safeFields.join(', ')})`;
 238      }
 239
 0240      return `Object error: ${Object.keys(error as object).join(', ')}`;
 241    }
 242
 0243    return `Unknown error: ${String(error)}`;
 244  }
 245}