| | | 1 | | import { inject, injectable } from 'inversify'; |
| | | 2 | | import pc from 'picocolors'; |
| | | 3 | | import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js'; |
| | | 4 | | import { |
| | | 5 | | ExpiredCredentialsError, |
| | | 6 | | SecretsFetchError, |
| | | 7 | | SsoSessionExpiredError, |
| | | 8 | | } from '../../domain/errors/DomainErrors.js'; |
| | | 9 | | import type { ILogger } from '../../domain/ports/ILogger.js'; |
| | | 10 | | import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js'; |
| | | 11 | | import type { IVariableStore } from '../../domain/ports/IVariableStore.js'; |
| | | 12 | | import { describeError } from '../../infrastructure/describeError.js'; |
| | | 13 | | import { TYPES } from '../../types.js'; |
| | | 14 | | import type { PullSecretsToEnvCommand } from './PullSecretsToEnvCommand.js'; |
| | | 15 | | |
| | | 16 | | type ResolvedOutcome = { status: 'resolved'; envVar: string; masked: string }; |
| | | 17 | | type WarningOutcome = { |
| | | 18 | | status: 'warning'; |
| | | 19 | | envVar: string; |
| | | 20 | | path: string; |
| | | 21 | | reason: 'not-found' | 'empty'; |
| | | 22 | | }; |
| | | 23 | | type ErrorOutcome = { |
| | | 24 | | status: 'error'; |
| | | 25 | | envVar: string; |
| | | 26 | | path: string; |
| | | 27 | | reason: string; |
| | | 28 | | }; |
| | | 29 | | type SecretOutcome = ResolvedOutcome | WarningOutcome | ErrorOutcome; |
| | | 30 | | |
| | | 31 | | @injectable() |
| | 8 | 32 | | export class PullSecretsToEnvCommandHandler { |
| | 8 | 33 | | private static readonly LABEL_WIDTH = 20; |
| | 8 | 34 | | private static readonly RULE = pc.yellow('\u2501'.repeat(60)); |
| | | 35 | | |
| | | 36 | | constructor( |
| | | 37 | | @inject(TYPES.ISecretProvider) |
| | 32 | 38 | | private readonly secretProvider: ISecretProvider, |
| | | 39 | | @inject(TYPES.IVariableStore) |
| | 32 | 40 | | private readonly variableStore: IVariableStore, |
| | 32 | 41 | | @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 } = |
| | 14 | 52 | | await this.loadVariables(command); |
| | 14 | 53 | | const { variables, resolvedCount, totalCount } = await this.envild( |
| | | 54 | | requestVariables, |
| | | 55 | | currentVariables, |
| | | 56 | | ); |
| | 9 | 57 | | await this.saveEnvFile(command.envFilePath, variables); |
| | | 58 | | |
| | 9 | 59 | | 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 | | }> { |
| | 14 | 72 | | const requestVariables = await this.variableStore.getMapping( |
| | | 73 | | command.mapPath, |
| | | 74 | | ); |
| | 14 | 75 | | const currentVariables = await this.variableStore.getEnvironment( |
| | | 76 | | command.envFilePath, |
| | | 77 | | ); |
| | | 78 | | |
| | 14 | 79 | | return { requestVariables, currentVariables }; |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | private async saveEnvFile( |
| | | 83 | | envFilePath: string, |
| | | 84 | | variables: Record<string, string>, |
| | | 85 | | ): Promise<void> { |
| | 9 | 86 | | 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 | | }> { |
| | 14 | 97 | | const outcomes = await Promise.all( |
| | | 98 | | Object.entries(paramMap).map(([envVar, secretName]) => |
| | 16 | 99 | | this.processSecret(envVar, secretName, existingEnvVariables), |
| | | 100 | | ), |
| | | 101 | | ); |
| | | 102 | | |
| | 12 | 103 | | const resolved = outcomes.filter( |
| | 14 | 104 | | (outcome): outcome is ResolvedOutcome => outcome.status === 'resolved', |
| | | 105 | | ); |
| | 12 | 106 | | const warnings = outcomes.filter( |
| | 14 | 107 | | (outcome): outcome is WarningOutcome => outcome.status === 'warning', |
| | | 108 | | ); |
| | 12 | 109 | | const errors = outcomes.filter( |
| | 14 | 110 | | (outcome): outcome is ErrorOutcome => outcome.status === 'error', |
| | | 111 | | ); |
| | | 112 | | |
| | 12 | 113 | | this.logSecretsSection(resolved, warnings); |
| | | 114 | | |
| | 12 | 115 | | if (errors.length > 0) { |
| | 3 | 116 | | throw new SecretsFetchError( |
| | 3 | 117 | | errors.map((error) => ({ |
| | | 118 | | envVar: error.envVar, |
| | | 119 | | path: error.path, |
| | | 120 | | reason: error.reason, |
| | | 121 | | })), |
| | | 122 | | ); |
| | | 123 | | } |
| | | 124 | | |
| | 9 | 125 | | 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> { |
| | 16 | 137 | | try { |
| | 16 | 138 | | const value = await this.secretProvider.getSecret(secretName); |
| | 11 | 139 | | if (value === undefined) { |
| | 1 | 140 | | return { |
| | | 141 | | status: 'warning', |
| | | 142 | | envVar, |
| | | 143 | | path: secretName, |
| | | 144 | | reason: 'not-found', |
| | | 145 | | }; |
| | | 146 | | } |
| | 10 | 147 | | if (value === '') { |
| | 3 | 148 | | return { status: 'warning', envVar, path: secretName, reason: 'empty' }; |
| | | 149 | | } |
| | | 150 | | |
| | 7 | 151 | | existingEnvVariables[envVar] = value; |
| | 7 | 152 | | const masked = new EnvironmentVariable(envVar, value, true).maskedValue; |
| | | 153 | | |
| | 7 | 154 | | return { status: 'resolved', envVar, masked }; |
| | | 155 | | } catch (error) { |
| | 5 | 156 | | if ( |
| | | 157 | | error instanceof ExpiredCredentialsError || |
| | | 158 | | error instanceof SsoSessionExpiredError |
| | | 159 | | ) { |
| | 2 | 160 | | throw error; |
| | | 161 | | } |
| | 3 | 162 | | const maskedPath = EnvironmentVariable.maskSecretPath(secretName); |
| | 3 | 163 | | const reason = PullSecretsToEnvCommandHandler.describeErrorReason( |
| | | 164 | | error, |
| | | 165 | | maskedPath, |
| | | 166 | | ); |
| | 3 | 167 | | return { status: 'error', envVar, path: maskedPath, reason }; |
| | | 168 | | } |
| | | 169 | | } |
| | | 170 | | |
| | | 171 | | private static describeErrorReason( |
| | | 172 | | error: unknown, |
| | | 173 | | maskedPath: string, |
| | | 174 | | ): string { |
| | 3 | 175 | | const message = describeError(error); |
| | 3 | 176 | | const duplicatedPrefix = `${maskedPath}: `; |
| | 3 | 177 | | return message.startsWith(duplicatedPrefix) |
| | | 178 | | ? message.slice(duplicatedPrefix.length) |
| | | 179 | | : message; |
| | | 180 | | } |
| | | 181 | | |
| | | 182 | | private logSecretsSection( |
| | | 183 | | resolved: ResolvedOutcome[], |
| | | 184 | | warnings: WarningOutcome[], |
| | | 185 | | ): void { |
| | 12 | 186 | | if (resolved.length === 0 && warnings.length === 0) { |
| | 2 | 187 | | return; |
| | | 188 | | } |
| | | 189 | | |
| | 10 | 190 | | this.logger.info(`\n${pc.bold(pc.yellow('\u{1FA99} RESOLVING SECRETS'))}`); |
| | 10 | 191 | | this.logger.info(PullSecretsToEnvCommandHandler.RULE); |
| | 10 | 192 | | for (const outcome of resolved) { |
| | 7 | 193 | | this.logger.info( |
| | | 194 | | ` ${pc.green('\u2713 ')}${pc.bold( |
| | | 195 | | PullSecretsToEnvCommandHandler.pad(outcome.envVar), |
| | | 196 | | )}${pc.dim('\u2192 ')}${pc.dim(outcome.masked)}`, |
| | | 197 | | ); |
| | | 198 | | } |
| | 10 | 199 | | this.logWarnings(warnings); |
| | | 200 | | } |
| | | 201 | | |
| | | 202 | | private logWarnings(warnings: WarningOutcome[]): void { |
| | 10 | 203 | | for (const outcome of warnings) { |
| | 4 | 204 | | const maskedPath = EnvironmentVariable.maskSecretPath(outcome.path); |
| | 4 | 205 | | if (outcome.reason === 'not-found') { |
| | 1 | 206 | | 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 | | ); |
| | 1 | 211 | | continue; |
| | | 212 | | } |
| | 3 | 213 | | 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 { |
| | 11 | 222 | | return name.padEnd(PullSecretsToEnvCommandHandler.LABEL_WIDTH); |
| | | 223 | | } |
| | | 224 | | |
| | | 225 | | private static buildSummary( |
| | | 226 | | resolvedCount: number, |
| | | 227 | | totalCount: number, |
| | | 228 | | envFilePath: string, |
| | | 229 | | ): string { |
| | 9 | 230 | | 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 | | } |