Skip to content

Commit fdff859

Browse files
authored
Merge pull request #22 from route06/issue-9-aggregation-unit
Allow users to choose aggregation unit for `Breakdown by service`
2 parents 38dc2f3 + 87083ae commit fdff859

9 files changed

+250
-98
lines changed

Makefile

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ BUILD_DIR = ./dist
22
SRC_DIR = ./src
33
NODE_MODULES = ./node_modules
44
SRC_FILES := $(shell find $(SRC_DIR) -type f)
5+
BREAKDOWN_PERIOD ?= "yesterday"
56

67
.PHONY: all install build run
78

@@ -18,7 +19,7 @@ $(BUILD_DIR): $(SRC_FILES) $(NODE_MODULES)
1819
build: $(BUILD_DIR)
1920

2021
run: build
21-
node ./bin/index.js --profile "${AWS_PROFILE}" --access-key "${ACCESS_KEY}" --secret-key "${SECRET_KEY}" --role-arn "${ROLE_ARN}" --region "${AWS_REGION}" -S $(SLACK_TOKEN_AWS_COST_CLI) -C $(SLACK_CHANNEL_ID)
22+
node ./bin/index.js --profile "${AWS_PROFILE}" --access-key "${ACCESS_KEY}" --secret-key "${SECRET_KEY}" --role-arn "${ROLE_ARN}" --region "${AWS_REGION}" -S "${SLACK_TOKEN_AWS_COST_CLI}" -C "${SLACK_CHANNEL_ID}" --breakdown-period "${BREAKDOWN_PERIOD}"
2223

2324
test:
2425
pnpm test

jest.config.cjs

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ var esmModules = [
1010
'is-interactive',
1111
'stdin-discarder',
1212
'aws-sdk-client-mock',
13+
'node-fetch',
14+
'data-uri-to-buffer',
15+
'fetch-blob',
16+
'formdata-polyfill',
1317
];
1418

1519
module.exports = {

readme.md

+33-32
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ $ aws-cost --help
4747

4848
-S, --slack-token [token] Slack token for the slack message
4949
-C, --slack-channel [channel] Slack channel to post the message to
50+
-P, --breakdown-period [period] Unit period to show service breakdown (yesterday|last7Days|thisMonth|lastMonth)')
5051
5152
-v, --version Get the version of the CLI
5253
-h, --help Get the help of the CLI
@@ -216,47 +217,47 @@ If you need to use Role-Based Access Control (RBAC), you will need to configure
216217
217218
1. **Provider Role**
218219
219-
This role provides the necessary permissions for `aws-cost-cli`. It requires the above permissions policy and the following trust policy.
220+
This role provides the necessary permissions for `aws-cost-cli`. It requires the above permissions policy and the following trust policy.
220221
221-
**Trust Policy**
222+
**Trust Policy**
222223
223-
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole` with the ARN of the consumer role.
224+
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole` with the ARN of the consumer role.
224225
225-
```json
226-
{
227-
"Version": "2012-10-17",
228-
"Statement": [
229-
{
230-
"Effect": "Allow",
231-
"Principal": {
232-
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole"
233-
},
234-
"Action": "sts:AssumeRole"
235-
}
236-
]
237-
}
238-
```
226+
```json
227+
{
228+
"Version": "2012-10-17",
229+
"Statement": [
230+
{
231+
"Effect": "Allow",
232+
"Principal": {
233+
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole"
234+
},
235+
"Action": "sts:AssumeRole"
236+
}
237+
]
238+
}
239+
```
239240
240241
2. **Consumer Role**
241242
242-
This role is used by the user or service (such as GitHub Actions) that needs to assume the provider role to access cost information. It requires the following permissions policy.
243+
This role is used by the user or service (such as GitHub Actions) that needs to assume the provider role to access cost information. It requires the following permissions policy.
243244
244-
**Permissions Policy**
245+
**Permissions Policy**
245246
246-
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole` with the ARN of the provider role.
247+
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole` with the ARN of the provider role.
247248
248-
```json
249-
{
250-
"Version": "2012-10-17",
251-
"Statement": [
252-
{
253-
"Effect": "Allow",
254-
"Action": "sts:AssumeRole",
255-
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole"
256-
}
257-
]
258-
}
259-
```
249+
```json
250+
{
251+
"Version": "2012-10-17",
252+
"Statement": [
253+
{
254+
"Effect": "Allow",
255+
"Action": "sts:AssumeRole",
256+
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole"
257+
}
258+
]
259+
}
260+
```
260261
261262
Please also note that this tool uses AWS Cost Explorer under the hood which [costs $0.01 per request](https://aws.amazon.com/aws-cost-management/aws-cost-explorer/pricing/).
262263

src/cost.test.ts

+13-55
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import AWS from 'aws-sdk';
22
import { AWSConfig } from './config';
33
import { getRawCostByService, getTotalCosts } from './cost';
4+
import { generateMockedCostByService } from './testUtils';
45
import AWSMock from 'aws-sdk-mock';
56
import dayjs from 'dayjs';
67

@@ -10,57 +11,6 @@ const costDataLength = 65;
1011
const fixedToday = '2024-05-11'; // cost of 'this month' will be sum of 10 days from May 1 to May 10 ('today' is omitted because its cost is incomplete)
1112
const fixedFirstDay = dayjs(fixedToday).subtract(costDataLength, 'day');
1213

13-
const generateMockPricingData = () => {
14-
const resultsByTime = [];
15-
for (let i = 0; i < costDataLength; i++) {
16-
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
17-
const month = dayjs(date).month(); // 0-indexed (0 = January, 1 = February, etc.)
18-
let service1Cost;
19-
20-
switch (month) {
21-
case 2: // March
22-
service1Cost = 0.9;
23-
break;
24-
case 3: // April
25-
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
26-
break;
27-
case 4: // May
28-
service1Cost = 1.1;
29-
break;
30-
default:
31-
service1Cost = 0.0; // Default cost if none of the above
32-
}
33-
34-
resultsByTime.push({
35-
TimePeriod: {
36-
Start: date,
37-
End: dayjs(date).add(1, 'day').format('YYYY-MM-DD'),
38-
},
39-
Groups: [
40-
{
41-
Keys: ['service1'],
42-
Metrics: {
43-
UnblendedCost: {
44-
Amount: String(service1Cost),
45-
Unit: 'USD',
46-
},
47-
},
48-
},
49-
{
50-
Keys: ['service2'],
51-
Metrics: {
52-
UnblendedCost: {
53-
Amount: String(service1Cost * 100),
54-
Unit: 'USD',
55-
},
56-
},
57-
},
58-
],
59-
});
60-
}
61-
return { ResultsByTime: resultsByTime };
62-
};
63-
6414
describe('Cost Functions', () => {
6515
beforeAll(() => {
6616
AWSMock.setSDKInstance(AWS);
@@ -90,10 +40,13 @@ describe('Cost Functions', () => {
9040
region: 'us-east-1',
9141
};
9242

93-
const mockPricingData = generateMockPricingData();
43+
const mockedPricingData = generateMockedCostByService(
44+
fixedToday,
45+
costDataLength,
46+
);
9447

9548
AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
96-
callback(null, mockPricingData);
49+
callback(null, mockedPricingData);
9750
});
9851

9952
const rawCostByService = await getRawCostByService(awsConfig);
@@ -142,10 +95,13 @@ describe('Cost Functions', () => {
14295
region: 'us-east-1',
14396
};
14497

145-
const mockPricingData = generateMockPricingData();
98+
const mockedPricingData = generateMockedCostByService(
99+
fixedToday,
100+
costDataLength,
101+
);
146102

147103
AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
148-
callback(null, mockPricingData);
104+
callback(null, mockedPricingData);
149105
});
150106

151107
const totalCosts = await getTotalCosts(awsConfig);
@@ -178,6 +134,8 @@ describe('Cost Functions', () => {
178134
},
179135
};
180136

137+
console.log(expectedTotalCosts);
138+
181139
const roundToTwoDecimals = (num: number) => Math.round(num * 100) / 100;
182140

183141
Object.keys(totalCosts.totals).forEach((key) => {

src/cost.ts

+6
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,9 @@ export async function getTotalCosts(awsConfig: AWSConfig): Promise<TotalCosts> {
180180

181181
return totals;
182182
}
183+
184+
if (process.env.NODE_ENV === 'test') {
185+
Object.assign(module.exports, {
186+
calculateServiceTotals,
187+
});
188+
}

src/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ program
3636
'-C, --slack-channel [channel]',
3737
'Channel to which the slack integration should post',
3838
)
39+
.option(
40+
'-P, --breakdown-period [period]',
41+
'Unit period to show service breakdown (yesterday|last7Days|thisMonth|lastMonth)',
42+
)
3943
// Other options
4044
.option('-v, --version', 'Get the version of the CLI')
4145
.option('-h, --help', 'Get the help of the CLI')
@@ -58,6 +62,7 @@ type OptionsType = {
5862
// Slack token
5963
slackToken: string;
6064
slackChannel: string;
65+
breakdownPeriod: string;
6166
// Other options
6267
help: boolean;
6368
};
@@ -98,5 +103,6 @@ if (options.slackToken && options.slackChannel) {
98103
options.summary,
99104
options.slackToken,
100105
options.slackChannel,
106+
options.breakdownPeriod,
101107
);
102108
}

src/printers/slack.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import AWS from 'aws-sdk';
2+
import { AWSConfig } from './config';
3+
import { formatServiceBreakdown } from './slack';
4+
import { generateMockedCostByService } from '../testUtils';
5+
import { getTotalCosts } from '../cost';
6+
import AWSMock from 'aws-sdk-mock';
7+
8+
const costDataLength = 65;
9+
const fixedToday = '2024-05-11'; // cost of 'this month' will be sum of 10 days from May 1 to May 10 ('today' is omitted because its cost is incomplete)
10+
11+
const awsConfig: AWSConfig = {
12+
credentials: {
13+
accessKeyId: 'testAccessKeyId',
14+
secretAccessKey: 'testSecretAccessKey',
15+
sessionToken: 'testSessionToken',
16+
},
17+
region: 'us-east-1',
18+
};
19+
20+
const mockedCostByService = generateMockedCostByService(
21+
fixedToday,
22+
costDataLength,
23+
);
24+
25+
beforeAll(() => {
26+
AWSMock.setSDKInstance(AWS);
27+
});
28+
29+
afterAll(() => {
30+
AWSMock.restore();
31+
});
32+
33+
beforeEach(() => {
34+
jest.useFakeTimers('modern');
35+
jest.setSystemTime(new Date(fixedToday).getTime());
36+
});
37+
38+
afterEach(() => {
39+
jest.useRealTimers();
40+
});
41+
42+
AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
43+
callback(null, mockedCostByService);
44+
});
45+
46+
describe('formatServiceBreakdown', () => {
47+
it('should return service breakdown of "yesterday"', async () => {
48+
const totalCosts = await getTotalCosts(awsConfig);
49+
const result = formatServiceBreakdown(totalCosts, 'yesterday');
50+
51+
// cost value is defined in generateMockedCostByService
52+
expect(result).toEqual('> service2: `$110.00`\n' + '> service1: `$1.10`');
53+
});
54+
55+
it('should return service breakdown of "Last 7 days"', async () => {
56+
const totalCosts = await getTotalCosts(awsConfig);
57+
const result = formatServiceBreakdown(totalCosts, 'last7Days');
58+
59+
// cost value is defined in generateMockedCostByService
60+
expect(result).toEqual('> service2: `$770.00`\n' + '> service1: `$7.70`');
61+
});
62+
63+
it('should return service breakdown of "This Month"', async () => {
64+
const totalCosts = await getTotalCosts(awsConfig);
65+
const result = formatServiceBreakdown(totalCosts, 'thisMonth');
66+
67+
// cost value is defined in generateMockedCostByService
68+
expect(result).toEqual('> service2: `$1100.00`\n' + '> service1: `$11.00`');
69+
});
70+
71+
it('should return service breakdown of "Last Month"', async () => {
72+
const totalCosts = await getTotalCosts(awsConfig);
73+
const result = formatServiceBreakdown(totalCosts, 'lastMonth');
74+
75+
// cost value is defined in generateMockedCostByService
76+
expect(result).toEqual('> service2: `$3000.00`\n' + '> service1: `$30.00`');
77+
});
78+
});

0 commit comments

Comments
 (0)