@joshualiamzn/open-stack 0.0.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/bin/open-stack.mjs +3 -0
- package/package.json +22 -0
- package/src/aws.mjs +824 -0
- package/src/cli.mjs +200 -0
- package/src/commands/create.mjs +43 -0
- package/src/commands/describe.mjs +72 -0
- package/src/commands/help.mjs +19 -0
- package/src/commands/index.mjs +40 -0
- package/src/commands/list.mjs +26 -0
- package/src/commands/update.mjs +309 -0
- package/src/config.mjs +45 -0
- package/src/interactive.mjs +421 -0
- package/src/main.mjs +223 -0
- package/src/render.mjs +185 -0
- package/src/repl.mjs +91 -0
- package/src/ui.mjs +298 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { select, input, confirm } from '@inquirer/prompts';
|
|
2
|
+
import {
|
|
3
|
+
printHeader, printStep, printInfo, printSubStep, printKeyHint,
|
|
4
|
+
createSpinner, theme, GoBack, withEscape,
|
|
5
|
+
} from './ui.mjs';
|
|
6
|
+
import { createDefaultConfig, DEFAULTS } from './config.mjs';
|
|
7
|
+
import { listDomains, listRoles, listWorkspaces } from './aws.mjs';
|
|
8
|
+
|
|
9
|
+
const CUSTOM_INPUT = Symbol('custom');
|
|
10
|
+
|
|
11
|
+
const eSelect = withEscape(select);
|
|
12
|
+
const eInput = withEscape(input);
|
|
13
|
+
const eConfirm = withEscape(confirm);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch resources with a spinner, returning [] on failure.
|
|
17
|
+
*/
|
|
18
|
+
async function fetchWithSpinner(label, fn) {
|
|
19
|
+
const spinner = createSpinner(label);
|
|
20
|
+
spinner.start();
|
|
21
|
+
try {
|
|
22
|
+
const result = await fn();
|
|
23
|
+
spinner.succeed(`${label} (${result.length} found)`);
|
|
24
|
+
return result;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
spinner.warn(`Could not list resources: ${err.message}`);
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Step functions ────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
async function stepMode(cfg) {
|
|
34
|
+
printStep('Select mode');
|
|
35
|
+
console.error();
|
|
36
|
+
|
|
37
|
+
const mode = await eSelect({
|
|
38
|
+
message: 'Mode',
|
|
39
|
+
choices: [
|
|
40
|
+
{ name: `Simple ${theme.muted('\u2014 just name + region; creates all resources with defaults')}`, value: 'simple' },
|
|
41
|
+
{ name: `Advanced ${theme.muted('\u2014 create new or reuse existing resources; tune pipeline settings')}`, value: 'advanced' },
|
|
42
|
+
],
|
|
43
|
+
default: cfg.mode || 'simple',
|
|
44
|
+
});
|
|
45
|
+
if (mode === GoBack) return GoBack;
|
|
46
|
+
cfg.mode = mode;
|
|
47
|
+
console.error();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function stepCore(cfg, session) {
|
|
51
|
+
printStep('Core settings');
|
|
52
|
+
console.error();
|
|
53
|
+
|
|
54
|
+
const name = await eInput({
|
|
55
|
+
message: 'Pipeline name',
|
|
56
|
+
default: cfg.pipelineName || DEFAULTS.pipelineName,
|
|
57
|
+
validate: (v) => v.trim().length > 0 || 'Pipeline name is required',
|
|
58
|
+
});
|
|
59
|
+
if (name === GoBack) return GoBack;
|
|
60
|
+
cfg.pipelineName = name;
|
|
61
|
+
|
|
62
|
+
if (session) {
|
|
63
|
+
cfg.region = session.region;
|
|
64
|
+
cfg.accountId = session.accountId;
|
|
65
|
+
printSubStep(`Region: ${theme.accent(cfg.region)} (from session)`);
|
|
66
|
+
} else {
|
|
67
|
+
const region = await eInput({
|
|
68
|
+
message: 'AWS region',
|
|
69
|
+
default: cfg.region || 'us-east-1',
|
|
70
|
+
validate: (v) => /^[a-z]{2}-[a-z]+-\d+$/.test(v) || 'Expected format: us-east-1',
|
|
71
|
+
});
|
|
72
|
+
if (region === GoBack) return GoBack;
|
|
73
|
+
cfg.region = region;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Simple mode: auto-derive all resources from pipeline name
|
|
77
|
+
if (cfg.mode === 'simple') {
|
|
78
|
+
cfg.osAction = 'create';
|
|
79
|
+
cfg.osDomainName = cfg.pipelineName;
|
|
80
|
+
cfg.serverless = true;
|
|
81
|
+
cfg.iamAction = 'create';
|
|
82
|
+
cfg.iamRoleName = `${cfg.pipelineName}-osi-role`;
|
|
83
|
+
cfg.apsAction = 'create';
|
|
84
|
+
cfg.apsWorkspaceAlias = cfg.pipelineName;
|
|
85
|
+
console.error();
|
|
86
|
+
printSubStep(
|
|
87
|
+
`Will create: OpenSearch Serverless collection '${theme.accent(cfg.osDomainName)}', ` +
|
|
88
|
+
`IAM role '${theme.accent(cfg.iamRoleName)}', APS workspace '${theme.accent(cfg.apsWorkspaceAlias)}'`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function stepOpenSearch(cfg) {
|
|
94
|
+
if (cfg.mode !== 'advanced') return 'skip';
|
|
95
|
+
|
|
96
|
+
printStep('OpenSearch domain');
|
|
97
|
+
console.error();
|
|
98
|
+
|
|
99
|
+
const osChoice = await eSelect({
|
|
100
|
+
message: 'Create new or reuse existing?',
|
|
101
|
+
choices: [
|
|
102
|
+
{ name: 'Create new', value: 'create' },
|
|
103
|
+
{ name: 'Reuse existing', value: 'reuse' },
|
|
104
|
+
],
|
|
105
|
+
default: cfg.osAction || 'create',
|
|
106
|
+
});
|
|
107
|
+
if (osChoice === GoBack) return GoBack;
|
|
108
|
+
|
|
109
|
+
if (osChoice === 'reuse') {
|
|
110
|
+
cfg.osAction = 'reuse';
|
|
111
|
+
|
|
112
|
+
const domains = await fetchWithSpinner(
|
|
113
|
+
'Loading OpenSearch domains & collections',
|
|
114
|
+
() => listDomains(cfg.region),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (domains.length > 0) {
|
|
118
|
+
const choices = domains.map((d) => ({
|
|
119
|
+
name: d.endpoint
|
|
120
|
+
? `${d.name} ${theme.muted(`\u2014 ${d.endpoint} (${d.engineVersion})`)}`
|
|
121
|
+
: `${d.name} ${theme.muted(`\u2014 provisioning... (${d.engineVersion})`)}`,
|
|
122
|
+
value: { endpoint: d.endpoint, serverless: d.serverless },
|
|
123
|
+
disabled: !d.endpoint ? '(no endpoint yet)' : false,
|
|
124
|
+
}));
|
|
125
|
+
choices.push({ name: theme.accent('Enter manually...'), value: CUSTOM_INPUT });
|
|
126
|
+
|
|
127
|
+
const selected = await eSelect({ message: 'Select domain or collection', choices });
|
|
128
|
+
if (selected === GoBack) return GoBack;
|
|
129
|
+
if (selected === CUSTOM_INPUT) {
|
|
130
|
+
const ep = await promptEndpoint();
|
|
131
|
+
if (ep === GoBack) return GoBack;
|
|
132
|
+
cfg.opensearchEndpoint = ep;
|
|
133
|
+
cfg.serverless = isServerlessEndpoint(ep);
|
|
134
|
+
} else {
|
|
135
|
+
cfg.opensearchEndpoint = selected.endpoint;
|
|
136
|
+
cfg.serverless = selected.serverless;
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
printInfo('No domains or collections found \u2014 enter endpoint manually');
|
|
140
|
+
const ep = await promptEndpoint();
|
|
141
|
+
if (ep === GoBack) return GoBack;
|
|
142
|
+
cfg.opensearchEndpoint = ep;
|
|
143
|
+
cfg.serverless = isServerlessEndpoint(ep);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
printSubStep(`Detected type: ${cfg.serverless ? theme.accent('OpenSearch Serverless') : theme.accent('Managed OpenSearch domain')}`);
|
|
147
|
+
} else {
|
|
148
|
+
cfg.osAction = 'create';
|
|
149
|
+
|
|
150
|
+
const domainName = await eInput({ message: 'Domain name', default: cfg.osDomainName || cfg.pipelineName });
|
|
151
|
+
if (domainName === GoBack) return GoBack;
|
|
152
|
+
cfg.osDomainName = domainName;
|
|
153
|
+
|
|
154
|
+
const instType = await eInput({ message: 'Instance type', default: cfg.osInstanceType || DEFAULTS.osInstanceType });
|
|
155
|
+
if (instType === GoBack) return GoBack;
|
|
156
|
+
cfg.osInstanceType = instType;
|
|
157
|
+
|
|
158
|
+
const instCount = await eInput({
|
|
159
|
+
message: 'Instance count',
|
|
160
|
+
default: String(cfg.osInstanceCount || DEFAULTS.osInstanceCount),
|
|
161
|
+
validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 1 || 'Must be a positive integer',
|
|
162
|
+
});
|
|
163
|
+
if (instCount === GoBack) return GoBack;
|
|
164
|
+
cfg.osInstanceCount = Number(instCount);
|
|
165
|
+
|
|
166
|
+
const volSize = await eInput({
|
|
167
|
+
message: 'EBS volume size (GB)',
|
|
168
|
+
default: String(cfg.osVolumeSize || DEFAULTS.osVolumeSize),
|
|
169
|
+
validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 10 || 'Must be at least 10 GB',
|
|
170
|
+
});
|
|
171
|
+
if (volSize === GoBack) return GoBack;
|
|
172
|
+
cfg.osVolumeSize = Number(volSize);
|
|
173
|
+
|
|
174
|
+
const engineVer = await eInput({ message: 'Engine version', default: cfg.osEngineVersion || DEFAULTS.osEngineVersion });
|
|
175
|
+
if (engineVer === GoBack) return GoBack;
|
|
176
|
+
cfg.osEngineVersion = engineVer;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function stepIam(cfg) {
|
|
181
|
+
if (cfg.mode !== 'advanced') return 'skip';
|
|
182
|
+
|
|
183
|
+
printStep('IAM role for OSI pipeline');
|
|
184
|
+
console.error();
|
|
185
|
+
|
|
186
|
+
const iamChoice = await eSelect({
|
|
187
|
+
message: 'Create new or reuse existing?',
|
|
188
|
+
choices: [
|
|
189
|
+
{ name: 'Create new', value: 'create' },
|
|
190
|
+
{ name: 'Reuse existing', value: 'reuse' },
|
|
191
|
+
],
|
|
192
|
+
default: cfg.iamAction || 'create',
|
|
193
|
+
});
|
|
194
|
+
if (iamChoice === GoBack) return GoBack;
|
|
195
|
+
|
|
196
|
+
if (iamChoice === 'reuse') {
|
|
197
|
+
cfg.iamAction = 'reuse';
|
|
198
|
+
|
|
199
|
+
const roles = await fetchWithSpinner(
|
|
200
|
+
'Loading IAM roles',
|
|
201
|
+
() => listRoles(cfg.region),
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (roles.length > 0) {
|
|
205
|
+
const osiRoles = [];
|
|
206
|
+
const otherRoles = [];
|
|
207
|
+
for (const r of roles) {
|
|
208
|
+
(/osi|pipeline|ingestion/i.test(r.name) ? osiRoles : otherRoles).push(r);
|
|
209
|
+
}
|
|
210
|
+
const sorted = [...osiRoles, ...otherRoles];
|
|
211
|
+
|
|
212
|
+
const choices = sorted.map((r) => ({
|
|
213
|
+
name: `${r.name} ${theme.muted(`\u2014 ${r.arn}`)}`,
|
|
214
|
+
value: r.arn,
|
|
215
|
+
}));
|
|
216
|
+
choices.push({ name: theme.accent('Enter ARN manually...'), value: CUSTOM_INPUT });
|
|
217
|
+
|
|
218
|
+
const selected = await eSelect({ message: 'Select role', choices });
|
|
219
|
+
if (selected === GoBack) return GoBack;
|
|
220
|
+
if (selected === CUSTOM_INPUT) {
|
|
221
|
+
const arn = await promptArn();
|
|
222
|
+
if (arn === GoBack) return GoBack;
|
|
223
|
+
cfg.iamRoleArn = arn;
|
|
224
|
+
} else {
|
|
225
|
+
cfg.iamRoleArn = selected;
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
printInfo('No roles found \u2014 enter ARN manually');
|
|
229
|
+
const arn = await promptArn();
|
|
230
|
+
if (arn === GoBack) return GoBack;
|
|
231
|
+
cfg.iamRoleArn = arn;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
cfg.iamAction = 'create';
|
|
235
|
+
const roleName = await eInput({ message: 'Role name', default: cfg.iamRoleName || `${cfg.pipelineName}-osi-role` });
|
|
236
|
+
if (roleName === GoBack) return GoBack;
|
|
237
|
+
cfg.iamRoleName = roleName;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function stepAps(cfg) {
|
|
242
|
+
if (cfg.mode !== 'advanced') return 'skip';
|
|
243
|
+
|
|
244
|
+
printStep('Amazon Managed Prometheus (APS) workspace');
|
|
245
|
+
console.error();
|
|
246
|
+
|
|
247
|
+
const apsChoice = await eSelect({
|
|
248
|
+
message: 'Create new or reuse existing?',
|
|
249
|
+
choices: [
|
|
250
|
+
{ name: 'Create new', value: 'create' },
|
|
251
|
+
{ name: 'Reuse existing', value: 'reuse' },
|
|
252
|
+
],
|
|
253
|
+
default: cfg.apsAction || 'create',
|
|
254
|
+
});
|
|
255
|
+
if (apsChoice === GoBack) return GoBack;
|
|
256
|
+
|
|
257
|
+
if (apsChoice === 'reuse') {
|
|
258
|
+
cfg.apsAction = 'reuse';
|
|
259
|
+
|
|
260
|
+
const workspaces = await fetchWithSpinner(
|
|
261
|
+
'Loading APS workspaces',
|
|
262
|
+
() => listWorkspaces(cfg.region),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (workspaces.length > 0) {
|
|
266
|
+
const choices = workspaces.map((w) => ({
|
|
267
|
+
name: w.alias
|
|
268
|
+
? `${w.alias} ${theme.muted(`\u2014 ${w.id}`)}`
|
|
269
|
+
: `${w.id} ${theme.muted('(no alias)')}`,
|
|
270
|
+
value: w.url,
|
|
271
|
+
}));
|
|
272
|
+
choices.push({ name: theme.accent('Enter URL manually...'), value: CUSTOM_INPUT });
|
|
273
|
+
|
|
274
|
+
const selected = await eSelect({ message: 'Select workspace', choices });
|
|
275
|
+
if (selected === GoBack) return GoBack;
|
|
276
|
+
if (selected === CUSTOM_INPUT) {
|
|
277
|
+
const url = await promptUrl('Prometheus remote-write URL');
|
|
278
|
+
if (url === GoBack) return GoBack;
|
|
279
|
+
cfg.prometheusUrl = url;
|
|
280
|
+
} else {
|
|
281
|
+
cfg.prometheusUrl = selected;
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
printInfo('No workspaces found \u2014 enter URL manually');
|
|
285
|
+
const url = await promptUrl('Prometheus remote-write URL');
|
|
286
|
+
if (url === GoBack) return GoBack;
|
|
287
|
+
cfg.prometheusUrl = url;
|
|
288
|
+
}
|
|
289
|
+
} else {
|
|
290
|
+
cfg.apsAction = 'create';
|
|
291
|
+
const alias = await eInput({ message: 'Workspace alias', default: cfg.apsWorkspaceAlias || cfg.pipelineName });
|
|
292
|
+
if (alias === GoBack) return GoBack;
|
|
293
|
+
cfg.apsWorkspaceAlias = alias;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function stepTuning(cfg) {
|
|
298
|
+
if (cfg.mode !== 'advanced') return 'skip';
|
|
299
|
+
|
|
300
|
+
printStep('Pipeline tuning');
|
|
301
|
+
console.error();
|
|
302
|
+
|
|
303
|
+
const minOcu = await eInput({
|
|
304
|
+
message: 'Minimum OCUs',
|
|
305
|
+
default: String(cfg.minOcu ?? DEFAULTS.minOcu),
|
|
306
|
+
validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 1 || 'Must be a positive integer',
|
|
307
|
+
});
|
|
308
|
+
if (minOcu === GoBack) return GoBack;
|
|
309
|
+
cfg.minOcu = Number(minOcu);
|
|
310
|
+
|
|
311
|
+
const maxOcu = await eInput({
|
|
312
|
+
message: 'Maximum OCUs',
|
|
313
|
+
default: String(cfg.maxOcu ?? DEFAULTS.maxOcu),
|
|
314
|
+
validate: (v) => /^\d+$/.test(v.trim()) && Number(v) >= 1 || 'Must be a positive integer',
|
|
315
|
+
});
|
|
316
|
+
if (maxOcu === GoBack) return GoBack;
|
|
317
|
+
cfg.maxOcu = Number(maxOcu);
|
|
318
|
+
|
|
319
|
+
const window = await eInput({ message: 'Service-map window duration', default: cfg.serviceMapWindow || DEFAULTS.serviceMapWindow });
|
|
320
|
+
if (window === GoBack) return GoBack;
|
|
321
|
+
cfg.serviceMapWindow = window;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function stepOutput(cfg) {
|
|
325
|
+
printStep('Output');
|
|
326
|
+
console.error();
|
|
327
|
+
|
|
328
|
+
const outputFile = await eInput({ message: 'Output file for pipeline YAML (leave empty for stdout)', default: cfg.outputFile || '' });
|
|
329
|
+
if (outputFile === GoBack) return GoBack;
|
|
330
|
+
cfg.outputFile = outputFile;
|
|
331
|
+
|
|
332
|
+
const dryRun = await eConfirm({
|
|
333
|
+
message: 'Dry run only (generate config, skip resource creation)?',
|
|
334
|
+
default: cfg.dryRun ?? true,
|
|
335
|
+
});
|
|
336
|
+
if (dryRun === GoBack) return GoBack;
|
|
337
|
+
cfg.dryRun = dryRun;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ── Main wizard ──────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Run the interactive create wizard. Returns a fully populated config object.
|
|
344
|
+
* Supports Escape to go back to the previous step.
|
|
345
|
+
* @param {Object} [session] - Optional session with pre-filled { region, accountId }
|
|
346
|
+
*/
|
|
347
|
+
export async function runCreateWizard(session = null) {
|
|
348
|
+
const cfg = createDefaultConfig();
|
|
349
|
+
|
|
350
|
+
if (!session) printHeader();
|
|
351
|
+
printKeyHint([['Esc', 'back'], ['Ctrl+C', 'cancel']]);
|
|
352
|
+
console.error();
|
|
353
|
+
|
|
354
|
+
const steps = [stepMode, stepCore, stepOpenSearch, stepIam, stepAps, stepTuning, stepOutput];
|
|
355
|
+
const visited = [];
|
|
356
|
+
let i = 0;
|
|
357
|
+
|
|
358
|
+
while (i < steps.length) {
|
|
359
|
+
const result = await steps[i](cfg, session);
|
|
360
|
+
|
|
361
|
+
if (result === GoBack) {
|
|
362
|
+
if (visited.length === 0) {
|
|
363
|
+
// Escape at first step → cancel wizard (same as Ctrl+C)
|
|
364
|
+
throw Object.assign(new Error('Cancelled'), { name: 'ExitPromptError' });
|
|
365
|
+
}
|
|
366
|
+
i = visited.pop();
|
|
367
|
+
} else if (result === 'skip') {
|
|
368
|
+
i++;
|
|
369
|
+
} else {
|
|
370
|
+
visited.push(i);
|
|
371
|
+
i++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.error();
|
|
376
|
+
return cfg;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── Prompt helpers for manual input fallback ────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
function promptEndpoint() {
|
|
382
|
+
return eInput({
|
|
383
|
+
message: 'OpenSearch endpoint URL',
|
|
384
|
+
validate: (v) => {
|
|
385
|
+
if (!v.trim()) return 'Endpoint is required';
|
|
386
|
+
if (!/^https?:\/\//.test(v)) return 'Must start with http:// or https://';
|
|
387
|
+
return true;
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function promptArn() {
|
|
393
|
+
return eInput({
|
|
394
|
+
message: 'IAM role ARN',
|
|
395
|
+
validate: (v) => {
|
|
396
|
+
if (!v.trim()) return 'ARN is required';
|
|
397
|
+
if (!v.startsWith('arn:aws:iam:')) return 'Must start with arn:aws:iam:';
|
|
398
|
+
return true;
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function promptUrl(message) {
|
|
404
|
+
return eInput({
|
|
405
|
+
message,
|
|
406
|
+
validate: (v) => {
|
|
407
|
+
if (!v.trim()) return 'URL is required';
|
|
408
|
+
if (!/^https?:\/\//.test(v)) return 'Must start with http:// or https://';
|
|
409
|
+
return true;
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Detect serverless from endpoint URL pattern.
|
|
416
|
+
* Serverless endpoints: https://<id>.<region>.aoss.amazonaws.com
|
|
417
|
+
* Managed endpoints: https://search-<name>.<region>.es.amazonaws.com
|
|
418
|
+
*/
|
|
419
|
+
function isServerlessEndpoint(endpoint) {
|
|
420
|
+
return /\.aoss\.amazonaws\.com/i.test(endpoint);
|
|
421
|
+
}
|
package/src/main.mjs
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { parseCli, applySimpleDefaults, validateConfig, fillDryRunPlaceholders } from './cli.mjs';
|
|
3
|
+
import { renderPipeline } from './render.mjs';
|
|
4
|
+
import {
|
|
5
|
+
checkRequirements,
|
|
6
|
+
createOpenSearch,
|
|
7
|
+
createIamRole,
|
|
8
|
+
createApsWorkspace,
|
|
9
|
+
createOsiPipeline,
|
|
10
|
+
mapOsiRoleInDomain,
|
|
11
|
+
} from './aws.mjs';
|
|
12
|
+
import {
|
|
13
|
+
printError,
|
|
14
|
+
printSuccess,
|
|
15
|
+
printPanel,
|
|
16
|
+
printBox,
|
|
17
|
+
STAR,
|
|
18
|
+
theme,
|
|
19
|
+
} from './ui.mjs';
|
|
20
|
+
|
|
21
|
+
export async function run() {
|
|
22
|
+
try {
|
|
23
|
+
// Parse CLI or run interactive REPL
|
|
24
|
+
let cfg = parseCli(process.argv);
|
|
25
|
+
if (!cfg) {
|
|
26
|
+
const { startRepl } = await import('./repl.mjs');
|
|
27
|
+
return startRepl();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Apply simple-mode defaults for anything not explicitly set
|
|
31
|
+
if (!cfg.mode) cfg.mode = 'simple';
|
|
32
|
+
if (cfg.mode === 'simple') applySimpleDefaults(cfg);
|
|
33
|
+
|
|
34
|
+
// Validate
|
|
35
|
+
const errors = validateConfig(cfg);
|
|
36
|
+
if (errors.length) {
|
|
37
|
+
for (const e of errors) printError(e);
|
|
38
|
+
console.error('Run with --help for usage information.');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Dry-run path ──────────────────────────────────────────────────────
|
|
43
|
+
if (cfg.dryRun) {
|
|
44
|
+
printSummary(cfg);
|
|
45
|
+
fillDryRunPlaceholders(cfg);
|
|
46
|
+
|
|
47
|
+
const yaml = renderPipeline(cfg);
|
|
48
|
+
|
|
49
|
+
if (cfg.outputFile) {
|
|
50
|
+
writeFileSync(cfg.outputFile, yaml + '\n');
|
|
51
|
+
printSuccess(`Pipeline YAML written to ${cfg.outputFile}`);
|
|
52
|
+
} else {
|
|
53
|
+
if (process.stdout.isTTY) {
|
|
54
|
+
console.error(` ${theme.muted('\u2500'.repeat(43))}`);
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write(yaml);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Live path ─────────────────────────────────────────────────────────
|
|
62
|
+
await executePipeline(cfg);
|
|
63
|
+
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err.name === 'ExitPromptError') {
|
|
66
|
+
// User pressed Ctrl+C during a prompt
|
|
67
|
+
console.error();
|
|
68
|
+
process.exit(130);
|
|
69
|
+
}
|
|
70
|
+
printError(err.message);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Execute the full pipeline creation flow.
|
|
77
|
+
* Shared by the CLI path (main.mjs) and the REPL create command.
|
|
78
|
+
*/
|
|
79
|
+
export async function executePipeline(cfg) {
|
|
80
|
+
await checkRequirements(cfg);
|
|
81
|
+
printSummary(cfg);
|
|
82
|
+
console.error();
|
|
83
|
+
|
|
84
|
+
// Create resources — OpenSearch first (slow), then IAM & APS
|
|
85
|
+
if (cfg.osAction === 'create') {
|
|
86
|
+
await createOpenSearch(cfg);
|
|
87
|
+
console.error();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cfg.iamAction === 'create') {
|
|
91
|
+
await createIamRole(cfg);
|
|
92
|
+
console.error();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (cfg.apsAction === 'create') {
|
|
96
|
+
await createApsWorkspace(cfg);
|
|
97
|
+
console.error();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Map OSI role in managed domain FGAC (no-op for serverless)
|
|
101
|
+
if (!cfg.serverless && cfg.opensearchEndpoint && cfg.iamRoleArn) {
|
|
102
|
+
await mapOsiRoleInDomain(cfg);
|
|
103
|
+
console.error();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Generate pipeline YAML
|
|
107
|
+
const pipelineYaml = renderPipeline(cfg);
|
|
108
|
+
|
|
109
|
+
if (cfg.outputFile) {
|
|
110
|
+
writeFileSync(cfg.outputFile, pipelineYaml + '\n');
|
|
111
|
+
printSuccess(`Pipeline YAML saved to ${cfg.outputFile}`);
|
|
112
|
+
console.error();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Create the OSI pipeline
|
|
116
|
+
await createOsiPipeline(cfg, pipelineYaml);
|
|
117
|
+
|
|
118
|
+
// ── Final summary ───────────────────────────────────────────────────
|
|
119
|
+
console.error();
|
|
120
|
+
printBox([
|
|
121
|
+
'',
|
|
122
|
+
`${theme.success.bold(`${STAR} OSI Pipeline Setup Complete! ${STAR}`)}`,
|
|
123
|
+
'',
|
|
124
|
+
`${theme.label('Pipeline:')} ${cfg.pipelineName}`,
|
|
125
|
+
`${theme.label('OpenSearch:')} ${cfg.opensearchEndpoint}`,
|
|
126
|
+
`${theme.label('IAM Role:')} ${cfg.iamRoleArn}`,
|
|
127
|
+
`${theme.label('Prometheus:')} ${cfg.prometheusUrl}`,
|
|
128
|
+
'',
|
|
129
|
+
], { color: 'primary', padding: 2 });
|
|
130
|
+
|
|
131
|
+
console.error();
|
|
132
|
+
printBox([
|
|
133
|
+
'',
|
|
134
|
+
`${theme.muted('# Check pipeline status')}`,
|
|
135
|
+
`${theme.accentBold(`aws osis get-pipeline --pipeline-name ${cfg.pipelineName} --region ${cfg.region}`)}`,
|
|
136
|
+
'',
|
|
137
|
+
`${theme.muted('# Delete pipeline')}`,
|
|
138
|
+
`${theme.accentBold(`aws osis delete-pipeline --pipeline-name ${cfg.pipelineName} --region ${cfg.region}`)}`,
|
|
139
|
+
'',
|
|
140
|
+
], { title: 'Useful commands', color: 'dim', padding: 2 });
|
|
141
|
+
console.error();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Summary ─────────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
function printSummary(cfg) {
|
|
147
|
+
console.error();
|
|
148
|
+
|
|
149
|
+
// Core info
|
|
150
|
+
const coreEntries = [
|
|
151
|
+
['Mode', cfg.mode],
|
|
152
|
+
['Pipeline name', cfg.pipelineName],
|
|
153
|
+
['Region', cfg.region],
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
// OpenSearch
|
|
157
|
+
const osEntries = [];
|
|
158
|
+
if (cfg.osAction === 'reuse') {
|
|
159
|
+
osEntries.push(['Action', 'reuse existing']);
|
|
160
|
+
osEntries.push(['Endpoint', cfg.opensearchEndpoint]);
|
|
161
|
+
osEntries.push(['Type', cfg.serverless ? 'Serverless' : 'Managed domain']);
|
|
162
|
+
} else if (cfg.serverless) {
|
|
163
|
+
osEntries.push(['Action', 'create new Serverless collection']);
|
|
164
|
+
osEntries.push(['Collection name', cfg.osDomainName]);
|
|
165
|
+
} else {
|
|
166
|
+
osEntries.push(['Action', 'create new managed domain']);
|
|
167
|
+
osEntries.push(['Domain name', cfg.osDomainName]);
|
|
168
|
+
osEntries.push(['Instance type', cfg.osInstanceType]);
|
|
169
|
+
osEntries.push(['Instance count', String(cfg.osInstanceCount)]);
|
|
170
|
+
osEntries.push(['Volume size', `${cfg.osVolumeSize} GB`]);
|
|
171
|
+
osEntries.push(['Engine version', cfg.osEngineVersion]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// IAM
|
|
175
|
+
const iamEntries = [];
|
|
176
|
+
if (cfg.iamAction === 'reuse') {
|
|
177
|
+
iamEntries.push(['Action', 'reuse existing']);
|
|
178
|
+
iamEntries.push(['ARN', cfg.iamRoleArn]);
|
|
179
|
+
} else {
|
|
180
|
+
iamEntries.push(['Action', 'create new']);
|
|
181
|
+
iamEntries.push(['Role name', cfg.iamRoleName]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// APS
|
|
185
|
+
const apsEntries = [];
|
|
186
|
+
if (cfg.apsAction === 'reuse') {
|
|
187
|
+
apsEntries.push(['Action', 'reuse existing']);
|
|
188
|
+
apsEntries.push(['Remote write URL', cfg.prometheusUrl]);
|
|
189
|
+
} else {
|
|
190
|
+
apsEntries.push(['Action', 'create new']);
|
|
191
|
+
apsEntries.push(['Workspace alias', cfg.apsWorkspaceAlias]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Pipeline settings
|
|
195
|
+
const tuneEntries = [
|
|
196
|
+
['Min OCU', String(cfg.minOcu)],
|
|
197
|
+
['Max OCU', String(cfg.maxOcu)],
|
|
198
|
+
['Service-map window', cfg.serviceMapWindow],
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
printPanel(`${STAR} Configuration Summary`, [
|
|
202
|
+
...coreEntries,
|
|
203
|
+
['', ''],
|
|
204
|
+
['', theme.accentBold('OpenSearch')],
|
|
205
|
+
...osEntries,
|
|
206
|
+
['', ''],
|
|
207
|
+
['', theme.accentBold('IAM Role')],
|
|
208
|
+
...iamEntries,
|
|
209
|
+
['', ''],
|
|
210
|
+
['', theme.accentBold('Amazon Managed Prometheus')],
|
|
211
|
+
...apsEntries,
|
|
212
|
+
['', ''],
|
|
213
|
+
['', theme.accentBold('Pipeline Settings')],
|
|
214
|
+
...tuneEntries,
|
|
215
|
+
]);
|
|
216
|
+
|
|
217
|
+
if (cfg.dryRun) {
|
|
218
|
+
console.error();
|
|
219
|
+
console.error(` ${theme.warn('DRY RUN')} ${theme.muted('\u2014 will generate config only, no AWS resources created')}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.error();
|
|
223
|
+
}
|