@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.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -1
- package/dist_ts/qenv.classes.invalidqenvfileerror.d.ts +21 -0
- package/dist_ts/qenv.classes.invalidqenvfileerror.js +25 -0
- package/dist_ts/qenv.classes.missingqenvfileerror.d.ts +16 -0
- package/dist_ts/qenv.classes.missingqenvfileerror.js +19 -0
- package/dist_ts/qenv.classes.missingrequiredenvvarserror.d.ts +18 -0
- package/dist_ts/qenv.classes.missingrequiredenvvarserror.js +19 -0
- package/dist_ts/qenv.classes.qenv.d.ts +56 -7
- package/dist_ts/qenv.classes.qenv.js +140 -49
- package/package.json +5 -5
- package/readme.md +120 -15
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/index.ts +3 -0
- package/ts/qenv.classes.invalidqenvfileerror.ts +43 -0
- package/ts/qenv.classes.missingqenvfileerror.ts +28 -0
- package/ts/qenv.classes.missingrequiredenvvarserror.ts +30 -0
- package/ts/qenv.classes.qenv.ts +194 -65
package/ts/qenv.classes.qenv.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
225
|
-
|
|
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(
|
|
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:
|
|
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):
|
|
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):
|
|
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
|
|
400
|
+
return typeof parsedObject === 'object' && parsedObject !== null
|
|
401
|
+
? (parsedObject as TParsedFileObject)
|
|
402
|
+
: {};
|
|
274
403
|
}
|
|
275
404
|
}
|