@push.rocks/qenv 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,25 +1,60 @@
1
1
  import * as plugins from './qenv.plugins.js';
2
+ import {
3
+ QenvInvalidQenvFileError,
4
+ type TQenvInvalidQenvFileReason,
5
+ } from './qenv.classes.invalidqenvfileerror.js';
6
+ import { QenvMissingQenvFileError } from './qenv.classes.missingqenvfileerror.js';
7
+ import { QenvMissingRequiredEnvVarsError } from './qenv.classes.missingrequiredenvvarserror.js';
2
8
 
9
+ /**
10
+ * a reference to an environment variable: its name, or an async function that produces the value.
11
+ * The function form is only honoured by the async getters; the synchronous getter accepts names.
12
+ */
3
13
  export type TEnvVarRef = string | (() => Promise<string>);
4
- type TKeyValueObject = Record<string, any>;
14
+
15
+ /** the resolved value of every available required env var, always a string */
16
+ export type TEnvVarValueMap = Record<string, string>;
17
+
18
+ /** a parsed qenv.yml, env file or secret.json, whose values are whatever the file declared */
19
+ type TParsedFileObject = Record<string, unknown>;
5
20
 
6
21
  export class Qenv {
22
+ /** the names listed under `required:` in qenv.yml */
7
23
  public requiredEnvVars: string[] = [];
24
+
25
+ /** the required names that a source provided */
8
26
  public availableEnvVars: string[] = [];
27
+
28
+ /** the required names that no source provided */
9
29
  public missingEnvVars: string[] = [];
10
- public keyValueObject: TKeyValueObject = {};
30
+
31
+ /** the resolved value of every available required name */
32
+ public keyValueObject: TEnvVarValueMap = {};
33
+
11
34
  public logger = new plugins.smartlog.ConsoleLog();
12
35
 
13
36
  public qenvFilePathAbsolute = '';
14
37
  public envFilePathAbsolute?: string;
15
38
 
39
+ /**
40
+ * Resolves every name listed under `required:` in qenv.yml while constructing.
41
+ * @param qenvFileBasePathArg directory that holds qenv.yml
42
+ * @param envFileBasePathArg directory that holds env.json, env.yml or env.yaml
43
+ * @param failOnMissing throws a QenvMissingRequiredEnvVarsError when a variable listed under
44
+ * `required:` in qenv.yml is not provided by any source, a QenvMissingQenvFileError when there
45
+ * is no qenv.yml to read that list from, and a QenvInvalidQenvFileError when the list cannot be
46
+ * read as names. Pass false to only record the names in `missingEnvVars` and continue.
47
+ * @throws QenvMissingQenvFileError
48
+ * @throws QenvInvalidQenvFileError
49
+ * @throws QenvMissingRequiredEnvVarsError
50
+ */
16
51
  constructor(
17
52
  qenvFileBasePathArg: string = process.cwd(),
18
53
  envFileBasePathArg?: string,
19
54
  failOnMissing: boolean = true
20
55
  ) {
21
56
  this.initializeFilePaths(qenvFileBasePathArg, envFileBasePathArg);
22
- this.loadRequiredEnvVars();
57
+ this.loadRequiredEnvVars(failOnMissing);
23
58
  this.loadAvailableEnvVars();
24
59
  this.checkForMissingEnvVars(failOnMissing);
25
60
  }
@@ -29,18 +64,18 @@ export class Qenv {
29
64
  plugins.path.resolve(qenvFileBasePathArg),
30
65
  'qenv.yml'
31
66
  );
32
-
67
+
33
68
  if (envFileBasePathArg) {
34
69
  const envFileBasePath = plugins.path.resolve(envFileBasePathArg);
35
-
70
+
36
71
  const envFileJsonPath = plugins.path.join(envFileBasePath, 'env.json');
37
72
  const envFileYmlPath = plugins.path.join(envFileBasePath, 'env.yml');
38
73
  const envFileYamlPath = plugins.path.join(envFileBasePath, 'env.yaml');
39
-
74
+
40
75
  const envFileJsonExists = this.fileExists(envFileJsonPath);
41
76
  const envFileYmlExists = this.fileExists(envFileYmlPath);
42
77
  const envFileYamlExists = this.fileExists(envFileYamlPath);
43
-
78
+
44
79
  if (envFileJsonExists && (envFileYmlExists || envFileYamlExists)) {
45
80
  this.logger.log('warn', 'Both env.json and env.yml files exist! Using env.json');
46
81
  this.envFilePathAbsolute = envFileJsonPath;
@@ -54,23 +89,79 @@ export class Qenv {
54
89
  }
55
90
  }
56
91
 
57
- private loadRequiredEnvVars() {
58
- if (this.fileExists(this.qenvFilePathAbsolute)) {
59
- const qenvFile = this.readObjectFromFile(this.qenvFilePathAbsolute);
60
- const requiredEnvVars = qenvFile.required;
61
- if (Array.isArray(requiredEnvVars)) {
62
- this.requiredEnvVars.push(
63
- ...requiredEnvVars.filter((envVar): envVar is string => typeof envVar === 'string')
64
- );
65
- } else {
66
- this.logger.log('warn', 'qenv.yml does not contain a "required" Array!');
92
+ private loadRequiredEnvVars(failOnMissing: boolean) {
93
+ if (!this.fileExists(this.qenvFilePathAbsolute)) {
94
+ if (failOnMissing) {
95
+ // enforcing without the file that declares what to enforce would pass every check by
96
+ // accident: a deployable that forgot to ship qenv.yml must be refused by name, not
97
+ // silently exempted
98
+ throw new QenvMissingQenvFileError({
99
+ qenvFilePathAbsolute: this.qenvFilePathAbsolute,
100
+ qenvDir: plugins.path.dirname(this.qenvFilePathAbsolute),
101
+ });
67
102
  }
103
+ return;
104
+ }
105
+ const qenvFile = this.readObjectFromFile(this.qenvFilePathAbsolute);
106
+ const declaredRequirements = qenvFile['required'];
107
+ if (declaredRequirements === undefined || declaredRequirements === null) {
108
+ // a qenv.yml that lists no requirement legitimately requires nothing
109
+ return;
68
110
  }
111
+ if (!Array.isArray(declaredRequirements)) {
112
+ this.refuseInvalidQenvFile(failOnMissing, 'requiredNotAnArray', declaredRequirements);
113
+ return;
114
+ }
115
+ const declaredEntries: unknown[] = declaredRequirements;
116
+ const invalidEntryIndex = declaredEntries.findIndex((entry) => typeof entry !== 'string');
117
+ if (invalidEntryIndex !== -1) {
118
+ this.refuseInvalidQenvFile(
119
+ failOnMissing,
120
+ 'requiredEntryNotAString',
121
+ declaredEntries[invalidEntryIndex]
122
+ );
123
+ }
124
+ this.requiredEnvVars.push(
125
+ ...declaredEntries.filter((entry): entry is string => typeof entry === 'string')
126
+ );
127
+ }
128
+
129
+ /**
130
+ * Refuses a qenv.yml whose `required:` cannot be read as names: it enforces nothing, which is
131
+ * the fail-open a missing qenv.yml would be. A caller that opted out of failing keeps running,
132
+ * but hears the same named reason instead of losing the requirement silently.
133
+ */
134
+ private refuseInvalidQenvFile(
135
+ failOnMissing: boolean,
136
+ reason: TQenvInvalidQenvFileReason,
137
+ invalidValue: unknown
138
+ ) {
139
+ const invalidQenvFileError = new QenvInvalidQenvFileError({
140
+ qenvFilePathAbsolute: this.qenvFilePathAbsolute,
141
+ reason,
142
+ invalidValueTypeName: this.describeValueType(invalidValue),
143
+ });
144
+ if (failOnMissing) {
145
+ throw invalidQenvFileError;
146
+ }
147
+ this.logger.log('warn', invalidQenvFileError.message);
148
+ }
149
+
150
+ /** names the type of a value without rendering it: a value in a name's place may be a secret */
151
+ private describeValueType(valueArg: unknown): string {
152
+ if (valueArg === null) {
153
+ return 'null';
154
+ }
155
+ return Array.isArray(valueArg) ? 'array' : typeof valueArg;
69
156
  }
70
157
 
71
158
  private loadAvailableEnvVars() {
159
+ // resolved synchronously: a required env var is always a name, and every source a name can come
160
+ // from - process environment, env file, Docker secrets - is a synchronous read. Resolving it
161
+ // here as a promise would store a pending promise instead of a value and make the check below
162
+ // pass for every name.
72
163
  for (const envVar of this.requiredEnvVars) {
73
- const value = this.getEnvVarOnDemand(envVar);
164
+ const value = this.tryGetEnvVarSync(envVar);
74
165
  if (value !== undefined) {
75
166
  this.availableEnvVars.push(envVar);
76
167
  this.keyValueObject[envVar] = value;
@@ -83,18 +174,30 @@ export class Qenv {
83
174
  (envVar) => !this.availableEnvVars.includes(envVar)
84
175
  );
85
176
 
86
- if (this.missingEnvVars.length > 0) {
87
- console.info('Required Env Vars are:', this.requiredEnvVars);
88
- console.error('Missing Env Vars:', this.missingEnvVars);
89
- if (failOnMissing) {
90
- this.logger.log('error', 'Exiting due to missing env vars!');
91
- process.exit(1);
92
- } else {
93
- this.logger.log('warn', 'qenv is not set to fail on missing environment variables');
94
- }
177
+ if (this.missingEnvVars.length === 0) {
178
+ return;
179
+ }
180
+
181
+ if (failOnMissing) {
182
+ // a library must not end the process: the caller decides what an incomplete environment means
183
+ throw new QenvMissingRequiredEnvVarsError({
184
+ missingEnvVars: this.missingEnvVars,
185
+ requiredEnvVars: this.requiredEnvVars,
186
+ qenvFilePathAbsolute: this.qenvFilePathAbsolute,
187
+ });
95
188
  }
189
+
190
+ this.logger.log(
191
+ 'warn',
192
+ `qenv is not set to fail on missing environment variables. Missing: ${this.missingEnvVars.join(', ')}`
193
+ );
96
194
  }
97
195
 
196
+ /**
197
+ * Resolves an env var from the process environment, the env file and the Docker secrets, in that
198
+ * order. An array is tried left to right and the first defined value wins.
199
+ * @param envVarNameOrNames a name, an async resolver function, or a list of either
200
+ */
98
201
  public async getEnvVarOnDemand(
99
202
  envVarNameOrNames: TEnvVarRef | TEnvVarRef[]
100
203
  ): Promise<string | undefined> {
@@ -112,23 +215,25 @@ export class Qenv {
112
215
  }
113
216
 
114
217
  /**
115
- * Like getEnvVarOnDemand, but throws an error if the env var is not set.
116
- * @param envVarNameOrNames
117
- * @returns
218
+ * Like getEnvVarOnDemand, but throws when no source provides a value.
219
+ * @param envVarNameOrNames a name, an async resolver function, or a list of either
118
220
  */
119
221
  public async getEnvVarOnDemandStrict(
120
222
  envVarNameOrNames: TEnvVarRef | TEnvVarRef[]
121
223
  ): Promise<string> {
122
224
  const value = await this.getEnvVarOnDemand(envVarNameOrNames);
123
225
  if (value === undefined) {
124
- throw new Error(`Env var ${envVarNameOrNames} is not set!`);
226
+ throw new Error(`Env var ${this.describeEnvVarRefs(envVarNameOrNames)} is not set!`);
125
227
  }
126
228
  return value;
127
229
  }
128
230
 
231
+ /**
232
+ * The synchronous counterpart of getEnvVarOnDemand. It resolves names only: an async resolver
233
+ * function cannot be awaited here, so the function form of TEnvVarRef is not accepted.
234
+ * @param envVarNameOrNames a name or a list of names
235
+ */
129
236
  public getEnvVarOnDemandSync(envVarNameOrNames: string | string[]): string | undefined {
130
- console.warn('requesting env var sync leaves out potentially important async env sources.');
131
-
132
237
  if (Array.isArray(envVarNameOrNames)) {
133
238
  for (const envVarName of envVarNameOrNames) {
134
239
  const value = this.tryGetEnvVarSync(envVarName);
@@ -142,7 +247,14 @@ export class Qenv {
142
247
  }
143
248
  }
144
249
 
145
- public async getEnvVarOnDemandAsObject(envVarNameOrNames: string | string[]): Promise<any> {
250
+ /**
251
+ * Resolves an env var whose value was stored as a base64 encoded object and decodes it. A plain
252
+ * value is returned as the string it is, so the caller narrows what it gets.
253
+ * @param envVarNameOrNames a name or a list of names
254
+ */
255
+ public async getEnvVarOnDemandAsObject(
256
+ envVarNameOrNames: string | string[]
257
+ ): Promise<unknown> {
146
258
  const rawValue = await this.getEnvVarOnDemand(envVarNameOrNames);
147
259
  if (rawValue && rawValue.startsWith('base64Object:')) {
148
260
  const base64Part = rawValue.split('base64Object:')[1];
@@ -156,39 +268,43 @@ export class Qenv {
156
268
  return await envVarRefArg();
157
269
  }
158
270
 
159
- const sources = [
160
- this.getFromEnvironmentVariable(envVarRefArg),
161
- this.getFromEnvYamlOrJsonFile(envVarRefArg),
162
- this.getFromDockerSecret(envVarRefArg),
163
- this.getFromDockerSecretJson(envVarRefArg)
164
- ];
165
-
166
- for (const value of sources) {
167
- if (value !== undefined) {
168
- return value;
169
- }
170
- }
171
-
172
- return undefined;
271
+ // a name resolves from synchronous sources only, so both getters share one resolution order
272
+ return this.tryGetEnvVarSync(envVarRefArg);
173
273
  }
174
274
 
175
275
  private tryGetEnvVarSync(envVarName: string): string | undefined {
176
- const sources = [
177
- this.getFromEnvironmentVariable(envVarName),
178
- this.getFromEnvYamlOrJsonFile(envVarName),
179
- this.getFromDockerSecret(envVarName),
180
- this.getFromDockerSecretJson(envVarName)
276
+ // read lazily: a source is only touched once every earlier one came back undefined, so a name
277
+ // the process environment answers never opens a secret file, and a malformed secret.json only
278
+ // ever affects the names that actually reach it
279
+ const sources: Array<() => string | undefined> = [
280
+ () => this.getFromEnvironmentVariable(envVarName),
281
+ () => this.getFromEnvYamlOrJsonFile(envVarName),
282
+ () => this.getFromDockerSecret(envVarName),
283
+ () => this.getFromDockerSecretJson(envVarName),
181
284
  ];
182
-
183
- for (const value of sources) {
285
+
286
+ for (const readSource of sources) {
287
+ const value = readSource();
184
288
  if (value !== undefined) {
185
289
  return value;
186
290
  }
187
291
  }
188
-
292
+
189
293
  return undefined;
190
294
  }
191
295
 
296
+ /** renders env var references for an error message, naming a resolver function where it has one */
297
+ private describeEnvVarRefs(envVarNameOrNames: TEnvVarRef | TEnvVarRef[]): string {
298
+ const envVarRefs = Array.isArray(envVarNameOrNames) ? envVarNameOrNames : [envVarNameOrNames];
299
+ return envVarRefs
300
+ .map((envVarRef) =>
301
+ typeof envVarRef === 'function'
302
+ ? `${envVarRef.name || 'anonymous'}()`
303
+ : envVarRef
304
+ )
305
+ .join(', ');
306
+ }
307
+
192
308
  private getFromEnvironmentVariable(envVarName: string): string | undefined {
193
309
  return process.env[envVarName];
194
310
  }
@@ -212,8 +328,16 @@ export class Qenv {
212
328
  }
213
329
  }
214
330
 
331
+ /**
332
+ * the directory Docker mounts secrets into. It is a method so a test can point both secret
333
+ * readers at a directory it is allowed to create; a process can never write /run/secrets itself.
334
+ */
335
+ protected getDockerSecretsDirectoryPath(): string {
336
+ return '/run/secrets';
337
+ }
338
+
215
339
  private getFromDockerSecret(envVarName: string): string | undefined {
216
- const secretPath = `/run/secrets/${envVarName}`;
340
+ const secretPath = plugins.path.join(this.getDockerSecretsDirectoryPath(), envVarName);
217
341
  if (this.fileExists(secretPath)) {
218
342
  return plugins.fs.readFileSync(secretPath, 'utf8');
219
343
  }
@@ -221,11 +345,14 @@ export class Qenv {
221
345
  }
222
346
 
223
347
  private getFromDockerSecretJson(envVarName: string): string | undefined {
224
- if (this.directoryExists('/run/secrets')) {
225
- const availableSecrets = plugins.fs.readdirSync('/run/secrets');
348
+ const secretsDirectoryPath = this.getDockerSecretsDirectoryPath();
349
+ if (this.directoryExists(secretsDirectoryPath)) {
350
+ const availableSecrets = plugins.fs.readdirSync(secretsDirectoryPath);
226
351
  for (const secret of availableSecrets) {
227
352
  if (secret.includes('secret.json')) {
228
- const secretObject = this.readObjectFromFile(`/run/secrets/${secret}`);
353
+ const secretObject = this.readObjectFromFile(
354
+ plugins.path.join(secretsDirectoryPath, secret)
355
+ );
229
356
  const value = secretObject[envVarName];
230
357
  if (value === undefined) {
231
358
  continue;
@@ -240,12 +367,12 @@ export class Qenv {
240
367
  return undefined;
241
368
  }
242
369
 
243
- private encodeBase64(data: any): string {
370
+ private encodeBase64(data: unknown): string {
244
371
  const jsonString = JSON.stringify(data);
245
372
  return Buffer.from(jsonString).toString('base64');
246
373
  }
247
374
 
248
- private decodeBase64(encodedString: string): any {
375
+ private decodeBase64(encodedString: string): unknown {
249
376
  const decodedString = Buffer.from(encodedString, 'base64').toString('utf-8');
250
377
  return JSON.parse(decodedString);
251
378
  }
@@ -265,11 +392,13 @@ export class Qenv {
265
392
  }
266
393
  }
267
394
 
268
- private readObjectFromFile(filePath: string): TKeyValueObject {
395
+ private readObjectFromFile(filePath: string): TParsedFileObject {
269
396
  const fileString = plugins.fs.readFileSync(filePath, 'utf8');
270
- const parsedObject = filePath.endsWith('.json')
397
+ const parsedObject: unknown = filePath.endsWith('.json')
271
398
  ? JSON.parse(fileString)
272
399
  : plugins.yaml.parse(fileString);
273
- return typeof parsedObject === 'object' && parsedObject !== null ? parsedObject : {};
400
+ return typeof parsedObject === 'object' && parsedObject !== null
401
+ ? (parsedObject as TParsedFileObject)
402
+ : {};
274
403
  }
275
404
  }