@axinom/mosaic-cli 0.60.0-rc.9 → 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 +6 -4
- 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,78 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import type { CommandModule } from 'yargs';
|
|
3
|
+
import type { DatabaseProxyOptions } from './database-proxy-options.js';
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_LOCAL_PROXY_PORT,
|
|
6
|
+
proxyDatabase,
|
|
7
|
+
validateDatabaseProxyArgs,
|
|
8
|
+
} from './database-proxy.js';
|
|
9
|
+
|
|
10
|
+
const { red, yellow } = chalk;
|
|
11
|
+
|
|
12
|
+
export const databaseProxy: CommandModule<unknown, DatabaseProxyOptions> = {
|
|
13
|
+
builder: (yargs) => {
|
|
14
|
+
return yargs
|
|
15
|
+
.option('serviceId', {
|
|
16
|
+
describe: 'Service ID to open a database access session for',
|
|
17
|
+
alias: 'i',
|
|
18
|
+
string: true,
|
|
19
|
+
})
|
|
20
|
+
.option('ttlMinutes', {
|
|
21
|
+
describe:
|
|
22
|
+
'How long the session should last. A request, not a guarantee: a shorter lifetime is granted when the service allows less. Defaults to whatever the service allows.',
|
|
23
|
+
number: true,
|
|
24
|
+
})
|
|
25
|
+
.option('localProxyPort', {
|
|
26
|
+
describe:
|
|
27
|
+
'Local loopback port the database client connects to. Not 5432, which a local PostgreSQL usually holds. Deliberately not named `port`: a solution sets PORT for its own service, and that would override this.',
|
|
28
|
+
number: true,
|
|
29
|
+
default: DEFAULT_LOCAL_PROXY_PORT,
|
|
30
|
+
})
|
|
31
|
+
.option('json', {
|
|
32
|
+
describe:
|
|
33
|
+
'Emit the connection details as one JSON object on stdout, status messages on stderr. Contains the password, so it is as sensitive as the normal output.',
|
|
34
|
+
boolean: true,
|
|
35
|
+
})
|
|
36
|
+
.option('mosaicHostingClientId', {
|
|
37
|
+
describe:
|
|
38
|
+
"The service account client id.\nRequired permissions:\nHOSTING SERVICE -> 'Database Access: Write', 'Database Access Sessions: View', 'Service Definitions: View'",
|
|
39
|
+
alias: 'c',
|
|
40
|
+
string: true,
|
|
41
|
+
})
|
|
42
|
+
.option('mosaicHostingClientSecret', {
|
|
43
|
+
describe: 'Service Account Client Secret to authenticate',
|
|
44
|
+
alias: 's',
|
|
45
|
+
string: true,
|
|
46
|
+
})
|
|
47
|
+
.option('idServiceAuthBaseURL', {
|
|
48
|
+
describe: 'ID Service Authentication Endpoint Base URL',
|
|
49
|
+
alias: 'a',
|
|
50
|
+
string: true,
|
|
51
|
+
})
|
|
52
|
+
.option('hostingServiceBaseURL', {
|
|
53
|
+
describe: 'Hosting Service Base URL',
|
|
54
|
+
alias: 'h',
|
|
55
|
+
string: true,
|
|
56
|
+
})
|
|
57
|
+
.example(
|
|
58
|
+
'$0 hosting database proxy -i channel-service',
|
|
59
|
+
'Open a session and listen on 127.0.0.1:5433',
|
|
60
|
+
)
|
|
61
|
+
.example(
|
|
62
|
+
'$0 hosting database proxy -i channel-service --ttl-minutes 30',
|
|
63
|
+
'Open a session for half an hour',
|
|
64
|
+
);
|
|
65
|
+
},
|
|
66
|
+
handler: async (args) => {
|
|
67
|
+
const [validatedArgs, errorMessages] = validateDatabaseProxyArgs(args);
|
|
68
|
+
if (errorMessages.length > 0) {
|
|
69
|
+
process.stderr.write(red('Some required arguments are missing.\n'));
|
|
70
|
+
process.stderr.write(yellow(errorMessages.join('\n') + '\n'));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
} else {
|
|
73
|
+
await proxyDatabase(
|
|
74
|
+
validatedArgs as DatabaseProxyOptions & { serviceId: string },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for proxying a direct database connection to a customizable service.
|
|
3
|
+
*/
|
|
4
|
+
export interface DatabaseProxyOptions {
|
|
5
|
+
serviceId?: string;
|
|
6
|
+
ttlMinutes?: number;
|
|
7
|
+
localProxyPort?: number;
|
|
8
|
+
json?: boolean;
|
|
9
|
+
mosaicHostingClientId?: string;
|
|
10
|
+
mosaicHostingClientSecret?: string;
|
|
11
|
+
idServiceAuthBaseURL?: string;
|
|
12
|
+
hostingServiceBaseURL?: string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
WARN_BEFORE_EXPIRY_MS,
|
|
4
|
+
formatClampedTtlWarning,
|
|
5
|
+
formatConnectionDetails,
|
|
6
|
+
minutesUntil,
|
|
7
|
+
toConnectionJson,
|
|
8
|
+
toExpiryWarningDelay,
|
|
9
|
+
validateDatabaseProxyArgs,
|
|
10
|
+
type DatabaseAccessSession,
|
|
11
|
+
} from './database-proxy.js';
|
|
12
|
+
|
|
13
|
+
const NOW = Date.parse('2026-10-12T12:00:00.000Z');
|
|
14
|
+
const inMinutes = (minutes: number): string =>
|
|
15
|
+
new Date(NOW + minutes * 60_000).toISOString();
|
|
16
|
+
|
|
17
|
+
const session = (expiresAt: string): DatabaseAccessSession => ({
|
|
18
|
+
databaseAccessSessionId: '8191584c-dd96-4803-bfd8-5f644b32293c',
|
|
19
|
+
username: 'dbaccess_8191584cdd964803bfd85f644b32293c',
|
|
20
|
+
password: 'a-single-use-password',
|
|
21
|
+
databaseName: 'h_xyrlwpb8pyoqthz9_test_service_a',
|
|
22
|
+
expiresAt,
|
|
23
|
+
databaseProxyUrl: 'ws://localhost:12500/connect',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const validArgs = {
|
|
27
|
+
serviceId: 'test-service-a',
|
|
28
|
+
idServiceAuthBaseURL: 'http://localhost:10500',
|
|
29
|
+
mosaicHostingClientId: 'client',
|
|
30
|
+
mosaicHostingClientSecret: 'secret',
|
|
31
|
+
hostingServiceBaseURL: 'http://localhost:11300',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('minutesUntil', () => {
|
|
35
|
+
it('rounds down to whole minutes', () => {
|
|
36
|
+
expect(minutesUntil(inMinutes(59.6), NOW)).toBe(59);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('never reports a negative remainder for a session already over', () => {
|
|
40
|
+
expect(minutesUntil(inMinutes(-10), NOW)).toBe(0);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('formatClampedTtlWarning', () => {
|
|
45
|
+
it('says nothing when the granted lifetime is what was asked for', () => {
|
|
46
|
+
expect(formatClampedTtlWarning(60, inMinutes(60), NOW)).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('tolerates the minute the round trip costs', () => {
|
|
50
|
+
expect(formatClampedTtlWarning(60, inMinutes(59.2), NOW)).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('says nothing when no lifetime was asked for', () => {
|
|
54
|
+
expect(
|
|
55
|
+
formatClampedTtlWarning(undefined, inMinutes(10), NOW),
|
|
56
|
+
).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('reports both numbers when Hosting clamped the lifetime', () => {
|
|
60
|
+
expect(formatClampedTtlWarning(60, inMinutes(10), NOW)).toBe(
|
|
61
|
+
'Requested 60 minutes, granted 10 - database access for this service expires then.',
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('toExpiryWarningDelay', () => {
|
|
67
|
+
it('schedules the warning ahead of the expiry it warns about', () => {
|
|
68
|
+
expect(toExpiryWarningDelay(inMinutes(60), NOW)).toBe(
|
|
69
|
+
60 * 60_000 - WARN_BEFORE_EXPIRY_MS,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('skips the warning when it could only arrive after the expiry', () => {
|
|
74
|
+
expect(toExpiryWarningDelay(inMinutes(4), NOW)).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('formatConnectionDetails', () => {
|
|
79
|
+
it('lists everything a database client needs', () => {
|
|
80
|
+
const details = formatConnectionDetails(session(inMinutes(60)), 5433, NOW);
|
|
81
|
+
expect(details).toContain('Host 127.0.0.1');
|
|
82
|
+
expect(details).toContain('Port 5433');
|
|
83
|
+
expect(details).toContain('Database h_xyrlwpb8pyoqthz9_test_service_a');
|
|
84
|
+
expect(details).toContain(
|
|
85
|
+
'User dbaccess_8191584cdd964803bfd85f644b32293c',
|
|
86
|
+
);
|
|
87
|
+
expect(details).toContain('Password a-single-use-password');
|
|
88
|
+
expect(details).toContain('(60 minutes)');
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('toConnectionJson', () => {
|
|
93
|
+
it('names the local proxy, not the database host the proxy reaches', () => {
|
|
94
|
+
expect(toConnectionJson(session(inMinutes(60)), 5433)).toMatchObject({
|
|
95
|
+
host: '127.0.0.1',
|
|
96
|
+
port: 5433,
|
|
97
|
+
user: 'dbaccess_8191584cdd964803bfd85f644b32293c',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('validateDatabaseProxyArgs', () => {
|
|
103
|
+
// The fallbacks read process.env, so the assertions below would otherwise
|
|
104
|
+
// depend on whatever the shell running the tests happens to export.
|
|
105
|
+
const ENV_FALLBACKS = [
|
|
106
|
+
'SERVICE_ID',
|
|
107
|
+
'ID_SERVICE_AUTH_BASE_URL',
|
|
108
|
+
'MOSAIC_HOSTING_CLIENT_ID',
|
|
109
|
+
'MOSAIC_HOSTING_CLIENT_SECRET',
|
|
110
|
+
'HOSTING_SERVICE_BASE_URL',
|
|
111
|
+
];
|
|
112
|
+
let saved: Record<string, string | undefined>;
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
saved = Object.fromEntries(ENV_FALLBACKS.map((k) => [k, process.env[k]]));
|
|
116
|
+
for (const key of ENV_FALLBACKS) {
|
|
117
|
+
delete process.env[key];
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
123
|
+
if (value === undefined) {
|
|
124
|
+
delete process.env[key];
|
|
125
|
+
} else {
|
|
126
|
+
process.env[key] = value;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('accepts a fully specified invocation', () => {
|
|
132
|
+
const [, errors] = validateDatabaseProxyArgs(validArgs);
|
|
133
|
+
expect(errors).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('takes the service id from the environment when the flag is absent', () => {
|
|
137
|
+
process.env.SERVICE_ID = 'media-service';
|
|
138
|
+
const [resolved, errors] = validateDatabaseProxyArgs({
|
|
139
|
+
...validArgs,
|
|
140
|
+
serviceId: undefined,
|
|
141
|
+
});
|
|
142
|
+
delete process.env.SERVICE_ID;
|
|
143
|
+
expect(resolved.serviceId).toBe('media-service');
|
|
144
|
+
expect(errors).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('names every missing connection argument', () => {
|
|
148
|
+
const [, errors] = validateDatabaseProxyArgs({
|
|
149
|
+
serviceId: 'test-service-a',
|
|
150
|
+
});
|
|
151
|
+
expect(errors).toEqual([
|
|
152
|
+
'[idServiceAuthBaseURL] is required.',
|
|
153
|
+
'[mosaicHostingClientId] is required.',
|
|
154
|
+
'[mosaicHostingClientSecret] is required.',
|
|
155
|
+
'[hostingServiceBaseURL] is required.',
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects a lifetime that is not a whole positive number of minutes', () => {
|
|
160
|
+
const [, errors] = validateDatabaseProxyArgs({
|
|
161
|
+
...validArgs,
|
|
162
|
+
ttlMinutes: 0,
|
|
163
|
+
});
|
|
164
|
+
expect(errors).toContain(
|
|
165
|
+
'[ttlMinutes] must be a whole number of 1 or more.',
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('rejects a local proxy port outside the range a socket can bind', () => {
|
|
170
|
+
const [, errors] = validateDatabaseProxyArgs({
|
|
171
|
+
...validArgs,
|
|
172
|
+
localProxyPort: 70000,
|
|
173
|
+
});
|
|
174
|
+
expect(errors).toContain(
|
|
175
|
+
'[localProxyPort] must be a whole number between 1 and 65535.',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|