@joshualiamzn/open-stack 0.0.1 → 0.0.2

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/src/main.mjs CHANGED
@@ -8,6 +8,10 @@ import {
8
8
  createApsWorkspace,
9
9
  createOsiPipeline,
10
10
  mapOsiRoleInDomain,
11
+ setupDashboards,
12
+ createDqsPrometheusRole,
13
+ createDirectQueryDataSource,
14
+ createOpenSearchApplication,
11
15
  } from './aws.mjs';
12
16
  import {
13
17
  printError,
@@ -18,6 +22,52 @@ import {
18
22
  theme,
19
23
  } from './ui.mjs';
20
24
 
25
+ async function runDemoCommand(cfg) {
26
+ const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
27
+ const { checkDemoPrerequisites, createEksCluster, installHelmChart, installOtelDemo } = await import('./eks.mjs');
28
+ const { getPipelineEndpoint } = await import('./aws.mjs');
29
+
30
+ if (!cfg.region) throw new Error('--region is required (or set AWS_REGION)');
31
+ if (!cfg.pipeline) throw new Error('--pipeline is required');
32
+
33
+ const sts = new STSClient({ region: cfg.region });
34
+ const identity = await sts.send(new GetCallerIdentityCommand({}));
35
+
36
+ checkDemoPrerequisites();
37
+
38
+ const otlpEndpoint = await getPipelineEndpoint(cfg.region, cfg.pipeline);
39
+ if (!otlpEndpoint) throw new Error(`No OTLP endpoint found for pipeline: ${cfg.pipeline}`);
40
+ printSuccess(`Pipeline endpoint: ${otlpEndpoint}`);
41
+
42
+ await createEksCluster({
43
+ clusterName: cfg.clusterName,
44
+ region: cfg.region,
45
+ nodeCount: cfg.nodeCount,
46
+ instanceType: cfg.instanceType,
47
+ stackName: cfg.pipeline,
48
+ accountId: identity.Account,
49
+ });
50
+
51
+ console.error();
52
+ await installHelmChart({ otlpEndpoint });
53
+
54
+ if (!cfg.skipOtelDemo) {
55
+ console.error();
56
+ await installOtelDemo({ otlpEndpoint });
57
+ }
58
+
59
+ console.error();
60
+ printBox([
61
+ '',
62
+ `${theme.success.bold(`${STAR} Demo Services Deployed! ${STAR}`)}`,
63
+ '',
64
+ `${theme.label('Cluster:')} ${cfg.clusterName}`,
65
+ `${theme.label('Region:')} ${cfg.region}`,
66
+ `${theme.label('Pipeline:')} ${cfg.pipeline}`,
67
+ '',
68
+ ], { color: 'primary', padding: 2 });
69
+ }
70
+
21
71
  export async function run() {
22
72
  try {
23
73
  // Parse CLI or run interactive REPL
@@ -27,6 +77,12 @@ export async function run() {
27
77
  return startRepl();
28
78
  }
29
79
 
80
+ // Demo subcommand
81
+ if (cfg._command === 'demo') {
82
+ await runDemoCommand(cfg);
83
+ return;
84
+ }
85
+
30
86
  // Apply simple-mode defaults for anything not explicitly set
31
87
  if (!cfg.mode) cfg.mode = 'simple';
32
88
  if (cfg.mode === 'simple') applySimpleDefaults(cfg);
@@ -103,6 +159,27 @@ export async function executePipeline(cfg) {
103
159
  console.error();
104
160
  }
105
161
 
162
+ // Extract apsWorkspaceId from prometheusUrl if not already set
163
+ if (!cfg.apsWorkspaceId && cfg.prometheusUrl) {
164
+ const m = cfg.prometheusUrl.match(/\/workspaces\/(ws-[^/]+)\//);
165
+ if (m) cfg.apsWorkspaceId = m[1];
166
+ }
167
+
168
+ // Create DQS Prometheus role and Direct Query data source (connects AMP to OpenSearch)
169
+ if (cfg.apsWorkspaceId && cfg.dqsRoleName) {
170
+ await createDqsPrometheusRole(cfg);
171
+ console.error();
172
+
173
+ await createDirectQueryDataSource(cfg);
174
+ console.error();
175
+ }
176
+
177
+ // Create OpenSearch Application and associate data sources
178
+ if (cfg.appName) {
179
+ await createOpenSearchApplication(cfg);
180
+ console.error();
181
+ }
182
+
106
183
  // Generate pipeline YAML
107
184
  const pipelineYaml = renderPipeline(cfg);
108
185
 
@@ -114,31 +191,28 @@ export async function executePipeline(cfg) {
114
191
 
115
192
  // Create the OSI pipeline
116
193
  await createOsiPipeline(cfg, pipelineYaml);
194
+ console.error();
195
+
196
+ // Set up OpenSearch UI and create Observability workspace
197
+ await setupDashboards(cfg);
117
198
 
118
199
  // ── Final summary ───────────────────────────────────────────────────
119
200
  console.error();
201
+ const pad = (l) => l.padEnd(35);
120
202
  printBox([
121
203
  '',
122
- `${theme.success.bold(`${STAR} OSI Pipeline Setup Complete! ${STAR}`)}`,
204
+ `${theme.success.bold(`${STAR} Open Stack Setup Complete! ${STAR}`)}`,
123
205
  '',
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}`,
206
+ `${theme.label(pad('OSI Pipeline:'))} ${cfg.ingestEndpoints?.length ? `https://${cfg.ingestEndpoints[0]}` : cfg.pipelineName}`,
207
+ `${theme.label(pad('OSI Pipeline Role:'))} ${cfg.iamRoleArn}`,
208
+ `${theme.label(pad('OpenSearch:'))} ${cfg.opensearchEndpoint}`,
209
+ `${theme.label(pad('OpenSearch UI:'))} ${cfg.dashboardsUrl}`,
210
+ `${theme.label(pad('Prometheus:'))} ${cfg.prometheusUrl}`,
211
+ `${theme.label(pad('Direct Query Service Datasource:'))} ${cfg.dqsDataSourceArn || 'n/a'}`,
212
+ `${theme.label(pad('Direct Query Service Role:'))} ${cfg.dqsRoleArn || 'n/a'}`,
128
213
  '',
129
214
  ], { color: 'primary', padding: 2 });
130
215
 
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
216
  }
143
217
 
144
218
  // ── Summary ─────────────────────────────────────────────────────────────────
@@ -191,6 +265,21 @@ function printSummary(cfg) {
191
265
  apsEntries.push(['Workspace alias', cfg.apsWorkspaceAlias]);
192
266
  }
193
267
 
268
+ // Dashboards
269
+ const dashEntries = [];
270
+ if (cfg.dashboardsAction === 'reuse') {
271
+ dashEntries.push(['Action', 'reuse existing']);
272
+ dashEntries.push(['URL', cfg.dashboardsUrl]);
273
+ } else {
274
+ dashEntries.push(['Action', 'create new Observability workspace']);
275
+ }
276
+
277
+ // Direct Query & Application
278
+ const dqEntries = [];
279
+ if (cfg.dqsRoleName) dqEntries.push(['DQS role', cfg.dqsRoleName]);
280
+ if (cfg.dqsDataSourceName) dqEntries.push(['Data source name', cfg.dqsDataSourceName]);
281
+ if (cfg.appName) dqEntries.push(['Application name', cfg.appName]);
282
+
194
283
  // Pipeline settings
195
284
  const tuneEntries = [
196
285
  ['Min OCU', String(cfg.minOcu)],
@@ -204,12 +293,18 @@ function printSummary(cfg) {
204
293
  ['', theme.accentBold('OpenSearch')],
205
294
  ...osEntries,
206
295
  ['', ''],
296
+ ['', theme.accentBold('OpenSearch UI')],
297
+ ...dashEntries,
298
+ ['', ''],
207
299
  ['', theme.accentBold('IAM Role')],
208
300
  ...iamEntries,
209
301
  ['', ''],
210
302
  ['', theme.accentBold('Amazon Managed Prometheus')],
211
303
  ...apsEntries,
212
304
  ['', ''],
305
+ ['', theme.accentBold('Direct Query & Application')],
306
+ ...dqEntries,
307
+ ['', ''],
213
308
  ['', theme.accentBold('Pipeline Settings')],
214
309
  ...tuneEntries,
215
310
  ]);
package/src/render.mjs CHANGED
@@ -174,8 +174,7 @@ otel-metrics-pipeline:
174
174
  source:
175
175
  pipeline:
176
176
  name: otlp-pipeline
177
- processor:
178
- - otel_metrics:
177
+ processor: []
179
178
  sink:
180
179
  - prometheus:
181
180
  url: '${cfg.prometheusUrl}'
package/src/repl.mjs CHANGED
@@ -1,8 +1,8 @@
1
- import { select, input } from '@inquirer/prompts';
1
+ import { input } from '@inquirer/prompts';
2
2
  import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
3
3
  import {
4
- printBanner, printError, printInfo,
5
- printKeyHint, printPanel, theme,
4
+ printBanner, printDivider, printError, printInfo,
5
+ theme, GoBack, eSelect,
6
6
  } from './ui.mjs';
7
7
  import { COMMANDS, COMMAND_CHOICES } from './commands/index.mjs';
8
8
 
@@ -10,39 +10,31 @@ import { COMMANDS, COMMAND_CHOICES } from './commands/index.mjs';
10
10
  * Initialize session — prompt for region and verify AWS credentials.
11
11
  */
12
12
  async function initSession() {
13
- const region = await input({
13
+ const envRegion = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
14
+ const region = envRegion || await input({
14
15
  message: 'AWS region',
15
16
  default: 'us-east-1',
16
17
  validate: (v) => /^[a-z]{2}-[a-z]+-\d+$/.test(v) || 'Expected format: us-east-1',
17
18
  });
18
19
 
19
- console.error();
20
20
  const sts = new STSClient({ region });
21
21
  let identity;
22
22
  try {
23
23
  identity = await sts.send(new GetCallerIdentityCommand({}));
24
24
  } catch (err) {
25
25
  printError('AWS credentials are not configured or have expired');
26
- printInfo(err.message);
27
26
  printInfo('Run "aws configure" or "aws sso login" to set up credentials, then restart.');
27
+ printInfo('See documentation: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html#getting-started-quickstart-new-command.');
28
28
  throw err;
29
29
  }
30
30
 
31
- printPanel('Session', [
32
- ['Account', identity.Account],
33
- ['Region', region],
34
- ['Identity', theme.muted(identity.Arn)],
35
- ]);
36
-
37
- return { region, accountId: identity.Account };
31
+ return { region, accountId: identity.Account, arn: identity.Arn };
38
32
  }
39
33
 
40
34
  /**
41
35
  * Start the interactive REPL loop.
42
36
  */
43
37
  export async function startRepl() {
44
- printBanner();
45
-
46
38
  let session;
47
39
  try {
48
40
  session = await initSession();
@@ -50,39 +42,23 @@ export async function startRepl() {
50
42
  process.exit(1);
51
43
  }
52
44
 
53
- console.error();
54
- printKeyHint([['Enter', 'select'], ['Esc', 'back'], ['Ctrl+C', 'exit']]);
45
+ printBanner({ account: session.accountId, region: session.region, arn: session.arn });
46
+
55
47
  console.error();
56
48
 
57
49
  while (true) {
58
- let cmd;
59
- try {
60
- cmd = await select({
61
- message: theme.primary(`osi-pipeline [${session.region}]`),
62
- choices: COMMAND_CHOICES,
63
- });
64
- } catch (err) {
65
- // Ctrl+C at the menu level → exit
66
- if (err.name === 'ExitPromptError') break;
67
- throw err;
68
- }
50
+ const cmd = await eSelect({
51
+ message: theme.primary(`open-stack [${session.region}]`),
52
+ choices: COMMAND_CHOICES,
53
+ clearPromptOnDone: true,
54
+ });
69
55
 
70
- if (cmd === 'quit') break;
56
+ if (cmd === GoBack || cmd === 'quit') break;
71
57
 
72
- try {
73
- await COMMANDS[cmd](session);
74
- } catch (err) {
75
- if (err.name === 'ExitPromptError') {
76
- // Ctrl+C during a command → return to menu
77
- console.error();
78
- printInfo('Cancelled.');
79
- console.error();
80
- continue;
81
- }
82
- console.error();
83
- printError(err.message);
84
- console.error();
85
- }
58
+ const result = await COMMANDS[cmd](session);
59
+ printDivider();
60
+ console.error();
61
+ if (result === GoBack) continue;
86
62
  }
87
63
 
88
64
  console.error();
package/src/ui.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import ora from 'ora';
3
3
  import readline from 'node:readline';
4
+ import { search, input, confirm } from '@inquirer/prompts';
4
5
 
5
6
  // ── Theme colors ─────────────────────────────────────────────────────────────
6
7
 
@@ -28,9 +29,11 @@ export const DOT = '\u2022';
28
29
  export const DIAMOND = '\u25C6';
29
30
 
30
31
  // Box-drawing characters
32
+ const DIVIDER = Symbol('divider');
33
+
31
34
  const BOX = {
32
35
  tl: '\u256D', tr: '\u256E', bl: '\u2570', br: '\u256F', // rounded corners
33
- h: '\u2500', v: '\u2502',
36
+ h: '\u2500', v: '\u2502', lT: '\u251C', rT: '\u2524',
34
37
  ltee: '\u251C', rtee: '\u2524',
35
38
  };
36
39
 
@@ -51,7 +54,7 @@ export function renderBox(lines, opts = {}) {
51
54
 
52
55
  // Strip ANSI for width calculation
53
56
  const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, '');
54
- const innerWidth = opts.width || Math.max(...lines.map((l) => stripAnsi(l).length)) + pad * 2;
57
+ const innerWidth = opts.width || Math.max(...lines.filter((l) => typeof l === 'string').map((l) => stripAnsi(l).length)) + pad * 2;
55
58
 
56
59
  const colorFn = opts.color === 'dim' ? chalk.dim
57
60
  : opts.color === 'primary' ? theme.primary
@@ -62,7 +65,7 @@ export function renderBox(lines, opts = {}) {
62
65
  if (opts.title) {
63
66
  const titleStr = ` ${opts.title} `;
64
67
  const titleLen = stripAnsi(titleStr).length;
65
- const remaining = innerWidth - titleLen;
68
+ const remaining = innerWidth - titleLen - 1;
66
69
  topBorder = colorFn(BOX.tl + BOX.h) + theme.primaryBold(titleStr) + colorFn(BOX.h.repeat(Math.max(0, remaining)) + BOX.tr);
67
70
  } else {
68
71
  topBorder = colorFn(BOX.tl + BOX.h.repeat(innerWidth) + BOX.tr);
@@ -72,6 +75,10 @@ export function renderBox(lines, opts = {}) {
72
75
 
73
76
  const boxLines = [topBorder];
74
77
  for (const line of lines) {
78
+ if (line === DIVIDER) {
79
+ boxLines.push(colorFn(BOX.lT + BOX.h.repeat(innerWidth) + BOX.rT));
80
+ continue;
81
+ }
75
82
  const visLen = stripAnsi(line).length;
76
83
  const rightPad = Math.max(0, innerWidth - pad * 2 - visLen);
77
84
  boxLines.push(colorFn(BOX.v) + sp + line + ' '.repeat(rightPad) + sp + colorFn(BOX.v));
@@ -118,7 +125,7 @@ export function printHeader() {
118
125
  const banner = [
119
126
  '',
120
127
  `${theme.primaryBold('Open Stack CLI')}`,
121
- `${theme.muted('Create AWS resources and OpenSearch Ingestion pipelines')}`,
128
+ `${theme.muted('Create and manage your observability stack on AWS')}`,
122
129
  '',
123
130
  ];
124
131
  printBox(banner, { color: 'primary', padding: 2 });
@@ -152,7 +159,7 @@ export function printInfo(msg) {
152
159
 
153
160
  // Spinner — wraps ora for consistent styling
154
161
  export function createSpinner(text) {
155
- return ora({ text, stream: process.stderr, spinner: 'dots', color: 'magenta' });
162
+ return ora({ text, stream: process.stderr, spinner: 'dots', color: 'magenta', indent: 2 });
156
163
  }
157
164
 
158
165
  // ── Key hints ────────────────────────────────────────────────────────────────
@@ -165,6 +172,99 @@ export function printKeyHint(hints) {
165
172
  console.error(` ${parts.join(' ')}`);
166
173
  }
167
174
 
175
+ /**
176
+ * Returns a keysHelpTip theme function for @inquirer/select that appends
177
+ * extra key hints (e.g. Esc, Ctrl+C) to the default navigation line.
178
+ */
179
+ export function formatKeysHelpTip(extraHints) {
180
+ const extra = extraHints.map(([key, desc]) =>
181
+ `${theme.accent(key)} ${theme.muted(desc)}`
182
+ ).join(theme.muted(' \u2022 '));
183
+ return (keys) => {
184
+ const base = keys.map(([key, action]) =>
185
+ `${theme.accent(key)} ${theme.muted(action)}`
186
+ ).join(theme.muted(' \u2022 '));
187
+ return `${base} ${theme.muted('\u2022')} ${extra}`;
188
+ };
189
+ }
190
+
191
+ // ── Shared escape-wrapped prompts ────────────────────────────────────────────
192
+
193
+ const _promptPrefix = { idle: ' ?', done: ' ✔', canceled: ' ✖' };
194
+
195
+ const _selectKeyTheme = {
196
+ prefix: _promptPrefix,
197
+ style: { keysHelpTip: formatKeysHelpTip([['Esc', 'back']]) },
198
+ };
199
+
200
+ export const eInput = withEscape(input);
201
+ export const eConfirm = withEscape(confirm);
202
+
203
+ // ── Searchable select ───────────────────────────────────────────────────────
204
+
205
+ /**
206
+ * Select prompt with built-in incremental search and Escape-to-go-back.
207
+ * Type to filter, arrow keys to navigate, Enter to select, Esc to go back.
208
+ */
209
+ export function eSelect(opts) {
210
+ if (!_keypressInit && process.stdin.isTTY) {
211
+ readline.emitKeypressEvents(process.stdin);
212
+ _keypressInit = true;
213
+ }
214
+
215
+ const searchableChoices = (opts.choices || []).filter((c) => c.type !== 'separator' && !c.disabled);
216
+
217
+ const promise = search({
218
+ message: opts.message || 'Select',
219
+ theme: _selectKeyTheme,
220
+ source: (term) => {
221
+ if (!term) return searchableChoices;
222
+ const lower = term.toLowerCase();
223
+ return searchableChoices.filter((c) => {
224
+ const name = (c.name || String(c.value || '')).toLowerCase();
225
+ return name.includes(lower);
226
+ });
227
+ },
228
+ });
229
+
230
+ // Intercept tab at emit level to prevent search prompt's autocomplete
231
+ const origEmit = process.stdin.emit;
232
+ process.stdin.emit = function (event, ...args) {
233
+ if (event === 'keypress' && args[1]?.name === 'tab') return true;
234
+ return origEmit.apply(this, [event, ...args]);
235
+ };
236
+
237
+ let escaped = false;
238
+ const onKeypress = (_ch, key) => {
239
+ if (key?.name === 'escape') {
240
+ escaped = true;
241
+ promise.cancel();
242
+ }
243
+ };
244
+
245
+ process.stdin.on('keypress', onKeypress);
246
+
247
+ const cleanup = () => {
248
+ process.stdin.removeListener('keypress', onKeypress);
249
+ process.stdin.emit = origEmit;
250
+ };
251
+
252
+ return promise.then(
253
+ (val) => { cleanup(); return val; },
254
+ (err) => {
255
+ cleanup();
256
+ if (escaped) return GoBack;
257
+ if (err.name === 'ExitPromptError') {
258
+ console.error();
259
+ console.error(` ${theme.muted('Goodbye.')}`);
260
+ console.error();
261
+ process.exit(0);
262
+ }
263
+ throw err;
264
+ },
265
+ );
266
+ }
267
+
168
268
  // ── Pipeline status colorizer ────────────────────────────────────────────────
169
269
 
170
270
  const STATUS_COLORS = {
@@ -196,15 +296,18 @@ export function formatDate(d) {
196
296
 
197
297
  // ── REPL banner ──────────────────────────────────────────────────────────────
198
298
 
199
- export function printBanner() {
299
+ export function printBanner(session) {
200
300
  console.error();
201
- const banner = [
202
- '',
203
- `${theme.primaryBold('Open Stack')}`,
204
- `${theme.muted('Create, list, describe, and update OpenSearch Ingestion pipelines')}`,
205
- '',
301
+ const lines = [
302
+ `${theme.muted('Create and manage your observability stack on AWS')}`,
206
303
  ];
207
- printBox(banner, { color: 'primary', padding: 2 });
304
+ if (session) {
305
+ lines.push(DIVIDER);
306
+ lines.push(`${theme.muted('Account')} ${session.account}`);
307
+ lines.push(`${theme.muted('Region')} ${session.region}`);
308
+ lines.push(`${theme.muted('Identity')} ${theme.muted(session.arn)}`);
309
+ }
310
+ printBox(lines, { title: 'Open Stack', color: 'primary', padding: 2 });
208
311
  }
209
312
 
210
313
  // ── Horizontal divider ──────────────────────────────────────────────────────
@@ -222,6 +325,7 @@ let _keypressInit = false;
222
325
  /**
223
326
  * Wrap an @inquirer/prompts function so that pressing Escape cancels
224
327
  * the prompt and returns the GoBack sentinel instead of throwing.
328
+ * @param {Function} promptFn
225
329
  */
226
330
  export function withEscape(promptFn) {
227
331
  return (...args) => {
@@ -247,6 +351,13 @@ export function withEscape(promptFn) {
247
351
  (err) => {
248
352
  process.stdin.removeListener('keypress', onKeypress);
249
353
  if (escaped) return GoBack;
354
+ // Ctrl+C → exit immediately
355
+ if (err.name === 'ExitPromptError') {
356
+ console.error();
357
+ console.error(` ${theme.muted('Goodbye.')}`);
358
+ console.error();
359
+ process.exit(0);
360
+ }
250
361
  throw err;
251
362
  },
252
363
  );