@axinom/mosaic-cli 0.62.0-rc.1 → 0.62.0-rc.4

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