Added a dynamic Cost Summary section to SQL MIAA Deployment Wizard (#17420)

* Added valueprovider for pricing. Pushing this for troubleshooting help.

* Committing changes for troubleshooting help. Moved InputValueType to typings file.

* Add readonly inputs to list

* Fixed ordering of package.json merge items

* Estimated cost moved to input page, ValueProvider only takes in a triggerfields[] and not a single string, fixed pricing logic.

* Removed pricingModel.ts

* Reverted some comments and code changes that were used in debugging.

* Changed some values from localizedConstants to single-quote constants'

* Changed some values from localizedConstants to single-quote constants'

* Added copyright header to pricingUtils.ts

* Removed try catch in extension.ts valueproviders, made some values in PricingUtils.ts top-level instead of exporting.

* Minor changes, added some comments and localized USD.

* Changes pricingutils classes to be constants, and added disposable to Hookupvalueprovider

Co-authored-by: Candice Ye <canye@microsoft.com>
Co-authored-by: chgagnon <chgagnon@microsoft.com>
This commit is contained in:
Candice Ye
2021-10-21 16:51:31 -07:00
committed by GitHub
parent 5fe569e864
commit dc0651aef7
10 changed files with 321 additions and 21 deletions

View File

@@ -1079,6 +1079,7 @@
"variableName": "AZDATA_NB_VAR_SQL_CORES_REQUEST",
"type": "number",
"min": 1,
"defaultValue": 2,
"required": false,
"validations": [
{
@@ -1095,6 +1096,7 @@
"type": "number",
"min": 1,
"required": false,
"defaultValue": 4,
"validations": [
{
"type": ">=",
@@ -1109,6 +1111,7 @@
"variableName": "AZDATA_NB_VAR_SQL_MEMORY_REQUEST",
"type": "number",
"min": 2,
"defaultValue": 4,
"required": false,
"validations": [
{
@@ -1124,6 +1127,7 @@
"variableName": "AZDATA_NB_VAR_SQL_MEMORY_LIMIT",
"type": "number",
"min": 2,
"defaultValue": 8,
"required": false,
"validations": [
{
@@ -1136,9 +1140,9 @@
{
"type": "options",
"label": "%arc.sql.service.tier.label%",
"variableName": "AZDATA_NB_VAR_SQL_SERVICE_TIER",
"description": "%arc.sql.service.tier.description%",
"required": true,
"variableName": "AZDATA_NB_VAR_SQL_SERVICE_TIER",
"options": {
"values": [
"%arc.sql.service.tier.business.critical%",
@@ -1151,10 +1155,16 @@
{
"type": "checkbox",
"label": "%arc.sql.dev.use.label%",
"description": "%arc.sql.dev.use.description%",
"defaultValue": "false",
"variableName": "AZDATA_NB_VAR_SQL_DEV_USE",
"required": true
"description": "%arc.sql.dev.use.description%",
"defaultValue": false
},
{
"type": "checkbox",
"label": "%arc.sql.license.type.label%",
"variableName": "AZDATA_NB_VAR_SQL_LICENSE_TYPE",
"description": "%arc.sql.license.type.description%",
"defaultValue": false
}
]
},
@@ -1184,8 +1194,81 @@
"required": false
}
]
},
{
"title": "%arc.sql.cost.summary%",
"fields": [
{
"label": "%arc.sql.cost.summary.additional.charge%",
"type": "readonly_text",
"enabled": true,
"labelWidth": "750px",
"links": [
{
"text": "%arc.sql.cost.summary.pricing.details%",
"url": "https://aka.ms/ArcSQLBilling"
}
]
},
{
"label": "%arc.sql.cost.summary.cost.vcore%",
"type": "readonly_text",
"isEvaluated": true,
"defaultValue": "0.00 USD",
"valueProvider": {
"providerId": "params-to-cost-per-vcore",
"triggerFields": [
"AZDATA_NB_VAR_SQL_DEV_USE",
"AZDATA_NB_VAR_SQL_SERVICE_TIER"
]
}
},
{
"label": "%arc.sql.cost.summary.vcore.limit%",
"type": "readonly_text",
"isEvaluated": true,
"defaultValue": "x 4",
"valueProvider": {
"providerId": "params-to-vcore-limit",
"triggerFields": [
"AZDATA_NB_VAR_SQL_CORES_LIMIT"
]
}
},
{
"label": "%arc.sql.cost.summary.azure.hybrid.benefit.discount%",
"type": "readonly_text",
"isEvaluated": true,
"defaultValue": "- 0",
"valueProvider": {
"providerId": "params-to-hybrid-benefit-discount",
"triggerFields": [
"AZDATA_NB_VAR_SQL_CORES_LIMIT",
"AZDATA_NB_VAR_SQL_DEV_USE",
"AZDATA_NB_VAR_SQL_SERVICE_TIER",
"AZDATA_NB_VAR_SQL_LICENSE_TYPE"
]
}
},
{
"label": "%arc.sql.cost.summary.estimated.cost.per.month%",
"type": "readonly_text",
"variableName": "AZDATA_NB_VAR_SQL_ESTIMATED_COST",
"defaultValue": "0.00 USD",
"valueProvider": {
"providerId": "params-to-estimated-cost",
"triggerFields": [
"AZDATA_NB_VAR_SQL_REPLICAS",
"AZDATA_NB_VAR_SQL_CORES_LIMIT",
"AZDATA_NB_VAR_SQL_DEV_USE",
"AZDATA_NB_VAR_SQL_SERVICE_TIER",
"AZDATA_NB_VAR_SQL_LICENSE_TYPE"
]
}
}
]
}
]
]
}
]
},

View File

@@ -10,6 +10,7 @@
"command.removeController.title": "Remove Controller",
"command.refresh.title": "Refresh",
"command.editConnection.title": "Edit Connection",
"command.estimateCostSqlMiaa.title": "Estimate Cost of SQL Managed Instance - Azure Arc",
"arc.openDashboard": "Manage",
"resource.type.azure.arc.display.name": "Azure Arc data controller (preview)",
@@ -96,6 +97,30 @@
"arc.sql.three.replicas": "3 replicas",
"arc.storage-class.data.label": "Storage Class (Data)",
"arc.sql.storage-class.data.description": "The storage class to be used for data (.mdf). If no value is specified, the default storage class will be used.",
"arc.sql.cost.summary.sql.miaa.cost.summary": "SQL Managed Instance - Azure Arc Cost Summary",
"arc.sql.cost.summary.sql.miaa": "SQL managed instance - Azure Arc",
"arc.sql.cost.summary.estimated.cost.per.month": "Estimated cost per month",
"arc.sql.summary.arc.by.microsoft" : "by Microsoft",
"arc.sql.cost.summary": "Cost Summary",
"arc.sql.cost.summary.service.tier": "Service Tier",
"arc.sql.cost.summary.general.purpose": "General Purpose",
"arc.sql.cost.summary.business.critical": "Business Critical",
"arc.sql.cost.summary.cost.vcore": "Cost per vCore (in USD)",
"arc.sql.cost.summary.vcore.limit": "CPU vCores Limit",
"arc.sql.cost.summary.azure.hybrid.benefit.discount": "Azure Hybrid Benefit discount (in USD)",
"arc.sql.cost.summary.sql.connection.info": "SQL Connection Information",
"arc.sql.cost.summary.sql.instance.settings": "SQL Instance Settings",
"arc.sql.cost.summary.service.tier.learn.more.description": "Select from the latest vCore service tiers available for SQL Managed Instance - Azure Arc including General Purpose and Business Critical. {0}",
"arc.sql.cost.summary.service.tier.learn.more.text": "Learn more",
"arc.sql.cost.summary.basics": "Basics",
"arc.sql.cost.summary.subscription": "Subscription",
"arc.sql.cost.summary.resource.group": "Resource group",
"arc.sql.cost.summary.instance.name": "Instance name",
"arc.sql.cost.summary.custom.location": "Custom location",
"arc.sql.cost.summary.admin.account": "Administrator account",
"arc.sql.cost.summary.managed.instance.admin.login": "Managed Instance admin login",
"arc.sql.cost.summary.additional.charge": "Additional charge per usage. See {0} for more detail.",
"arc.sql.cost.summary.pricing.details": "pricing details",
"arc.postgres.storage-class.data.description": "The storage class to be used for data persistent volumes",
"arc.storage-class.datalogs.label": "Storage Class (Database logs)",
"arc.sql.storage-class.datalogs.description": "The storage class to be used for database logs (.ldf). If no value is specified, the default storage class will be used.",
@@ -123,6 +148,8 @@
"arc.sql.service.tier.label": "Service Tier",
"arc.sql.service.tier.description": "Select from the latest vCore service tiers available for SQL Managed Instance - Azure Arc including General Purpose and Business Critical. {0}",
"arc.sql.dev.use.label": "For development use only",
"arc.sql.license.type.label": "I already have a SQL Server License",
"arc.sql.license.type.description": "Apply the Azure Hybrid Benefit if you already own a SQL Server License",
"arc.sql.pitr.description": "Point in time restore",
"arc.sql.retention.days.label": "PITR retention (days)",
"arc.sql.retention.days.description": "Specify how long you want to keep your point-in-time backups.",

View File

@@ -0,0 +1,117 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { InputValueType } from 'resource-deployment';
import * as loc from '../localizedConstants';
export const SqlManagedInstanceGeneralPurpose = {
tierName: loc.generalPurposeLabel,
basePricePerCore: 80,
licenseIncludedPricePerCore: 153,
maxMemorySize: 128,
maxVCores: 24,
replicaOptions: [
{
text: loc.replicaOne,
value: 1,
}
],
defaultReplicaValue: 1
};
const SqlManagedInstanceBusinessCritical = {
tierName: loc.businessCriticalLabel,
// Set to real values when BC is ready
basePricePerCore: 0,
licenseIncludedPricePerCore: 0,
replicaOptions: [
{
text: loc.replicaTwo,
value: 2,
},
{
text: loc.replicaThree,
value: 3,
}
],
defaultReplicaValue: 3
};
export const SqlManagedInstancePricingLink: string = 'https://aka.ms/ArcSQLBilling';
export const serviceTierVarName = 'AZDATA_NB_VAR_SQL_SERVICE_TIER';
export const devUseVarName = 'AZDATA_NB_VAR_SQL_DEV_USE';
export const vcoresLimitVarName = 'AZDATA_NB_VAR_SQL_CORES_LIMIT';
export const licenseTypeVarName = 'AZDATA_NB_VAR_SQL_LICENSE_TYPE';
// Estimated base price for one vCore.
export function estimatedBasePriceForOneVCore(mapping: { [key: string]: InputValueType }): number {
let price = 0;
if (mapping[devUseVarName] === 'true') {
price = 0;
} else if (mapping[devUseVarName] === 'false') {
if (mapping[serviceTierVarName] === SqlManagedInstanceGeneralPurpose.tierName) {
price = SqlManagedInstanceGeneralPurpose.basePricePerCore;
} else if (mapping[serviceTierVarName] === SqlManagedInstanceBusinessCritical.tierName) {
price = SqlManagedInstanceBusinessCritical.basePricePerCore;
}
}
return price;
}
// Estimated SQL server license price for one vCore.
export function estimatedSqlServerLicensePriceForOneVCore(mapping: { [key: string]: InputValueType }): number {
let price = 0;
if (mapping[devUseVarName] === 'true') {
price = 0;
} else if (mapping[devUseVarName] === 'false') {
if (mapping[serviceTierVarName] === SqlManagedInstanceGeneralPurpose.tierName) {
price = SqlManagedInstanceGeneralPurpose.licenseIncludedPricePerCore - SqlManagedInstanceGeneralPurpose.basePricePerCore;
} else if (mapping[serviceTierVarName] === SqlManagedInstanceBusinessCritical.tierName) {
price = SqlManagedInstanceBusinessCritical.licenseIncludedPricePerCore - SqlManagedInstanceBusinessCritical.basePricePerCore;
}
}
return price;
}
// Full price for one vCore. This is shown on the cost summary card.
export function fullPriceForOneVCore(mapping: { [key: string]: InputValueType }): number {
return estimatedBasePriceForOneVCore(mapping) + estimatedSqlServerLicensePriceForOneVCore(mapping);
}
// Gets number of vCores limit specified
export function numCores(mapping: { [key: string]: InputValueType }): number {
return mapping[vcoresLimitVarName] ? <number>mapping[vcoresLimitVarName] : 0;
}
// Full price for all selected vCores.
export function vCoreFullPriceForAllCores(mapping: { [key: string]: InputValueType }): number {
return fullPriceForOneVCore(mapping) * numCores(mapping);
}
// SQL Server License price for all vCores. This is shown on the cost summary card if customer has SQL server license.
export function vCoreSqlServerLicensePriceForAllCores(mapping: { [key: string]: InputValueType }): number {
return estimatedSqlServerLicensePriceForOneVCore(mapping) * numCores(mapping);
}
// If the customer doesn't already have SQL Server License, AHB discount is set to zero because the price will be included
// in the total cost. If they already have it (they checked the box), then a discount will be applied.
export function azureHybridBenefitDiscount(mapping: { [key: string]: InputValueType }): number {
if (mapping[licenseTypeVarName] === 'true') {
return vCoreSqlServerLicensePriceForAllCores(mapping);
} else {
return 0;
}
}
// Total price that will be charged to a customer. Is shown on the cost summary card.
export function total(mapping: { [key: string]: InputValueType }): number {
return vCoreFullPriceForAllCores(mapping) - azureHybridBenefitDiscount(mapping);
}

View File

@@ -14,6 +14,7 @@ import { ConnectToControllerDialog } from './ui/dialogs/connectControllerDialog'
import { AzureArcTreeDataProvider } from './ui/tree/azureArcTreeDataProvider';
import { ControllerTreeNode } from './ui/tree/controllerTreeNode';
import { TreeNode } from './ui/tree/treeNode';
import * as pricing from './common/pricingUtils';
export async function activate(context: vscode.ExtensionContext): Promise<arc.IExtension> {
IconPathHelper.setExtensionContext(context);
@@ -61,6 +62,38 @@ export async function activate(context: vscode.ExtensionContext): Promise<arc.IE
const rdApi = <rd.IExtension>vscode.extensions.getExtension(rd.extension.name)?.exports;
context.subscriptions.push(rdApi.registerOptionsSourceProvider(new ArcControllersOptionsSourceProvider(treeDataProvider)));
// Register valueprovider for getting the calculated cost per VCore.
context.subscriptions.push(rdApi.registerValueProvider({
id: 'params-to-cost-per-vcore',
getValue: async (mapping: { [key: string]: rd.InputValueType }) => {
return pricing.fullPriceForOneVCore(mapping);
}
}));
// Register valueprovider for getting the number of CPU VCores Limit input by the user.
context.subscriptions.push(rdApi.registerValueProvider({
id: 'params-to-vcore-limit',
getValue: async (mapping: { [key: string]: rd.InputValueType }) => {
return 'x ' + pricing.numCores(mapping).toString();
}
}));
// Register valueprovider for getting the amount of hybrid benefit discount to be applied.
context.subscriptions.push(rdApi.registerValueProvider({
id: 'params-to-hybrid-benefit-discount',
getValue: async (mapping: { [key: string]: rd.InputValueType }) => {
return '- ' + pricing.azureHybridBenefitDiscount(mapping).toString();
}
}));
// Register valueprovider for getting the total estimated cost.
context.subscriptions.push(rdApi.registerValueProvider({
id: 'params-to-estimated-cost',
getValue: async (mapping: { [key: string]: rd.InputValueType }) => {
return pricing.total(mapping).toString() + ' ' + loc.USD;
}
}));
return arcApi(treeDataProvider);
}

View File

@@ -274,6 +274,14 @@ export function connectionString(type: string): string { return localize({ key:
export function copyConnectionStringToClipboard(type: string): string { return localize({ key: 'arc.copyConnectionStringToClipboard', comment: ['{0} is the name of the type of connection string (e.g. Java)'] }, "Copy {0} Connection String to clipboard", type); }
export function copyValueToClipboard(valueName: string): string { return localize({ key: 'arc.copyValueToClipboard', comment: ['{0} is the name of the type of value being copied (e.g. Coordinator endpoint)'] }, "Copy {0} to clipboard", valueName); }
// Pricing Constants
export const replicaOne = localize('arc.replicaOne', "1 replica");
export const replicaTwo = localize('arc.replicaTwo', "2 replicas");
export const replicaThree = localize('arc.replicaThree', "3 replicas");
export const generalPurposeLabel = localize('arc.generalPurposeLabel', "General Purpose (Up to 24 vCores and 128 Gi of RAM, standard high availability)");
export const businessCriticalLabel = localize('arc.businessCriticalLabel', "[PREVIEW] Business Critical (Unlimited vCores and RAM, advanced high availability)");
export const USD = localize('arc.USD', "USD");
// Errors
export const pgConnectionRequired = localize('arc.pgConnectionRequired', "A connection is required to show and set database engine settings.");
export const miaaConnectionRequired = localize('arc.miaaConnectionRequired', "A connection is required to list the databases on this instance.");

View File

@@ -307,7 +307,7 @@ export interface DynamicOptionsAlternates {
export interface ValueProviderInfo {
providerId: string,
triggerField: string
triggerFields: string[]
}
export interface FieldInfoBase {

View File

@@ -5,10 +5,10 @@
import * as azdata from 'azdata';
import 'mocha';
import { InputValueType } from 'resource-deployment';
import * as should from 'should';
import * as sinon from 'sinon';
import * as vscode from 'vscode';
import { InputValueType } from '../../../ui/modelViewUtils';
import { createValidation, GreaterThanOrEqualsValidation, IntegerValidation, LessThanOrEqualsValidation, RegexValidation, validateInputBoxComponent, Validation, ValidationType } from '../../../ui/validation/validations';
const inputBox = <azdata.InputBoxComponent>{

View File

@@ -24,9 +24,15 @@ declare module 'resource-deployment' {
getIsPassword?: (variableName: string) => boolean | Promise<boolean>;
}
export type InputValueType = string | number | boolean | undefined;
/**
* Gets a calculated value based on the given input values.
* @param triggerValues A map of the trigger field names and their current values specified in the valueProvider field info
*/
export interface IValueProvider {
readonly id: string,
getValue(triggerValue: string): Promise<string>;
getValue(triggerValues: string | {[key: string]: InputValueType}): Promise<InputValueType>;
}
/**

View File

@@ -7,7 +7,7 @@ import { azureResource } from 'azureResource';
import * as fs from 'fs';
import { EOL } from 'os';
import * as path from 'path';
import { IOptionsSourceProvider } from 'resource-deployment';
import { InputValueType, IOptionsSourceProvider } from 'resource-deployment';
import * as vscode from 'vscode';
import * as nls from 'vscode-nls';
import { getDateTimeString, getErrorMessage, isUserCancelledError, throwUnless } from '../common/utils';
@@ -33,7 +33,6 @@ const localize = nls.loadMessageBundle();
*/
export type Validator = () => { valid: boolean, message: string };
export type InputValueType = string | number | undefined;
export type InputComponent = azdata.TextComponent | azdata.InputBoxComponent | azdata.DropDownComponent | azdata.CheckBoxComponent | RadioGroupLoadingComponentBuilder;
export type InputComponentInfo<T extends InputComponent> = {
component: T;
@@ -471,20 +470,35 @@ async function hookUpValueProviders(context: WizardPageContext): Promise<void> {
if (field.valueProvider) {
const fieldKey = field.variableName || field.label;
const fieldComponent = context.inputComponents[fieldKey];
const targetComponent = context.inputComponents[field.valueProvider.triggerField];
if (!targetComponent) {
console.error(`Could not find target component ${field.valueProvider.triggerField} when hooking up value providers for ${field.label}`);
return;
}
const provider = await valueProviderService.getValueProvider(field.valueProvider.providerId);
let targetComponentLabelToComponent: { [label: string]: InputComponentInfo<InputComponent>; } = {};
field.valueProvider.triggerFields.forEach((triggerField) => {
const targetComponent = context.inputComponents[triggerField];
if (!targetComponent) {
console.error(`Could not find target component ${triggerField} when hooking up value providers for ${field.label}`);
return;
}
targetComponentLabelToComponent[triggerField] = targetComponent;
});
// If one triggerfield changes value, update the new field value.
const updateFields = async () => {
const targetComponentValue = await targetComponent.getValue();
const newFieldValue = await provider.getValue(targetComponentValue?.toString() ?? '');
let targetComponentLabelToValue: { [label: string]: InputValueType; } = {};
for (let label in targetComponentLabelToComponent) {
targetComponentLabelToValue[label] = await targetComponentLabelToComponent[label].getValue();
}
let newFieldValue = await provider.getValue(targetComponentLabelToValue);
fieldComponent.setValue(newFieldValue);
};
targetComponent.onValueChanged(() => {
updateFields();
});
// Set the onValueChanged behavior for each component
for (let label in targetComponentLabelToComponent) {
context.onNewDisposableCreated(targetComponentLabelToComponent[label].onValueChanged(() => {
updateFields();
}));
}
await updateFields();
}
}));
@@ -863,6 +877,18 @@ function processReadonlyTextField(context: FieldContext, allowEvaluation: boolea
const text = context.fieldInfo.defaultValue !== undefined
? createLabel(context.view, { text: context.fieldInfo.defaultValue, description: '', required: false, width: context.fieldInfo.inputWidth })
: undefined;
if (text) {
// If we created the text component then add it to our list of inputs so other fields can utilize it
const onChangedEmitter = new vscode.EventEmitter<void>(); // Stub event since we don't currently support updating this when the dependent fields change
context.onNewDisposableCreated(onChangedEmitter);
context.onNewInputComponentCreated(context.fieldInfo.variableName || context.fieldInfo.label, {
component: text,
getValue: async (): Promise<InputValueType> => typeof text.value === 'string' ? text.value : text.value?.join(EOL),
setValue: (value: InputValueType) => text.value = value?.toString(),
onValueChanged: onChangedEmitter.event,
});
}
addLabelInputPairToContainer(context.view, context.components, label, text, context.fieldInfo);
return { label: label, text: text };
}

View File

@@ -4,9 +4,9 @@
*--------------------------------------------------------------------------------------------*/
import * as azdata from 'azdata';
import { InputValueType } from 'resource-deployment';
import * as vscode from 'vscode';
import { isUndefinedOrEmpty, throwUnless } from '../../common/utils';
import { InputValueType } from '../modelViewUtils';
export interface ValidationResult {
valid: boolean;