@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/src/cli.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import { Command } from 'commander';
2
+ import { DEFAULTS } from './config.mjs';
3
+
4
+ /**
5
+ * Parse CLI arguments into a config object.
6
+ * Returns null when no flags were given (triggers interactive mode).
7
+ */
8
+ export function parseCli(argv) {
9
+ const program = new Command()
10
+ .name('open-stack')
11
+ .description(
12
+ 'Create AWS resources and an OpenSearch Ingestion (OSI) pipeline\n' +
13
+ 'for the full Observability Stack: logs, traces, metrics, service map.'
14
+ )
15
+ .version('1.0.0');
16
+
17
+ // Mode
18
+ program
19
+ .option('--simple', 'Minimal input — auto-creates all resources with defaults')
20
+ .option('--advanced', 'More options — create new or reuse existing resources');
21
+
22
+ // Core
23
+ program
24
+ .option('--pipeline-name <name>', 'Pipeline name and resource prefix', DEFAULTS.pipelineName)
25
+ .option('--region <region>', 'AWS region (e.g. us-east-1)');
26
+
27
+ // OpenSearch — reuse
28
+ program
29
+ .option('--opensearch-endpoint <url>', 'Reuse an existing OpenSearch endpoint');
30
+ // OpenSearch — create
31
+ program
32
+ .option('--os-domain-name <name>', 'Domain name for new OpenSearch domain')
33
+ .option('--os-instance-type <type>', 'Instance type', DEFAULTS.osInstanceType)
34
+ .option('--os-instance-count <n>', 'Number of data nodes', DEFAULTS.osInstanceCount)
35
+ .option('--os-volume-size <gb>', 'EBS volume size in GB', DEFAULTS.osVolumeSize)
36
+ .option('--os-engine-version <ver>', 'Engine version', DEFAULTS.osEngineVersion)
37
+ .option('--serverless', 'Target is OpenSearch Serverless (default in simple mode)')
38
+ .option('--managed', 'Target is OpenSearch managed domain');
39
+
40
+ // IAM
41
+ program
42
+ .option('--iam-role-arn <arn>', 'Reuse an existing IAM role')
43
+ .option('--iam-role-name <name>', 'Name for new IAM role');
44
+
45
+ // APS
46
+ program
47
+ .option('--prometheus-url <url>', 'Reuse an existing APS remote-write URL')
48
+ .option('--aps-workspace-alias <name>', 'Alias for new APS workspace');
49
+
50
+ // Pipeline tuning
51
+ program
52
+ .option('--min-ocu <n>', 'Minimum OCUs', DEFAULTS.minOcu)
53
+ .option('--max-ocu <n>', 'Maximum OCUs', DEFAULTS.maxOcu)
54
+ .option('--service-map-window <dur>', 'Service-map window duration', DEFAULTS.serviceMapWindow);
55
+
56
+ // Output
57
+ program
58
+ .option('-o, --output <file>', 'Write pipeline YAML to file instead of stdout')
59
+ .option('--dry-run', 'Generate config only; do not create AWS resources');
60
+
61
+ program.parse(argv);
62
+ const opts = program.opts();
63
+
64
+ // If no meaningful flags were provided, return null → interactive
65
+ const userArgs = argv.slice(2);
66
+ if (userArgs.length === 0) return null;
67
+
68
+ return optsToConfig(opts);
69
+ }
70
+
71
+ /**
72
+ * Convert commander opts to our normalized config shape.
73
+ */
74
+ function optsToConfig(opts) {
75
+ const mode = opts.advanced ? 'advanced' : 'simple';
76
+
77
+ // Determine actions based on which flags were provided
78
+ let osAction = '';
79
+ if (opts.opensearchEndpoint) osAction = 'reuse';
80
+ else if (opts.osDomainName) osAction = 'create';
81
+
82
+ let iamAction = '';
83
+ if (opts.iamRoleArn) iamAction = 'reuse';
84
+ else if (opts.iamRoleName) iamAction = 'create';
85
+
86
+ let apsAction = '';
87
+ if (opts.prometheusUrl) apsAction = 'reuse';
88
+ else if (opts.apsWorkspaceAlias) apsAction = 'create';
89
+
90
+ // Auto-detect serverless from endpoint URL when not explicitly set
91
+ if (opts.serverless && opts.managed) {
92
+ throw new Error('--serverless and --managed are mutually exclusive');
93
+ }
94
+
95
+ let serverless;
96
+ if (opts.managed) {
97
+ serverless = false;
98
+ } else if (opts.serverless) {
99
+ serverless = true;
100
+ } else if (opts.opensearchEndpoint && /\.aoss\.amazonaws\.com/i.test(opts.opensearchEndpoint)) {
101
+ serverless = true;
102
+ } else if (opts.opensearchEndpoint) {
103
+ serverless = false;
104
+ } else {
105
+ serverless = null; // let applySimpleDefaults decide
106
+ }
107
+
108
+ return {
109
+ mode,
110
+ pipelineName: opts.pipelineName,
111
+ region: opts.region || '',
112
+ osAction,
113
+ opensearchEndpoint: opts.opensearchEndpoint || '',
114
+ osDomainName: opts.osDomainName || '',
115
+ osInstanceType: opts.osInstanceType,
116
+ osInstanceCount: Number(opts.osInstanceCount),
117
+ osVolumeSize: Number(opts.osVolumeSize),
118
+ osEngineVersion: opts.osEngineVersion,
119
+ serverless,
120
+ iamAction,
121
+ iamRoleArn: opts.iamRoleArn || '',
122
+ iamRoleName: opts.iamRoleName || '',
123
+ apsAction,
124
+ prometheusUrl: opts.prometheusUrl || '',
125
+ apsWorkspaceAlias: opts.apsWorkspaceAlias || '',
126
+ apsWorkspaceId: '',
127
+ minOcu: Number(opts.minOcu),
128
+ maxOcu: Number(opts.maxOcu),
129
+ serviceMapWindow: opts.serviceMapWindow,
130
+ outputFile: opts.output || '',
131
+ dryRun: opts.dryRun || false,
132
+ accountId: '',
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Apply simple-mode defaults: fill in blanks so every field has a value.
138
+ */
139
+ export function applySimpleDefaults(cfg) {
140
+ if (!cfg.osAction) cfg.osAction = 'create';
141
+ if (!cfg.osDomainName) cfg.osDomainName = cfg.pipelineName;
142
+ if (cfg.serverless === null) cfg.serverless = true;
143
+ if (!cfg.iamAction) cfg.iamAction = 'create';
144
+ if (!cfg.iamRoleName) cfg.iamRoleName = `${cfg.pipelineName}-osi-role`;
145
+ if (!cfg.apsAction) cfg.apsAction = 'create';
146
+ if (!cfg.apsWorkspaceAlias) cfg.apsWorkspaceAlias = cfg.pipelineName;
147
+ }
148
+
149
+ /**
150
+ * Fill in placeholder values for resources that would be created in dry-run mode.
151
+ */
152
+ export function fillDryRunPlaceholders(cfg) {
153
+ if (cfg.osAction === 'create' && !cfg.opensearchEndpoint) {
154
+ cfg.opensearchEndpoint = cfg.serverless
155
+ ? `https://<collection-id>.${cfg.region}.aoss.amazonaws.com`
156
+ : `https://search-${cfg.osDomainName}.${cfg.region}.es.amazonaws.com`;
157
+ }
158
+ if (cfg.iamAction === 'create' && !cfg.iamRoleArn) {
159
+ cfg.iamRoleArn = `arn:aws:iam::${cfg.accountId || '123456789012'}:role/${cfg.iamRoleName}`;
160
+ }
161
+ if (cfg.apsAction === 'create' && !cfg.prometheusUrl) {
162
+ cfg.prometheusUrl = `https://aps-workspaces.${cfg.region}.amazonaws.com/workspaces/<workspace-id>/api/v1/remote_write`;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Validate the config. Returns an array of error strings (empty = valid).
168
+ */
169
+ export function validateConfig(cfg) {
170
+ const errors = [];
171
+
172
+ if (!cfg.pipelineName) errors.push('--pipeline-name is required');
173
+ if (!cfg.region) errors.push('--region is required');
174
+
175
+ if (cfg.osAction === 'reuse' && !cfg.opensearchEndpoint) {
176
+ errors.push('--opensearch-endpoint required when reusing OpenSearch');
177
+ }
178
+ if (cfg.iamAction === 'reuse' && !cfg.iamRoleArn) {
179
+ errors.push('--iam-role-arn required when reusing IAM role');
180
+ }
181
+ if (cfg.apsAction === 'reuse' && !cfg.prometheusUrl) {
182
+ errors.push('--prometheus-url required when reusing APS workspace');
183
+ }
184
+
185
+ // Format checks
186
+ if (cfg.region && !/^[a-z]{2}-[a-z]+-\d+$/.test(cfg.region)) {
187
+ errors.push(`Region format looks wrong: ${cfg.region} (expected e.g. us-east-1)`);
188
+ }
189
+ if (cfg.osAction === 'reuse' && cfg.opensearchEndpoint && !/^https?:\/\//.test(cfg.opensearchEndpoint)) {
190
+ errors.push('OpenSearch endpoint must start with http:// or https://');
191
+ }
192
+ if (cfg.iamAction === 'reuse' && cfg.iamRoleArn && !cfg.iamRoleArn.startsWith('arn:aws:iam:')) {
193
+ errors.push('IAM role ARN must start with arn:aws:iam:');
194
+ }
195
+ if (cfg.apsAction === 'reuse' && cfg.prometheusUrl && !/^https?:\/\//.test(cfg.prometheusUrl)) {
196
+ errors.push('Prometheus URL must start with http:// or https://');
197
+ }
198
+
199
+ return errors;
200
+ }
@@ -0,0 +1,43 @@
1
+ import { runCreateWizard } from '../interactive.mjs';
2
+ import { applySimpleDefaults, validateConfig, fillDryRunPlaceholders } from '../cli.mjs';
3
+ import { renderPipeline } from '../render.mjs';
4
+ import { executePipeline } from '../main.mjs';
5
+ import { printError, printSuccess, printDivider, theme } from '../ui.mjs';
6
+ import { writeFileSync } from 'node:fs';
7
+
8
+ export async function runCreate(session) {
9
+ console.error();
10
+ printDivider();
11
+
12
+ const cfg = await runCreateWizard(session);
13
+
14
+ // Apply simple-mode defaults
15
+ if (!cfg.mode) cfg.mode = 'simple';
16
+ if (cfg.mode === 'simple') applySimpleDefaults(cfg);
17
+
18
+ // Validate
19
+ const errors = validateConfig(cfg);
20
+ if (errors.length) {
21
+ for (const e of errors) printError(e);
22
+ return;
23
+ }
24
+
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
+ }
37
+ console.error();
38
+ return;
39
+ }
40
+
41
+ // Live path
42
+ await executePipeline(cfg);
43
+ }
@@ -0,0 +1,72 @@
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';
5
+
6
+ export async function runDescribe(session) {
7
+ console.error();
8
+
9
+ const pipelines = await loadPipelines(session.region);
10
+
11
+ if (pipelines.length === 0) {
12
+ printInfo('No OSI pipelines found in this region.');
13
+ console.error();
14
+ return;
15
+ }
16
+
17
+ // Select a pipeline
18
+ const choices = pipelines.map((p) => ({
19
+ name: `${p.name} ${theme.muted(`(${p.status})`)}`,
20
+ value: p.name,
21
+ }));
22
+
23
+ const pipelineName = await select({
24
+ message: 'Select pipeline',
25
+ choices,
26
+ });
27
+
28
+ // Fetch full details
29
+ const detailSpinner = createSpinner(`Loading ${pipelineName}...`);
30
+ detailSpinner.start();
31
+
32
+ let pipeline;
33
+ try {
34
+ pipeline = await getPipeline(session.region, pipelineName);
35
+ detailSpinner.succeed(`Pipeline: ${pipelineName}`);
36
+ } catch (err) {
37
+ detailSpinner.fail('Failed to get pipeline details');
38
+ throw err;
39
+ }
40
+
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);
58
+
59
+ if (pipeline.ingestEndpoints.length > 0) {
60
+ console.error();
61
+ const epLines = pipeline.ingestEndpoints.map((ep) => theme.accent(ep));
62
+ printBox(['', ...epLines, ''], { title: 'Ingestion Endpoints', color: 'dim', padding: 2 });
63
+ }
64
+
65
+ if (pipeline.pipelineConfigurationBody) {
66
+ console.error();
67
+ const configLines = pipeline.pipelineConfigurationBody.split('\n').map((l) => theme.muted(l));
68
+ printBox(['', ...configLines, ''], { title: 'Pipeline Configuration', color: 'dim', padding: 1 });
69
+ }
70
+
71
+ console.error();
72
+ }
@@ -0,0 +1,19 @@
1
+ import { printBox, printKeyHint, theme } from '../ui.mjs';
2
+
3
+ export async function runHelp() {
4
+ console.error();
5
+ const lines = [
6
+ '',
7
+ `${theme.accentBold('create')} Create a new OSI pipeline (interactive wizard)`,
8
+ `${theme.accentBold('list')} List existing OSI pipelines`,
9
+ `${theme.accentBold('describe')} Show details of a specific pipeline`,
10
+ `${theme.accentBold('update')} Update an existing pipeline's settings`,
11
+ `${theme.accentBold('help')} Show this help message`,
12
+ `${theme.accentBold('quit')} Exit the pipeline manager`,
13
+ '',
14
+ ];
15
+ printBox(lines, { title: 'Commands', color: 'dim', padding: 2 });
16
+ console.error();
17
+ printKeyHint([['Ctrl+C', 'cancel any operation and return to menu']]);
18
+ console.error();
19
+ }
@@ -0,0 +1,40 @@
1
+ import { runCreate } from './create.mjs';
2
+ import { runList } from './list.mjs';
3
+ import { runDescribe } from './describe.mjs';
4
+ import { runUpdate } from './update.mjs';
5
+ import { runHelp } from './help.mjs';
6
+ import { listPipelines } from '../aws.mjs';
7
+ import { createSpinner, theme } from '../ui.mjs';
8
+
9
+ /**
10
+ * Load pipelines with a spinner. Shared by describe, list, and update commands.
11
+ */
12
+ export async function loadPipelines(region) {
13
+ const spinner = createSpinner('Loading pipelines...');
14
+ spinner.start();
15
+ try {
16
+ const pipelines = await listPipelines(region);
17
+ spinner.succeed(`${pipelines.length} pipeline${pipelines.length !== 1 ? 's' : ''} found`);
18
+ return pipelines;
19
+ } catch (err) {
20
+ spinner.fail('Failed to list pipelines');
21
+ throw err;
22
+ }
23
+ }
24
+
25
+ export const COMMANDS = {
26
+ create: runCreate,
27
+ list: runList,
28
+ describe: runDescribe,
29
+ update: runUpdate,
30
+ help: runHelp,
31
+ };
32
+
33
+ export const COMMAND_CHOICES = [
34
+ { name: `\u2728 Create ${theme.muted('Create a new OSI pipeline')}`, value: 'create' },
35
+ { name: `\u2630 List ${theme.muted('List existing pipelines')}`, value: 'list' },
36
+ { name: `\uD83D\uDD0D Describe ${theme.muted('Show details of a pipeline')}`, value: 'describe' },
37
+ { name: `\u270E Update ${theme.muted('Update a pipeline')}`, value: 'update' },
38
+ { name: `\u2753 Help ${theme.muted('Show available commands')}`, value: 'help' },
39
+ { name: `\uD83D\uDEAA Quit ${theme.muted('Exit')}`, value: 'quit' },
40
+ ];
@@ -0,0 +1,26 @@
1
+ import { printTable, printInfo, colorStatus, formatDate } from '../ui.mjs';
2
+ import { loadPipelines } from './index.mjs';
3
+
4
+ export async function runList(session) {
5
+ console.error();
6
+
7
+ const pipelines = await loadPipelines(session.region);
8
+
9
+ if (pipelines.length === 0) {
10
+ printInfo('No OSI pipelines found in this region.');
11
+ console.error();
12
+ return;
13
+ }
14
+
15
+ console.error();
16
+ const headers = ['Name', 'Status', 'OCUs', 'Created', 'Updated'];
17
+ const rows = pipelines.map((p) => [
18
+ p.name,
19
+ colorStatus(p.status),
20
+ `${p.minUnits}\u2013${p.maxUnits}`,
21
+ formatDate(p.createdAt),
22
+ formatDate(p.lastUpdatedAt),
23
+ ]);
24
+
25
+ printTable(headers, rows);
26
+ }