| | | 1 | | import { inject, injectable } from 'inversify'; |
| | | 2 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 3 | | import type { ILogger } from '../../domain/ports/ILogger.js'; |
| | | 4 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 5 | | import type { IVariableStore } from '../../domain/ports/IVariableStore.js'; |
| | | 6 | | import { TYPES } from '../../types.js'; |
| | | 7 | | import type { PushEnvToSecretsCommand } from './PushEnvToSecretsCommand.js'; |
| | | 8 | | |
| | | 9 | | @injectable() |
| | 8 | 10 | | export class PushEnvToSecretsCommandHandler { |
| | | 11 | | constructor( |
| | | 12 | | @inject(TYPES.ISecretProvider) |
| | 30 | 13 | | private readonly secretProvider: ISecretProvider, |
| | | 14 | | @inject(TYPES.IVariableStore) |
| | 30 | 15 | | private readonly variableStore: IVariableStore, |
| | 30 | 16 | | @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> { |
| | 12 | 27 | | try { |
| | 12 | 28 | | this.logger.info( |
| | | 29 | | `Starting push operation from '${command.envFilePath}' using map '${command.mapPath}'`, |
| | | 30 | | ); |
| | 12 | 31 | | const config = await this.loadConfiguration(command); |
| | 12 | 32 | | const validatedPaths = this.validateAndGroupByPath(config); |
| | 12 | 33 | | await this.pushParametersToStore(validatedPaths); |
| | | 34 | | |
| | 6 | 35 | | this.logger.info( |
| | | 36 | | `Successfully pushed environment variables from '${command.envFilePath}' to secret store.`, |
| | | 37 | | ); |
| | | 38 | | } catch (error) { |
| | 6 | 39 | | const errorMessage = this.getErrorMessage(error); |
| | 6 | 40 | | this.logger.error(`Failed to push environment file: ${errorMessage}`); |
| | 6 | 41 | | throw error; |
| | | 42 | | } |
| | | 43 | | } |
| | | 44 | | |
| | | 45 | | private async loadConfiguration(command: PushEnvToSecretsCommand): Promise<{ |
| | | 46 | | paramMap: Record<string, string>; |
| | | 47 | | envVariables: Record<string, string>; |
| | | 48 | | }> { |
| | 12 | 49 | | this.logger.info(`Loading parameter map from '${command.mapPath}'`); |
| | 12 | 50 | | const paramMap = await this.variableStore.getMapping(command.mapPath); |
| | | 51 | | |
| | 12 | 52 | | this.logger.info( |
| | | 53 | | `Loading environment variables from '${command.envFilePath}'`, |
| | | 54 | | ); |
| | 12 | 55 | | const envVariables = await this.variableStore.getEnvironment( |
| | | 56 | | command.envFilePath, |
| | | 57 | | ); |
| | | 58 | | |
| | 12 | 59 | | this.logger.info( |
| | | 60 | | `Found ${Object.keys(paramMap).length} parameter mappings in map file`, |
| | | 61 | | ); |
| | 12 | 62 | | this.logger.info( |
| | | 63 | | `Found ${Object.keys(envVariables).length} environment variables in env file`, |
| | | 64 | | ); |
| | | 65 | | |
| | 12 | 66 | | 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[] }> { |
| | 12 | 78 | | const { paramMap, envVariables } = config; |
| | 12 | 79 | | const pathToValueMap = new Map< |
| | | 80 | | string, |
| | | 81 | | { value: string; sourceKeys: string[] } |
| | | 82 | | >(); |
| | | 83 | | |
| | 12 | 84 | | for (const [envKey, secretPath] of Object.entries(paramMap)) { |
| | 19 | 85 | | const envValue = envVariables[envKey]; |
| | | 86 | | |
| | 19 | 87 | | if (envValue === undefined) { |
| | 1 | 88 | | this.logger.warn( |
| | | 89 | | `Warning: Environment variable ${envKey} not found in environment file`, |
| | | 90 | | ); |
| | 1 | 91 | | continue; |
| | | 92 | | } |
| | | 93 | | |
| | 18 | 94 | | const existing = pathToValueMap.get(secretPath); |
| | 18 | 95 | | if (existing) { |
| | 2 | 96 | | if (existing.value !== envValue) { |
| | 1 | 97 | | const existingMasked = new EnvironmentVariable( |
| | | 98 | | existing.sourceKeys[0], |
| | | 99 | | existing.value, |
| | | 100 | | true, |
| | | 101 | | ).maskedValue; |
| | 1 | 102 | | const newMasked = new EnvironmentVariable(envKey, envValue, true) |
| | | 103 | | .maskedValue; |
| | 1 | 104 | | 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 | | } |
| | 1 | 110 | | existing.sourceKeys.push(envKey); |
| | | 111 | | } else { |
| | 16 | 112 | | pathToValueMap.set(secretPath, { |
| | | 113 | | value: envValue, |
| | | 114 | | sourceKeys: [envKey], |
| | | 115 | | }); |
| | | 116 | | } |
| | | 117 | | } |
| | | 118 | | |
| | 11 | 119 | | const uniquePaths = pathToValueMap.size; |
| | 11 | 120 | | const totalVariables = Object.keys(paramMap).length; |
| | 11 | 121 | | this.logger.info( |
| | | 122 | | `Validated ${totalVariables} environment variables mapping to ${uniquePaths} unique secrets`, |
| | | 123 | | ); |
| | | 124 | | |
| | 11 | 125 | | return pathToValueMap; |
| | | 126 | | } |
| | | 127 | | |
| | | 128 | | private async pushParametersToStore( |
| | | 129 | | pathToValueMap: Map<string, { value: string; sourceKeys: string[] }>, |
| | | 130 | | ): Promise<void> { |
| | 11 | 131 | | const pathsToProcess = Array.from(pathToValueMap.keys()); |
| | 11 | 132 | | this.logger.info(`Processing ${pathsToProcess.length} unique secrets`); |
| | | 133 | | |
| | | 134 | | // Process secrets in parallel with retry logic for throttling errors |
| | 11 | 135 | | const parameterProcessingPromises = Array.from( |
| | | 136 | | pathToValueMap.entries(), |
| | | 137 | | ).map(([secretPath, { value, sourceKeys }]) => { |
| | 15 | 138 | | return this.retryWithBackoff(() => |
| | 24 | 139 | | this.pushParameter(secretPath, value, sourceKeys), |
| | | 140 | | ); |
| | | 141 | | }); |
| | | 142 | | |
| | 11 | 143 | | await Promise.all(parameterProcessingPromises); |
| | | 144 | | } |
| | | 145 | | |
| | | 146 | | private async pushParameter( |
| | | 147 | | secretPath: string, |
| | | 148 | | value: string, |
| | | 149 | | sourceKeys: string[], |
| | | 150 | | ): Promise<void> { |
| | 24 | 151 | | const envVariable = new EnvironmentVariable(sourceKeys[0], value, true); |
| | 24 | 152 | | await this.secretProvider.setSecret(secretPath, value); |
| | | 153 | | |
| | | 154 | | const keysDescription = |
| | 10 | 155 | | sourceKeys.length > 1 ? `${sourceKeys.join(', ')}` : sourceKeys[0]; |
| | | 156 | | |
| | 24 | 157 | | 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 | | |
| | 15 | 173 | | for (let attempt = 0; attempt <= maxRetries; attempt++) { |
| | 24 | 174 | | try { |
| | 24 | 175 | | return await operation(); |
| | | 176 | | } catch (error) { |
| | 14 | 177 | | lastError = error; |
| | | 178 | | |
| | | 179 | | const isThrottlingError = |
| | 14 | 180 | | 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 | | |
| | 14 | 188 | | if (!isThrottlingError || attempt === maxRetries) { |
| | 5 | 189 | | throw error; |
| | | 190 | | } |
| | | 191 | | |
| | 9 | 192 | | const exponentialDelay = baseDelayMs * 2 ** attempt; |
| | 9 | 193 | | const jitter = Math.random() * exponentialDelay * 0.5; // 0-50% jitter |
| | 9 | 194 | | const delayMs = exponentialDelay + jitter; |
| | | 195 | | |
| | 9 | 196 | | await new Promise((resolve) => setTimeout(resolve, delayMs)); |
| | | 197 | | } |
| | | 198 | | } |
| | | 199 | | |
| | 9 | 200 | | throw lastError; |
| | | 201 | | } |
| | | 202 | | |
| | | 203 | | private getErrorMessage(error: unknown): string { |
| | 6 | 204 | | if (error instanceof Error) { |
| | 2 | 205 | | return error.message; |
| | | 206 | | } |
| | | 207 | | |
| | 4 | 208 | | if (typeof error === 'string') { |
| | 1 | 209 | | return error; |
| | | 210 | | } |
| | | 211 | | |
| | 3 | 212 | | if (error === null) { |
| | 0 | 213 | | return 'Unknown error (null)'; |
| | | 214 | | } |
| | | 215 | | |
| | 3 | 216 | | if (error === undefined) { |
| | 1 | 217 | | return 'Unknown error (undefined)'; |
| | | 218 | | } |
| | | 219 | | |
| | 2 | 220 | | if (typeof error === 'object') { |
| | 2 | 221 | | const awsError = error as { |
| | | 222 | | name?: string; |
| | | 223 | | message?: string; |
| | | 224 | | code?: string; |
| | | 225 | | }; |
| | 2 | 226 | | if (awsError.name) { |
| | 1 | 227 | | return awsError.message |
| | | 228 | | ? `${awsError.name}: ${awsError.message}` |
| | | 229 | | : awsError.name; |
| | | 230 | | } |
| | | 231 | | |
| | 1 | 232 | | const safeFields: string[] = []; |
| | 1 | 233 | | if (awsError.code) safeFields.push(`code: ${awsError.code}`); |
| | 1 | 234 | | if (awsError.message) safeFields.push(`message: ${awsError.message}`); |
| | | 235 | | |
| | 1 | 236 | | if (safeFields.length > 0) { |
| | 1 | 237 | | return `Object error (${safeFields.join(', ')})`; |
| | | 238 | | } |
| | | 239 | | |
| | 0 | 240 | | return `Object error: ${Object.keys(error as object).join(', ')}`; |
| | | 241 | | } |
| | | 242 | | |
| | 0 | 243 | | return `Unknown error: ${String(error)}`; |
| | | 244 | | } |
| | | 245 | | } |