@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/cli.mjs CHANGED
@@ -6,11 +6,17 @@ import { DEFAULTS } from './config.mjs';
6
6
  * Returns null when no flags were given (triggers interactive mode).
7
7
  */
8
8
  export function parseCli(argv) {
9
+ // No args → interactive REPL
10
+ if (argv.length <= 2) return null;
11
+
12
+ // Demo subcommand — separate parser to avoid option conflicts
13
+ if (argv[2] === 'demo') return parseDemoArgs(argv);
14
+
9
15
  const program = new Command()
10
16
  .name('open-stack')
11
17
  .description(
12
- 'Create AWS resources and an OpenSearch Ingestion (OSI) pipeline\n' +
13
- 'for the full Observability Stack: logs, traces, metrics, service map.'
18
+ 'Create and manage your observability stack on AWS:\n' +
19
+ 'OpenSearch, Prometheus, IAM roles, and ingestion pipelines.'
14
20
  )
15
21
  .version('1.0.0');
16
22
 
@@ -47,6 +53,10 @@ export function parseCli(argv) {
47
53
  .option('--prometheus-url <url>', 'Reuse an existing APS remote-write URL')
48
54
  .option('--aps-workspace-alias <name>', 'Alias for new APS workspace');
49
55
 
56
+ // Dashboards
57
+ program
58
+ .option('--dashboards-url <url>', 'Reuse an existing OpenSearch UI URL');
59
+
50
60
  // Pipeline tuning
51
61
  program
52
62
  .option('--min-ocu <n>', 'Minimum OCUs', DEFAULTS.minOcu)
@@ -59,13 +69,24 @@ export function parseCli(argv) {
59
69
  .option('--dry-run', 'Generate config only; do not create AWS resources');
60
70
 
61
71
  program.parse(argv);
62
- const opts = program.opts();
63
72
 
64
- // If no meaningful flags were provided, return null → interactive
65
- const userArgs = argv.slice(2);
66
- if (userArgs.length === 0) return null;
73
+ return optsToConfig(program.opts());
74
+ }
67
75
 
68
- return optsToConfig(opts);
76
+ function parseDemoArgs(argv) {
77
+ const program = new Command()
78
+ .name('open-stack demo')
79
+ .description('Create an EKS cluster and install the observability stack + OTel demo')
80
+ .option('--cluster-name <name>', 'EKS cluster name', 'open-stack-demo')
81
+ .option('--region <region>', 'AWS region', process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION)
82
+ .option('--pipeline <name>', 'Existing OSI pipeline name to connect')
83
+ .option('--node-count <n>', 'Number of EKS nodes', '3')
84
+ .option('--instance-type <type>', 'EKS node instance type', 'm8i.large')
85
+ .option('--skip-otel-demo', 'Skip installing the OpenTelemetry Demo app');
86
+
87
+ program.parse(argv.slice(1));
88
+ const opts = program.opts();
89
+ return { _command: 'demo', ...opts, nodeCount: Number(opts.nodeCount) };
69
90
  }
70
91
 
71
92
  /**
@@ -87,6 +108,10 @@ function optsToConfig(opts) {
87
108
  if (opts.prometheusUrl) apsAction = 'reuse';
88
109
  else if (opts.apsWorkspaceAlias) apsAction = 'create';
89
110
 
111
+ let dashboardsAction = '';
112
+ if (opts.dashboardsUrl) dashboardsAction = 'reuse';
113
+ else dashboardsAction = 'create';
114
+
90
115
  // Auto-detect serverless from endpoint URL when not explicitly set
91
116
  if (opts.serverless && opts.managed) {
92
117
  throw new Error('--serverless and --managed are mutually exclusive');
@@ -127,6 +152,15 @@ function optsToConfig(opts) {
127
152
  minOcu: Number(opts.minOcu),
128
153
  maxOcu: Number(opts.maxOcu),
129
154
  serviceMapWindow: opts.serviceMapWindow,
155
+ dashboardsAction,
156
+ dashboardsUrl: opts.dashboardsUrl || '',
157
+ dqsRoleName: '',
158
+ dqsRoleArn: '',
159
+ dqsDataSourceName: '',
160
+ dqsDataSourceArn: '',
161
+ appName: '',
162
+ appId: '',
163
+ appEndpoint: '',
130
164
  outputFile: opts.output || '',
131
165
  dryRun: opts.dryRun || false,
132
166
  accountId: '',
@@ -144,6 +178,10 @@ export function applySimpleDefaults(cfg) {
144
178
  if (!cfg.iamRoleName) cfg.iamRoleName = `${cfg.pipelineName}-osi-role`;
145
179
  if (!cfg.apsAction) cfg.apsAction = 'create';
146
180
  if (!cfg.apsWorkspaceAlias) cfg.apsWorkspaceAlias = cfg.pipelineName;
181
+ if (!cfg.dashboardsAction) cfg.dashboardsAction = 'create';
182
+ if (!cfg.dqsRoleName) cfg.dqsRoleName = `${cfg.pipelineName}-dqs-prometheus-role`;
183
+ if (!cfg.dqsDataSourceName) cfg.dqsDataSourceName = `${cfg.pipelineName.replace(/-/g, '_')}_prometheus`;
184
+ if (!cfg.appName) cfg.appName = cfg.pipelineName;
147
185
  }
148
186
 
149
187
  /**
@@ -161,6 +199,15 @@ export function fillDryRunPlaceholders(cfg) {
161
199
  if (cfg.apsAction === 'create' && !cfg.prometheusUrl) {
162
200
  cfg.prometheusUrl = `https://aps-workspaces.${cfg.region}.amazonaws.com/workspaces/<workspace-id>/api/v1/remote_write`;
163
201
  }
202
+ if (!cfg.dqsRoleArn && cfg.dqsRoleName) {
203
+ cfg.dqsRoleArn = `arn:aws:iam::${cfg.accountId || '123456789012'}:role/${cfg.dqsRoleName}`;
204
+ }
205
+ if (!cfg.dqsDataSourceArn && cfg.dqsDataSourceName) {
206
+ cfg.dqsDataSourceArn = `arn:aws:opensearch:${cfg.region}:${cfg.accountId || '123456789012'}:datasource/${cfg.dqsDataSourceName}`;
207
+ }
208
+ if (!cfg.appEndpoint) {
209
+ cfg.appEndpoint = `https://<app-id>.${cfg.region}.opensearch.amazonaws.com`;
210
+ }
164
211
  }
165
212
 
166
213
  /**
@@ -181,6 +228,9 @@ export function validateConfig(cfg) {
181
228
  if (cfg.apsAction === 'reuse' && !cfg.prometheusUrl) {
182
229
  errors.push('--prometheus-url required when reusing APS workspace');
183
230
  }
231
+ if (cfg.dashboardsAction === 'reuse' && !cfg.dashboardsUrl) {
232
+ errors.push('--dashboards-url required when reusing OpenSearch UI');
233
+ }
184
234
 
185
235
  // Format checks
186
236
  if (cfg.region && !/^[a-z]{2}-[a-z]+-\d+$/.test(cfg.region)) {
@@ -1,15 +1,188 @@
1
1
  import { runCreateWizard } from '../interactive.mjs';
2
- import { applySimpleDefaults, validateConfig, fillDryRunPlaceholders } from '../cli.mjs';
3
- import { renderPipeline } from '../render.mjs';
2
+ import { applySimpleDefaults, validateConfig } from '../cli.mjs';
4
3
  import { executePipeline } from '../main.mjs';
5
- import { printError, printSuccess, printDivider, theme } from '../ui.mjs';
6
- import { writeFileSync } from 'node:fs';
4
+ import { promptDemoAfterCreate } from './demo.mjs';
5
+ import { printError, printStep, theme, GoBack, eConfirm } from '../ui.mjs';
6
+
7
+ // ── Architecture diagram ────────────────────────────────────────────────────
8
+
9
+ const strip = (s) => s.replace(/\x1B\[[0-9;]*m/g, '');
10
+
11
+ /**
12
+ * Build a styled box. Returns { w, mid, inner, top, bot, botC, lines }.
13
+ * All strings have correct visual width thanks to ANSI-aware padding.
14
+ */
15
+ function box(texts, minInner = 0) {
16
+ const m = theme.muted;
17
+ const inner = Math.max(minInner, ...texts.map((t) => strip(t).length + 2));
18
+ const w = inner + 2;
19
+ const mid = Math.floor(w / 2);
20
+
21
+ const padLine = (t) => {
22
+ const gap = inner - strip(t).length - 1;
23
+ return m('│') + ' ' + t + ' '.repeat(Math.max(0, gap)) + m('│');
24
+ };
25
+
26
+ return {
27
+ w, mid, inner,
28
+ top: m('┌' + '─'.repeat(inner) + '┐'),
29
+ bot: m('└' + '─'.repeat(inner) + '┘'),
30
+ botC: m('└' + '─'.repeat(mid - 1) + '┬' + '─'.repeat(inner - mid) + '┘'),
31
+ lines: texts.map(padLine),
32
+ };
33
+ }
34
+
35
+ /** Build a horizontal connector line in a char array, then wrap with muted style. */
36
+ function hline(width, from, to, specs) {
37
+ const arr = Array(width).fill(' ');
38
+ for (let i = from; i <= to; i++) arr[i] = '─';
39
+ for (const [pos, ch] of specs) if (pos >= 0 && pos < width) arr[pos] = ch;
40
+ return theme.muted(arr.join('').trimEnd());
41
+ }
42
+
43
+ /** Place single characters at given positions, everything else is a space. */
44
+ function connector(width, specs) {
45
+ const arr = Array(width).fill(' ');
46
+ for (const [pos, ch] of specs) if (pos >= 0 && pos < width) arr[pos] = ch;
47
+ return theme.muted(arr.join('').trimEnd());
48
+ }
49
+
50
+ function renderArchitectureDiagram(cfg) {
51
+ const osLabel = cfg.serverless ? 'OpenSearch Serverless' : 'OpenSearch';
52
+ const pathLabel = `/${cfg.pipelineName}/v1/*`;
53
+ const m = theme.muted;
54
+ const a = theme.accent;
55
+ const p = theme.primary;
56
+ const h = theme.highlight;
57
+ const sp = (n) => ' '.repeat(Math.max(0, n));
58
+
59
+ // ── Define all boxes ──────────────────────────────────────────────────
60
+ const otlp = box([a('OSI Endpoint'), m(pathLabel)], 21);
61
+ const logs = box([h('Logs')], 9);
62
+ const traces = box([h('Traces')], 9);
63
+ const metrics = box([h('Metrics')], 9);
64
+ const raw = box([m('Raw'), m('Traces')], 9);
65
+ const svc = box([m('Service'), m('Map')], 9);
66
+ const os = box([p(osLabel), m('logs, traces, svc-map')]);
67
+ const prom = box([p('AWS Prometheus'), m('metrics, svc-map')]);
68
+ const dash = box([p('OpenSearch UI'), m('Observability workspace')]);
69
+
70
+ // ── Layout positions ──────────────────────────────────────────────────
71
+ const sigGap = 2;
72
+ const subGap = 1;
73
+ const sinkGap = 1;
74
+
75
+ // Signal row — three boxes side by side (column 0 origin)
76
+ const C_LOGS = logs.mid;
77
+ const C_TRACES = logs.w + sigGap + traces.mid;
78
+ const C_METRICS = logs.w + sigGap + traces.w + sigGap + metrics.mid;
79
+ const totalSigW = logs.w + sigGap + traces.w + sigGap + metrics.w;
80
+
81
+ // Sub-stage row — centered under Traces
82
+ const subTotalW = raw.w + subGap + svc.w;
83
+ const subOff = C_TRACES - Math.floor(subTotalW / 2);
84
+ const C_RAW = subOff + raw.mid;
85
+ const C_SVC = subOff + raw.w + subGap + svc.mid;
86
+
87
+ // OTLP — centered above Traces column
88
+ const otlpOff = Math.max(0, C_TRACES - otlp.mid);
89
+ const C_OTLP = otlpOff + otlp.mid;
90
+
91
+ // Service-map split — symmetric fork, left into OS box, right into Prom box
92
+ const C_SVC_L = C_SVC - 3;
93
+ const C_SVC_R = C_SVC + 3;
94
+
95
+ // Diagram width (widest row)
96
+ const W = Math.max(totalSigW, os.w + sinkGap + prom.w);
97
+
98
+ // ── Assemble lines ────────────────────────────────────────────────────
99
+ const out = [''];
100
+
101
+ // OTLP box
102
+ out.push(sp(otlpOff) + otlp.top);
103
+ for (const l of otlp.lines) out.push(sp(otlpOff) + l);
104
+ out.push(sp(otlpOff) + otlp.botC);
105
+
106
+ // Fan-out from OTLP to three signal columns
107
+ out.push(hline(W, C_LOGS, C_METRICS, [
108
+ [C_LOGS, '┌'], [C_OTLP, '┼'], [C_TRACES, C_TRACES === C_OTLP ? '┼' : '┬'], [C_METRICS, '┐'],
109
+ ]));
110
+ out.push(connector(W, [[C_LOGS, '▼'], [C_TRACES, '▼'], [C_METRICS, '▼']]));
111
+
112
+ // Signal boxes
113
+ out.push([logs, traces, metrics].map((b) => b.top).join(sp(sigGap)));
114
+ const sigRows = Math.max(logs.lines.length, traces.lines.length, metrics.lines.length);
115
+ for (let r = 0; r < sigRows; r++) {
116
+ out.push([logs, traces, metrics].map((b) => b.lines[r] || sp(b.w)).join(sp(sigGap)));
117
+ }
118
+ out.push([logs, traces, metrics].map((b) => b.botC).join(sp(sigGap)));
119
+
120
+ // Traces fans into Raw Traces + Service Map
121
+ out.push(hline(W, C_RAW, C_SVC, [
122
+ [C_LOGS, '│'], [C_RAW, '┌'], [C_TRACES, '┴'], [C_SVC, '┐'], [C_METRICS, '│'],
123
+ ]));
124
+ out.push(connector(W, [[C_LOGS, '│'], [C_RAW, '▼'], [C_SVC, '▼'], [C_METRICS, '│']]));
125
+
126
+ // Sub-stage boxes with Logs/Metrics pipes on either side
127
+ const subLine = (content) =>
128
+ sp(C_LOGS) + m('│') + sp(subOff - C_LOGS - 1) + content + sp(C_METRICS - subOff - subTotalW) + m('│');
129
+
130
+ out.push(subLine(raw.top + sp(subGap) + svc.top));
131
+ for (let r = 0; r < Math.max(raw.lines.length, svc.lines.length); r++) {
132
+ out.push(subLine((raw.lines[r] || sp(raw.w)) + sp(subGap) + (svc.lines[r] || sp(svc.w))));
133
+ }
134
+ out.push(subLine(raw.botC + sp(subGap) + svc.botC));
135
+
136
+ // Service-map split + merge toward sinks
137
+ out.push(hline(W, C_SVC_L, C_SVC_R, [
138
+ [C_LOGS, '│'], [C_RAW, '│'], [C_SVC_L, '┌'], [C_SVC, '┴'], [C_SVC_R, '┐'], [C_METRICS, '│'],
139
+ ]));
140
+ out.push(connector(W, [
141
+ [C_LOGS, '▼'], [C_RAW, '▼'], [C_SVC_L, '▼'], [C_SVC_R, '▼'], [C_METRICS, '▼'],
142
+ ]));
143
+
144
+ // Sink boxes
145
+ out.push(os.top + sp(sinkGap) + prom.top);
146
+ for (let r = 0; r < Math.max(os.lines.length, prom.lines.length); r++) {
147
+ out.push((os.lines[r] || sp(os.w)) + sp(sinkGap) + (prom.lines[r] || sp(prom.w)));
148
+ }
149
+ const C_PROM = os.w + sinkGap + prom.mid;
150
+ out.push(os.botC + sp(sinkGap) + prom.botC);
151
+
152
+ // Prometheus → DQS box
153
+ out.push(connector(os.w + sinkGap + prom.w, [[os.mid, '│'], [C_PROM, '│']]));
154
+ out.push(connector(os.w + sinkGap + prom.w, [[os.mid, '│'], [C_PROM, '▼']]));
155
+
156
+ const dqs = box([p('Direct Query Service')]);
157
+ const dqsOff = Math.max(0, C_PROM - dqs.mid);
158
+ const C_DQS = dqsOff + dqs.mid;
159
+ const osPipe = (rest) => sp(os.mid) + m('│') + sp(dqsOff - os.mid - 1) + rest;
160
+ out.push(osPipe(dqs.top));
161
+ for (const l of dqs.lines) out.push(osPipe(l));
162
+ out.push(osPipe(dqs.botC));
163
+
164
+ // Merge OpenSearch + DQS → Dashboards
165
+ out.push(hline(dqsOff + dqs.w, os.mid, C_DQS, [
166
+ [os.mid, '└'], [C_DQS, '┘'],
167
+ ]));
168
+ const mergeMid = Math.floor((os.mid + C_DQS) / 2);
169
+ out.push(connector(dqsOff + dqs.w, [[mergeMid, '│']]));
170
+ out.push(connector(dqsOff + dqs.w, [[mergeMid, '▼']]));
171
+
172
+ // Dashboards box — centered under merge point
173
+ const dashOff = Math.max(0, mergeMid - dash.mid);
174
+ out.push(sp(dashOff) + dash.top);
175
+ for (const l of dash.lines) out.push(sp(dashOff) + l);
176
+ out.push(sp(dashOff) + dash.bot);
177
+ out.push('');
178
+
179
+ return out;
180
+ }
7
181
 
8
182
  export async function runCreate(session) {
9
183
  console.error();
10
- printDivider();
11
-
12
184
  const cfg = await runCreateWizard(session);
185
+ if (cfg === GoBack) return GoBack;
13
186
 
14
187
  // Apply simple-mode defaults
15
188
  if (!cfg.mode) cfg.mode = 'simple';
@@ -22,22 +195,31 @@ export async function runCreate(session) {
22
195
  return;
23
196
  }
24
197
 
25
- // Dry-run path
26
- if (cfg.dryRun) {
27
- fillDryRunPlaceholders(cfg);
28
- const yaml = renderPipeline(cfg);
29
-
30
- if (cfg.outputFile) {
31
- writeFileSync(cfg.outputFile, yaml + '\n');
32
- printSuccess(`Pipeline YAML written to ${cfg.outputFile}`);
33
- } else {
34
- console.error(` ${theme.muted('\u2500'.repeat(43))}`);
35
- process.stdout.write(yaml);
36
- }
198
+ // ── Show architecture diagram ──────────────────────────────────────────
199
+ printStep('Architecture');
200
+ const diagram = renderArchitectureDiagram(cfg);
201
+ for (const line of diagram) console.error(` ${line}`);
202
+
203
+ // ── Confirm to proceed ─────────────────────────────────────────────────
204
+ console.error();
205
+ const proceed = await eConfirm({
206
+ message: 'Create these resources and deploy the stack?',
207
+ default: true,
208
+ });
209
+ if (proceed === GoBack || !proceed) {
210
+ console.error(` ${theme.muted('Cancelled.')}`);
37
211
  console.error();
38
212
  return;
39
213
  }
40
214
 
41
215
  // Live path
42
216
  await executePipeline(cfg);
217
+
218
+ // Offer to create demo EKS services
219
+ try {
220
+ await promptDemoAfterCreate(session, cfg.pipelineName);
221
+ } catch (err) {
222
+ // Demo is optional — don't fail the create flow
223
+ console.error(` ${theme.muted(`Demo setup skipped: ${err.message}`)}`);
224
+ }
43
225
  }
@@ -0,0 +1,193 @@
1
+ import {
2
+ printStep, printInfo, printBox, printWarning,
3
+ theme, GoBack, STAR, eSelect, eInput, eConfirm, createSpinner,
4
+ } from '../ui.mjs';
5
+ import { listPipelines, getPipelineEndpoint } from '../aws.mjs';
6
+ import { checkDemoPrerequisites, createEksCluster, installHelmChart, installOtelDemo } from '../eks.mjs';
7
+
8
+ const DEMO_DEFAULTS = {
9
+ clusterName: 'open-stack-demo',
10
+ nodeCount: 3,
11
+ instanceType: 'm8i.large',
12
+ };
13
+
14
+ /**
15
+ * Prompt user to select an OSI pipeline. Returns { pipelineName, otlpEndpoint } or GoBack.
16
+ */
17
+ async function selectPipeline(region) {
18
+ const spinner = createSpinner('Loading pipelines...');
19
+ spinner.start();
20
+
21
+ let pipelines;
22
+ try {
23
+ pipelines = await listPipelines(region);
24
+ spinner.succeed(`${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''} found`);
25
+ } catch (err) {
26
+ spinner.fail('Failed to list pipelines');
27
+ throw err;
28
+ }
29
+
30
+ const active = pipelines.filter((p) => p.status === 'ACTIVE');
31
+ if (active.length === 0) {
32
+ printWarning('No active OSI pipelines found. Create one first.');
33
+ return null;
34
+ }
35
+
36
+ const choice = await eSelect({
37
+ message: 'Select OSI pipeline to connect',
38
+ choices: active.map((p) => ({
39
+ name: `${theme.accent(p.name)} ${theme.muted(p.status)}`,
40
+ value: p.name,
41
+ })),
42
+ });
43
+ if (choice === GoBack) return GoBack;
44
+
45
+ const endpointSpinner = createSpinner('Getting pipeline endpoint...');
46
+ endpointSpinner.start();
47
+ try {
48
+ const endpoint = await getPipelineEndpoint(region, choice);
49
+ endpointSpinner.succeed(`Endpoint: ${endpoint}`);
50
+ return { pipelineName: choice, otlpEndpoint: endpoint };
51
+ } catch (err) {
52
+ endpointSpinner.fail('Failed to get pipeline endpoint');
53
+ throw err;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Run the demo services command — creates an EKS cluster and installs
59
+ * the observability stack Helm chart with demo applications.
60
+ */
61
+ export async function runDemo(session) {
62
+ console.error();
63
+ printStep('Install Demo');
64
+ printInfo('Creates an EKS cluster and installs the observability stack Helm chart with demo applications');
65
+ printInfo(`Default config: ${DEMO_DEFAULTS.nodeCount} x ${DEMO_DEFAULTS.instanceType} nodes`);
66
+ console.error();
67
+
68
+ // Collect cluster name
69
+ const clusterName = await eInput({
70
+ message: 'EKS cluster name',
71
+ default: DEMO_DEFAULTS.clusterName,
72
+ validate: (v) => v.trim().length > 0 || 'Cluster name is required',
73
+ });
74
+ if (clusterName === GoBack) return GoBack;
75
+
76
+ // Select OSI pipeline to connect
77
+ const pipeline = await selectPipeline(session.region);
78
+ if (pipeline === GoBack) return GoBack;
79
+ if (!pipeline) return;
80
+
81
+ const { nodeCount, instanceType } = DEMO_DEFAULTS;
82
+
83
+ // Confirm
84
+ console.error();
85
+ printInfo(`Cluster: ${theme.accent(clusterName)} | Nodes: ${theme.accent(String(nodeCount))} x ${theme.accent(instanceType)} | Region: ${theme.accent(session.region)}`);
86
+ printInfo(`Pipeline: ${theme.accent(pipeline.pipelineName)}`);
87
+ console.error();
88
+
89
+ const proceed = await eConfirm({
90
+ message: 'Create EKS cluster and install demo services?',
91
+ default: true,
92
+ });
93
+ if (proceed === GoBack || !proceed) {
94
+ console.error(` ${theme.muted('Cancelled.')}`);
95
+ console.error();
96
+ return;
97
+ }
98
+
99
+ // Execute
100
+ console.error();
101
+ checkDemoPrerequisites();
102
+ await createEksCluster({
103
+ clusterName,
104
+ region: session.region,
105
+ nodeCount,
106
+ instanceType,
107
+ stackName: pipeline.pipelineName,
108
+ accountId: session.accountId,
109
+ });
110
+ console.error();
111
+ await installHelmChart({ otlpEndpoint: pipeline.otlpEndpoint });
112
+ console.error();
113
+ await installOtelDemo({ otlpEndpoint: pipeline.otlpEndpoint });
114
+ console.error();
115
+
116
+ printBox([
117
+ '',
118
+ `${theme.success.bold(`${STAR} Demo Services Deployed! ${STAR}`)}`,
119
+ '',
120
+ `${theme.label('Cluster:')} ${clusterName}`,
121
+ `${theme.label('Region:')} ${session.region}`,
122
+ `${theme.label('Nodes:')} ${nodeCount} x ${instanceType}`,
123
+ `${theme.label('Pipeline:')} ${pipeline.pipelineName}`,
124
+ `${theme.label('Namespace:')} observability`,
125
+ `${theme.label('Release:')} obs-stack`,
126
+ '',
127
+ `${theme.muted('Check pods:')} kubectl get pods -n observability`,
128
+ `${theme.muted('OTel Demo pods:')} kubectl get pods -n otel-demo`,
129
+ `${theme.muted('Dashboards:')} kubectl port-forward svc/obs-stack-opensearch-dashboards 5601:5601 -n observability`,
130
+ `${theme.muted('Demo Frontend:')} kubectl port-forward svc/otel-demo-frontend-proxy 8080:8080 -n otel-demo`,
131
+ '',
132
+ ], { color: 'primary', padding: 2 });
133
+ }
134
+
135
+ /**
136
+ * Prompt the user to optionally create demo EKS services after stack creation.
137
+ * When called from the create flow, pipelineName is the pipeline just created.
138
+ * Returns true if demo was created, false if skipped.
139
+ */
140
+ export async function promptDemoAfterCreate(session, pipelineName) {
141
+ console.error();
142
+ const wantDemo = await eConfirm({
143
+ message: 'Would you like to install demo EKS services?',
144
+ default: false,
145
+ });
146
+
147
+ if (wantDemo === GoBack || !wantDemo) return false;
148
+
149
+ console.error();
150
+ const clusterName = await eInput({
151
+ message: 'EKS cluster name',
152
+ default: DEMO_DEFAULTS.clusterName,
153
+ validate: (v) => v.trim().length > 0 || 'Cluster name is required',
154
+ });
155
+ if (clusterName === GoBack) return false;
156
+
157
+ console.error();
158
+ checkDemoPrerequisites();
159
+
160
+ // Get the OTLP endpoint for the just-created pipeline
161
+ let otlpEndpoint;
162
+ try {
163
+ otlpEndpoint = await getPipelineEndpoint(session.region, pipelineName);
164
+ if (otlpEndpoint) {
165
+ printInfo(`Using pipeline: ${theme.accent(pipelineName)}`);
166
+ printInfo(`OTLP endpoint: ${theme.accent(otlpEndpoint)}`);
167
+ }
168
+ } catch {
169
+ printWarning('Could not get pipeline endpoint — Helm chart will use default config');
170
+ }
171
+
172
+ const { nodeCount, instanceType } = DEMO_DEFAULTS;
173
+
174
+ console.error();
175
+ await createEksCluster({
176
+ clusterName,
177
+ region: session.region,
178
+ nodeCount,
179
+ instanceType,
180
+ stackName: pipelineName,
181
+ accountId: session.accountId,
182
+ });
183
+ console.error();
184
+ await installHelmChart({ otlpEndpoint });
185
+ console.error();
186
+ await installOtelDemo({ otlpEndpoint });
187
+ console.error();
188
+
189
+ printInfo(`Demo services deployed. Check: kubectl get pods -n observability`);
190
+ printInfo(`OTel Demo pods: kubectl get pods -n otel-demo`);
191
+ printInfo(`Demo Frontend: kubectl port-forward svc/otel-demo-frontend-proxy 8080:8080 -n otel-demo`);
192
+ return true;
193
+ }
@@ -1,71 +1,81 @@
1
- import { select } from '@inquirer/prompts';
2
- import { getPipeline } from '../aws.mjs';
3
- import { printInfo, printPanel, printBox, createSpinner, colorStatus, formatDate, theme } from '../ui.mjs';
4
- import { loadPipelines } from './index.mjs';
1
+ import { getStackResources, arnToName, describeResource, enrichResourceNames } from '../aws.mjs';
2
+ import { printInfo, createSpinner, printPanel, theme, GoBack, eSelect } from '../ui.mjs';
3
+ import { loadStacks } from './index.mjs';
5
4
 
6
5
  export async function runDescribe(session) {
7
6
  console.error();
8
7
 
9
- const pipelines = await loadPipelines(session.region);
8
+ const stacks = await loadStacks(session.region);
10
9
 
11
- if (pipelines.length === 0) {
12
- printInfo('No OSI pipelines found in this region.');
10
+ if (stacks.length === 0) {
11
+ printInfo('No open-stack stacks found in this region.');
13
12
  console.error();
14
13
  return;
15
14
  }
16
15
 
17
- // Select a pipeline
18
- const choices = pipelines.map((p) => ({
19
- name: `${p.name} ${theme.muted(`(${p.status})`)}`,
20
- value: p.name,
16
+ // Select a stack
17
+ const choices = stacks.map((s) => ({
18
+ name: `${s.name} ${theme.muted(`(${s.resources.length} resources)`)}`,
19
+ value: s.name,
21
20
  }));
22
21
 
23
- const pipelineName = await select({
24
- message: 'Select pipeline',
22
+ const stackName = await eSelect({
23
+ message: 'Select stack',
25
24
  choices,
26
25
  });
26
+ if (stackName === GoBack) return GoBack;
27
27
 
28
- // Fetch full details
29
- const detailSpinner = createSpinner(`Loading ${pipelineName}...`);
28
+ // Fetch full resource list
29
+ const detailSpinner = createSpinner(`Loading ${stackName}...`);
30
30
  detailSpinner.start();
31
31
 
32
- let pipeline;
32
+ let resources;
33
33
  try {
34
- pipeline = await getPipeline(session.region, pipelineName);
35
- detailSpinner.succeed(`Pipeline: ${pipelineName}`);
34
+ resources = await getStackResources(session.region, stackName);
35
+ await enrichResourceNames(session.region, resources);
36
+ detailSpinner.succeed(`Stack: ${stackName} (${resources.length} resources)`);
36
37
  } catch (err) {
37
- detailSpinner.fail('Failed to get pipeline details');
38
+ detailSpinner.fail('Failed to get stack details');
38
39
  throw err;
39
40
  }
40
41
 
41
- // Display details in a panel
42
- console.error();
43
- const entries = [
44
- ['Name', pipeline.name],
45
- ['ARN', theme.muted(pipeline.arn)],
46
- ['Status', colorStatus(pipeline.status)],
47
- ];
48
- if (pipeline.statusReason) {
49
- entries.push(['Status Reason', pipeline.statusReason]);
50
- }
51
- entries.push(
52
- ['Min OCUs', String(pipeline.minUnits)],
53
- ['Max OCUs', String(pipeline.maxUnits)],
54
- ['Created', formatDate(pipeline.createdAt)],
55
- ['Last Updated', formatDate(pipeline.lastUpdatedAt)],
56
- );
57
- printPanel(pipelineName, entries);
42
+ const resName = (r) => r.displayName || arnToName(r.arn);
58
43
 
59
- if (pipeline.ingestEndpoints.length > 0) {
44
+ // Resource selection loop
45
+ while (true) {
60
46
  console.error();
61
- const epLines = pipeline.ingestEndpoints.map((ep) => theme.accent(ep));
62
- printBox(['', ...epLines, ''], { title: 'Ingestion Endpoints', color: 'dim', padding: 2 });
63
- }
47
+ const resourceChoices = resources.map((r) => ({
48
+ name: `${theme.accentBold(r.type)} ${theme.muted(resName(r))}`,
49
+ value: r,
50
+ }));
51
+
52
+ const selected = await eSelect({
53
+ message: 'Select resource',
54
+ choices: resourceChoices,
55
+ });
56
+ if (selected === GoBack) break;
57
+
58
+ // Fetch and display resource details
59
+ const resSpinner = createSpinner(`Loading ${selected.type}...`);
60
+ resSpinner.start();
61
+
62
+ let result;
63
+ try {
64
+ result = await describeResource(session.region, selected);
65
+ resSpinner.succeed(`${selected.type}: ${resName(selected)}`);
66
+ } catch (err) {
67
+ resSpinner.fail(`Failed to describe ${selected.type}`);
68
+ continue;
69
+ }
64
70
 
65
- if (pipeline.pipelineConfigurationBody) {
66
71
  console.error();
67
- const configLines = pipeline.pipelineConfigurationBody.split('\n').map((l) => theme.muted(l));
68
- printBox(['', ...configLines, ''], { title: 'Pipeline Configuration', color: 'dim', padding: 1 });
72
+ const panelEntries = result.entries.map(([label, value]) => [label, theme.muted(value)]);
73
+ printPanel(resName(selected), panelEntries);
74
+
75
+ if (result.rawConfig) {
76
+ console.error();
77
+ console.error(theme.muted(result.rawConfig));
78
+ }
69
79
  }
70
80
 
71
81
  console.error();