@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/aws.mjs ADDED
@@ -0,0 +1,824 @@
1
+ import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
2
+ import {
3
+ OpenSearchClient,
4
+ CreateDomainCommand,
5
+ DescribeDomainCommand,
6
+ ListDomainNamesCommand,
7
+ DescribeDomainsCommand,
8
+ } from '@aws-sdk/client-opensearch';
9
+ import {
10
+ OpenSearchServerlessClient,
11
+ ListCollectionsCommand as ListServerlessCollectionsCommand,
12
+ CreateCollectionCommand,
13
+ BatchGetCollectionCommand,
14
+ CreateSecurityPolicyCommand,
15
+ CreateAccessPolicyCommand,
16
+ } from '@aws-sdk/client-opensearchserverless';
17
+ import {
18
+ IAMClient,
19
+ GetRoleCommand,
20
+ CreateRoleCommand,
21
+ PutRolePolicyCommand,
22
+ ListRolesCommand,
23
+ } from '@aws-sdk/client-iam';
24
+ import {
25
+ AmpClient,
26
+ ListWorkspacesCommand,
27
+ CreateWorkspaceCommand,
28
+ } from '@aws-sdk/client-amp';
29
+ import {
30
+ OSISClient,
31
+ ListPipelinesCommand,
32
+ CreatePipelineCommand,
33
+ GetPipelineCommand,
34
+ UpdatePipelineCommand,
35
+ } from '@aws-sdk/client-osis';
36
+ import {
37
+ printStep,
38
+ printSuccess,
39
+ printError,
40
+ printWarning,
41
+ printInfo,
42
+ createSpinner,
43
+ } from './ui.mjs';
44
+ import chalk from 'chalk';
45
+
46
+ // ── Prerequisites ───────────────────────────────────────────────────────────
47
+
48
+ export async function checkRequirements(cfg) {
49
+ printStep('Checking prerequisites...');
50
+ console.error();
51
+
52
+ // 1. Credentials + account ID
53
+ const sts = new STSClient({ region: cfg.region });
54
+ let identity;
55
+ try {
56
+ identity = await sts.send(new GetCallerIdentityCommand({}));
57
+ } catch (err) {
58
+ printError('AWS credentials are not configured or have expired');
59
+ console.error();
60
+ if (/unable to locate credentials|no credentials/i.test(err.message)) {
61
+ console.error(` ${chalk.bold('No credentials found. Set up AWS access:')}`);
62
+ console.error();
63
+ console.error(` ${chalk.dim('Option A — Configure long-term credentials:')}`);
64
+ console.error(` ${chalk.bold('aws configure')}`);
65
+ console.error();
66
+ console.error(` ${chalk.dim('Option B — Use IAM Identity Center (SSO):')}`);
67
+ console.error(` ${chalk.bold('aws configure sso')}`);
68
+ console.error(` ${chalk.bold('aws sso login --profile <your-profile>')}`);
69
+ console.error();
70
+ console.error(` ${chalk.dim('Option C — Export temporary credentials:')}`);
71
+ console.error(` ${chalk.bold('export AWS_ACCESS_KEY_ID=<key>')}`);
72
+ console.error(` ${chalk.bold('export AWS_SECRET_ACCESS_KEY=<secret>')}`);
73
+ console.error(` ${chalk.bold('export AWS_SESSION_TOKEN=<token>')} ${chalk.dim('(if using temporary creds)')}`);
74
+ } else if (/expired|ExpiredToken/i.test(err.message)) {
75
+ console.error(` ${chalk.bold('Your session has expired. Refresh credentials:')}`);
76
+ console.error();
77
+ console.error(` ${chalk.dim('If using SSO:')} ${chalk.bold('aws sso login')}`);
78
+ console.error(` ${chalk.dim('If using profiles:')} ${chalk.bold('aws sts get-session-token')}`);
79
+ console.error(` ${chalk.dim('If using assume-role:')} re-run your assume-role command`);
80
+ } else {
81
+ console.error(` ${chalk.bold('Authentication failed:')}`);
82
+ console.error(` ${chalk.dim(err.message)}`);
83
+ console.error();
84
+ console.error(` ${chalk.bold('Try:')}`);
85
+ console.error(` ${chalk.bold('aws configure')} ${chalk.dim('— set up credentials')}`);
86
+ console.error(` ${chalk.bold('aws sso login')} ${chalk.dim('— refresh SSO session')}`);
87
+ }
88
+ console.error();
89
+ throw new Error('AWS credentials are not configured or have expired');
90
+ }
91
+
92
+ cfg.accountId = identity.Account;
93
+ printSuccess(`Authenticated — account ${cfg.accountId}`);
94
+ printInfo(`Identity: ${identity.Arn}`);
95
+
96
+ // 2. Quick OSIS access check
97
+ const osis = new OSISClient({ region: cfg.region });
98
+ try {
99
+ await osis.send(new ListPipelinesCommand({ MaxResults: 1 }));
100
+ printSuccess(`OSI service accessible in ${cfg.region}`);
101
+ } catch {
102
+ printWarning(`Cannot list OSI pipelines in ${cfg.region} — you may lack osis:* permissions`);
103
+ printInfo('The script will attempt to proceed, but resource creation may fail.');
104
+ printInfo('Required IAM actions: es:*, iam:CreateRole, iam:PutRolePolicy, aps:*, osis:*');
105
+ }
106
+
107
+ console.error();
108
+ }
109
+
110
+ // ── OpenSearch (managed domain OR serverless collection) ────────────────────
111
+
112
+ export async function createOpenSearch(cfg) {
113
+ if (cfg.serverless) {
114
+ return createServerlessCollection(cfg);
115
+ }
116
+ return createManagedDomain(cfg);
117
+ }
118
+
119
+ async function createServerlessCollection(cfg) {
120
+ const collectionName = cfg.osDomainName;
121
+ printStep(`Creating OpenSearch Serverless collection '${collectionName}'...`);
122
+ console.error();
123
+
124
+ const client = new OpenSearchServerlessClient({ region: cfg.region });
125
+
126
+ // Check if collection already exists
127
+ try {
128
+ const resp = await client.send(new BatchGetCollectionCommand({ names: [collectionName] }));
129
+ const existing = resp.collectionDetails?.[0];
130
+ if (existing) {
131
+ if (existing.collectionEndpoint) {
132
+ cfg.opensearchEndpoint = existing.collectionEndpoint;
133
+ printSuccess(`Collection '${collectionName}' already exists: ${cfg.opensearchEndpoint}`);
134
+ return;
135
+ }
136
+ printSuccess(`Collection '${collectionName}' already exists — waiting for endpoint`);
137
+ }
138
+ } catch { /* not found — proceed to create */ }
139
+
140
+ // Create required security policies before the collection
141
+ if (!cfg.opensearchEndpoint) {
142
+ await ensureServerlessPolicies(client, collectionName, cfg);
143
+
144
+ try {
145
+ await client.send(new CreateCollectionCommand({
146
+ name: collectionName,
147
+ type: 'TIMESERIES',
148
+ }));
149
+ printSuccess('Collection creation initiated — waiting for endpoint');
150
+ } catch (err) {
151
+ if (err.name !== 'ConflictException') {
152
+ printError('Failed to create OpenSearch Serverless collection');
153
+ console.error();
154
+ if (/AccessDenied|not authorized/i.test(err.message)) {
155
+ console.error(` ${chalk.bold('Permission denied.')} Your IAM identity needs ${chalk.bold('aoss:CreateCollection')}.`);
156
+ } else {
157
+ console.error(` ${chalk.dim(err.message)}`);
158
+ }
159
+ console.error();
160
+ throw new Error('Failed to create OpenSearch Serverless collection');
161
+ }
162
+ }
163
+ }
164
+
165
+ // Poll for endpoint
166
+ const spinner = createSpinner('Provisioning Serverless collection...');
167
+ spinner.start();
168
+ const maxWait = 300_000; // 5 min (serverless is faster than managed)
169
+ const start = Date.now();
170
+
171
+ while (Date.now() - start < maxWait) {
172
+ try {
173
+ const resp = await client.send(new BatchGetCollectionCommand({ names: [collectionName] }));
174
+ const coll = resp.collectionDetails?.[0];
175
+ if (coll?.collectionEndpoint) {
176
+ cfg.opensearchEndpoint = coll.collectionEndpoint;
177
+ spinner.succeed(`Collection ready: ${cfg.opensearchEndpoint}`);
178
+ return;
179
+ }
180
+ if (coll?.status === 'FAILED') {
181
+ spinner.fail('Collection creation failed');
182
+ printInfo(`Status: ${coll.status}`);
183
+ throw new Error('OpenSearch Serverless collection creation failed');
184
+ }
185
+ } catch { /* keep polling */ }
186
+ await sleepWithTicker(10_000, spinner, start,
187
+ (s) => `Provisioning Serverless collection... (${fmtElapsed(s)} elapsed)`);
188
+ }
189
+
190
+ spinner.fail('Timed out waiting for Serverless collection');
191
+ throw new Error('Timed out waiting for Serverless collection');
192
+ }
193
+
194
+ /**
195
+ * Create encryption, network, and data-access policies required by Serverless.
196
+ * Each policy is idempotent — ConflictException (already exists) is ignored.
197
+ */
198
+ async function ensureServerlessPolicies(client, collectionName, cfg) {
199
+ const policyName = `${collectionName}-policy`;
200
+
201
+ // 1. Encryption policy (required before collection creation)
202
+ try {
203
+ await client.send(new CreateSecurityPolicyCommand({
204
+ name: `${collectionName}-enc`,
205
+ type: 'encryption',
206
+ policy: JSON.stringify({
207
+ Rules: [{ ResourceType: 'collection', Resource: [`collection/${collectionName}`] }],
208
+ AWSOwnedKey: true,
209
+ }),
210
+ }));
211
+ printSuccess('Encryption policy created');
212
+ } catch (err) {
213
+ if (err.name !== 'ConflictException') throw err;
214
+ printSuccess('Encryption policy already exists');
215
+ }
216
+
217
+ // 2. Network policy (public access for simplicity — matches managed domain defaults)
218
+ try {
219
+ await client.send(new CreateSecurityPolicyCommand({
220
+ name: `${collectionName}-net`,
221
+ type: 'network',
222
+ policy: JSON.stringify([{
223
+ Rules: [
224
+ { ResourceType: 'collection', Resource: [`collection/${collectionName}`] },
225
+ { ResourceType: 'dashboard', Resource: [`collection/${collectionName}`] },
226
+ ],
227
+ AllowFromPublic: true,
228
+ }]),
229
+ }));
230
+ printSuccess('Network policy created');
231
+ } catch (err) {
232
+ if (err.name !== 'ConflictException') throw err;
233
+ printSuccess('Network policy already exists');
234
+ }
235
+
236
+ // 3. Data access policy — grant the OSI pipeline role + current caller access
237
+ const principals = [`arn:aws:iam::${cfg.accountId}:root`];
238
+ if (cfg.iamRoleArn) {
239
+ principals.push(cfg.iamRoleArn);
240
+ } else if (cfg.iamRoleName) {
241
+ principals.push(`arn:aws:iam::${cfg.accountId}:role/${cfg.iamRoleName}`);
242
+ }
243
+
244
+ try {
245
+ await client.send(new CreateAccessPolicyCommand({
246
+ name: policyName,
247
+ type: 'data',
248
+ policy: JSON.stringify([{
249
+ Rules: [
250
+ {
251
+ ResourceType: 'index',
252
+ Resource: [`index/${collectionName}/*`],
253
+ Permission: ['aoss:CreateIndex', 'aoss:UpdateIndex', 'aoss:DescribeIndex', 'aoss:ReadDocument', 'aoss:WriteDocument'],
254
+ },
255
+ {
256
+ ResourceType: 'collection',
257
+ Resource: [`collection/${collectionName}`],
258
+ Permission: ['aoss:CreateCollectionItems', 'aoss:UpdateCollectionItems', 'aoss:DescribeCollectionItems'],
259
+ },
260
+ ],
261
+ Principal: principals,
262
+ }]),
263
+ }));
264
+ printSuccess('Data access policy created');
265
+ } catch (err) {
266
+ if (err.name !== 'ConflictException') throw err;
267
+ printSuccess('Data access policy already exists');
268
+ }
269
+ }
270
+
271
+ async function createManagedDomain(cfg) {
272
+ printStep(`Creating OpenSearch domain '${cfg.osDomainName}'...`);
273
+ console.error();
274
+
275
+ const client = new OpenSearchClient({ region: cfg.region });
276
+
277
+ // Check if domain already exists
278
+ try {
279
+ const desc = await client.send(new DescribeDomainCommand({ DomainName: cfg.osDomainName }));
280
+ const endpoint = desc.DomainStatus?.Endpoint;
281
+ if (endpoint) {
282
+ cfg.opensearchEndpoint = `https://${endpoint}`;
283
+ printSuccess(`Domain '${cfg.osDomainName}' already exists: ${cfg.opensearchEndpoint}`);
284
+ return;
285
+ }
286
+ printSuccess(`Domain '${cfg.osDomainName}' already exists — waiting for endpoint`);
287
+ } catch (err) {
288
+ if (err.name !== 'ResourceNotFoundException') throw err;
289
+
290
+ const accessPolicy = JSON.stringify({
291
+ Version: '2012-10-17',
292
+ Statement: [{
293
+ Effect: 'Allow',
294
+ Principal: { AWS: '*' },
295
+ Action: 'es:*',
296
+ Resource: `arn:aws:es:${cfg.region}:${cfg.accountId}:domain/${cfg.osDomainName}/*`,
297
+ }],
298
+ });
299
+
300
+ try {
301
+ await client.send(new CreateDomainCommand({
302
+ DomainName: cfg.osDomainName,
303
+ EngineVersion: cfg.osEngineVersion,
304
+ ClusterConfig: {
305
+ InstanceType: cfg.osInstanceType,
306
+ InstanceCount: cfg.osInstanceCount,
307
+ },
308
+ EBSOptions: {
309
+ EBSEnabled: true,
310
+ VolumeType: 'gp3',
311
+ VolumeSize: cfg.osVolumeSize,
312
+ },
313
+ NodeToNodeEncryptionOptions: { Enabled: true },
314
+ EncryptionAtRestOptions: { Enabled: true },
315
+ DomainEndpointOptions: { EnforceHTTPS: true },
316
+ AdvancedSecurityOptions: {
317
+ Enabled: true,
318
+ InternalUserDatabaseEnabled: true,
319
+ MasterUserOptions: {
320
+ MasterUserName: 'admin',
321
+ MasterUserPassword: 'Admin_password_123!@#',
322
+ },
323
+ },
324
+ AccessPolicies: accessPolicy,
325
+ }));
326
+ printSuccess('Domain creation initiated — waiting for endpoint');
327
+ } catch (createErr) {
328
+ printError('Failed to create OpenSearch domain');
329
+ console.error();
330
+ if (/AccessDeniedException|not authorized/i.test(createErr.message)) {
331
+ console.error(` ${chalk.bold('Permission denied.')} Your IAM identity needs the ${chalk.bold('es:CreateDomain')} action.`);
332
+ } else {
333
+ console.error(` ${chalk.dim(createErr.message)}`);
334
+ }
335
+ console.error();
336
+ throw new Error('Failed to create OpenSearch domain');
337
+ }
338
+ }
339
+
340
+ // Poll for endpoint
341
+ const spinner = createSpinner('Provisioning OpenSearch domain (15-20 min)...');
342
+ spinner.start();
343
+ const maxWait = 1200_000; // 20 min
344
+ const interval = 10_000;
345
+ const start = Date.now();
346
+
347
+ while (Date.now() - start < maxWait) {
348
+ try {
349
+ const desc = await client.send(new DescribeDomainCommand({ DomainName: cfg.osDomainName }));
350
+ const endpoint = desc.DomainStatus?.Endpoint;
351
+ if (endpoint) {
352
+ cfg.opensearchEndpoint = `https://${endpoint}`;
353
+ spinner.succeed(`Domain ready: ${cfg.opensearchEndpoint}`);
354
+ return;
355
+ }
356
+ } catch { /* keep polling */ }
357
+ await sleepWithTicker(interval, spinner, start,
358
+ (s) => `Provisioning OpenSearch domain... (${fmtElapsed(s)} elapsed)`);
359
+ }
360
+
361
+ spinner.fail('Timed out waiting for OpenSearch domain');
362
+ throw new Error('Timed out waiting for OpenSearch domain');
363
+ }
364
+
365
+ // ── FGAC role mapping for managed domains ────────────────────────────────
366
+
367
+ const MANAGED_MASTER_USER = 'admin';
368
+ const MANAGED_MASTER_PASS = 'Admin_password_123!@#';
369
+
370
+ /**
371
+ * Map the OSI pipeline's IAM role to the all_access backend role in OpenSearch's
372
+ * fine-grained access control. Without this, the pipeline can connect to the
373
+ * managed domain but has no permissions to create indices or write data.
374
+ */
375
+ export async function mapOsiRoleInDomain(cfg) {
376
+ if (cfg.serverless || !cfg.opensearchEndpoint || !cfg.iamRoleArn) return;
377
+
378
+ printStep('Mapping OSI role in OpenSearch FGAC...');
379
+
380
+ const url = `${cfg.opensearchEndpoint}/_plugins/_security/api/rolesmapping/all_access`;
381
+ const auth = Buffer.from(`${MANAGED_MASTER_USER}:${MANAGED_MASTER_PASS}`).toString('base64');
382
+
383
+ try {
384
+ const resp = await fetch(url, {
385
+ method: 'PATCH',
386
+ headers: {
387
+ 'Content-Type': 'application/json',
388
+ 'Authorization': `Basic ${auth}`,
389
+ },
390
+ body: JSON.stringify([
391
+ { op: 'add', path: '/backend_roles', value: [cfg.iamRoleArn] },
392
+ ]),
393
+ });
394
+
395
+ if (resp.ok) {
396
+ printSuccess(`OSI role mapped to all_access in OpenSearch`);
397
+ } else {
398
+ const body = await resp.text();
399
+ printWarning(`FGAC mapping returned ${resp.status}: ${body}`);
400
+ printInfo('You may need to manually map the IAM role in OpenSearch Dashboards → Security → Roles');
401
+ }
402
+ } catch (err) {
403
+ printWarning(`Could not map OSI role in FGAC: ${err.message}`);
404
+ printInfo('You may need to manually map the IAM role in OpenSearch Dashboards → Security → Roles');
405
+ }
406
+ }
407
+
408
+ // ── IAM role ────────────────────────────────────────────────────────────────
409
+
410
+ export async function createIamRole(cfg) {
411
+ printStep(`Creating IAM role '${cfg.iamRoleName}'...`);
412
+
413
+ const client = new IAMClient({ region: cfg.region });
414
+
415
+ // Check if role already exists
416
+ try {
417
+ const existing = await client.send(new GetRoleCommand({ RoleName: cfg.iamRoleName }));
418
+ cfg.iamRoleArn = existing.Role.Arn;
419
+ printSuccess(`Role already exists: ${cfg.iamRoleArn}`);
420
+ return;
421
+ } catch (err) {
422
+ if (err.name !== 'NoSuchEntityException') throw err;
423
+ }
424
+
425
+ // Trust policy
426
+ const trustPolicy = JSON.stringify({
427
+ Version: '2012-10-17',
428
+ Statement: [{
429
+ Effect: 'Allow',
430
+ Principal: { Service: 'osis-pipelines.amazonaws.com' },
431
+ Action: 'sts:AssumeRole',
432
+ }],
433
+ });
434
+
435
+ try {
436
+ const result = await client.send(new CreateRoleCommand({
437
+ RoleName: cfg.iamRoleName,
438
+ AssumeRolePolicyDocument: trustPolicy,
439
+ }));
440
+ cfg.iamRoleArn = result.Role.Arn;
441
+ printSuccess(`Role created: ${cfg.iamRoleArn}`);
442
+ } catch (err) {
443
+ printError('Failed to create IAM role');
444
+ console.error();
445
+ if (/AccessDenied|not authorized/i.test(err.message)) {
446
+ console.error(` ${chalk.bold('Permission denied.')} Your IAM identity needs ${chalk.bold('iam:CreateRole')}.`);
447
+ } else {
448
+ console.error(` ${chalk.dim(err.message)}`);
449
+ }
450
+ console.error();
451
+ throw new Error('Failed to create IAM role');
452
+ }
453
+
454
+ // Permissions policy — includes both managed (es:*) and serverless (aoss:*) access
455
+ const statements = [
456
+ {
457
+ Effect: 'Allow',
458
+ Action: ['es:DescribeDomain', 'es:ESHttp*'],
459
+ Resource: `arn:aws:es:${cfg.region}:${cfg.accountId}:domain/*`,
460
+ },
461
+ {
462
+ Effect: 'Allow',
463
+ Action: ['aps:RemoteWrite'],
464
+ Resource: `arn:aws:aps:${cfg.region}:${cfg.accountId}:workspace/*`,
465
+ },
466
+ ];
467
+
468
+ // Add AOSS permissions for serverless collections
469
+ if (cfg.serverless) {
470
+ statements.push({
471
+ Effect: 'Allow',
472
+ Action: ['aoss:BatchGetCollection', 'aoss:APIAccessAll'],
473
+ Resource: `arn:aws:aoss:${cfg.region}:${cfg.accountId}:collection/*`,
474
+ });
475
+ }
476
+
477
+ const permissionsPolicy = JSON.stringify({
478
+ Version: '2012-10-17',
479
+ Statement: statements,
480
+ });
481
+
482
+ try {
483
+ await client.send(new PutRolePolicyCommand({
484
+ RoleName: cfg.iamRoleName,
485
+ PolicyName: `${cfg.iamRoleName}-policy`,
486
+ PolicyDocument: permissionsPolicy,
487
+ }));
488
+ printSuccess('Permissions policy attached');
489
+ } catch (err) {
490
+ printError('Failed to attach permissions policy');
491
+ console.error(` ${chalk.dim(err.message)}`);
492
+ console.error();
493
+ throw new Error('Failed to attach IAM permissions policy');
494
+ }
495
+
496
+ // Give IAM a moment to propagate
497
+ await sleep(5000);
498
+ }
499
+
500
+ // ── APS workspace ───────────────────────────────────────────────────────────
501
+
502
+ export async function createApsWorkspace(cfg) {
503
+ printStep(`Creating APS workspace '${cfg.apsWorkspaceAlias}'...`);
504
+
505
+ const client = new AmpClient({ region: cfg.region });
506
+
507
+ // Check if workspace already exists
508
+ try {
509
+ const list = await client.send(new ListWorkspacesCommand({ alias: cfg.apsWorkspaceAlias }));
510
+ const existing = list.workspaces?.[0];
511
+ if (existing?.workspaceId) {
512
+ cfg.apsWorkspaceId = existing.workspaceId;
513
+ cfg.prometheusUrl = `https://aps-workspaces.${cfg.region}.amazonaws.com/workspaces/${cfg.apsWorkspaceId}/api/v1/remote_write`;
514
+ printSuccess(`Workspace already exists: ${cfg.apsWorkspaceId}`);
515
+ printInfo(`Remote write URL: ${cfg.prometheusUrl}`);
516
+ return;
517
+ }
518
+ } catch { /* proceed to create */ }
519
+
520
+ try {
521
+ const result = await client.send(new CreateWorkspaceCommand({ alias: cfg.apsWorkspaceAlias }));
522
+ cfg.apsWorkspaceId = result.workspaceId;
523
+ cfg.prometheusUrl = `https://aps-workspaces.${cfg.region}.amazonaws.com/workspaces/${cfg.apsWorkspaceId}/api/v1/remote_write`;
524
+
525
+ // Wait for workspace to be active
526
+ const spinner = createSpinner('Waiting for APS workspace...');
527
+ spinner.start();
528
+ const maxWait = 60_000;
529
+ const start = Date.now();
530
+ while (Date.now() - start < maxWait) {
531
+ try {
532
+ const check = await client.send(new ListWorkspacesCommand({ alias: cfg.apsWorkspaceAlias }));
533
+ if (check.workspaces?.[0]?.status?.statusCode === 'ACTIVE') break;
534
+ } catch { /* keep waiting */ }
535
+ await sleep(5000);
536
+ }
537
+ spinner.succeed(`Workspace created: ${cfg.apsWorkspaceId}`);
538
+ printInfo(`Remote write URL: ${cfg.prometheusUrl}`);
539
+ } catch (err) {
540
+ printError('Failed to create APS workspace');
541
+ console.error();
542
+ if (/AccessDenied|not authorized/i.test(err.message)) {
543
+ console.error(` ${chalk.bold('Permission denied.')} Your IAM identity needs ${chalk.bold('aps:CreateWorkspace')}.`);
544
+ } else {
545
+ console.error(` ${chalk.dim(err.message)}`);
546
+ }
547
+ console.error();
548
+ throw new Error('Failed to create APS workspace');
549
+ }
550
+ }
551
+
552
+ // ── OSI pipeline ────────────────────────────────────────────────────────────
553
+
554
+ export async function createOsiPipeline(cfg, pipelineYaml) {
555
+ printStep(`Creating OSI pipeline '${cfg.pipelineName}'...`);
556
+
557
+ const client = new OSISClient({ region: cfg.region });
558
+
559
+ // Check if pipeline already exists
560
+ try {
561
+ await client.send(new GetPipelineCommand({ PipelineName: cfg.pipelineName }));
562
+ printWarning(`Pipeline '${cfg.pipelineName}' already exists — skipping creation`);
563
+ printInfo('To update, delete the existing pipeline first or use a different name');
564
+ return;
565
+ } catch (err) {
566
+ if (err.name !== 'ResourceNotFoundException') throw err;
567
+ }
568
+
569
+ try {
570
+ await client.send(new CreatePipelineCommand({
571
+ PipelineName: cfg.pipelineName,
572
+ MinUnits: cfg.minOcu,
573
+ MaxUnits: cfg.maxOcu,
574
+ PipelineConfigurationBody: pipelineYaml,
575
+ }));
576
+ printSuccess(`Pipeline '${cfg.pipelineName}' creation initiated`);
577
+ } catch (err) {
578
+ printError('Failed to create OSI pipeline');
579
+ console.error();
580
+ if (/AccessDenied|not authorized/i.test(err.message)) {
581
+ console.error(` ${chalk.bold('Permission denied.')} Your IAM identity needs ${chalk.bold('osis:CreatePipeline')}.`);
582
+ console.error(` ${chalk.dim(err.message)}`);
583
+ } else {
584
+ console.error(` ${chalk.dim(err.message)}`);
585
+ }
586
+ console.error();
587
+ throw new Error('Failed to create OSI pipeline');
588
+ }
589
+
590
+ // Wait for pipeline to become active
591
+ const spinner = createSpinner('Waiting for pipeline to activate...');
592
+ spinner.start();
593
+ const maxWait = 300_000; // 5 min
594
+ const start = Date.now();
595
+
596
+ while (Date.now() - start < maxWait) {
597
+ try {
598
+ const resp = await client.send(new GetPipelineCommand({ PipelineName: cfg.pipelineName }));
599
+ const status = resp.Pipeline?.Status;
600
+ if (status === 'ACTIVE') {
601
+ const urls = resp.Pipeline?.IngestEndpointUrls;
602
+ spinner.succeed('Pipeline is active');
603
+ if (urls?.length) {
604
+ printInfo(`Ingestion endpoint: ${urls[0]}`);
605
+ }
606
+ return;
607
+ }
608
+ if (status === 'CREATE_FAILED') {
609
+ const reason = resp.Pipeline?.StatusReason?.Description || 'unknown';
610
+ spinner.fail('Pipeline creation failed');
611
+ printInfo(`Reason: ${reason}`);
612
+ throw new Error(`Pipeline creation failed: ${reason}`);
613
+ }
614
+ } catch { /* keep polling */ }
615
+ await sleepWithTicker(10_000, spinner, start,
616
+ (s) => `Waiting for pipeline... (${fmtElapsed(s)})`);
617
+ }
618
+
619
+ spinner.warn('Timed out waiting — pipeline may still be provisioning');
620
+ printInfo(`Check: aws osis get-pipeline --pipeline-name ${cfg.pipelineName} --region ${cfg.region}`);
621
+ }
622
+
623
+ // ── Resource listing (for interactive reuse selection) ──────────────────────
624
+
625
+ /**
626
+ * List all OpenSearch endpoints in the given region — both managed domains
627
+ * and serverless collections.
628
+ * Returns [{ name, endpoint, engineVersion, serverless }].
629
+ */
630
+ export async function listDomains(region) {
631
+ const results = [];
632
+
633
+ // 1. Managed domains
634
+ try {
635
+ const client = new OpenSearchClient({ region });
636
+ const { DomainNames } = await client.send(new ListDomainNamesCommand({}));
637
+ if (DomainNames?.length) {
638
+ const names = DomainNames.map((d) => d.DomainName);
639
+ const { DomainStatusList } = await client.send(
640
+ new DescribeDomainsCommand({ DomainNames: names }),
641
+ );
642
+ for (const d of DomainStatusList || []) {
643
+ results.push({
644
+ name: d.DomainName,
645
+ endpoint: d.Endpoint ? `https://${d.Endpoint}` : '',
646
+ engineVersion: d.EngineVersion || '',
647
+ serverless: false,
648
+ });
649
+ }
650
+ }
651
+ } catch { /* managed listing failed — continue */ }
652
+
653
+ // 2. Serverless collections
654
+ try {
655
+ const aoss = new OpenSearchServerlessClient({ region });
656
+ const resp = await aoss.send(new ListServerlessCollectionsCommand({}));
657
+ for (const c of resp.collectionSummaries || []) {
658
+ const endpoint = c.arn
659
+ ? `https://${c.id}.${region}.aoss.amazonaws.com`
660
+ : '';
661
+ results.push({
662
+ name: c.name,
663
+ endpoint: c.status === 'ACTIVE' ? endpoint : '',
664
+ engineVersion: 'Serverless',
665
+ serverless: true,
666
+ });
667
+ }
668
+ } catch { /* serverless listing failed — continue */ }
669
+
670
+ return results;
671
+ }
672
+
673
+ /**
674
+ * List IAM roles, optionally filtered by a prefix/keyword.
675
+ * Returns [{ name, arn }].
676
+ */
677
+ export async function listRoles(region) {
678
+ const client = new IAMClient({ region });
679
+ const roles = [];
680
+ let marker;
681
+
682
+ // Paginate (IAM can have many roles)
683
+ do {
684
+ const resp = await client.send(new ListRolesCommand({
685
+ MaxItems: 200,
686
+ Marker: marker,
687
+ }));
688
+ for (const r of resp.Roles || []) {
689
+ roles.push({ name: r.RoleName, arn: r.Arn });
690
+ }
691
+ marker = resp.IsTruncated ? resp.Marker : undefined;
692
+ } while (marker);
693
+
694
+ return roles;
695
+ }
696
+
697
+ /**
698
+ * List APS workspaces in the given region.
699
+ * Returns [{ alias, id, url }].
700
+ */
701
+ export async function listWorkspaces(region) {
702
+ const client = new AmpClient({ region });
703
+ const resp = await client.send(new ListWorkspacesCommand({}));
704
+ return (resp.workspaces || [])
705
+ .filter((w) => w.status?.statusCode === 'ACTIVE')
706
+ .map((w) => ({
707
+ alias: w.alias || '',
708
+ id: w.workspaceId,
709
+ url: `https://aps-workspaces.${region}.amazonaws.com/workspaces/${w.workspaceId}/api/v1/remote_write`,
710
+ }));
711
+ }
712
+
713
+ // ── Pipeline listing / describe / update ─────────────────────────────────────
714
+
715
+ /**
716
+ * List all OSI pipelines in the given region.
717
+ * Returns [{ name, status, minUnits, maxUnits, createdAt, lastUpdatedAt }].
718
+ */
719
+ export async function listPipelines(region) {
720
+ const client = new OSISClient({ region });
721
+ const resp = await client.send(new ListPipelinesCommand({ MaxResults: 100 }));
722
+ return (resp.Pipelines || []).map((p) => ({
723
+ name: p.PipelineName,
724
+ status: p.Status,
725
+ minUnits: p.MinUnits,
726
+ maxUnits: p.MaxUnits,
727
+ createdAt: p.CreatedAt,
728
+ lastUpdatedAt: p.LastUpdatedAt,
729
+ }));
730
+ }
731
+
732
+ /**
733
+ * Get full details of a single OSI pipeline.
734
+ */
735
+ export async function getPipeline(region, pipelineName) {
736
+ const client = new OSISClient({ region });
737
+ const resp = await client.send(new GetPipelineCommand({ PipelineName: pipelineName }));
738
+ const p = resp.Pipeline;
739
+ return {
740
+ name: p.PipelineName,
741
+ arn: p.PipelineArn,
742
+ status: p.Status,
743
+ statusReason: p.StatusReason?.Description,
744
+ minUnits: p.MinUnits,
745
+ maxUnits: p.MaxUnits,
746
+ ingestEndpoints: p.IngestEndpointUrls || [],
747
+ createdAt: p.CreatedAt,
748
+ lastUpdatedAt: p.LastUpdatedAt,
749
+ pipelineConfigurationBody: p.PipelineConfigurationBody,
750
+ logPublishingOptions: p.LogPublishingOptions,
751
+ bufferOptions: p.BufferOptions,
752
+ };
753
+ }
754
+
755
+ /**
756
+ * Update an OSI pipeline.
757
+ * @param {string} region
758
+ * @param {string} pipelineName
759
+ * @param {{ minUnits?: number, maxUnits?: number, pipelineConfigurationBody?: string, logPublishingOptions?: object, bufferOptions?: object }} params
760
+ */
761
+ export async function updatePipeline(region, pipelineName, params) {
762
+ const client = new OSISClient({ region });
763
+
764
+ const cmd = { PipelineName: pipelineName };
765
+ if (params.minUnits != null) cmd.MinUnits = params.minUnits;
766
+ if (params.maxUnits != null) cmd.MaxUnits = params.maxUnits;
767
+ if (params.pipelineConfigurationBody != null) cmd.PipelineConfigurationBody = params.pipelineConfigurationBody;
768
+ if (params.logPublishingOptions != null) cmd.LogPublishingOptions = params.logPublishingOptions;
769
+ if (params.bufferOptions != null) cmd.BufferOptions = params.bufferOptions;
770
+
771
+ await client.send(new UpdatePipelineCommand(cmd));
772
+
773
+ // Poll for update completion
774
+ const spinner = createSpinner('Waiting for pipeline update...');
775
+ spinner.start();
776
+ const maxWait = 300_000;
777
+ const start = Date.now();
778
+
779
+ while (Date.now() - start < maxWait) {
780
+ try {
781
+ const resp = await client.send(new GetPipelineCommand({ PipelineName: pipelineName }));
782
+ const status = resp.Pipeline?.Status;
783
+ if (status === 'ACTIVE') {
784
+ spinner.succeed('Pipeline updated successfully');
785
+ return;
786
+ }
787
+ if (status === 'UPDATE_FAILED') {
788
+ const reason = resp.Pipeline?.StatusReason?.Description || 'unknown';
789
+ spinner.fail('Pipeline update failed');
790
+ throw new Error(`Pipeline update failed: ${reason}`);
791
+ }
792
+ } catch (err) {
793
+ if (err.message.startsWith('Pipeline update failed')) throw err;
794
+ }
795
+ await sleepWithTicker(10_000, spinner, start,
796
+ (s) => `Waiting for pipeline update... (${fmtElapsed(s)})`);
797
+ }
798
+
799
+ spinner.warn('Timed out waiting for update — pipeline may still be updating');
800
+ }
801
+
802
+ // ── Helpers ─────────────────────────────────────────────────────────────────
803
+
804
+ function sleep(ms) {
805
+ return new Promise((resolve) => setTimeout(resolve, ms));
806
+ }
807
+
808
+ function fmtElapsed(totalSec) {
809
+ if (totalSec < 60) return `${totalSec}s`;
810
+ const m = Math.floor(totalSec / 60);
811
+ const s = totalSec % 60;
812
+ if (m < 60) return `${m}m ${s}s`;
813
+ const h = Math.floor(m / 60);
814
+ return `${h}h ${m % 60}m ${s}s`;
815
+ }
816
+
817
+ /** Sleep for `totalMs`, updating `spinner.text` every second via `textFn(elapsedSec)`. */
818
+ async function sleepWithTicker(totalMs, spinner, startTime, textFn) {
819
+ const end = Date.now() + totalMs;
820
+ while (Date.now() < end) {
821
+ spinner.text = textFn(Math.round((Date.now() - startTime) / 1000));
822
+ await sleep(Math.min(1000, end - Date.now()));
823
+ }
824
+ }