@jacebenson/jsn 4.3.7-dev.20 → 4.3.7-dev.22
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/package.json +1 -1
- package/src/cli.js +2 -0
- package/src/commands/midservers.js +340 -0
- package/src/midserver-install.js +134 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -69,6 +69,7 @@ import { b4rulesCmd } from './commands/b4rules.js';
|
|
|
69
69
|
import { atfCmd } from './commands/atf.js';
|
|
70
70
|
import { approvalsCmd } from './commands/approvals.js';
|
|
71
71
|
import { perfCmd } from './commands/perf.js';
|
|
72
|
+
import { midserversCmd } from './commands/midservers.js';
|
|
72
73
|
import { diffCmd } from './commands/diff.js';
|
|
73
74
|
import { graphqlCmd } from './commands/graphql.js';
|
|
74
75
|
|
|
@@ -373,6 +374,7 @@ export function buildCLI() {
|
|
|
373
374
|
.command(atfCmd(wrap))
|
|
374
375
|
.command(approvalsCmd(wrap))
|
|
375
376
|
.command(perfCmd(wrap))
|
|
377
|
+
.command(midserversCmd(wrap))
|
|
376
378
|
.command(diffCmd(wrap))
|
|
377
379
|
.command(graphqlCmd(wrap))
|
|
378
380
|
.command(evalCmd(wrap))
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { input, password as passwordPrompt, select } from '@inquirer/prompts';
|
|
2
|
+
import { confirmDelete, formatRecordForDisplay, getStringField, interactiveList, canPrompt } from '../helpers.js';
|
|
3
|
+
import { FormatAuto } from '../output.js';
|
|
4
|
+
import { declareCapabilities } from '../capabilities.js';
|
|
5
|
+
import { resolveRecord, unwrapSysId } from '../resolve-record.js';
|
|
6
|
+
import { dockerComposeAvailable, installDockerMidServer, validateMidCredentials } from '../midserver-install.js';
|
|
7
|
+
|
|
8
|
+
const AGENT_TABLE = 'ecc_agent';
|
|
9
|
+
const QUEUE_TABLE = 'ecc_queue';
|
|
10
|
+
const LIST_FIELDS = ['sys_id', 'name', 'status', 'host_name', 'ip_address', 'version', 'validated', 'last_heartbeat', 'sys_updated_on'];
|
|
11
|
+
const DEFAULT_TIMEOUT = 120000;
|
|
12
|
+
const DEFAULT_INTERVAL = 2000;
|
|
13
|
+
|
|
14
|
+
declareCapabilities('midservers', { mutationSubcommands: ['add', 'remove', 'execute'] });
|
|
15
|
+
|
|
16
|
+
function agentName(record) {
|
|
17
|
+
const name = getStringField(record, 'name');
|
|
18
|
+
return name.startsWith('mid.server.') ? name : `mid.server.${name}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function queueValue(value) {
|
|
22
|
+
return value && typeof value === 'object' ? (value.value || value.display_value || '') : String(value || '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function escapeXml(value) {
|
|
26
|
+
return String(value)
|
|
27
|
+
.replaceAll('&', '&')
|
|
28
|
+
.replaceAll('"', '"')
|
|
29
|
+
.replaceAll('<', '<')
|
|
30
|
+
.replaceAll('>', '>')
|
|
31
|
+
.replaceAll("'", ''');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildCommandPayload(command) {
|
|
35
|
+
return `<parameters><parameter name="name" value="${escapeXml(command)}"/></parameters>`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeXml(value) {
|
|
39
|
+
return String(value || '')
|
|
40
|
+
.replaceAll('<', '<')
|
|
41
|
+
.replaceAll('>', '>')
|
|
42
|
+
.replaceAll('"', '"')
|
|
43
|
+
.replaceAll(''', "'")
|
|
44
|
+
.replaceAll('&', '&');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseCommandResult(payload) {
|
|
48
|
+
if (!payload) return { stdout: '', stderr: '' };
|
|
49
|
+
const result = payload.match(/<result\b[^>]*>([\s\S]*?)<\/result>/i)?.[1] || payload;
|
|
50
|
+
const stdout = result.match(/<stdout\b[^>]*>([\s\S]*?)<\/stdout>/i)?.[1] || '';
|
|
51
|
+
const stderr = result.match(/<stderr\b[^>]*>([\s\S]*?)<\/stderr>/i)?.[1] || '';
|
|
52
|
+
return { stdout: decodeXml(stdout), stderr: decodeXml(stderr) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitForCommandResult(sdk, outputSysId, timeoutMs, intervalMs) {
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
while (Date.now() - started <= timeoutMs) {
|
|
58
|
+
const params = new URLSearchParams({
|
|
59
|
+
sysparm_query: `response_to=${outputSysId}^queue=input`,
|
|
60
|
+
sysparm_limit: '1',
|
|
61
|
+
sysparm_display_value: 'all',
|
|
62
|
+
sysparm_fields: 'sys_id,state,error,payload,name,topic,queue,agent,created_on,processed',
|
|
63
|
+
});
|
|
64
|
+
const records = await sdk.list(QUEUE_TABLE, params);
|
|
65
|
+
if (records.length > 0) return records[0];
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
67
|
+
}
|
|
68
|
+
const error = new Error(`Timed out waiting for MID Server response to ECC Queue record ${outputSysId}`);
|
|
69
|
+
error.code = 'midserver_timeout';
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveMidServer(sdk, identifier) {
|
|
74
|
+
try {
|
|
75
|
+
return await resolveRecord(sdk, {
|
|
76
|
+
table: AGENT_TABLE,
|
|
77
|
+
identifier,
|
|
78
|
+
matchField: 'name',
|
|
79
|
+
resource: 'MID Server',
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error.code !== 'not_found' || identifier.startsWith('mid.server.')) throw error;
|
|
83
|
+
return resolveRecord(sdk, {
|
|
84
|
+
table: AGENT_TABLE,
|
|
85
|
+
identifier: `mid.server.${identifier}`,
|
|
86
|
+
matchField: 'name',
|
|
87
|
+
resource: 'MID Server',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function listAgents(app, argv) {
|
|
93
|
+
const columns = argv.columns
|
|
94
|
+
? [...new Set(argv.columns.split(',').map(column => column.trim()).filter(Boolean))]
|
|
95
|
+
: LIST_FIELDS;
|
|
96
|
+
const query = argv.query || '';
|
|
97
|
+
|
|
98
|
+
if (canPrompt() && app.output.getFormat() === FormatAuto && !query) {
|
|
99
|
+
const picked = await interactiveList({
|
|
100
|
+
app,
|
|
101
|
+
table: AGENT_TABLE,
|
|
102
|
+
singular: 'MID Server',
|
|
103
|
+
columns,
|
|
104
|
+
limit: argv.limit,
|
|
105
|
+
labelField: 'name',
|
|
106
|
+
message: 'Select a MID Server',
|
|
107
|
+
formatLabel: (record) => {
|
|
108
|
+
const name = getStringField(record, 'name') || getStringField(record, 'sys_id');
|
|
109
|
+
const status = getStringField(record, 'status');
|
|
110
|
+
return status ? `${name} [${status}]` : name;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
if (picked === undefined || picked === null) return;
|
|
114
|
+
picked._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
|
|
115
|
+
app.ok(picked, {
|
|
116
|
+
summary: `MID Server: ${getStringField(picked, 'name') || getStringField(picked, 'sys_id')}`,
|
|
117
|
+
breadcrumbs: [{ action: 'list', cmd: 'jsn midservers list', description: 'Back to all MID Servers' }],
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const params = new URLSearchParams({
|
|
123
|
+
sysparm_limit: String(argv.limit),
|
|
124
|
+
sysparm_display_value: 'all',
|
|
125
|
+
sysparm_fields: ['sys_id', ...columns.filter(column => column !== 'sys_id')].join(','),
|
|
126
|
+
sysparm_query: query ? `${query}^ORDERBYDESCsys_updated_on` : 'ORDERBYname',
|
|
127
|
+
});
|
|
128
|
+
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
129
|
+
app.ok({
|
|
130
|
+
table: AGENT_TABLE,
|
|
131
|
+
count: records.length,
|
|
132
|
+
columns,
|
|
133
|
+
records: records.map(record => formatRecordForDisplay(record, columns)),
|
|
134
|
+
context: { instance_url: app.getEffectiveInstance() },
|
|
135
|
+
}, { summary: `${records.length} MID Server(s)` });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function assertMidServerUp(record) {
|
|
139
|
+
const name = getStringField(record, 'name') || getStringField(record, 'sys_id') || 'MID Server';
|
|
140
|
+
const status = getStringField(record, 'status');
|
|
141
|
+
if (status.toLowerCase() !== 'up') {
|
|
142
|
+
const error = new Error(`MID Server '${name}' is not Up (status: ${status || 'unknown'}). Start or install it before executing a command.`);
|
|
143
|
+
error.code = 'midserver_not_up';
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function promptMidCredentials(app, argv) {
|
|
149
|
+
if (!canPrompt()) throw new Error('MID Server installation is interactive. Run it from a terminal.');
|
|
150
|
+
let username = argv.username || '';
|
|
151
|
+
while (true) {
|
|
152
|
+
username = username || (await input({
|
|
153
|
+
message: 'MID Server username:',
|
|
154
|
+
validate: value => value.trim() ? true : 'Username is required',
|
|
155
|
+
})).trim();
|
|
156
|
+
const password = await passwordPrompt({
|
|
157
|
+
message: 'MID Server password:',
|
|
158
|
+
mask: '*',
|
|
159
|
+
validate: value => value ? true : 'Password is required',
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const validation = await validateMidCredentials(app.getEffectiveInstance(), username, password);
|
|
163
|
+
return { username, password, releaseName: validation.releaseName };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
const answer = await input({ message: `${error.message}. Press Enter to retry or Q to quit:` });
|
|
166
|
+
if (answer.trim().toLowerCase() === 'q') {
|
|
167
|
+
const quit = new Error('MID Server installation cancelled');
|
|
168
|
+
quit.code = 'cancelled';
|
|
169
|
+
throw quit;
|
|
170
|
+
}
|
|
171
|
+
username = argv.username || '';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function waitForMidServerUp(app, name, timeoutMs, intervalMs) {
|
|
177
|
+
const started = Date.now();
|
|
178
|
+
const fullName = `mid.server.${name.replace(/^mid\.server\./, '')}`;
|
|
179
|
+
while (Date.now() - started <= timeoutMs) {
|
|
180
|
+
const params = new URLSearchParams({
|
|
181
|
+
sysparm_query: `name=${fullName}`,
|
|
182
|
+
sysparm_limit: '1',
|
|
183
|
+
sysparm_display_value: 'all',
|
|
184
|
+
sysparm_fields: 'sys_id,name,status,host_name,version,last_heartbeat',
|
|
185
|
+
});
|
|
186
|
+
const records = await app.sdk.list(AGENT_TABLE, params);
|
|
187
|
+
if (records[0] && getStringField(records[0], 'status').toLowerCase() === 'up') return records[0];
|
|
188
|
+
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`MID Server '${fullName}' did not report Up within ${timeoutMs}ms`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function midserversCmd(wrap) {
|
|
194
|
+
return {
|
|
195
|
+
command: 'midservers [subcommand]',
|
|
196
|
+
aliases: ['midserver', 'mid'],
|
|
197
|
+
describe: 'Manage ServiceNow MID Servers and run commands through them',
|
|
198
|
+
builder: (y) => y
|
|
199
|
+
.command({
|
|
200
|
+
command: 'list',
|
|
201
|
+
aliases: ['ls'],
|
|
202
|
+
describe: 'List MID Servers',
|
|
203
|
+
builder: (b) => b
|
|
204
|
+
.option('query', { type: 'string', describe: 'Encoded query against ecc_agent' })
|
|
205
|
+
.option('columns', { type: 'string', describe: 'Comma-separated fields' })
|
|
206
|
+
.option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
|
|
207
|
+
handler: wrap(async (argv, app) => {
|
|
208
|
+
await listAgents(app, argv);
|
|
209
|
+
}),
|
|
210
|
+
})
|
|
211
|
+
.command({
|
|
212
|
+
command: 'show <identifier>',
|
|
213
|
+
aliases: ['get'],
|
|
214
|
+
describe: 'Show a MID Server by name or sys_id',
|
|
215
|
+
handler: wrap(async (argv, app) => {
|
|
216
|
+
const record = await resolveMidServer(app.sdk, argv.identifier);
|
|
217
|
+
record._context = { instance_url: app.getEffectiveInstance(), table: AGENT_TABLE };
|
|
218
|
+
app.ok(record, { summary: `MID Server: ${getStringField(record, 'name') || argv.identifier}` });
|
|
219
|
+
}),
|
|
220
|
+
})
|
|
221
|
+
.command({
|
|
222
|
+
command: 'add',
|
|
223
|
+
describe: 'Install and verify a MID Server',
|
|
224
|
+
builder: (b) => b
|
|
225
|
+
.option('name', { type: 'string', describe: 'MID Server name (prompted when omitted)' })
|
|
226
|
+
.option('username', { type: 'string', describe: 'MID Server username (password is always prompted)' })
|
|
227
|
+
.option('install-method', { type: 'string', choices: ['docker', 'direct'], describe: 'Installation method (prompted when omitted)' })
|
|
228
|
+
.option('directory', { type: 'string', describe: 'Docker installation directory' })
|
|
229
|
+
.option('wait-timeout', { type: 'number', default: 120000, describe: 'How long to wait for the MID Server to report Up' })
|
|
230
|
+
.option('wait-interval', { type: 'number', default: 3000, describe: 'MID Server status polling interval in milliseconds' }),
|
|
231
|
+
handler: wrap(async (argv, app) => {
|
|
232
|
+
app.requireInstance();
|
|
233
|
+
const credentials = await promptMidCredentials(app, argv);
|
|
234
|
+
const name = argv.name || (await input({
|
|
235
|
+
message: 'MID Server name:',
|
|
236
|
+
validate: value => value.trim() ? true : 'MID Server name is required',
|
|
237
|
+
})).trim();
|
|
238
|
+
const dockerAvailable = dockerComposeAvailable();
|
|
239
|
+
const installMethod = argv['install-method'] || await select({
|
|
240
|
+
message: dockerAvailable
|
|
241
|
+
? 'Docker and Docker Compose detected. How should the MID Server be installed?'
|
|
242
|
+
: 'Docker was not detected. How should the MID Server be installed?',
|
|
243
|
+
choices: dockerAvailable
|
|
244
|
+
? [
|
|
245
|
+
{ name: 'Docker (recommended)', value: 'docker' },
|
|
246
|
+
{ name: 'Direct install (not available yet)', value: 'direct' },
|
|
247
|
+
]
|
|
248
|
+
: [
|
|
249
|
+
{ name: 'Install Docker first', value: 'install-docker' },
|
|
250
|
+
{ name: 'Direct install (not available yet)', value: 'direct' },
|
|
251
|
+
],
|
|
252
|
+
});
|
|
253
|
+
if (installMethod === 'direct') throw new Error('Direct MID Server installation is not implemented yet. Choose Docker.');
|
|
254
|
+
if (installMethod === 'install-docker') throw new Error('Automatic Docker installation is not implemented yet. Install Docker and Compose, then run this command again.');
|
|
255
|
+
|
|
256
|
+
const installation = await installDockerMidServer({
|
|
257
|
+
instanceURL: app.getEffectiveInstance(),
|
|
258
|
+
username: credentials.username,
|
|
259
|
+
password: credentials.password,
|
|
260
|
+
name,
|
|
261
|
+
releaseName: credentials.releaseName,
|
|
262
|
+
directory: argv.directory,
|
|
263
|
+
});
|
|
264
|
+
let record;
|
|
265
|
+
try {
|
|
266
|
+
record = await waitForMidServerUp(app, installation.name, Math.max(1, argv['wait-timeout']), Math.max(500, argv['wait-interval']));
|
|
267
|
+
} catch (error) {
|
|
268
|
+
try { installation.rollback(); } catch { /* preserve the verification error */ }
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
app.ok({
|
|
272
|
+
installation: 'docker',
|
|
273
|
+
directory: installation.installDir,
|
|
274
|
+
release: installation.releaseName,
|
|
275
|
+
record,
|
|
276
|
+
}, { summary: `Installed MID Server '${installation.name}' and verified it is Up` });
|
|
277
|
+
}),
|
|
278
|
+
})
|
|
279
|
+
.command({
|
|
280
|
+
command: 'remove <identifier>',
|
|
281
|
+
aliases: ['delete'],
|
|
282
|
+
describe: 'Remove a MID Server record',
|
|
283
|
+
builder: (b) => b.option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
|
|
284
|
+
handler: wrap(async (argv, app) => {
|
|
285
|
+
const record = await resolveMidServer(app.sdk, argv.identifier);
|
|
286
|
+
const sysId = unwrapSysId(record);
|
|
287
|
+
const name = getStringField(record, 'name') || argv.identifier;
|
|
288
|
+
await confirmDelete(app, argv, `Remove MID Server '${name}'`);
|
|
289
|
+
await app.sdk.delete(AGENT_TABLE, sysId);
|
|
290
|
+
app.ok({ name, sys_id: sysId, deleted: true }, { summary: `Removed MID Server '${name}'` });
|
|
291
|
+
}),
|
|
292
|
+
})
|
|
293
|
+
.command({
|
|
294
|
+
command: 'execute <identifier>',
|
|
295
|
+
describe: 'Execute a shell command through a MID Server',
|
|
296
|
+
builder: (b) => b
|
|
297
|
+
.option('command', { alias: 'c', type: 'string', demandOption: true, describe: 'Command line to execute on the MID Server host' })
|
|
298
|
+
.option('timeout', { type: 'number', default: DEFAULT_TIMEOUT, describe: 'Response timeout in milliseconds' })
|
|
299
|
+
.option('interval', { type: 'number', default: DEFAULT_INTERVAL, describe: 'Polling interval in milliseconds' }),
|
|
300
|
+
handler: wrap(async (argv, app) => {
|
|
301
|
+
const record = await resolveMidServer(app.sdk, argv.identifier);
|
|
302
|
+
assertMidServerUp(record);
|
|
303
|
+
const agent = agentName(record);
|
|
304
|
+
const output = await app.sdk.create(QUEUE_TABLE, {
|
|
305
|
+
agent,
|
|
306
|
+
topic: 'Command',
|
|
307
|
+
queue: 'output',
|
|
308
|
+
name: argv.command,
|
|
309
|
+
payload: buildCommandPayload(argv.command),
|
|
310
|
+
});
|
|
311
|
+
const outputSysId = unwrapSysId(output);
|
|
312
|
+
if (!outputSysId) throw new Error('ServiceNow did not return the ECC Queue sys_id for the command');
|
|
313
|
+
const response = await waitForCommandResult(app.sdk, outputSysId, Math.max(1, argv.timeout), Math.max(250, argv.interval));
|
|
314
|
+
const responseError = getStringField(response, 'error');
|
|
315
|
+
if (responseError) throw new Error(`MID Server command failed: ${responseError}`);
|
|
316
|
+
const result = parseCommandResult(queueValue(response.payload));
|
|
317
|
+
app.ok({
|
|
318
|
+
mid_server: getStringField(record, 'name'),
|
|
319
|
+
command: argv.command,
|
|
320
|
+
output: { sys_id: outputSysId, ...output },
|
|
321
|
+
response: { ...response, ...result },
|
|
322
|
+
}, { summary: `Executed command on MID Server '${getStringField(record, 'name')}'` });
|
|
323
|
+
}),
|
|
324
|
+
}),
|
|
325
|
+
handler: () => {
|
|
326
|
+
console.log('Manage ServiceNow MID Servers and execute commands through them.');
|
|
327
|
+
console.log('');
|
|
328
|
+
console.log('Available subcommands:');
|
|
329
|
+
console.log(' list List MID Servers');
|
|
330
|
+
console.log(' show <name_or_sys_id> Show a MID Server');
|
|
331
|
+
console.log(' add --name <name> Create a MID Server record');
|
|
332
|
+
console.log(' remove <name_or_sys_id> Remove a MID Server record');
|
|
333
|
+
console.log(' execute <name_or_sys_id> Execute a shell command');
|
|
334
|
+
console.log('');
|
|
335
|
+
console.log('Run "jsn midservers <command> --help" for details.');
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export { waitForCommandResult };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const INSTALL_HOST = 'install.service-now.com';
|
|
8
|
+
|
|
9
|
+
function defaultRun(command, args, options = {}) {
|
|
10
|
+
return execFileSync(command, args, {
|
|
11
|
+
cwd: options.cwd,
|
|
12
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
stdio: options.stdio || 'inherit',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertSafePart(value, label) {
|
|
19
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) {
|
|
20
|
+
throw new Error(`${label} may contain only letters, numbers, dots, underscores, and dashes`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeMidName(name) {
|
|
25
|
+
const shortName = String(name || '').replace(/^mid\.server\./, '');
|
|
26
|
+
assertSafePart(shortName, 'MID Server name');
|
|
27
|
+
return shortName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildMidRecipeUrl(releaseName) {
|
|
31
|
+
const match = String(releaseName).match(/(\d{4})-(\d{2})-(\d{2})|(\d{2})-(\d{2})-(\d{4})/);
|
|
32
|
+
if (!match) throw new Error(`MID release name has no build date: ${releaseName}`);
|
|
33
|
+
const year = match[1] || match[6];
|
|
34
|
+
const month = match[2] || match[4];
|
|
35
|
+
const day = match[3] || match[5];
|
|
36
|
+
const filename = `mid-linux-container-recipe.${releaseName}.linux.x86-64.zip`;
|
|
37
|
+
return `https://${INSTALL_HOST}/glide/distribution/builds/package/app-signed/mid-linux-container-recipe/${year}/${month}/${day}/${filename}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function validateMidCredentials(instanceURL, username, password, fetchImpl = fetch) {
|
|
41
|
+
if (!username || !password) throw new Error('MID Server username and password are required');
|
|
42
|
+
const endpoint = `${String(instanceURL).replace(/\/$/, '')}/api/now/table/sys_properties?sysparm_query=name=mid.version&sysparm_fields=value&sysparm_limit=1`;
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await fetchImpl(endpoint, {
|
|
46
|
+
headers: {
|
|
47
|
+
Accept: 'application/json',
|
|
48
|
+
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
} catch {
|
|
52
|
+
throw new Error('Could not reach the ServiceNow instance to validate the MID Server credentials');
|
|
53
|
+
}
|
|
54
|
+
if (response.status === 401 || response.status === 403) {
|
|
55
|
+
throw new Error('The MID Server username or password was rejected by the ServiceNow instance');
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`MID Server credential validation failed with HTTP ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
const body = await response.json();
|
|
61
|
+
const releaseName = body?.result?.[0]?.value;
|
|
62
|
+
if (!releaseName) throw new Error('Credential validation succeeded, but the instance returned no mid.version value');
|
|
63
|
+
return { releaseName };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function dockerComposeAvailable(run = defaultRun) {
|
|
67
|
+
try {
|
|
68
|
+
run('docker', ['info'], { stdio: 'ignore' });
|
|
69
|
+
run('docker', ['compose', 'version'], { stdio: 'ignore' });
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function composeFile({ serviceName }) {
|
|
77
|
+
return `services:\n ${serviceName}:\n container_name: ${serviceName}\n image: ${serviceName}:\${MID_RELEASE}\n restart: unless-stopped\n volumes:\n - ./export:/opt/snc_mid_server/agent/export\n environment:\n MID_INSTANCE_URL: \${MID_INSTANCE_URL}\n MID_INSTANCE_USERNAME: \${MID_INSTANCE_USERNAME}\n MID_INSTANCE_PASSWORD: \${MID_INSTANCE_PASSWORD}\n MID_SERVER_NAME: \${MID_SERVER_NAME}\n`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function installDockerMidServer({
|
|
81
|
+
instanceURL,
|
|
82
|
+
username,
|
|
83
|
+
password,
|
|
84
|
+
name,
|
|
85
|
+
directory,
|
|
86
|
+
releaseName: suppliedReleaseName,
|
|
87
|
+
fetchImpl = fetch,
|
|
88
|
+
run = defaultRun,
|
|
89
|
+
}) {
|
|
90
|
+
if (!dockerComposeAvailable(run)) {
|
|
91
|
+
throw new Error('Docker and Docker Compose are required for Docker MID Server installation');
|
|
92
|
+
}
|
|
93
|
+
const shortName = normalizeMidName(name);
|
|
94
|
+
const displayName = `mid.server.${shortName}`;
|
|
95
|
+
const releaseName = suppliedReleaseName || (await validateMidCredentials(instanceURL, username, password, fetchImpl)).releaseName;
|
|
96
|
+
const recipeURL = buildMidRecipeUrl(releaseName);
|
|
97
|
+
const installDir = directory || path.join(os.homedir(), '.local', 'share', 'jsn', 'midservers', shortName);
|
|
98
|
+
fs.mkdirSync(installDir, { recursive: true, mode: 0o700 });
|
|
99
|
+
const exportDir = path.join(installDir, 'export');
|
|
100
|
+
fs.mkdirSync(exportDir, { recursive: true, mode: 0o700 });
|
|
101
|
+
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsn-mid-'));
|
|
102
|
+
const filename = path.basename(recipeURL);
|
|
103
|
+
const archive = path.join(workDir, filename);
|
|
104
|
+
const recipeDir = path.join(workDir, 'recipe');
|
|
105
|
+
try {
|
|
106
|
+
run('curl', ['-fSL', '--connect-timeout', '10', '--max-time', '300', recipeURL, '-o', archive]);
|
|
107
|
+
fs.mkdirSync(recipeDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
run('unzip', ['-q', archive, '-d', recipeDir]);
|
|
109
|
+
const composePath = path.join(installDir, 'docker-compose.yaml');
|
|
110
|
+
const serviceName = `jsn_mid_${shortName.replace(/[^A-Za-z0-9_.-]/g, '_')}`;
|
|
111
|
+
run('docker', ['build', '--tag', `${serviceName}:${releaseName}`, recipeDir]);
|
|
112
|
+
fs.writeFileSync(composePath, composeFile({ serviceName, instanceURL, displayName }), { mode: 0o600 });
|
|
113
|
+
const env = {
|
|
114
|
+
MID_RELEASE: releaseName,
|
|
115
|
+
MID_INSTANCE_URL: `${String(instanceURL).replace(/\/$/, '')}/`,
|
|
116
|
+
MID_INSTANCE_USERNAME: username,
|
|
117
|
+
MID_INSTANCE_PASSWORD: password,
|
|
118
|
+
MID_SERVER_NAME: displayName,
|
|
119
|
+
};
|
|
120
|
+
run('docker', ['compose', '-f', composePath, 'up', '-d'], { cwd: installDir, env });
|
|
121
|
+
return {
|
|
122
|
+
name: displayName,
|
|
123
|
+
releaseName,
|
|
124
|
+
installDir,
|
|
125
|
+
composePath,
|
|
126
|
+
serviceName,
|
|
127
|
+
rollback: () => run('docker', ['compose', '-f', composePath, 'down'], { cwd: installDir, env }),
|
|
128
|
+
};
|
|
129
|
+
} finally {
|
|
130
|
+
fs.rmSync(workDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { defaultRun };
|