charm-mysql-router/src/lib/charm/openstack/mysql_router.py

490 lines
16 KiB
Python

# 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 os
import shutil
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
MYSQLROUTER_CNF = "/var/lib/mysql/mysqlrouter/mysqlrouter.conf"
# 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"
services = ["mysqlrouter"]
restart_map = {
MYSQLROUTER_CNF: services,
}
# TODO Pick group owner
group = "mysql"
# For internal use with mysql.get_db_data
_unprefixed = "MRUP"
@property
def mysqlrouter_bin(self):
"""Determine the path to the mysqlrouter binary.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Path to the binary
:rtype: str
"""
return "/usr/bin/mysqlrouter"
@property
def db_router_endpoint(self):
"""Get the MySQL Router (db-router) interface.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: db-router interface
:rtype: MySQLRouterRequires object
"""
return reactive.relations.endpoint_from_flag("db-router.available")
@property
def db_prefix(self):
"""Determine the prefix to use on the db-router relation.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Prefix
:rtype: str
"""
return "mysqlrouter"
@property
def db_router_user(self):
"""Determine the username to access the MySQL InnoDB Cluster.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Username
:rtype: str
"""
return "{}user".format(self.db_prefix)
@property
def db_router_password(self):
"""Determine the password for the MySQL InnoDB Cluster.
Using the MySQL Router Endpoint determine the password to access the
MySQL InnoDB Cluster.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Password
:rtype: str
"""
return json.loads(
self.db_router_endpoint.password(prefix=self.db_prefix))
@property
def db_router_address(self):
"""Determine this unit's DB-Router address.
Using the class method determine this unit's address for the DB-Router
relation.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Address
:rtype: str
"""
return self.options.db_router_address
@property
def cluster_address(self):
"""Determine MySQL InnoDB Cluster's address.
Using the MySQL Router Endpoint determine the MySQL InnoDB Cluster's
address.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Address
:rtype: str
"""
return json.loads(self.db_router_endpoint.db_host())
@property
def shared_db_address(self):
"""Determine this unit's Shared-DB address.
Using the class method determine this unit's address for the Shared-DB
relation.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Address
:rtype: str
"""
return self.options.shared_db_address
@property
def mysqlrouter_working_dir(self):
"""Determine the path to the mysqlrouter working directory.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Path to the directory
:rtype: str
"""
return "{}/mysqlrouter".format(self.mysqlrouter_home_dir)
@property
def mysqlrouter_home_dir(self):
"""Determine the path to the mysqlrouter working directory.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Path to the directory
:rtype: str
"""
return "/var/lib/mysql"
@property
def mysqlrouter_user(self):
return "mysql"
@property
def mysqlrouter_group(self):
return "mysql"
def install(self):
"""Custom install function.
:param self: Self
:type self: MySQLRouterCharm instance
:side effect: Executes other functions
:returns: This function is called for its side effect
:rtype: None
"""
# TODO: charms.openstack should probably do this
# Need to configure source first
self.configure_source()
super().install()
# Neither MySQL Router nor MySQL common packaging creates a user, group
# or home dir. As we want it to run as a system user in a predictable
# location create all of these.
# Create the group
if not ch_core.host.group_exists(self.mysqlrouter_group):
ch_core.host.add_group(
self.mysqlrouter_group, system_group=True)
# Create the user
if not ch_core.host.user_exists(self.mysqlrouter_user):
ch_core.host.adduser(
self.mysqlrouter_user, shell="/usr/sbin/nologin",
system_user=True, primary_group=self.mysqlrouter_group,
home_dir=self.mysqlrouter_home_dir)
# Create the directory
if not os.path.exists(self.mysqlrouter_home_dir):
ch_core.host.mkdir(
self.mysqlrouter_home_dir,
owner=self.mysqlrouter_user,
group=self.mysqlrouter_group,
perms=0o755)
# Systemd File
_systemd_filename = "mysqlrouter.service"
_src_systemd = os.path.join(
ch_core.hookenv.charm_dir(), "files", _systemd_filename)
_dst_systemd = os.path.join("/etc/systemd/system", _systemd_filename)
shutil.copy(_src_systemd, _dst_systemd)
cmd = ["/usr/bin/systemctl", "enable", "mysqlrouter"]
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def get_db_helper(self):
"""Get an instance of the MySQLDB8Helper class.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Instance of MySQLDB8Helper class
:rtype: MySQLDB8Helper instance
"""
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 states to check function.
Construct a custom set of connected and available states for each
of the relations passed, along with error messages and new status
conditions.
:param self: Self
:type self: MySQLRouterCharm instance
:param required_relations: List of relations which overrides
self.relations
:type required_relations: list of strings
:returns: {relation: [(state, err_status, err_msg), (...),]}
:rtype: dict
"""
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 an instance of MySQL is accessible.
Attempt a connection to the given 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: True if connection succeeds or False if not
:rtype: 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):
"""Custom assess status check.
Custom assess status check that validates connectivity to this unit's
MySQL instance.
Returns tuple of (sate, message), if there is a problem to report to
status output, or (None, None) if all is well.
:param self: Self
:type self: MySQLRouterCharm instance
:returns: Either (state, message) or (None, None)
:rtype: Union[tuple(str, str), tuple(None, None)]
"""
# 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):
"""Bootstrap MySQL Router.
Execute the mysqlrouter bootstrap command. MySQL Router bootstraps into
a working directory information it gathers from the MySQL InnoDB
Cluster about the cluster's schema. Configuration and working files
live in self.mysqlrouter_bin.
:param self: Self
:type self: MySQLRouterCharm instance
:side effect: Executes the mysqlrouter bootstrap command
:returns: This function is called for its side effect
:rtype: None
"""
cmd = [self.mysqlrouter_bin,
"--user", self.mysqlrouter_user,
"--bootstrap",
"{}:{}@{}".format(self.db_router_user,
self.db_router_password,
self.cluster_address),
"--directory", self.mysqlrouter_working_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):
"""Start MySQL Router.
Start up the mysqlrouter daemon via the start script.
:param self: Self
:type self: MySQLRouterCharm instance
:side effect: Executes the mysqlrouter start script
:returns: This function is called for its side effect
:rtype: None
"""
ch_core.host.service_start("mysqlrouter")
reactive.flags.set_flag(MYSQL_ROUTER_STARTED)
def stop_mysqlrouter(self):
"""Stop MySQL Router.
Stop the mysqlrouter daemon via the stop script.
:param self: Self
:type self: MySQLRouterCharm instance
:side effect: Executes the mysqlrouter stop script
:returns: This function is called for its side effect
:rtype: None
"""
ch_core.host.service_stop("mysqlrouter")
def restart_mysqlrouter(self):
"""Restart MySQL Router.
Restart the mysqlrouter daemon by calling self.stop_mysqlrouter and
self.start_mysqlrouter.
:param self: Self
:type self: MySQLRouterCharm instance
:side effect: Executes other functions
:returns: This function is called for its side effect
:rtype: None
"""
ch_core.host.service_restart("mysqlrouter")
def proxy_db_and_user_requests(
self, receiving_interface, sending_interface):
"""Proxy database and user requests to the MySQL InnoDB Cluster.
Take requests from the shared-db relation and proxy them to the
db-router relation using their respective endpoints.
:param self: Self
:type self: MySQLRouterCharm instance
:param receiving_interface: Shared-DB interface
:type receiving_interface: MySQLSharedProvides object
:param sending_interface: DB-Router interface
:type sending_interface: MySQLRouterRequires object
:side effect: Executes sending interface's set function
:returns: This function is called for its side effect
:rtype: None
"""
# 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):
"""Proxy database and user responses to clients.
Take responses from the db-router relation and proxy them to the
shared-db relation using their respective endpoints.
:param self: Self
:type self: MySQLRouterCharm instance
:param receiving_interface: DB-Router interface
:type receiving_interface: MySQLRouterRequires object
:param sending_interface: Shared-DB interface
:type sending_interface: MySQLSharedProvides object
:side effect: Executes sending interface's set function
:returns: This function is called for its side effect
:rtype: None
"""
# 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)