< Summary - Envilder CLI

Information
Class: src/envilder/core/application/secretResolution/SecretResolver.ts
Assembly: Default
File(s): src/envilder/core/application/secretResolution/SecretResolver.ts
Tag: 561_41292037308
Line coverage
100%
Covered lines: 45
Uncovered lines: 0
Coverable lines: 45
Total lines: 201
Line coverage: 100%
Branch coverage
100%
Covered branches: 20
Total branches: 20
Branch coverage: 100%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

File(s)

src/envilder/core/application/secretResolution/SecretResolver.ts

#LineLine coverage
 1import pc from 'picocolors';
 2import { EnvironmentVariable } from '../../domain/EnvironmentVariable.js';
 3import {
 4  ExpiredCredentialsError,
 5  type SecretFetchFailure,
 6  SsoSessionExpiredError,
 7} from '../../domain/errors/DomainErrors.js';
 8import type { ILogger } from '../../domain/ports/ILogger.js';
 9import type { ISecretMasker } from '../../domain/ports/ISecretMasker.js';
 10import type { ISecretProvider } from '../../domain/ports/ISecretProvider.js';
 11import { describeError } from '../../infrastructure/describeError.js';
 12
 13export type ResolvedSecret = {
 14  status: 'resolved';
 15  envVar: string;
 16  value: string;
 17  masked: string;
 18};
 19
 20export type UnresolvedSecret = {
 21  status: 'warning';
 22  envVar: string;
 23  path: string;
 24  reason: 'not-found' | 'empty';
 25};
 26
 27export type FailedSecret = {
 28  status: 'error';
 29  envVar: string;
 30  /** Already masked. */
 31  path: string;
 32  reason: string;
 33};
 34
 35export type SecretOutcome = ResolvedSecret | UnresolvedSecret | FailedSecret;
 36
 37export type SecretResolution = {
 38  /** One outcome per mapping, in map-file order. */
 39  outcomes: SecretOutcome[];
 40  resolved: ResolvedSecret[];
 41  warnings: UnresolvedSecret[];
 42  errors: FailedSecret[];
 43};
 44
 1345const LABEL_WIDTH = 20;
 1346const RULE = pc.yellow('\u2501'.repeat(60));
 47
 48/**
 49 * Resolves the secrets declared in a map file and presents the outcome.
 50 *
 51 * Shared by every mode that reads secrets (pull and exec) so the outcomes,
 52 * masking, failure reasons and the per-secret block stay identical. Each mode
 53 * decides what to do with missing or empty secrets: pull skips them, exec
 54 * refuses to start the child.
 55 */
 56export class SecretResolver {
 57  constructor(
 16658    private readonly secretProvider: ISecretProvider,
 16659    private readonly logger: ILogger,
 16660    private readonly secretMasker: ISecretMasker,
 61  ) {}
 62
 63  /**
 64   * Fetches every mapped secret concurrently. Every resolved value is
 65   * registered with the secret masker as soon as it is known. Expired
 66   * credentials and SSO sessions propagate so callers can recover; any other
 67   * provider failure becomes an error outcome with a masked path.
 68   */
 69  async resolve(mappings: Record<string, string>): Promise<SecretResolution> {
 9070    const outcomes = await Promise.all(
 71      Object.entries(mappings).map(([envVar, secretName]) =>
 15672        this.resolveOne(envVar, secretName),
 73      ),
 74    );
 75
 8076    return {
 77      outcomes,
 78      resolved: outcomes.filter(
 14279        (outcome): outcome is ResolvedSecret => outcome.status === 'resolved',
 80      ),
 81      warnings: outcomes.filter(
 14282        (outcome): outcome is UnresolvedSecret => outcome.status === 'warning',
 83      ),
 84      errors: outcomes.filter(
 14285        (outcome): outcome is FailedSecret => outcome.status === 'error',
 86      ),
 87    };
 88  }
 89
 90  /**
 91   * Builds the failure entry used by `SecretsFetchError` for an outcome that
 92   * did not resolve. Missing and empty secrets use the reasons `not found`
 93   * and `empty`.
 94   */
 95  static toFailure(
 96    outcome: UnresolvedSecret | FailedSecret,
 97  ): SecretFetchFailure {
 1198    if (outcome.status === 'error') {
 499      return {
 100        envVar: outcome.envVar,
 101        path: outcome.path,
 102        reason: outcome.reason,
 103      };
 104    }
 7105    return {
 106      envVar: outcome.envVar,
 107      path: EnvironmentVariable.maskSecretPath(outcome.path),
 108      reason: outcome.reason === 'not-found' ? 'not found' : 'empty',
 109    };
 110  }
 111
 112  /**
 113   * Logs the per-secret block: resolved secrets with masked values, then the
 114   * skipped ones as warnings.
 115   */
 116  logSecretsSection(
 117    resolved: ResolvedSecret[],
 118    warnings: UnresolvedSecret[],
 119  ): void {
 22120    if (resolved.length === 0 && warnings.length === 0) {
 2121      return;
 122    }
 123
 20124    this.logger.info(`\n${pc.bold(pc.yellow('\u{1FA99}  RESOLVING SECRETS'))}`);
 20125    this.logger.info(RULE);
 20126    for (const outcome of resolved) {
 16127      this.logger.info(
 128        `  ${pc.green('\u2713 ')}${pc.bold(pad(outcome.envVar))}${pc.dim(
 129          '\u2192 ',
 130        )}${pc.dim(outcome.masked)}`,
 131      );
 132    }
 20133    this.logSkippedWarnings(warnings);
 134  }
 135
 136  private logSkippedWarnings(warnings: UnresolvedSecret[]): void {
 20137    for (const outcome of warnings) {
 6138      const maskedPath = EnvironmentVariable.maskSecretPath(outcome.path);
 6139      if (outcome.reason === 'not-found') {
 2140        this.logger.warn(
 141          `  ${pc.red('\u2717 ')}${pc.bold(
 142            pc.red(pad(outcome.envVar)),
 143          )} ${pc.red(`secret not found (path: ${maskedPath}) \u2014 skipped`)}`,
 144        );
 2145        continue;
 146      }
 4147      this.logger.warn(
 148        `  ${pc.yellow('\u26A0 ')}${pc.bold(pad(outcome.envVar))} ${pc.dim(
 149          `no value found (path: ${maskedPath}) \u2014 skipped`,
 150        )}`,
 151      );
 152    }
 153  }
 154
 155  private async resolveOne(
 156    envVar: string,
 157    secretName: string,
 158  ): Promise<SecretOutcome> {
 156159    try {
 156160      const value = await this.secretProvider.getSecret(secretName);
 140161      if (value === undefined) {
 7162        return {
 163          status: 'warning',
 164          envVar,
 165          path: secretName,
 166          reason: 'not-found',
 167        };
 168      }
 133169      if (value === '') {
 6170        return { status: 'warning', envVar, path: secretName, reason: 'empty' };
 171      }
 172
 127173      this.secretMasker.mask(value);
 127174      const masked = EnvironmentVariable.secret(envVar, value).maskedValue;
 175
 127176      return { status: 'resolved', envVar, value, masked };
 177    } catch (error) {
 16178      if (
 179        error instanceof ExpiredCredentialsError ||
 180        error instanceof SsoSessionExpiredError
 181      ) {
 12182        throw error;
 183      }
 4184      const maskedPath = EnvironmentVariable.maskSecretPath(secretName);
 4185      const reason = describeErrorReason(error, maskedPath);
 4186      return { status: 'error', envVar, path: maskedPath, reason };
 187    }
 188  }
 189}
 190
 191function describeErrorReason(error: unknown, maskedPath: string): string {
 4192  const message = describeError(error);
 4193  const duplicatedPrefix = `${maskedPath}: `;
 4194  return message.startsWith(duplicatedPrefix)
 195    ? message.slice(duplicatedPrefix.length)
 196    : message;
 197}
 198
 199function pad(name: string): string {
 22200  return name.padEnd(LABEL_WIDTH);
 201}