Skip to content

Commit 3c25e5a

Browse files
authored
[cli] Fix bugs and improve UI (#154)
1 parent d9cd158 commit 3c25e5a

File tree

12 files changed

+476
-222
lines changed

12 files changed

+476
-222
lines changed

aws/cli-installer/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ Takes ~15 minutes. When complete, the CLI prints a dashboard URL — open it and
2222
| OpenSearch domain | Stores logs, traces, and service map data |
2323
| OSIS pipeline | Ingests OTLP data (logs, traces, metrics) via SigV4 |
2424
| Amazon Managed Prometheus | Stores time-series metrics |
25-
| DQS Prometheus datasource | Connects AMP to OpenSearch for metric queries |
25+
| Connected Data Source (Prometheus) | Connects AMP to OpenSearch for metric queries |
2626
| OpenSearch Application | UI with workspace, index patterns, correlations, dashboards |
27-
| IAM roles | Pipeline role (OSIS → OpenSearch + AMP) and DQS role |
27+
| IAM roles | Pipeline role (OSIS → OpenSearch + AMP) and Connected Data Source role |
2828
| EC2 instance (t3.xlarge) | Runs OTel Demo + example agents (29 containers generating telemetry) |
2929

3030
All resources are tagged with `observability-stack:pipeline-name` for identification and cleanup.
@@ -68,7 +68,7 @@ node bin/cli-installer.mjs destroy \
6868
--region us-east-1
6969
```
7070

71-
Deletes: EC2 instance, OpenSearch Application, DQS datasource, OSIS pipeline, IAM roles. OpenSearch domain and AMP workspace are preserved (shared resources).
71+
Deletes: EC2 instance, OpenSearch Application, Connected Data Source, OSIS pipeline, IAM roles. OpenSearch domain and AMP workspace are preserved (shared resources).
7272

7373
## Prerequisites
7474

@@ -93,7 +93,7 @@ aws/cli-installer/
9393
├── src/
9494
│ ├── main.mjs # CLI orchestration + executePipeline flow
9595
│ ├── cli.mjs # Argument parsing + config
96-
│ ├── aws.mjs # AWS resource creation (IAM, OSIS, DQS, Application)
96+
│ ├── aws.mjs # AWS resource creation (IAM, OSIS, Connected Data Source, Application)
9797
│ ├── render.mjs # OSIS pipeline YAML generation
9898
│ ├── opensearch-ui-init.mjs # OpenSearch UI setup (SigV4, workspace, dashboards)
9999
│ ├── ec2-demo.mjs # EC2 demo workload launcher

aws/cli-installer/src/aws.mjs

Lines changed: 96 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
printWarning,
4444
printInfo,
4545
createSpinner,
46+
createAsciiAnimation,
4647
} from './ui.mjs';
4748
import chalk from 'chalk';
4849
import { randomBytes } from 'node:crypto';
@@ -145,11 +146,20 @@ export async function checkRequirements(cfg) {
145146
}
146147

147148
cfg.accountId = identity.Account;
148-
// Extract the IAM role ARN from the caller identity for FGAC and OpenSearch UI access
149-
// identity.Arn is like arn:aws:sts::123:assumed-role/RoleName/session
150-
const arnMatch = identity.Arn.match(/assumed-role\/([^/]+)\//);
151-
if (arnMatch) {
152-
cfg.callerRoleArn = `arn:aws:iam::${cfg.accountId}:role/${arnMatch[1]}`;
149+
// Extract the caller's IAM principal for FGAC mapping.
150+
// Handles: assumed-role, IAM user, federated user, and root.
151+
const arn = identity.Arn;
152+
const assumedMatch = arn.match(/assumed-role\/([^/]+)\//);
153+
const userMatch = arn.match(/:user\/(.+)$/);
154+
const fedMatch = arn.match(/:federated-user\/(.+)$/);
155+
if (assumedMatch) {
156+
cfg.callerPrincipal = { arn: `arn:aws:iam::${cfg.accountId}:role/${assumedMatch[1]}`, type: 'role' };
157+
} else if (userMatch) {
158+
cfg.callerPrincipal = { arn, type: 'user' };
159+
} else if (fedMatch) {
160+
cfg.callerPrincipal = { arn, type: 'user' };
161+
} else if (arn.endsWith(':root')) {
162+
cfg.callerPrincipal = { arn, type: 'user' };
153163
}
154164
printSuccess(`Authenticated — account ${cfg.accountId}`);
155165
printInfo(`Identity: ${identity.Arn}`);
@@ -252,25 +262,29 @@ async function createManagedDomain(cfg) {
252262
// Poll for endpoint
253263
const spinner = createSpinner('Provisioning OpenSearch domain (20-30 min)...');
254264
spinner.start();
265+
const anim = createAsciiAnimation('opensearch');
266+
anim.start(spinner);
255267
const maxWait = 1800_000; // 30 min
256268
const interval = 10_000;
257269
const start = Date.now();
270+
anim.setStatus(() => `Provisioning OpenSearch domain... (${fmtElapsed(Math.round((Date.now() - start) / 1000))} elapsed)`);
258271

259272
while (Date.now() - start < maxWait) {
260273
try {
261274
const desc = await client.send(new DescribeDomainCommand({ DomainName: cfg.osDomainName }));
262275
const endpoint = desc.DomainStatus?.Endpoint;
263276
if (endpoint) {
264277
cfg.opensearchEndpoint = `https://${endpoint}`;
265-
spinner.succeed(`Domain ready: ${cfg.opensearchEndpoint}`);
278+
anim.stop();
279+
spinner.succeed(`Domain ready: ${cfg.opensearchEndpoint} (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
266280
return;
267281
}
268282
} catch { /* keep polling */ }
269-
await sleepWithTicker(interval, spinner, start,
270-
(s) => `Provisioning OpenSearch domain... (${fmtElapsed(s)} elapsed)`);
283+
await sleep(interval);
271284
}
272285

273-
spinner.fail('Timed out waiting for OpenSearch domain');
286+
anim.stop();
287+
spinner.fail(`Timed out waiting for OpenSearch domain (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
274288
throw new Error('Timed out waiting for OpenSearch domain');
275289
}
276290

@@ -296,11 +310,16 @@ export async function mapOsiRoleInDomain(cfg) {
296310
const url = `${cfg.opensearchEndpoint}/_plugins/_security/api/rolesmapping`;
297311
const auth = Buffer.from(`${cfg.opensearchUser || 'admin'}:${masterPass}`).toString('base64');
298312

299-
// Map both the OSI pipeline role and the caller's role (for OpenSearch UI access)
300-
const callerRoleArn = cfg.callerRoleArn || '';
301-
const newRoles = [cfg.iamRoleArn];
302-
if (callerRoleArn && callerRoleArn !== cfg.iamRoleArn) {
303-
newRoles.push(callerRoleArn);
313+
// Map both the OSI pipeline role and the caller's principal (for OpenSearch UI access)
314+
const callerPrincipal = cfg.callerPrincipal; // { arn, type: 'role'|'user' }
315+
const newBackendRoles = [cfg.iamRoleArn];
316+
const newUsers = [];
317+
if (callerPrincipal && callerPrincipal.arn !== cfg.iamRoleArn) {
318+
if (callerPrincipal.type === 'role') {
319+
newBackendRoles.push(callerPrincipal.arn);
320+
} else {
321+
newUsers.push(callerPrincipal.arn);
322+
}
304323
}
305324

306325
// Map to both all_access and security_manager for full permissions (including PPL)
@@ -312,19 +331,25 @@ export async function mapOsiRoleInDomain(cfg) {
312331
for (const role of rolesToMap) {
313332
const roleUrl = `${url}/${role}`;
314333
const getResp = await fetch(roleUrl, { headers });
315-
let existing = [];
334+
let existingBackendRoles = [];
335+
let existingUsers = [];
316336
if (getResp.ok) {
317337
const data = await getResp.json();
318-
existing = data?.[role]?.backend_roles || [];
338+
existingBackendRoles = data?.[role]?.backend_roles || [];
339+
existingUsers = data?.[role]?.users || [];
340+
}
341+
const mergedBackendRoles = [...new Set([...existingBackendRoles, ...newBackendRoles])];
342+
const mergedUsers = [...new Set([...existingUsers, ...newUsers])];
343+
344+
const ops = [{ op: 'add', path: '/backend_roles', value: mergedBackendRoles }];
345+
if (newUsers.length) {
346+
ops.push({ op: 'add', path: '/users', value: mergedUsers });
319347
}
320-
const merged = [...new Set([...existing, ...newRoles])];
321348

322349
const resp = await fetch(roleUrl, {
323350
method: 'PATCH',
324351
headers,
325-
body: JSON.stringify([
326-
{ op: 'add', path: '/backend_roles', value: merged },
327-
]),
352+
body: JSON.stringify(ops),
328353
});
329354

330355
if (!resp.ok) {
@@ -535,8 +560,11 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
535560
// Wait for pipeline to become active
536561
const spinner = createSpinner('Waiting for pipeline to activate...');
537562
spinner.start();
563+
const anim = createAsciiAnimation('pipeline');
564+
anim.start(spinner);
538565
const maxWait = 1200_000; // 20 min
539566
const start = Date.now();
567+
anim.setStatus(() => `Waiting for pipeline... (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
540568

541569
while (Date.now() - start < maxWait) {
542570
try {
@@ -545,27 +573,29 @@ export async function createOsiPipeline(cfg, pipelineYaml) {
545573
if (status === 'ACTIVE') {
546574
const urls = resp.Pipeline?.IngestEndpointUrls || [];
547575
cfg.ingestEndpoints = urls;
548-
spinner.succeed('Pipeline is active');
576+
anim.stop();
577+
spinner.succeed(`Pipeline is active (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
549578
for (const url of urls) {
550579
printInfo(`Ingestion endpoint: https://${url}`);
551580
}
552581
return;
553582
}
554583
if (status === 'CREATE_FAILED') {
555584
const reason = resp.Pipeline?.StatusReason?.Description || 'unknown';
556-
spinner.fail('Pipeline creation failed');
585+
anim.stop();
586+
spinner.fail(`Pipeline creation failed (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
557587
printInfo(`Reason: ${reason}`);
558588
throw new Error(`Pipeline creation failed: ${reason}`);
559589
}
560590
} catch (err) {
561591
if (err.message?.startsWith('Pipeline creation failed')) throw err;
562592
/* keep polling */
563593
}
564-
await sleepWithTicker(10_000, spinner, start,
565-
(s) => `Waiting for pipeline... (${fmtElapsed(s)})`);
594+
await sleep(10_000);
566595
}
567596

568-
spinner.fail('Timed out waiting for pipeline after 15 minutes');
597+
anim.stop();
598+
spinner.fail(`Timed out waiting for pipeline (${fmtElapsed(Math.round((Date.now() - start) / 1000))})`);
569599
throw new Error(`Pipeline '${cfg.pipelineName}' did not become active within 15 minutes`);
570600
}
571601

@@ -599,23 +629,23 @@ export async function setupDashboards(cfg) {
599629
printSuccess(`URL: ${cfg.dashboardsUrl}`);
600630
}
601631

602-
// ── Direct Query Data Source (AMP → OpenSearch) ─────────────────────────────
632+
// ── Connected Data Source (AMP → OpenSearch) ────────────────────────────────
603633

604634
/**
605-
* Create an IAM role for the Direct Query Service to access AMP.
635+
* Create an IAM role for the Connected Data Source to access AMP.
606636
* Trust policy allows directquery.opensearchservice.amazonaws.com to assume it.
607637
*/
608-
export async function createDqsPrometheusRole(cfg) {
609-
const roleName = cfg.dqsRoleName;
610-
printStep(`Creating DQS Prometheus role '${roleName}'...`);
638+
export async function createConnectedDataSourceRole(cfg) {
639+
const roleName = cfg.connectedDataSourceRoleName;
640+
printStep(`Creating Connected Data Source Prometheus role '${roleName}'...`);
611641

612642
const client = new IAMClient({ region: cfg.region });
613643

614644
// Check if role already exists
615645
try {
616646
const existing = await client.send(new GetRoleCommand({ RoleName: roleName }));
617-
cfg.dqsRoleArn = existing.Role.Arn;
618-
printSuccess(`DQS role already exists: ${cfg.dqsRoleArn}`);
647+
cfg.connectedDataSourceRoleArn = existing.Role.Arn;
648+
printSuccess(`Connected Data Source role already exists: ${cfg.connectedDataSourceRoleArn}`);
619649
return;
620650
} catch (err) {
621651
if (err.name !== 'NoSuchEntityException') throw err;
@@ -636,13 +666,13 @@ export async function createDqsPrometheusRole(cfg) {
636666
AssumeRolePolicyDocument: trustPolicy,
637667
Tags: stackTags(cfg.pipelineName),
638668
}));
639-
cfg.dqsRoleArn = result.Role.Arn;
640-
printSuccess(`DQS role created: ${cfg.dqsRoleArn}`);
669+
cfg.connectedDataSourceRoleArn = result.Role.Arn;
670+
printSuccess(`Connected Data Source role created: ${cfg.connectedDataSourceRoleArn}`);
641671
} catch (err) {
642-
printError('Failed to create DQS Prometheus role');
672+
printError('Failed to create Connected Data Source Prometheus role');
643673
console.error(` ${chalk.dim(err.message)}`);
644674
console.error();
645-
throw new Error('Failed to create DQS Prometheus role');
675+
throw new Error('Failed to create Connected Data Source Prometheus role');
646676
}
647677

648678
// Attach APS access policy
@@ -658,24 +688,24 @@ export async function createDqsPrometheusRole(cfg) {
658688
PolicyName: 'APSAccess',
659689
PolicyDocument: permissionsPolicy,
660690
}));
661-
printSuccess('APS access policy attached to DQS role');
691+
printSuccess('APS access policy attached to Connected Data Source role');
662692
} catch (err) {
663-
printError('Failed to attach APS policy to DQS role');
693+
printError('Failed to attach APS policy to Connected Data Source role');
664694
console.error(` ${chalk.dim(err.message)}`);
665695
console.error();
666-
throw new Error('Failed to attach APS policy to DQS role');
696+
throw new Error('Failed to attach APS policy to Connected Data Source role');
667697
}
668698

669699
await sleep(5000);
670700
}
671701

672702
/**
673-
* Create a Direct Query Data Source connecting OpenSearch to AMP (Prometheus).
703+
* Create a Connected Data Source connecting OpenSearch to AMP (Prometheus).
674704
* Uses the OpenSearch service control plane API.
675705
*/
676-
export async function createDirectQueryDataSource(cfg) {
677-
const dataSourceName = cfg.dqsDataSourceName;
678-
printStep(`Creating Direct Query data source '${dataSourceName}'...`);
706+
export async function createConnectedDataSource(cfg) {
707+
const dataSourceName = cfg.connectedDataSourceName;
708+
printStep(`Creating Connected Data Source '${dataSourceName}'...`);
679709

680710
const client = new OpenSearchClient({ region: cfg.region });
681711
const workspaceArn = `arn:aws:aps:${cfg.region}:${cfg.accountId}:workspace/${cfg.apsWorkspaceId}`;
@@ -685,32 +715,32 @@ export async function createDirectQueryDataSource(cfg) {
685715
DataSourceName: dataSourceName,
686716
DataSourceType: {
687717
Prometheus: {
688-
RoleArn: cfg.dqsRoleArn,
718+
RoleArn: cfg.connectedDataSourceRoleArn,
689719
WorkspaceArn: workspaceArn,
690720
},
691721
},
692722
Description: `Prometheus data source for ${cfg.pipelineName} observability stack`,
693723
}));
694-
cfg.dqsDataSourceArn = result.DataSourceArn;
695-
printSuccess(`Direct Query data source created: ${cfg.dqsDataSourceArn}`);
696-
await tagResource(cfg.region, cfg.dqsDataSourceArn, cfg.pipelineName);
724+
cfg.connectedDataSourceArn = result.DataSourceArn;
725+
printSuccess(`Connected Data Source created: ${cfg.connectedDataSourceArn}`);
726+
await tagResource(cfg.region, cfg.connectedDataSourceArn, cfg.pipelineName);
697727
} catch (err) {
698728
// Treat "already exists" as success
699729
if (/already exists/i.test(err.message) || err.name === 'ResourceAlreadyExistsException') {
700-
cfg.dqsDataSourceArn = `arn:aws:opensearch:${cfg.region}:${cfg.accountId}:datasource/${dataSourceName}`;
730+
cfg.connectedDataSourceArn = `arn:aws:opensearch:${cfg.region}:${cfg.accountId}:datasource/${dataSourceName}`;
701731
printSuccess(`Data source '${dataSourceName}' already exists`);
702732
return;
703733
}
704-
printError('Failed to create Direct Query data source');
734+
printError('Failed to create Connected Data Source');
705735
console.error(` ${chalk.dim(err.message)}`);
706736
console.error();
707-
throw new Error('Failed to create Direct Query data source');
737+
throw new Error('Failed to create Connected Data Source');
708738
}
709739
}
710740

711741
/**
712742
* Create an OpenSearch Application (the new OpenSearch UI) and associate
713-
* the OpenSearch domain/collection and the DQS data source with it.
743+
* the OpenSearch domain/collection and the Connected Data Source with it.
714744
*/
715745
export async function createOpenSearchApplication(cfg) {
716746
const appName = cfg.appName;
@@ -797,7 +827,7 @@ async function fetchAppEndpoint(client, cfg) {
797827
function buildAppDataSources(cfg) {
798828
const dataSources = [];
799829
// Derive the domain name from the endpoint URL if reusing,
800-
// otherwise use cfg.osDomainName (which may be set by applySimpleDefaults)
830+
// otherwise use cfg.osDomainName (which may be set by applyQuickDefaults)
801831
let domainName = cfg.osDomainName;
802832
if (cfg.opensearchEndpoint && cfg.osAction === 'reuse') {
803833
const m = cfg.opensearchEndpoint.match(/search-(.+?)-[a-z0-9]+\.[a-z0-9-]+\.es\.amazonaws\.com/);
@@ -808,14 +838,14 @@ function buildAppDataSources(cfg) {
808838
dataSourceArn: `arn:aws:es:${cfg.region}:${cfg.accountId}:domain/${domainName}`,
809839
});
810840
}
811-
if (cfg.dqsDataSourceArn) {
812-
dataSources.push({ dataSourceArn: cfg.dqsDataSourceArn });
841+
if (cfg.connectedDataSourceArn) {
842+
dataSources.push({ dataSourceArn: cfg.connectedDataSourceArn });
813843
}
814844
return dataSources;
815845
}
816846

817847
/**
818-
* Associate the OpenSearch domain and DQS data source with the application.
848+
* Associate the OpenSearch domain and Connected Data Source with the application.
819849
*/
820850
async function associateDataSourcesWithApp(cfg, client) {
821851
if (!cfg.appId) return;
@@ -848,15 +878,17 @@ export async function listDomains(region) {
848878
const { DomainNames } = await client.send(new ListDomainNamesCommand({}));
849879
if (DomainNames?.length) {
850880
const names = DomainNames.map((d) => d.DomainName);
851-
const { DomainStatusList } = await client.send(
852-
new DescribeDomainsCommand({ DomainNames: names }),
853-
);
854-
for (const d of DomainStatusList || []) {
855-
results.push({
856-
name: d.DomainName,
857-
endpoint: d.Endpoint ? `https://${d.Endpoint}` : '',
858-
engineVersion: d.EngineVersion || '',
859-
});
881+
for (let j = 0; j < names.length; j += 5) {
882+
const { DomainStatusList } = await client.send(
883+
new DescribeDomainsCommand({ DomainNames: names.slice(j, j + 5) }),
884+
);
885+
for (const d of DomainStatusList || []) {
886+
results.push({
887+
name: d.DomainName,
888+
endpoint: d.Endpoint ? `https://${d.Endpoint}` : '',
889+
engineVersion: d.EngineVersion || '',
890+
});
891+
}
860892
}
861893
}
862894
} catch { /* listing failed */ }

0 commit comments

Comments
 (0)