UNPKG

aws-cost-cli

Version:

A CLI tool to perform cost analysis on your AWS account

487 lines (472 loc) 16.5 kB
// src/index.ts import { Command } from "commander"; // package.json var package_default = { name: "aws-cost-cli", version: "0.2.7", description: "A CLI tool to perform cost analysis on your AWS account", type: "module", author: { name: "Kamran Ahmed", email: "kamranahmed.se@gmail.com", url: "https://github.com/kamranahmedse" }, files: [ "!tests/**/*", "dist/**/*", "!dist/**/*.js.map", "bin/**/*" ], bin: { "aws-cost": "./bin/index.js" }, scripts: { build: "tsup", dev: "tsup --watch", prebuild: "run-s clean", predev: "run-s clean", clean: "rm -rf dist", typecheck: "tsc --noEmit", test: 'echo "Error: no test specified" && exit 1' }, keywords: [ "aws", "cost", "cli", "aws-cost", "aws-cost-cli", "aws-costs", "typescript", "aws cli" ], license: "MIT", repository: { type: "git", url: "https://github.com/kamranahmedse/aws-cost-cli.git" }, engines: { node: ">=12.0" }, bugs: { url: "https://github.com/kamranahmedse/aws-cost-cli/issues" }, homepage: "https://github.com/kamranahmedse/aws-cost-cli#readme", dependencies: { "@aws-sdk/shared-ini-file-loader": "^3.254.0", "aws-sdk": "^2.1299.0", chalk: "^5.2.0", commander: "^10.0.0", dayjs: "^1.11.7", dotenv: "^16.0.3", "node-fetch": "^3.3.0", ora: "^6.1.2" }, devDependencies: { "@types/node": "^18.11.18", "npm-run-all": "^4.1.5", "ts-node": "^10.9.1", tsup: "^6.5.0", typescript: "^4.9.4" } }; // src/account.ts import AWS from "aws-sdk"; // src/logger.ts import chalk from "chalk"; import ora from "ora"; function printFatalError(error) { console.error(` ${chalk.bold.redBright.underline(`Error:`)} ${chalk.redBright(`${error}`)} `); process.exit(1); } var spinner; function showSpinner(text) { if (!spinner) { spinner = ora({ text: "" }).start(); } spinner.text = text; } function hideSpinner() { if (!spinner) { return; } spinner.stop(); } // src/account.ts async function getAccountAlias(awsConfig2) { var _a; showSpinner("Getting account alias"); const iam = new AWS.IAM(awsConfig2); const accountAliases = await iam.listAccountAliases().promise(); const foundAlias = (_a = accountAliases == null ? void 0 : accountAliases["AccountAliases"]) == null ? void 0 : _a[0]; if (foundAlias) { return foundAlias; } const sts = new AWS.STS(awsConfig2); const accountInfo = await sts.getCallerIdentity().promise(); return (accountInfo == null ? void 0 : accountInfo.Account) || ""; } // src/config.ts import { loadSharedConfigFiles } from "@aws-sdk/shared-ini-file-loader"; import chalk2 from "chalk"; async function getAwsConfigFromOptionsOrFile(options2) { const { profile, accessKey, secretKey, sessionToken, region } = options2; if (accessKey || secretKey) { if (!accessKey || !secretKey) { printFatalError(` You need to provide both of the following options: ${chalk2.bold("--access-key")} ${chalk2.bold("--secret-key")} `); } return { credentials: { accessKeyId: accessKey, secretAccessKey: secretKey, sessionToken }, region }; } return { credentials: await loadAwsCredentials(profile), region }; } async function loadAwsCredentials(profile = "default") { var _a, _b, _c; const configFiles = await loadSharedConfigFiles(); const credentialsFile = configFiles.credentialsFile; const accessKey = (_a = credentialsFile == null ? void 0 : credentialsFile[profile]) == null ? void 0 : _a.aws_access_key_id; const secretKey = (_b = credentialsFile == null ? void 0 : credentialsFile[profile]) == null ? void 0 : _b.aws_secret_access_key; const sessionToken = (_c = credentialsFile == null ? void 0 : credentialsFile[profile]) == null ? void 0 : _c.aws_session_token; if (!accessKey || !secretKey) { const sharedCredentialsFile = process.env.AWS_SHARED_CREDENTIALS_FILE || "~/.aws/credentials"; const sharedConfigFile = process.env.AWS_CONFIG_FILE || "~/.aws/config"; printFatalError(` Could not find the AWS credentials in the following files for the profile "${profile}": ${chalk2.bold(sharedCredentialsFile)} ${chalk2.bold(sharedConfigFile)} If the config files exist at different locations, set the following environment variables: ${chalk2.bold(`AWS_SHARED_CREDENTIALS_FILE`)} ${chalk2.bold(`AWS_CONFIG_FILE`)} You can also configure the credentials via the following command: ${chalk2.bold(`aws configure --profile ${profile}`)} You can also provide the credentials via the following options: ${chalk2.bold(`--access-key`)} ${chalk2.bold(`--secret-key`)} ${chalk2.bold(`--region`)} `); } return { accessKeyId: accessKey, secretAccessKey: secretKey, sessionToken }; } // src/cost.ts import AWS2 from "aws-sdk"; import dayjs from "dayjs"; async function getRawCostByService(awsConfig2) { showSpinner("Getting pricing data"); const costExplorer = new AWS2.CostExplorer(awsConfig2); const endDate = dayjs().subtract(1, "day"); const startDate = endDate.subtract(65, "day"); const pricingData = await costExplorer.getCostAndUsage({ TimePeriod: { Start: startDate.format("YYYY-MM-DD"), End: endDate.format("YYYY-MM-DD") }, Granularity: "DAILY", Filter: { Not: { Dimensions: { Key: "RECORD_TYPE", Values: ["Credit", "Refund", "Upfront", "Support"] } } }, Metrics: ["UnblendedCost"], GroupBy: [ { Type: "DIMENSION", Key: "SERVICE" } ] }).promise(); const costByService = {}; for (const day of pricingData.ResultsByTime) { for (const group of day.Groups) { const serviceName = group.Keys[0]; const cost = group.Metrics.UnblendedCost.Amount; const costDate = day.TimePeriod.End; costByService[serviceName] = costByService[serviceName] || {}; costByService[serviceName][costDate] = parseFloat(cost); } } return costByService; } function calculateServiceTotals(rawCostByService) { const totals = { lastMonth: 0, thisMonth: 0, last7Days: 0, yesterday: 0 }; const totalsByService = { lastMonth: {}, thisMonth: {}, last7Days: {}, yesterday: {} }; const startOfLastMonth = dayjs().subtract(1, "month").startOf("month"); const startOfThisMonth = dayjs().startOf("month"); const startOfLast7Days = dayjs().subtract(7, "day"); const startOfYesterday = dayjs().subtract(1, "day"); for (const service of Object.keys(rawCostByService)) { const servicePrices = rawCostByService[service]; let lastMonthServiceTotal = 0; let thisMonthServiceTotal = 0; let last7DaysServiceTotal = 0; let yesterdayServiceTotal = 0; for (const date of Object.keys(servicePrices)) { const price = servicePrices[date]; const dateObj = dayjs(date); if (dateObj.isSame(startOfLastMonth, "month")) { lastMonthServiceTotal += price; } if (dateObj.isSame(startOfThisMonth, "month")) { thisMonthServiceTotal += price; } if (dateObj.isSame(startOfLast7Days, "week") && !dateObj.isSame(startOfYesterday, "day")) { last7DaysServiceTotal += price; } if (dateObj.isSame(startOfYesterday, "day")) { yesterdayServiceTotal += price; } } totalsByService.lastMonth[service] = lastMonthServiceTotal; totalsByService.thisMonth[service] = thisMonthServiceTotal; totalsByService.last7Days[service] = last7DaysServiceTotal; totalsByService.yesterday[service] = yesterdayServiceTotal; totals.lastMonth += lastMonthServiceTotal; totals.thisMonth += thisMonthServiceTotal; totals.last7Days += last7DaysServiceTotal; totals.yesterday += yesterdayServiceTotal; } return { totals, totalsByService }; } async function getTotalCosts(awsConfig2) { const rawCosts = await getRawCostByService(awsConfig2); const totals = calculateServiceTotals(rawCosts); return totals; } // src/printers/fancy.ts import chalk3 from "chalk"; function printFancy(accountAlias, totals, isSummary = false) { hideSpinner(); console.clear(); const totalCosts = totals.totals; const serviceCosts = totals.totalsByService; const allServices = Object.keys(serviceCosts.yesterday); const sortedServiceNames = allServices.sort((a, b) => b.length - a.length); const maxServiceLength = sortedServiceNames.reduce((max, service) => { return Math.max(max, service.length); }, 0) + 1; const totalLastMonth = chalk3.green(`$${totalCosts.lastMonth.toFixed(2)}`); const totalThisMonth = chalk3.green(`$${totalCosts.thisMonth.toFixed(2)}`); const totalLast7Days = chalk3.green(`$${totalCosts.last7Days.toFixed(2)}`); const totalYesterday = chalk3.bold.yellowBright(`$${totalCosts.yesterday.toFixed(2)}`); console.log(""); console.log(`${"AWS Cost Report:".padStart(maxServiceLength + 1)} ${chalk3.bold.yellow(accountAlias)}`); console.log(""); console.log(`${"Last Month".padStart(maxServiceLength)}: ${totalLastMonth}`); console.log(`${"This Month".padStart(maxServiceLength)}: ${totalThisMonth}`); console.log(`${"Last 7 days".padStart(maxServiceLength)}: ${totalLast7Days}`); console.log(`${chalk3.bold("Yesterday".padStart(maxServiceLength))}: ${totalYesterday}`); console.log(""); if (isSummary) { return; } const headerPadLength = 11; const serviceHeader = chalk3.white("Service".padStart(maxServiceLength)); const lastMonthHeader = chalk3.white(`Last Month`.padEnd(headerPadLength)); const thisMonthHeader = chalk3.white(`This Month`.padEnd(headerPadLength)); const last7DaysHeader = chalk3.white(`Last 7 Days`.padEnd(headerPadLength)); const yesterdayHeader = chalk3.bold.white("Yesterday".padEnd(headerPadLength)); console.log(`${serviceHeader} ${lastMonthHeader} ${thisMonthHeader} ${last7DaysHeader} ${yesterdayHeader}`); for (let service of sortedServiceNames) { const serviceLabel = chalk3.cyan(service.padStart(maxServiceLength)); const lastMonthTotal = chalk3.green(`$${serviceCosts.lastMonth[service].toFixed(2)}`.padEnd(headerPadLength)); const thisMonthTotal = chalk3.green(`$${serviceCosts.thisMonth[service].toFixed(2)}`.padEnd(headerPadLength)); const last7DaysTotal = chalk3.green(`$${serviceCosts.last7Days[service].toFixed(2)}`.padEnd(headerPadLength)); const yesterdayTotal = chalk3.bold.yellowBright( `$${serviceCosts.yesterday[service].toFixed(2)}`.padEnd(headerPadLength) ); console.log(`${serviceLabel} ${lastMonthTotal} ${thisMonthTotal} ${last7DaysTotal} ${yesterdayTotal}`); } } // src/printers/json.ts function printJson(accountAlias, totalCosts, isSummary = false) { hideSpinner(); if (isSummary) { console.log( JSON.stringify( { account: accountAlias, totals: totalCosts.totals }, null, 2 ) ); return; } console.log( JSON.stringify( { account: accountAlias, ...totalCosts }, null, 2 ) ); } // src/printers/slack.ts import fetch from "node-fetch"; function formatServiceBreakdown(costs2) { const serviceCosts = costs2.totalsByService; const sortedServices = Object.keys(serviceCosts.yesterday).filter((service) => serviceCosts.yesterday[service] > 0).sort((a, b) => serviceCosts.yesterday[b] - serviceCosts.yesterday[a]); const serviceCostsYesterday = sortedServices.map((service) => { return `> ${service}: \`$${serviceCosts.yesterday[service].toFixed(2)}\``; }); return serviceCostsYesterday.join("\n"); } async function notifySlack(accountAlias, costs2, isSummary, slackToken, slackChannel) { const channel = slackChannel; const totals = costs2.totals; const serviceCosts = costs2.totalsByService; let serviceCostsYesterday = []; Object.keys(serviceCosts.yesterday).forEach((service) => { serviceCosts.yesterday[service].toFixed(2); serviceCostsYesterday.push(`${service}: $${serviceCosts.yesterday[service].toFixed(2)}`); }); const summary = `> *Account: ${accountAlias}* > *Summary * > Total Yesterday: \`$${totals.yesterday.toFixed(2)}\` > Total This Month: \`$${totals.thisMonth.toFixed(2)}\` > Total Last Month: \`$${totals.lastMonth.toFixed(2)}\` `; const breakdown = ` > *Breakdown by Service:* ${formatServiceBreakdown(costs2)} `; let message = `${summary}`; if (!isSummary) { message += `${breakdown}`; } const response = await fetch("https://slack.com/api/chat.postMessage", { method: "post", body: JSON.stringify({ channel, blocks: [ { type: "section", text: { type: "mrkdwn", text: message } } ] }), headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${slackToken}` } }); const data = await response.json(); if (!data.ok) { const message2 = data.error || "Unknown error"; console.error(` Failed to send message to Slack: ${message2}`); process.exit(1); } console.log("\nSuccessfully sent message to Slack"); } // src/printers/text.ts function printPlainSummary(accountAlias, costs2) { hideSpinner(); console.clear(); console.log(""); console.log(`Account: ${accountAlias}`); console.log(""); console.log("Totals:"); console.log(` Last Month: $${costs2.totals.lastMonth.toFixed(2)}`); console.log(` This Month: $${costs2.totals.thisMonth.toFixed(2)}`); console.log(` Last 7 Days: $${costs2.totals.last7Days.toFixed(2)}`); console.log(` Yesterday: $${costs2.totals.yesterday.toFixed(2)}`); } function printPlainText(accountAlias, totals, isSummary = false) { printPlainSummary(accountAlias, totals); if (isSummary) { return; } const serviceTotals = totals.totalsByService; const allServices = Object.keys(serviceTotals.yesterday).sort((a, b) => b.length - a.length); console.log(""); console.log("Totals by Service:"); console.log(" Last Month:"); allServices.forEach((service) => { console.log(` ${service}: $${serviceTotals.lastMonth[service].toFixed(2)}`); }); console.log(""); console.log(" This Month:"); allServices.forEach((service) => { console.log(` ${service}: $${serviceTotals.thisMonth[service].toFixed(2)}`); }); console.log(""); console.log(" Last 7 Days:"); allServices.forEach((service) => { console.log(` ${service}: $${serviceTotals.last7Days[service].toFixed(2)}`); }); console.log(""); console.log(" Yesterday:"); allServices.forEach((service) => { console.log(` ${service}: $${serviceTotals.yesterday[service].toFixed(2)}`); }); } // src/index.ts process.env.AWS_SDK_JS_SUPPRESS_MAINTENANCE_MODE_MESSAGE = "1"; var program = new Command(); program.version(package_default.version).name("aws-cost").description(package_default.description).option("-p, --profile [profile]", "AWS profile to use", "default").option("-k, --access-key [key]", "AWS access key").option("-s, --secret-key [key]", "AWS secret key").option("-T, --session-token [key]", "AWS session Token").option("-r, --region [region]", "AWS region", "us-east-1").option("-j, --json", "Get the output as JSON").option("-u, --summary", "Get only the summary without service breakdown").option("-t, --text", "Get the output as plain text (no colors / tables)").option("-S, --slack-token [token]", "Token for the slack integration").option("-C, --slack-channel [channel]", "Channel to which the slack integration should post").option("-h, --help", "Get the help of the CLI").parse(process.argv); var options = program.opts(); if (options.help) { program.help(); process.exit(0); } var awsConfig = await getAwsConfigFromOptionsOrFile({ profile: options.profile, accessKey: options.accessKey, secretKey: options.secretKey, sessionToken: options.sessionToken, region: options.region }); var alias = await getAccountAlias(awsConfig); var costs = await getTotalCosts(awsConfig); if (options.json) { printJson(alias, costs, options.summary); } else if (options.text) { printPlainText(alias, costs, options.summary); } else { printFancy(alias, costs, options.summary); } if (options.slackToken && options.slackChannel) { await notifySlack(alias, costs, options.summary, options.slackToken, options.slackChannel); } //# sourceMappingURL=index.js.map