@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/aws.mjs CHANGED
@@ -5,6 +5,12 @@ import {
5
5
  DescribeDomainCommand,
6
6
  ListDomainNamesCommand,
7
7
  DescribeDomainsCommand,
8
+ AddDirectQueryDataSourceCommand,
9
+ GetDirectQueryDataSourceCommand,
10
+ CreateApplicationCommand,
11
+ GetApplicationCommand,
12
+ UpdateApplicationCommand,
13
+ ListApplicationsCommand,
8
14
  } from '@aws-sdk/client-opensearch';
9
15
  import {
10
16
  OpenSearchServerlessClient,
@@ -25,14 +31,23 @@ import {
25
31
  AmpClient,
26
32
  ListWorkspacesCommand,
27
33
  CreateWorkspaceCommand,
34
+ DescribeWorkspaceCommand,
28
35
  } from '@aws-sdk/client-amp';
29
36
  import {
30
37
  OSISClient,
31
38
  ListPipelinesCommand,
32
39
  CreatePipelineCommand,
33
40
  GetPipelineCommand,
34
- UpdatePipelineCommand,
35
41
  } from '@aws-sdk/client-osis';
42
+ import {
43
+ EKSClient,
44
+ DescribeClusterCommand,
45
+ } from '@aws-sdk/client-eks';
46
+ import {
47
+ ResourceGroupsTaggingAPIClient,
48
+ GetResourcesCommand,
49
+ TagResourcesCommand,
50
+ } from '@aws-sdk/client-resource-groups-tagging-api';
36
51
  import {
37
52
  printStep,
38
53
  printSuccess,
@@ -43,6 +58,14 @@ import {
43
58
  } from './ui.mjs';
44
59
  import chalk from 'chalk';
45
60
 
61
+ // ── Tagging ─────────────────────────────────────────────────────────────────
62
+
63
+ const TAG_KEY = 'open-stack';
64
+
65
+ function stackTags(stackName) {
66
+ return [{ Key: TAG_KEY, Value: stackName }];
67
+ }
68
+
46
69
  // ── Prerequisites ───────────────────────────────────────────────────────────
47
70
 
48
71
  export async function checkRequirements(cfg) {
@@ -71,6 +94,8 @@ export async function checkRequirements(cfg) {
71
94
  console.error(` ${chalk.bold('export AWS_ACCESS_KEY_ID=<key>')}`);
72
95
  console.error(` ${chalk.bold('export AWS_SECRET_ACCESS_KEY=<secret>')}`);
73
96
  console.error(` ${chalk.bold('export AWS_SESSION_TOKEN=<token>')} ${chalk.dim('(if using temporary creds)')}`);
97
+ console.error();
98
+ console.error(` ${chalk.dim('Docs:')} ${chalk.underline('https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html#getting-started-quickstart-new-command')}`);
74
99
  } else if (/expired|ExpiredToken/i.test(err.message)) {
75
100
  console.error(` ${chalk.bold('Your session has expired. Refresh credentials:')}`);
76
101
  console.error();
@@ -84,6 +109,8 @@ export async function checkRequirements(cfg) {
84
109
  console.error(` ${chalk.bold('Try:')}`);
85
110
  console.error(` ${chalk.bold('aws configure')} ${chalk.dim('— set up credentials')}`);
86
111
  console.error(` ${chalk.bold('aws sso login')} ${chalk.dim('— refresh SSO session')}`);
112
+ console.error();
113
+ console.error(` ${chalk.dim('Docs:')} ${chalk.underline('https://docs.aws.amazon.com/cli/latest/userguide/getting-started-quickstart.html#getting-started-quickstart-new-command')}`);
87
114
  }
88
115
  console.error();
89
116
  throw new Error('AWS credentials are not configured or have expired');
@@ -145,6 +172,7 @@ async function createServerlessCollection(cfg) {
145
172
  await client.send(new CreateCollectionCommand({
146
173
  name: collectionName,
147
174
  type: 'TIMESERIES',
175
+ tags: stackTags(cfg.pipelineName).map((t) => ({ key: t.Key, value: t.Value })),
148
176
  }));
149
177
  printSuccess('Collection creation initiated — waiting for endpoint');
150
178
  } catch (err) {
@@ -322,6 +350,7 @@ async function createManagedDomain(cfg) {
322
350
  },
323
351
  },
324
352
  AccessPolicies: accessPolicy,
353
+ TagList: stackTags(cfg.pipelineName),
325
354
  }));
326
355
  printSuccess('Domain creation initiated — waiting for endpoint');
327
356
  } catch (createErr) {
@@ -397,11 +426,11 @@ export async function mapOsiRoleInDomain(cfg) {
397
426
  } else {
398
427
  const body = await resp.text();
399
428
  printWarning(`FGAC mapping returned ${resp.status}: ${body}`);
400
- printInfo('You may need to manually map the IAM role in OpenSearch Dashboards → Security → Roles');
429
+ printInfo('You may need to manually map the IAM role in OpenSearch UI → Security → Roles');
401
430
  }
402
431
  } catch (err) {
403
432
  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');
433
+ printInfo('You may need to manually map the IAM role in OpenSearch UI → Security → Roles');
405
434
  }
406
435
  }
407
436
 
@@ -436,6 +465,7 @@ export async function createIamRole(cfg) {
436
465
  const result = await client.send(new CreateRoleCommand({
437
466
  RoleName: cfg.iamRoleName,
438
467
  AssumeRolePolicyDocument: trustPolicy,
468
+ Tags: stackTags(cfg.pipelineName),
439
469
  }));
440
470
  cfg.iamRoleArn = result.Role.Arn;
441
471
  printSuccess(`Role created: ${cfg.iamRoleArn}`);
@@ -518,7 +548,10 @@ export async function createApsWorkspace(cfg) {
518
548
  } catch { /* proceed to create */ }
519
549
 
520
550
  try {
521
- const result = await client.send(new CreateWorkspaceCommand({ alias: cfg.apsWorkspaceAlias }));
551
+ const result = await client.send(new CreateWorkspaceCommand({
552
+ alias: cfg.apsWorkspaceAlias,
553
+ tags: { [TAG_KEY]: cfg.pipelineName },
554
+ }));
522
555
  cfg.apsWorkspaceId = result.workspaceId;
523
556
  cfg.prometheusUrl = `https://aps-workspaces.${cfg.region}.amazonaws.com/workspaces/${cfg.apsWorkspaceId}/api/v1/remote_write`;
524
557
 
@@ -572,6 +605,8 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
572
605
  MinUnits: cfg.minOcu,
573
606
  MaxUnits: cfg.maxOcu,
574
607
  PipelineConfigurationBody: pipelineYaml,
608
+ PipelineRoleArn: cfg.iamRoleArn,
609
+ Tags: stackTags(cfg.pipelineName),
575
610
  }));
576
611
  printSuccess(`Pipeline '${cfg.pipelineName}' creation initiated`);
577
612
  } catch (err) {
@@ -590,7 +625,7 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
590
625
  // Wait for pipeline to become active
591
626
  const spinner = createSpinner('Waiting for pipeline to activate...');
592
627
  spinner.start();
593
- const maxWait = 300_000; // 5 min
628
+ const maxWait = 900_000; // 15 min
594
629
  const start = Date.now();
595
630
 
596
631
  while (Date.now() - start < maxWait) {
@@ -598,10 +633,11 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
598
633
  const resp = await client.send(new GetPipelineCommand({ PipelineName: cfg.pipelineName }));
599
634
  const status = resp.Pipeline?.Status;
600
635
  if (status === 'ACTIVE') {
601
- const urls = resp.Pipeline?.IngestEndpointUrls;
636
+ const urls = resp.Pipeline?.IngestEndpointUrls || [];
637
+ cfg.ingestEndpoints = urls;
602
638
  spinner.succeed('Pipeline is active');
603
- if (urls?.length) {
604
- printInfo(`Ingestion endpoint: ${urls[0]}`);
639
+ for (const url of urls) {
640
+ printInfo(`Ingestion endpoint: https://${url}`);
605
641
  }
606
642
  return;
607
643
  }
@@ -611,13 +647,360 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
611
647
  printInfo(`Reason: ${reason}`);
612
648
  throw new Error(`Pipeline creation failed: ${reason}`);
613
649
  }
614
- } catch { /* keep polling */ }
650
+ } catch (err) {
651
+ if (err.message?.startsWith('Pipeline creation failed')) throw err;
652
+ /* keep polling */
653
+ }
615
654
  await sleepWithTicker(10_000, spinner, start,
616
655
  (s) => `Waiting for pipeline... (${fmtElapsed(s)})`);
617
656
  }
618
657
 
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}`);
658
+ spinner.fail('Timed out waiting for pipeline after 15 minutes');
659
+ throw new Error(`Pipeline '${cfg.pipelineName}' did not become active within 15 minutes`);
660
+ }
661
+
662
+ // ── OpenSearch UI workspace ──────────────────────────────────────────
663
+
664
+ /**
665
+ * Set up OpenSearch UI: derive the URL and create an Observability workspace.
666
+ * Skipped when dashboardsAction is 'reuse' (user provided their own URL).
667
+ * For managed domains, uses basic auth to call the Dashboards API.
668
+ * For serverless, provides the URL only (workspace setup via AWS console).
669
+ */
670
+ export async function setupDashboards(cfg) {
671
+ if (!cfg.opensearchEndpoint) return;
672
+ if (cfg.dashboardsAction === 'reuse') {
673
+ printStep('OpenSearch UI');
674
+ printSuccess(`Using existing Dashboards: ${cfg.dashboardsUrl}`);
675
+ if (!cfg.serverless) {
676
+ await createObservabilityWorkspace(cfg);
677
+ }
678
+ return;
679
+ }
680
+
681
+ printStep('Setting up OpenSearch UI...');
682
+
683
+ // Use OpenSearch Application URL
684
+ if (!cfg.appEndpoint && cfg.appId) {
685
+ const client = new OpenSearchClient({ region: cfg.region });
686
+ await fetchAppEndpoint(client, cfg);
687
+ }
688
+ cfg.dashboardsUrl = cfg.appEndpoint || '';
689
+ if (!cfg.dashboardsUrl) {
690
+ printWarning('No OpenSearch Application endpoint available');
691
+ printInfo('Create an OpenSearch Application in the AWS console to get the UI URL');
692
+ return;
693
+ }
694
+ printSuccess(`URL: ${cfg.dashboardsUrl}`);
695
+
696
+ // Create observability workspace (managed domains only — uses basic auth)
697
+ if (!cfg.serverless) {
698
+ await createObservabilityWorkspace(cfg);
699
+ }
700
+ }
701
+
702
+ async function createObservabilityWorkspace(cfg) {
703
+ if (!cfg.dashboardsUrl) return;
704
+ const dashboardsBase = cfg.dashboardsUrl.replace(/\/+$/, '');
705
+ const url = `${dashboardsBase}/api/workspaces`;
706
+ const auth = Buffer.from(`${MANAGED_MASTER_USER}:${MANAGED_MASTER_PASS}`).toString('base64');
707
+
708
+ // Check if an observability workspace already exists
709
+ try {
710
+ const listResp = await fetch(url, {
711
+ method: 'GET',
712
+ headers: {
713
+ 'osd-xsrf': 'true',
714
+ 'Authorization': `Basic ${auth}`,
715
+ },
716
+ });
717
+
718
+ if (listResp.ok) {
719
+ const data = await listResp.json();
720
+ const existing = (data.result?.workspaces || []).find(
721
+ (w) => w.name === 'Observability'
722
+ );
723
+ if (existing) {
724
+ printSuccess(`Observability workspace already exists (id: ${existing.id})`);
725
+ return;
726
+ }
727
+ }
728
+ } catch { /* proceed to create */ }
729
+
730
+ try {
731
+ const resp = await fetch(url, {
732
+ method: 'POST',
733
+ headers: {
734
+ 'Content-Type': 'application/json',
735
+ 'osd-xsrf': 'true',
736
+ 'Authorization': `Basic ${auth}`,
737
+ },
738
+ body: JSON.stringify({
739
+ attributes: {
740
+ name: 'Observability',
741
+ description: 'Observability workspace for traces, logs, and metrics',
742
+ features: ['use-case-observability'],
743
+ },
744
+ }),
745
+ });
746
+
747
+ if (resp.ok) {
748
+ const data = await resp.json();
749
+ const workspaceId = data.result?.id;
750
+ if (workspaceId) {
751
+ printSuccess(`Observability workspace created (id: ${workspaceId})`);
752
+ } else {
753
+ printSuccess('Observability workspace created');
754
+ }
755
+ } else {
756
+ const body = await resp.text();
757
+ printWarning(`Could not create Observability workspace (${resp.status}): ${body}`);
758
+ printInfo('You can create one manually in OpenSearch UI → Workspaces');
759
+ }
760
+ } catch (err) {
761
+ printWarning(`Could not create Observability workspace: ${err.message}`);
762
+ printInfo('You can create one manually in OpenSearch UI → Workspaces');
763
+ }
764
+ }
765
+
766
+ // ── Direct Query Data Source (AMP → OpenSearch) ─────────────────────────────
767
+
768
+ /**
769
+ * Create an IAM role for the Direct Query Service to access AMP.
770
+ * Trust policy allows directquery.opensearchservice.amazonaws.com to assume it.
771
+ */
772
+ export async function createDqsPrometheusRole(cfg) {
773
+ const roleName = cfg.dqsRoleName;
774
+ printStep(`Creating DQS Prometheus role '${roleName}'...`);
775
+
776
+ const client = new IAMClient({ region: cfg.region });
777
+
778
+ // Check if role already exists
779
+ try {
780
+ const existing = await client.send(new GetRoleCommand({ RoleName: roleName }));
781
+ cfg.dqsRoleArn = existing.Role.Arn;
782
+ printSuccess(`DQS role already exists: ${cfg.dqsRoleArn}`);
783
+ return;
784
+ } catch (err) {
785
+ if (err.name !== 'NoSuchEntityException') throw err;
786
+ }
787
+
788
+ const trustPolicy = JSON.stringify({
789
+ Version: '2012-10-17',
790
+ Statement: [{
791
+ Effect: 'Allow',
792
+ Principal: { Service: 'directquery.opensearchservice.amazonaws.com' },
793
+ Action: 'sts:AssumeRole',
794
+ }],
795
+ });
796
+
797
+ try {
798
+ const result = await client.send(new CreateRoleCommand({
799
+ RoleName: roleName,
800
+ AssumeRolePolicyDocument: trustPolicy,
801
+ Tags: stackTags(cfg.pipelineName),
802
+ }));
803
+ cfg.dqsRoleArn = result.Role.Arn;
804
+ printSuccess(`DQS role created: ${cfg.dqsRoleArn}`);
805
+ } catch (err) {
806
+ printError('Failed to create DQS Prometheus role');
807
+ console.error(` ${chalk.dim(err.message)}`);
808
+ console.error();
809
+ throw new Error('Failed to create DQS Prometheus role');
810
+ }
811
+
812
+ // Attach APS access policy
813
+ const apsWorkspaceArn = `arn:aws:aps:${cfg.region}:${cfg.accountId}:workspace/${cfg.apsWorkspaceId}`;
814
+ const permissionsPolicy = JSON.stringify({
815
+ Version: '2012-10-17',
816
+ Statement: [{ Effect: 'Allow', Action: 'aps:*', Resource: apsWorkspaceArn }],
817
+ });
818
+
819
+ try {
820
+ await client.send(new PutRolePolicyCommand({
821
+ RoleName: roleName,
822
+ PolicyName: 'APSAccess',
823
+ PolicyDocument: permissionsPolicy,
824
+ }));
825
+ printSuccess('APS access policy attached to DQS role');
826
+ } catch (err) {
827
+ printError('Failed to attach APS policy to DQS role');
828
+ console.error(` ${chalk.dim(err.message)}`);
829
+ console.error();
830
+ throw new Error('Failed to attach APS policy to DQS role');
831
+ }
832
+
833
+ await sleep(5000);
834
+ }
835
+
836
+ /**
837
+ * Create a Direct Query Data Source connecting OpenSearch to AMP (Prometheus).
838
+ * Uses the OpenSearch service control plane API.
839
+ */
840
+ export async function createDirectQueryDataSource(cfg) {
841
+ const dataSourceName = cfg.dqsDataSourceName;
842
+ printStep(`Creating Direct Query data source '${dataSourceName}'...`);
843
+
844
+ const client = new OpenSearchClient({ region: cfg.region });
845
+ const workspaceArn = `arn:aws:aps:${cfg.region}:${cfg.accountId}:workspace/${cfg.apsWorkspaceId}`;
846
+
847
+ try {
848
+ const result = await client.send(new AddDirectQueryDataSourceCommand({
849
+ DataSourceName: dataSourceName,
850
+ DataSourceType: {
851
+ Prometheus: {
852
+ RoleArn: cfg.dqsRoleArn,
853
+ WorkspaceArn: workspaceArn,
854
+ },
855
+ },
856
+ Description: `Prometheus data source for ${cfg.pipelineName} observability stack`,
857
+ }));
858
+ cfg.dqsDataSourceArn = result.DataSourceArn;
859
+ printSuccess(`Direct Query data source created: ${cfg.dqsDataSourceArn}`);
860
+ await tagResource(cfg.region, cfg.dqsDataSourceArn, cfg.pipelineName);
861
+ } catch (err) {
862
+ // Treat "already exists" as success
863
+ if (/already exists/i.test(err.message) || err.name === 'ResourceAlreadyExistsException') {
864
+ cfg.dqsDataSourceArn = `arn:aws:opensearch:${cfg.region}:${cfg.accountId}:datasource/${dataSourceName}`;
865
+ printSuccess(`Data source '${dataSourceName}' already exists`);
866
+ return;
867
+ }
868
+ printError('Failed to create Direct Query data source');
869
+ console.error(` ${chalk.dim(err.message)}`);
870
+ console.error();
871
+ throw new Error('Failed to create Direct Query data source');
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Create an OpenSearch Application (the new OpenSearch UI) and associate
877
+ * the OpenSearch domain/collection and the DQS data source with it.
878
+ */
879
+ export async function createOpenSearchApplication(cfg) {
880
+ const appName = cfg.appName;
881
+ printStep(`Creating OpenSearch Application '${appName}'...`);
882
+
883
+ const client = new OpenSearchClient({ region: cfg.region });
884
+
885
+ // Check if app already exists
886
+ try {
887
+ const list = await client.send(new ListApplicationsCommand({}));
888
+ const existing = (list.ApplicationSummaries || []).find((a) => a.name === appName);
889
+ if (existing) {
890
+ cfg.appId = existing.id;
891
+ printSuccess(`Application '${appName}' already exists (id: ${cfg.appId})`);
892
+ await fetchAppEndpoint(client, cfg);
893
+ // Update data sources on existing app
894
+ await associateDataSourcesWithApp(cfg, client);
895
+ return;
896
+ }
897
+ } catch { /* proceed to create */ }
898
+
899
+ // Build data sources list
900
+ const dataSources = buildAppDataSources(cfg);
901
+
902
+ try {
903
+ const result = await client.send(new CreateApplicationCommand({
904
+ name: appName,
905
+ dataSources,
906
+ appConfigs: [
907
+ {
908
+ key: 'opensearchDashboards.dashboardAdmin.users',
909
+ value: JSON.stringify(['*']),
910
+ },
911
+ ],
912
+ iamIdentityCenterOptions: {
913
+ enabled: false,
914
+ },
915
+ }));
916
+ cfg.appId = result.id;
917
+ printSuccess(`Application created: ${cfg.appId}`);
918
+ if (result.arn) {
919
+ await tagResource(cfg.region, result.arn, cfg.pipelineName);
920
+ }
921
+ await fetchAppEndpoint(client, cfg);
922
+ } catch (err) {
923
+ if (/already exists/i.test(err.message) || err.name === 'ResourceAlreadyExistsException' || err.name === 'ConflictException') {
924
+ printSuccess(`Application '${appName}' already exists`);
925
+ // Try to find and update it
926
+ try {
927
+ const list = await client.send(new ListApplicationsCommand({}));
928
+ const existing = (list.ApplicationSummaries || []).find((a) => a.name === appName);
929
+ if (existing) {
930
+ cfg.appId = existing.id;
931
+ await fetchAppEndpoint(client, cfg);
932
+ await associateDataSourcesWithApp(cfg, client);
933
+ }
934
+ } catch { /* best effort */ }
935
+ return;
936
+ }
937
+ printWarning(`Could not create OpenSearch Application: ${err.message}`);
938
+ printInfo('You can create one manually in the AWS console');
939
+ }
940
+ }
941
+
942
+ /**
943
+ * Fetch the application endpoint via GetApplicationCommand.
944
+ */
945
+ async function fetchAppEndpoint(client, cfg) {
946
+ if (!cfg.appId) return;
947
+ try {
948
+ const resp = await client.send(new GetApplicationCommand({ id: cfg.appId }));
949
+ cfg.appEndpoint = resp.endpoint || '';
950
+ // endpoint logged by setupDashboards
951
+ } catch { /* best effort */ }
952
+ }
953
+
954
+ /**
955
+ * Build the data sources list for application create/update.
956
+ */
957
+ function buildAppDataSources(cfg) {
958
+ const dataSources = [];
959
+ if (cfg.serverless) {
960
+ const collectionId = extractServerlessCollectionId(cfg.opensearchEndpoint);
961
+ if (collectionId) {
962
+ dataSources.push({
963
+ dataSourceArn: `arn:aws:aoss:${cfg.region}:${cfg.accountId}:collection/${collectionId}`,
964
+ });
965
+ }
966
+ } else if (cfg.osDomainName) {
967
+ dataSources.push({
968
+ dataSourceArn: `arn:aws:es:${cfg.region}:${cfg.accountId}:domain/${cfg.osDomainName}`,
969
+ });
970
+ }
971
+ if (cfg.dqsDataSourceArn) {
972
+ dataSources.push({ dataSourceArn: cfg.dqsDataSourceArn });
973
+ }
974
+ return dataSources;
975
+ }
976
+
977
+ /**
978
+ * Associate the OpenSearch domain and DQS data source with the application.
979
+ */
980
+ async function associateDataSourcesWithApp(cfg, client) {
981
+ if (!cfg.appId) return;
982
+
983
+ const dataSources = buildAppDataSources(cfg);
984
+ if (dataSources.length === 0) return;
985
+
986
+ try {
987
+ await client.send(new UpdateApplicationCommand({
988
+ id: cfg.appId,
989
+ dataSources,
990
+ }));
991
+ printSuccess('Data sources associated with application');
992
+ } catch (err) {
993
+ printWarning(`Could not associate data sources: ${err.message}`);
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Extract serverless collection ID from endpoint URL.
999
+ * Endpoint format: https://<id>.<region>.aoss.amazonaws.com
1000
+ */
1001
+ function extractServerlessCollectionId(endpoint) {
1002
+ const match = endpoint?.match(/https?:\/\/([^.]+)\./);
1003
+ return match?.[1] || '';
621
1004
  }
622
1005
 
623
1006
  // ── Resource listing (for interactive reuse selection) ──────────────────────
@@ -710,6 +1093,20 @@ export async function listWorkspaces(region) {
710
1093
  }));
711
1094
  }
712
1095
 
1096
+ /**
1097
+ * List OpenSearch Applications in the given region.
1098
+ * Returns [{ name, id, endpoint }].
1099
+ */
1100
+ export async function listApplications(region) {
1101
+ const client = new OpenSearchClient({ region });
1102
+ const resp = await client.send(new ListApplicationsCommand({}));
1103
+ return (resp.ApplicationSummaries || []).map((a) => ({
1104
+ name: a.name,
1105
+ id: a.id,
1106
+ endpoint: a.endpoint || '',
1107
+ }));
1108
+ }
1109
+
713
1110
  // ── Pipeline listing / describe / update ─────────────────────────────────────
714
1111
 
715
1112
  /**
@@ -729,74 +1126,296 @@ export async function listPipelines(region) {
729
1126
  }));
730
1127
  }
731
1128
 
1129
+
732
1130
  /**
733
- * Get full details of a single OSI pipeline.
1131
+ * Get the OTLP ingest endpoint URL for an OSI pipeline.
734
1132
  */
735
- export async function getPipeline(region, pipelineName) {
1133
+ export async function getPipelineEndpoint(region, pipelineName) {
736
1134
  const client = new OSISClient({ region });
737
1135
  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
- };
1136
+ const urls = resp.Pipeline?.IngestEndpointUrls;
1137
+ return urls?.length ? urls[0] : null;
753
1138
  }
754
1139
 
1140
+ // ── Stack discovery (tag-based) ──────────────────────────────────────────────
1141
+
755
1142
  /**
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
1143
+ * Tag a resource after creation using the Resource Groups Tagging API.
1144
+ * Best-effort failures are silently ignored.
760
1145
  */
761
- export async function updatePipeline(region, pipelineName, params) {
762
- const client = new OSISClient({ region });
1146
+ export async function tagResource(region, arn, stackName) {
1147
+ try {
1148
+ const client = new ResourceGroupsTaggingAPIClient({ region });
1149
+ await client.send(new TagResourcesCommand({
1150
+ ResourceARNList: [arn],
1151
+ Tags: { [TAG_KEY]: stackName },
1152
+ }));
1153
+ } catch { /* best effort */ }
1154
+ }
763
1155
 
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;
1156
+ /**
1157
+ * List all open-stack stacks in a region by querying the Resource Groups Tagging API.
1158
+ * Returns [{ name, resources: [{ arn, type }] }] grouped by stack name.
1159
+ */
1160
+ export async function listStacks(region) {
1161
+ const client = new ResourceGroupsTaggingAPIClient({ region });
1162
+ const stacks = new Map();
770
1163
 
771
- await client.send(new UpdatePipelineCommand(cmd));
1164
+ let paginationToken;
1165
+ do {
1166
+ const resp = await client.send(new GetResourcesCommand({
1167
+ TagFilters: [{ Key: TAG_KEY }],
1168
+ PaginationToken: paginationToken || undefined,
1169
+ }));
772
1170
 
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();
1171
+ for (const r of resp.ResourceTagMappingList || []) {
1172
+ const tag = (r.Tags || []).find((t) => t.Key === TAG_KEY);
1173
+ if (!tag) continue;
1174
+ const stackName = tag.Value;
1175
+ if (!stacks.has(stackName)) {
1176
+ stacks.set(stackName, []);
1177
+ }
1178
+ stacks.get(stackName).push({
1179
+ arn: r.ResourceARN,
1180
+ type: arnToType(r.ResourceARN),
1181
+ });
1182
+ }
778
1183
 
779
- while (Date.now() - start < maxWait) {
1184
+ paginationToken = resp.PaginationToken;
1185
+ } while (paginationToken);
1186
+
1187
+ // Supplement with OpenSearch Applications (may not appear in tagging API)
1188
+ await supplementApplications(region, stacks);
1189
+
1190
+ return [...stacks.entries()].map(([name, resources]) => ({ name, resources }));
1191
+ }
1192
+
1193
+ /**
1194
+ * Get all resources for a specific stack by its tag value.
1195
+ * Returns [{ arn, type }].
1196
+ */
1197
+ export async function getStackResources(region, stackName) {
1198
+ const client = new ResourceGroupsTaggingAPIClient({ region });
1199
+ const resources = [];
1200
+
1201
+ let paginationToken;
1202
+ do {
1203
+ const resp = await client.send(new GetResourcesCommand({
1204
+ TagFilters: [{ Key: TAG_KEY, Values: [stackName] }],
1205
+ PaginationToken: paginationToken || undefined,
1206
+ }));
1207
+
1208
+ for (const r of resp.ResourceTagMappingList || []) {
1209
+ resources.push({
1210
+ arn: r.ResourceARN,
1211
+ type: arnToType(r.ResourceARN),
1212
+ });
1213
+ }
1214
+
1215
+ paginationToken = resp.PaginationToken;
1216
+ } while (paginationToken);
1217
+
1218
+ // Supplement with OpenSearch Application if not already present
1219
+ const stacks = new Map([[stackName, resources]]);
1220
+ await supplementApplications(region, stacks);
1221
+
1222
+ return resources;
1223
+ }
1224
+
1225
+ /**
1226
+ * Map an ARN to a human-readable resource type.
1227
+ */
1228
+ function arnToType(arn) {
1229
+ if (/^arn:aws:osis:/.test(arn)) return 'OSI Pipeline';
1230
+ if (/^arn:aws:aoss:.*:collection\//.test(arn)) return 'OpenSearch Serverless';
1231
+ if (/^arn:aws:es:.*:domain\//.test(arn)) return 'OpenSearch Domain';
1232
+ if (/^arn:aws:iam:.*:role\//.test(arn)) return 'IAM Role';
1233
+ if (/^arn:aws:aps:.*:workspace\//.test(arn)) return 'APS Workspace';
1234
+ if (/^arn:aws:opensearch:.*:datasource\//.test(arn)) return 'DQ Data Source';
1235
+ if (/^arn:aws:opensearch:.*:application\//.test(arn)) return 'OpenSearch Application';
1236
+ if (/^arn:aws:eks:.*:cluster\//.test(arn)) return 'EKS Cluster';
1237
+ return 'Resource';
1238
+ }
1239
+
1240
+ /**
1241
+ * Extract the resource name from an ARN.
1242
+ */
1243
+ export function arnToName(arn) {
1244
+ // IAM roles: arn:aws:iam::123:role/role-name
1245
+ const iamMatch = arn.match(/:role\/(.+)$/);
1246
+ if (iamMatch) return iamMatch[1];
1247
+ // Most others: .../{name} or ...:<name>
1248
+ const lastSlash = arn.lastIndexOf('/');
1249
+ if (lastSlash !== -1) return arn.slice(lastSlash + 1);
1250
+ const lastColon = arn.lastIndexOf(':');
1251
+ if (lastColon !== -1) return arn.slice(lastColon + 1);
1252
+ return arn;
1253
+ }
1254
+
1255
+ /**
1256
+ * Enrich resource objects with display names where the ARN-derived name is not
1257
+ * human-friendly (e.g. APS workspace IDs → aliases, application IDs → names).
1258
+ */
1259
+ export async function enrichResourceNames(region, resources) {
1260
+ // APS workspaces: resolve alias
1261
+ const apsResources = resources.filter((r) => r.type === 'APS Workspace');
1262
+ if (apsResources.length) {
1263
+ const client = new AmpClient({ region });
1264
+ for (const r of apsResources) {
1265
+ try {
1266
+ const wsId = arnToName(r.arn);
1267
+ const resp = await client.send(new DescribeWorkspaceCommand({ workspaceId: wsId }));
1268
+ if (resp.workspace?.alias) r.displayName = resp.workspace.alias;
1269
+ } catch { /* keep default */ }
1270
+ }
1271
+ }
1272
+ // OpenSearch Applications: resolve name from ID
1273
+ const appResources = resources.filter((r) => r.type === 'OpenSearch Application');
1274
+ if (appResources.length) {
780
1275
  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;
1276
+ const client = new OpenSearchClient({ region });
1277
+ const list = await client.send(new ListApplicationsCommand({}));
1278
+ for (const r of appResources) {
1279
+ const appId = arnToName(r.arn);
1280
+ const app = (list.ApplicationSummaries || []).find((a) => a.id === appId);
1281
+ if (app?.name) r.displayName = app.name;
786
1282
  }
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}`);
1283
+ } catch { /* keep default */ }
1284
+ }
1285
+ }
1286
+
1287
+ /**
1288
+ * Find OpenSearch Applications whose name matches a stack name and add them
1289
+ * to the resource list if not already present (tagging API may not return them).
1290
+ */
1291
+ async function supplementApplications(region, stacks) {
1292
+ if (stacks.size === 0) return;
1293
+ try {
1294
+ const client = new OpenSearchClient({ region });
1295
+ const list = await client.send(new ListApplicationsCommand({}));
1296
+ for (const app of list.ApplicationSummaries || []) {
1297
+ if (!app.name || !app.arn) continue;
1298
+ const resources = stacks.get(app.name);
1299
+ if (!resources) continue;
1300
+ const alreadyPresent = resources.some((r) => r.type === 'OpenSearch Application');
1301
+ if (!alreadyPresent) {
1302
+ resources.push({ arn: app.arn, type: 'OpenSearch Application' });
791
1303
  }
792
- } catch (err) {
793
- if (err.message.startsWith('Pipeline update failed')) throw err;
794
1304
  }
795
- await sleepWithTicker(10_000, spinner, start,
796
- (s) => `Waiting for pipeline update... (${fmtElapsed(s)})`);
1305
+ } catch { /* best effort */ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Fetch detailed information for a single resource by ARN.
1310
+ * Returns { entries: [[label, value], ...], rawConfig?: string }.
1311
+ */
1312
+ export async function describeResource(region, resource) {
1313
+ const { arn, type } = resource;
1314
+ const name = arnToName(arn);
1315
+ const entries = [['ARN', arn]];
1316
+ let rawConfig;
1317
+
1318
+ try {
1319
+ if (type === 'OSI Pipeline') {
1320
+ const client = new OSISClient({ region });
1321
+ const resp = await client.send(new GetPipelineCommand({ PipelineName: name }));
1322
+ const p = resp.Pipeline || {};
1323
+ entries.push(['Status', p.Status || 'Unknown']);
1324
+ if (p.StatusReason?.Message) entries.push(['Status Reason', p.StatusReason.Message]);
1325
+ entries.push(['Min Units', String(p.MinUnits ?? '')]);
1326
+ entries.push(['Max Units', String(p.MaxUnits ?? '')]);
1327
+ if (p.IngestEndpointUrls?.length) {
1328
+ for (const url of p.IngestEndpointUrls) entries.push(['Ingest Endpoint', url]);
1329
+ }
1330
+ if (p.PipelineRoleArn) entries.push(['Role ARN', p.PipelineRoleArn]);
1331
+ if (p.CreatedAt) entries.push(['Created', p.CreatedAt.toISOString()]);
1332
+ if (p.LastUpdatedAt) entries.push(['Last Updated', p.LastUpdatedAt.toISOString()]);
1333
+ if (p.PipelineConfigurationBody) rawConfig = p.PipelineConfigurationBody;
1334
+ } else if (type === 'OpenSearch Serverless') {
1335
+ const client = new OpenSearchServerlessClient({ region });
1336
+ const resp = await client.send(new BatchGetCollectionCommand({ ids: [name] }));
1337
+ const c = (resp.collectionDetails || [])[0] || {};
1338
+ if (c.status) entries.push(['Status', c.status]);
1339
+ if (c.type) entries.push(['Type', c.type]);
1340
+ if (c.collectionEndpoint) entries.push(['Collection Endpoint', c.collectionEndpoint]);
1341
+ if (c.dashboardEndpoint) entries.push(['Dashboard Endpoint', c.dashboardEndpoint]);
1342
+ if (c.description) entries.push(['Description', c.description]);
1343
+ if (c.createdDate) entries.push(['Created', new Date(c.createdDate).toISOString()]);
1344
+ if (c.lastModifiedDate) entries.push(['Last Modified', new Date(c.lastModifiedDate).toISOString()]);
1345
+ } else if (type === 'OpenSearch Domain') {
1346
+ const client = new OpenSearchClient({ region });
1347
+ const resp = await client.send(new DescribeDomainCommand({ DomainName: name }));
1348
+ const d = resp.DomainStatus || {};
1349
+ if (d.EngineVersion) entries.push(['Engine Version', d.EngineVersion]);
1350
+ if (d.Endpoint) entries.push(['Endpoint', `https://${d.Endpoint}`]);
1351
+ if (d.ClusterConfig) {
1352
+ const cc = d.ClusterConfig;
1353
+ if (cc.InstanceType) entries.push(['Instance Type', cc.InstanceType]);
1354
+ entries.push(['Instance Count', String(cc.InstanceCount ?? 1)]);
1355
+ }
1356
+ entries.push(['Processing', String(d.Processing ?? false)]);
1357
+ if (d.Created !== undefined) entries.push(['Created', String(d.Created)]);
1358
+ } else if (type === 'APS Workspace') {
1359
+ const wsId = name;
1360
+ const client = new AmpClient({ region });
1361
+ const resp = await client.send(new DescribeWorkspaceCommand({ workspaceId: wsId }));
1362
+ const w = resp.workspace || {};
1363
+ if (w.status?.statusCode) entries.push(['Status', w.status.statusCode]);
1364
+ if (w.alias) entries.push(['Alias', w.alias]);
1365
+ if (w.prometheusEndpoint) entries.push(['Prometheus Endpoint', w.prometheusEndpoint]);
1366
+ if (w.createdAt) entries.push(['Created', w.createdAt.toISOString()]);
1367
+ } else if (type === 'IAM Role') {
1368
+ const client = new IAMClient({ region });
1369
+ const resp = await client.send(new GetRoleCommand({ RoleName: name }));
1370
+ const r = resp.Role || {};
1371
+ if (r.Description) entries.push(['Description', r.Description]);
1372
+ if (r.Path) entries.push(['Path', r.Path]);
1373
+ if (r.CreateDate) entries.push(['Created', r.CreateDate.toISOString()]);
1374
+ if (r.MaxSessionDuration) entries.push(['Max Session Duration', `${r.MaxSessionDuration}s`]);
1375
+ } else if (type === 'DQ Data Source') {
1376
+ const client = new OpenSearchClient({ region });
1377
+ const resp = await client.send(new GetDirectQueryDataSourceCommand({ DataSourceName: name }));
1378
+ if (resp.DataSourceType) {
1379
+ const typeKey = Object.keys(resp.DataSourceType)[0];
1380
+ if (typeKey) entries.push(['Data Source Type', typeKey]);
1381
+ }
1382
+ if (resp.Description) entries.push(['Description', resp.Description]);
1383
+ if (resp.OpenSearchArns?.length) {
1384
+ for (const a of resp.OpenSearchArns) entries.push(['OpenSearch ARN', a]);
1385
+ }
1386
+ } else if (type === 'OpenSearch Application') {
1387
+ const client = new OpenSearchClient({ region });
1388
+ const appId = name;
1389
+ const resp = await client.send(new GetApplicationCommand({ id: appId }));
1390
+ if (resp.status) entries.push(['Status', resp.status]);
1391
+ if (resp.endpoint) entries.push(['Endpoint', resp.endpoint]);
1392
+ if (resp.dataSources?.length) {
1393
+ for (const ds of resp.dataSources) {
1394
+ if (ds.dataSourceArn) entries.push(['Data Source', ds.dataSourceArn]);
1395
+ }
1396
+ }
1397
+ if (resp.createdAt) entries.push(['Created', resp.createdAt.toISOString()]);
1398
+ if (resp.lastUpdatedAt) entries.push(['Last Updated', resp.lastUpdatedAt.toISOString()]);
1399
+ } else if (type === 'EKS Cluster') {
1400
+ const client = new EKSClient({ region });
1401
+ const resp = await client.send(new DescribeClusterCommand({ name }));
1402
+ const c = resp.cluster || {};
1403
+ if (c.status) entries.push(['Status', c.status]);
1404
+ if (c.version) entries.push(['Kubernetes Version', c.version]);
1405
+ if (c.platformVersion) entries.push(['Platform Version', c.platformVersion]);
1406
+ if (c.endpoint) entries.push(['API Endpoint', c.endpoint]);
1407
+ if (c.roleArn) entries.push(['Role ARN', c.roleArn]);
1408
+ if (c.resourcesVpcConfig) {
1409
+ const vpc = c.resourcesVpcConfig;
1410
+ if (vpc.vpcId) entries.push(['VPC', vpc.vpcId]);
1411
+ }
1412
+ if (c.createdAt) entries.push(['Created', c.createdAt.toISOString()]);
1413
+ }
1414
+ } catch (err) {
1415
+ entries.push(['Error', err.message]);
797
1416
  }
798
1417
 
799
- spinner.warn('Timed out waiting for update — pipeline may still be updating');
1418
+ return { entries, rawConfig };
800
1419
  }
801
1420
 
802
1421
  // ── Helpers ─────────────────────────────────────────────────────────────────