Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to choose aggregation unit for Breakdown by service #22

Merged
merged 9 commits into from
Jun 14, 2024
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}"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍


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