perfkitbenchmarker/providers/azure/azure_relational_db.py (395 lines of code) (raw):
# Copyright 2017 PerfKitBenchmarker Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Relational database provisioning and teardown for Azure RDS."""
import datetime
import json
import logging
import time
from absl import flags
from perfkitbenchmarker import errors
from perfkitbenchmarker import mysql_iaas_relational_db
from perfkitbenchmarker import postgres_iaas_relational_db
from perfkitbenchmarker import provider_info
from perfkitbenchmarker import relational_db
from perfkitbenchmarker import sql_engine_utils
from perfkitbenchmarker import sqlserver_iaas_relational_db
from perfkitbenchmarker import vm_util
from perfkitbenchmarker.providers import azure
from perfkitbenchmarker.providers.azure import azure_network
from perfkitbenchmarker.providers.azure import util
DEFAULT_DATABASE_NAME = 'database'
FLAGS = flags.FLAGS
DEFAULT_MYSQL_VERSION = '5.7'
DEFAULT_POSTGRES_VERSION = '9.6'
DEFALUT_SQLSERVER_VERSION = 'DEFAULT'
# Disk size configurations details at
# https://docs.microsoft.com/en-us/cli/azure/mysql/server?view=azure-cli-latest#az_mysql_server_create
AZURE_MIN_DB_DISK_SIZE_MB = 5120 # Minimum db disk size supported by Azure
AZURE_MAX_DB_DISK_SIZE_MB = 16777216 # Maximum db disk size supported by Azure
IS_READY_TIMEOUT = 60 * 60 * 1 # 1 hour (might take some time to prepare)
# Longest time recorded is 20 minutes when
# creating STANDARD_D64_V3 - 12/02/2020
# The Azure command timeout with the following error message:
#
# Deployment failed. Correlation ID: fcdc3c76-33cc-4eb1-986c-fbc30ce7d820.
# The operation timed out and automatically rolled back.
# Please retry the operation.
CREATE_AZURE_DB_TIMEOUT = 60 * 30
class AzureSQLServerIAASRelationalDb(
sqlserver_iaas_relational_db.SQLServerIAASRelationalDb
):
"""A AWS IAAS database resource."""
CLOUD = provider_info.AZURE
TEMPDB_DISK_LETTER = 'D'
def CreateIpReservation(self) -> str:
cluster_ip_address = '.'.join(
self.server_vm.internal_ip.split('.')[:-1]+['128'])
return cluster_ip_address
def ReleaseIpReservation(self) -> bool:
return True
class AzurePostgresIAASRelationalDb(
postgres_iaas_relational_db.PostgresIAASRelationalDb
):
"""A AWS IAAS database resource."""
CLOUD = provider_info.AZURE
class AzureMysqlIAASRelationalDb(
mysql_iaas_relational_db.MysqlIAASRelationalDb
):
"""A AWS IAAS database resource."""
CLOUD = provider_info.AZURE
class AzureRelationalDb(relational_db.BaseRelationalDb):
"""An object representing an Azure RDS relational database.
Currently Postgres is supported. This class requires that a
client vm be available as an attribute on the instance before Create() is
called, which is the current behavior of PKB. This is necessary to setup the
networking correctly. The following steps are performed to provision the
database:
1. create the RDS instance in the requested region.
Instructions from:
https://docs.microsoft.com/en-us/azure/postgresql/quickstart-create-server-database-azure-cli
On teardown, all resources are deleted.
Note that the client VM's region and the region requested for the database
must be the same.
"""
CLOUD = provider_info.AZURE
ENGINE = [
sql_engine_utils.POSTGRES,
sql_engine_utils.MYSQL,
sql_engine_utils.SQLSERVER,
]
SERVER_TYPE = 'server'
REQUIRED_ATTRS = ['CLOUD', 'IS_MANAGED', 'ENGINE']
database_name: str
def __init__(self, relational_db_spec):
super().__init__(relational_db_spec)
if util.IsZone(self.spec.db_spec.zone):
raise errors.Config.InvalidValue(
'Availability zones are currently not supported by Azure DBs'
)
self.region = util.GetRegionFromZone(self.spec.db_spec.zone)
self.resource_group = azure_network.GetResourceGroup(self.region)
def GetResourceMetadata(self):
"""Returns the metadata associated with the resource.
All keys will be prefaced with relational_db before
being published (done in publisher.py).
Returns:
metadata: dict of Azure DB metadata.
"""
metadata = super().GetResourceMetadata()
metadata.update({
'zone': self.spec.db_spec.zone,
})
if hasattr(self.spec.db_disk_spec, 'iops'):
metadata.update({
'disk_iops': self.spec.db_disk_spec.iops,
})
return metadata
@staticmethod
def GetDefaultEngineVersion(engine):
"""Returns the default version of a given database engine.
Args:
engine (string): type of database (my_sql or postgres).
Returns:
(string): Default engine version.
Raises:
RelationalDbEngineNotFoundError: if an unknown engine is
requested.
"""
if engine == sql_engine_utils.POSTGRES:
return DEFAULT_POSTGRES_VERSION
elif engine == sql_engine_utils.MYSQL:
return DEFAULT_MYSQL_VERSION
elif engine == sql_engine_utils.SQLSERVER:
return DEFALUT_SQLSERVER_VERSION
else:
raise relational_db.RelationalDbEngineNotFoundError(
'Unsupported engine {}'.format(engine)
)
def GetAzCommandForEngine(self):
engine = self.spec.engine
if engine == sql_engine_utils.POSTGRES:
return 'postgres'
elif engine == sql_engine_utils.MYSQL:
return 'mysql'
elif engine == sql_engine_utils.SQLSERVER:
return 'sql'
raise relational_db.RelationalDbEngineNotFoundError(
'Unsupported engine {}'.format(engine)
)
def GetConfigFromMachineType(self, machine_type):
"""Returns a tuple of (edition, family, vcore) from Azure machine type.
Args:
machine_type (string): Azure machine type i.e GP_Gen5_4
Returns:
(string, string, string): edition, family, vcore
Raises:
UnsupportedError: if the machine type is not supported.
"""
machine_type = machine_type.split('_')
if len(machine_type) != 3:
raise relational_db.UnsupportedError(
'Unsupported machine type {}, sample machine type GP_Gen5_2'.format(
machine_type
)
)
edition = machine_type[0]
if edition == 'BC':
edition = 'BusinessCritical'
elif edition == 'GP':
edition = 'GeneralPurpose'
else:
raise relational_db.UnsupportedError(
'Unsupported edition {}. Only supports BC or GP'.format(machine_type)
)
family = machine_type[1]
vcore = machine_type[2]
return (edition, family, vcore)
def SetDbConfiguration(self, name, value):
"""Set configuration for the database instance.
Args:
name: string, the name of the settings to change
value: value, string the value to set
Returns:
Tuple of standand output and standard error
"""
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'configuration',
'set',
'--name',
name,
'--value',
value,
'--resource-group',
self.resource_group.name,
'--server',
self.instance_id,
]
return vm_util.IssueCommand(cmd, raise_on_failure=False)
def RenameDatabase(self, new_name):
"""Renames an the database instace."""
engine = self.spec.engine
if engine == sql_engine_utils.SQLSERVER:
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
'db',
'rename',
'--resource-group',
self.resource_group.name,
'--server',
self.instance_id,
'--name',
self.database_name,
'--new-name',
new_name,
]
vm_util.IssueCommand(cmd)
self.database_name = new_name
else:
raise relational_db.RelationalDbEngineNotFoundError(
'Unsupported engine {}'.format(engine)
)
def _ApplyDbFlags(self):
"""Applies the MySqlFlags to a managed instance."""
for flag in FLAGS.db_flags:
name_and_value = flag.split('=')
_, stderr, _ = self.SetDbConfiguration(
name_and_value[0], name_and_value[1]
)
if stderr:
raise KeyError(
'Invalid MySQL flags: {}. Error {}'.format(
name_and_value, stderr
)
)
self._Reboot()
def _CreateMySqlOrPostgresInstance(self):
"""Creates a managed MySql or Postgres instance."""
if not self.spec.high_availability:
raise KeyError(
'Azure databases can only be used in high '
'availability. Please rerurn with flag '
'--db_high_availability=True'
)
# Valid storage sizes range from minimum of 5120 MB
# and additional increments of 1024 MB up to maximum of 16777216 MB.
azure_disk_size_mb = self.spec.db_disk_spec.disk_size * 1024
if azure_disk_size_mb > AZURE_MAX_DB_DISK_SIZE_MB:
error_msg = (
'Azure disk size was specified as in the disk spec as %s,'
'got rounded to %s which is greater than the '
'maximum of 16777216 MB'
% (self.spec.db_disk_spec.disk_size, azure_disk_size_mb)
)
raise errors.Config.InvalidValue(error_msg)
elif azure_disk_size_mb < AZURE_MIN_DB_DISK_SIZE_MB:
error_msg = (
'Azure disk size was specified '
'as in the disk spec as %s, got rounded to %s '
'which is smaller than the minimum of 5120 MB'
% (self.spec.db_disk_spec.disk_size, azure_disk_size_mb)
)
raise errors.Config.InvalidValue(error_msg)
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'create',
'--resource-group',
self.resource_group.name,
'--name',
self.instance_id,
'--location',
self.region,
'--admin-user',
self.spec.database_username,
'--admin-password',
self.spec.database_password,
'--storage-size',
str(azure_disk_size_mb),
'--sku-name',
self.spec.db_spec.machine_type,
'--version',
self.spec.engine_version,
]
vm_util.IssueCommand(cmd, timeout=CREATE_AZURE_DB_TIMEOUT)
def _CreateSqlServerInstance(self):
"""Creates a managed sql server instance."""
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
'server',
'create',
'--resource-group',
self.resource_group.name,
'--name',
self.instance_id,
'--location',
self.region,
'--admin-user',
self.spec.database_username,
'--admin-password',
self.spec.database_password,
]
vm_util.IssueCommand(cmd)
# Azure support two ways of specifying machine type DTU or with vcores
# if compute units is specified we will use the DTU model
if self.spec.db_spec.compute_units is not None:
# Supported families & capacities for 'Standard' are:
# [(None, 10), (None, 20), (None, 50), (None, 100), (None, 200),
# (None, 400), (None, 800), (None, 1600), (None, 3000)]
# Supported families & capacities for 'Premium' are:
# [(None, 125), (None, 250), (None, 500), (None, 1000), (None, 1750),
# (None, 4000)].
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
'db',
'create',
'--resource-group',
self.resource_group.name,
'--server',
self.instance_id,
'--name',
DEFAULT_DATABASE_NAME,
'--edition',
self.spec.db_tier,
'--capacity',
str(self.spec.db_spec.compute_units),
'--zone-redundant',
'true' if self.spec.high_availability else 'false',
]
else:
# Sample machine_type: GP_Gen5_2
edition, family, vcore = self.GetConfigFromMachineType(
self.spec.db_spec.machine_type
)
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
'db',
'create',
'--resource-group',
self.resource_group.name,
'--server',
self.instance_id,
'--name',
DEFAULT_DATABASE_NAME,
'--edition',
edition,
'--family',
family,
'--capacity',
vcore,
'--zone-redundant',
'true' if self.spec.high_availability else 'false',
]
vm_util.IssueCommand(cmd, timeout=CREATE_AZURE_DB_TIMEOUT)
self.database_name = DEFAULT_DATABASE_NAME
def _CreateAzureManagedSqlInstance(self):
"""Creates an Azure Sql Instance from a managed service."""
if self.engine_type == sql_engine_utils.POSTGRES:
self._CreateMySqlOrPostgresInstance()
elif self.engine_type == sql_engine_utils.MYSQL:
self._CreateMySqlOrPostgresInstance()
elif self.engine_type == sql_engine_utils.SQLSERVER:
self._CreateSqlServerInstance()
else:
raise NotImplementedError(
'Unknown how to create Azure data base engine {}'.format(
self.engine_type
)
)
def _Create(self):
"""Creates the Azure RDS instance.
Raises:
NotImplementedError: if unknown how to create self.spec.engine.
Exception: if attempting to create a non high availability database.
"""
self._CreateAzureManagedSqlInstance()
def _Delete(self):
"""Deletes the underlying resource.
Implementations of this method should be idempotent since it may
be called multiple times, even if the resource has already been
deleted.
"""
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'delete',
'--resource-group',
self.resource_group.name,
'--name',
self.instance_id,
'--yes',
]
vm_util.IssueCommand(cmd, raise_on_failure=False)
def _Exists(self):
"""Returns true if the underlying resource exists.
Supplying this method is optional. If it is not implemented then the
default is to assume success when _Create and _Delete do not raise
exceptions.
"""
json_server_show = self._AzServerShow()
if json_server_show is None:
return False
return True
def _IsReady(self, timeout=IS_READY_TIMEOUT):
"""Return true if the underlying resource is ready.
This method will query the instance every 5 seconds until
its instance state is 'available', or until a timeout occurs.
Args:
timeout: timeout in seconds
Returns:
True if the resource was ready in time, False if the wait timed out
or an Exception occurred.
"""
return self._IsInstanceReady(timeout)
def _PostCreate(self):
"""Perform general post create operations on the cluster."""
super()._PostCreate()
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'firewall-rule',
'create',
'--resource-group',
self.resource_group.name,
'--server',
self.instance_id,
'--name',
'AllowAllIps',
'--start-ip-address',
'0.0.0.0',
'--end-ip-address',
'255.255.255.255',
]
vm_util.IssueCommand(cmd)
if self.spec.engine == 'mysql' or self.spec.engine == 'postgres':
# Azure will add @domainname after the database username
self.spec.database_username = (
self.spec.database_username + '@' + self.endpoint.split('.')[0]
)
def _Reboot(self):
"""Reboot the managed db."""
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'restart',
'--resource-group',
self.resource_group.name,
'--name',
self.instance_id,
]
vm_util.IssueCommand(cmd)
if not self._IsInstanceReady():
raise RuntimeError('Instance could not be set to ready after reboot')
def _IsInstanceReady(self, timeout=IS_READY_TIMEOUT):
"""Return true if the instance is ready.
This method will query the instance every 5 seconds until
its instance state is 'Ready', or until a timeout occurs.
Args:
timeout: timeout in seconds
Returns:
True if the resource was ready in time, False if the wait timed out
or an Exception occurred.
"""
start_time = datetime.datetime.now()
while True:
if (datetime.datetime.now() - start_time).seconds >= timeout:
logging.warning('Timeout waiting for sql instance to be ready')
return False
server_show_json = self._AzServerShow()
if server_show_json is not None:
engine = self.engine_type
if engine == sql_engine_utils.POSTGRES:
state = server_show_json['userVisibleState']
elif engine == sql_engine_utils.MYSQL:
state = server_show_json['userVisibleState']
elif engine == sql_engine_utils.SQLSERVER:
state = server_show_json['state']
else:
raise relational_db.RelationalDbEngineNotFoundError(
'The db engine does not contain a valid state'
)
if state == 'Ready':
break
time.sleep(5)
return True
def _AzServerShow(self):
"""Runs the azure command az server show.
Returns:
json object representing holding the of the show command on success.
None for a non 0 retcode. A non 0 retcode can occur if queried
before the database has finished being created.
"""
cmd = [
azure.AZURE_PATH,
self.GetAzCommandForEngine(),
self.SERVER_TYPE,
'show',
'--resource-group',
self.resource_group.name,
'--name',
self.instance_id,
]
stdout, _, retcode = vm_util.IssueCommand(cmd, raise_on_failure=False)
if retcode != 0:
return None
json_output = json.loads(stdout)
return json_output
def _SetEndpoint(self):
"""Assigns the ports and endpoints from the instance_id to self.
These will be used to communicate with the database. Called during
_PostCreate().
"""
server_show_json = self._AzServerShow()
self.endpoint = server_show_json['fullyQualifiedDomainName']
def _FailoverHA(self):
raise NotImplementedError()