@axinom/mosaic-cli 0.61.0 → 0.61.1
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/esm/commands/hosting/database/database-commands.d.ts +2 -0
- package/dist/esm/commands/hosting/database/database-commands.js +13 -0
- package/dist/esm/commands/hosting/database/database-commands.js.map +1 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-command.d.ts +3 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-command.js +60 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-command.js.map +1 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-options.d.ts +13 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-options.js +2 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy-options.js.map +1 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy.d.ts +27 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy.js +382 -0
- package/dist/esm/commands/hosting/database/proxy/database-proxy.js.map +1 -0
- package/dist/esm/commands/hosting/index.js +2 -0
- package/dist/esm/commands/hosting/index.js.map +1 -1
- package/package.json +4 -2
- package/src/commands/hosting/database/database-commands.ts +21 -0
- package/src/commands/hosting/database/proxy/database-proxy-command.ts +78 -0
- package/src/commands/hosting/database/proxy/database-proxy-options.ts +13 -0
- package/src/commands/hosting/database/proxy/database-proxy.spec.ts +178 -0
- package/src/commands/hosting/database/proxy/database-proxy.ts +548 -0
- package/src/commands/hosting/index.ts +2 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { getServiceAccountToken } from '@axinom/mosaic-id-link-be';
|
|
2
|
+
import { isNullOrWhitespace } from '@axinom/mosaic-service-common';
|
|
3
|
+
import axios, { AxiosError, type AxiosInstance } from 'axios';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import net from 'net';
|
|
6
|
+
import ora from 'ora';
|
|
7
|
+
import WebSocket, { createWebSocketStream } from 'ws';
|
|
8
|
+
import { exitCode } from '../../../../common/index.js';
|
|
9
|
+
import type { DatabaseProxyOptions } from './database-proxy-options.js';
|
|
10
|
+
|
|
11
|
+
const { red, yellow, green, dim, bold } = chalk;
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_LOCAL_PROXY_PORT = 5433;
|
|
14
|
+
|
|
15
|
+
/** Only loopback is ever bound: the credential is not meant to leave the machine. */
|
|
16
|
+
const LOOPBACK = '127.0.0.1';
|
|
17
|
+
|
|
18
|
+
/** Declared here rather than imported: the Hosting schema types are private. */
|
|
19
|
+
export interface DatabaseAccessSession {
|
|
20
|
+
databaseAccessSessionId: string;
|
|
21
|
+
username: string;
|
|
22
|
+
password: string;
|
|
23
|
+
databaseName: string;
|
|
24
|
+
expiresAt: string;
|
|
25
|
+
databaseProxyUrl: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const OPEN_SESSION = `mutation OpenDatabaseAccessSession($input: OpenDatabaseAccessSessionInput!) {
|
|
29
|
+
openDatabaseAccessSession(input: $input) {
|
|
30
|
+
databaseAccessSessionId
|
|
31
|
+
username
|
|
32
|
+
password
|
|
33
|
+
databaseName
|
|
34
|
+
expiresAt
|
|
35
|
+
databaseProxyUrl
|
|
36
|
+
}
|
|
37
|
+
}`;
|
|
38
|
+
|
|
39
|
+
const CLOSE_SESSION = `mutation CloseDatabaseAccessSession($input: CloseDatabaseAccessSessionInput!) {
|
|
40
|
+
closeDatabaseAccessSession(input: $input) {
|
|
41
|
+
status
|
|
42
|
+
terminatedBackends
|
|
43
|
+
}
|
|
44
|
+
}`;
|
|
45
|
+
|
|
46
|
+
/** Connection details go to stdout and nothing else does, so `--json` pipes. */
|
|
47
|
+
const writeOut = (text: string): void => {
|
|
48
|
+
process.stdout.write(text + '\n');
|
|
49
|
+
};
|
|
50
|
+
const writeStatus = (text: string): void => {
|
|
51
|
+
process.stderr.write(text + '\n');
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const minutesUntil = (expiresAt: string, now = Date.now()): number =>
|
|
55
|
+
Math.max(0, Math.floor((new Date(expiresAt).getTime() - now) / 60_000));
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hosting may grant less than was asked for. The minute of tolerance absorbs
|
|
59
|
+
* the round trip.
|
|
60
|
+
*/
|
|
61
|
+
export const formatClampedTtlWarning = (
|
|
62
|
+
requestedMinutes: number | undefined,
|
|
63
|
+
expiresAt: string,
|
|
64
|
+
now = Date.now(),
|
|
65
|
+
): string | undefined => {
|
|
66
|
+
if (requestedMinutes === undefined) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const granted = minutesUntil(expiresAt, now);
|
|
70
|
+
if (granted >= requestedMinutes - 1) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return `Requested ${requestedMinutes} minutes, granted ${granted} - database access for this service expires then.`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const WARN_BEFORE_EXPIRY_MS = 5 * 60_000;
|
|
77
|
+
|
|
78
|
+
/** The byte a terminal delivers for Ctrl+C once it stops raising SIGINT. */
|
|
79
|
+
const ETX = 0x03;
|
|
80
|
+
|
|
81
|
+
/** Undefined when the session is too short for the warning to precede it. */
|
|
82
|
+
export const toExpiryWarningDelay = (
|
|
83
|
+
expiresAt: string,
|
|
84
|
+
now = Date.now(),
|
|
85
|
+
): number | undefined => {
|
|
86
|
+
const remaining = new Date(expiresAt).getTime() - now;
|
|
87
|
+
return remaining > WARN_BEFORE_EXPIRY_MS
|
|
88
|
+
? remaining - WARN_BEFORE_EXPIRY_MS
|
|
89
|
+
: undefined;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const toConnectionJson = (
|
|
93
|
+
session: DatabaseAccessSession,
|
|
94
|
+
port: number,
|
|
95
|
+
): Record<string, string | number> => ({
|
|
96
|
+
databaseAccessSessionId: session.databaseAccessSessionId,
|
|
97
|
+
host: LOOPBACK,
|
|
98
|
+
port,
|
|
99
|
+
database: session.databaseName,
|
|
100
|
+
user: session.username,
|
|
101
|
+
password: session.password,
|
|
102
|
+
expiresAt: session.expiresAt,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const formatConnectionDetails = (
|
|
106
|
+
session: DatabaseAccessSession,
|
|
107
|
+
port: number,
|
|
108
|
+
now = Date.now(),
|
|
109
|
+
): string => {
|
|
110
|
+
const rows: [string, string][] = [
|
|
111
|
+
['Host', LOOPBACK],
|
|
112
|
+
['Port', String(port)],
|
|
113
|
+
['Database', session.databaseName],
|
|
114
|
+
['User', session.username],
|
|
115
|
+
['Password', session.password],
|
|
116
|
+
[
|
|
117
|
+
'Expires',
|
|
118
|
+
`${session.expiresAt} (${minutesUntil(session.expiresAt, now)} minutes)`,
|
|
119
|
+
],
|
|
120
|
+
];
|
|
121
|
+
return rows
|
|
122
|
+
.map(([label, value]) => ` ${label.padEnd(9)} ${value}`)
|
|
123
|
+
.join('\n');
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
interface GraphQlError {
|
|
127
|
+
message: string;
|
|
128
|
+
code?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const getAxiosInstance = (
|
|
132
|
+
hostingServiceBaseUrl: string,
|
|
133
|
+
serviceAccountToken: string,
|
|
134
|
+
): AxiosInstance => {
|
|
135
|
+
return axios.create({
|
|
136
|
+
baseURL: new URL(hostingServiceBaseUrl).toString(),
|
|
137
|
+
headers: {
|
|
138
|
+
Authorization: `Bearer ${serviceAccountToken}`,
|
|
139
|
+
'content-type': 'application/json',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** Built per call rather than once: a long session outlives its startup token. */
|
|
145
|
+
const callHosting = async <T>(
|
|
146
|
+
hostingServiceBaseUrl: string,
|
|
147
|
+
serviceAccountToken: string,
|
|
148
|
+
query: string,
|
|
149
|
+
variables: unknown,
|
|
150
|
+
): Promise<T> => {
|
|
151
|
+
const axiosInstance = getAxiosInstance(
|
|
152
|
+
hostingServiceBaseUrl,
|
|
153
|
+
serviceAccountToken,
|
|
154
|
+
);
|
|
155
|
+
const payload = JSON.stringify({ query, variables });
|
|
156
|
+
const response = await axiosInstance.post('graphql', payload);
|
|
157
|
+
|
|
158
|
+
const errors: GraphQlError[] = response.data.errors ?? [];
|
|
159
|
+
if (errors.length > 0) {
|
|
160
|
+
const { message, code } = errors[0];
|
|
161
|
+
throw new Error(code ? `${code}: ${message}` : message);
|
|
162
|
+
}
|
|
163
|
+
return response.data.data as T;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** Caches the token and renews it a minute before it lapses. */
|
|
167
|
+
const createTokenProvider = (
|
|
168
|
+
authEndpoint: string,
|
|
169
|
+
clientId: string,
|
|
170
|
+
clientSecret: string,
|
|
171
|
+
): (() => Promise<string>) => {
|
|
172
|
+
let token = '';
|
|
173
|
+
let renewAt = 0;
|
|
174
|
+
return async () => {
|
|
175
|
+
if (token === '' || Date.now() >= renewAt) {
|
|
176
|
+
const result = await getServiceAccountToken(
|
|
177
|
+
authEndpoint,
|
|
178
|
+
clientId,
|
|
179
|
+
clientSecret,
|
|
180
|
+
);
|
|
181
|
+
token = result.accessToken;
|
|
182
|
+
renewAt = Date.now() + (result.expiresInSeconds - 60) * 1_000;
|
|
183
|
+
}
|
|
184
|
+
return token;
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const describeError = (error: unknown): string => {
|
|
189
|
+
const axiosError = error as AxiosError;
|
|
190
|
+
if (axiosError.response) {
|
|
191
|
+
return `${axiosError.message} (HTTP ${
|
|
192
|
+
axiosError.response.status
|
|
193
|
+
}: ${JSON.stringify(axiosError.response.data)})`;
|
|
194
|
+
}
|
|
195
|
+
return error instanceof Error ? error.message : String(error);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Pipes one accepted TCP connection through one WebSocket, never multiplexed.
|
|
200
|
+
* The bytes are the TLS session the client negotiated with Postgres directly.
|
|
201
|
+
*/
|
|
202
|
+
const databaseProxyConnection = (
|
|
203
|
+
client: net.Socket,
|
|
204
|
+
session: DatabaseAccessSession,
|
|
205
|
+
getToken: () => Promise<string>,
|
|
206
|
+
): void => {
|
|
207
|
+
// Paused until the carrier is open, so nothing the client sends is dropped.
|
|
208
|
+
client.pause();
|
|
209
|
+
|
|
210
|
+
void (async () => {
|
|
211
|
+
// Everything up to the carrier existing is guarded: an escape here is an
|
|
212
|
+
// unhandled rejection, which kills the process before the session is
|
|
213
|
+
// closed and strands the database role until it expires.
|
|
214
|
+
let ws: WebSocket;
|
|
215
|
+
try {
|
|
216
|
+
const token = await getToken();
|
|
217
|
+
ws = new WebSocket(session.databaseProxyUrl, {
|
|
218
|
+
headers: {
|
|
219
|
+
authorization: `Bearer ${token}`,
|
|
220
|
+
'x-mosaic-database-access-session': session.databaseAccessSessionId,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
} catch (error) {
|
|
224
|
+
writeStatus(
|
|
225
|
+
red(`Could not reach the database proxy: ${describeError(error)}`),
|
|
226
|
+
);
|
|
227
|
+
client.destroy();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// end(), not destroy(): destroy drops bytes still buffered for the client.
|
|
232
|
+
const stop = (): void => {
|
|
233
|
+
client.end();
|
|
234
|
+
if (
|
|
235
|
+
ws.readyState === WebSocket.CONNECTING ||
|
|
236
|
+
ws.readyState === WebSocket.OPEN
|
|
237
|
+
) {
|
|
238
|
+
ws.close();
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Piped rather than forwarded by hand: a slow reader on either end pauses
|
|
243
|
+
// the other instead of queueing without limit.
|
|
244
|
+
ws.on('open', () => {
|
|
245
|
+
const carrier = createWebSocketStream(ws);
|
|
246
|
+
carrier.on('error', stop);
|
|
247
|
+
client.pipe(carrier);
|
|
248
|
+
carrier.pipe(client);
|
|
249
|
+
client.resume();
|
|
250
|
+
});
|
|
251
|
+
ws.on('unexpected-response', (_request, response) => {
|
|
252
|
+
writeStatus(
|
|
253
|
+
red(
|
|
254
|
+
`The database proxy refused the connection (HTTP ${response.statusCode}).`,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
client.destroy();
|
|
258
|
+
});
|
|
259
|
+
ws.on('error', (error) => {
|
|
260
|
+
writeStatus(red(`Database proxy error: ${error.message}`));
|
|
261
|
+
stop();
|
|
262
|
+
});
|
|
263
|
+
ws.on('close', stop);
|
|
264
|
+
client.on('error', stop);
|
|
265
|
+
client.on('close', () => {
|
|
266
|
+
if (
|
|
267
|
+
ws.readyState === WebSocket.CONNECTING ||
|
|
268
|
+
ws.readyState === WebSocket.OPEN
|
|
269
|
+
) {
|
|
270
|
+
ws.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
})();
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const listen = (server: net.Server, port: number): Promise<void> =>
|
|
277
|
+
new Promise((resolve, reject) => {
|
|
278
|
+
server.once('error', reject);
|
|
279
|
+
server.listen(port, LOOPBACK, () => {
|
|
280
|
+
server.removeListener('error', reject);
|
|
281
|
+
resolve();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
/** Hands the terminal back; the mode belongs to it, not to this process. */
|
|
286
|
+
const restoreStdinMode = (): void => {
|
|
287
|
+
if (process.stdin.isTTY && process.stdin.isRaw) {
|
|
288
|
+
process.stdin.setRawMode(false);
|
|
289
|
+
process.stdin.pause();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Calls `onShutdown` once, on Ctrl+C or a termination signal.
|
|
295
|
+
*
|
|
296
|
+
* Ctrl+C is read from stdin rather than taken as a signal: in raw mode the
|
|
297
|
+
* terminal stops raising one, which keeps it from also killing the `yarn` and
|
|
298
|
+
* `env-cmd` wrappers a solution runs this through. They would exit at once and
|
|
299
|
+
* hand the shell prompt back while the session was still closing.
|
|
300
|
+
*/
|
|
301
|
+
const onShutdownRequested = (onShutdown: () => void): void => {
|
|
302
|
+
let requested = false;
|
|
303
|
+
const request = (): void => {
|
|
304
|
+
if (requested) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
requested = true;
|
|
308
|
+
onShutdown();
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
process.on('exit', restoreStdinMode);
|
|
312
|
+
|
|
313
|
+
// The baseline everywhere: `kill`, a shutting-down machine, Ctrl+Break, and
|
|
314
|
+
// any stdin that is not a terminal. SIGKILL cannot be caught; that falls to
|
|
315
|
+
// the session's expiry.
|
|
316
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) {
|
|
317
|
+
process.on(signal, request);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// On a terminal, take the key before it becomes a SIGINT that would kill the
|
|
321
|
+
// wrappers too. Raw mode suppresses that signal on every platform.
|
|
322
|
+
if (process.stdin.isTTY) {
|
|
323
|
+
process.stdin.setRawMode(true);
|
|
324
|
+
process.stdin.resume();
|
|
325
|
+
process.stdin.on('data', (chunk: Buffer) => {
|
|
326
|
+
if (chunk.includes(ETX)) {
|
|
327
|
+
request();
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const openSession = async (
|
|
334
|
+
args: DatabaseProxyOptions,
|
|
335
|
+
hostingServiceBaseUrl: string,
|
|
336
|
+
getToken: () => Promise<string>,
|
|
337
|
+
): Promise<DatabaseAccessSession> => {
|
|
338
|
+
// Creating the role is a remote call that regularly takes half a minute.
|
|
339
|
+
const spinner = ora({
|
|
340
|
+
text: `Opening a database access session for [${args.serviceId}]...`,
|
|
341
|
+
// Off: it would put stdin in raw mode, which is this command's to manage.
|
|
342
|
+
discardStdin: false,
|
|
343
|
+
}).start();
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
const data = await callHosting<{
|
|
347
|
+
openDatabaseAccessSession: DatabaseAccessSession;
|
|
348
|
+
}>(hostingServiceBaseUrl, await getToken(), OPEN_SESSION, {
|
|
349
|
+
input: {
|
|
350
|
+
serviceId: args.serviceId,
|
|
351
|
+
// Left undefined when not asked for, so the service applies its own
|
|
352
|
+
// default rather than this one.
|
|
353
|
+
ttlMinutes: args.ttlMinutes,
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
spinner.stop();
|
|
357
|
+
return data.openDatabaseAccessSession;
|
|
358
|
+
} catch (error) {
|
|
359
|
+
spinner.fail(`Could not open a session for service [${args.serviceId}].`);
|
|
360
|
+
writeStatus(red(describeError(error)));
|
|
361
|
+
process.exit(exitCode);
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/** Closes the session and reports the outcome. Resolves to the exit code. */
|
|
366
|
+
const closeSession = async (
|
|
367
|
+
hostingServiceBaseUrl: string,
|
|
368
|
+
getToken: () => Promise<string>,
|
|
369
|
+
session: DatabaseAccessSession,
|
|
370
|
+
): Promise<number> => {
|
|
371
|
+
try {
|
|
372
|
+
// Fresh token: the startup one may have lapsed, and a 401 strands the role.
|
|
373
|
+
const data = await callHosting<{
|
|
374
|
+
closeDatabaseAccessSession: { status: string };
|
|
375
|
+
}>(hostingServiceBaseUrl, await getToken(), CLOSE_SESSION, {
|
|
376
|
+
input: { databaseAccessSessionId: session.databaseAccessSessionId },
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (data.closeDatabaseAccessSession.status === 'CLOSED') {
|
|
380
|
+
writeStatus(green('Session closed and the database role dropped.'));
|
|
381
|
+
} else {
|
|
382
|
+
// CLOSING: the role outlives the command by at most one reconciliation
|
|
383
|
+
// pass, so there is nothing useful to wait here for.
|
|
384
|
+
writeStatus(
|
|
385
|
+
yellow(
|
|
386
|
+
'Local proxy closed. Dropping the database role is still pending and will be completed by the service.',
|
|
387
|
+
),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return 0;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
writeStatus(
|
|
393
|
+
yellow(
|
|
394
|
+
`The session could not be closed and will expire at ${
|
|
395
|
+
session.expiresAt
|
|
396
|
+
}: ${describeError(error)}`,
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
return exitCode;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const printSession = (
|
|
404
|
+
args: DatabaseProxyOptions,
|
|
405
|
+
session: DatabaseAccessSession,
|
|
406
|
+
port: number,
|
|
407
|
+
): void => {
|
|
408
|
+
const clampWarning = formatClampedTtlWarning(
|
|
409
|
+
args.ttlMinutes,
|
|
410
|
+
session.expiresAt,
|
|
411
|
+
);
|
|
412
|
+
if (clampWarning) {
|
|
413
|
+
writeStatus(yellow(clampWarning));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (args.json === true) {
|
|
417
|
+
writeOut(JSON.stringify(toConnectionJson(session, port)));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
writeStatus(bold(`\nDatabase access to [${args.serviceId}] is open.\n`));
|
|
422
|
+
writeOut(formatConnectionDetails(session, port));
|
|
423
|
+
writeStatus(
|
|
424
|
+
dim('\nThe password is shown once and cannot be retrieved again.'),
|
|
425
|
+
);
|
|
426
|
+
writeStatus(dim('Press Ctrl+C to close the session.'));
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const warnBeforeExpiry = (session: DatabaseAccessSession): void => {
|
|
430
|
+
const delay = toExpiryWarningDelay(session.expiresAt);
|
|
431
|
+
if (delay === undefined) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
setTimeout(() => {
|
|
435
|
+
writeStatus(
|
|
436
|
+
yellow(
|
|
437
|
+
`\nThis session ends at ${session.expiresAt}. Open connections will be dropped.`,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}, delay).unref();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export const proxyDatabase = async (
|
|
444
|
+
args: Required<Pick<DatabaseProxyOptions, 'serviceId'>> &
|
|
445
|
+
DatabaseProxyOptions,
|
|
446
|
+
): Promise<void> => {
|
|
447
|
+
const port = args.localProxyPort ?? DEFAULT_LOCAL_PROXY_PORT;
|
|
448
|
+
const hostingServiceBaseUrl = args.hostingServiceBaseURL as string;
|
|
449
|
+
|
|
450
|
+
const getToken = createTokenProvider(
|
|
451
|
+
args.idServiceAuthBaseURL as string,
|
|
452
|
+
args.mosaicHostingClientId as string,
|
|
453
|
+
args.mosaicHostingClientSecret as string,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const session = await openSession(args, hostingServiceBaseUrl, getToken);
|
|
457
|
+
|
|
458
|
+
const server = net.createServer((client) =>
|
|
459
|
+
databaseProxyConnection(client, session, getToken),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
// Bound before printing: a bind error gives the session back.
|
|
463
|
+
try {
|
|
464
|
+
await listen(server, port);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
writeStatus(red(`Could not listen on ${LOOPBACK}:${port}.`));
|
|
467
|
+
writeStatus(red(describeError(error)));
|
|
468
|
+
await closeSession(hostingServiceBaseUrl, getToken, session);
|
|
469
|
+
process.exit(exitCode);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
onShutdownRequested(() => {
|
|
473
|
+
writeStatus(dim('\nClosing the session...'));
|
|
474
|
+
server.close();
|
|
475
|
+
void closeSession(hostingServiceBaseUrl, getToken, session).then((code) =>
|
|
476
|
+
process.exit(code),
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
printSession(args, session, port);
|
|
481
|
+
warnBeforeExpiry(session);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Validate arguments for proxying a direct database connection.
|
|
486
|
+
*/
|
|
487
|
+
export const validateDatabaseProxyArgs = (
|
|
488
|
+
args: DatabaseProxyOptions,
|
|
489
|
+
): [DatabaseProxyOptions, string[]] => {
|
|
490
|
+
const errorMessages: string[] = [];
|
|
491
|
+
|
|
492
|
+
const idServiceAuthBaseURL =
|
|
493
|
+
args.idServiceAuthBaseURL ?? process.env.ID_SERVICE_AUTH_BASE_URL ?? '';
|
|
494
|
+
const mosaicHostingClientId =
|
|
495
|
+
args.mosaicHostingClientId ?? process.env.MOSAIC_HOSTING_CLIENT_ID ?? '';
|
|
496
|
+
const mosaicHostingClientSecret =
|
|
497
|
+
args.mosaicHostingClientSecret ??
|
|
498
|
+
process.env.MOSAIC_HOSTING_CLIENT_SECRET ??
|
|
499
|
+
'';
|
|
500
|
+
const hostingServiceBaseURL =
|
|
501
|
+
args.hostingServiceBaseURL ?? process.env.HOSTING_SERVICE_BASE_URL ?? '';
|
|
502
|
+
// Run from a solution, the service's own .env names it.
|
|
503
|
+
const serviceId = args.serviceId ?? process.env.SERVICE_ID ?? '';
|
|
504
|
+
|
|
505
|
+
if (isNullOrWhitespace(idServiceAuthBaseURL)) {
|
|
506
|
+
errorMessages.push('[idServiceAuthBaseURL] is required.');
|
|
507
|
+
}
|
|
508
|
+
if (isNullOrWhitespace(mosaicHostingClientId)) {
|
|
509
|
+
errorMessages.push('[mosaicHostingClientId] is required.');
|
|
510
|
+
}
|
|
511
|
+
if (isNullOrWhitespace(mosaicHostingClientSecret)) {
|
|
512
|
+
errorMessages.push('[mosaicHostingClientSecret] is required.');
|
|
513
|
+
}
|
|
514
|
+
if (isNullOrWhitespace(hostingServiceBaseURL)) {
|
|
515
|
+
errorMessages.push('[hostingServiceBaseURL] is required.');
|
|
516
|
+
}
|
|
517
|
+
if (isNullOrWhitespace(serviceId)) {
|
|
518
|
+
errorMessages.push('[serviceId] is required.');
|
|
519
|
+
}
|
|
520
|
+
if (
|
|
521
|
+
args.ttlMinutes !== undefined &&
|
|
522
|
+
(!Number.isInteger(args.ttlMinutes) || args.ttlMinutes < 1)
|
|
523
|
+
) {
|
|
524
|
+
errorMessages.push('[ttlMinutes] must be a whole number of 1 or more.');
|
|
525
|
+
}
|
|
526
|
+
if (
|
|
527
|
+
args.localProxyPort !== undefined &&
|
|
528
|
+
(!Number.isInteger(args.localProxyPort) ||
|
|
529
|
+
args.localProxyPort < 1 ||
|
|
530
|
+
args.localProxyPort > 65535)
|
|
531
|
+
) {
|
|
532
|
+
errorMessages.push(
|
|
533
|
+
'[localProxyPort] must be a whole number between 1 and 65535.',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
...args,
|
|
540
|
+
serviceId,
|
|
541
|
+
idServiceAuthBaseURL,
|
|
542
|
+
mosaicHostingClientId,
|
|
543
|
+
mosaicHostingClientSecret,
|
|
544
|
+
hostingServiceBaseURL,
|
|
545
|
+
},
|
|
546
|
+
errorMessages,
|
|
547
|
+
];
|
|
548
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CommandModule } from 'yargs';
|
|
2
|
+
import { databaseCommands } from './database/database-commands.js';
|
|
2
3
|
import { manifestCommands } from './manifest/manifest-commands.js';
|
|
3
4
|
import { piletCommands } from './pilet/pilet-commands.js';
|
|
4
5
|
import { serviceCommands } from './service/service-commands.js';
|
|
@@ -15,6 +16,7 @@ export const hosting: CommandModule<unknown, unknown> = {
|
|
|
15
16
|
manifestCommands,
|
|
16
17
|
)
|
|
17
18
|
.command('service', 'Service Deployment related actions', serviceCommands)
|
|
19
|
+
.command(databaseCommands)
|
|
18
20
|
.demandCommand(),
|
|
19
21
|
handler: (_args) => {
|
|
20
22
|
console.log('Please pick a sub command related to hosting.');
|