From e0f96a91064c768d96979eb956713e1f87994d12 Mon Sep 17 00:00:00 2001 From: Mike Heffner Date: Mon, 22 Jul 2019 14:32:32 -0400 Subject: [PATCH] RDS functions and tests --- .gitignore | 3 +- README.md | 5 +- scripts/gen-funcs.rb | 96 +++++++++++++++++- src/functions/rds.ts | 61 ++++++++++++ src/models/rds_db_engine.ts | 7 ++ src/models/rds_instance_price.ts | 15 +++ src/rds_price.ts | 131 +++++++++++++++++++++++++ src/settings/rds_settings_validator.ts | 33 +++++++ tests/functions/rds_test.ts | 55 +++++++++++ tests/main.ts | 2 + 10 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 src/functions/rds.ts create mode 100644 src/models/rds_db_engine.ts create mode 100644 src/models/rds_instance_price.ts create mode 100644 src/rds_price.ts create mode 100644 src/settings/rds_settings_validator.ts create mode 100644 tests/functions/rds_test.ts diff --git a/.gitignore b/.gitignore index f5e2980..646012f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ tmp/ node_modules/ .clasprc.json help_dialog.html -src/functions/gen/*.ts \ No newline at end of file +src/functions/gen/*.ts +.vscode/ diff --git a/README.md b/README.md index 1bbf39d..6830c1f 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ EBS snapshot cost is measured by the amount of stored Gigabytes using the follow ### Pricing Duration -The AWS pricing pages for EBS costs returns pricing amounts in monthly values, despite the actual billing being billed to the second. To match the EC2 functions hourly usage, the EBS cost functions in *AWS Pricing* return costs in hourly durations. This makes it easy to multiply the combined EC2 and EBS costs by 730 (hours in month) to compute a monthly cost. +The AWS pricing pages for EBS costs returns pricing amounts in monthly values, despite the actual billing being billed to the second. To match the EC2 functions hourly usage, the EBS cost functions in *AWS Pricing* return costs in hourly durations. This makes it easy to multiply the combined EC2 and EBS costs by 730 (hours in month), for example, to compute a monthly cost. # Notes @@ -160,7 +160,8 @@ This currently pulls data from the pricing data files used on the main EC2 prici * Daily, Monthly, Yearly pricing * Data transfer -* RDS +* RDS Aurora Serverless, Storage, IOPS, Aurora Global, Data Xfer +* RDS SQL Server * Elasticache * Upfront down-payments for Partial and All Upfront RI's, along with hourly rates * More services as requested diff --git a/scripts/gen-funcs.rb b/scripts/gen-funcs.rb index 0754b3b..91f92f9 100755 --- a/scripts/gen-funcs.rb +++ b/scripts/gen-funcs.rb @@ -140,6 +140,99 @@ def gen_ebs(func_dir) f.close end + +def gen_rds(func_dir) + outfilename = 'rds_gen.ts' + outfile = File.join(func_dir, outfilename) + + f = create_file(outfile) + + f.write <<~EOF + import { _rds_settings, _rds_full } from "../rds"; + import { RDSDbEngine } from "../../models/rds_db_engine"; + + EOF + + engines = { + "AURORA_MYSQL" => "Aurora_Mysql", + "AURORA_POSTGRESQL" => "Aurora_Postgresql", + "MYSQL" => "Mysql", + "POSTGRESQL" => "Postgresql", + "MARIADB" => "Mariadb" + } + + engines.each do |engine| + func = <<~EOF + /** + * Returns the instance price for a #{engine[1]} RDS DB instance + * + * @param settingsRange Two-column range of default EC2 instance settings + * @param instanceType Type of RDS instance + * @param region Override the region setting (optional) + * @returns price + * @customfunction + */ + export function RDS_#{engine[0].upcase}(settingsRange: Array>, instanceType: string, region?: string) { + return _rds_settings(settingsRange, RDSDbEngine.#{engine[1]}, instanceType, region) + } + + /** + * Returns the on-demand instance price for a #{engine[1]} RDS DB instance + * + * @param instanceType Type of RDS instance + * @param region AWS region of instance + * @returns price + * @customfunction + */ + export function RDS_#{engine[0].upcase}_OD(instanceType: string, region: string) { + return _rds_full(RDSDbEngine.#{engine[1]}, instanceType, region, 'ondemand') + } + + /** + * Returns the reserved instance price for a #{engine[1]} RDS DB instance + * + * @param instanceType Type of RDS instance + * @param region AWS region of instance + * @param purchaseTerm Duration of RI in years (1 or 3) + * @param paymentOption Payment terms (no_upfront, partial_upfront, all_upfront) + * @returns price + * @customfunction + */ + export function RDS_#{engine[0].upcase}_RI(instanceType: string, region: string, purchaseTerm: string | number, paymentOption: string) { + return _rds_full(RDSDbEngine.#{engine[1]}, instanceType, region, 'reserved', purchaseTerm, paymentOption) + } + + EOF + f.write(func) + + + payment_options = { + "no_upfront" => "no", + "partial_upfront" => "partial", + "all_upfront" => "all" + } + + payment_options.each do |payment_option| + func = <<~EOF + /** + * Returns the reserved instance price for a #{engine[1]} RDS DB instance with #{payment_option[0]} payment + * + * @param instanceType Type of RDS instance + * @param region AWS region of instance + * @param purchaseTerm Duration of RI in years (1 or 3) + * @returns price + * @customfunction + */ + export function RDS_#{engine[0].upcase}_RI_#{payment_option[1].upcase}(instanceType: string, region: string, purchaseTerm: string | number) { + return _rds_full(RDSDbEngine.#{engine[1]}, instanceType, region, 'reserved', purchaseTerm, "#{payment_option[0]}") + } + EOF + f.write(func) + end + end + + f.close +end # # MAIN # @@ -150,4 +243,5 @@ def gen_ebs(func_dir) func_dir = File.join(topdir, 'src/functions/gen') gen_ec2_ri(func_dir) -gen_ebs(func_dir) \ No newline at end of file +gen_ebs(func_dir) +gen_rds(func_dir) \ No newline at end of file diff --git a/src/functions/rds.ts b/src/functions/rds.ts new file mode 100644 index 0000000..03d079c --- /dev/null +++ b/src/functions/rds.ts @@ -0,0 +1,61 @@ +import { RDSDbEngine } from "../models/rds_db_engine"; +import { InvocationSettings } from "../settings/invocation_settings"; +import { RDSPrice } from "../rds_price"; +import { PriceDuration } from "../price_converter"; +import { _initContext } from "../context"; +import { RDSSettingsValidator } from "../settings/rds_settings_validator"; + +function _rds(settings: InvocationSettings, dbEngine: RDSDbEngine, instanceType: string): number { + if (dbEngine === undefined || dbEngine === null) { + throw `Must specify DB engine` + } + + if (!instanceType) { + throw `Must specify a DB instance type` + } + + let [ret, msg] = new RDSSettingsValidator(settings).validate() + if (!ret) { + throw msg + } + + instanceType = instanceType.toString().toLowerCase() + + return new RDSPrice(settings, dbEngine, instanceType).get(PriceDuration.Hourly) +} + +export function _rds_full(dbEngine: RDSDbEngine, instanceType: string, region: string, + purchaseType: string, purchaseTerm?: string | number, paymentOption?: string) { + _initContext() + + let settingsMap = { + 'region': region, + 'purchase_type': purchaseType + } + + if (purchaseType === "reserved") { + settingsMap['purchase_term'] = purchaseTerm + settingsMap['payment_option'] = paymentOption + } + + let settings = InvocationSettings.loadFromMap(settingsMap) + + return _rds(settings, dbEngine, instanceType) +} + +export function _rds_settings(settingsRange: Array>, dbEngine: RDSDbEngine, instanceType: string, region?: string) { + _initContext() + + if (!settingsRange) { + throw `Must specify settings range` + } + + let overrides = {} + if (region) { + overrides['region'] = region + } + + let settings = InvocationSettings.loadFromRange(settingsRange, overrides) + + return _rds(settings, dbEngine, instanceType) +} \ No newline at end of file diff --git a/src/models/rds_db_engine.ts b/src/models/rds_db_engine.ts new file mode 100644 index 0000000..c68e714 --- /dev/null +++ b/src/models/rds_db_engine.ts @@ -0,0 +1,7 @@ +export enum RDSDbEngine { + Aurora_Mysql, + Aurora_Postgresql, + Mysql, + Postgresql, + Mariadb +} \ No newline at end of file diff --git a/src/models/rds_instance_price.ts b/src/models/rds_instance_price.ts new file mode 100644 index 0000000..d599524 --- /dev/null +++ b/src/models/rds_instance_price.ts @@ -0,0 +1,15 @@ +import { PriceDuration } from "../price_converter"; + +export class RDSInstancePrice { + constructor(private readonly price, private readonly isReserved: boolean) { + + } + + totalPrice(duration: PriceDuration): number { + if (this.isReserved) { + return parseFloat(this.price.calculatedPrice.effectiveHourlyRate.USD) + } else { + return parseFloat(this.price.price.USD) + } + } +} \ No newline at end of file diff --git a/src/rds_price.ts b/src/rds_price.ts new file mode 100644 index 0000000..163fe69 --- /dev/null +++ b/src/rds_price.ts @@ -0,0 +1,131 @@ +import { InvocationSettings } from "./settings/invocation_settings"; +import { RDSDbEngine } from "./models/rds_db_engine"; +import { PriceDuration } from "./price_converter"; +import { ctxt } from "./context"; +import { RDSInstancePrice } from "./models/rds_instance_price"; + +export class RDSPrice { + constructor(private readonly settings: InvocationSettings, private readonly dbEngine: RDSDbEngine, + private readonly instanceType: string) { + + } + + get(duration: PriceDuration): number { + let priceData = this.loadPriceData() + + return priceData.totalPrice(duration) + } + + private loadPriceData() { + if (this.isReserved() && this.settings.get("purchase_term") === "3" && + this.settings.get("payment_option") === "no_upfront") { + throw `The No-Upfront payment option is not supported for 3 year RDS RIs` + } + + let pricePath = Utilities.formatString("/pricing/1.0/rds/%s/%s/%sindex.json", + this.dbEngineUrlParam(), this.purchaseTypeUrlParam(), this.azUrlParam()) + + let body = ctxt().awsDataLoader.loadPath(pricePath) + + let resp = JSON.parse(body) + + let prices = this.filterPrices(resp.prices) + + if (prices.length == 0) { + throw `Unable to find RDS instance ${this.instanceType} for DB engine ${this.dbEngineStr()}` + } + + if (prices.length > 1) { + throw `Too many matches found for ${this.instanceType} for DB engine ${this.dbEngineStr()}` + } + + return new RDSInstancePrice(prices[0], this.isReserved()) + } + + private filterPrices(prices) { + return prices.filter(price => { + let ret = price.attributes['aws:region'] == this.settings.get('region') && + price.attributes['aws:rds:term'] === this.purchaseTypeAttr() && + price.attributes['aws:rds:deploymentOption'] === 'Single-AZ' && + price.attributes['aws:productFamily'] === 'Database Instance' && + price.attributes['aws:rds:instanceType'] === this.instanceType + if (!ret || !this.isReserved()) { + return ret + } + + return price.attributes['aws:offerTermLeaseLength'] === this.purchaseTermAttr() && + // There are no convertible RDS RIs + price.attributes['aws:offerTermOfferingClass'] === 'standard' && + price.attributes['aws:offerTermPurchaseOption'] === this.paymentOptionAttr() + }) + } + + private isReserved(): boolean { + return this.settings.get('purchase_type') === 'reserved' + } + + private dbEngineUrlParam(): string { + switch (this.dbEngine) { + case RDSDbEngine.Aurora_Mysql: { + return "aurora/mysql" + } + case RDSDbEngine.Aurora_Postgresql: { + return "aurora/postgresql" + } + case RDSDbEngine.Mysql: { + return "mysql" + } + case RDSDbEngine.Mariadb: { + return "mariadb" + } + case RDSDbEngine.Postgresql: { + return "postgresql" + } + } + } + + private azUrlParam(): string { + if (this.isAurora()) { + return "" + } else { + return "single-az/" + } + } + + private isAurora(): boolean { + return this.dbEngine === RDSDbEngine.Aurora_Mysql || this.dbEngine === RDSDbEngine.Aurora_Postgresql + } + + private purchaseTypeUrlParam(): string { + return this.isReserved() ? "reserved-instance" : "ondemand" + } + + private purchaseTypeAttr(): string { + return this.isReserved() ? "reserved" : "on-demand" + } + + private purchaseTermAttr(): string { + return Utilities.formatString("%syr", this.settings.get('purchase_term')) + } + + private paymentOptionAttr(): string { + switch(this.settings.get('payment_option')) { + case 'no_upfront': { + return 'No Upfront' + } + case 'partial_upfront': { + return 'Partial Upfront' + } + case 'all_upfront': { + return 'All Upfront' + } + default: { + throw `Unknown payment option ${this.settings.get('payment_option')}` + } + } + } + + private dbEngineStr(): string { + return RDSDbEngine[this.dbEngine] + } +} \ No newline at end of file diff --git a/src/settings/rds_settings_validator.ts b/src/settings/rds_settings_validator.ts new file mode 100644 index 0000000..668ba39 --- /dev/null +++ b/src/settings/rds_settings_validator.ts @@ -0,0 +1,33 @@ +import { SettingsValidator } from "./_settings_validator"; +import { InvocationSettings } from "./invocation_settings"; +import { SettingKeys } from "./setting_keys"; + +export class RDSSettingsValidator extends SettingsValidator { + constructor(private readonly settings: InvocationSettings) { + super() + } + + validate(): [boolean, string] { + let reqd = [SettingKeys.Region, SettingKeys.PurchaseType] + + let [ret, msg] = this.verifyOptions(reqd) + if (!ret) { + return [ret, msg] + } + + let riOpts = [SettingKeys.PurchaseTerm, SettingKeys.PaymentOption] + if (this.get(SettingKeys.PurchaseType) === "reserved") { + [ret, msg] = this.verifyOptions(riOpts) + if (!ret) { + return [ret, msg] + } + } + + return [true, null] + } + + protected get(key: string): string { + return this.settings.get(key) + } + +} \ No newline at end of file diff --git a/tests/functions/rds_test.ts b/tests/functions/rds_test.ts new file mode 100644 index 0000000..1f36223 --- /dev/null +++ b/tests/functions/rds_test.ts @@ -0,0 +1,55 @@ +import { TestSuite } from "../_framework/test_suite"; +import { TestRun } from "../_framework/test_run"; +import { RDS_AURORA_MYSQL_OD, RDS_AURORA_MYSQL_RI, RDS_AURORA_MYSQL_RI_NO, RDS_AURORA_MYSQL_RI_PARTIAL, RDS_AURORA_MYSQL_RI_ALL, RDS_AURORA_POSTGRESQL_OD, RDS_MARIADB_OD, RDS_POSTGRESQL_OD, RDS_MYSQL_OD, RDS_AURORA_MYSQL } from "../../src/functions/gen/rds_gen"; + +export class RDSFunctionTestSuite extends TestSuite { + protected name(): string { + return this.constructor.name + } + + protected run(t: TestRun): void { + t.describe("RDS func tests", () => { + t.areEqual(0.58, RDS_AURORA_MYSQL_OD("db.r4.xlarge", "us-east-1")) + t.areClose(0.379999, RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 1, "no_upfront"), 0.000001) + t.areClose(0.322694, RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 1, "partial_upfront"), 0.000001) + t.areClose(0.316210, RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 1, "all_upfront"), 0.000001) + + t.willThrow(function() { + RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 3, "no_upfront") + }, "not supported") + t.areClose(0.215129, RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 3, "partial_upfront"), 0.000001) + t.areClose(0.202207, RDS_AURORA_MYSQL_RI("db.r4.xlarge", "us-east-1", 3, "all_upfront"), 0.000001) + + t.areClose(0.379999, RDS_AURORA_MYSQL_RI_NO("db.r4.xlarge", "us-east-1", 1), 0.000001) + t.areClose(0.322694, RDS_AURORA_MYSQL_RI_PARTIAL("db.r4.xlarge", "us-east-1", 1), 0.000001) + t.areClose(0.316210, RDS_AURORA_MYSQL_RI_ALL("db.r4.xlarge", "us-east-1", 1), 0.000001) + + t.areEqual(1.16, RDS_AURORA_POSTGRESQL_OD("db.r4.2xlarge", "us-east-1")) + t.areEqual(1.04, RDS_MARIADB_OD("db.r4.2xlarge", "ca-central-1")) + t.areEqual(1.0809, RDS_POSTGRESQL_OD("DB.R4.2XLARGE", "CA-CENTRAL-1")) + t.areEqual(1.04, RDS_MYSQL_OD("db.r4.2xlarge", "ca-central-1")) + }) + + t.describe("RDS settings tests", () => { + let s = [ + ['region', 'us-east-2'], + ['purchase_type', 'ondemand'] + ] + + t.areEqual(0.58, RDS_AURORA_MYSQL(s, "db.r4.xlarge")) + t.areEqual(0.64, RDS_AURORA_MYSQL(s, "db.r4.xlarge", "ca-central-1")) + + s = [ + ['region', 'us-east-1'], + ['purchase_type', 'reserved'], + ['purchase_term', '1'], + ['payment_option', 'partial_upfront'] + ] + + t.areClose(0.322694, RDS_AURORA_MYSQL(s, "db.r4.xlarge"), 0.000001) + s[3][1] = 'all_upfront' + t.areClose(0.316210, RDS_AURORA_MYSQL(s, "db.r4.xlarge"), 0.000001) + }) + } + +} \ No newline at end of file diff --git a/tests/main.ts b/tests/main.ts index 9c50b1c..177caa1 100644 --- a/tests/main.ts +++ b/tests/main.ts @@ -6,6 +6,7 @@ import { SettingsTestSuite } from "./settings_tests"; import { _setContext, _initContext, ctxt } from "../src/context"; import { CacheLoaderTestSuite } from "./cache_loader_test"; import { EBSFunctionTestSuite } from "./functions/ebs_test"; +import { RDSFunctionTestSuite } from "./functions/rds_test"; function runAllTests(): string { return TestRunner.runAllTests(function(t) { @@ -30,6 +31,7 @@ function runAllTests(): string { new EC2FunctionTestSuite().test(t) new EC2InstanceTestSuite().test(t) new EBSFunctionTestSuite().test(t) + new RDSFunctionTestSuite().test(t) new PriceConverterTestSuite().test(t) new SettingsTestSuite().test(t) new CacheLoaderTestSuite().test(t)