Skip to content

Commit

Permalink
Merge pull request #22 from route06/issue-9-aggregation-unit
Browse files Browse the repository at this point in the history
Allow users to choose aggregation unit for `Breakdown by service`
  • Loading branch information
Rindrics authored Jun 14, 2024
2 parents 38dc2f3 + 87083ae commit fdff859
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 98 deletions.
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ BUILD_DIR = ./dist
SRC_DIR = ./src
NODE_MODULES = ./node_modules
SRC_FILES := $(shell find $(SRC_DIR) -type f)
BREAKDOWN_PERIOD ?= "yesterday"

.PHONY: all install build run

Expand All @@ -18,7 +19,7 @@ $(BUILD_DIR): $(SRC_FILES) $(NODE_MODULES)
build: $(BUILD_DIR)

run: build
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)
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}"

test:
pnpm test
Expand Down
4 changes: 4 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ var esmModules = [
'is-interactive',
'stdin-discarder',
'aws-sdk-client-mock',
'node-fetch',
'data-uri-to-buffer',
'fetch-blob',
'formdata-polyfill',
];

module.exports = {
Expand Down
65 changes: 33 additions & 32 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ $ aws-cost --help

-S, --slack-token [token] Slack token for the slack message
-C, --slack-channel [channel] Slack channel to post the message to
-P, --breakdown-period [period] Unit period to show service breakdown (yesterday|last7Days|thisMonth|lastMonth)')
-v, --version Get the version of the CLI
-h, --help Get the help of the CLI
Expand Down Expand Up @@ -216,47 +217,47 @@ If you need to use Role-Based Access Control (RBAC), you will need to configure
1. **Provider Role**
This role provides the necessary permissions for `aws-cost-cli`. It requires the above permissions policy and the following trust policy.
This role provides the necessary permissions for `aws-cost-cli`. It requires the above permissions policy and the following trust policy.
**Trust Policy**
**Trust Policy**
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole` with the ARN of the consumer role.
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole` with the ARN of the consumer role.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole"
},
"Action": "sts:AssumeRole"
}
]
}
```
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourConsumerRole"
},
"Action": "sts:AssumeRole"
}
]
}
```
2. **Consumer Role**
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.
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.
**Permissions Policy**
**Permissions Policy**
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole` with the ARN of the provider role.
Replace `arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole` with the ARN of the provider role.
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole"
}
]
}
```
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": "arn:aws:iam::YOUR_ACCOUNT_ID:role/YourProviderRole"
}
]
}
```
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/).
Expand Down
68 changes: 13 additions & 55 deletions src/cost.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AWS from 'aws-sdk';
import { AWSConfig } from './config';
import { getRawCostByService, getTotalCosts } from './cost';
import { generateMockedCostByService } from './testUtils';
import AWSMock from 'aws-sdk-mock';
import dayjs from 'dayjs';

Expand All @@ -10,57 +11,6 @@ const costDataLength = 65;
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)
const fixedFirstDay = dayjs(fixedToday).subtract(costDataLength, 'day');

const generateMockPricingData = () => {
const resultsByTime = [];
for (let i = 0; i < costDataLength; i++) {
const date = dayjs(fixedFirstDay).add(i, 'day').format('YYYY-MM-DD');
const month = dayjs(date).month(); // 0-indexed (0 = January, 1 = February, etc.)
let service1Cost;

switch (month) {
case 2: // March
service1Cost = 0.9;
break;
case 3: // April
service1Cost = 1.0; // Total cost of service1 in April will be 30.00
break;
case 4: // May
service1Cost = 1.1;
break;
default:
service1Cost = 0.0; // Default cost if none of the above
}

resultsByTime.push({
TimePeriod: {
Start: date,
End: dayjs(date).add(1, 'day').format('YYYY-MM-DD'),
},
Groups: [
{
Keys: ['service1'],
Metrics: {
UnblendedCost: {
Amount: String(service1Cost),
Unit: 'USD',
},
},
},
{
Keys: ['service2'],
Metrics: {
UnblendedCost: {
Amount: String(service1Cost * 100),
Unit: 'USD',
},
},
},
],
});
}
return { ResultsByTime: resultsByTime };
};

describe('Cost Functions', () => {
beforeAll(() => {
AWSMock.setSDKInstance(AWS);
Expand Down Expand Up @@ -90,10 +40,13 @@ describe('Cost Functions', () => {
region: 'us-east-1',
};

const mockPricingData = generateMockPricingData();
const mockedPricingData = generateMockedCostByService(
fixedToday,
costDataLength,
);

AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
callback(null, mockPricingData);
callback(null, mockedPricingData);
});

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

const mockPricingData = generateMockPricingData();
const mockedPricingData = generateMockedCostByService(
fixedToday,
costDataLength,
);

AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
callback(null, mockPricingData);
callback(null, mockedPricingData);
});

const totalCosts = await getTotalCosts(awsConfig);
Expand Down Expand Up @@ -178,6 +134,8 @@ describe('Cost Functions', () => {
},
};

console.log(expectedTotalCosts);

const roundToTwoDecimals = (num: number) => Math.round(num * 100) / 100;

Object.keys(totalCosts.totals).forEach((key) => {
Expand Down
6 changes: 6 additions & 0 deletions src/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,9 @@ export async function getTotalCosts(awsConfig: AWSConfig): Promise<TotalCosts> {

return totals;
}

if (process.env.NODE_ENV === 'test') {
Object.assign(module.exports, {
calculateServiceTotals,
});
}
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ program
'-C, --slack-channel [channel]',
'Channel to which the slack integration should post',
)
.option(
'-P, --breakdown-period [period]',
'Unit period to show service breakdown (yesterday|last7Days|thisMonth|lastMonth)',
)
// Other options
.option('-v, --version', 'Get the version of the CLI')
.option('-h, --help', 'Get the help of the CLI')
Expand All @@ -58,6 +62,7 @@ type OptionsType = {
// Slack token
slackToken: string;
slackChannel: string;
breakdownPeriod: string;
// Other options
help: boolean;
};
Expand Down Expand Up @@ -98,5 +103,6 @@ if (options.slackToken && options.slackChannel) {
options.summary,
options.slackToken,
options.slackChannel,
options.breakdownPeriod,
);
}
78 changes: 78 additions & 0 deletions src/printers/slack.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import AWS from 'aws-sdk';
import { AWSConfig } from './config';
import { formatServiceBreakdown } from './slack';
import { generateMockedCostByService } from '../testUtils';
import { getTotalCosts } from '../cost';
import AWSMock from 'aws-sdk-mock';

const costDataLength = 65;
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)

const awsConfig: AWSConfig = {
credentials: {
accessKeyId: 'testAccessKeyId',
secretAccessKey: 'testSecretAccessKey',
sessionToken: 'testSessionToken',
},
region: 'us-east-1',
};

const mockedCostByService = generateMockedCostByService(
fixedToday,
costDataLength,
);

beforeAll(() => {
AWSMock.setSDKInstance(AWS);
});

afterAll(() => {
AWSMock.restore();
});

beforeEach(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date(fixedToday).getTime());
});

afterEach(() => {
jest.useRealTimers();
});

AWSMock.mock('CostExplorer', 'getCostAndUsage', (params, callback) => {
callback(null, mockedCostByService);
});

describe('formatServiceBreakdown', () => {
it('should return service breakdown of "yesterday"', async () => {
const totalCosts = await getTotalCosts(awsConfig);
const result = formatServiceBreakdown(totalCosts, 'yesterday');

// cost value is defined in generateMockedCostByService
expect(result).toEqual('> service2: `$110.00`\n' + '> service1: `$1.10`');
});

it('should return service breakdown of "Last 7 days"', async () => {
const totalCosts = await getTotalCosts(awsConfig);
const result = formatServiceBreakdown(totalCosts, 'last7Days');

// cost value is defined in generateMockedCostByService
expect(result).toEqual('> service2: `$770.00`\n' + '> service1: `$7.70`');
});

it('should return service breakdown of "This Month"', async () => {
const totalCosts = await getTotalCosts(awsConfig);
const result = formatServiceBreakdown(totalCosts, 'thisMonth');

// cost value is defined in generateMockedCostByService
expect(result).toEqual('> service2: `$1100.00`\n' + '> service1: `$11.00`');
});

it('should return service breakdown of "Last Month"', async () => {
const totalCosts = await getTotalCosts(awsConfig);
const result = formatServiceBreakdown(totalCosts, 'lastMonth');

// cost value is defined in generateMockedCostByService
expect(result).toEqual('> service2: `$3000.00`\n' + '> service1: `$30.00`');
});
});
Loading

0 comments on commit fdff859

Please sign in to comment.