MySQL Router Charm

The charm intelligently proxies database requests from clients to MySQL
InnoDB Clusters.
This commit is contained in:
David Ames 2019-10-04 10:18:04 -07:00
commit 67d9b4483c
16 changed files with 866 additions and 0 deletions

14
.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
.tox
.stestr
*__pycache__*
*.pyc
build
interfaces
layers
README.ex
# Remove these
src/tests/mysqlsh.snap
src/tests/bundles/overlays/local-charm-overlay.yaml.j2
src/files
manual-attach.sh

235
icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
# This file is managed centrally. If you find the need to modify this as a
# one-off, please don't. Intead, consult #openstack-charms and ask about
# requirements management in charms via bot-control. Thank you.
#
# Build requirements
charm-tools>=2.4.4
simplejson

27
src/config.yaml Normal file
View File

@ -0,0 +1,27 @@
options:
source:
type: string
default: distro
description: |
Repository from which to install. May be one of the following:
distro (default), ppa:somecustom/ppa, a deb url sources entry,
or a supported Ubuntu Cloud Archive e.g.
.
cloud:<series>-<openstack-release>
cloud:<series>-<openstack-release>/updates
cloud:<series>-<openstack-release>/staging
cloud:<series>-<openstack-release>/proposed
.
See https://wiki.ubuntu.com/OpenStack/CloudArchive for info on which
cloud archives are available and supported.
system-user:
# TODO What user? No mysql user exists. Create one?
type: string
description: System user to run mysqlrouter
default: ubuntu
base-port:
type: int
default: 3306
description: |
Base port number for RW interface. RO, xRW and xRO will
increment from base_port.

15
src/layer.yaml Normal file
View File

@ -0,0 +1,15 @@
includes:
- layer:openstack
- interface:mysql-shared
- interface:mysql-router
options:
basic:
use_venv: True
packages: [ 'libmysqlclient-dev']
repo: https://github.com/openstack-charmers/charm-mysql-router
config:
deletes:
- verbose
- openstack-origin
- use-internal-endpoints
- debug

View File

@ -0,0 +1,290 @@
# Copyright 2019 Canonicauh Ltd
#
# 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.
import json
import subprocess
import charms_openstack.charm
import charms_openstack.adapters
import charms.reactive as reactive
import charmhelpers.core as ch_core
import charmhelpers.contrib.network.ip as ch_net_ip
import charmhelpers.contrib.database.mysql as mysql
MYSQLD_CNF = "/etc/mysql/mysql.conf.d/mysqld.cnf"
# Flag Strings
MYSQL_ROUTER_BOOTSTRAPPED = "charm.mysqlrouter.bootstrapped"
MYSQL_ROUTER_STARTED = "charm.mysqlrouter.started"
DB_ROUTER_AVAILABLE = "db-router.available"
DB_ROUTER_PROXY_AVAILABLE = "db-router.available.proxy"
@charms_openstack.adapters.config_property
def db_router_address(cls):
return ch_net_ip.get_relation_ip("db-router")
@charms_openstack.adapters.config_property
def shared_db_address(cls):
# This is is a subordinate relation, we want mysql communication
# to run over localhost
# Alternatively: ch_net_ip.get_relation_ip("shared-db")
return "127.0.0.1"
class MySQLRouterCharm(charms_openstack.charm.OpenStackCharm):
"""Charm class for the MySQLRouter charm."""
name = "mysqlrouter"
packages = ["mysql-router"]
release = "stein"
release_pkg = "mysql-router"
required_relations = ["db-router", "shared-db"]
source_config_key = "source"
# FIXME Can we have a non-systemd services?
# Create a systemd shim?
# services = ["mysqlrouter"]
services = []
# TODO Post bootstrap config management and restarts
restart_map = {}
# TODO Pick group owner
group = "mysql"
# For internal use with mysql.get_db_data
_unprefixed = "MRUP"
@property
def mysqlrouter_bin(self):
return "/usr/bin/mysqlrouter"
@property
def db_router_endpoint(self):
return reactive.relations.endpoint_from_flag("db-router.available")
@property
def db_prefix(self):
return "mysqlrouter"
@property
def db_router_user(self):
# The prefix will be prepended
return "{}user".format(self.db_prefix)
@property
def db_router_password(self):
return json.loads(
self.db_router_endpoint.password(prefix=self.db_prefix))
@property
def db_router_address(self):
""" My address """
return self.options.db_router_address
@property
def cluster_address(self):
""" Database Cluster Addresss """
return json.loads(self.db_router_endpoint.db_host())
@property
def shared_db_address(self):
""" My address """
return self.options.shared_db_address
@property
def mysqlrouter_dir(self):
return "/home/{}/mysqlrouter".format(self.options.system_user)
def install(self):
"""Custom install function.
"""
# TODO: charms.openstack should probably do this
# Need to configure source first
self.configure_source()
super().install()
def get_db_helper(self):
db_helper = mysql.MySQL8Helper(
rpasswdf_template="/var/lib/charm/{}/mysql.passwd"
.format(ch_core.hookenv.service_name()),
upasswdf_template="/var/lib/charm/{}/mysql-{{}}.passwd"
.format(ch_core.hookenv.service_name()),
user=self.db_router_user,
password=self.db_router_password,
host=self.cluster_address)
return db_helper
def states_to_check(self, required_relations=None):
"""Custom state check function for charm specific state check needs.
"""
states_to_check = super().states_to_check(required_relations)
states_to_check["charm"] = [
(MYSQL_ROUTER_BOOTSTRAPPED,
"waiting",
"MySQL-Router not yet bootstrapped"),
(MYSQL_ROUTER_STARTED,
"waiting",
"MySQL-Router not yet started"),
(DB_ROUTER_PROXY_AVAILABLE,
"waiting",
"Waiting for proxied DB creation from cluster")]
return states_to_check
def check_mysql_connection(self):
"""Check if local instance of mysql is accessible.
Attempt a connection to the local instance of mysql to determine
if it is running and accessible.
:side effect: Uses get_db_helper to execute a connection to the DB.
:returns: boolean
"""
m_helper = self.get_db_helper()
try:
m_helper.connect(self.db_router_user,
self.db_router_password,
self.shared_db_address)
return True
except mysql.MySQLdb._exceptions.OperationalError:
ch_core.hookenv.log("Could not connect to db", "DEBUG")
return False
def custom_assess_status_check(self):
# Start with default checks
for f in [self.check_if_paused,
self.check_interfaces,
self.check_mandatory_config]:
state, message = f()
if state is not None:
ch_core.hookenv.status_set(state, message)
return state, message
# We should not get here until there is a connection to the
# cluster (db-router available)
if not self.check_mysql_connection():
return "blocked", "Failed to connect to MySQL"
return None, None
def bootstrap_mysqlrouter(self):
cmd = [self.mysqlrouter_bin,
"--user", self.options.system_user,
"--bootstrap",
"{}:{}@{}".format(self.db_router_user,
self.db_router_password,
self.cluster_address),
"--directory", self.mysqlrouter_dir,
"--conf-use-sockets",
"--conf-base-port", str(self.options.base_port)]
try:
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
ch_core.hookenv.log(output, "DEBUG")
except subprocess.CalledProcessError as e:
ch_core.hookenv.log(
"Failed to bootstrap mysqlrouter: {}"
.format(e.output.decode("UTF-8")), "ERROR")
return
reactive.flags.set_flag(MYSQL_ROUTER_BOOTSTRAPPED)
def start_mysqlrouter(self):
cmd = ["{}/start.sh".format(self.mysqlrouter_dir)]
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, bufsize=1,
universal_newlines=True)
proc.wait()
ch_core.hookenv.log("MySQL router started", "DEBUG")
except subprocess.CalledProcessError as e:
ch_core.hookenv.log(
"Failed to start mysqlrouter: {}"
.format(e.output.decode("UTF-8")), "ERROR")
return
reactive.flags.set_flag(MYSQL_ROUTER_STARTED)
def stop_mysqlrouter(self):
cmd = ["{}/stop.sh".format(self.mysqlrouter_dir)]
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, bufsize=1,
universal_newlines=True)
proc.wait()
ch_core.hookenv.log("MySQL router stopped", "DEBUG")
except subprocess.CalledProcessError as e:
ch_core.hookenv.log(
"Failed to start mysqlrouter: {}"
.format(e.output.decode("UTF-8")), "ERROR")
return
reactive.flags.clear_flag(MYSQL_ROUTER_STARTED)
def restart_mysqlrouter(self):
self.stop_mysqlrouter()
self.start_mysqlrouter()
def proxy_db_and_user_requests(
self, receiving_interface, sending_interface):
# We can use receiving_interface.all_joined_units.received
# as this is a subordiante and there is only one unit related.
db_data = mysql.get_db_data(
dict(receiving_interface.all_joined_units.received),
unprefixed=self._unprefixed)
for prefix in db_data:
sending_interface.configure_proxy_db(
db_data[prefix].get("database"),
db_data[prefix].get("username"),
db_data[prefix].get("hostname"),
prefix=prefix)
def proxy_db_and_user_responses(
self, receiving_interface, sending_interface):
# This is a suborndinate relationship there is only ever one
unit = sending_interface.all_joined_units[0]
for prefix in receiving_interface.get_prefixes():
if prefix in self.db_prefix:
# Do not send the mysqlrouter credentials to the client
continue
_password = json.loads(
receiving_interface.password(prefix=prefix))
if ch_core.hookenv.local_unit() in (json.loads(
receiving_interface.allowed_units(prefix=prefix))):
_allowed_hosts = unit.unit_name
else:
_allowed_hosts = None
if prefix in self._unprefixed:
prefix = None
sending_interface.set_db_connection_info(
unit.relation.relation_id,
self.shared_db_address,
_password,
_allowed_hosts,
prefix=prefix)

20
src/metadata.yaml Normal file
View File

@ -0,0 +1,20 @@
name: mysql-router
summary: MySQL Router
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
description: |
MySQL Router proxying communication between application clients and MySQL InnoDB Clusters.
tags:
- databases
subordinate: true
series:
- eoan
provides:
shared-db:
interface: mysql-shared
scope: container
requires:
juju-info:
interface: juju-info
scope: container
db-router:
interface: mysql-router

View File

@ -0,0 +1,64 @@
import charms.reactive as reactive
import charms_openstack.bus
import charms_openstack.charm as charm
import charm.mysql_router as mysql_router # noqa
charms_openstack.bus.discover()
charm.use_defaults(
'charm.installed',
'config.changed',
'update-status',
'upgrade-charm')
@reactive.when('charm.installed')
@reactive.when('db-router.connected')
def db_router_request(db_router):
with charm.provide_charm_instance() as instance:
db_router.set_prefix(instance.db_prefix)
db_router.configure_db_router(
instance.db_router_user,
instance.db_router_address,
prefix=instance.db_prefix)
instance.assess_status()
@reactive.when('charm.installed')
@reactive.when(mysql_router.DB_ROUTER_AVAILABLE)
@reactive.when_not(mysql_router.MYSQL_ROUTER_BOOTSTRAPPED)
def bootstrap_mysqlrouter(db_router):
with charm.provide_charm_instance() as instance:
instance.bootstrap_mysqlrouter()
instance.assess_status()
@reactive.when('charm.installed')
@reactive.when(mysql_router.DB_ROUTER_AVAILABLE)
@reactive.when(mysql_router.MYSQL_ROUTER_BOOTSTRAPPED)
@reactive.when_not(mysql_router.MYSQL_ROUTER_STARTED)
def start_mysqlrouter(db_router):
with charm.provide_charm_instance() as instance:
instance.start_mysqlrouter()
instance.assess_status()
@reactive.when(mysql_router.MYSQL_ROUTER_STARTED)
@reactive.when(mysql_router.DB_ROUTER_AVAILABLE)
@reactive.when('shared-db.available')
def proxy_shared_db_requests(shared_db, db_router):
with charm.provide_charm_instance() as instance:
instance.proxy_db_and_user_requests(shared_db, db_router)
instance.assess_status()
@reactive.when(mysql_router.MYSQL_ROUTER_STARTED)
@reactive.when(mysql_router.DB_ROUTER_PROXY_AVAILABLE)
@reactive.when('shared-db.available')
def proxy_shared_db_responses(shared_db, db_router):
with charm.provide_charm_instance() as instance:
instance.proxy_db_and_user_responses(db_router, shared_db)
instance.assess_status()

View File

@ -0,0 +1,3 @@
# zaza
git+https://github.com/openstack-charmers/zaza.git#egg=zaza
git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack

View File

@ -0,0 +1,17 @@
series: eoan
relations:
- ["keystone:shared-db", "mysql-router:shared-db"]
- ["mysql-router:db-router", "mysql-innodb-cluster:db-router"]
applications:
mysql-router:
charm: ../../../mysql-router
mysql-innodb-cluster:
series: eoan
charm: cs:~thedac/mysql-innodb-cluster
num_units: 3
options:
source: distro-proposed
keystone:
series: eoan
charm: cs:~openstack-charmers-next/keystone
num_units: 1

11
src/tests/tests.yaml Normal file
View File

@ -0,0 +1,11 @@
charm_name: mysql-router
configure:
- zaza.openstack.charm_tests.keystone.setup.add_demo_user
tests:
# Validates DB connectivity
- zaza.openstack.charm_tests.keystone.tests.AuthenticationAuthorizationTest
dev_bundles:
gate_bundles:
- eoan
smoke_bundles:
- eoan

35
src/tox.ini Normal file
View File

@ -0,0 +1,35 @@
[tox]
envlist = pep8
skipsdist = True
[testenv]
setenv = VIRTUAL_ENV={envdir}
PYTHONHASHSEED=0
whitelist_externals = juju
passenv = HOME TERM CS_API_* OS_* AMULET_*
deps = -r{toxinidir}/test-requirements.txt
install_command =
pip install {opts} {packages}
[testenv:pep8]
basepython = python3
deps=charm-tools
commands = charm-proof
[testenv:func-noop]
basepython = python3
commands =
true
[testenv:func]
basepython = python3
commands =
functest-run-suite --keep-model
[testenv:func-smoke]
basepython = python3
commands =
functest-run-suite --keep-model --smoke
[testenv:venv]
commands = {posargs}

3
src/wheelhouse.txt Normal file
View File

@ -0,0 +1,3 @@
jinja2
psutil
mysqlclient

13
test-requirements.txt Normal file
View File

@ -0,0 +1,13 @@
# This file is managed centrally. If you find the need to modify this as a
# one-off, please don't. Intead, consult #openstack-charms and ask about
# requirements management in charms via bot-control. Thank you.
#
# Lint and unit test requirements
flake8>=2.2.4,<=2.4.1
stestr>=2.2.0
requests>=2.18.4
charms.reactive
mock>=1.2
nose>=1.3.7
coverage>=3.6
git+https://github.com/openstack/charms.openstack.git#egg=charms.openstack

80
tox.ini Normal file
View File

@ -0,0 +1,80 @@
# Source charm: ./tox.ini
# This file is managed centrally by release-tools and should not be modified
# within individual charm repos.
[tox]
skipsdist = True
envlist = pep8,py3
[testenv]
setenv = VIRTUAL_ENV={envdir}
PYTHONHASHSEED=0
TERM=linux
CHARM_LAYER_PATH={toxinidir}/layers
CHARM_INTERFACES_DIR={toxinidir}/interfaces
JUJU_REPOSITORY={toxinidir}/build
passenv = http_proxy https_proxy OS_*
install_command =
pip install {opts} {packages}
deps =
-r{toxinidir}/requirements.txt
[testenv:build]
basepython = python3
commands =
charm-build --log-level DEBUG -o {toxinidir}/build src {posargs}
[testenv:py3]
basepython = python3
deps = -r{toxinidir}/test-requirements.txt
commands = stestr run {posargs}
[testenv:py35]
basepython = python3.5
deps = -r{toxinidir}/test-requirements.txt
commands = stestr run {posargs}
[testenv:py36]
basepython = python3.6
deps = -r{toxinidir}/test-requirements.txt
commands = stestr run {posargs}
[testenv:pep8]
basepython = python3
deps = -r{toxinidir}/test-requirements.txt
commands = flake8 {posargs} src unit_tests
[testenv:cover]
# Technique based heavily upon
# https://github.com/openstack/nova/blob/master/tox.ini
basepython = python3
deps = -r{toxinidir}/requirements.txt
-r{toxinidir}/test-requirements.txt
setenv =
{[testenv]setenv}
PYTHON=coverage run
commands =
coverage erase
stestr run {posargs}
coverage combine
coverage html -d cover
coverage xml -o cover/coverage.xml
coverage report
[coverage:run]
branch = True
concurrency = multiprocessing
parallel = True
source =
.
omit =
.tox/*
*/charmhelpers/*
unit_tests/*
[testenv:venv]
basepython = python3
commands = {posargs}
[flake8]
# E402 ignore necessary for path append before sys module import in actions
ignore = E402

32
unit_tests/__init__.py Normal file
View File

@ -0,0 +1,32 @@
# Copyright 2019 Canonical Ltd
#
# 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.
import os
import sys
# Mock out charmhelpers so that we can test without it.
import charms_openstack.test_mocks # noqa
charms_openstack.test_mocks.mock_charmhelpers()
_path = os.path.dirname(os.path.realpath(__file__))
_src = os.path.abspath(os.path.join(_path, 'src'))
_lib = os.path.abspath(os.path.join(_path, 'src/lib'))
def _add_path(path):
if path not in sys.path:
sys.path.insert(1, path)
_add_path(_src)
_add_path(_lib)