From dd1bb708d3fdac52d8e4d6a7ed027b1c0e6df3ba Mon Sep 17 00:00:00 2001 From: Andrew Klychkov Date: Sun, 12 Apr 2020 07:13:56 +0300 Subject: [PATCH] mysql_user: add resource_limits parameter (#142) * mysql_user: add resource_limits parameter * add CI test * added changelog fragment * skip opensuse * remove skip/suse --- ...ysql_user_add_resource_limit_parameter.yml | 2 + plugins/modules/database/mysql/mysql_user.py | 152 ++++++++++++++++++ .../targets/mysql_user/tasks/main.yml | 2 + .../mysql_user/tasks/resource_limits.yml | 112 +++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 changelogs/fragments/142-mysql_user_add_resource_limit_parameter.yml create mode 100644 tests/integration/targets/mysql_user/tasks/resource_limits.yml diff --git a/changelogs/fragments/142-mysql_user_add_resource_limit_parameter.yml b/changelogs/fragments/142-mysql_user_add_resource_limit_parameter.yml new file mode 100644 index 0000000000..bc742ab08c --- /dev/null +++ b/changelogs/fragments/142-mysql_user_add_resource_limit_parameter.yml @@ -0,0 +1,2 @@ +minor_changes: +- mysql_user - add the resource_limits parameter (https://github.com/ansible-collections/community.general/issues/133). diff --git a/plugins/modules/database/mysql/mysql_user.py b/plugins/modules/database/mysql/mysql_user.py index 7700c8c8ac..edf3f11468 100644 --- a/plugins/modules/database/mysql/mysql_user.py +++ b/plugins/modules/database/mysql/mysql_user.py @@ -101,6 +101,13 @@ options: description: - User's plugin auth_string (``CREATE USER user IDENTIFIED WITH plugin BY plugin_auth_string``). type: str + resource_limits: + description: + - Limit the user for certain server resources. Provided since MySQL 5.6 / MariaDB 10.2. + - "Available options are C(MAX_QUERIES_PER_HOUR: num), C(MAX_UPDATES_PER_HOUR: num), + C(MAX_CONNECTIONS_PER_HOUR: num), C(MAX_USER_CONNECTIONS: num)." + - Used when I(state=present), ignored otherwise. + type: dict notes: - "MySQL server installs with default login_user of 'root' and no password. To secure this user @@ -232,6 +239,13 @@ EXAMPLES = r''' priv: '*.*:ALL' state: present +- name: Limit bob's resources to 10 queries per hour and 5 connections per hour + mysql_user: + name: bob + resource_limits: + MAX_QUERIES_PER_HOUR: 10 + MAX_CONNECTIONS_PER_HOUR: 5 + # Example .my.cnf file for setting the root password # [client] # user=root @@ -672,6 +686,138 @@ def convert_priv_dict_to_str(priv): return '/'.join(priv_list) + +# Alter user is supported since MySQL 5.6 and MariaDB 10.2.0 +def server_supports_alter_user(cursor): + """Check if the server supports ALTER USER statement or doesn't. + + Args: + cursor (cursor): DB driver cursor object. + + Returns: True if supports, False otherwise. + """ + cursor.execute("SELECT VERSION()") + version_str = cursor.fetchone()[0] + version = version_str.split('.') + + if 'mariadb' in version_str.lower(): + # MariaDB 10.2 and later + if int(version[0]) * 1000 + int(version[1]) >= 10002: + return True + else: + return False + else: + # MySQL 5.6 and later + if int(version[0]) * 1000 + int(version[1]) >= 5006: + return True + else: + return False + + +def get_resource_limits(cursor, user, host): + """Get user resource limits. + + Args: + cursor (cursor): DB driver cursor object. + user (str): User name. + host (str): User host name. + + Returns: Dictionary containing current resource limits. + """ + + query = ('SELECT max_questions AS MAX_QUERIES_PER_HOUR, ' + 'max_updates AS MAX_UPDATES_PER_HOUR, ' + 'max_connections AS MAX_CONNECTIONS_PER_HOUR, ' + 'max_user_connections AS MAX_USER_CONNECTIONS ' + 'FROM mysql.user WHERE User = %s AND Host = %s') + cursor.execute(query, (user, host)) + res = cursor.fetchone() + + if not res: + return None + + current_limits = { + 'MAX_QUERIES_PER_HOUR': res[0], + 'MAX_UPDATES_PER_HOUR': res[1], + 'MAX_CONNECTIONS_PER_HOUR': res[2], + 'MAX_USER_CONNECTIONS': res[3], + } + return current_limits + + +def match_resource_limits(module, current, desired): + """Check and match limits. + + Args: + module (AnsibleModule): Ansible module object. + current (dict): Dictionary with current limits. + desired (dict): Dictionary with desired limits. + + Returns: Dictionary containing parameters that need to change. + """ + + if not current: + # It means the user does not exists, so we need + # to set all limits after its creation + return desired + + needs_to_change = {} + + for key, val in iteritems(desired): + if key not in current: + # Supported keys are listed in the documentation + # and must be determined in the get_resource_limits function + # (follow 'AS' keyword) + module.fail_json(msg="resource_limits: key '%s' is unsupported." % key) + + try: + val = int(val) + except Exception: + module.fail_json(msg="Can't convert value '%s' to integer." % val) + + if val != current.get(key): + needs_to_change[key] = val + + return needs_to_change + + +def limit_resources(module, cursor, user, host, resource_limits, check_mode): + """Limit user resources. + + Args: + module (AnsibleModule): Ansible module object. + cursor (cursor): DB driver cursor object. + user (str): User name. + host (str): User host name. + resource_limit (dict): Dictionary with desired limits. + check_mode (bool): Run the function in check mode or not. + + Returns: True, if changed, False otherwise. + """ + if not server_supports_alter_user(cursor): + module.fail_json(msg="The server version does not match the requirements " + "for resource_limits parameter. See module's documentation.") + + current_limits = get_resource_limits(cursor, user, host) + + needs_to_change = match_resource_limits(module, current_limits, resource_limits) + + if not needs_to_change: + return False + + if needs_to_change and check_mode: + return True + + # If not check_mode + tmp = [] + for key, val in iteritems(needs_to_change): + tmp.append('%s %s' % (key, val)) + + query = "ALTER USER %s@%s" + query += ' WITH %s' % ' '.join(tmp) + cursor.execute(query, (user, host)) + return True + # =========================================== # Module execution. # @@ -704,6 +850,7 @@ def main(): plugin=dict(default=None, type='str'), plugin_hash_string=dict(default=None, type='str'), plugin_auth_string=dict(default=None, type='str'), + resource_limits=dict(type='dict'), ), supports_check_mode=True, ) @@ -729,6 +876,7 @@ def main(): plugin = module.params["plugin"] plugin_hash_string = module.params["plugin_hash_string"] plugin_auth_string = module.params["plugin_auth_string"] + resource_limits = module.params["resource_limits"] if priv and not (isinstance(priv, str) or isinstance(priv, dict)): module.fail_json(msg="priv parameter must be str or dict but %s was passed" % type(priv)) @@ -793,6 +941,10 @@ def main(): except (SQLParseError, InvalidPrivsError, mysql_driver.Error) as e: module.fail_json(msg=to_native(e)) + + if resource_limits: + changed = limit_resources(module, cursor, user, host, resource_limits, module.check_mode) or changed + elif state == "absent": if user_exists(cursor, user, host, host_all): changed = user_delete(cursor, user, host, host_all, module.check_mode) diff --git a/tests/integration/targets/mysql_user/tasks/main.yml b/tests/integration/targets/mysql_user/tasks/main.yml index 7d2a10c014..9bc78a64cc 100644 --- a/tests/integration/targets/mysql_user/tasks/main.yml +++ b/tests/integration/targets/mysql_user/tasks/main.yml @@ -21,6 +21,8 @@ # - include: create_user.yml user_name={{user_name_1}} user_password={{ user_password_1 }} +- include: resource_limits.yml + - include: assert_user.yml user_name={{user_name_1}} - include: remove_user.yml user_name={{user_name_1}} user_password={{ user_password_1 }} diff --git a/tests/integration/targets/mysql_user/tasks/resource_limits.yml b/tests/integration/targets/mysql_user/tasks/resource_limits.yml new file mode 100644 index 0000000000..1e2928f0da --- /dev/null +++ b/tests/integration/targets/mysql_user/tasks/resource_limits.yml @@ -0,0 +1,112 @@ +# test code for resource_limits parameter + +- block: + + - name: Drop mysql user {{ user_name_1 }} if exists + mysql_user: + name: '{{ user_name_1 }}' + state: absent + login_unix_socket: '{{ mysql_socket }}' + + - name: Create mysql user {{ user_name_1 }} with resource limits in check_mode + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + state: present + login_unix_socket: '{{ mysql_socket }}' + resource_limits: + MAX_QUERIES_PER_HOUR: 10 + MAX_CONNECTIONS_PER_HOUR: 5 + check_mode: yes + register: result + + - assert: + that: + - result is changed + + - name: Create mysql user {{ user_name_1 }} with resource limits in actual mode + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + state: present + login_unix_socket: '{{ mysql_socket }}' + resource_limits: + MAX_QUERIES_PER_HOUR: 10 + MAX_CONNECTIONS_PER_HOUR: 5 + register: result + + - assert: + that: + - result is changed + + - name: Check + mysql_query: + query: > + SELECT User FROM mysql.user WHERE User = '{{ user_name_1 }}' AND Host = 'localhost' + AND max_questions = 10 AND max_connections = 5 + login_unix_socket: '{{ mysql_socket }}' + register: result + + - assert: + that: + - result.rowcount[0] == 1 + + - name: Try to set the same limits again in check mode + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + state: present + login_unix_socket: '{{ mysql_socket }}' + resource_limits: + MAX_QUERIES_PER_HOUR: 10 + MAX_CONNECTIONS_PER_HOUR: 5 + check_mode: yes + register: result + + - assert: + that: + - result is not changed + + - name: Try to set the same limits again in actual mode + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + state: present + login_unix_socket: '{{ mysql_socket }}' + resource_limits: + MAX_QUERIES_PER_HOUR: 10 + MAX_CONNECTIONS_PER_HOUR: 5 + register: result + + - assert: + that: + - result is not changed + + - name: Change limits + mysql_user: + name: '{{ user_name_1 }}' + password: '{{ user_password_1 }}' + state: present + login_unix_socket: '{{ mysql_socket }}' + resource_limits: + MAX_QUERIES_PER_HOUR: 5 + MAX_CONNECTIONS_PER_HOUR: 5 + register: result + + - assert: + that: + - result is changed + + - name: Check + mysql_query: + query: > + SELECT User FROM mysql.user WHERE User = '{{ user_name_1 }}' AND Host = 'localhost' + AND max_questions = 5 AND max_connections = 5 + login_unix_socket: '{{ mysql_socket }}' + register: result + + - assert: + that: + - result.rowcount[0] == 1 + + when: (ansible_distribution == 'Ubuntu' and ansible_distribution_major_version >= '18') or (ansible_distribution == 'CentOS' and ansible_distribution_major_version >= '8')