@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
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
|
+
}
|