@red-hat-developer-hub/e2e-test-utils 2.1.15 → 2.1.17
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/deployment/rhdh/config/common/dynamic-plugins.yaml +5 -0
- package/dist/deployment/rhdh/constants.d.ts +0 -5
- package/dist/deployment/rhdh/constants.d.ts.map +1 -1
- package/dist/deployment/rhdh/constants.js +0 -5
- package/dist/deployment/rhdh/deployment.d.ts +2 -2
- package/dist/deployment/rhdh/deployment.d.ts.map +1 -1
- package/dist/deployment/rhdh/deployment.js +9 -25
- package/dist/deployment/rhdh/deployment.test.js +9 -9
- package/dist/deployment/rhdh/types.d.ts +0 -4
- package/dist/deployment/rhdh/types.d.ts.map +1 -1
- package/dist/playwright/helpers/github-session.test.js +1 -1
- package/dist/secrets/bitwarden.d.ts +43 -0
- package/dist/secrets/bitwarden.d.ts.map +1 -1
- package/dist/secrets/bitwarden.js +465 -53
- package/dist/secrets/cli.d.ts +48 -1
- package/dist/secrets/cli.d.ts.map +1 -1
- package/dist/secrets/cli.js +455 -28
- package/dist/secrets/command.d.ts +7 -0
- package/dist/secrets/command.d.ts.map +1 -1
- package/dist/secrets/command.js +103 -26
- package/dist/secrets/config.d.ts +11 -1
- package/dist/secrets/config.d.ts.map +1 -1
- package/dist/secrets/config.js +32 -1
- package/dist/secrets/environment-name.d.ts +2 -0
- package/dist/secrets/environment-name.d.ts.map +1 -0
- package/dist/secrets/environment-name.js +4 -0
- package/dist/secrets/environment.d.ts +10 -0
- package/dist/secrets/environment.d.ts.map +1 -1
- package/dist/secrets/environment.js +35 -4
- package/dist/secrets/exec.d.ts +9 -1
- package/dist/secrets/exec.d.ts.map +1 -1
- package/dist/secrets/exec.js +318 -38
- package/dist/secrets/gsm-wrapper.d.ts +42 -0
- package/dist/secrets/gsm-wrapper.d.ts.map +1 -0
- package/dist/secrets/gsm-wrapper.js +238 -0
- package/dist/secrets/gsm.d.ts +33 -0
- package/dist/secrets/gsm.d.ts.map +1 -0
- package/dist/secrets/gsm.js +172 -0
- package/dist/secrets/index.d.ts +9 -4
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +7 -2
- package/dist/secrets/lock.d.ts +3 -0
- package/dist/secrets/lock.d.ts.map +1 -0
- package/dist/secrets/lock.js +28 -0
- package/dist/secrets/mutation.d.ts +71 -0
- package/dist/secrets/mutation.d.ts.map +1 -0
- package/dist/secrets/mutation.js +210 -0
- package/dist/secrets/secret-input.d.ts +17 -0
- package/dist/secrets/secret-input.d.ts.map +1 -0
- package/dist/secrets/secret-input.js +40 -0
- package/dist/secrets/stream.d.ts +9 -0
- package/dist/secrets/stream.d.ts.map +1 -0
- package/dist/secrets/stream.js +211 -0
- package/dist/secrets/temporary-secret.d.ts +13 -0
- package/dist/secrets/temporary-secret.d.ts.map +1 -0
- package/dist/secrets/temporary-secret.js +87 -0
- package/dist/secrets/tests/bitwarden.test.js +640 -0
- package/dist/secrets/tests/cli.integration.test.js +131 -3
- package/dist/secrets/tests/cli.test.js +281 -6
- package/dist/secrets/tests/command.test.d.ts +2 -0
- package/dist/secrets/tests/command.test.d.ts.map +1 -0
- package/dist/secrets/tests/command.test.js +39 -0
- package/dist/secrets/tests/config.test.js +20 -1
- package/dist/secrets/tests/environment.test.js +145 -3
- package/dist/secrets/tests/exec.test.js +469 -2
- package/dist/secrets/tests/gsm-wrapper.test.d.ts +2 -0
- package/dist/secrets/tests/gsm-wrapper.test.d.ts.map +1 -0
- package/dist/secrets/tests/gsm-wrapper.test.js +166 -0
- package/dist/secrets/tests/gsm.test.d.ts +2 -0
- package/dist/secrets/tests/gsm.test.d.ts.map +1 -0
- package/dist/secrets/tests/gsm.test.js +181 -0
- package/dist/secrets/tests/mutation.test.d.ts +2 -0
- package/dist/secrets/tests/mutation.test.d.ts.map +1 -0
- package/dist/secrets/tests/mutation.test.js +390 -0
- package/dist/secrets/tests/secret-input.test.d.ts +2 -0
- package/dist/secrets/tests/secret-input.test.d.ts.map +1 -0
- package/dist/secrets/tests/secret-input.test.js +71 -0
- package/dist/secrets/tests/stream.test.d.ts +2 -0
- package/dist/secrets/tests/stream.test.d.ts.map +1 -0
- package/dist/secrets/tests/stream.test.js +88 -0
- package/dist/secrets/tests/temporary-secret.test.d.ts +2 -0
- package/dist/secrets/tests/temporary-secret.test.d.ts.map +1 -0
- package/dist/secrets/tests/temporary-secret.test.js +76 -0
- package/dist/utils/plugin-metadata.d.ts +5 -0
- package/dist/utils/plugin-metadata.d.ts.map +1 -1
- package/dist/utils/plugin-metadata.js +19 -0
- package/dist/utils/tests/plugin-metadata.test.js +17 -1
- package/dist/utils/workspace-paths.d.ts +0 -2
- package/dist/utils/workspace-paths.d.ts.map +1 -1
- package/dist/utils/workspace-paths.js +0 -4
- package/package.json +1 -1
- package/dist/deployment/rhdh/config/new-frontend-system/dynamic-plugins.yaml +0 -5
- package/dist/deployment/rhdh/config/new-frontend-system/value_file.yaml +0 -2
package/dist/secrets/exec.js
CHANGED
|
@@ -1,50 +1,330 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { Writable } from "node:stream";
|
|
2
3
|
import { BitwardenClient } from "./bitwarden.js";
|
|
3
4
|
import { expandProfile, } from "./config.js";
|
|
4
|
-
import {
|
|
5
|
+
import { materializeEnvironmentWithSecrets, materializeStreamEnvironment, } from "./environment.js";
|
|
6
|
+
import { writeSecretStream } from "./stream.js";
|
|
5
7
|
export async function executeCommand(options) {
|
|
6
8
|
const expanded = expandProfile(options.profile, options.workspaces);
|
|
7
9
|
const client = options.client ?? new BitwardenClient({ env: options.env ?? process.env });
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
+
const secretValues = await client.read(options.profile.collection, expanded.selectors);
|
|
11
|
+
const materialized = materializeEnvironmentWithSecrets(secretValues, expanded.selectors, options.env ?? process.env);
|
|
12
|
+
const childEnvironment = options.streamSecrets
|
|
13
|
+
? materializeStreamEnvironment(materialized)
|
|
14
|
+
: materialized.environment;
|
|
10
15
|
const childRunner = options.childRunner ?? runChild;
|
|
16
|
+
if (options.streamSecrets) {
|
|
17
|
+
return childRunner(options.command, options.args, childEnvironment, {
|
|
18
|
+
secretStream: materialized.secrets,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
11
21
|
return childRunner(options.command, options.args, childEnvironment);
|
|
12
22
|
}
|
|
13
|
-
export const runChild = (
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
export const runChild = createChildRunner(spawn);
|
|
24
|
+
/** Internal spawn seam used by lifecycle tests; omitted from the package barrel. */
|
|
25
|
+
export function createChildRunner(spawnCommand) {
|
|
26
|
+
return (command, args, env, options) => runChildWithSpawn(spawnCommand, command, args, env, options);
|
|
27
|
+
}
|
|
28
|
+
async function runChildWithSpawn(spawnCommand, command, args, env, options) {
|
|
29
|
+
const secretStream = options?.secretStream;
|
|
30
|
+
if (secretStream !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
await validateSecretStream(secretStream);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw streamError(command);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const detached = process.platform !== "win32";
|
|
40
|
+
let child;
|
|
41
|
+
try {
|
|
42
|
+
child = spawnCommand(command, args, {
|
|
43
|
+
detached,
|
|
44
|
+
env,
|
|
45
|
+
stdio: secretStream === undefined
|
|
46
|
+
? "inherit"
|
|
47
|
+
: ["inherit", "inherit", "inherit", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
reject(commandStartError(command, error));
|
|
35
52
|
return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
53
|
+
}
|
|
54
|
+
let settled = false;
|
|
55
|
+
let childClosed = false;
|
|
56
|
+
let forwardedSignal = false;
|
|
57
|
+
let childResult;
|
|
58
|
+
let fatalStreamError;
|
|
59
|
+
let streamFailure;
|
|
60
|
+
let secretPipe;
|
|
61
|
+
let streamWriter;
|
|
62
|
+
let writerPromise;
|
|
63
|
+
let writerSettled = secretStream === undefined;
|
|
64
|
+
let streamClosing = false;
|
|
65
|
+
let forceTermination;
|
|
66
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
67
|
+
const signalExitCodes = new Map([
|
|
68
|
+
["SIGINT", 130],
|
|
69
|
+
["SIGTERM", 143],
|
|
70
|
+
["SIGHUP", 129],
|
|
71
|
+
]);
|
|
72
|
+
const forwarders = new Map();
|
|
73
|
+
const terminate = (signal) => {
|
|
74
|
+
if (detached && child.pid !== undefined) {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(-child.pid, signal);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Fall through to the direct child signal.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
child.kill(signal);
|
|
84
|
+
};
|
|
85
|
+
const removeForwarders = () => {
|
|
86
|
+
for (const [signal, forwardSignal] of forwarders) {
|
|
87
|
+
process.removeListener(signal, forwardSignal);
|
|
88
|
+
}
|
|
89
|
+
forwarders.clear();
|
|
90
|
+
};
|
|
91
|
+
const clearForceTermination = () => {
|
|
92
|
+
if (forceTermination !== undefined)
|
|
93
|
+
clearTimeout(forceTermination);
|
|
94
|
+
forceTermination = undefined;
|
|
95
|
+
};
|
|
96
|
+
const onStreamError = (error) => {
|
|
97
|
+
streamFailure = error;
|
|
98
|
+
};
|
|
99
|
+
const onStreamClose = () => {
|
|
100
|
+
if (streamClosing && writerSettled)
|
|
101
|
+
removeStreamListeners();
|
|
102
|
+
};
|
|
103
|
+
const removeStreamListeners = () => {
|
|
104
|
+
if (secretPipe) {
|
|
105
|
+
secretPipe.removeListener("error", onStreamError);
|
|
106
|
+
secretPipe.removeListener("close", onStreamClose);
|
|
107
|
+
}
|
|
108
|
+
streamWriter?.removeListener("error", onStreamError);
|
|
109
|
+
};
|
|
110
|
+
const adoptSecretPipe = () => {
|
|
111
|
+
if (!secretPipe && child.stdio[3] instanceof Writable) {
|
|
112
|
+
secretPipe = child.stdio[3];
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const settleUnstartedWriter = () => {
|
|
116
|
+
if (secretStream !== undefined && !streamWriter)
|
|
117
|
+
writerSettled = true;
|
|
118
|
+
};
|
|
119
|
+
const closeSecretStream = (reason) => {
|
|
120
|
+
adoptSecretPipe();
|
|
121
|
+
if (streamClosing)
|
|
122
|
+
return;
|
|
123
|
+
streamClosing = true;
|
|
124
|
+
if (streamWriter && !streamWriter.destroyed) {
|
|
125
|
+
streamWriter.destroy(reason);
|
|
126
|
+
}
|
|
127
|
+
else if (secretPipe && !secretPipe.destroyed) {
|
|
128
|
+
secretPipe.destroy();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const finish = () => {
|
|
132
|
+
if (settled || !childClosed || !writerSettled)
|
|
133
|
+
return;
|
|
134
|
+
settled = true;
|
|
135
|
+
clearForceTermination();
|
|
136
|
+
removeForwarders();
|
|
137
|
+
if (fatalStreamError)
|
|
138
|
+
reject(fatalStreamError);
|
|
139
|
+
else
|
|
140
|
+
resolve(childResult ?? 1);
|
|
141
|
+
};
|
|
142
|
+
const rejectStreamFailure = () => {
|
|
143
|
+
if (settled ||
|
|
144
|
+
childClosed ||
|
|
145
|
+
forwardedSignal ||
|
|
146
|
+
isBrokenPipe(streamFailure)) {
|
|
147
|
+
finish();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
fatalStreamError ??= streamError(command);
|
|
151
|
+
removeForwarders();
|
|
152
|
+
closeSecretStream();
|
|
153
|
+
terminate("SIGTERM");
|
|
154
|
+
forceTermination = setTimeout(() => {
|
|
155
|
+
forceTermination = undefined;
|
|
156
|
+
if (!childClosed)
|
|
157
|
+
terminate("SIGKILL");
|
|
158
|
+
}, 1_000);
|
|
159
|
+
finish();
|
|
160
|
+
};
|
|
161
|
+
const startStream = () => {
|
|
162
|
+
const candidate = child.stdio[3];
|
|
163
|
+
if (!(candidate instanceof Writable) || !candidate.writable) {
|
|
164
|
+
writerSettled = true;
|
|
165
|
+
rejectStreamFailure();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
secretPipe = candidate;
|
|
169
|
+
secretPipe.on("error", onStreamError);
|
|
170
|
+
secretPipe.once("close", onStreamClose);
|
|
171
|
+
streamWriter = new SecretStreamWriter(secretPipe, onStreamError);
|
|
172
|
+
streamWriter.on("error", onStreamError);
|
|
173
|
+
writerPromise = writeSecretStream(streamWriter, secretStream).then(() => {
|
|
174
|
+
writerSettled = true;
|
|
175
|
+
removeStreamListeners();
|
|
176
|
+
finish();
|
|
177
|
+
}, () => {
|
|
178
|
+
writerSettled = true;
|
|
179
|
+
rejectStreamFailure();
|
|
180
|
+
removeStreamListeners();
|
|
181
|
+
finish();
|
|
182
|
+
});
|
|
183
|
+
void writerPromise.catch(() => undefined);
|
|
184
|
+
};
|
|
185
|
+
for (const signal of signals) {
|
|
186
|
+
const forwardSignal = () => {
|
|
187
|
+
if (settled)
|
|
188
|
+
return;
|
|
189
|
+
forwardedSignal = true;
|
|
190
|
+
removeForwarders();
|
|
191
|
+
settleUnstartedWriter();
|
|
192
|
+
closeSecretStream(writerPromise && !writerSettled
|
|
193
|
+
? new Error("secret stream closed")
|
|
194
|
+
: undefined);
|
|
195
|
+
terminate(signal);
|
|
196
|
+
};
|
|
197
|
+
forwarders.set(signal, forwardSignal);
|
|
198
|
+
process.on(signal, forwardSignal);
|
|
199
|
+
}
|
|
200
|
+
child.once("spawn", () => {
|
|
201
|
+
if (secretStream !== undefined) {
|
|
202
|
+
if (forwardedSignal || settled) {
|
|
203
|
+
settleUnstartedWriter();
|
|
204
|
+
closeSecretStream();
|
|
205
|
+
}
|
|
206
|
+
else
|
|
207
|
+
startStream();
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
child.once("error", (error) => {
|
|
211
|
+
if (settled)
|
|
212
|
+
return;
|
|
213
|
+
settled = true;
|
|
214
|
+
clearForceTermination();
|
|
215
|
+
removeForwarders();
|
|
216
|
+
settleUnstartedWriter();
|
|
217
|
+
closeSecretStream();
|
|
218
|
+
reject(commandStartError(command, error));
|
|
219
|
+
});
|
|
220
|
+
child.once("close", (code, signal) => {
|
|
221
|
+
childClosed = true;
|
|
222
|
+
settleUnstartedWriter();
|
|
223
|
+
const signalExitCode = signal
|
|
224
|
+
? signalExitCodes.get(signal)
|
|
225
|
+
: undefined;
|
|
226
|
+
childResult = code ?? signalExitCode ?? 1;
|
|
227
|
+
closeSecretStream(writerPromise && !writerSettled
|
|
228
|
+
? new Error("secret stream closed")
|
|
229
|
+
: undefined);
|
|
230
|
+
removeForwarders();
|
|
231
|
+
finish();
|
|
232
|
+
});
|
|
39
233
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
234
|
+
}
|
|
235
|
+
/* eslint-disable @typescript-eslint/naming-convention -- Node Writable hook names */
|
|
236
|
+
class SecretStreamWriter extends Writable {
|
|
237
|
+
target;
|
|
238
|
+
onFailure;
|
|
239
|
+
onTargetError;
|
|
240
|
+
onTargetClose;
|
|
241
|
+
constructor(target, onFailure) {
|
|
242
|
+
super();
|
|
243
|
+
this.target = target;
|
|
244
|
+
this.onFailure = onFailure;
|
|
245
|
+
this.onTargetError = (error) => {
|
|
246
|
+
onFailure(error);
|
|
247
|
+
if (!this.destroyed)
|
|
248
|
+
this.destroy(error);
|
|
249
|
+
};
|
|
250
|
+
this.onTargetClose = () => {
|
|
251
|
+
if (this.destroyed || this.writableFinished)
|
|
252
|
+
return;
|
|
253
|
+
const error = pipeClosedError();
|
|
254
|
+
onFailure(error);
|
|
255
|
+
this.destroy(error);
|
|
256
|
+
};
|
|
257
|
+
target.on("error", this.onTargetError);
|
|
258
|
+
target.once("close", this.onTargetClose);
|
|
259
|
+
}
|
|
260
|
+
_write(chunk, _encoding, callback) {
|
|
261
|
+
try {
|
|
262
|
+
if (this.target.write(chunk))
|
|
263
|
+
callback();
|
|
264
|
+
else
|
|
265
|
+
this.target.once("drain", callback);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
this.onFailure(error);
|
|
269
|
+
callback(toError(error));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
_final(callback) {
|
|
273
|
+
try {
|
|
274
|
+
this.target.end(callback);
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.onFailure(error);
|
|
278
|
+
callback(toError(error));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
_destroy(error, callback) {
|
|
282
|
+
this.target.removeListener("error", this.onTargetError);
|
|
283
|
+
this.target.removeListener("close", this.onTargetClose);
|
|
284
|
+
if (!this.target.destroyed)
|
|
285
|
+
this.target.destroy();
|
|
286
|
+
callback(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/* eslint-enable @typescript-eslint/naming-convention */
|
|
290
|
+
async function validateSecretStream(entries) {
|
|
291
|
+
const sink = new Writable({
|
|
292
|
+
write(_chunk, _encoding, callback) {
|
|
293
|
+
callback();
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
await writeSecretStream(sink, entries);
|
|
298
|
+
}
|
|
299
|
+
finally {
|
|
300
|
+
sink.destroy();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function isBrokenPipe(error) {
|
|
304
|
+
return (typeof error === "object" &&
|
|
305
|
+
error !== null &&
|
|
306
|
+
"code" in error &&
|
|
307
|
+
(error.code === "EPIPE" ||
|
|
308
|
+
error.code === "ECONNRESET" ||
|
|
309
|
+
error.code === "ERR_STREAM_DESTROYED"));
|
|
310
|
+
}
|
|
311
|
+
function commandStartError(command, error) {
|
|
312
|
+
const environmentHint = typeof error === "object" &&
|
|
313
|
+
error !== null &&
|
|
314
|
+
"code" in error &&
|
|
315
|
+
error.code === "E2BIG"
|
|
316
|
+
? "; child environment exceeds OS limits; retry with --stream-secrets"
|
|
317
|
+
: "";
|
|
318
|
+
return new Error(`Unable to start command: ${command}${environmentHint}`);
|
|
319
|
+
}
|
|
320
|
+
function pipeClosedError() {
|
|
321
|
+
return Object.assign(new Error("secret stream pipe closed"), {
|
|
322
|
+
code: "ERR_STREAM_DESTROYED",
|
|
49
323
|
});
|
|
50
|
-
}
|
|
324
|
+
}
|
|
325
|
+
function streamError(command) {
|
|
326
|
+
return new Error(`Unable to write secret stream for command: ${command}`);
|
|
327
|
+
}
|
|
328
|
+
function toError(error) {
|
|
329
|
+
return error instanceof Error ? error : new Error("secret stream failure");
|
|
330
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type CommandResult, type CommandRunner } from "./command.js";
|
|
2
|
+
import type { GsmRunOptions } from "./gsm.js";
|
|
3
|
+
export interface GsmWrapperOptions {
|
|
4
|
+
cacheDir?: string;
|
|
5
|
+
fetchScript?: (timeoutMs?: number) => Promise<string>;
|
|
6
|
+
fetchTimeoutMs?: number;
|
|
7
|
+
commandRunner?: CommandRunner;
|
|
8
|
+
env?: NodeJS.ProcessEnv;
|
|
9
|
+
warning?: (message: string) => void;
|
|
10
|
+
}
|
|
11
|
+
export interface GsmWrapperMetadata {
|
|
12
|
+
scriptPath: string;
|
|
13
|
+
sha256: string;
|
|
14
|
+
fetchedAt: string;
|
|
15
|
+
usedCache: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface GsmWrapperRunResult extends CommandResult {
|
|
18
|
+
usedCache: boolean;
|
|
19
|
+
sha256?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare class GsmWrapper {
|
|
22
|
+
private readonly cacheDir;
|
|
23
|
+
private readonly fetchScript;
|
|
24
|
+
private readonly commandRunner;
|
|
25
|
+
private readonly env;
|
|
26
|
+
private readonly warning;
|
|
27
|
+
private readonly fetchTimeoutMs;
|
|
28
|
+
private activeMetadata?;
|
|
29
|
+
private initialization?;
|
|
30
|
+
constructor(options?: GsmWrapperOptions);
|
|
31
|
+
run(args: readonly string[], timeoutMs?: number, options?: GsmRunOptions): Promise<GsmWrapperRunResult>;
|
|
32
|
+
login(): Promise<GsmWrapperRunResult>;
|
|
33
|
+
clean(): Promise<GsmWrapperRunResult>;
|
|
34
|
+
ensureInteractive(): Promise<void>;
|
|
35
|
+
private ensureWrapper;
|
|
36
|
+
private initializeWrapper;
|
|
37
|
+
private writeCache;
|
|
38
|
+
private readCachedMetadata;
|
|
39
|
+
}
|
|
40
|
+
export declare function defaultCacheDir(env?: NodeJS.ProcessEnv): string;
|
|
41
|
+
export declare function validateWrapper(script: string): void;
|
|
42
|
+
//# sourceMappingURL=gsm-wrapper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gsm-wrapper.d.ts","sourceRoot":"","sources":["../../src/secrets/gsm-wrapper.ts"],"names":[],"mappings":"AAaA,OAAO,EAGL,KAAK,aAAa,EAClB,KAAK,aAAa,EACnB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAU9C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,CAAC,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACtD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACrC;AAED,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,mBAAoB,SAAQ,aAAa;IACxD,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA0C;IACtE,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAoB;IACxC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA4B;IACpD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,cAAc,CAAC,CAAqB;IAC5C,OAAO,CAAC,cAAc,CAAC,CAA8B;gBAEzC,OAAO,GAAE,iBAAsB;IAWrC,GAAG,CACP,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,SAAS,CAAC,EAAE,MAAM,EAClB,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,mBAAmB,CAAC;IAmBzB,KAAK,IAAI,OAAO,CAAC,mBAAmB,CAAC;IAIrC,KAAK,IAAI,OAAO,CAAC,mBAAmB,CAAC;IAgBrC,iBAAiB,IAAI,OAAO,CAAC,IAAI,CAAC;YAI1B,aAAa;YAUb,iBAAiB;YA2BjB,UAAU;YAgCV,kBAAkB;CAkCjC;AAiBD,wBAAgB,eAAe,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,MAAM,CAO5E;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAepD"}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, readFile, rm, rename, stat, writeFile, } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { assertInteractiveTerminal, runCommand, } from "./command.js";
|
|
6
|
+
const WRAPPER_URL = "https://raw.githubusercontent.com/openshift/release/main/hack/secret-manager.sh";
|
|
7
|
+
const WRAPPER_FILE = "secret-manager.sh";
|
|
8
|
+
const CACHE_METADATA_FILE = "secret-manager.json";
|
|
9
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 60_000;
|
|
10
|
+
const TRUSTED_IMAGE_PATTERN = /^quay\.io\/openshift\/ci-public(?::[A-Za-z0-9][A-Za-z0-9._-]*|@sha256:[a-f0-9]{64})$/;
|
|
11
|
+
export class GsmWrapper {
|
|
12
|
+
cacheDir;
|
|
13
|
+
fetchScript;
|
|
14
|
+
commandRunner;
|
|
15
|
+
env;
|
|
16
|
+
warning;
|
|
17
|
+
fetchTimeoutMs;
|
|
18
|
+
activeMetadata;
|
|
19
|
+
initialization;
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.cacheDir = path.resolve(options.cacheDir ?? defaultCacheDir(options.env ?? process.env));
|
|
22
|
+
this.fetchScript = options.fetchScript ?? fetchCurrentWrapper;
|
|
23
|
+
this.fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
24
|
+
this.commandRunner = options.commandRunner ?? runCommand;
|
|
25
|
+
this.env = buildGsmEnvironment({ ...process.env, ...options.env });
|
|
26
|
+
this.warning = options.warning ?? console.warn;
|
|
27
|
+
}
|
|
28
|
+
async run(args, timeoutMs, options = {}) {
|
|
29
|
+
const metadata = await this.ensureWrapper();
|
|
30
|
+
const result = await this.commandRunner("bash", [metadata.scriptPath, ...args], {
|
|
31
|
+
env: this.env,
|
|
32
|
+
timeoutMs,
|
|
33
|
+
stdio: options.stdio,
|
|
34
|
+
tty: options.tty,
|
|
35
|
+
});
|
|
36
|
+
return {
|
|
37
|
+
...result,
|
|
38
|
+
usedCache: metadata.usedCache,
|
|
39
|
+
sha256: metadata.sha256,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async login() {
|
|
43
|
+
return this.run(["login"], undefined, { stdio: "inherit", tty: true });
|
|
44
|
+
}
|
|
45
|
+
async clean() {
|
|
46
|
+
await rm(path.join(this.cacheDir, "gcp-secret-manager", ".secret-manager-gcloud"), { recursive: true, force: true });
|
|
47
|
+
return {
|
|
48
|
+
status: 0,
|
|
49
|
+
stdout: "",
|
|
50
|
+
stderr: "",
|
|
51
|
+
usedCache: this.activeMetadata?.usedCache ?? false,
|
|
52
|
+
...(this.activeMetadata?.sha256
|
|
53
|
+
? { sha256: this.activeMetadata.sha256 }
|
|
54
|
+
: {}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async ensureInteractive() {
|
|
58
|
+
await assertInteractiveTerminal();
|
|
59
|
+
}
|
|
60
|
+
async ensureWrapper() {
|
|
61
|
+
if (this.activeMetadata)
|
|
62
|
+
return this.activeMetadata;
|
|
63
|
+
if (this.initialization)
|
|
64
|
+
return this.initialization;
|
|
65
|
+
this.initialization = this.initializeWrapper().catch((error) => {
|
|
66
|
+
this.initialization = undefined;
|
|
67
|
+
throw error;
|
|
68
|
+
});
|
|
69
|
+
return this.initialization;
|
|
70
|
+
}
|
|
71
|
+
async initializeWrapper() {
|
|
72
|
+
await mkdir(path.join(this.cacheDir, "gcp-secret-manager"), {
|
|
73
|
+
recursive: true,
|
|
74
|
+
mode: 0o700,
|
|
75
|
+
});
|
|
76
|
+
await chmod(this.cacheDir, 0o700);
|
|
77
|
+
try {
|
|
78
|
+
const script = await this.fetchScript(this.fetchTimeoutMs);
|
|
79
|
+
validateWrapper(script);
|
|
80
|
+
return (this.activeMetadata = await this.writeCache(script, false));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const cached = await this.readCachedMetadata();
|
|
84
|
+
if (!cached) {
|
|
85
|
+
throw new Error(`Unable to download the GSM wrapper and no validated cache exists: ${error instanceof Error ? error.message : "untrusted wrapper"}`, { cause: error });
|
|
86
|
+
}
|
|
87
|
+
this.warning(`Using cached GSM wrapper from ${cached.fetchedAt} because refresh failed`);
|
|
88
|
+
return (this.activeMetadata = { ...cached, usedCache: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async writeCache(script, usedCache) {
|
|
92
|
+
const sha256 = hash(script);
|
|
93
|
+
const scriptPath = path.join(this.cacheDir, `${WRAPPER_FILE}.${sha256}`);
|
|
94
|
+
const fetchedAt = new Date().toISOString();
|
|
95
|
+
const suffix = `${process.pid}.${randomUUID()}`;
|
|
96
|
+
const temporaryPath = `${scriptPath}.${suffix}.tmp`;
|
|
97
|
+
const metadataPath = path.join(this.cacheDir, CACHE_METADATA_FILE);
|
|
98
|
+
const temporaryMetadataPath = `${metadataPath}.${suffix}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
await writeFile(temporaryPath, script, { encoding: "utf8", mode: 0o700 });
|
|
101
|
+
await chmod(temporaryPath, 0o700);
|
|
102
|
+
await rename(temporaryPath, scriptPath);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
106
|
+
}
|
|
107
|
+
const metadata = { scriptPath, sha256, fetchedAt };
|
|
108
|
+
try {
|
|
109
|
+
await writeFile(temporaryMetadataPath, `${JSON.stringify(metadata)}\n`, {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
mode: 0o600,
|
|
112
|
+
});
|
|
113
|
+
await chmod(temporaryMetadataPath, 0o600);
|
|
114
|
+
await rename(temporaryMetadataPath, metadataPath);
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await rm(temporaryMetadataPath, { force: true }).catch(() => undefined);
|
|
118
|
+
}
|
|
119
|
+
return { ...metadata, usedCache };
|
|
120
|
+
}
|
|
121
|
+
async readCachedMetadata() {
|
|
122
|
+
try {
|
|
123
|
+
const metadata = JSON.parse(await readFile(path.join(this.cacheDir, CACHE_METADATA_FILE), "utf8"));
|
|
124
|
+
if (!isRecord(metadata))
|
|
125
|
+
return undefined;
|
|
126
|
+
if (typeof metadata.scriptPath !== "string" ||
|
|
127
|
+
typeof metadata.sha256 !== "string" ||
|
|
128
|
+
typeof metadata.fetchedAt !== "string") {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
const scriptPath = path.resolve(metadata.scriptPath);
|
|
132
|
+
if (!isCacheScriptPath(this.cacheDir, scriptPath)) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
if ((await lstat(scriptPath)).isSymbolicLink())
|
|
136
|
+
return undefined;
|
|
137
|
+
const script = await readFile(scriptPath, "utf8");
|
|
138
|
+
validateWrapper(script);
|
|
139
|
+
if (hash(script) !== metadata.sha256)
|
|
140
|
+
return undefined;
|
|
141
|
+
const details = await stat(scriptPath);
|
|
142
|
+
if ((details.mode & 0o111) === 0)
|
|
143
|
+
return undefined;
|
|
144
|
+
return {
|
|
145
|
+
scriptPath,
|
|
146
|
+
sha256: metadata.sha256,
|
|
147
|
+
fetchedAt: metadata.fetchedAt,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function isCacheScriptPath(cacheDir, scriptPath) {
|
|
156
|
+
const relative = path.relative(path.resolve(cacheDir), path.resolve(scriptPath));
|
|
157
|
+
if (relative.startsWith("..") || path.isAbsolute(relative))
|
|
158
|
+
return false;
|
|
159
|
+
const baseName = path.basename(scriptPath);
|
|
160
|
+
return (baseName === WRAPPER_FILE ||
|
|
161
|
+
new RegExp(`^${WRAPPER_FILE.replace(".", "\\.")}\\.[a-f0-9]{64}$`).test(baseName));
|
|
162
|
+
}
|
|
163
|
+
export function defaultCacheDir(env = process.env) {
|
|
164
|
+
const configuredRoot = env.XDG_CACHE_HOME;
|
|
165
|
+
const cacheRoot = configuredRoot && path.isAbsolute(configuredRoot)
|
|
166
|
+
? configuredRoot
|
|
167
|
+
: path.join(os.homedir(), ".cache");
|
|
168
|
+
return path.join(cacheRoot, "rhdh-e2e-secrets", "gsm");
|
|
169
|
+
}
|
|
170
|
+
export function validateWrapper(script) {
|
|
171
|
+
if (!script.startsWith("#!/bin/bash") ||
|
|
172
|
+
!script.includes("set -euo pipefail") ||
|
|
173
|
+
!script.includes("GCLOUD_CONFIG_PATH") ||
|
|
174
|
+
!script.includes("CONTAINER_ENGINE")) {
|
|
175
|
+
throw new Error("GSM wrapper validation failed");
|
|
176
|
+
}
|
|
177
|
+
const image = script.match(/^IMAGE="\$\{SECRET_MANAGER_IMAGE:-([^}]+)\}"$/m)?.[1];
|
|
178
|
+
if (!image || !TRUSTED_IMAGE_PATTERN.test(image)) {
|
|
179
|
+
throw new Error("GSM wrapper validation failed: trusted image is required");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async function fetchCurrentWrapper(timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
|
183
|
+
const response = await fetch(WRAPPER_URL, {
|
|
184
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
185
|
+
});
|
|
186
|
+
if (response.url !== WRAPPER_URL) {
|
|
187
|
+
throw new Error("GSM wrapper download redirected to an unexpected URL");
|
|
188
|
+
}
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new Error(`GitHub returned HTTP ${response.status}`);
|
|
191
|
+
}
|
|
192
|
+
return response.text();
|
|
193
|
+
}
|
|
194
|
+
function hash(value) {
|
|
195
|
+
return createHash("sha256").update(value, "utf8").digest("hex");
|
|
196
|
+
}
|
|
197
|
+
function buildGsmEnvironment(parent) {
|
|
198
|
+
if (parent.SECRET_MANAGER_IMAGE !== undefined) {
|
|
199
|
+
validateImageReference(parent.SECRET_MANAGER_IMAGE);
|
|
200
|
+
}
|
|
201
|
+
const allowed = new Set([
|
|
202
|
+
"PATH",
|
|
203
|
+
"HOME",
|
|
204
|
+
"USER",
|
|
205
|
+
"LOGNAME",
|
|
206
|
+
"SHELL",
|
|
207
|
+
"TMPDIR",
|
|
208
|
+
"XDG_RUNTIME_DIR",
|
|
209
|
+
"XDG_CONFIG_HOME",
|
|
210
|
+
"CONTAINER_ENGINE",
|
|
211
|
+
"CONTAINER_HOST",
|
|
212
|
+
"DOCKER_HOST",
|
|
213
|
+
"SECRET_MANAGER_IMAGE",
|
|
214
|
+
"REGISTRY_AUTH_FILE",
|
|
215
|
+
"CONTAINERS_AUTH_FILE",
|
|
216
|
+
"SSL_CERT_FILE",
|
|
217
|
+
"NO_COLOR",
|
|
218
|
+
"TERM",
|
|
219
|
+
"HTTP_PROXY",
|
|
220
|
+
"HTTPS_PROXY",
|
|
221
|
+
"NO_PROXY",
|
|
222
|
+
]);
|
|
223
|
+
const result = {};
|
|
224
|
+
for (const [key, value] of Object.entries(parent)) {
|
|
225
|
+
if (value !== undefined && allowed.has(key.toUpperCase())) {
|
|
226
|
+
result[key] = value;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
function validateImageReference(image) {
|
|
232
|
+
if (!TRUSTED_IMAGE_PATTERN.test(image)) {
|
|
233
|
+
throw new Error("SECRET_MANAGER_IMAGE must reference the trusted image repository quay.io/openshift/ci-public");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function isRecord(value) {
|
|
237
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
238
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type GsmWrapperRunResult } from "./gsm-wrapper.js";
|
|
2
|
+
export type GsmMetadata = Record<string, unknown>;
|
|
3
|
+
export declare class GsmNotFoundError extends Error {
|
|
4
|
+
readonly secretPath: string;
|
|
5
|
+
constructor(secretPath: string);
|
|
6
|
+
}
|
|
7
|
+
export interface GsmRunOptions {
|
|
8
|
+
stdio?: "pipe" | "inherit";
|
|
9
|
+
tty?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface GsmRunner {
|
|
12
|
+
run(args: readonly string[], timeoutMs?: number, options?: GsmRunOptions): Promise<GsmWrapperRunResult>;
|
|
13
|
+
ensureInteractive?: () => Promise<void>;
|
|
14
|
+
}
|
|
15
|
+
export interface GsmClientOptions {
|
|
16
|
+
runner?: GsmRunner;
|
|
17
|
+
metadataTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export declare class GsmClient {
|
|
20
|
+
private readonly runner;
|
|
21
|
+
private readonly metadataTimeoutMs;
|
|
22
|
+
wrapperSha256?: string;
|
|
23
|
+
constructor(options?: GsmClientOptions);
|
|
24
|
+
describe(collection: string, secretPath: string): Promise<GsmMetadata>;
|
|
25
|
+
exists(collection: string, secretPath: string): Promise<boolean>;
|
|
26
|
+
list(collection: string): Promise<string[]>;
|
|
27
|
+
ensureInteractive(): Promise<void>;
|
|
28
|
+
create(collection: string, secretPath: string, snapshotPath: string, timeoutMs: number): Promise<void>;
|
|
29
|
+
update(collection: string, secretPath: string, snapshotPath: string, timeoutMs: number): Promise<void>;
|
|
30
|
+
delete(collection: string, secretPath: string, timeoutMs: number): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
export declare function gsmMutationError(message: string, indeterminate: boolean): Error;
|
|
33
|
+
//# sourceMappingURL=gsm.d.ts.map
|