@cloud-carbon-footprint/aws
Version:
The core logic to get cloud usage data and estimate energy and carbon emissions from Amazon Web Services.
452 lines (414 loc) • 12.6 kB
text/typescript
/*
* © 2021 Thoughtworks, Inc.
*/
import { mockClient } from 'aws-sdk-client-mock'
import { CloudWatchLogsClient } from '@aws-sdk/client-cloudwatch-logs'
import {
CloudWatchClient,
GetMetricDataCommand,
GetMetricDataCommandInput,
GetMetricDataCommandOutput,
} from '@aws-sdk/client-cloudwatch'
import {
CostExplorerClient,
GetCostAndUsageCommand,
GetCostAndUsageRequest,
GetRightsizingRecommendationCommand,
GetRightsizingRecommendationRequest,
} from '@aws-sdk/client-cost-explorer'
import { S3Client } from '@aws-sdk/client-s3'
import {
AthenaClient,
GetQueryResultsCommand,
GetQueryResultsOutput,
} from '@aws-sdk/client-athena'
import { GetTableCommand, GlueClient } from '@aws-sdk/client-glue'
import { ServiceWrapper } from '../lib'
const startDate = '2020-08-06'
const endDate = '2020-08-07'
const cloudWatchMock = mockClient(CloudWatchClient)
const costExplorerMock = mockClient(CostExplorerClient)
const athenaMock = mockClient(AthenaClient)
const glueMock = mockClient(GlueClient)
beforeEach(() => {
costExplorerMock.reset()
cloudWatchMock.reset()
athenaMock.reset()
glueMock.reset()
})
describe('aws service helper', () => {
afterEach(() => {
jest.restoreAllMocks()
})
const getServiceWrapper = () =>
new ServiceWrapper(
new CloudWatchClient(),
new CloudWatchLogsClient(),
new CostExplorerClient(),
new S3Client(),
new AthenaClient(),
new GlueClient(),
)
it('enablePagination decorator should follow CostExplorer CostAndUsage next pages', async () => {
const costExplorerGetCostAndUsageSpy = jest.fn()
const firstPageResponse = buildAwsCostExplorerGetCostAndUsageResponse(
[
{
start: startDate,
value: '1.2120679',
types: ['EBS:VolumeUsage.gp2'],
},
],
'tokenToNextPage',
)
const secondPageResponse = buildAwsCostExplorerGetCostAndUsageResponse(
[
{
start: startDate,
value: '1.2120679',
types: ['EBS:VolumeUsage.gp2'],
},
],
null,
)
const getCostAndUsageRequest = buildAwsCostExplorerGetCostAndUsageRequest()
costExplorerGetCostAndUsageSpy
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse)
costExplorerMock
.on(GetCostAndUsageCommand)
.callsFake(costExplorerGetCostAndUsageSpy)
const responses = await getServiceWrapper().getCostAndUsageResponses(
getCostAndUsageRequest,
)
expect(costExplorerGetCostAndUsageSpy).toHaveBeenNthCalledWith(
1,
getCostAndUsageRequest,
expect.anything(),
)
getCostAndUsageRequest.NextPageToken = 'tokenToNextPage'
expect(costExplorerGetCostAndUsageSpy).toHaveBeenNthCalledWith(
2,
getCostAndUsageRequest,
expect.anything(),
)
expect(responses).toEqual([firstPageResponse, secondPageResponse])
})
it('enablePagination decorator should follow CostExplorer Rightsizing Recommendation next pages', async () => {
const costExplorerGetRightsizingRecommendationsSpy = jest.fn()
const firstPageResponse =
buildAwsCostExplorerGetRightsizingRecommendationsResponse(
[
{
type: 'Terminate',
instanceType: 't2.nano',
savings: '200',
},
],
'tokenToNextPage',
)
const secondPageResponse =
buildAwsCostExplorerGetRightsizingRecommendationsResponse(
[
{
type: 'Terminate',
instanceType: 't2.micro',
savings: '300',
},
],
null,
)
const getRightsizingRecommendationsRequest =
buildAwsCostExplorerGetRightsizingRecommendationsRequest()
costExplorerGetRightsizingRecommendationsSpy
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse)
costExplorerMock
.on(GetRightsizingRecommendationCommand)
.callsFake(costExplorerGetRightsizingRecommendationsSpy)
const responses =
await getServiceWrapper().getRightsizingRecommendationsResponses(
getRightsizingRecommendationsRequest,
)
expect(
costExplorerGetRightsizingRecommendationsSpy,
).toHaveBeenNthCalledWith(
1,
getRightsizingRecommendationsRequest,
expect.anything(),
)
getRightsizingRecommendationsRequest.NextPageToken = 'tokenToNextPage'
expect(
costExplorerGetRightsizingRecommendationsSpy,
).toHaveBeenNthCalledWith(
2,
getRightsizingRecommendationsRequest,
expect.anything(),
)
expect(responses).toEqual([firstPageResponse, secondPageResponse])
})
it('enablePagination decorator should follow CloudWatch next pages', async () => {
const firstPageResponse =
buildAwsCloudWatchGetMetricDataResponse('tokenToNextPage')
const secondPageResponse = buildAwsCloudWatchGetMetricDataResponse(null)
const metricDataRequest = buildAwsCloudWatchGetMetricDataRequest()
const cloudWatchGetMetricDataSpy = jest.fn()
cloudWatchGetMetricDataSpy
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse)
cloudWatchMock
.on(GetMetricDataCommand)
.callsFake(cloudWatchGetMetricDataSpy)
const responses = await getServiceWrapper().getMetricDataResponses(
buildAwsCloudWatchGetMetricDataRequest(),
)
expect(cloudWatchGetMetricDataSpy).toHaveBeenNthCalledWith(
1,
metricDataRequest,
expect.anything(),
)
metricDataRequest.NextToken = 'tokenToNextPage'
expect(cloudWatchGetMetricDataSpy).toHaveBeenNthCalledWith(
2,
metricDataRequest,
expect.anything(),
)
expect(responses).toEqual([firstPageResponse, secondPageResponse])
})
it('enablePagination decorator should follow Athena next pages', async () => {
const firstPageResponse =
buildAthenaGetQueryResultsResponse('tokenToNextPage')
const secondPageResponse = buildAthenaGetQueryResultsResponse(null)
const athenaGetResultsSpy = jest.fn()
athenaGetResultsSpy
.mockResolvedValueOnce(firstPageResponse)
.mockResolvedValueOnce(secondPageResponse)
athenaMock.on(GetQueryResultsCommand).callsFake(athenaGetResultsSpy)
const responses = await getServiceWrapper().getAthenaQueryResultSets({
QueryExecutionId: 'some-query-id',
})
expect(athenaGetResultsSpy).toHaveBeenNthCalledWith(
1,
{ QueryExecutionId: 'some-query-id' },
expect.anything(),
)
expect(athenaGetResultsSpy).toHaveBeenNthCalledWith(
2,
{
QueryExecutionId: 'some-query-id',
NextToken: 'tokenToNextPage',
},
expect.anything(),
)
expect(responses).toEqual([firstPageResponse, secondPageResponse])
})
it('should query AWS Glue Data Catalog for the description of a given Athena table ', async () => {
const glueGetTableSpy = jest.fn()
const mockTableDetails = {
Table: {
StorageDescriptor: {
Columns: [
{
Name: 'column1',
Type: 'string',
},
{
Name: 'column2',
Type: 'string',
},
],
},
},
}
glueGetTableSpy.mockResolvedValueOnce(mockTableDetails)
glueMock.on(GetTableCommand).callsFake(glueGetTableSpy)
const params = {
DatabaseName: 'database-name',
Name: 'table-name',
}
const result = await getServiceWrapper().getAthenaTableDescription(params)
expect(glueGetTableSpy).toHaveBeenCalledWith(params, expect.anything())
expect(result).toEqual(mockTableDetails)
})
})
function buildAwsCostExplorerGetRightsizingRecommendationsRequest(): GetRightsizingRecommendationRequest {
return {
Service: 'AmazonEC2',
Configuration: {
BenefitsConsidered: false,
RecommendationTarget: 'SAME_INSTANCE_FAMILY',
},
}
}
function buildAwsCostExplorerGetRightsizingRecommendationsResponse(
data: { type: string; instanceType: string; savings: string }[],
nextPageToken: string,
) {
return {
Metadata: {
RecommendationId: '',
GenerationTimestamp: '',
LookbackPeriodInDays: '14',
},
Summary: {
TotalRecommendationCount: '',
EstimatedTotalMonthlySavingsAmount: '',
SavingsCurrencyCode: 'USD',
SavingsPercentage: '',
},
RightsizingRecommendations: data.map(({ type, instanceType, savings }) => {
return {
AccountId: '',
CurrentInstance: {
ResourceId: '',
InstanceName: '',
Tags: [
{
Key: 'aws:createdBy',
Values: [''],
},
{ Key: 'user:Name', Values: [''] },
],
ResourceDetails: {
EC2ResourceDetails: {
HourlyOnDemandRate: '',
InstanceType: instanceType,
Platform: 'Red Hat Enterprise Linux',
Region: 'Asia Pacific (Mumbai)',
Sku: '',
Memory: '1 GiB',
NetworkPerformance: 'Low to Moderate',
Storage: 'EBS only',
Vcpu: '1',
},
},
ResourceUtilization: {
EC2ResourceUtilization: {
MaxCpuUtilizationPercentage: '',
MaxMemoryUtilizationPercentage: '',
MaxStorageUtilizationPercentage: '',
EBSResourceUtilization: {
EbsReadOpsPerSecond: '',
EbsWriteOpsPerSecond: '',
EbsReadBytesPerSecond: '',
EbsWriteBytesPerSecond: '',
},
},
},
ReservationCoveredHoursInLookbackPeriod: '',
SavingsPlansCoveredHoursInLookbackPeriod: '',
OnDemandHoursInLookbackPeriod: '',
TotalRunningHoursInLookbackPeriod: '',
MonthlyCost: '',
CurrencyCode: 'USD',
},
RightsizingType: type,
TerminateRecommendationDetail: {
EstimatedMonthlySavings: savings,
CurrencyCode: 'USD',
},
}
}),
Configuration: {
RecommendationTarget: 'SAME_INSTANCE_FAMILY',
BenefitsConsidered: false,
},
NextPageToken: nextPageToken,
}
}
function buildAwsCostExplorerGetCostAndUsageRequest(): GetCostAndUsageRequest {
return {
TimePeriod: {
Start: startDate,
End: endDate,
},
Granularity: 'DAILY',
Metrics: [
'AmortizedCost',
'BlendedCost',
'NetAmortizedCost',
'NetUnblendedCost',
'NormalizedUsageAmount',
'UnblendedCost',
'UsageQuantity',
],
GroupBy: [
{
Key: 'USAGE_TYPE',
Type: 'DIMENSION',
},
],
}
}
function buildAwsCostExplorerGetCostAndUsageResponse(
data: { start: string; value: string; types: string[] }[],
nextPageToken: string,
) {
return {
NextPageToken: nextPageToken,
GroupDefinitions: [
{
Type: 'DIMENSION',
Key: 'USAGE_TYPE',
},
],
ResultsByTime: data.map(({ start, value, types }) => {
return {
TimePeriod: {
Start: start,
},
Groups: [
{
Keys: types,
Metrics: { UsageQuantity: { Amount: value, Unit: 'GB-Month' } },
},
],
}
}),
}
}
function buildAwsCloudWatchGetMetricDataResponse(
nextPageToken: string,
): GetMetricDataCommandOutput {
return {
$metadata: {},
NextToken: nextPageToken,
MetricDataResults: [
{
Id: 'cpuUtilization',
Label: 'AWS/ElastiCache CPUUtilization',
Timestamps: [new Date(startDate), new Date(endDate)],
Values: [1.0456, 2.03242],
StatusCode: 'Complete',
Messages: [],
},
],
}
}
function buildAwsCloudWatchGetMetricDataRequest(): GetMetricDataCommandInput {
return {
StartTime: new Date(startDate),
EndTime: new Date(endDate),
MetricDataQueries: [
{
Id: 'cpuUtilizationWithEmptyValues',
Expression:
"SEARCH('{AWS/ElastiCache} MetricName=\"CPUUtilization\"', 'Average', 3600)",
ReturnData: false,
},
{
Id: 'cpuUtilization',
Expression: 'REMOVE_EMPTY(cpuUtilizationWithEmptyValues)',
},
],
ScanBy: 'TimestampAscending',
}
}
function buildAthenaGetQueryResultsResponse(
nextPageToken: string,
): GetQueryResultsOutput {
return {
NextToken: nextPageToken,
ResultSet: { Rows: [] },
}
}