├── .node-version ├── json └── .gitignore ├── source-map-install.js ├── .gitignore ├── src ├── lib │ ├── validator.ts │ ├── parser │ │ ├── parseInstanceType.ts │ │ ├── index.ts │ │ ├── parsePriceDimensions.ts │ │ ├── parsePrices.ts │ │ ├── parseFirstPrice.ts │ │ ├── parseRange.ts │ │ ├── parseCache.ts │ │ └── parseInstances.ts │ ├── notification │ │ └── slack.ts │ ├── aws │ │ ├── sns.ts │ │ ├── s3.ts │ │ └── pricing.ts │ ├── fx │ │ └── fetchFx.ts │ ├── price │ │ ├── fetchPrices.ts │ │ ├── fetchPrice.ts │ │ └── helpers.ts │ ├── ci │ │ └── circleci.ts │ ├── response.ts │ └── types.ts ├── services │ ├── ebs.ts │ ├── transfer.ts │ ├── cognito.ts │ ├── route53.ts │ ├── elasticache.ts │ ├── apigateway.ts │ ├── natgw.ts │ ├── nlb.ts │ ├── clb.ts │ ├── lambda.ts │ ├── alb.ts │ ├── fargate.ts │ ├── index.ts │ ├── sqs.ts │ ├── s3.ts │ ├── cloudfront.ts │ ├── sns.ts │ ├── dynamodb.ts │ ├── aurora.ts │ ├── ec2.ts │ ├── ses.ts │ ├── rds.ts │ └── cloudwatch.ts ├── cli │ ├── describeServices.ts │ └── getAttributeValues.ts └── functions │ ├── api │ ├── z │ │ ├── get.ts │ │ └── post.ts │ └── contact │ │ └── post.ts │ └── batch │ ├── fx.ts │ └── price.ts ├── test ├── fixtures │ ├── index.ts │ ├── transfer.ts │ ├── ec2.ts │ ├── rds.ts │ └── apigateway.ts └── lib │ ├── parser │ ├── parseFirstPrice.spec.ts │ ├── parseCache.spec.ts │ ├── parsePriceDimensions.spec.ts │ ├── parseRange.spec.ts │ ├── parseInstanceType.spec.ts │ ├── parsePrices.spec.ts │ └── parseInstances.spec.ts │ ├── fx │ └── fetchFx.spec.ts │ ├── validator.spec.ts │ ├── price │ ├── fetchPrice.spec.ts │ ├── fetchPrices.spec.ts │ └── helpers.spec.ts │ └── response.spec.ts ├── tslint.json ├── jest.config.js ├── README.md ├── tsconfig.json ├── webpack.config.js ├── LICENSE ├── .circleci └── config.yml ├── package.json └── serverless.yml /.node-version: -------------------------------------------------------------------------------- 1 | 14.17.1 2 | -------------------------------------------------------------------------------- /json/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore -------------------------------------------------------------------------------- /source-map-install.js: -------------------------------------------------------------------------------- 1 | require('source-map-support').install() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .serverless 3 | .webpack 4 | *.log 5 | /coverage/ 6 | serverless.env.yml 7 | node_modules -------------------------------------------------------------------------------- /src/lib/validator.ts: -------------------------------------------------------------------------------- 1 | export function isValidHash(str: string): boolean { 2 | if (!str) { 3 | return false 4 | } 5 | 6 | return /^[0-9A-Fa-f]{20}$/.test(str) 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/parser/parseInstanceType.ts: -------------------------------------------------------------------------------- 1 | import { PriceItem } from '@/lib/types' 2 | 3 | export function parseInstanceType(priceItem: PriceItem): string { 4 | return priceItem.product.attributes.instanceType 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ec2 } from './ec2' 2 | export { default as rds } from './rds' 3 | export { default as apigateway } from './apigateway' 4 | export { default as transfer } from './transfer' 5 | -------------------------------------------------------------------------------- /src/lib/parser/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parseCache' 2 | export * from './parseFirstPrice' 3 | export * from './parseInstances' 4 | export * from './parseInstanceType' 5 | export * from './parsePriceDimensions' 6 | export * from './parsePrices' 7 | export * from './parseRange' 8 | -------------------------------------------------------------------------------- /src/lib/parser/parsePriceDimensions.ts: -------------------------------------------------------------------------------- 1 | import { PriceItem } from '@/lib/types' 2 | 3 | export function parsePriceDimensions(priceItem: PriceItem): any { 4 | const { 5 | terms: { OnDemand } 6 | } = priceItem 7 | 8 | return OnDemand[Object.keys(OnDemand)[0]].priceDimensions 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/parser/parsePrices.ts: -------------------------------------------------------------------------------- 1 | import { parsePriceDimensions } from './index' 2 | import { PriceItem } from '@/lib/types' 3 | 4 | export function parsePrices(priceItem: PriceItem): any { 5 | const priceDimensions = parsePriceDimensions(priceItem) 6 | 7 | return Object.keys(priceDimensions).map(name => priceDimensions[name]) 8 | } 9 | -------------------------------------------------------------------------------- /test/lib/parser/parseFirstPrice.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseFirstPrice } from '@/lib/parser' 3 | 4 | describe('parseFirstPrice', () => { 5 | test('skuの最初の料金をパースできる', () => { 6 | const expected = 0.1 7 | 8 | expect(parseFirstPrice(fixtures.ec2[0])).toEqual(expected) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "tslint-plugin-prettier" 4 | ], 5 | "extends": [ 6 | "tslint-config-standard", 7 | "tslint-config-prettier" 8 | ], 9 | "rules": { 10 | "prettier": [ 11 | true, 12 | { 13 | "singleQuote": true, 14 | "semi": false 15 | } 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | verbose: true, 4 | testMatch: ['**/?(*.)+(spec).ts'], 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest' 7 | }, 8 | moduleNameMapper: { 9 | '^@/(.*)$': '/src/$1', 10 | '^test/(.*)$': '/test/$1' 11 | }, 12 | moduleFileExtensions: ['ts', 'js', 'json'] 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/parser/parseFirstPrice.ts: -------------------------------------------------------------------------------- 1 | import { PriceItem } from '@/lib/types' 2 | import { parsePriceDimensions } from './index' 3 | 4 | export function parseFirstPrice(priceItem: PriceItem): number { 5 | const priceDimensions = parsePriceDimensions(priceItem) 6 | 7 | return parseFloat( 8 | priceDimensions[Object.keys(priceDimensions)[0]].pricePerUnit.USD 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/services/ebs.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | gp2: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AmazonEC2', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-EBS:VolumeUsage.gp2' 11 | } 12 | }, 13 | parse: priceList => parseFirstPrice(priceList[0]) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/services/transfer.ts: -------------------------------------------------------------------------------- 1 | import { parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | out: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AWSDataTransfer', 8 | Filters: { 9 | fromLocation: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-DataTransfer-Out-Bytes' 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 使い方 2 | 3 | ``` 4 | # インストール 5 | $ npm install 6 | 7 | # デプロイ 8 | $ npm run deploy 9 | 10 | # 実行 11 | $ npm run invoke price 12 | $ npm run invoke fx 13 | $ npm run invoke validate 14 | ``` 15 | 16 | # 環境設定 17 | 18 | バケット名などの環境に依存するものは、 19 | `serverless.env.yml` というファイルを作成して設定します。 20 | 21 | ``` 22 | BUCKET_NAME: 'aws.noplan.cc' 23 | SNS_PRICE_UPDATE_ARN: 'arn:aws:sns:hoge' 24 | SLACK_WEBHOOK_URL: 'https://hooks.slack.com/hoge' 25 | ``` 26 | -------------------------------------------------------------------------------- /src/cli/describeServices.ts: -------------------------------------------------------------------------------- 1 | import prettyjson from 'prettyjson' 2 | import { describeServices } from '@/lib/aws/pricing' 3 | 4 | const main = async () => { 5 | const ServiceCode = process.argv[2] 6 | 7 | try { 8 | const data = await describeServices(ServiceCode) 9 | 10 | console.log(prettyjson.render(data)) 11 | } catch (e) { 12 | console.log(e) 13 | } 14 | } 15 | 16 | // tslint:disable-next-line:no-floating-promises 17 | main() 18 | -------------------------------------------------------------------------------- /test/lib/parser/parseCache.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseCache } from '@/lib/parser' 3 | 4 | describe('parseCache', () => { 5 | test('API Gatewayのキャッシュメモリをパースして昇順でソートして取得できる', () => { 6 | const expected = [ 7 | { cacheMemorySizeGb: '0.5', price: 0.03 }, 8 | { cacheMemorySizeGb: '1.6', price: 0.05 } 9 | ] 10 | 11 | expect(parseCache(fixtures.apigateway)).toEqual(expected) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /src/lib/notification/slack.ts: -------------------------------------------------------------------------------- 1 | import { IncomingWebhook } from '@slack/webhook' 2 | 3 | export async function send(text: string): Promise { 4 | const url = process.env.SLACK_WEBHOOK_URL || '' 5 | const slack = new IncomingWebhook(url) 6 | 7 | await slack.send({ text }) 8 | } 9 | 10 | export async function sendWarning(err): Promise { 11 | console.log('***********slack called************') 12 | await send(`:warning: Oops\n\n${err}`) 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "es2017" 5 | ], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "sourceMap": true, 11 | "target": "es2017", 12 | "outDir": "dist", 13 | "baseUrl": "./", 14 | "paths": { 15 | "@/*": ["src/*"] 16 | } 17 | }, 18 | "exclude": [ 19 | "node_modules" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /test/lib/parser/parsePriceDimensions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parsePriceDimensions } from '@/lib/parser' 3 | 4 | describe('parsePriceDimensions', () => { 5 | test('priceDimensionsをパースできる', () => { 6 | const expected = { 7 | sku: { 8 | pricePerUnit: { 9 | USD: '0.1' 10 | } 11 | } 12 | } 13 | 14 | expect(parsePriceDimensions(fixtures.ec2[0])).toEqual(expected) 15 | }) 16 | }) 17 | -------------------------------------------------------------------------------- /src/services/cognito.ts: -------------------------------------------------------------------------------- 1 | import { parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | mau: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonCognito', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-CognitoUserPoolsMAU', 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | }, 15 | free: { 16 | manual: 50000 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/aws/sns.ts: -------------------------------------------------------------------------------- 1 | import { SNS } from 'aws-sdk' 2 | 3 | const sns = new SNS() 4 | 5 | export function publish( 6 | arn: string, 7 | message = 'message is empty' 8 | ): Promise { 9 | return new Promise((resolve, reject) => { 10 | sns.publish( 11 | { 12 | Message: message, 13 | TopicArn: arn 14 | }, 15 | err => { 16 | if (err) { 17 | reject(err) 18 | } else { 19 | resolve() 20 | } 21 | } 22 | ) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /src/cli/getAttributeValues.ts: -------------------------------------------------------------------------------- 1 | import prettyjson from 'prettyjson' 2 | import { getAttributeValues } from '@/lib/aws/pricing' 3 | 4 | const main = async () => { 5 | const ServiceCode = process.argv[2] 6 | const AttributeName = process.argv[3] 7 | 8 | try { 9 | const data = await getAttributeValues(ServiceCode, AttributeName) 10 | 11 | console.log(prettyjson.render(data)) 12 | } catch (e) { 13 | console.log(e) 14 | } 15 | } 16 | 17 | // tslint:disable-next-line:no-floating-promises 18 | main() 19 | -------------------------------------------------------------------------------- /src/lib/fx/fetchFx.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const { FX_API_KEY } = process.env 4 | 5 | export default async function(endpoint: string): Promise { 6 | const res = await axios.get(endpoint, { headers: { apikey: FX_API_KEY }}) 7 | 8 | const usdjpy = Math.round(parseFloat(res.data.quotes.USDJPY) * 1000) / 1000 9 | 10 | // 明らかにおかしい為替じゃないかだけ確認 11 | if (!usdjpy || usdjpy < 50 || usdjpy > 300) { 12 | throw new Error(`為替の値がなんだかおかしいです : ${usdjpy}`) 13 | } 14 | 15 | return usdjpy 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/parser/parseRange.ts: -------------------------------------------------------------------------------- 1 | import sortBy from 'lodash/sortBy' 2 | import { parsePrices } from './index' 3 | import { PriceRange, PriceItem } from '@/lib/types' 4 | 5 | export function parseRange(priceItem: PriceItem): PriceRange[] { 6 | const prices = parsePrices(priceItem).map(price => ({ 7 | beginRange: parseInt(price.beginRange, 10), 8 | endRange: price.endRange === 'Inf' ? null : parseInt(price.endRange, 10), 9 | price: parseFloat(price.pricePerUnit.USD) 10 | })) 11 | 12 | return sortBy(prices, ['beginRange']) 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/price/fetchPrices.ts: -------------------------------------------------------------------------------- 1 | import { fetchPrice } from './fetchPrice' 2 | import { wait, separate, combine } from './helpers' 3 | 4 | export async function fetchPrices(getProducts, services) { 5 | const kv = separate(services) 6 | 7 | const data = await Promise.all( 8 | kv.values.map(async (obj, index) => { 9 | if (obj.manual) { 10 | return obj.manual 11 | } 12 | 13 | await wait(index * 1000) 14 | 15 | return fetchPrice(getProducts, obj) 16 | }) 17 | ) 18 | 19 | return combine(kv.keys, data) 20 | } 21 | -------------------------------------------------------------------------------- /src/services/route53.ts: -------------------------------------------------------------------------------- 1 | import { parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | hostzone: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonRoute53', 8 | Filters: { 9 | usagetype: 'HostedZone' 10 | } 11 | }, 12 | parse: priceList => parseRange(priceList[0]) 13 | } 14 | }, 15 | query: { 16 | priceRange: { 17 | params: { 18 | ServiceCode: 'AmazonRoute53', 19 | Filters: { 20 | usagetype: 'DNS-Queries' 21 | } 22 | }, 23 | parse: priceList => parseRange(priceList[0]) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/lib/parser/parseRange.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseRange } from '@/lib/parser' 3 | 4 | describe('parseRange', () => { 5 | test('料金のレンジを昇順でソートして取得できる', () => { 6 | const expected = [ 7 | { 8 | beginRange: 0, 9 | endRange: 1, 10 | price: 0 11 | }, 12 | { 13 | beginRange: 1, 14 | endRange: 1000, 15 | price: 0.2 16 | }, 17 | { 18 | beginRange: 1000, 19 | endRange: null, 20 | price: 0.1 21 | } 22 | ] 23 | 24 | expect(parseRange(fixtures.transfer[0])).toEqual(expected) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/services/elasticache.ts: -------------------------------------------------------------------------------- 1 | import { parseInstances } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | params: { 6 | ServiceCode: 'AmazonElastiCache', 7 | Filters: { 8 | location: 'Asia Pacific (Tokyo)', 9 | locationType: 'AWS Region', 10 | currentGeneration: 'Yes', 11 | cacheEngine: 'Redis' 12 | } 13 | }, 14 | parse: priceList => { 15 | return parseInstances(priceList, { 16 | name: 'ElastiCache', 17 | index: 1, 18 | order: ['t4g', 't3', 't2', 'm7g', 'm6g', 'm5', 'm4', 'r7g', 'r6gd', 'r6g', 'r5', 'r4'] 19 | }) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/ci/circleci.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export async function deploy(branch: string): Promise { 4 | const { CIRCLE_API_TOKEN, CIRCLE_BUILD_ENDPOINT } = process.env 5 | 6 | try { 7 | await axios.post( 8 | CIRCLE_BUILD_ENDPOINT, 9 | { branch }, 10 | { 11 | headers: { 12 | 'Circle-Token': CIRCLE_API_TOKEN, 13 | 'content-type': 'application/json' 14 | } 15 | } 16 | ) 17 | } catch (e) { 18 | console.error('CircleCIでエラーが発生しました') 19 | console.error(`CIRCLE_BUILD_ENDPOINT: ${CIRCLE_BUILD_ENDPOINT}`) 20 | console.error(e) 21 | 22 | throw new Error(e) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/lib/parser/parseInstanceType.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseInstanceType } from '@/lib/parser' 2 | 3 | describe('parseInstanceType', () => { 4 | test('インスタンスタイプをパースできる', () => { 5 | const priceList = { 6 | serviceCode: 'AmazonEC2', 7 | product: { 8 | productFamily: '', 9 | attributes: { 10 | instanceType: 't2.micro' 11 | } 12 | }, 13 | terms: { 14 | OnDemand: { 15 | sku: { 16 | priceDimensions: { 17 | sku: {} 18 | } 19 | } 20 | } 21 | } 22 | } 23 | 24 | expect(parseInstanceType(priceList)).toBe('t2.micro') 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /test/lib/fx/fetchFx.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import fetchFx from '@/lib/fx/fetchFx' 3 | 4 | jest.mock('axios') 5 | 6 | describe('fetchFx', () => { 7 | test('妥当な為替を取得できたらそのまま返す', async () => { 8 | ;(axios.get as any).mockResolvedValue({ 9 | data: { 10 | JPY: 100 11 | } 12 | }) 13 | 14 | const usdjpy = await fetchFx('url') 15 | 16 | expect(usdjpy).toBe(100) 17 | }) 18 | 19 | test('おかしな為替を取得したら例外を投げる', async () => { 20 | ;(axios.get as any).mockResolvedValue({ 21 | data: { 22 | JPY: 5 23 | } 24 | }) 25 | 26 | await expect(fetchFx('url')).rejects.toThrow('為替の値がなんだか') 27 | }) 28 | }) 29 | -------------------------------------------------------------------------------- /src/lib/parser/parseCache.ts: -------------------------------------------------------------------------------- 1 | import orderBy from 'lodash/orderBy' 2 | import { ApiGatewayCache, PriceItem } from '@/lib/types' 3 | import { parseFirstPrice } from './index' 4 | 5 | export function parseCache(priceList: PriceItem[]): ApiGatewayCache[] { 6 | const formattedPriceList = priceList.map(priceItem => ({ 7 | cacheMemorySizeGb: parseFloat( 8 | priceItem.product.attributes.cacheMemorySizeGb 9 | ), 10 | price: parseFirstPrice(priceItem) 11 | })) 12 | 13 | return orderBy(formattedPriceList, ['cacheMemorySizeGb'], ['asc']).map( 14 | ({ cacheMemorySizeGb, price }) => ({ 15 | cacheMemorySizeGb: cacheMemorySizeGb.toString(), 16 | price 17 | }) 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /src/services/apigateway.ts: -------------------------------------------------------------------------------- 1 | import { parseRange, parseCache } from '@/lib/parser' 2 | 3 | export default { 4 | request: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonApiGateway', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | operation: 'ApiGatewayRequest' 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | }, 16 | cache: { 17 | params: { 18 | ServiceCode: 'AmazonApiGateway', 19 | Filters: { 20 | location: 'Asia Pacific (Tokyo)', 21 | operation: 'RunInstances' 22 | } 23 | }, 24 | parse: priceList => parseCache(priceList) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/services/natgw.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AmazonEC2', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-NatGateway-Hours' 11 | } 12 | }, 13 | parse: priceList => parseFirstPrice(priceList[0]) 14 | } 15 | }, 16 | processedData: { 17 | price: { 18 | params: { 19 | ServiceCode: 'AmazonEC2', 20 | Filters: { 21 | location: 'Asia Pacific (Tokyo)', 22 | usagetype: 'APN1-NatGateway-Bytes' 23 | } 24 | }, 25 | parse: priceList => parseFirstPrice(priceList[0]) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/functions/api/z/get.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from 'aws-lambda' 2 | import * as s3 from '@/lib/aws/s3' 3 | import { isValidHash } from '@/lib/validator' 4 | import { 5 | createResponse, 6 | createClientErrorResponse, 7 | createServerErrorResponse 8 | } from '@/lib/response' 9 | 10 | export async function main(event: APIGatewayProxyEvent) { 11 | const { BUCKET_NAME } = process.env 12 | const { hash } = event.pathParameters 13 | 14 | if (!isValidHash(hash)) { 15 | return createClientErrorResponse() 16 | } 17 | 18 | try { 19 | const body = await s3.fetchJson(BUCKET_NAME, `json/z/${hash}.json`) 20 | 21 | return createResponse({ body }) 22 | } catch (e) { 23 | return createServerErrorResponse() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/lib/parser/parsePrices.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parsePrices } from '@/lib/parser' 3 | 4 | describe('parsePrices', () => { 5 | test('skuの料金の配列をパースできる', () => { 6 | const expected = [ 7 | { 8 | beginRange: '1000', 9 | endRange: 'Inf', 10 | pricePerUnit: { 11 | USD: '0.1' 12 | } 13 | }, 14 | { 15 | beginRange: '1', 16 | endRange: '1000', 17 | pricePerUnit: { 18 | USD: '0.2' 19 | } 20 | }, 21 | { 22 | beginRange: '0', 23 | endRange: '1', 24 | pricePerUnit: { 25 | USD: '0' 26 | } 27 | } 28 | ] 29 | 30 | expect(parsePrices(fixtures.transfer[0])).toEqual(expected) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /test/lib/validator.spec.ts: -------------------------------------------------------------------------------- 1 | import { isValidHash } from '@/lib/validator' 2 | 3 | describe('validator', () => { 4 | describe('isValidHash', () => { 5 | test('20文字の16進数ならtrue', async () => { 6 | const hash = '0123456789abcdef0123' 7 | 8 | expect(isValidHash(hash)).toBe(true) 9 | }) 10 | 11 | test('20文字だけど16進数じゃなければfalse', async () => { 12 | const hash = '0123456789abcdefg012' 13 | 14 | expect(isValidHash(hash)).toBe(false) 15 | }) 16 | 17 | test('19文字の16進数はfalse', async () => { 18 | const hash = '0123456789abcdef012' 19 | 20 | expect(isValidHash(hash)).toBe(false) 21 | }) 22 | 23 | test('21文字の16進数はfalse', async () => { 24 | const hash = '0123456789abcdef01234' 25 | 26 | expect(isValidHash(hash)).toBe(false) 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /src/services/nlb.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AmazonEC2', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-LoadBalancerUsage', 11 | operation: 'LoadBalancing:Network' 12 | } 13 | }, 14 | parse: priceList => parseFirstPrice(priceList[0]) 15 | } 16 | }, 17 | lcu: { 18 | price: { 19 | params: { 20 | ServiceCode: 'AmazonEC2', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-LCUUsage', 24 | operation: 'LoadBalancing:Network' 25 | } 26 | }, 27 | parse: priceList => parseFirstPrice(priceList[0]) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/clb.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AmazonEC2', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-LoadBalancerUsage', 11 | operation: 'LoadBalancing' 12 | } 13 | }, 14 | parse: priceList => parseFirstPrice(priceList[0]) 15 | } 16 | }, 17 | transfer: { 18 | price: { 19 | params: { 20 | ServiceCode: 'AmazonEC2', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-DataProcessing-Bytes', 24 | operation: 'LoadBalancing' 25 | } 26 | }, 27 | parse: priceList => parseFirstPrice(priceList[0]) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/lambda.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | request: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AWSLambda', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | group: 'AWS-Lambda-Requests' 11 | } 12 | }, 13 | parse: priceList => parseFirstPrice(priceList[0]) 14 | }, 15 | free: { 16 | manual: 1e6 17 | } 18 | }, 19 | memory: { 20 | price: { 21 | params: { 22 | ServiceCode: 'AWSLambda', 23 | Filters: { 24 | location: 'Asia Pacific (Tokyo)', 25 | group: 'AWS-Lambda-Duration' 26 | } 27 | }, 28 | parse: priceList => parseFirstPrice(priceList[0]) 29 | }, 30 | free: { 31 | manual: 4e5 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/alb.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | price: { 6 | params: { 7 | ServiceCode: 'AmazonEC2', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-LoadBalancerUsage', 11 | operation: 'LoadBalancing:Application' 12 | } 13 | }, 14 | parse: priceList => parseFirstPrice(priceList[0]) 15 | } 16 | }, 17 | lcu: { 18 | price: { 19 | params: { 20 | ServiceCode: 'AmazonEC2', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-LCUUsage', 24 | operation: 'LoadBalancing:Application' 25 | } 26 | }, 27 | parse: priceList => parseFirstPrice(priceList[0]) 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/response.ts: -------------------------------------------------------------------------------- 1 | import { ApiResponse } from '@/lib/types' 2 | 3 | export function createResponse({ 4 | statusCode = 200, 5 | headers = {}, 6 | body = {} 7 | } = {}): ApiResponse { 8 | return { 9 | statusCode: statusCode, 10 | headers: { 11 | 'Access-Control-Allow-Origin': '*', 12 | ...headers 13 | }, 14 | body: JSON.stringify(body) 15 | } 16 | } 17 | 18 | export function createClientErrorResponse({ 19 | statusCode = 400, 20 | headers = {}, 21 | message = 'invalid request' 22 | } = {}): ApiResponse { 23 | return createResponse({ statusCode, headers, body: { message } }) 24 | } 25 | 26 | export function createServerErrorResponse({ 27 | statusCode = 503, 28 | headers = {}, 29 | message = 'oops...' 30 | } = {}): ApiResponse { 31 | return createResponse({ statusCode, headers, body: { message } }) 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/transfer.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | serviceCode: 'AmazonEC2', 4 | product: { 5 | productFamily: '', 6 | attributes: {} 7 | }, 8 | terms: { 9 | OnDemand: { 10 | sku: { 11 | priceDimensions: { 12 | sku01: { 13 | beginRange: '1000', 14 | endRange: 'Inf', 15 | pricePerUnit: { 16 | USD: '0.1' 17 | } 18 | }, 19 | sku02: { 20 | beginRange: '1', 21 | endRange: '1000', 22 | pricePerUnit: { 23 | USD: '0.2' 24 | } 25 | }, 26 | sku03: { 27 | beginRange: '0', 28 | endRange: '1', 29 | pricePerUnit: { 30 | USD: '0' 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | } 38 | ] 39 | -------------------------------------------------------------------------------- /test/fixtures/ec2.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | serviceCode: 'AmazonEC2', 4 | product: { 5 | productFamily: '', 6 | attributes: { 7 | instanceType: 'm5.large' 8 | } 9 | }, 10 | terms: { 11 | OnDemand: { 12 | sku: { 13 | priceDimensions: { 14 | sku: { 15 | pricePerUnit: { 16 | USD: '0.1' 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | }, 24 | { 25 | serviceCode: 'AmazonEC2', 26 | product: { 27 | productFamily: '', 28 | attributes: { 29 | instanceType: 't2.micro' 30 | } 31 | }, 32 | terms: { 33 | OnDemand: { 34 | sku: { 35 | priceDimensions: { 36 | sku: { 37 | pricePerUnit: { 38 | USD: '0.01' 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /test/fixtures/rds.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | serviceCode: 'AmazonEC2', 4 | product: { 5 | productFamily: '', 6 | attributes: { 7 | instanceType: 'db.r4.large' 8 | } 9 | }, 10 | terms: { 11 | OnDemand: { 12 | sku: { 13 | priceDimensions: { 14 | sku: { 15 | pricePerUnit: { 16 | USD: '0.3' 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | }, 24 | { 25 | serviceCode: 'AmazonEC2', 26 | product: { 27 | productFamily: '', 28 | attributes: { 29 | instanceType: 'db.m4.large' 30 | } 31 | }, 32 | terms: { 33 | OnDemand: { 34 | sku: { 35 | priceDimensions: { 36 | sku: { 37 | pricePerUnit: { 38 | USD: '0.2' 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /test/lib/price/fetchPrice.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseInstances } from '@/lib/parser' 3 | import { fetchPrice } from '@/lib/price/fetchPrice' 4 | 5 | describe('fetchPrice', () => { 6 | test('サービスを指定して料金を取得できる', async () => { 7 | const getProducts = jest.fn().mockResolvedValue({ 8 | PriceList: fixtures.ec2, 9 | NextToken: null 10 | }) 11 | const service = { 12 | params: { 13 | ServiceCode: 'AmazonEC2', 14 | Filters: {} 15 | }, 16 | parse: priceList => 17 | parseInstances(priceList, { 18 | name: 'EC2', 19 | index: 0, 20 | order: ['t2', 'm5'] 21 | }) 22 | } 23 | const expected = [ 24 | { price: 0.01, instanceType: 't2.micro' }, 25 | { price: 0.1, instanceType: 'm5.large' } 26 | ] 27 | 28 | const result = await fetchPrice(getProducts, service) 29 | 30 | expect(result).toEqual(expected) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /src/functions/batch/fx.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | import * as path from 'path' 3 | import fetchFx from '@/lib/fx/fetchFx' 4 | import * as sns from '@/lib/aws/sns' 5 | import * as s3 from '@/lib/aws/s3' 6 | import * as slack from '@/lib/notification/slack' 7 | 8 | const { SNS_PRICE_UPDATE_ARN, FX_ENDPOINT, IS_LOCAL, BUCKET_NAME } = process.env 9 | 10 | export async function main() { 11 | try { 12 | const usdjpy = await fetchFx(FX_ENDPOINT) 13 | 14 | if (IS_LOCAL) { 15 | const jsonPath = path.resolve( 16 | process.env.LOCAL_PROJECT_DIR, 17 | `json/fx.json` 18 | ) 19 | 20 | writeFileSync(jsonPath, JSON.stringify({ usdjpy })) 21 | } else { 22 | await s3.uploadJson(BUCKET_NAME, 'json/fx.json', { usdjpy }) 23 | await sns.publish(SNS_PRICE_UPDATE_ARN, 'fx updated') 24 | } 25 | 26 | return 'success' 27 | } catch (e) { 28 | await slack.sendWarning(e) 29 | 30 | throw new Error(e) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/fixtures/apigateway.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | serviceCode: 'AmazonApiGateway', 4 | product: { 5 | productFamily: '', 6 | attributes: { 7 | cacheMemorySizeGb: '1.6' 8 | } 9 | }, 10 | terms: { 11 | OnDemand: { 12 | sku: { 13 | priceDimensions: { 14 | sku: { 15 | pricePerUnit: { 16 | USD: '0.05' 17 | } 18 | } 19 | } 20 | } 21 | } 22 | } 23 | }, 24 | { 25 | serviceCode: 'AmazonApiGateway', 26 | product: { 27 | productFamily: '', 28 | attributes: { 29 | cacheMemorySizeGb: '0.5' 30 | } 31 | }, 32 | terms: { 33 | OnDemand: { 34 | sku: { 35 | priceDimensions: { 36 | sku: { 37 | pricePerUnit: { 38 | USD: '0.03' 39 | } 40 | } 41 | } 42 | } 43 | } 44 | } 45 | } 46 | ] 47 | -------------------------------------------------------------------------------- /src/functions/api/z/post.ts: -------------------------------------------------------------------------------- 1 | import { APIGatewayProxyEvent } from 'aws-lambda' 2 | import isPlainObject from 'lodash/isPlainObject' 3 | import * as s3 from '@/lib/aws/s3' 4 | import { isValidHash } from '@/lib/validator' 5 | import { 6 | createResponse, 7 | createClientErrorResponse, 8 | createServerErrorResponse 9 | } from '@/lib/response' 10 | 11 | export async function main(event: APIGatewayProxyEvent) { 12 | const { BUCKET_NAME } = process.env 13 | const { hash, tables } = JSON.parse(event.body) 14 | 15 | let parsedTable: any 16 | 17 | try { 18 | parsedTable = JSON.parse(tables) 19 | } catch (e) { 20 | return createClientErrorResponse() 21 | } 22 | 23 | if (!isValidHash(hash) || !isPlainObject(parsedTable)) { 24 | return createClientErrorResponse() 25 | } 26 | 27 | try { 28 | await s3.uploadJson(BUCKET_NAME, `json/z/${hash}.json`, parsedTable) 29 | 30 | return createResponse() 31 | } catch (e) { 32 | return createServerErrorResponse() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/functions/batch/price.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | import * as path from 'path' 3 | import * as services from '@/services' 4 | import { fetchPrices } from '@/lib/price/fetchPrices' 5 | import * as pricing from '@/lib/aws/pricing' 6 | import * as s3 from '@/lib/aws/s3' 7 | import * as circleci from '@/lib/ci/circleci' 8 | import * as slack from '@/lib/notification/slack' 9 | 10 | export async function main() { 11 | const { BUCKET_NAME, IS_LOCAL } = process.env 12 | 13 | try { 14 | const prices = await fetchPrices(pricing.getProducts, services) 15 | 16 | if (IS_LOCAL) { 17 | const jsonPath = path.resolve( 18 | process.env.LOCAL_PROJECT_DIR, 19 | `json/price.json` 20 | ) 21 | 22 | writeFileSync(jsonPath, JSON.stringify(prices)) 23 | } else { 24 | await s3.uploadJson(BUCKET_NAME, 'json/price.json', prices) 25 | await circleci.deploy('master') 26 | } 27 | 28 | return 'success' 29 | } catch (e) { 30 | await slack.sendWarning(e) 31 | 32 | throw new Error(e) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/services/fargate.ts: -------------------------------------------------------------------------------- 1 | import range from 'lodash/range' 2 | import { parseFirstPrice } from '@/lib/parser' 3 | 4 | export default { 5 | cpu: { 6 | price: { 7 | params: { 8 | ServiceCode: 'AmazonECS', 9 | Filters: { 10 | location: 'Asia Pacific (Tokyo)', 11 | usagetype: 'APN1-Fargate-vCPU-Hours:perCPU' 12 | } 13 | }, 14 | parse: priceList => parseFirstPrice(priceList[0]) 15 | } 16 | }, 17 | memory: { 18 | price: { 19 | params: { 20 | ServiceCode: 'AmazonECS', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-Fargate-GB-Hours' 24 | } 25 | }, 26 | parse: priceList => parseFirstPrice(priceList[0]) 27 | } 28 | }, 29 | pair: { 30 | manual: { 31 | '0.25': [0.5, ...range(1, 3)], 32 | '0.5': range(1, 5), 33 | '1': range(2, 9), 34 | '2': range(4, 17), 35 | '4': range(8, 31), 36 | '8': range(16, 61, 4), 37 | '16': range(32, 121, 8), 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ec2 } from './ec2' 2 | export { default as fargate } from './fargate' 3 | export { default as elasticache } from './elasticache' 4 | export { default as clb } from './clb' 5 | export { default as alb } from './alb' 6 | export { default as nlb } from './nlb' 7 | export { default as ebs } from './ebs' 8 | export { default as route53 } from './route53' 9 | export { default as cloudfront } from './cloudfront' 10 | export { default as apigateway } from './apigateway' 11 | export { default as s3 } from './s3' 12 | export { default as rds } from './rds' 13 | export { default as aurora } from './aurora' 14 | export { default as dynamodb } from './dynamodb' 15 | export { default as lambda } from './lambda' 16 | export { default as transfer } from './transfer' 17 | export { default as natgw } from './natgw' 18 | export { default as sns } from './sns' 19 | export { default as sqs } from './sqs' 20 | export { default as ses } from './ses' 21 | export { default as cloudwatch } from './cloudwatch' 22 | export { default as cognito } from './cognito' 23 | -------------------------------------------------------------------------------- /test/lib/parser/parseInstances.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseInstances } from '@/lib/parser' 3 | 4 | describe('parseInstances', () => { 5 | test('インスタンスをパースして指定した順番にソートできる', () => { 6 | const options = { 7 | name: 'EC2', 8 | index: 0, 9 | order: ['t2', 'm5'] 10 | } 11 | const expected = [ 12 | { price: 0.01, instanceType: 't2.micro' }, 13 | { price: 0.1, instanceType: 'm5.large' } 14 | ] 15 | 16 | expect(parseInstances(fixtures.ec2, options)).toEqual(expected) 17 | }) 18 | 19 | test('未知のインスタンスがあったら例外を投げる', () => { 20 | const options = { 21 | name: 'EC2', 22 | index: 0, 23 | order: ['t2'] 24 | } 25 | 26 | expect(() => parseInstances(fixtures.ec2, options)).toThrowError('未知') 27 | }) 28 | 29 | test('インスタンスがなかったら例外を投げる', () => { 30 | const options = { 31 | name: 'EC2', 32 | index: 0, 33 | order: ['t2', 'm5', 'z5'] 34 | } 35 | 36 | expect(() => parseInstances(fixtures.ec2, options)).toThrowError('過去') 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface InstanceOptions { 2 | name: string 3 | index: number 4 | order: string[] 5 | } 6 | 7 | export interface ParsedInstance { 8 | price: number 9 | instanceType: string 10 | } 11 | 12 | export interface ApiGatewayCache { 13 | cacheMemorySizeGb: string 14 | price: number 15 | } 16 | 17 | export interface PriceRange { 18 | beginRange: number 19 | endRange: number | null 20 | price: number 21 | } 22 | 23 | export interface PriceFilter { 24 | Field: string 25 | Type: string 26 | Value: string 27 | } 28 | 29 | export interface SeparatedObject { 30 | keys: string[] 31 | values: any[] 32 | } 33 | 34 | export interface ApiResponse { 35 | statusCode: number 36 | headers: any 37 | body: string 38 | } 39 | 40 | export interface PriceItem { 41 | serviceCode: string 42 | product: { 43 | productFamily: string 44 | attributes: any 45 | } 46 | terms: { 47 | OnDemand: { 48 | [key: string]: { 49 | priceDimensions: { 50 | [key: string]: any 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/services/sqs.ts: -------------------------------------------------------------------------------- 1 | import { parseRange, parsePrices } from '@/lib/parser' 2 | 3 | export default { 4 | free: { 5 | params: { 6 | ServiceCode: 'AWSQueueService', 7 | Filters: { 8 | usagetype: 'Global-Requests', 9 | locationType: 'AWS Region', 10 | group: 'SQS-APIRequest-Tier1' 11 | } 12 | }, 13 | parse: priceList => parseInt(parsePrices(priceList[0])[0].endRange, 10) 14 | }, 15 | standard: { 16 | priceRange: { 17 | params: { 18 | ServiceCode: 'AWSQueueService', 19 | Filters: { 20 | location: 'Asia Pacific (Tokyo)', 21 | usagetype: 'APN1-Requests-Tier1' 22 | } 23 | }, 24 | parse: priceList => parseRange(priceList[0]) 25 | } 26 | }, 27 | fifo: { 28 | priceRange: { 29 | params: { 30 | ServiceCode: 'AWSQueueService', 31 | Filters: { 32 | location: 'Asia Pacific (Tokyo)', 33 | usagetype: 'APN1-Requests-FIFO-Tier1' 34 | } 35 | }, 36 | parse: priceList => parseRange(priceList[0]) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/services/s3.ts: -------------------------------------------------------------------------------- 1 | import { parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | storage: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonS3', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | volumeType: 'Standard' 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | }, 16 | request: { 17 | read: { 18 | priceRange: { 19 | params: { 20 | ServiceCode: 'AmazonS3', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-Requests-Tier2' 24 | } 25 | }, 26 | parse: priceList => parseRange(priceList[0]) 27 | } 28 | }, 29 | write: { 30 | priceRange: { 31 | params: { 32 | ServiceCode: 'AmazonS3', 33 | Filters: { 34 | location: 'Asia Pacific (Tokyo)', 35 | usagetype: 'APN1-Requests-Tier1' 36 | } 37 | }, 38 | parse: priceList => parseRange(priceList[0]) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/lib/aws/s3.ts: -------------------------------------------------------------------------------- 1 | import { S3 } from 'aws-sdk' 2 | 3 | const s3 = new S3({ region: 'ap-northeast-1' }) 4 | 5 | export function fetchJson(bucketName: string, path: string): Promise { 6 | return new Promise((resolve, reject) => { 7 | s3.getObject( 8 | { 9 | Bucket: bucketName, 10 | Key: path 11 | }, 12 | (err, data) => { 13 | if (err) { 14 | reject(err) 15 | } else { 16 | resolve(JSON.parse(data.Body.toString())) 17 | } 18 | } 19 | ) 20 | }) 21 | } 22 | 23 | export function uploadJson( 24 | bucketName: string, 25 | path: string, 26 | obj: any 27 | ): Promise { 28 | return new Promise((resolve, reject) => { 29 | s3.upload( 30 | { 31 | Bucket: bucketName, 32 | Key: path, 33 | Body: JSON.stringify(obj), 34 | ContentType: 'application/json', 35 | CacheControl: 'no-store' 36 | }, 37 | err => { 38 | if (err) { 39 | reject(err) 40 | } else { 41 | resolve() 42 | } 43 | } 44 | ) 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const slsw = require('serverless-webpack') 4 | 5 | const entries = {} 6 | 7 | Object.keys(slsw.lib.entries).forEach( 8 | key => (entries[key] = ['./source-map-install.js', slsw.lib.entries[key]]) 9 | ) 10 | 11 | module.exports = { 12 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 13 | entry: entries, 14 | devtool: 'source-map', 15 | resolve: { 16 | extensions: ['.ts', '.js', '.json'], 17 | alias: { 18 | '@': path.resolve(__dirname, 'src') 19 | } 20 | }, 21 | output: { 22 | libraryTarget: 'commonjs', 23 | path: path.join(__dirname, '.webpack'), 24 | filename: '[name].js' 25 | }, 26 | target: 'node', 27 | module: { 28 | rules: [{ test: /\.ts$/, loader: 'ts-loader' }] 29 | }, 30 | externals: ['bufferutil', 'utf-8-validate'], 31 | plugins: [ 32 | new webpack.DefinePlugin({ 33 | 'process.env.LOCAL_PROJECT_DIR': slsw.lib.webpack.isLocal 34 | ? JSON.stringify(__dirname) 35 | : JSON.stringify('/tmp') 36 | }) 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/aws/pricing.ts: -------------------------------------------------------------------------------- 1 | import { Pricing } from 'aws-sdk' 2 | 3 | const pricing = new Pricing({ region: 'us-east-1' }) 4 | 5 | export function describeServices(ServiceCode: string): Promise { 6 | return new Promise((resolve, reject) => { 7 | pricing.describeServices({ ServiceCode }, (err, data) => { 8 | if (err) { 9 | reject(err) 10 | } else { 11 | resolve(data) 12 | } 13 | }) 14 | }) 15 | } 16 | 17 | export function getAttributeValues( 18 | ServiceCode: string, 19 | AttributeName: string 20 | ): Promise { 21 | return new Promise((resolve, reject) => { 22 | pricing.getAttributeValues({ ServiceCode, AttributeName }, (err, data) => { 23 | if (err) { 24 | reject(err) 25 | } else { 26 | resolve(data) 27 | } 28 | }) 29 | }) 30 | } 31 | 32 | export function getProducts(params: any): Promise { 33 | return new Promise((resolve, reject) => { 34 | pricing.getProducts(params, (err, data) => { 35 | if (err) { 36 | reject(err) 37 | } else { 38 | resolve(data) 39 | } 40 | }) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 noplan1989 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/price/fetchPrice.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs' 2 | import * as path from 'path' 3 | import { formatFilters } from './helpers' 4 | 5 | export function fetchPrice(getProducts, service) { 6 | return new Promise((resolve, reject) => { 7 | const { IS_LOCAL } = process.env 8 | 9 | const fetchPrice = async (params, arr) => { 10 | try { 11 | const productParams = { 12 | ...params, 13 | Filters: formatFilters(params.Filters) 14 | } 15 | const { PriceList, NextToken } = await getProducts(productParams) 16 | const priceLists = arr.concat(PriceList) 17 | 18 | if (NextToken) { 19 | return fetchPrice({ ...params, NextToken }, priceLists) 20 | } else { 21 | if (IS_LOCAL) { 22 | const jsonPath = path.resolve( 23 | process.env.LOCAL_PROJECT_DIR, 24 | `json/${params.ServiceCode}.json` 25 | ) 26 | 27 | writeFileSync(jsonPath, JSON.stringify(priceLists)) 28 | } 29 | return resolve(service.parse(priceLists)) 30 | } 31 | } catch (e) { 32 | return reject(e) 33 | } 34 | } 35 | 36 | fetchPrice(service.params, []) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/price/helpers.ts: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set' 2 | import { PriceFilter, SeparatedObject } from '@/lib/types' 3 | 4 | export function wait(timeout: number): Promise { 5 | return new Promise(resolve => { 6 | setTimeout(() => { 7 | resolve() 8 | }, timeout) 9 | }) 10 | } 11 | 12 | export function separate(targets: any): SeparatedObject { 13 | let keys = [] 14 | let values = [] 15 | 16 | const deep = (obj, stack) => { 17 | Object.keys(obj).forEach(name => { 18 | const str = stack ? `${stack}.${name}` : name 19 | 20 | if (obj[name].parse || obj[name].manual) { 21 | keys.push(str) 22 | values.push(obj[name]) 23 | } else { 24 | deep(obj[name], str) 25 | } 26 | }) 27 | } 28 | 29 | deep(targets, '') 30 | 31 | return { keys, values } 32 | } 33 | 34 | export function combine(keys: string[], values: any[]): any { 35 | let obj = {} 36 | 37 | keys.forEach((key, i) => { 38 | set(obj, key, values[i]) 39 | }) 40 | 41 | return obj 42 | } 43 | 44 | export function formatFilters(filters: any): PriceFilter[] { 45 | return Object.keys(filters).map(name => ({ 46 | Field: name, 47 | Type: 'TERM_MATCH', 48 | Value: filters[name] 49 | })) 50 | } 51 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | working_directory: ~/app 6 | docker: 7 | - image: circleci/node:10-jessie-browsers 8 | steps: 9 | - checkout 10 | - run: 11 | name: Create AWS Credentials 12 | command: | 13 | mkdir ~/.aws && 14 | echo "[default]" > ~/.aws/credentials && 15 | echo "aws_access_key_id=${AWS_ACCESS_KEY_ID}" >> ~/.aws/credentials && 16 | echo "aws_secret_access_key=${AWS_SECRET_ACCESS_KEY}" >> ~/.aws/credentials 17 | - run: 18 | name: Create Env File 19 | command: echo $SERVERLESS_ENV | base64 --decode > serverless.env.yml 20 | - restore_cache: 21 | name: Restore npm package 22 | keys: 23 | - node-{{ .Branch }}-{{ checksum "package-lock.json" }} 24 | - run: npm install 25 | - save_cache: 26 | name: Save npm package 27 | key: node-{{ .Branch }}-{{ checksum "package-lock.json" }} 28 | paths: 29 | - node_modules 30 | - run: npm run test 31 | - run: npm run deploy:production 32 | - run: npm run invoke:production fx 33 | 34 | workflows: 35 | version: 2 36 | build-deploy: 37 | jobs: 38 | - build: 39 | filters: 40 | branches: 41 | only: master -------------------------------------------------------------------------------- /src/functions/api/contact/post.ts: -------------------------------------------------------------------------------- 1 | import sg from '@sendgrid/mail' 2 | import { APIGatewayProxyEvent } from 'aws-lambda' 3 | import { 4 | createResponse, 5 | createClientErrorResponse, 6 | createServerErrorResponse 7 | } from '@/lib/response' 8 | 9 | export async function main(event: APIGatewayProxyEvent) { 10 | const SEND_GRID_API_KEY = process.env.SEND_GRID_API_KEY || '' 11 | const SEND_GRID_EMAIL_FROM = process.env.SEND_GRID_EMAIL_FROM || '' 12 | const SEND_GRID_EMAIL_TO = process.env.SEND_GRID_EMAIL_TO || '' 13 | 14 | let body 15 | 16 | console.log(event) 17 | 18 | if (!SEND_GRID_API_KEY || !SEND_GRID_EMAIL_FROM || !SEND_GRID_EMAIL_TO) { 19 | return createServerErrorResponse() 20 | } 21 | 22 | try { 23 | body = JSON.parse(event.body) 24 | } catch (e) { 25 | return createClientErrorResponse() 26 | } 27 | 28 | const text = body.text 29 | 30 | if (!text || text.length > 4096) { 31 | return createClientErrorResponse() 32 | } 33 | 34 | try { 35 | const message = { 36 | to: SEND_GRID_EMAIL_TO, 37 | from: SEND_GRID_EMAIL_FROM, 38 | subject: '[ざっくりAWS]お問い合わせ', 39 | text, 40 | }; 41 | 42 | sg.setApiKey(SEND_GRID_API_KEY) 43 | 44 | await sg.send(message) 45 | 46 | return createResponse() 47 | } catch (e) { 48 | return createServerErrorResponse() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/lib/response.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createResponse, 3 | createClientErrorResponse, 4 | createServerErrorResponse 5 | } from '@/lib/response' 6 | 7 | describe('response', () => { 8 | describe('createResponse', () => { 9 | test('正常なレスポンスを構築できる', async () => { 10 | const body = { 11 | message: 'success' 12 | } 13 | 14 | const expected = { 15 | statusCode: 200, 16 | headers: { 17 | 'Access-Control-Allow-Origin': '*' 18 | }, 19 | body: JSON.stringify(body) 20 | } 21 | 22 | expect(createResponse({ body })).toEqual(expected) 23 | }) 24 | }) 25 | 26 | describe('createClientErrorResponse', () => { 27 | test('クライアントエラーのレスポンスを構築できる', async () => { 28 | const expected = { 29 | statusCode: 400, 30 | headers: { 31 | 'Access-Control-Allow-Origin': '*' 32 | }, 33 | body: JSON.stringify({ message: 'invalid request' }) 34 | } 35 | 36 | expect(createClientErrorResponse()).toEqual(expected) 37 | }) 38 | }) 39 | 40 | describe('createServerErrorResponse', () => { 41 | test('サーバーエラーのレスポンスを構築できる', async () => { 42 | const expected = { 43 | statusCode: 503, 44 | headers: { 45 | 'Access-Control-Allow-Origin': '*' 46 | }, 47 | body: JSON.stringify({ message: 'oops...' }) 48 | } 49 | 50 | expect(createServerErrorResponse()).toEqual(expected) 51 | }) 52 | }) 53 | }) 54 | -------------------------------------------------------------------------------- /src/services/cloudfront.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice, parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | transfer: { 5 | external: { 6 | priceRange: { 7 | params: { 8 | ServiceCode: 'AmazonCloudFront', 9 | Filters: { 10 | fromLocation: 'Japan', 11 | toLocation: 'External' 12 | } 13 | }, 14 | parse: priceList => parseRange(priceList[0]) 15 | } 16 | }, 17 | origin: { 18 | priceRange: { 19 | params: { 20 | ServiceCode: 'AmazonCloudFront', 21 | Filters: { 22 | fromLocation: 'Japan', 23 | toLocation: 'Data Origin' 24 | } 25 | }, 26 | parse: priceList => parseRange(priceList[0]) 27 | } 28 | } 29 | }, 30 | request: { 31 | http: { 32 | price: { 33 | params: { 34 | ServiceCode: 'AmazonCloudFront', 35 | Filters: { 36 | location: 'Japan', 37 | requestType: 'CloudFront-Request-HTTP-Proxy' 38 | } 39 | }, 40 | parse: priceList => parseFirstPrice(priceList[0]) 41 | } 42 | }, 43 | https: { 44 | price: { 45 | params: { 46 | ServiceCode: 'AmazonCloudFront', 47 | Filters: { 48 | location: 'Japan', 49 | requestType: 'CloudFront-Request-HTTPS-Proxy' 50 | } 51 | }, 52 | parse: priceList => parseFirstPrice(priceList[0]) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/parser/parseInstances.ts: -------------------------------------------------------------------------------- 1 | import sortBy from 'lodash/sortBy' 2 | import flatten from 'lodash/flatten' 3 | import uniqBy from 'lodash/uniqBy' 4 | import { InstanceOptions, ParsedInstance, PriceItem } from '@/lib/types' 5 | import { parseFirstPrice, parseInstanceType } from './index' 6 | 7 | export function parseInstances( 8 | priceList: PriceItem[], 9 | options: InstanceOptions 10 | ): ParsedInstance[] { 11 | let obj = {} 12 | 13 | priceList.forEach(priceItem => { 14 | const instanceType = parseInstanceType(priceItem) 15 | const prefix = instanceType.split('.')[options.index] 16 | 17 | if (!obj[prefix]) { 18 | obj[prefix] = [] 19 | } 20 | 21 | obj[prefix].push({ 22 | price: parseFirstPrice(priceItem), 23 | instanceType 24 | }) 25 | }) 26 | 27 | const prefixes = Object.keys(obj) 28 | 29 | // 未知のインスタンス 30 | prefixes.forEach(prefix => { 31 | if (!options.order.includes(prefix)) { 32 | throw new Error(`${options.name} => 未知のインスタンスを発見 : ${prefix}`) 33 | } 34 | }) 35 | 36 | // 過去のインスタンス 37 | options.order.forEach(prefix => { 38 | if (!prefixes.includes(prefix)) { 39 | throw new Error(`${options.name} => 過去のインスタンスを発見 : ${prefix}`) 40 | } 41 | }) 42 | 43 | prefixes.forEach(p => { 44 | const instances = obj[p] 45 | 46 | if (instances.length !== uniqBy(instances, 'instanceType').length) { 47 | throw new Error(`${options.name} => インスタンスが重複しています`) 48 | } 49 | }) 50 | 51 | return flatten(options.order.map(name => sortBy(obj[name], ['price']))) 52 | } 53 | -------------------------------------------------------------------------------- /src/services/sns.ts: -------------------------------------------------------------------------------- 1 | import { parseRange, parseFirstPrice, parsePrices } from '@/lib/parser' 2 | 3 | export default { 4 | request: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonSNS', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-Requests-Tier1' 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | }, 16 | mobile: { 17 | price: { 18 | params: { 19 | ServiceCode: 'AmazonSNS', 20 | Filters: { 21 | location: 'Asia Pacific (Tokyo)', 22 | usagetype: 'APN1-DeliveryAttempts-APNS' 23 | } 24 | }, 25 | parse: priceList => parseFirstPrice(priceList[0]) 26 | }, 27 | free: { 28 | params: { 29 | ServiceCode: 'AmazonSNS', 30 | Filters: { 31 | usagetype: 'Notifications-Mobile', 32 | group: 'SNS-MonthlyFree-Notifications' 33 | } 34 | }, 35 | parse: priceList => parseInt(parsePrices(priceList[0])[0].endRange, 10) 36 | } 37 | }, 38 | http: { 39 | priceRange: { 40 | params: { 41 | ServiceCode: 'AmazonSNS', 42 | Filters: { 43 | location: 'Asia Pacific (Tokyo)', 44 | usagetype: 'APN1-DeliveryAttempts-HTTP' 45 | } 46 | }, 47 | parse: priceList => parseRange(priceList[0]) 48 | } 49 | }, 50 | email: { 51 | priceRange: { 52 | params: { 53 | ServiceCode: 'AmazonSNS', 54 | Filters: { 55 | location: 'Asia Pacific (Tokyo)', 56 | usagetype: 'APN1-DeliveryAttempts-SMTP' 57 | } 58 | }, 59 | parse: priceList => parseRange(priceList[0]) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "deploy:dev": "serverless deploy -v --stage dev", 4 | "deploy:production": "serverless deploy -v --stage production", 5 | "invoke": "serverless invoke -l -f", 6 | "invoke:local": "serverless invoke local -f", 7 | "invoke:dev": "serverless invoke --stage dev -l -f", 8 | "invoke:production": "serverless invoke --stage production -l -f", 9 | "test": "jest --forceExit", 10 | "format": "tslint -p ./tsconfig.json --fix", 11 | "lint": "tslint -p ./tsconfig.json", 12 | "precommit": "lint-staged" 13 | }, 14 | "lint-staged": { 15 | "*.{js}": [ 16 | "tslint -p ./tsconfig.json --fix", 17 | "git add" 18 | ] 19 | }, 20 | "dependencies": { 21 | "@sendgrid/mail": "^7.4.6", 22 | "@slack/webhook": "^5.0.2", 23 | "axios": "^0.21.1", 24 | "dayjs": "^1.10.7", 25 | "lodash": "^4.17.21" 26 | }, 27 | "devDependencies": { 28 | "@types/aws-lambda": "^8.10.35", 29 | "@types/jest": "^24.0.22", 30 | "@types/node": "^12.12.7", 31 | "@types/ws": "^6.0.3", 32 | "@typescript-eslint/parser": "^2.6.1", 33 | "aws-sdk": "^2.568.0", 34 | "husky": "^3.0.9", 35 | "jest": "^24.9.0", 36 | "lint-staged": "^9.4.2", 37 | "prettier": "^1.19.1", 38 | "prettyjson": "^1.2.1", 39 | "serverless": "^1.83.3", 40 | "serverless-domain-manager": "^3.3.2", 41 | "serverless-webpack": "^5.3.1", 42 | "source-map-support": "^0.5.16", 43 | "ts-jest": "^24.1.0", 44 | "ts-loader": "^6.2.1", 45 | "tslint": "^5.20.1", 46 | "tslint-config-prettier": "^1.18.0", 47 | "tslint-config-standard": "^9.0.0", 48 | "tslint-plugin-prettier": "^2.0.1", 49 | "typescript": "^3.7.2", 50 | "webpack": "^4.41.2" 51 | }, 52 | "volta": { 53 | "node": "12.22.12" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/services/dynamodb.ts: -------------------------------------------------------------------------------- 1 | import { parseRange, parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | storage: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonDynamoDB', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype: 'APN1-TimedStorage-ByteHrs' 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | }, 16 | provisioning: { 17 | wcu: { 18 | priceRange: { 19 | params: { 20 | ServiceCode: 'AmazonDynamoDB', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype: 'APN1-WriteCapacityUnit-Hrs' 24 | } 25 | }, 26 | parse: priceList => parseRange(priceList[0]) 27 | } 28 | }, 29 | rcu: { 30 | priceRange: { 31 | params: { 32 | ServiceCode: 'AmazonDynamoDB', 33 | Filters: { 34 | location: 'Asia Pacific (Tokyo)', 35 | usagetype: 'APN1-ReadCapacityUnit-Hrs' 36 | } 37 | }, 38 | parse: priceList => parseRange(priceList[0]) 39 | } 40 | } 41 | }, 42 | ondemand: { 43 | write: { 44 | price: { 45 | params: { 46 | ServiceCode: 'AmazonDynamoDB', 47 | Filters: { 48 | location: 'Asia Pacific (Tokyo)', 49 | usagetype: 'APN1-WriteRequestUnits' 50 | } 51 | }, 52 | parse: priceList => parseFirstPrice(priceList[0]) 53 | } 54 | }, 55 | read: { 56 | price: { 57 | params: { 58 | ServiceCode: 'AmazonDynamoDB', 59 | Filters: { 60 | location: 'Asia Pacific (Tokyo)', 61 | usagetype: 'APN1-ReadRequestUnits' 62 | } 63 | }, 64 | parse: priceList => parseFirstPrice(priceList[0]) 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/services/aurora.ts: -------------------------------------------------------------------------------- 1 | import { parseInstances, parseFirstPrice } from '@/lib/parser' 2 | 3 | const auroraFilter = { 4 | location: 'Asia Pacific (Tokyo)', 5 | storage: 'EBS only', 6 | deploymentOption: 'Single-AZ' 7 | } 8 | 9 | export default { 10 | instance: { 11 | MySQL: { 12 | params: { 13 | ServiceCode: 'AmazonRDS', 14 | Filters: { 15 | ...auroraFilter, 16 | databaseEngine: 'Aurora MySQL' 17 | } 18 | }, 19 | parse: priceList => 20 | parseInstances(priceList, { 21 | name: 'Aurora MySQL', 22 | index: 1, 23 | order: ['t4g', 't3', 't2', 'r8g', 'r7g', 'r7i', 'r6g', 'r6i', 'r5', 'r4'] 24 | }) 25 | }, 26 | PostgreSQL: { 27 | params: { 28 | ServiceCode: 'AmazonRDS', 29 | Filters: { 30 | ...auroraFilter, 31 | databaseEngine: 'Aurora PostgreSQL' 32 | } 33 | }, 34 | parse: priceList => 35 | parseInstances(priceList, { 36 | name: 'Aurora PostgreSQL', 37 | index: 1, 38 | order: ['t4g', 't3', 'r8g', 'r7g', 'r7i', 'r6g', 'r6i', 'r5', 'r4'] 39 | }) 40 | } 41 | }, 42 | storage: { 43 | price: { 44 | params: { 45 | ServiceCode: 'AmazonRDS', 46 | Filters: { 47 | location: 'Asia Pacific (Tokyo)', 48 | databaseEngine: 'Any', 49 | usagetype: 'APN1-Aurora:StorageUsage' 50 | } 51 | }, 52 | parse: priceList => parseFirstPrice(priceList[0]) 53 | } 54 | }, 55 | io: { 56 | price: { 57 | params: { 58 | ServiceCode: 'AmazonRDS', 59 | Filters: { 60 | location: 'Asia Pacific (Tokyo)', 61 | databaseEngine: 'Any', 62 | usagetype: 'APN1-Aurora:StorageIOUsage' 63 | } 64 | }, 65 | parse: priceList => parseFirstPrice(priceList[0]) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/services/ec2.ts: -------------------------------------------------------------------------------- 1 | import { parseInstances } from '@/lib/parser' 2 | 3 | export default { 4 | instance: { 5 | params: { 6 | ServiceCode: 'AmazonEC2', 7 | Filters: { 8 | location: 'Asia Pacific (Tokyo)', 9 | operatingSystem: 'Linux', 10 | currentGeneration: 'Yes', 11 | capacitystatus: 'Used', 12 | preInstalledSw: 'NA', 13 | storage: 'EBS only', 14 | tenancy: 'Shared' 15 | } 16 | }, 17 | parse: priceList => 18 | parseInstances(priceList, { 19 | name: 'EC2', 20 | index: 0, 21 | order: [ 22 | 't4g', 23 | 't3', 24 | 't3a', 25 | 't2', 26 | 'm8g', 27 | 'm7a', 28 | 'm7g', 29 | 'm7i', 30 | 'm7i-flex', 31 | 'm6a', 32 | 'm6g', 33 | 'm6i', 34 | 'm6in', 35 | 'm5', 36 | 'm5a', 37 | 'm5n', 38 | 'm5zn', 39 | 'm4', 40 | 'c8g', 41 | 'c7a', 42 | 'c7gn', 43 | 'c7g', 44 | 'c7i', 45 | 'c7i-flex', 46 | 'c6a', 47 | 'c6i', 48 | 'c6in', 49 | 'c6g', 50 | 'c6gn', 51 | 'c5', 52 | 'c5a', 53 | 'c5n', 54 | 'c4', 55 | 'r8g', 56 | 'r7a', 57 | 'r7g', 58 | 'r7i', 59 | 'r7iz', 60 | 'r6i', 61 | 'r6in', 62 | 'r6g', 63 | 'r6a', 64 | 'r5', 65 | 'r5a', 66 | 'r5b', 67 | 'r5n', 68 | 'r4', 69 | 'p3', 70 | 'p2', 71 | 'g5g', 72 | 'g3', 73 | 'g3s', 74 | 'inf1', 75 | 'inf2', 76 | 'vt1', 77 | 'hpc7g', 78 | 'x2iezn', 79 | 'u-3tb1', 80 | 'u-6tb1' 81 | ] 82 | }) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/services/ses.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice } from '@/lib/parser' 2 | 3 | export default { 4 | send: { 5 | ec2: { 6 | price: { 7 | params: { 8 | ServiceCode: 'AmazonSES', 9 | Filters: { 10 | location: 'US East (N. Virginia)', 11 | usagetype: 'Recipients-EC2', 12 | operation: 'Send' 13 | } 14 | }, 15 | parse: priceList => parseFirstPrice(priceList[0]) 16 | } 17 | }, 18 | general: { 19 | price: { 20 | params: { 21 | ServiceCode: 'AmazonSES', 22 | Filters: { 23 | location: 'US East (N. Virginia)', 24 | usagetype: 'Recipients', 25 | operation: 'Send' 26 | } 27 | }, 28 | parse: priceList => parseFirstPrice(priceList[0]) 29 | } 30 | }, 31 | attachment: { 32 | price: { 33 | params: { 34 | ServiceCode: 'AmazonSES', 35 | Filters: { 36 | location: 'US East (N. Virginia)', 37 | usagetype: 'AttachmentsSize-Bytes' 38 | } 39 | }, 40 | parse: priceList => parseFirstPrice(priceList[0]) 41 | } 42 | } 43 | }, 44 | recieve: { 45 | request: { 46 | price: { 47 | params: { 48 | ServiceCode: 'AmazonSES', 49 | Filters: { 50 | location: 'US East (N. Virginia)', 51 | usagetype: 'Message', 52 | operation: 'Receive' 53 | } 54 | }, 55 | parse: priceList => parseFirstPrice(priceList[0]) 56 | } 57 | }, 58 | chunk: { 59 | price: { 60 | params: { 61 | ServiceCode: 'AmazonSES', 62 | Filters: { 63 | location: 'US East (N. Virginia)', 64 | usagetype: 'ReceivedChunk' 65 | } 66 | }, 67 | parse: priceList => parseFirstPrice(priceList[0]) 68 | } 69 | } 70 | }, 71 | dedicatedIp: { 72 | price: { 73 | params: { 74 | ServiceCode: 'AmazonSES', 75 | Filters: { 76 | location: 'US East (N. Virginia)', 77 | usagetype: 'USE1-DIP-Hours' 78 | } 79 | }, 80 | parse: priceList => parseFirstPrice(priceList[0]) 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/services/rds.ts: -------------------------------------------------------------------------------- 1 | import { parseInstances, parseFirstPrice } from '@/lib/parser' 2 | 3 | const rdsFilter = { 4 | location: 'Asia Pacific (Tokyo)', 5 | currentGeneration: 'Yes', 6 | storage: 'EBS only', 7 | deploymentOption: 'Single-AZ' 8 | } 9 | 10 | export default { 11 | instance: { 12 | MySQL: { 13 | params: { 14 | ServiceCode: 'AmazonRDS', 15 | Filters: { 16 | ...rdsFilter, 17 | databaseEngine: 'MySQL', 18 | engineCode: '2' 19 | } 20 | }, 21 | parse: priceList => 22 | parseInstances(priceList, { 23 | name: 'RDS MySQL', 24 | index: 1, 25 | order: ['t4g', 't3', 'm8g', 'm7g', 'm7i', 'm6g', 'm6i', 'm6in', 'm5', 'r8g', 'r7g', 'r7i', 'r6g', 'r6i', 'r6in', 'r5', 'r5b'] 26 | }) 27 | }, 28 | MariaDB: { 29 | params: { 30 | ServiceCode: 'AmazonRDS', 31 | Filters: { 32 | ...rdsFilter, 33 | databaseEngine: 'MariaDB' 34 | } 35 | }, 36 | parse: priceList => 37 | parseInstances(priceList, { 38 | name: 'RDS MariaDB', 39 | index: 1, 40 | order: ['t4g', 't3', 'm8g', 'm7g', 'm7i', 'm6g', 'm6i', 'm6in', 'm5', 'r8g', 'r7g', 'r7i', 'r6g', 'r6i', 'r6in', 'r5', 'r5b'] 41 | }) 42 | }, 43 | PostgreSQL: { 44 | params: { 45 | ServiceCode: 'AmazonRDS', 46 | Filters: { 47 | ...rdsFilter, 48 | databaseEngine: 'PostgreSQL', 49 | engineCode: '14' 50 | } 51 | }, 52 | parse: priceList => 53 | parseInstances(priceList, { 54 | name: 'RDS PostgreSQL', 55 | index: 1, 56 | order: ['t4g', 't3', 'm8g', 'm7g', 'm7i', 'm6g', 'm6i', 'm6in', 'm5', 'r8g', 'r7g', 'r7i', 'r6g', 'r6i', 'r6in', 'r5b', 'r5'] 57 | }) 58 | } 59 | }, 60 | storage: { 61 | gp2: { 62 | price: { 63 | params: { 64 | ServiceCode: 'AmazonRDS', 65 | Filters: { 66 | location: 'Asia Pacific (Tokyo)', 67 | usagetype: 'APN1-RDS:GP2-Storage', 68 | deploymentOption: 'Single-AZ' 69 | } 70 | }, 71 | parse: priceList => parseFirstPrice(priceList[0]) 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/lib/price/fetchPrices.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fixtures from 'test/fixtures' 2 | import { parseInstances, parseRange } from '@/lib/parser' 3 | import { fetchPrices } from '@/lib/price/fetchPrices' 4 | 5 | describe('fetchPrices', () => { 6 | test('全サービスの料金を取得できる', async () => { 7 | const getProducts = jest 8 | .fn() 9 | .mockResolvedValueOnce( 10 | Promise.resolve({ 11 | PriceList: fixtures.ec2, 12 | NextToken: null 13 | }) 14 | ) 15 | .mockResolvedValueOnce( 16 | Promise.resolve({ 17 | PriceList: fixtures.transfer, 18 | NextToken: null 19 | }) 20 | ) 21 | 22 | const services = { 23 | ec2: { 24 | instance: { 25 | params: { 26 | ServiceCode: 'AmazonEC2', 27 | Filters: {} 28 | }, 29 | parse: priceList => 30 | parseInstances(priceList, { 31 | name: 'EC2', 32 | index: 0, 33 | order: ['t2', 'm5'] 34 | }) 35 | } 36 | }, 37 | fargate: { 38 | pair: { 39 | manual: { 40 | '0.25': [0.5, 1, 2, 3], 41 | '0.5': [1, 2, 3, 4, 5] 42 | } 43 | } 44 | }, 45 | transfer: { 46 | out: { 47 | priceRange: { 48 | params: { 49 | ServiceCode: 'AWSDataTransfer', 50 | Filters: {} 51 | }, 52 | parse: priceList => parseRange(priceList[0]) 53 | } 54 | } 55 | } 56 | } 57 | const expected = { 58 | ec2: { 59 | instance: [ 60 | { price: 0.01, instanceType: 't2.micro' }, 61 | { price: 0.1, instanceType: 'm5.large' } 62 | ] 63 | }, 64 | fargate: { 65 | pair: { 66 | '0.25': [0.5, 1, 2, 3], 67 | '0.5': [1, 2, 3, 4, 5] 68 | } 69 | }, 70 | transfer: { 71 | out: { 72 | priceRange: [ 73 | { 74 | beginRange: 0, 75 | endRange: 1, 76 | price: 0 77 | }, 78 | { 79 | beginRange: 1, 80 | endRange: 1000, 81 | price: 0.2 82 | }, 83 | { 84 | beginRange: 1000, 85 | endRange: null, 86 | price: 0.1 87 | } 88 | ] 89 | } 90 | } 91 | } 92 | 93 | const result = await fetchPrices(getProducts, services) 94 | 95 | expect(result).toEqual(expected) 96 | }) 97 | }) 98 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: aws-rough-functions 2 | 3 | provider: 4 | name: aws 5 | runtime: nodejs18.x 6 | stage: dev 7 | region: ap-northeast-1 8 | profile: ${opt:profile, self:custom.defaultProfile} 9 | environment: 10 | FX_ENDPOINT: "https://api.apilayer.com/currency_data/live?source=USD¤cies=JPY" 11 | FX_API_KEY: ${file(./serverless.env.yml):FX_API_KEY} 12 | BUCKET_NAME: ${file(./serverless.env.yml):BUCKET_NAME} 13 | SNS_PRICE_UPDATE_ARN: ${file(./serverless.env.yml):SNS_PRICE_UPDATE_ARN} 14 | SLACK_WEBHOOK_URL: ${file(./serverless.env.yml):SLACK_WEBHOOK_URL} 15 | CIRCLE_API_TOKEN: ${file(./serverless.env.yml):CIRCLE_API_TOKEN} 16 | CIRCLE_BUILD_ENDPOINT: ${file(./serverless.env.yml):CIRCLE_BUILD_ENDPOINT} 17 | SEND_GRID_API_KEY: ${file(./serverless.env.yml):SEND_GRID_API_KEY} 18 | SEND_GRID_EMAIL_FROM: ${file(./serverless.env.yml):SEND_GRID_EMAIL_FROM} 19 | SEND_GRID_EMAIL_TO: ${file(./serverless.env.yml):SEND_GRID_EMAIL_TO} 20 | apiGateway: 21 | minimumCompressionSize: 512 22 | iamRoleStatements: 23 | - Effect: Allow 24 | Action: 25 | - "pricing:*" 26 | Resource: "*" 27 | - Effect: "Allow" 28 | Action: 29 | - "s3:*" 30 | Resource: "arn:aws:s3:::${self:provider.environment.BUCKET_NAME}/*" 31 | - Effect: Allow 32 | Action: 33 | - "cloudfront:*" 34 | Resource: "*" 35 | - Effect: Allow 36 | Action: 37 | - "sns:Publish" 38 | Resource: 39 | - ${self:provider.environment.SNS_PRICE_UPDATE_ARN} 40 | plugins: 41 | - serverless-domain-manager 42 | - serverless-webpack 43 | custom: 44 | defaultProfile: default 45 | stage: "${opt:stage, self:provider.stage}" 46 | customDomain: 47 | domainName: ${self:custom.${self:custom.stage}.domain} 48 | basePath: '' 49 | stage: ${self:custom.stage} 50 | createRoute53Record: true 51 | dev: 52 | domain: dev-aws-api.noplan.cc 53 | priceScheduleEnabled: false 54 | production: 55 | domain: aws-api.noplan.cc 56 | priceScheduleEnabled: true 57 | webpack: 58 | includeModules: 59 | forceExclude: 60 | - aws-sdk 61 | 62 | functions: 63 | fx: 64 | handler: src/functions/batch/fx.main 65 | memorySize: 512 66 | timeout: 300 67 | events: 68 | - schedule: 69 | rate: cron(0 1 * * ? *) 70 | enabled: ${self:custom.${self:custom.stage}.priceScheduleEnabled} 71 | price: 72 | handler: src/functions/batch/price.main 73 | memorySize: 512 74 | timeout: 300 75 | events: 76 | - sns: ${self:provider.environment.SNS_PRICE_UPDATE_ARN} 77 | getZ: 78 | handler: src/functions/api/z/get.main 79 | events: 80 | - http: 81 | path: /z/{hash} 82 | method: get 83 | cors: true 84 | postZ: 85 | handler: src/functions/api/z/post.main 86 | events: 87 | - http: 88 | path: /z 89 | method: post 90 | cors: true 91 | postContact: 92 | handler: src/functions/api/contact/post.main 93 | events: 94 | - http: 95 | path: /contact 96 | method: post 97 | cors: true -------------------------------------------------------------------------------- /test/lib/price/helpers.spec.ts: -------------------------------------------------------------------------------- 1 | import { separate, combine, formatFilters } from '@/lib/price/helpers' 2 | 3 | describe('helpers', () => { 4 | describe('separate', () => { 5 | test('設定ファイルのキーと値を分割できる', () => { 6 | // parseの関数か手入力のmanualに遭遇するまで掘って値を返す 7 | const targets = { 8 | instance: { 9 | MySQL: { 10 | params: { name: 'MySQL' }, 11 | parse: 'Function' 12 | }, 13 | PostgreSQL: { 14 | params: { name: 'PostgreSQL' }, 15 | parse: 'Function' 16 | } 17 | }, 18 | storage: { 19 | gp2: { 20 | price: { 21 | params: { name: 'gp2' }, 22 | parse: 'Function' 23 | } 24 | } 25 | }, 26 | manual: { 27 | manual: { 28 | hoge: 'fuga' 29 | } 30 | } 31 | } 32 | const expected = { 33 | keys: [ 34 | 'instance.MySQL', 35 | 'instance.PostgreSQL', 36 | 'storage.gp2.price', 37 | 'manual' 38 | ], 39 | values: [ 40 | { 41 | params: { name: 'MySQL' }, 42 | parse: 'Function' 43 | }, 44 | { 45 | params: { name: 'PostgreSQL' }, 46 | parse: 'Function' 47 | }, 48 | { 49 | params: { name: 'gp2' }, 50 | parse: 'Function' 51 | }, 52 | { 53 | manual: { 54 | hoge: 'fuga' 55 | } 56 | } 57 | ] 58 | } 59 | 60 | expect(separate(targets)).toEqual(expected) 61 | }) 62 | }) 63 | 64 | describe('combine', () => { 65 | test('キーと値を結合してオブジェクトを構築できる', () => { 66 | const keys = [ 67 | 'instance.MySQL', 68 | 'instance.PostgreSQL', 69 | 'storage.gp2.price', 70 | 'manual' 71 | ] 72 | const values = [ 73 | [{ price: 0.02, instanceType: 'db.t2.micro' }], 74 | [{ price: 0.02, instanceType: 'db.t2.micro' }], 75 | 0.14, 76 | { 77 | hoge: 'fuga' 78 | } 79 | ] 80 | const expected = { 81 | instance: { 82 | MySQL: [{ price: 0.02, instanceType: 'db.t2.micro' }], 83 | PostgreSQL: [{ price: 0.02, instanceType: 'db.t2.micro' }] 84 | }, 85 | storage: { 86 | gp2: { 87 | price: 0.14 88 | } 89 | }, 90 | manual: { 91 | hoge: 'fuga' 92 | } 93 | } 94 | 95 | expect(combine(keys, values)).toEqual(expected) 96 | }) 97 | }) 98 | 99 | describe('formatFilters', () => { 100 | test('Filtersの形式をAPIの仕様に合わせて整形できる', () => { 101 | const filters = { 102 | location: 'Asia Pacific (Tokyo)', 103 | operatingSystem: 'Linux' 104 | } 105 | const expected = [ 106 | { 107 | Field: 'location', 108 | Type: 'TERM_MATCH', 109 | Value: 'Asia Pacific (Tokyo)' 110 | }, 111 | { 112 | Field: 'operatingSystem', 113 | Type: 'TERM_MATCH', 114 | Value: 'Linux' 115 | } 116 | ] 117 | 118 | expect(formatFilters(filters)).toEqual(expected) 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /src/services/cloudwatch.ts: -------------------------------------------------------------------------------- 1 | import { parseFirstPrice, parseRange } from '@/lib/parser' 2 | 3 | export default { 4 | metrics: { 5 | priceRange: { 6 | params: { 7 | ServiceCode: 'AmazonCloudWatch', 8 | Filters: { 9 | location: 'Asia Pacific (Tokyo)', 10 | usagetype : 'APN1-CW:MetricMonitorUsage', 11 | } 12 | }, 13 | parse: priceList => parseRange(priceList[0]) 14 | } 15 | }, 16 | api: { 17 | GetMetricData: { 18 | price: { 19 | params: { 20 | ServiceCode: 'AmazonCloudWatch', 21 | Filters: { 22 | location: 'Asia Pacific (Tokyo)', 23 | usagetype : 'APN1-CW:GMD-Metrics', 24 | } 25 | }, 26 | parse: priceList => parseFirstPrice(priceList[0]) 27 | } 28 | }, 29 | GetMetricWidgetImage: { 30 | price: { 31 | params: { 32 | ServiceCode: 'AmazonCloudWatch', 33 | Filters: { 34 | location: 'Asia Pacific (Tokyo)', 35 | usagetype : 'APN1-CW:GMWI-Metrics', 36 | } 37 | }, 38 | parse: priceList => parseFirstPrice(priceList[0]) 39 | } 40 | }, 41 | other: { 42 | price: { 43 | params: { 44 | ServiceCode: 'AmazonCloudWatch', 45 | Filters: { 46 | location: 'Asia Pacific (Tokyo)', 47 | usagetype : 'APN1-CW:Requests', 48 | } 49 | }, 50 | parse: priceList => parseFirstPrice(priceList[0]) 51 | }, 52 | free: { 53 | manual: 1e6 54 | } 55 | }, 56 | }, 57 | alarm: { 58 | standard: { 59 | price: { 60 | params: { 61 | ServiceCode: 'AmazonCloudWatch', 62 | Filters: { 63 | location: 'Asia Pacific (Tokyo)', 64 | usagetype : 'APN1-CW:AlarmMonitorUsage', 65 | alarmType : 'Standard' 66 | } 67 | }, 68 | parse: priceList => parseFirstPrice(priceList[0]) 69 | }, 70 | free: { 71 | manual: 10 72 | } 73 | }, 74 | highResolution: { 75 | price: { 76 | params: { 77 | ServiceCode: 'AmazonCloudWatch', 78 | Filters: { 79 | location: 'Asia Pacific (Tokyo)', 80 | usagetype : 'APN1-CW:HighResAlarmMonitorUsage', 81 | alarmType : 'High Resolution' 82 | } 83 | }, 84 | parse: priceList => parseFirstPrice(priceList[0]) 85 | } 86 | }, 87 | }, 88 | log: { 89 | collect: { 90 | price: { 91 | params: { 92 | ServiceCode: 'AmazonCloudWatch', 93 | Filters: { 94 | location: 'Asia Pacific (Tokyo)', 95 | usagetype : 'APN1-DataProcessing-Bytes' 96 | } 97 | }, 98 | parse: priceList => parseFirstPrice(priceList[0]) 99 | }, 100 | free: { 101 | manual: 5 102 | } 103 | }, 104 | store: { 105 | price: { 106 | params: { 107 | ServiceCode: 'AmazonCloudWatch', 108 | Filters: { 109 | location: 'Asia Pacific (Tokyo)', 110 | usagetype : 'APN1-TimedStorage-ByteHrs', 111 | } 112 | }, 113 | parse: priceList => parseFirstPrice(priceList[0]) 114 | } 115 | }, 116 | }, 117 | dashboard: { 118 | price: { 119 | params: { 120 | ServiceCode: 'AmazonCloudWatch', 121 | Filters: { 122 | location: 'Any', 123 | usagetype : 'DashboardsUsageHour-Basic' 124 | } 125 | }, 126 | parse: priceList => parseFirstPrice(priceList[0]) 127 | }, 128 | free: { 129 | manual: 3 130 | } 131 | }, 132 | } 133 | --------------------------------------------------------------------------------