Skip to content

Commit 9f2d834

Browse files
committed
WL13372: DNS SRV support
A SRV record is a specification of data in the DNS, that typically defines a symbolic name and the transport protocol used as part of the domain name. It defines the priority, weight, port, and target for the service in the record content. This worklog implements the DNS SRV records support, which allows resolving SRV records available in a DNS server. The implementation of routers in the X DevAPI and failover in classic protocol were changed to support SRV records. The `dnspython` package was added as a required dependency.
1 parent 27dc55b commit 9f2d834

File tree

9 files changed

+249
-58
lines changed

9 files changed

+249
-58
lines changed

docs/mysqlx/tutorials/getting_started.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,28 @@ Parameter binding is also available as a chained method to each of the CRUD oper
184184
185185
my_coll = db.get_collection('my_collection')
186186
my_coll.remove('name = :data').bind('data', 'Sakila').execute()
187+
188+
Resolving DNS SRV records
189+
-------------------------
190+
191+
If you are using a DNS server with service discovery utility that supports mapping `SRV records <https://tools.ietf.org/html/rfc2782>`_, you can use the ``mysqlx+srv`` scheme or ``dns-srv`` connection option and Connector/Python will automatically resolve the available server addresses described by those SRV records.
192+
193+
.. code-block:: python
194+
195+
session = mysqlx.get_session('mysqlx://root:@foo.abc.com')
196+
# or
197+
session = mysqlx.get_session({
198+
'host': 'foo.abc.com',
199+
'user': 'root',
200+
'password': '',
201+
'dns-srv': True
202+
})
203+
204+
For instance, given the following SRV records by a DNS server at the ``foo.abc.com`` endpoint, the servers would be in the following priority: foo2.abc.com, foo1.abc.com, foo3.abc.com, foo4.abc.com. ::
205+
206+
Record TTL Class Priority Weight Port Target
207+
_mysqlx._tcp.foo.abc.com. 86400 IN SRV 0 5 33060 foo1.abc.com
208+
_mysqlx._tcp.foo.abc.com. 86400 IN SRV 0 10 33060 foo2.abc.com
209+
_mysqlx._tcp.foo.abc.com. 86400 IN SRV 10 5 33060 foo3.abc.com
210+
_mysqlx._tcp.foo.abc.com. 86400 IN SRV 20 5 33060 foo4.abc.com
211+

lib/mysql/connector/__init__.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
MySQL Connector/Python - MySQL driver written in Python
3131
"""
3232

33+
import dns.resolver
34+
import dns.exception
35+
3336
try:
3437
import _mysql_connector # pylint: disable=F0401
3538
from .connection_cext import CMySQLConnection
@@ -40,6 +43,7 @@
4043

4144
from . import version
4245
from .connection import MySQLConnection
46+
from .constants import DEFAULT_CONFIGURATION
4347
from .errors import ( # pylint: disable=W0622
4448
Error, Warning, InterfaceError, DatabaseError,
4549
NotSupportedError, DataError, IntegrityError, ProgrammingError,
@@ -142,6 +146,44 @@ def connect(*args, **kwargs):
142146
143147
Returns MySQLConnection or PooledMySQLConnection.
144148
"""
149+
# DNS SRV
150+
dns_srv = kwargs.pop('dns_srv') if 'dns_srv' in kwargs else False
151+
152+
if not isinstance(dns_srv, bool):
153+
raise InterfaceError("The value of 'dns-srv' must be a boolean")
154+
155+
if dns_srv:
156+
if 'unix_socket' in kwargs:
157+
raise InterfaceError('Using Unix domain sockets with DNS SRV '
158+
'lookup is not allowed')
159+
if 'port' in kwargs:
160+
raise InterfaceError('Specifying a port number with DNS SRV '
161+
'lookup is not allowed')
162+
if 'failover' in kwargs:
163+
raise InterfaceError('Specifying multiple hostnames with DNS '
164+
'SRV look up is not allowed')
165+
if 'host' not in kwargs:
166+
kwargs['host'] = DEFAULT_CONFIGURATION['host']
167+
168+
try:
169+
srv_records = dns.resolver.query(kwargs['host'], 'SRV')
170+
except dns.exception.DNSException:
171+
raise InterfaceError("Unable to locate any hosts for '{0}'"
172+
"".format(kwargs['host']))
173+
174+
failover = []
175+
for srv in srv_records:
176+
failover.append({
177+
'host': srv.target.to_text(omit_final_dot=True),
178+
'port': srv.port,
179+
'priority': srv.priority,
180+
'weight': srv.weight
181+
})
182+
183+
failover.sort(key=lambda x: (x['priority'], -x['weight']))
184+
kwargs['failover'] = [{'host': srv['host'],
185+
'port': srv['port']} for srv in failover]
186+
145187
# Option files
146188
if 'read_default_file' in kwargs:
147189
kwargs['option_files'] = kwargs['read_default_file']

lib/mysql/connector/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
'allow_local_infile': False,
7575
'consume_results': False,
7676
'conn_attrs': None,
77+
'dns_srv': False,
7778
}
7879

7980
CNX_POOL_ARGS = ('pool_name', 'pool_size', 'pool_reset_session')

lib/mysqlx/__init__.py

Lines changed: 76 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@
5454

5555
from .expr import ExprParser as expr
5656

57-
_SPLIT = re.compile(r',(?![^\(\)]*\))')
58-
_PRIORITY = re.compile(r'^\(address=(.+),priority=(\d+)\)$', re.VERBOSE)
57+
_SPLIT_RE = re.compile(r",(?![^\(\)]*\))")
58+
_PRIORITY_RE = re.compile(r"^\(address=(.+),priority=(\d+)\)$", re.VERBOSE)
59+
_URI_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+\-.]+)://(.*)")
5960
_SSL_OPTS = ["ssl-cert", "ssl-ca", "ssl-key", "ssl-crl"]
6061
_SESS_OPTS = _SSL_OPTS + ["user", "password", "schema", "host", "port",
6162
"routers", "socket", "ssl-mode", "auth", "use-pure",
62-
"connect-timeout", "connection-attributes"]
63+
"connect-timeout", "connection-attributes",
64+
"dns-srv"]
6365

6466
logging.getLogger(__name__).addHandler(logging.NullHandler())
6567

@@ -80,11 +82,11 @@ def _parse_address_list(path):
8082
and path.endswith("]")
8183

8284
routers = []
83-
address_list = _SPLIT.split(path[1:-1] if array else path)
85+
address_list = _SPLIT_RE.split(path[1:-1] if array else path)
8486
for address in address_list:
8587
router = {}
8688

87-
match = _PRIORITY.match(address)
89+
match = _PRIORITY_RE.match(address)
8890
if match:
8991
address = match.group(1)
9092
router["priority"] = int(match.group(2))
@@ -109,13 +111,24 @@ def _parse_connection_uri(uri):
109111
Returns:
110112
Returns a dict with parsed values of credentials and address of the
111113
MySQL server/farm.
114+
115+
Raises:
116+
:class:`mysqlx.InterfaceError`: If contains a duplicate option or
117+
URI scheme is not valid.
112118
"""
113119
settings = {"schema": ""}
114-
uri = "{0}{1}".format("" if uri.startswith("mysqlx://")
115-
else "mysqlx://", uri)
116-
_, temp = uri.split("://", 1)
117-
userinfo, temp = temp.partition("@")[::2]
118-
host, query_str = temp.partition("?")[::2]
120+
121+
match = _URI_SCHEME_RE.match(uri)
122+
scheme, uri = match.groups() if match else ("mysqlx", uri)
123+
124+
if scheme not in ("mysqlx", "mysqlx+srv"):
125+
raise InterfaceError("Scheme '{0}' is not valid".format(scheme))
126+
127+
if scheme == "mysqlx+srv":
128+
settings["dns-srv"] = True
129+
130+
userinfo, tmp = uri.partition("@")[::2]
131+
host, query_str = tmp.partition("?")[::2]
119132

120133
pos = host.rfind("/")
121134
if host[pos:].find(")") == -1 and pos > 0:
@@ -130,14 +143,17 @@ def _parse_connection_uri(uri):
130143
if host.startswith(("/", "..", ".")):
131144
settings["socket"] = unquote(host)
132145
elif host.startswith("\\."):
133-
raise InterfaceError("Windows Pipe is not supported.")
146+
raise InterfaceError("Windows Pipe is not supported")
134147
else:
135148
settings.update(_parse_address_list(host))
136149

150+
invalid_options = ("user", "password", "dns-srv")
137151
for key, val in parse_qsl(query_str, True):
138152
opt = key.replace("_", "-").lower()
153+
if opt in invalid_options:
154+
raise InterfaceError("Invalid option: '{0}'".format(key))
139155
if opt in settings:
140-
raise InterfaceError("Duplicate option '{0}'.".format(key))
156+
raise InterfaceError("Duplicate option: '{0}'".format(key))
141157
if opt in _SSL_OPTS:
142158
settings[opt] = unquote(val.strip("()"))
143159
else:
@@ -159,15 +175,18 @@ def _validate_settings(settings):
159175
160176
Args:
161177
settings: dict containing connection settings.
178+
179+
Raises:
180+
:class:`mysqlx.InterfaceError`: On any configuration issue.
162181
"""
163182
invalid_opts = set(settings.keys()).difference(_SESS_OPTS)
164183
if invalid_opts:
165-
raise ProgrammingError("Invalid options: {0}."
166-
"".format(", ".join(invalid_opts)))
184+
raise InterfaceError("Invalid option(s): '{0}'"
185+
"".format("', '".join(invalid_opts)))
167186

168187
if "routers" in settings:
169188
for router in settings["routers"]:
170-
_validate_hosts(router)
189+
_validate_hosts(router, 33060)
171190
elif "host" in settings:
172191
_validate_hosts(settings)
173192

@@ -176,23 +195,23 @@ def _validate_settings(settings):
176195
settings["ssl-mode"] = settings["ssl-mode"].lower()
177196
SSLMode.index(settings["ssl-mode"])
178197
except (AttributeError, ValueError):
179-
raise InterfaceError("Invalid SSL Mode '{0}'."
198+
raise InterfaceError("Invalid SSL Mode '{0}'"
180199
"".format(settings["ssl-mode"]))
181200
if settings["ssl-mode"] == SSLMode.DISABLED and \
182201
any(key in settings for key in _SSL_OPTS):
183-
raise InterfaceError("SSL options used with ssl-mode 'disabled'.")
202+
raise InterfaceError("SSL options used with ssl-mode 'disabled'")
184203

185204
if "ssl-crl" in settings and not "ssl-ca" in settings:
186-
raise InterfaceError("CA Certificate not provided.")
205+
raise InterfaceError("CA Certificate not provided")
187206
if "ssl-key" in settings and not "ssl-cert" in settings:
188-
raise InterfaceError("Client Certificate not provided.")
207+
raise InterfaceError("Client Certificate not provided")
189208

190209
if not "ssl-ca" in settings and settings.get("ssl-mode") \
191210
in [SSLMode.VERIFY_IDENTITY, SSLMode.VERIFY_CA]:
192-
raise InterfaceError("Cannot verify Server without CA.")
211+
raise InterfaceError("Cannot verify Server without CA")
193212
if "ssl-ca" in settings and settings.get("ssl-mode") \
194213
not in [SSLMode.VERIFY_IDENTITY, SSLMode.VERIFY_CA]:
195-
raise InterfaceError("Must verify Server if CA is provided.")
214+
raise InterfaceError("Must verify Server if CA is provided")
196215

197216
if "auth" in settings:
198217
try:
@@ -204,12 +223,39 @@ def _validate_settings(settings):
204223
if "connection-attributes" in settings:
205224
validate_connection_attributes(settings)
206225

226+
if "connect-timeout" in settings:
227+
try:
228+
if isinstance(settings["connect-timeout"], STRING_TYPES):
229+
settings["connect-timeout"] = int(settings["connect-timeout"])
230+
if not isinstance(settings["connect-timeout"], INT_TYPES) \
231+
or settings["connect-timeout"] < 0:
232+
raise ValueError
233+
except ValueError:
234+
raise TypeError("The connection timeout value must be a positive "
235+
"integer (including 0)")
236+
237+
if "dns-srv" in settings:
238+
if not isinstance(settings["dns-srv"], bool):
239+
raise InterfaceError("The value of 'dns-srv' must be a boolean")
240+
if settings.get("socket"):
241+
raise InterfaceError("Using Unix domain sockets with DNS SRV "
242+
"lookup is not allowed")
243+
if settings.get("port"):
244+
raise InterfaceError("Specifying a port number with DNS SRV "
245+
"lookup is not allowed")
246+
if settings.get("routers"):
247+
raise InterfaceError("Specifying multiple hostnames with DNS "
248+
"SRV look up is not allowed")
249+
elif "host" in settings and not settings.get("port"):
250+
settings["port"] = 33060
207251

208-
def _validate_hosts(settings):
252+
253+
def _validate_hosts(settings, default_port=None):
209254
"""Validate hosts.
210255
211256
Args:
212257
settings (dict): Settings dictionary.
258+
default_port (int): Default connection port.
213259
214260
Raises:
215261
:class:`mysqlx.InterfaceError`: If priority or port are invalid.
@@ -225,8 +271,8 @@ def _validate_hosts(settings):
225271
settings["port"] = int(settings["port"])
226272
except NameError:
227273
raise InterfaceError("Invalid port")
228-
elif "host" in settings:
229-
settings["port"] = 33060
274+
elif "host" in settings and default_port:
275+
settings["port"] = default_port
230276

231277

232278
def validate_connection_attributes(settings):
@@ -250,9 +296,9 @@ def validate_connection_attributes(settings):
250296
return
251297
if not (conn_attrs.startswith("[") and conn_attrs.endswith("]")) and \
252298
not conn_attrs in ['False', "false", "True", "true"]:
253-
raise InterfaceError("connection-attributes must be Boolean or a "
254-
"list of key-value pairs, found: '{}'"
255-
"".format(conn_attrs))
299+
raise InterfaceError("The value of 'connection-attributes' must "
300+
"be a boolean or a list of key-value pairs, "
301+
"found: '{}'".format(conn_attrs))
256302
elif conn_attrs in ['False', "false", "True", "true"]:
257303
if conn_attrs in ['False', "false"]:
258304
settings["connection-attributes"] = False
@@ -317,7 +363,7 @@ def validate_connection_attributes(settings):
317363
# Validate attribute name limit 32 characters
318364
if len(attr_name) > 32:
319365
raise InterfaceError("Attribute name '{}' exceeds 32 "
320-
"characters limit size.".format(attr_name))
366+
"characters limit size".format(attr_name))
321367
# Validate names in connection-attributes cannot start with "_"
322368
if attr_name.startswith("_"):
323369
raise InterfaceError("Key names in connection-attributes "
@@ -327,7 +373,7 @@ def validate_connection_attributes(settings):
327373
# Validate value type
328374
if not isinstance(attr_value, STRING_TYPES):
329375
raise InterfaceError("Attribute '{}' value: '{}' must "
330-
"be a string type."
376+
"be a string type"
331377
"".format(attr_name, attr_value))
332378
# Validate attribute value limit 1024 characters
333379
if len(attr_value) > 1024:
@@ -354,6 +400,7 @@ def _get_connection_settings(*args, **kwargs):
354400
355401
Raises:
356402
TypeError: If connection timeout is not a positive integer.
403+
:class:`mysqlx.InterfaceError`: If settings not provided.
357404
"""
358405
settings = {}
359406
if args:
@@ -369,17 +416,6 @@ def _get_connection_settings(*args, **kwargs):
369416
if not settings:
370417
raise InterfaceError("Settings not provided")
371418

372-
if "connect-timeout" in settings:
373-
try:
374-
if isinstance(settings["connect-timeout"], STRING_TYPES):
375-
settings["connect-timeout"] = int(settings["connect-timeout"])
376-
if not isinstance(settings["connect-timeout"], INT_TYPES) \
377-
or settings["connect-timeout"] < 0:
378-
raise ValueError
379-
except ValueError:
380-
raise TypeError("The connection timeout value must be a positive "
381-
"integer (including 0)")
382-
383419
_validate_settings(settings)
384420
return settings
385421

0 commit comments

Comments
 (0)