@@ -43,6 +43,7 @@ import {
4343 printWarning ,
4444 printInfo ,
4545 createSpinner ,
46+ createAsciiAnimation ,
4647} from './ui.mjs' ;
4748import chalk from 'chalk' ;
4849import { 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 ( / a s s u m e d - r o l e \/ ( [ ^ / ] + ) \/ / ) ;
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 ( / a s s u m e d - r o l e \/ ( [ ^ / ] + ) \/ / ) ;
153+ const userMatch = arn . match ( / : u s e r \/ ( .+ ) $ / ) ;
154+ const fedMatch = arn . match ( / : f e d e r a t e d - u s e r \/ ( .+ ) $ / ) ;
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 ( / a l r e a d y e x i s t s / 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 */
715745export async function createOpenSearchApplication ( cfg ) {
716746 const appName = cfg . appName ;
@@ -797,7 +827,7 @@ async function fetchAppEndpoint(client, cfg) {
797827function 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 ( / s e a r c h - ( .+ ?) - [ a - z 0 - 9 ] + \. [ a - z 0 - 9 - ] + \. e s \. a m a z o n a w s \. c o m / ) ;
@@ -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 */
820850async 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