diff --git a/tests/grafana/index.test.ts b/tests/grafana/index.test.ts index 76b8b5f8..1a033cc2 100644 --- a/tests/grafana/index.test.ts +++ b/tests/grafana/index.test.ts @@ -5,6 +5,7 @@ import * as automation from '../automation'; import { requireEnv, unwrapOutputs } from '../util'; import { testAmpGrafana } from './amp-grafana.test'; import { testConfigurableGrafana } from './configurable-grafana.test'; +import { testLogsAndTracesGrafana } from './logs-and-traces-grafana.test'; import * as infraConfig from './infrastructure/config'; import { GrafanaTestContext, ProgramOutput } from './test-context'; @@ -42,4 +43,5 @@ describe('Grafana component deployment', () => { describe('AMP Grafana', () => testAmpGrafana(ctx)); describe('Configurable Grafana', () => testConfigurableGrafana(ctx)); + describe('Logs & Traces Grafana', () => testLogsAndTracesGrafana(ctx)); }); diff --git a/tests/grafana/infrastructure/index.ts b/tests/grafana/infrastructure/index.ts index 25be3961..80a3d73d 100644 --- a/tests/grafana/infrastructure/index.ts +++ b/tests/grafana/infrastructure/index.ts @@ -131,4 +131,37 @@ const configurableGrafana = new studion.grafana.GrafanaBuilder( ) .build({ parent }); -export { webServer, ampWorkspace, ampGrafana, configurableGrafana }; +const clodwatchLogsDataSourceName = `${appName}-cw-logs-datasource`; +const xRayDataSourceName = `${appName}-x-ray-datasource`; +const logsAndTracesGrafana = new studion.grafana.GrafanaBuilder( + `${appName}-logs-traces`, +) + .addCLoudWatchLogs(`${appName}-lt-cwl`, { + awsAccountId, + region: aws.config.requireRegion(), + dataSourceName: clodwatchLogsDataSourceName, + }) + .addXRay(`${appName}-lt-xray`, { + awsAccountId, + region: aws.config.requireRegion(), + dataSourceName: xRayDataSourceName, + }) + .addLogsAndTracesDashboard({ + name: `${appName}-lat-dashboard`, + title: 'ICB Grafana Test Logs & Traces', + logsDataSourceName: clodwatchLogsDataSourceName, + logGroupName: cloudWatchLogGroup.name as unknown as pulumi.Unwrap< + typeof cloudWatchLogGroup.name + >, + tracesDataSourceName: xRayDataSourceName, + }) + .build(); + +export { + webServer, + ampWorkspace, + ampGrafana, + cloudWatchLogGroup, + configurableGrafana, + logsAndTracesGrafana, +}; diff --git a/tests/grafana/logs-and-traces-grafana.test.ts b/tests/grafana/logs-and-traces-grafana.test.ts new file mode 100644 index 00000000..65e04ad9 --- /dev/null +++ b/tests/grafana/logs-and-traces-grafana.test.ts @@ -0,0 +1,349 @@ +import { it } from 'node:test'; +import * as assert from 'node:assert'; +import * as studion from '@studion/infra-code-blocks'; +import { + GetRoleCommand, + GetRolePolicyCommand, + ListRolePoliciesCommand, +} from '@aws-sdk/client-iam'; +import { Unwrap } from '@pulumi/pulumi'; +import { backOff } from '../util'; +import { GrafanaTestContext } from './test-context'; +import { grafanaRequest, requestEndpointWithExpectedStatus } from './util'; + +export function testLogsAndTracesGrafana(ctx: GrafanaTestContext) { + it('should have created the IAM role with CloudWwatch logs inline policy', async () => { + const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[0].role; + const grafanaCloudWatchLogsRoleArn = iamRole.arn as unknown as Unwrap< + typeof iamRole.arn + >; + const roleName = grafanaCloudWatchLogsRoleArn.split('/').pop()!; + const { Role } = await ctx.clients.iam.send( + new GetRoleCommand({ RoleName: roleName }), + ); + assert.ok(Role, 'Grafana IAM role should exist'); + + const { PolicyNames } = await ctx.clients.iam.send( + new ListRolePoliciesCommand({ RoleName: roleName }), + ); + assert.ok( + PolicyNames && PolicyNames.length > 0, + 'IAM role should have at least one inline policy', + ); + + const { PolicyDocument } = await ctx.clients.iam.send( + new GetRolePolicyCommand({ + RoleName: roleName, + PolicyName: PolicyNames[0], + }), + ); + const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { + Statement: Array<{ Action: string[] }>; + }; + const actions = policy.Statement.flatMap(s => s.Action).sort(); + const expectedActions = [ + 'logs:DescribeLogGroups', + 'logs:GetLogGroupFields', + 'logs:StartQuery', + 'logs:StopQuery', + 'logs:GetQueryResults', + 'logs:GetLogEvents', + ].sort(); + assert.deepStrictEqual( + actions, + expectedActions, + 'CloudWatch logs policy actions do not match expected actions', + ); + }); + + it('should have created the IAM role with xRay inline policy', async () => { + const iamRole = ctx.outputs!.logsAndTracesGrafana.connections[1].role; + const grafanaXRayRoleArn = iamRole.arn as unknown as Unwrap< + typeof iamRole.arn + >; + const roleName = grafanaXRayRoleArn.split('/').pop()!; + const { Role } = await ctx.clients.iam.send( + new GetRoleCommand({ RoleName: roleName }), + ); + assert.ok(Role, 'Grafana IAM role should exist'); + + const { PolicyNames } = await ctx.clients.iam.send( + new ListRolePoliciesCommand({ RoleName: roleName }), + ); + assert.ok( + PolicyNames && PolicyNames.length > 0, + 'IAM role should have at least one inline policy', + ); + + const { PolicyDocument } = await ctx.clients.iam.send( + new GetRolePolicyCommand({ + RoleName: roleName, + PolicyName: PolicyNames[0], + }), + ); + const policy = JSON.parse(decodeURIComponent(PolicyDocument!)) as { + Statement: Array<{ Action: string[] }>; + }; + const actions = policy.Statement.flatMap(s => s.Action).sort(); + const expectedActions = [ + 'xray:BatchGetTraces', + 'xray:GetTraceSummaries', + 'xray:GetTraceGraph', + 'xray:GetGroups', + 'xray:GetTimeSeriesServiceStatistics', + 'xray:GetInsightSummaries', + 'xray:GetInsight', + 'xray:GetServiceGraph', + 'ec2:DescribeRegions', + ].sort(); + assert.deepStrictEqual( + actions, + expectedActions, + 'XRay policy actions do not match expected actions', + ); + }); + + it('should have created the CloudWatch data source', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const cloudWatchDataSource = ( + grafana.connections[0] as studion.grafana.CloudWatchLogsConnection + ).dataSource; + const cloudWatchDataSourceName = + cloudWatchDataSource.name as unknown as Unwrap< + typeof cloudWatchDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(cloudWatchDataSourceName)}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected data source to exist'); + + const data = (await body.json()) as Record; + assert.strictEqual( + data.type, + 'cloudwatch', + 'Expected CloudWatch data source type', + ); + }); + }); + + it('should have created the XRay data source', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const xRayDataSource = ( + grafana.connections[1] as studion.grafana.XRayConnection + ).dataSource; + const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< + typeof xRayDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected data source to exist'); + + const data = (await body.json()) as Record; + assert.strictEqual( + data.type, + 'grafana-x-ray-datasource', + 'Expected XRay data source type', + ); + }); + }); + + it('should have created the dashboard with expected panels and variables', async () => { + const grafana = ctx.outputs!.logsAndTracesGrafana; + const dashboard = grafana.dashboards[0]; + const dashboardUid = dashboard.uid as unknown as Unwrap< + typeof dashboard.uid + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'GET', + `/api/dashboards/uid/${dashboardUid}`, + authToken, + ); + assert.strictEqual(statusCode, 200, 'Expected dashboard to exist'); + + const data = (await body.json()) as { + dashboard: { + title: string; + panels: Array<{ title: string }>; + templating: { list: Array<{ name: string }> }; + }; + }; + assert.strictEqual( + data.dashboard.title, + 'ICB Grafana Test Logs & Traces', + 'Expected dashboard title to match', + ); + + const panelTitles = data.dashboard.panels.map(p => p.title).sort(); + const expectedPanels = ['Logs']; + assert.deepStrictEqual( + panelTitles, + expectedPanels.sort(), + 'Dashboard panels do not match expected panels', + ); + + const variableNames = data.dashboard.templating.list + .map(p => p.name) + .sort(); + const expectedVariables = [ + 'search_text', + 'log_level', + 'status_code', + 'limit', + ]; + assert.deepStrictEqual( + variableNames, + expectedVariables.sort(), + 'Dashboard variables do not match expected variables', + ); + }); + }); + + it('should display logs data in the dashboard', async () => { + await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); + + const grafana = ctx.outputs!.logsAndTracesGrafana; + + const cloudWatchLogsDataSource = ( + grafana.connections[0] as studion.grafana.CloudWatchLogsConnection + ).dataSource; + const cloudWatchLogsDataSourceName = + cloudWatchLogsDataSource.name as unknown as Unwrap< + typeof cloudWatchLogsDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + const { body: dsBody } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(cloudWatchLogsDataSourceName)}`, + authToken, + ); + const dsData = (await dsBody.json()) as Record; + const dataSourceUid = dsData.uid as string; + const cloudWatchLogGroupName = ctx.outputs!.cloudWatchLogGroup.name; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'POST', + '/api/ds/query', + authToken, + { + queries: [ + { + datasource: { + type: 'cloudwatch', + uid: dataSourceUid, + }, + queryMode: 'Logs', + logGroups: [{ name: cloudWatchLogGroupName }], + expression: + 'fields @timestamp, @message | sort @timestamp desc | limit 10', + refId: 'A', + }, + ], + from: 'now-5m', + to: 'now', + }, + ); + assert.strictEqual(statusCode, 200, 'Expected query to succeed'); + + const data = (await body.json()) as { + results: Record }>; + }; + const frames = data.results?.A?.frames ?? []; + assert.ok( + frames.length > 0, + `Expected Grafana to return log frames for log group '${cloudWatchLogGroupName}'`, + ); + }); + }); + + it('should display traces data in the dashboard', async () => { + await requestEndpointWithExpectedStatus(ctx, ctx.config.usersPath, 200); + + const grafana = ctx.outputs!.logsAndTracesGrafana; + + const xRayDataSource = ( + grafana.connections[1] as studion.grafana.XRayConnection + ).dataSource; + const xRayDataSourceName = xRayDataSource.name as unknown as Unwrap< + typeof xRayDataSource.name + >; + + const authToken = grafana.serviceAccountToken.key as unknown as Unwrap< + typeof grafana.serviceAccountToken.key + >; + + const { body: dsBody } = await grafanaRequest( + ctx, + 'GET', + `/api/datasources/name/${encodeURIComponent(xRayDataSourceName)}`, + authToken, + ); + const dsData = (await dsBody.json()) as Record; + const dataSourceUid = dsData.uid as string; + + await backOff(async () => { + const { body, statusCode } = await grafanaRequest( + ctx, + 'POST', + '/api/ds/query', + authToken, + { + queries: [ + { + datasource: { + type: 'grafana-x-ray-datasource', + uid: dataSourceUid, + }, + queryType: 'getTraceSummaries', + refId: 'A', + }, + ], + from: 'now-5m', + to: 'now', + }, + ); + assert.strictEqual(statusCode, 200, 'Expected query to succeed'); + + const data = (await body.json()) as { + results: Record }>; + }; + const frames = data.results?.A?.frames ?? []; + assert.ok( + frames.length > 0, + `Expected Grafana to return trace frames from X-Ray`, + ); + }); + }); +} diff --git a/tests/grafana/test-context.ts b/tests/grafana/test-context.ts index 277a74ca..23be14fe 100644 --- a/tests/grafana/test-context.ts +++ b/tests/grafana/test-context.ts @@ -19,11 +19,14 @@ interface AwsClients { export interface ProgramOutput { webServer: studion.WebServer; ampWorkspace: aws.amp.Workspace; + cloudWatchLogGroup: aws.cloudwatch.LogGroup; ampGrafana: studion.grafana.Grafana; configurableGrafana: studion.grafana.Grafana; + logsAndTracesGrafana: studion.grafana.Grafana; } export interface GrafanaTestContext - extends ConfigContext, + extends + ConfigContext, PulumiProgramContext, AwsContext {}