diff --git a/.github/workflows/ansible-test-plugins.yml b/.github/workflows/ansible-test-plugins.yml index f20d15e..30d0068 100644 --- a/.github/workflows/ansible-test-plugins.yml +++ b/.github/workflows/ansible-test-plugins.yml @@ -36,7 +36,7 @@ jobs: pull-request-change-detection: true integration: - name: "Integration (Python: ${{ matrix.python }}, Ansible: ${{ matrix.ansible }}, DB: ${{ matrix.db_engine_name }} ${{ matrix.db_engine_version }}, connector: ${{ matrix.connector_name }} ${{ matrix.connector_version }})" + name: "Integration (Python: ${{ matrix.python }}, Ansible: ${{ matrix.ansible }}, DB: ${{ matrix.db_engine_name }} ${{ matrix.db_engine_version }})" runs-on: ubuntu-22.04 strategy: fail-fast: false @@ -59,28 +59,8 @@ jobs: - '3.8' - '3.9' - '3.10' - connector_name: - - pymysql - - mysqlclient - connector_version: - - 0.7.11 - - 0.9.3 - - 1.0.2 - - 2.0.1 - - 2.0.3 - - 2.1.1 - include: - - python: '3.9' # RHEL9 uses 3.9 by default - connector_version: '0.10.1' # From RHEL package python3-PyMySQL - connector_name: pymysql - - - python: '3.11' - connector_version: '1.0.2' # From RHEL package python3.11-PyMySQL - connector_name: pymysql - - - python: '3.12' - connector_version: '1.1.0' # From RHEL package python3.12-PyMySQL - connector_name: pymysql + - '3.11' + - '3.12' exclude: - db_engine_name: mysql db_engine_version: 10.4.27 @@ -97,48 +77,6 @@ jobs: - db_engine_name: mariadb db_engine_version: 8.0.31 - - connector_name: pymysql - connector_version: 2.0.1 - - - connector_name: pymysql - connector_version: 2.0.3 - - - connector_name: pymysql - connector_version: 2.1.1 - - - connector_name: mysqlclient - connector_version: 0.7.11 - - - connector_name: mysqlclient - connector_version: 0.10.1 - - - connector_name: mysqlclient - connector_version: 0.9.3 - - - connector_name: mysqlclient - connector_version: 1.0.2 - - - connector_name: mysqlclient - connector_version: 1.1.0 - - - db_engine_name: mariadb - connector_version: 0.7.11 - - - db_engine_version: 5.7.40 - python: '3.9' - - - db_engine_version: 5.7.40 - python: '3.10' - - - db_engine_version: 5.7.40 - ansible: stable-2.15 - - - db_engine_version: 5.7.40 - ansible: stable-2.16 - - - db_engine_version: 5.7.40 - ansible: devel - - db_engine_version: 8.0.31 python: '3.8' @@ -154,39 +92,6 @@ jobs: - db_engine_version: 10.6.11 python: '3.9' - - python: '3.8' - connector_version: 1.0.2 - - - python: '3.8' - connector_version: 2.0.3 - - - python: '3.8' - connector_version: 2.1.1 - - - python: '3.9' - connector_version: 0.7.11 - - - python: '3.9' - connector_version: 1.0.2 - - - python: '3.9' - connector_version: 2.0.1 - - - python: '3.9' - connector_version: 2.1.1 - - - python: '3.10' - connector_version: 0.7.11 - - - python: '3.10' - connector_version: 0.9.3 - - - python: '3.10' - connector_version: 2.0.1 - - - python: '3.10' - connector_version: 2.0.3 - - python: '3.8' ansible: stable-2.16 @@ -270,37 +175,6 @@ jobs: ${{ job.services.db_primary.id }} | grep healthy && [[ "$SECONDS" -lt 120 ]]; do sleep 1; done - - name: Compute docker_image - Set python_version_flat - run: > - echo "python_version_flat=$(echo ${{ matrix.python }} - | tr -d '.')" >> $GITHUB_ENV - - - name: Compute docker_image - Set connector_version_flat - run: > - echo "connector_version_flat=$(echo ${{ matrix.connector_version }} - |tr -d .)" >> $GITHUB_ENV - - - name: Compute docker_image - Set db_engine_version_flat - run: > - echo "db_engine_version_flat=$(echo ${{ matrix.db_engine_version }} - | awk -F '.' '{print $1 $2}')" >> $GITHUB_ENV - - - name: Compute docker_image - Set db_client - run: > - if [[ ${{ env.db_engine_version_flat }} == 57 ]]; then - echo "db_client=my57" >> $GITHUB_ENV; - else - echo "db_client=$(echo ${{ matrix.db_engine_name }})" >> $GITHUB_ENV; - fi - - - name: Set docker_image - run: |- - echo "docker_image=ghcr.io/ansible-collections/community.mysql\ - /test-container-${{ env.db_client }}\ - -py${{ env.python_version_flat }}\ - -${{ matrix.connector_name }}${{ env.connector_version_flat }}\ - :latest" >> $GITHUB_ENV - - name: >- Perform integration testing against Ansible version ${{ matrix.ansible }} @@ -318,14 +192,6 @@ jobs: echo -n "${{ matrix.db_engine_version }}" > tests/integration/db_engine_version; - echo Setting Connector name to "${{ matrix.connector_name }}"...; - echo -n "${{ matrix.connector_name }}" - > tests/integration/connector_name; - - echo Setting Connector name to "${{ matrix.connector_version }}"...; - echo -n "${{ matrix.connector_version }}" - > tests/integration/connector_version; - echo Setting Python version to "${{ matrix.python }}"...; echo -n "${{ matrix.python }}" > tests/integration/python; @@ -333,7 +199,6 @@ jobs: echo Setting Ansible version to "${{ matrix.ansible }}"...; echo -n "${{ matrix.ansible }}" > tests/integration/ansible - docker-image: ${{ env.docker_image }} target-python-version: ${{ matrix.python }} testing-type: integration diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml deleted file mode 100644 index eec7cfe..0000000 --- a/.github/workflows/build-docker-image.yml +++ /dev/null @@ -1,242 +0,0 @@ ---- -name: Build Docker Image for ansible-test - -on: # yamllint disable-line rule:truthy - workflow_call: - inputs: - registry: - required: true - type: string - image_name: - required: true - type: string - context: - required: true - type: string - -jobs: - - build: - - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - strategy: - fail-fast: false - matrix: - include: - - from: ubuntu2004 - db_client: mariadb - python_minor: '8' - connector_name: pymysql - connector_major: '0' - connector_minor: '9' - connector_release: '3' - - - from: ubuntu2004 - db_client: mariadb - python_minor: '8' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '0' - connector_release: '1' - - - from: ubuntu2204 - db_client: mariadb - python_minor: '9' # RHEL9 uses 3.9 by default - connector_name: pymysql - # Same ver. as RHEL package python3-PyMySQL - connector_major: '0' - connector_minor: '10' - connector_release: '1' - - - from: ubuntu2004 - db_client: mariadb - python_minor: '9' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '0' - connector_release: '3' - - - from: ubuntu2204 - db_client: mariadb - python_minor: '10' - connector_name: pymysql - connector_major: '1' - connector_minor: '0' - connector_release: '2' - - - from: ubuntu2204 - db_client: mariadb - python_minor: '10' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '1' - connector_release: '1' - - - from: ubuntu2204 - db_client: mariadb - python_minor: '11' # RHEL9 uses 3.9 by default - connector_name: pymysql - # Same ver. as RHEL package python3.11-PyMySQL - connector_major: '1' - connector_minor: '0' - connector_release: '2' - - - from: ubuntu2204 - db_client: mariadb - python_minor: '12' # RHEL9 uses 3.9 by default - connector_name: pymysql - # Same ver. as RHEL package python3.12-PyMySQL - connector_major: '1' - connector_minor: '1' - connector_release: '0' - - - from: ubuntu2004 - db_client: mysql - python_minor: '8' - connector_name: pymysql - connector_major: '0' - connector_minor: '9' - connector_release: '3' - - - from: ubuntu2004 - db_client: mysql - python_minor: '8' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '0' - connector_release: '1' - - - from: ubuntu2004 - db_client: mysql - python_minor: '9' - connector_name: pymysql - connector_major: '0' - connector_minor: '10' - connector_release: '1' - - - from: ubuntu2004 - db_client: mysql - python_minor: '9' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '0' - connector_release: '3' - - - from: ubuntu2204 - db_client: mysql - python_minor: '10' - connector_name: pymysql - connector_major: '1' - connector_minor: '0' - connector_release: '2' - - - from: ubuntu2204 - db_client: mysql - python_minor: '10' - connector_name: mysqlclient - connector_major: '2' - connector_minor: '1' - connector_release: '1' - - - from: ubuntu2204 - db_client: mysql - python_minor: '11' # RHEL9 uses 3.9 by default - connector_name: pymysql - # Same ver. as RHEL package python3.11-PyMySQL - connector_major: '1' - connector_minor: '0' - connector_release: '2' - - - from: ubuntu2204 - db_client: mysql - python_minor: '12' # RHEL9 uses 3.9 by default - connector_name: pymysql - # Same ver. as RHEL package python3.12-PyMySQL - connector_major: '1' - connector_minor: '1' - connector_release: '0' - - env: - connector_version: - "${{ matrix.connector_major }}.\ - ${{ matrix.connector_minor }}.\ - ${{ matrix.connector_release }}" - - steps: - # Requirement to use 'context' in docker/build-push-action@v3 - - name: Checkout repository - uses: actions/checkout@v3 - - # https://github.com/docker/login-action - - name: Log into registry ghcr.io - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # https://github.com/docker/metadata-action - - name: Extract Docker metadata (tags, labels) - id: meta - uses: docker/metadata-action@v4 - with: - images: - " ghcr.io\ - /${{ github.repository }}\ - /test-container-${{ matrix.db_client }}-\ - py3${{ matrix.python_minor }}-\ - ${{ matrix.connector_name }}${{ matrix.connector_major }}\ - ${{ matrix.connector_minor }}${{ matrix.connector_release }}" - tags: latest - - # Setting up Docker Buildx with docker-container driver is required - # at the moment to be able to use a subdirectory with Git context - # - # https://github.com/docker/setup-buildx-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - # https://github.com/docker/build-push-action - - name: Build and push Docker image with Buildx - id: build-and-push - uses: docker/build-push-action@v3 - with: - context: | - FROM quay.io/ansible/${{ matrix.from }}-test-container:main - - RUN apt-get update -y && \ - DEBIAN_FRONTEND=noninteractive apt-get upgrade -y \ - --no-install-recommends && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends \ - python3${{ matrix.python_minor }} \ - python3${{ matrix.python_minor }}-dev \ - iproute2 \ - build-essential \ - - if [[ "${{ matrix.db_client }}" == "mysql" ]]; then - RUN DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends default-libmysqlclient-dev \ - mysql-client - else - RUN DEBIAN_FRONTEND=noninteractive apt-get install -y \ - --no-install-recommends mariadb-client - fi - - RUN python3${{ matrix.python_minor }} -m pip install \ - --disable-pip-version-check \ - --no-cache-dir \ - cffi \ - ${{ matrix.connector_name }}==$connector_version - - ENV container=docker - CMD ["/sbin/init"] - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/Makefile b/Makefile index 7ea0785..ea4fad0 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,6 @@ endif db_ver_tuple := $(subst ., , $(db_engine_version)) db_engine_version_flat := $(word 1, $(db_ver_tuple))$(word 2, $(db_ver_tuple)) -con_ver_tuple := $(subst ., , $(connector_version)) -connector_version_flat := $(word 1, $(con_ver_tuple))$(word 2, $(con_ver_tuple))$(word 3, $(con_ver_tuple)) - py_ver_tuple := $(subst ., , $(python)) python_version_flat := $(word 1, $(py_ver_tuple))$(word 2, $(py_ver_tuple)) @@ -32,8 +29,6 @@ endif test-integration: @echo -n $(db_engine_name) > tests/integration/db_engine_name @echo -n $(db_engine_version) > tests/integration/db_engine_version - @echo -n $(connector_name) > tests/integration/connector_name - @echo -n $(connector_version) > tests/integration/connector_version @echo -n $(python) > tests/integration/python @echo -n $(ansible) > tests/integration/ansible @@ -94,16 +89,13 @@ test-integration: https://github.com/ansible/ansible/archive/$(ansible).tar.gz; \ set -x; \ ansible-test integration $(target) -v --color --coverage --diff \ - --docker ghcr.io/ansible-collections/community.mysql/test-container\ - -$(db_client)-py$(python_version_flat)-$(connector_name)$(connector_version_flat):latest \ + --docker \ --docker-network podman $(_continue_on_errors) $(_keep_containers_alive) --python $(python); \ set +x # End of venv rm tests/integration/db_engine_name rm tests/integration/db_engine_version - rm tests/integration/connector_name - rm tests/integration/connector_version rm tests/integration/python rm tests/integration/ansible ifndef keep_containers_alive diff --git a/README.md b/README.md index 07af184..5b0bbee 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,10 @@ For MariaDB, only Long Term releases are tested. - mysqlclient 2.0.3 (only collection version >= 3.5.2) - mysqlclient 2.1.1 (only collection version >= 3.5.2) -## External requirements +Starting with 3.9.1, pymysql is now included. It means it is no longer necessary to install the connector on the controller or the controlled nodes. Here is the version included: -The MySQL modules rely on a MySQL connector. The list of supported drivers is below: - -- [PyMySQL](https://github.com/PyMySQL/PyMySQL) -- [mysqlclient](https://github.com/PyMySQL/mysqlclient) -- Support for other Python MySQL connectors may be added in a future release. +- community.mysql 3.9.1: pymysql 1.1.1 +s ## Using this collection diff --git a/TESTING.md b/TESTING.md index f31db4a..3e09011 100644 --- a/TESTING.md +++ b/TESTING.md @@ -26,14 +26,6 @@ For now, the makefile only supports Podman. - Minimum 2GB of RAM -### Custom ansible-test containers - -Our integrations tests use custom containers for ansible-test. Those images have their definition file stored in the directory [test-containers](test-containers/). We build and publish the images on ghcr.io under the ansible-collection namespace: E.G.: -`ghcr.io/ansible-collections/community.mysql/test-container-mariadb106-py310-mysqlclient211:latest`. - -Availables images are listed [here](https://github.com/orgs/ansible-collections/packages). - - ### Makefile options The Makefile accept the following options @@ -72,24 +64,6 @@ The Makefile accept the following options - "10.6.11" <- mariadb - Description: The tag of the container to use for the service containers that will host a primary database and two replicas. Do not use short version, like `mysql:8` (don't do that) because our tests expect a full version to filter tests precisely. For instance: `when: db_version is version ('8.0.22', '>')`. You can use any tag available on [hub.docker.com/_/mysql](https://hub.docker.com/_/mysql) and [hub.docker.com/_/mariadb](https://hub.docker.com/_/mariadb) but GitHub Action will only use the versions listed above. -- `connector_name` - - Mandatory: true - - Choices: - - "pymysql" - - "mysqlclient" - - Description: The python package of the connector to use. In addition to selecting the test container, this value is also used for tests filtering: `when: connector_name == 'pymysql'`. - -- `connector_version` - - Mandatory: true - - Choices: - - "0.7.11" <- pymysql (Only for MySQL 5.7) - - "0.9.3" <- pymysql - - "1.0.2" <- pymysql - - "2.0.1" <- mysqlclient - - "2.0.3" <- mysqlclient - - "2.1.1" <- mysqlclient - - Description: The version of the python package of the connector to use. This value is used to filter tests meant for other connectors. - - `python` - Mandatory: true - Choices: @@ -124,17 +98,17 @@ tests will overwrite the 3 databases containers so no need to kill them in advan ```sh # Run all targets -make ansible="stable-2.12" db_engine_name="mysql" db_engine_version="5.7.40" python="3.8" connector_name="pymysql" connector_version="0.7.11" +make ansible="stable-2.12" db_engine_name="mysql" db_engine_version="5.7.40" python="3.8" # A single target -make ansible="stable-2.14" db_engine_name="mysql" db_engine_version="5.7.40" python="3.8" connector_name="pymysql" connector_version="0.7.11" target="test_mysql_info" +make ansible="stable-2.14" db_engine_name="mysql" db_engine_version="5.7.40" python="3.8" target="test_mysql_info" # Keep databases and ansible tests containers alives # A single target and continue on errors -make ansible="stable-2.14" db_engine_name="mysql" db_engine_version="8.0.31" python="3.9" connector_name="mysqlclient" connector_version="2.0.3" target="test_mysql_query" keep_containers_alive=1 continue_on_errors=1 +make ansible="stable-2.14" db_engine_name="mysql" db_engine_version="8.0.31" python="3.9" target="test_mysql_query" keep_containers_alive=1 continue_on_errors=1 # If your system has an usupported version of Python: -make local_python_version="3.8" ansible="stable-2.14" db_engine_name="mariadb" db_engine_version="10.6.11" python="3.9" connector_name="pymysql" connector_version="0.9.3" +make local_python_version="3.8" ansible="stable-2.14" db_engine_name="mariadb" db_engine_version="10.6.11" python="3.9" ``` @@ -149,18 +123,10 @@ python run_all_tests.py ``` -### Add a new Python, Connector or Database version +### Add a new Python or Database version + +You can look into [.github/workflows/ansible-test-plugins.yml](https://github.com/ansible-collections/community.mysql/tree/main/.github/workflows) -You can look into [.github/workflows/ansible-test-plugins.yml](https://github.com/ansible-collections/community.mysql/tree/main/.github/workflows) to see how those containers are built using [build-docker-image.yml](https://github.com/ansible-collections/community.mysql/blob/main/.github/workflows/build-docker-image.yml) and all [docker-image-xxx.yml](https://github.com/ansible-collections/community.mysql/blob/main/.github/workflows/docker-image-mariadb103-py38-mysqlclient201.yml) files. -1. Add a workflow in [.github/workflows/](.github/workflows) -1. Add a new folder in [test-containers](test-containers) containing a new Dockerfile. Your container must contains 3 things: - - Python - - A connector: The python package to connect to the database (pymysql, mysqlclient, ...) - - A mysql client to prepare databases before our tests starts. This client must provide both `mysql` and `mysqldump` commands. 1. Add your version in the matrix of *.github/workflows/ansible-test-plugins.yml*. You can use [run_all_tests.py](run_all_tests.py) to help you see what the matrix will be. Simply comment out the line `os.system(make_cmd)` before runing the script. You can also add `print(len(matrix))` to display how many tests there will be on GitHub Action. -1. Ask the lead maintainer to mark your new image(s) as `public` under [https://github.com/orgs/ansible-collections/packages](https://github.com/orgs/ansible-collections/packages) -After pushing your commit to the remote, the container will be built and published on ghcr.io. Have a look in the "Action" tab to see if it worked. In case of error `failed to copy: io: read/write on closed pipe` re-run the workflow, this append unfortunately a lot. - -To see the docker image produced, go to the package page in the ansible-collection namespace [https://github.com/orgs/ansible-collections/packages](https://github.com/orgs/ansible-collections/packages). This page indicate a "Published x days ago" that is updated infrequently. To see the last time the container has been updated you must click on its title and look in the right hands side bellow the title "Last published". diff --git a/plugins/doc_fragments/mysql.py b/plugins/doc_fragments/mysql.py index 27ec650..fddfb70 100644 --- a/plugins/doc_fragments/mysql.py +++ b/plugins/doc_fragments/mysql.py @@ -74,19 +74,7 @@ options: - This option has no effect on MySQLdb. type: bool version_added: '1.1.0' -requirements: - - mysqlclient (Python 3.5+) or - - PyMySQL (Python 2.7 and Python 3.x) or - - MySQLdb (Python 2.x) notes: - - Requires the PyMySQL (Python 2.7 and Python 3.X) or MySQL-python (Python 2.X) package installed on the remote host. - The Python package may be installed with apt-get install python-pymysql (Ubuntu; see M(ansible.builtin.apt)) or - yum install python2-PyMySQL (RHEL/CentOS/Fedora; see M(ansible.builtin.yum)). You can also use dnf install python2-PyMySQL - for newer versions of Fedora; see M(ansible.builtin.dnf). - - Be sure you have mysqlclient, PyMySQL, or MySQLdb library installed on the target machine - for the Python interpreter Ansible discovers. For example if ansible discovers and uses Python 3, you need to install - the Python 3 version of PyMySQL or mysqlclient. If ansible discovers and uses Python 2, you need to install the Python 2 - version of either PyMySQL or MySQL-python. - If you have trouble, it may help to force Ansible to use the Python interpreter you need by specifying C(ansible_python_interpreter). For more information, see U(https://docs.ansible.com/ansible/latest/reference_appendices/interpreter_discovery.html). @@ -99,9 +87,6 @@ notes: and later uses the unix_socket authentication plugin by default that without using I(login_unix_socket=/var/run/mysqld/mysqld.sock) (the default path) causes the error ``Host '127.0.0.1' is not allowed to connect to this MariaDB server``. - - Alternatively, you can use the mysqlclient library instead of MySQL-python (MySQLdb) - which supports both Python 2.X and Python >=3.5. - See U(https://pypi.org/project/mysqlclient/) how to install it. - "If credentials from the config file (for example, C(/root/.my.cnf)) are not needed to connect to a database server, but the file exists and does not contain a C([client]) section, before any other valid directives, it will be read and this will cause the connection to fail, to prevent this set it to an empty string, (for example C(config_file: ''))." diff --git a/plugins/module_utils/mysql.py b/plugins/module_utils/mysql.py index 10ccfcf..6af7800 100644 --- a/plugins/module_utils/mysql.py +++ b/plugins/module_utils/mysql.py @@ -17,64 +17,11 @@ import os from ansible.module_utils.six.moves import configparser from ansible.module_utils._text import to_native - -try: - import pymysql as mysql_driver - _mysql_cursor_param = 'cursor' -except ImportError: - try: - # mysqlclient is called MySQLdb - import MySQLdb as mysql_driver - import MySQLdb.cursors - _mysql_cursor_param = 'cursorclass' - except ImportError: - mysql_driver = None - -mysql_driver_fail_msg = ('A MySQL module is required: for Python 2.7 either PyMySQL, or ' - 'MySQL-python, or for Python 3.X mysqlclient or PyMySQL. ' - 'Consider setting ansible_python_interpreter to use ' - 'the intended Python version.') +from ansible_collections.community.mysql.plugins.module_utils import pymysql as mysql_driver from ansible_collections.community.mysql.plugins.module_utils.database import mysql_quote_identifier -def get_connector_name(connector): - """ (class) -> str - Return the name of the connector (pymysql or mysqlclient (MySQLdb)) - or 'Unknown' if not pymysql or MySQLdb. When adding a - connector here, also modify get_connector_version. - """ - if connector is None or not hasattr(connector, '__name__'): - return 'Unknown' - - return connector.__name__ - - -def get_connector_version(connector): - """ (class) -> str - Return the version of pymysql or mysqlclient (MySQLdb). - Return 'Unknown' if the connector name is unknown. - """ - - if connector is None: - return 'Unknown' - - connector_name = get_connector_name(connector) - - if connector_name == 'pymysql': - # pymysql has two methods: - # - __version__ that returns the string: 0.7.11.None - # - VERSION that returns the tuple (0, 7, 11, None) - v = connector.VERSION[:3] - return '.'.join(map(str, v)) - elif connector_name == 'MySQLdb': - # version_info returns the tuple (2, 1, 1, 'final', 0) - v = connector.version_info[:3] - return '.'.join(map(str, v)) - else: - return 'Unknown' - - def parse_from_mysql_config_file(cnf): # Default values of comment_prefix is '#' and ';'. # '!' added to prevent a parsing error @@ -134,38 +81,10 @@ def mysql_connect(module, login_user=None, login_password=None, config_file='', if connect_timeout is not None: config['connect_timeout'] = connect_timeout if check_hostname is not None: - if get_connector_name(mysql_driver) == 'pymysql': - version_tuple = (n for n in mysql_driver.__version__.split('.') if n != 'None') - if reduce(lambda x, y: int(x) * 100 + int(y), version_tuple) >= 711: - config['ssl']['check_hostname'] = check_hostname - else: - module.fail_json(msg='To use check_hostname, pymysql >= 0.7.11 is required on the target host') - - if get_connector_name(mysql_driver) == 'pymysql': - # In case of PyMySQL driver: - if mysql_driver.version_info[0] < 1: - # for PyMySQL < 1.0.0, use 'db' instead of 'database' and 'passwd' instead of 'password' - if 'database' in config: - config['db'] = config['database'] - del config['database'] - if 'password' in config: - config['passwd'] = config['password'] - del config['password'] - db_connection = mysql_driver.connect(autocommit=autocommit, **config) - else: - # In case of MySQLdb driver - if mysql_driver.version_info[0] < 2 or (mysql_driver.version_info[0] == 2 and mysql_driver.version_info[1] < 1): - # for MySQLdb < 2.1.0, use 'db' instead of 'database' and 'passwd' instead of 'password' - if 'database' in config: - config['db'] = config['database'] - del config['database'] - if 'password' in config: - config['passwd'] = config['password'] - del config['password'] - db_connection = mysql_driver.connect(**config) - if autocommit: - db_connection.autocommit(True) + config['ssl']['check_hostname'] = check_hostname + db_connection = mysql_driver.connect(autocommit=autocommit, **config) + # Monkey patch the Connection class to close the connection when garbage collected def _conn_patch(conn_self): conn_self.close() @@ -173,7 +92,7 @@ def mysql_connect(module, login_user=None, login_password=None, config_file='', # Patched if cursor_class == 'DictCursor': - return db_connection.cursor(**{_mysql_cursor_param: mysql_driver.cursors.DictCursor}), db_connection + return db_connection.cursor(**{'cursor': mysql_driver.cursors.DictCursor}), db_connection else: return db_connection.cursor(), db_connection diff --git a/plugins/module_utils/pymysql/__init__.py b/plugins/module_utils/pymysql/__init__.py new file mode 100644 index 0000000..bbf9023 --- /dev/null +++ b/plugins/module_utils/pymysql/__init__.py @@ -0,0 +1,183 @@ +""" +PyMySQL: A pure-Python MySQL client library. + +Copyright (c) 2010-2016 PyMySQL contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +import sys + +from .constants import FIELD_TYPE +from .err import ( + Warning, + Error, + InterfaceError, + DataError, + DatabaseError, + OperationalError, + IntegrityError, + InternalError, + NotSupportedError, + ProgrammingError, + MySQLError, +) +from .times import ( + Date, + Time, + Timestamp, + DateFromTicks, + TimeFromTicks, + TimestampFromTicks, +) + +# PyMySQL version. +# Used by setuptools and connection_attrs +VERSION = (1, 1, 1, "final", 1) +VERSION_STRING = "1.1.1" + +### for mysqlclient compatibility +### Django checks mysqlclient version. +version_info = (1, 4, 6, "final", 1) +__version__ = "1.4.6" + + +def get_client_info(): # for MySQLdb compatibility + return __version__ + + +def install_as_MySQLdb(): + """ + After this function is called, any application that imports MySQLdb + will unwittingly actually use pymysql. + """ + sys.modules["MySQLdb"] = sys.modules["pymysql"] + + +# end of mysqlclient compatibility code + +threadsafety = 1 +apilevel = "2.0" +paramstyle = "pyformat" + +from . import connections # noqa: E402 + + +class DBAPISet(frozenset): + def __ne__(self, other): + if isinstance(other, set): + return frozenset.__ne__(self, other) + else: + return other not in self + + def __eq__(self, other): + if isinstance(other, frozenset): + return frozenset.__eq__(self, other) + else: + return other in self + + def __hash__(self): + return frozenset.__hash__(self) + + +STRING = DBAPISet([FIELD_TYPE.ENUM, FIELD_TYPE.STRING, FIELD_TYPE.VAR_STRING]) +BINARY = DBAPISet( + [ + FIELD_TYPE.BLOB, + FIELD_TYPE.LONG_BLOB, + FIELD_TYPE.MEDIUM_BLOB, + FIELD_TYPE.TINY_BLOB, + ] +) +NUMBER = DBAPISet( + [ + FIELD_TYPE.DECIMAL, + FIELD_TYPE.DOUBLE, + FIELD_TYPE.FLOAT, + FIELD_TYPE.INT24, + FIELD_TYPE.LONG, + FIELD_TYPE.LONGLONG, + FIELD_TYPE.TINY, + FIELD_TYPE.YEAR, + ] +) +DATE = DBAPISet([FIELD_TYPE.DATE, FIELD_TYPE.NEWDATE]) +TIME = DBAPISet([FIELD_TYPE.TIME]) +TIMESTAMP = DBAPISet([FIELD_TYPE.TIMESTAMP, FIELD_TYPE.DATETIME]) +DATETIME = TIMESTAMP +ROWID = DBAPISet() + + +def Binary(x): + """Return x as a binary type.""" + return bytes(x) + + +def thread_safe(): + return True # match MySQLdb.thread_safe() + + +Connect = connect = Connection = connections.Connection +NULL = "NULL" + + +__all__ = [ + "BINARY", + "Binary", + "Connect", + "Connection", + "DATE", + "Date", + "Time", + "Timestamp", + "DateFromTicks", + "TimeFromTicks", + "TimestampFromTicks", + "DataError", + "DatabaseError", + "Error", + "FIELD_TYPE", + "IntegrityError", + "InterfaceError", + "InternalError", + "MySQLError", + "NULL", + "NUMBER", + "NotSupportedError", + "DBAPISet", + "OperationalError", + "ProgrammingError", + "ROWID", + "STRING", + "TIME", + "TIMESTAMP", + "Warning", + "apilevel", + "connect", + "connections", + "constants", + "converters", + "cursors", + "get_client_info", + "paramstyle", + "threadsafety", + "version_info", + "install_as_MySQLdb", + "__version__", +] diff --git a/plugins/module_utils/pymysql/_auth.py b/plugins/module_utils/pymysql/_auth.py new file mode 100644 index 0000000..8ce744f --- /dev/null +++ b/plugins/module_utils/pymysql/_auth.py @@ -0,0 +1,268 @@ +""" +Implements auth methods +""" + +from .err import OperationalError + + +try: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives import serialization, hashes + from cryptography.hazmat.primitives.asymmetric import padding + + _have_cryptography = True +except ImportError: + _have_cryptography = False + +from functools import partial +import hashlib + + +DEBUG = False +SCRAMBLE_LENGTH = 20 +sha1_new = partial(hashlib.new, "sha1") + + +# mysql_native_password +# https://dev.mysql.com/doc/internals/en/secure-password-authentication.html#packet-Authentication::Native41 + + +def scramble_native_password(password, message): + """Scramble used for mysql_native_password""" + if not password: + return b"" + + stage1 = sha1_new(password).digest() + stage2 = sha1_new(stage1).digest() + s = sha1_new() + s.update(message[:SCRAMBLE_LENGTH]) + s.update(stage2) + result = s.digest() + return _my_crypt(result, stage1) + + +def _my_crypt(message1, message2): + result = bytearray(message1) + + for i in range(len(result)): + result[i] ^= message2[i] + + return bytes(result) + + +# MariaDB's client_ed25519-plugin +# https://mariadb.com/kb/en/library/connection/#client_ed25519-plugin + +_nacl_bindings = False + + +def _init_nacl(): + global _nacl_bindings + try: + from nacl import bindings + + _nacl_bindings = bindings + except ImportError: + raise RuntimeError( + "'pynacl' package is required for ed25519_password auth method" + ) + + +def _scalar_clamp(s32): + ba = bytearray(s32) + ba0 = bytes(bytearray([ba[0] & 248])) + ba31 = bytes(bytearray([(ba[31] & 127) | 64])) + return ba0 + bytes(s32[1:31]) + ba31 + + +def ed25519_password(password, scramble): + """Sign a random scramble with elliptic curve Ed25519. + + Secret and public key are derived from password. + """ + # variable names based on rfc8032 section-5.1.6 + # + if not _nacl_bindings: + _init_nacl() + + # h = SHA512(password) + h = hashlib.sha512(password).digest() + + # s = prune(first_half(h)) + s = _scalar_clamp(h[:32]) + + # r = SHA512(second_half(h) || M) + r = hashlib.sha512(h[32:] + scramble).digest() + + # R = encoded point [r]B + r = _nacl_bindings.crypto_core_ed25519_scalar_reduce(r) + R = _nacl_bindings.crypto_scalarmult_ed25519_base_noclamp(r) + + # A = encoded point [s]B + A = _nacl_bindings.crypto_scalarmult_ed25519_base_noclamp(s) + + # k = SHA512(R || A || M) + k = hashlib.sha512(R + A + scramble).digest() + + # S = (k * s + r) mod L + k = _nacl_bindings.crypto_core_ed25519_scalar_reduce(k) + ks = _nacl_bindings.crypto_core_ed25519_scalar_mul(k, s) + S = _nacl_bindings.crypto_core_ed25519_scalar_add(ks, r) + + # signature = R || S + return R + S + + +# sha256_password + + +def _roundtrip(conn, send_data): + conn.write_packet(send_data) + pkt = conn._read_packet() + pkt.check_error() + return pkt + + +def _xor_password(password, salt): + # Trailing NUL character will be added in Auth Switch Request. + # See https://github.com/mysql/mysql-server/blob/7d10c82196c8e45554f27c00681474a9fb86d137/sql/auth/sha2_password.cc#L939-L945 + salt = salt[:SCRAMBLE_LENGTH] + password_bytes = bytearray(password) + # salt = bytearray(salt) # for PY2 compat. + salt_len = len(salt) + for i in range(len(password_bytes)): + password_bytes[i] ^= salt[i % salt_len] + return bytes(password_bytes) + + +def sha2_rsa_encrypt(password, salt, public_key): + """Encrypt password with salt and public_key. + + Used for sha256_password and caching_sha2_password. + """ + if not _have_cryptography: + raise RuntimeError( + "'cryptography' package is required for sha256_password or" + + " caching_sha2_password auth methods" + ) + message = _xor_password(password + b"\0", salt) + rsa_key = serialization.load_pem_public_key(public_key, default_backend()) + return rsa_key.encrypt( + message, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA1()), + algorithm=hashes.SHA1(), + label=None, + ), + ) + + +def sha256_password_auth(conn, pkt): + if conn._secure: + if DEBUG: + print("sha256: Sending plain password") + data = conn.password + b"\0" + return _roundtrip(conn, data) + + if pkt.is_auth_switch_request(): + conn.salt = pkt.read_all() + if not conn.server_public_key and conn.password: + # Request server public key + if DEBUG: + print("sha256: Requesting server public key") + pkt = _roundtrip(conn, b"\1") + + if pkt.is_extra_auth_data(): + conn.server_public_key = pkt._data[1:] + if DEBUG: + print("Received public key:\n", conn.server_public_key.decode("ascii")) + + if conn.password: + if not conn.server_public_key: + raise OperationalError("Couldn't receive server's public key") + + data = sha2_rsa_encrypt(conn.password, conn.salt, conn.server_public_key) + else: + data = b"" + + return _roundtrip(conn, data) + + +def scramble_caching_sha2(password, nonce): + # (bytes, bytes) -> bytes + """Scramble algorithm used in cached_sha2_password fast path. + + XOR(SHA256(password), SHA256(SHA256(SHA256(password)), nonce)) + """ + if not password: + return b"" + + p1 = hashlib.sha256(password).digest() + p2 = hashlib.sha256(p1).digest() + p3 = hashlib.sha256(p2 + nonce).digest() + + res = bytearray(p1) + for i in range(len(p3)): + res[i] ^= p3[i] + + return bytes(res) + + +def caching_sha2_password_auth(conn, pkt): + # No password fast path + if not conn.password: + return _roundtrip(conn, b"") + + if pkt.is_auth_switch_request(): + # Try from fast auth + if DEBUG: + print("caching sha2: Trying fast path") + conn.salt = pkt.read_all() + scrambled = scramble_caching_sha2(conn.password, conn.salt) + pkt = _roundtrip(conn, scrambled) + # else: fast auth is tried in initial handshake + + if not pkt.is_extra_auth_data(): + raise OperationalError( + "caching sha2: Unknown packet for fast auth: %s" % pkt._data[:1] + ) + + # magic numbers: + # 2 - request public key + # 3 - fast auth succeeded + # 4 - need full auth + + pkt.advance(1) + n = pkt.read_uint8() + + if n == 3: + if DEBUG: + print("caching sha2: succeeded by fast path.") + pkt = conn._read_packet() + pkt.check_error() # pkt must be OK packet + return pkt + + if n != 4: + raise OperationalError("caching sha2: Unknown result for fast auth: %s" % n) + + if DEBUG: + print("caching sha2: Trying full auth...") + + if conn._secure: + if DEBUG: + print("caching sha2: Sending plain password via secure connection") + return _roundtrip(conn, conn.password + b"\0") + + if not conn.server_public_key: + pkt = _roundtrip(conn, b"\x02") # Request public key + if not pkt.is_extra_auth_data(): + raise OperationalError( + "caching sha2: Unknown packet for public key: %s" % pkt._data[:1] + ) + + conn.server_public_key = pkt._data[1:] + if DEBUG: + print(conn.server_public_key.decode("ascii")) + + data = sha2_rsa_encrypt(conn.password, conn.salt, conn.server_public_key) + pkt = _roundtrip(conn, data) diff --git a/plugins/module_utils/pymysql/charset.py b/plugins/module_utils/pymysql/charset.py new file mode 100644 index 0000000..b1c1ca8 --- /dev/null +++ b/plugins/module_utils/pymysql/charset.py @@ -0,0 +1,216 @@ +# Internal use only. Do not use directly. + +MBLENGTH = {8: 1, 33: 3, 88: 2, 91: 2} + + +class Charset: + def __init__(self, id, name, collation, is_default=False): + self.id, self.name, self.collation = id, name, collation + self.is_default = is_default + + def __repr__(self): + return ( + f"Charset(id={self.id}, name={self.name!r}, collation={self.collation!r})" + ) + + @property + def encoding(self): + name = self.name + if name in ("utf8mb4", "utf8mb3"): + return "utf8" + if name == "latin1": + return "cp1252" + if name == "koi8r": + return "koi8_r" + if name == "koi8u": + return "koi8_u" + return name + + @property + def is_binary(self): + return self.id == 63 + + +class Charsets: + def __init__(self): + self._by_id = {} + self._by_name = {} + + def add(self, c): + self._by_id[c.id] = c + if c.is_default: + self._by_name[c.name] = c + + def by_id(self, id): + return self._by_id[id] + + def by_name(self, name): + if name == "utf8": + name = "utf8mb4" + return self._by_name.get(name.lower()) + + +_charsets = Charsets() +charset_by_name = _charsets.by_name +charset_by_id = _charsets.by_id + +""" +TODO: update this script. + +Generated with: + +mysql -N -s -e "select id, character_set_name, collation_name, is_default +from information_schema.collations order by id;" | python -c "import sys +for l in sys.stdin.readlines(): + id, name, collation, is_default = l.split(chr(9)) + if is_default.strip() == "Yes": + print('_charsets.add(Charset(%s, \'%s\', \'%s\', True))' \ + % (id, name, collation)) + else: + print('_charsets.add(Charset(%s, \'%s\', \'%s\'))' \ + % (id, name, collation, bool(is_default.strip())) +""" + +_charsets.add(Charset(1, "big5", "big5_chinese_ci", True)) +_charsets.add(Charset(2, "latin2", "latin2_czech_cs")) +_charsets.add(Charset(3, "dec8", "dec8_swedish_ci", True)) +_charsets.add(Charset(4, "cp850", "cp850_general_ci", True)) +_charsets.add(Charset(5, "latin1", "latin1_german1_ci")) +_charsets.add(Charset(6, "hp8", "hp8_english_ci", True)) +_charsets.add(Charset(7, "koi8r", "koi8r_general_ci", True)) +_charsets.add(Charset(8, "latin1", "latin1_swedish_ci", True)) +_charsets.add(Charset(9, "latin2", "latin2_general_ci", True)) +_charsets.add(Charset(10, "swe7", "swe7_swedish_ci", True)) +_charsets.add(Charset(11, "ascii", "ascii_general_ci", True)) +_charsets.add(Charset(12, "ujis", "ujis_japanese_ci", True)) +_charsets.add(Charset(13, "sjis", "sjis_japanese_ci", True)) +_charsets.add(Charset(14, "cp1251", "cp1251_bulgarian_ci")) +_charsets.add(Charset(15, "latin1", "latin1_danish_ci")) +_charsets.add(Charset(16, "hebrew", "hebrew_general_ci", True)) +_charsets.add(Charset(18, "tis620", "tis620_thai_ci", True)) +_charsets.add(Charset(19, "euckr", "euckr_korean_ci", True)) +_charsets.add(Charset(20, "latin7", "latin7_estonian_cs")) +_charsets.add(Charset(21, "latin2", "latin2_hungarian_ci")) +_charsets.add(Charset(22, "koi8u", "koi8u_general_ci", True)) +_charsets.add(Charset(23, "cp1251", "cp1251_ukrainian_ci")) +_charsets.add(Charset(24, "gb2312", "gb2312_chinese_ci", True)) +_charsets.add(Charset(25, "greek", "greek_general_ci", True)) +_charsets.add(Charset(26, "cp1250", "cp1250_general_ci", True)) +_charsets.add(Charset(27, "latin2", "latin2_croatian_ci")) +_charsets.add(Charset(28, "gbk", "gbk_chinese_ci", True)) +_charsets.add(Charset(29, "cp1257", "cp1257_lithuanian_ci")) +_charsets.add(Charset(30, "latin5", "latin5_turkish_ci", True)) +_charsets.add(Charset(31, "latin1", "latin1_german2_ci")) +_charsets.add(Charset(32, "armscii8", "armscii8_general_ci", True)) +_charsets.add(Charset(33, "utf8mb3", "utf8mb3_general_ci", True)) +_charsets.add(Charset(34, "cp1250", "cp1250_czech_cs")) +_charsets.add(Charset(36, "cp866", "cp866_general_ci", True)) +_charsets.add(Charset(37, "keybcs2", "keybcs2_general_ci", True)) +_charsets.add(Charset(38, "macce", "macce_general_ci", True)) +_charsets.add(Charset(39, "macroman", "macroman_general_ci", True)) +_charsets.add(Charset(40, "cp852", "cp852_general_ci", True)) +_charsets.add(Charset(41, "latin7", "latin7_general_ci", True)) +_charsets.add(Charset(42, "latin7", "latin7_general_cs")) +_charsets.add(Charset(43, "macce", "macce_bin")) +_charsets.add(Charset(44, "cp1250", "cp1250_croatian_ci")) +_charsets.add(Charset(45, "utf8mb4", "utf8mb4_general_ci", True)) +_charsets.add(Charset(46, "utf8mb4", "utf8mb4_bin")) +_charsets.add(Charset(47, "latin1", "latin1_bin")) +_charsets.add(Charset(48, "latin1", "latin1_general_ci")) +_charsets.add(Charset(49, "latin1", "latin1_general_cs")) +_charsets.add(Charset(50, "cp1251", "cp1251_bin")) +_charsets.add(Charset(51, "cp1251", "cp1251_general_ci", True)) +_charsets.add(Charset(52, "cp1251", "cp1251_general_cs")) +_charsets.add(Charset(53, "macroman", "macroman_bin")) +_charsets.add(Charset(57, "cp1256", "cp1256_general_ci", True)) +_charsets.add(Charset(58, "cp1257", "cp1257_bin")) +_charsets.add(Charset(59, "cp1257", "cp1257_general_ci", True)) +_charsets.add(Charset(63, "binary", "binary", True)) +_charsets.add(Charset(64, "armscii8", "armscii8_bin")) +_charsets.add(Charset(65, "ascii", "ascii_bin")) +_charsets.add(Charset(66, "cp1250", "cp1250_bin")) +_charsets.add(Charset(67, "cp1256", "cp1256_bin")) +_charsets.add(Charset(68, "cp866", "cp866_bin")) +_charsets.add(Charset(69, "dec8", "dec8_bin")) +_charsets.add(Charset(70, "greek", "greek_bin")) +_charsets.add(Charset(71, "hebrew", "hebrew_bin")) +_charsets.add(Charset(72, "hp8", "hp8_bin")) +_charsets.add(Charset(73, "keybcs2", "keybcs2_bin")) +_charsets.add(Charset(74, "koi8r", "koi8r_bin")) +_charsets.add(Charset(75, "koi8u", "koi8u_bin")) +_charsets.add(Charset(76, "utf8mb3", "utf8mb3_tolower_ci")) +_charsets.add(Charset(77, "latin2", "latin2_bin")) +_charsets.add(Charset(78, "latin5", "latin5_bin")) +_charsets.add(Charset(79, "latin7", "latin7_bin")) +_charsets.add(Charset(80, "cp850", "cp850_bin")) +_charsets.add(Charset(81, "cp852", "cp852_bin")) +_charsets.add(Charset(82, "swe7", "swe7_bin")) +_charsets.add(Charset(83, "utf8mb3", "utf8mb3_bin")) +_charsets.add(Charset(84, "big5", "big5_bin")) +_charsets.add(Charset(85, "euckr", "euckr_bin")) +_charsets.add(Charset(86, "gb2312", "gb2312_bin")) +_charsets.add(Charset(87, "gbk", "gbk_bin")) +_charsets.add(Charset(88, "sjis", "sjis_bin")) +_charsets.add(Charset(89, "tis620", "tis620_bin")) +_charsets.add(Charset(91, "ujis", "ujis_bin")) +_charsets.add(Charset(92, "geostd8", "geostd8_general_ci", True)) +_charsets.add(Charset(93, "geostd8", "geostd8_bin")) +_charsets.add(Charset(94, "latin1", "latin1_spanish_ci")) +_charsets.add(Charset(95, "cp932", "cp932_japanese_ci", True)) +_charsets.add(Charset(96, "cp932", "cp932_bin")) +_charsets.add(Charset(97, "eucjpms", "eucjpms_japanese_ci", True)) +_charsets.add(Charset(98, "eucjpms", "eucjpms_bin")) +_charsets.add(Charset(99, "cp1250", "cp1250_polish_ci")) +_charsets.add(Charset(192, "utf8mb3", "utf8mb3_unicode_ci")) +_charsets.add(Charset(193, "utf8mb3", "utf8mb3_icelandic_ci")) +_charsets.add(Charset(194, "utf8mb3", "utf8mb3_latvian_ci")) +_charsets.add(Charset(195, "utf8mb3", "utf8mb3_romanian_ci")) +_charsets.add(Charset(196, "utf8mb3", "utf8mb3_slovenian_ci")) +_charsets.add(Charset(197, "utf8mb3", "utf8mb3_polish_ci")) +_charsets.add(Charset(198, "utf8mb3", "utf8mb3_estonian_ci")) +_charsets.add(Charset(199, "utf8mb3", "utf8mb3_spanish_ci")) +_charsets.add(Charset(200, "utf8mb3", "utf8mb3_swedish_ci")) +_charsets.add(Charset(201, "utf8mb3", "utf8mb3_turkish_ci")) +_charsets.add(Charset(202, "utf8mb3", "utf8mb3_czech_ci")) +_charsets.add(Charset(203, "utf8mb3", "utf8mb3_danish_ci")) +_charsets.add(Charset(204, "utf8mb3", "utf8mb3_lithuanian_ci")) +_charsets.add(Charset(205, "utf8mb3", "utf8mb3_slovak_ci")) +_charsets.add(Charset(206, "utf8mb3", "utf8mb3_spanish2_ci")) +_charsets.add(Charset(207, "utf8mb3", "utf8mb3_roman_ci")) +_charsets.add(Charset(208, "utf8mb3", "utf8mb3_persian_ci")) +_charsets.add(Charset(209, "utf8mb3", "utf8mb3_esperanto_ci")) +_charsets.add(Charset(210, "utf8mb3", "utf8mb3_hungarian_ci")) +_charsets.add(Charset(211, "utf8mb3", "utf8mb3_sinhala_ci")) +_charsets.add(Charset(212, "utf8mb3", "utf8mb3_german2_ci")) +_charsets.add(Charset(213, "utf8mb3", "utf8mb3_croatian_ci")) +_charsets.add(Charset(214, "utf8mb3", "utf8mb3_unicode_520_ci")) +_charsets.add(Charset(215, "utf8mb3", "utf8mb3_vietnamese_ci")) +_charsets.add(Charset(223, "utf8mb3", "utf8mb3_general_mysql500_ci")) +_charsets.add(Charset(224, "utf8mb4", "utf8mb4_unicode_ci")) +_charsets.add(Charset(225, "utf8mb4", "utf8mb4_icelandic_ci")) +_charsets.add(Charset(226, "utf8mb4", "utf8mb4_latvian_ci")) +_charsets.add(Charset(227, "utf8mb4", "utf8mb4_romanian_ci")) +_charsets.add(Charset(228, "utf8mb4", "utf8mb4_slovenian_ci")) +_charsets.add(Charset(229, "utf8mb4", "utf8mb4_polish_ci")) +_charsets.add(Charset(230, "utf8mb4", "utf8mb4_estonian_ci")) +_charsets.add(Charset(231, "utf8mb4", "utf8mb4_spanish_ci")) +_charsets.add(Charset(232, "utf8mb4", "utf8mb4_swedish_ci")) +_charsets.add(Charset(233, "utf8mb4", "utf8mb4_turkish_ci")) +_charsets.add(Charset(234, "utf8mb4", "utf8mb4_czech_ci")) +_charsets.add(Charset(235, "utf8mb4", "utf8mb4_danish_ci")) +_charsets.add(Charset(236, "utf8mb4", "utf8mb4_lithuanian_ci")) +_charsets.add(Charset(237, "utf8mb4", "utf8mb4_slovak_ci")) +_charsets.add(Charset(238, "utf8mb4", "utf8mb4_spanish2_ci")) +_charsets.add(Charset(239, "utf8mb4", "utf8mb4_roman_ci")) +_charsets.add(Charset(240, "utf8mb4", "utf8mb4_persian_ci")) +_charsets.add(Charset(241, "utf8mb4", "utf8mb4_esperanto_ci")) +_charsets.add(Charset(242, "utf8mb4", "utf8mb4_hungarian_ci")) +_charsets.add(Charset(243, "utf8mb4", "utf8mb4_sinhala_ci")) +_charsets.add(Charset(244, "utf8mb4", "utf8mb4_german2_ci")) +_charsets.add(Charset(245, "utf8mb4", "utf8mb4_croatian_ci")) +_charsets.add(Charset(246, "utf8mb4", "utf8mb4_unicode_520_ci")) +_charsets.add(Charset(247, "utf8mb4", "utf8mb4_vietnamese_ci")) +_charsets.add(Charset(248, "gb18030", "gb18030_chinese_ci", True)) +_charsets.add(Charset(249, "gb18030", "gb18030_bin")) +_charsets.add(Charset(250, "gb18030", "gb18030_unicode_520_ci")) +_charsets.add(Charset(255, "utf8mb4", "utf8mb4_0900_ai_ci")) diff --git a/plugins/module_utils/pymysql/connections.py b/plugins/module_utils/pymysql/connections.py new file mode 100644 index 0000000..3a04ddd --- /dev/null +++ b/plugins/module_utils/pymysql/connections.py @@ -0,0 +1,1431 @@ +# Python implementation of the MySQL client-server protocol +# http://dev.mysql.com/doc/internals/en/client-server-protocol.html +# Error codes: +# https://dev.mysql.com/doc/refman/5.5/en/error-handling.html +import errno +import os +import socket +import struct +import sys +import traceback +import warnings + +from . import _auth + +from .charset import charset_by_name, charset_by_id +from .constants import CLIENT, COMMAND, CR, ER, FIELD_TYPE, SERVER_STATUS +from . import converters +from .cursors import Cursor +from .optionfile import Parser +from .protocol import ( + dump_packet, + MysqlPacket, + FieldDescriptorPacket, + OKPacketWrapper, + EOFPacketWrapper, + LoadLocalPacketWrapper, +) +from . import err, VERSION_STRING + +try: + import ssl + + SSL_ENABLED = True +except ImportError: + ssl = None + SSL_ENABLED = False + +try: + import getpass + + DEFAULT_USER = getpass.getuser() + del getpass +except (ImportError, KeyError): + # KeyError occurs when there's no entry in OS database for a current user. + DEFAULT_USER = None + +DEBUG = False + +TEXT_TYPES = { + FIELD_TYPE.BIT, + FIELD_TYPE.BLOB, + FIELD_TYPE.LONG_BLOB, + FIELD_TYPE.MEDIUM_BLOB, + FIELD_TYPE.STRING, + FIELD_TYPE.TINY_BLOB, + FIELD_TYPE.VAR_STRING, + FIELD_TYPE.VARCHAR, + FIELD_TYPE.GEOMETRY, +} + + +DEFAULT_CHARSET = "utf8mb4" + +MAX_PACKET_LEN = 2**24 - 1 + + +def _pack_int24(n): + return struct.pack("`_ in the + specification. + """ + + _sock = None + _auth_plugin_name = "" + _closed = False + _secure = False + + def __init__( + self, + *, + user=None, # The first four arguments is based on DB-API 2.0 recommendation. + password="", + host=None, + database=None, + unix_socket=None, + port=0, + charset="", + collation=None, + sql_mode=None, + read_default_file=None, + conv=None, + use_unicode=True, + client_flag=0, + cursorclass=Cursor, + init_command=None, + connect_timeout=10, + read_default_group=None, + autocommit=False, + local_infile=False, + max_allowed_packet=16 * 1024 * 1024, + defer_connect=False, + auth_plugin_map=None, + read_timeout=None, + write_timeout=None, + bind_address=None, + binary_prefix=False, + program_name=None, + server_public_key=None, + ssl=None, + ssl_ca=None, + ssl_cert=None, + ssl_disabled=None, + ssl_key=None, + ssl_key_password=None, + ssl_verify_cert=None, + ssl_verify_identity=None, + compress=None, # not supported + named_pipe=None, # not supported + passwd=None, # deprecated + db=None, # deprecated + ): + if db is not None and database is None: + # We will raise warning in 2022 or later. + # See https://github.com/PyMySQL/PyMySQL/issues/939 + # warnings.warn("'db' is deprecated, use 'database'", DeprecationWarning, 3) + database = db + if passwd is not None and not password: + # We will raise warning in 2022 or later. + # See https://github.com/PyMySQL/PyMySQL/issues/939 + # warnings.warn( + # "'passwd' is deprecated, use 'password'", DeprecationWarning, 3 + # ) + password = passwd + + if compress or named_pipe: + raise NotImplementedError( + "compress and named_pipe arguments are not supported" + ) + + self._local_infile = bool(local_infile) + if self._local_infile: + client_flag |= CLIENT.LOCAL_FILES + + if read_default_group and not read_default_file: + if sys.platform.startswith("win"): + read_default_file = "c:\\my.ini" + else: + read_default_file = "/etc/my.cnf" + + if read_default_file: + if not read_default_group: + read_default_group = "client" + + cfg = Parser() + cfg.read(os.path.expanduser(read_default_file)) + + def _config(key, arg): + if arg: + return arg + try: + return cfg.get(read_default_group, key) + except Exception: + return arg + + user = _config("user", user) + password = _config("password", password) + host = _config("host", host) + database = _config("database", database) + unix_socket = _config("socket", unix_socket) + port = int(_config("port", port)) + bind_address = _config("bind-address", bind_address) + charset = _config("default-character-set", charset) + if not ssl: + ssl = {} + if isinstance(ssl, dict): + for key in ["ca", "capath", "cert", "key", "password", "cipher"]: + value = _config("ssl-" + key, ssl.get(key)) + if value: + ssl[key] = value + + self.ssl = False + if not ssl_disabled: + if ssl_ca or ssl_cert or ssl_key or ssl_verify_cert or ssl_verify_identity: + ssl = { + "ca": ssl_ca, + "check_hostname": bool(ssl_verify_identity), + "verify_mode": ssl_verify_cert + if ssl_verify_cert is not None + else False, + } + if ssl_cert is not None: + ssl["cert"] = ssl_cert + if ssl_key is not None: + ssl["key"] = ssl_key + if ssl_key_password is not None: + ssl["password"] = ssl_key_password + if ssl: + if not SSL_ENABLED: + raise NotImplementedError("ssl module not found") + self.ssl = True + client_flag |= CLIENT.SSL + self.ctx = self._create_ssl_ctx(ssl) + + self.host = host or "localhost" + self.port = port or 3306 + if type(self.port) is not int: + raise ValueError("port should be of type int") + self.user = user or DEFAULT_USER + self.password = password or b"" + if isinstance(self.password, str): + self.password = self.password.encode("latin1") + self.db = database + self.unix_socket = unix_socket + self.bind_address = bind_address + if not (0 < connect_timeout <= 31536000): + raise ValueError("connect_timeout should be >0 and <=31536000") + self.connect_timeout = connect_timeout or None + if read_timeout is not None and read_timeout <= 0: + raise ValueError("read_timeout should be > 0") + self._read_timeout = read_timeout + if write_timeout is not None and write_timeout <= 0: + raise ValueError("write_timeout should be > 0") + self._write_timeout = write_timeout + + self.charset = charset or DEFAULT_CHARSET + self.collation = collation + self.use_unicode = use_unicode + + self.encoding = charset_by_name(self.charset).encoding + + client_flag |= CLIENT.CAPABILITIES + if self.db: + client_flag |= CLIENT.CONNECT_WITH_DB + + self.client_flag = client_flag + + self.cursorclass = cursorclass + + self._result = None + self._affected_rows = 0 + self.host_info = "Not connected" + + # specified autocommit mode. None means use server default. + self.autocommit_mode = autocommit + + if conv is None: + conv = converters.conversions + + # Need for MySQLdb compatibility. + self.encoders = {k: v for (k, v) in conv.items() if type(k) is not int} + self.decoders = {k: v for (k, v) in conv.items() if type(k) is int} + self.sql_mode = sql_mode + self.init_command = init_command + self.max_allowed_packet = max_allowed_packet + self._auth_plugin_map = auth_plugin_map or {} + self._binary_prefix = binary_prefix + self.server_public_key = server_public_key + + self._connect_attrs = { + "_client_name": "pymysql", + "_client_version": VERSION_STRING, + "_pid": str(os.getpid()), + } + + if program_name: + self._connect_attrs["program_name"] = program_name + + if defer_connect: + self._sock = None + else: + self.connect() + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + del exc_info + self.close() + + def _create_ssl_ctx(self, sslp): + if isinstance(sslp, ssl.SSLContext): + return sslp + ca = sslp.get("ca") + capath = sslp.get("capath") + hasnoca = ca is None and capath is None + ctx = ssl.create_default_context(cafile=ca, capath=capath) + ctx.check_hostname = not hasnoca and sslp.get("check_hostname", True) + verify_mode_value = sslp.get("verify_mode") + if verify_mode_value is None: + ctx.verify_mode = ssl.CERT_NONE if hasnoca else ssl.CERT_REQUIRED + elif isinstance(verify_mode_value, bool): + ctx.verify_mode = ssl.CERT_REQUIRED if verify_mode_value else ssl.CERT_NONE + else: + if isinstance(verify_mode_value, str): + verify_mode_value = verify_mode_value.lower() + if verify_mode_value in ("none", "0", "false", "no"): + ctx.verify_mode = ssl.CERT_NONE + elif verify_mode_value == "optional": + ctx.verify_mode = ssl.CERT_OPTIONAL + elif verify_mode_value in ("required", "1", "true", "yes"): + ctx.verify_mode = ssl.CERT_REQUIRED + else: + ctx.verify_mode = ssl.CERT_NONE if hasnoca else ssl.CERT_REQUIRED + if "cert" in sslp: + ctx.load_cert_chain( + sslp["cert"], keyfile=sslp.get("key"), password=sslp.get("password") + ) + if "cipher" in sslp: + ctx.set_ciphers(sslp["cipher"]) + ctx.options |= ssl.OP_NO_SSLv2 + ctx.options |= ssl.OP_NO_SSLv3 + return ctx + + def close(self): + """ + Send the quit message and close the socket. + + See `Connection.close() `_ + in the specification. + + :raise Error: If the connection is already closed. + """ + if self._closed: + raise err.Error("Already closed") + self._closed = True + if self._sock is None: + return + send_data = struct.pack("`_ + in the specification. + """ + self._execute_command(COMMAND.COM_QUERY, "COMMIT") + self._read_ok_packet() + + def rollback(self): + """ + Roll back the current transaction. + + See `Connection.rollback() `_ + in the specification. + """ + self._execute_command(COMMAND.COM_QUERY, "ROLLBACK") + self._read_ok_packet() + + def show_warnings(self): + """Send the "SHOW WARNINGS" SQL command.""" + self._execute_command(COMMAND.COM_QUERY, "SHOW WARNINGS") + result = MySQLResult(self) + result.read() + return result.rows + + def select_db(self, db): + """ + Set current db. + + :param db: The name of the db. + """ + self._execute_command(COMMAND.COM_INIT_DB, db) + self._read_ok_packet() + + def escape(self, obj, mapping=None): + """Escape whatever value is passed. + + Non-standard, for internal use; do not use this in your applications. + """ + if isinstance(obj, str): + return "'" + self.escape_string(obj) + "'" + if isinstance(obj, (bytes, bytearray)): + ret = self._quote_bytes(obj) + if self._binary_prefix: + ret = "_binary" + ret + return ret + return converters.escape_item(obj, self.charset, mapping=mapping) + + def literal(self, obj): + """Alias for escape(). + + Non-standard, for internal use; do not use this in your applications. + """ + return self.escape(obj, self.encoders) + + def escape_string(self, s): + if self.server_status & SERVER_STATUS.SERVER_STATUS_NO_BACKSLASH_ESCAPES: + return s.replace("'", "''") + return converters.escape_string(s) + + def _quote_bytes(self, s): + if self.server_status & SERVER_STATUS.SERVER_STATUS_NO_BACKSLASH_ESCAPES: + return "'{}'".format( + s.replace(b"'", b"''").decode("ascii", "surrogateescape") + ) + return converters.escape_bytes(s) + + def cursor(self, cursor=None): + """ + Create a new cursor to execute queries with. + + :param cursor: The type of cursor to create. None means use Cursor. + :type cursor: :py:class:`Cursor`, :py:class:`SSCursor`, :py:class:`DictCursor`, + or :py:class:`SSDictCursor`. + """ + if cursor: + return cursor(self) + return self.cursorclass(self) + + # The following methods are INTERNAL USE ONLY (called from Cursor) + def query(self, sql, unbuffered=False): + # if DEBUG: + # print("DEBUG: sending query:", sql) + if isinstance(sql, str): + sql = sql.encode(self.encoding, "surrogateescape") + self._execute_command(COMMAND.COM_QUERY, sql) + self._affected_rows = self._read_query_result(unbuffered=unbuffered) + return self._affected_rows + + def next_result(self, unbuffered=False): + self._affected_rows = self._read_query_result(unbuffered=unbuffered) + return self._affected_rows + + def affected_rows(self): + return self._affected_rows + + def kill(self, thread_id): + arg = struct.pack("= 5: + self.client_flag |= CLIENT.MULTI_RESULTS + + if self.user is None: + raise ValueError("Did not specify a username") + + charset_id = charset_by_name(self.charset).id + if isinstance(self.user, str): + self.user = self.user.encode(self.encoding) + + data_init = struct.pack( + "=5.0) + data += authresp + b"\0" + + if self.db and self.server_capabilities & CLIENT.CONNECT_WITH_DB: + if isinstance(self.db, str): + self.db = self.db.encode(self.encoding) + data += self.db + b"\0" + + if self.server_capabilities & CLIENT.PLUGIN_AUTH: + data += (plugin_name or b"") + b"\0" + + if self.server_capabilities & CLIENT.CONNECT_ATTRS: + connect_attrs = b"" + for k, v in self._connect_attrs.items(): + k = k.encode("utf-8") + connect_attrs += _lenenc_int(len(k)) + k + v = v.encode("utf-8") + connect_attrs += _lenenc_int(len(v)) + v + data += _lenenc_int(len(connect_attrs)) + connect_attrs + + self.write_packet(data) + auth_packet = self._read_packet() + + # if authentication method isn't accepted the first byte + # will have the octet 254 + if auth_packet.is_auth_switch_request(): + if DEBUG: + print("received auth switch") + # https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest + auth_packet.read_uint8() # 0xfe packet identifier + plugin_name = auth_packet.read_string() + if ( + self.server_capabilities & CLIENT.PLUGIN_AUTH + and plugin_name is not None + ): + auth_packet = self._process_auth(plugin_name, auth_packet) + else: + raise err.OperationalError("received unknown auth switch request") + elif auth_packet.is_extra_auth_data(): + if DEBUG: + print("received extra data") + # https://dev.mysql.com/doc/internals/en/successful-authentication.html + if self._auth_plugin_name == "caching_sha2_password": + auth_packet = _auth.caching_sha2_password_auth(self, auth_packet) + elif self._auth_plugin_name == "sha256_password": + auth_packet = _auth.sha256_password_auth(self, auth_packet) + else: + raise err.OperationalError( + "Received extra packet for auth method %r", self._auth_plugin_name + ) + + if DEBUG: + print("Succeed to auth") + + def _process_auth(self, plugin_name, auth_packet): + handler = self._get_auth_plugin_handler(plugin_name) + if handler: + try: + return handler.authenticate(auth_packet) + except AttributeError: + if plugin_name != b"dialog": + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_CANNOT_LOAD, + f"Authentication plugin '{plugin_name}'" + f" not loaded: - {type(handler)!r} missing authenticate method", + ) + if plugin_name == b"caching_sha2_password": + return _auth.caching_sha2_password_auth(self, auth_packet) + elif plugin_name == b"sha256_password": + return _auth.sha256_password_auth(self, auth_packet) + elif plugin_name == b"mysql_native_password": + data = _auth.scramble_native_password(self.password, auth_packet.read_all()) + elif plugin_name == b"client_ed25519": + data = _auth.ed25519_password(self.password, auth_packet.read_all()) + elif plugin_name == b"mysql_old_password": + data = ( + _auth.scramble_old_password(self.password, auth_packet.read_all()) + + b"\0" + ) + elif plugin_name == b"mysql_clear_password": + # https://dev.mysql.com/doc/internals/en/clear-text-authentication.html + data = self.password + b"\0" + elif plugin_name == b"dialog": + pkt = auth_packet + while True: + flag = pkt.read_uint8() + echo = (flag & 0x06) == 0x02 + last = (flag & 0x01) == 0x01 + prompt = pkt.read_all() + + if prompt == b"Password: ": + self.write_packet(self.password + b"\0") + elif handler: + resp = "no response - TypeError within plugin.prompt method" + try: + resp = handler.prompt(echo, prompt) + self.write_packet(resp + b"\0") + except AttributeError: + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_CANNOT_LOAD, + f"Authentication plugin '{plugin_name}'" + f" not loaded: - {handler!r} missing prompt method", + ) + except TypeError: + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_ERR, + f"Authentication plugin '{plugin_name}'" + f" {handler!r} didn't respond with string. Returned '{resp!r}' to prompt {prompt!r}", + ) + else: + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_CANNOT_LOAD, + f"Authentication plugin '{plugin_name}' not configured", + ) + pkt = self._read_packet() + pkt.check_error() + if pkt.is_ok_packet() or last: + break + return pkt + else: + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_CANNOT_LOAD, + "Authentication plugin '%s' not configured" % plugin_name, + ) + + self.write_packet(data) + pkt = self._read_packet() + pkt.check_error() + return pkt + + def _get_auth_plugin_handler(self, plugin_name): + plugin_class = self._auth_plugin_map.get(plugin_name) + if not plugin_class and isinstance(plugin_name, bytes): + plugin_class = self._auth_plugin_map.get(plugin_name.decode("ascii")) + if plugin_class: + try: + handler = plugin_class(self) + except TypeError: + raise err.OperationalError( + CR.CR_AUTH_PLUGIN_CANNOT_LOAD, + f"Authentication plugin '{plugin_name}'" + f" not loaded: - {plugin_class!r} cannot be constructed with connection object", + ) + else: + handler = None + return handler + + # _mysql support + def thread_id(self): + return self.server_thread_id[0] + + def character_set_name(self): + return self.charset + + def get_host_info(self): + return self.host_info + + def get_proto_info(self): + return self.protocol_version + + def _get_server_information(self): + i = 0 + packet = self._read_packet() + data = packet.get_all_data() + + self.protocol_version = data[i] + i += 1 + + server_end = data.find(b"\0", i) + self.server_version = data[i:server_end].decode("latin1") + i = server_end + 1 + + self.server_thread_id = struct.unpack("= i + 6: + lang, stat, cap_h, salt_len = struct.unpack("= i + salt_len: + # salt_len includes auth_plugin_data_part_1 and filler + self.salt += data[i : i + salt_len] + i += salt_len + + i += 1 + # AUTH PLUGIN NAME may appear here. + if self.server_capabilities & CLIENT.PLUGIN_AUTH and len(data) >= i: + # Due to Bug#59453 the auth-plugin-name is missing the terminating + # NUL-char in versions prior to 5.5.10 and 5.6.2. + # ref: https://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::Handshake + # didn't use version checks as mariadb is corrected and reports + # earlier than those two. + server_end = data.find(b"\0", i) + if server_end < 0: # pragma: no cover - very specific upstream bug + # not found \0 and last field so take it all + self._auth_plugin_name = data[i:].decode("utf-8") + else: + self._auth_plugin_name = data[i:server_end].decode("utf-8") + + def get_server_info(self): + return self.server_version + + Warning = err.Warning + Error = err.Error + InterfaceError = err.InterfaceError + DatabaseError = err.DatabaseError + DataError = err.DataError + OperationalError = err.OperationalError + IntegrityError = err.IntegrityError + InternalError = err.InternalError + ProgrammingError = err.ProgrammingError + NotSupportedError = err.NotSupportedError + + +class MySQLResult: + def __init__(self, connection): + """ + :type connection: Connection + """ + self.connection = connection + self.affected_rows = None + self.insert_id = None + self.server_status = None + self.warning_count = 0 + self.message = None + self.field_count = 0 + self.description = None + self.rows = None + self.has_next = None + self.unbuffered_active = False + + def __del__(self): + if self.unbuffered_active: + self._finish_unbuffered_query() + + def read(self): + try: + first_packet = self.connection._read_packet() + + if first_packet.is_ok_packet(): + self._read_ok_packet(first_packet) + elif first_packet.is_load_local_packet(): + self._read_load_local_packet(first_packet) + else: + self._read_result_packet(first_packet) + finally: + self.connection = None + + def init_unbuffered_query(self): + """ + :raise OperationalError: If the connection to the MySQL server is lost. + :raise InternalError: + """ + self.unbuffered_active = True + first_packet = self.connection._read_packet() + + if first_packet.is_ok_packet(): + self._read_ok_packet(first_packet) + self.unbuffered_active = False + self.connection = None + elif first_packet.is_load_local_packet(): + self._read_load_local_packet(first_packet) + self.unbuffered_active = False + self.connection = None + else: + self.field_count = first_packet.read_length_encoded_integer() + self._get_descriptions() + + # Apparently, MySQLdb picks this number because it's the maximum + # value of a 64bit unsigned integer. Since we're emulating MySQLdb, + # we set it to this instead of None, which would be preferred. + self.affected_rows = 18446744073709551615 + + def _read_ok_packet(self, first_packet): + ok_packet = OKPacketWrapper(first_packet) + self.affected_rows = ok_packet.affected_rows + self.insert_id = ok_packet.insert_id + self.server_status = ok_packet.server_status + self.warning_count = ok_packet.warning_count + self.message = ok_packet.message + self.has_next = ok_packet.has_next + + def _read_load_local_packet(self, first_packet): + if not self.connection._local_infile: + raise RuntimeError( + "**WARN**: Received LOAD_LOCAL packet but local_infile option is false." + ) + load_packet = LoadLocalPacketWrapper(first_packet) + sender = LoadLocalFile(load_packet.filename, self.connection) + try: + sender.send_data() + except: + self.connection._read_packet() # skip ok packet + raise + + ok_packet = self.connection._read_packet() + if ( + not ok_packet.is_ok_packet() + ): # pragma: no cover - upstream induced protocol error + raise err.OperationalError( + CR.CR_COMMANDS_OUT_OF_SYNC, + "Commands Out of Sync", + ) + self._read_ok_packet(ok_packet) + + def _check_packet_is_eof(self, packet): + if not packet.is_eof_packet(): + return False + # TODO: Support CLIENT.DEPRECATE_EOF + # 1) Add DEPRECATE_EOF to CAPABILITIES + # 2) Mask CAPABILITIES with server_capabilities + # 3) if server_capabilities & CLIENT.DEPRECATE_EOF: + # use OKPacketWrapper instead of EOFPacketWrapper + wp = EOFPacketWrapper(packet) + self.warning_count = wp.warning_count + self.has_next = wp.has_next + return True + + def _read_result_packet(self, first_packet): + self.field_count = first_packet.read_length_encoded_integer() + self._get_descriptions() + self._read_rowdata_packet() + + def _read_rowdata_packet_unbuffered(self): + # Check if in an active query + if not self.unbuffered_active: + return + + # EOF + packet = self.connection._read_packet() + if self._check_packet_is_eof(packet): + self.unbuffered_active = False + self.connection = None + self.rows = None + return + + row = self._read_row_from_packet(packet) + self.affected_rows = 1 + self.rows = (row,) # rows should tuple of row for MySQL-python compatibility. + return row + + def _finish_unbuffered_query(self): + # After much reading on the MySQL protocol, it appears that there is, + # in fact, no way to stop MySQL from sending all the data after + # executing a query, so we just spin, and wait for an EOF packet. + while self.unbuffered_active: + try: + packet = self.connection._read_packet() + except err.OperationalError as e: + if e.args[0] in ( + ER.QUERY_TIMEOUT, + ER.STATEMENT_TIMEOUT, + ): + # if the query timed out we can simply ignore this error + self.unbuffered_active = False + self.connection = None + return + + raise + + if self._check_packet_is_eof(packet): + self.unbuffered_active = False + self.connection = None # release reference to kill cyclic reference. + + def _read_rowdata_packet(self): + """Read a rowdata packet for each data row in the result set.""" + rows = [] + while True: + packet = self.connection._read_packet() + if self._check_packet_is_eof(packet): + self.connection = None # release reference to kill cyclic reference. + break + rows.append(self._read_row_from_packet(packet)) + + self.affected_rows = len(rows) + self.rows = tuple(rows) + + def _read_row_from_packet(self, packet): + row = [] + for encoding, converter in self.converters: + try: + data = packet.read_length_coded_string() + except IndexError: + # No more columns in this row + # See https://github.com/PyMySQL/PyMySQL/pull/434 + break + if data is not None: + if encoding is not None: + data = data.decode(encoding) + if DEBUG: + print("DEBUG: DATA = ", data) + if converter is not None: + data = converter(data) + row.append(data) + return tuple(row) + + def _get_descriptions(self): + """Read a column descriptor packet for each column in the result.""" + self.fields = [] + self.converters = [] + use_unicode = self.connection.use_unicode + conn_encoding = self.connection.encoding + description = [] + + for i in range(self.field_count): + field = self.connection._read_packet(FieldDescriptorPacket) + self.fields.append(field) + description.append(field.description()) + field_type = field.type_code + if use_unicode: + if field_type == FIELD_TYPE.JSON: + # When SELECT from JSON column: charset = binary + # When SELECT CAST(... AS JSON): charset = connection encoding + # This behavior is different from TEXT / BLOB. + # We should decode result by connection encoding regardless charsetnr. + # See https://github.com/PyMySQL/PyMySQL/issues/488 + encoding = conn_encoding # SELECT CAST(... AS JSON) + elif field_type in TEXT_TYPES: + if field.charsetnr == 63: # binary + # TEXTs with charset=binary means BINARY types. + encoding = None + else: + encoding = conn_encoding + else: + # Integers, Dates and Times, and other basic data is encoded in ascii + encoding = "ascii" + else: + encoding = None + converter = self.connection.decoders.get(field_type) + if converter is converters.through: + converter = None + if DEBUG: + print(f"DEBUG: field={field}, converter={converter}") + self.converters.append((encoding, converter)) + + eof_packet = self.connection._read_packet() + assert eof_packet.is_eof_packet(), "Protocol error, expecting EOF" + self.description = tuple(description) + + +class LoadLocalFile: + def __init__(self, filename, connection): + self.filename = filename + self.connection = connection + + def send_data(self): + """Send data packets from the local file to the server""" + if not self.connection._sock: + raise err.InterfaceError(0, "") + conn: Connection = self.connection + + try: + with open(self.filename, "rb") as open_file: + packet_size = min( + conn.max_allowed_packet, 16 * 1024 + ) # 16KB is efficient enough + while True: + chunk = open_file.read(packet_size) + if not chunk: + break + conn.write_packet(chunk) + except OSError: + raise err.OperationalError( + ER.FILE_NOT_FOUND, + f"Can't find file '{self.filename}'", + ) + finally: + if not conn._closed: + # send the empty packet to signify we are done sending data + conn.write_packet(b"") diff --git a/plugins/module_utils/pymysql/constants/CLIENT.py b/plugins/module_utils/pymysql/constants/CLIENT.py new file mode 100644 index 0000000..34fe57a --- /dev/null +++ b/plugins/module_utils/pymysql/constants/CLIENT.py @@ -0,0 +1,38 @@ +# https://dev.mysql.com/doc/internals/en/capability-flags.html#packet-Protocol::CapabilityFlags +LONG_PASSWORD = 1 +FOUND_ROWS = 1 << 1 +LONG_FLAG = 1 << 2 +CONNECT_WITH_DB = 1 << 3 +NO_SCHEMA = 1 << 4 +COMPRESS = 1 << 5 +ODBC = 1 << 6 +LOCAL_FILES = 1 << 7 +IGNORE_SPACE = 1 << 8 +PROTOCOL_41 = 1 << 9 +INTERACTIVE = 1 << 10 +SSL = 1 << 11 +IGNORE_SIGPIPE = 1 << 12 +TRANSACTIONS = 1 << 13 +SECURE_CONNECTION = 1 << 15 +MULTI_STATEMENTS = 1 << 16 +MULTI_RESULTS = 1 << 17 +PS_MULTI_RESULTS = 1 << 18 +PLUGIN_AUTH = 1 << 19 +CONNECT_ATTRS = 1 << 20 +PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21 +CAPABILITIES = ( + LONG_PASSWORD + | LONG_FLAG + | PROTOCOL_41 + | TRANSACTIONS + | SECURE_CONNECTION + | MULTI_RESULTS + | PLUGIN_AUTH + | PLUGIN_AUTH_LENENC_CLIENT_DATA + | CONNECT_ATTRS +) + +# Not done yet +HANDLE_EXPIRED_PASSWORDS = 1 << 22 +SESSION_TRACK = 1 << 23 +DEPRECATE_EOF = 1 << 24 diff --git a/plugins/module_utils/pymysql/constants/COMMAND.py b/plugins/module_utils/pymysql/constants/COMMAND.py new file mode 100644 index 0000000..2d98850 --- /dev/null +++ b/plugins/module_utils/pymysql/constants/COMMAND.py @@ -0,0 +1,32 @@ +COM_SLEEP = 0x00 +COM_QUIT = 0x01 +COM_INIT_DB = 0x02 +COM_QUERY = 0x03 +COM_FIELD_LIST = 0x04 +COM_CREATE_DB = 0x05 +COM_DROP_DB = 0x06 +COM_REFRESH = 0x07 +COM_SHUTDOWN = 0x08 +COM_STATISTICS = 0x09 +COM_PROCESS_INFO = 0x0A +COM_CONNECT = 0x0B +COM_PROCESS_KILL = 0x0C +COM_DEBUG = 0x0D +COM_PING = 0x0E +COM_TIME = 0x0F +COM_DELAYED_INSERT = 0x10 +COM_CHANGE_USER = 0x11 +COM_BINLOG_DUMP = 0x12 +COM_TABLE_DUMP = 0x13 +COM_CONNECT_OUT = 0x14 +COM_REGISTER_SLAVE = 0x15 +COM_STMT_PREPARE = 0x16 +COM_STMT_EXECUTE = 0x17 +COM_STMT_SEND_LONG_DATA = 0x18 +COM_STMT_CLOSE = 0x19 +COM_STMT_RESET = 0x1A +COM_SET_OPTION = 0x1B +COM_STMT_FETCH = 0x1C +COM_DAEMON = 0x1D +COM_BINLOG_DUMP_GTID = 0x1E +COM_END = 0x1F diff --git a/plugins/module_utils/pymysql/constants/CR.py b/plugins/module_utils/pymysql/constants/CR.py new file mode 100644 index 0000000..deae977 --- /dev/null +++ b/plugins/module_utils/pymysql/constants/CR.py @@ -0,0 +1,79 @@ +# flake8: noqa +# errmsg.h +CR_ERROR_FIRST = 2000 +CR_UNKNOWN_ERROR = 2000 +CR_SOCKET_CREATE_ERROR = 2001 +CR_CONNECTION_ERROR = 2002 +CR_CONN_HOST_ERROR = 2003 +CR_IPSOCK_ERROR = 2004 +CR_UNKNOWN_HOST = 2005 +CR_SERVER_GONE_ERROR = 2006 +CR_VERSION_ERROR = 2007 +CR_OUT_OF_MEMORY = 2008 +CR_WRONG_HOST_INFO = 2009 +CR_LOCALHOST_CONNECTION = 2010 +CR_TCP_CONNECTION = 2011 +CR_SERVER_HANDSHAKE_ERR = 2012 +CR_SERVER_LOST = 2013 +CR_COMMANDS_OUT_OF_SYNC = 2014 +CR_NAMEDPIPE_CONNECTION = 2015 +CR_NAMEDPIPEWAIT_ERROR = 2016 +CR_NAMEDPIPEOPEN_ERROR = 2017 +CR_NAMEDPIPESETSTATE_ERROR = 2018 +CR_CANT_READ_CHARSET = 2019 +CR_NET_PACKET_TOO_LARGE = 2020 +CR_EMBEDDED_CONNECTION = 2021 +CR_PROBE_SLAVE_STATUS = 2022 +CR_PROBE_SLAVE_HOSTS = 2023 +CR_PROBE_SLAVE_CONNECT = 2024 +CR_PROBE_MASTER_CONNECT = 2025 +CR_SSL_CONNECTION_ERROR = 2026 +CR_MALFORMED_PACKET = 2027 +CR_WRONG_LICENSE = 2028 + +CR_NULL_POINTER = 2029 +CR_NO_PREPARE_STMT = 2030 +CR_PARAMS_NOT_BOUND = 2031 +CR_DATA_TRUNCATED = 2032 +CR_NO_PARAMETERS_EXISTS = 2033 +CR_INVALID_PARAMETER_NO = 2034 +CR_INVALID_BUFFER_USE = 2035 +CR_UNSUPPORTED_PARAM_TYPE = 2036 + +CR_SHARED_MEMORY_CONNECTION = 2037 +CR_SHARED_MEMORY_CONNECT_REQUEST_ERROR = 2038 +CR_SHARED_MEMORY_CONNECT_ANSWER_ERROR = 2039 +CR_SHARED_MEMORY_CONNECT_FILE_MAP_ERROR = 2040 +CR_SHARED_MEMORY_CONNECT_MAP_ERROR = 2041 +CR_SHARED_MEMORY_FILE_MAP_ERROR = 2042 +CR_SHARED_MEMORY_MAP_ERROR = 2043 +CR_SHARED_MEMORY_EVENT_ERROR = 2044 +CR_SHARED_MEMORY_CONNECT_ABANDONED_ERROR = 2045 +CR_SHARED_MEMORY_CONNECT_SET_ERROR = 2046 +CR_CONN_UNKNOW_PROTOCOL = 2047 +CR_INVALID_CONN_HANDLE = 2048 +CR_SECURE_AUTH = 2049 +CR_FETCH_CANCELED = 2050 +CR_NO_DATA = 2051 +CR_NO_STMT_METADATA = 2052 +CR_NO_RESULT_SET = 2053 +CR_NOT_IMPLEMENTED = 2054 +CR_SERVER_LOST_EXTENDED = 2055 +CR_STMT_CLOSED = 2056 +CR_NEW_STMT_METADATA = 2057 +CR_ALREADY_CONNECTED = 2058 +CR_AUTH_PLUGIN_CANNOT_LOAD = 2059 +CR_DUPLICATE_CONNECTION_ATTR = 2060 +CR_AUTH_PLUGIN_ERR = 2061 +CR_INSECURE_API_ERR = 2062 +CR_FILE_NAME_TOO_LONG = 2063 +CR_SSL_FIPS_MODE_ERR = 2064 +CR_DEPRECATED_COMPRESSION_NOT_SUPPORTED = 2065 +CR_COMPRESSION_WRONGLY_CONFIGURED = 2066 +CR_KERBEROS_USER_NOT_FOUND = 2067 +CR_LOAD_DATA_LOCAL_INFILE_REJECTED = 2068 +CR_LOAD_DATA_LOCAL_INFILE_REALPATH_FAIL = 2069 +CR_DNS_SRV_LOOKUP_FAILED = 2070 +CR_MANDATORY_TRACKER_NOT_FOUND = 2071 +CR_INVALID_FACTOR_NO = 2072 +CR_ERROR_LAST = 2072 diff --git a/plugins/module_utils/pymysql/constants/ER.py b/plugins/module_utils/pymysql/constants/ER.py new file mode 100644 index 0000000..98729d1 --- /dev/null +++ b/plugins/module_utils/pymysql/constants/ER.py @@ -0,0 +1,477 @@ +ERROR_FIRST = 1000 +HASHCHK = 1000 +NISAMCHK = 1001 +NO = 1002 +YES = 1003 +CANT_CREATE_FILE = 1004 +CANT_CREATE_TABLE = 1005 +CANT_CREATE_DB = 1006 +DB_CREATE_EXISTS = 1007 +DB_DROP_EXISTS = 1008 +DB_DROP_DELETE = 1009 +DB_DROP_RMDIR = 1010 +CANT_DELETE_FILE = 1011 +CANT_FIND_SYSTEM_REC = 1012 +CANT_GET_STAT = 1013 +CANT_GET_WD = 1014 +CANT_LOCK = 1015 +CANT_OPEN_FILE = 1016 +FILE_NOT_FOUND = 1017 +CANT_READ_DIR = 1018 +CANT_SET_WD = 1019 +CHECKREAD = 1020 +DISK_FULL = 1021 +DUP_KEY = 1022 +ERROR_ON_CLOSE = 1023 +ERROR_ON_READ = 1024 +ERROR_ON_RENAME = 1025 +ERROR_ON_WRITE = 1026 +FILE_USED = 1027 +FILSORT_ABORT = 1028 +FORM_NOT_FOUND = 1029 +GET_ERRNO = 1030 +ILLEGAL_HA = 1031 +KEY_NOT_FOUND = 1032 +NOT_FORM_FILE = 1033 +NOT_KEYFILE = 1034 +OLD_KEYFILE = 1035 +OPEN_AS_READONLY = 1036 +OUTOFMEMORY = 1037 +OUT_OF_SORTMEMORY = 1038 +UNEXPECTED_EOF = 1039 +CON_COUNT_ERROR = 1040 +OUT_OF_RESOURCES = 1041 +BAD_HOST_ERROR = 1042 +HANDSHAKE_ERROR = 1043 +DBACCESS_DENIED_ERROR = 1044 +ACCESS_DENIED_ERROR = 1045 +NO_DB_ERROR = 1046 +UNKNOWN_COM_ERROR = 1047 +BAD_NULL_ERROR = 1048 +BAD_DB_ERROR = 1049 +TABLE_EXISTS_ERROR = 1050 +BAD_TABLE_ERROR = 1051 +NON_UNIQ_ERROR = 1052 +SERVER_SHUTDOWN = 1053 +BAD_FIELD_ERROR = 1054 +WRONG_FIELD_WITH_GROUP = 1055 +WRONG_GROUP_FIELD = 1056 +WRONG_SUM_SELECT = 1057 +WRONG_VALUE_COUNT = 1058 +TOO_LONG_IDENT = 1059 +DUP_FIELDNAME = 1060 +DUP_KEYNAME = 1061 +DUP_ENTRY = 1062 +WRONG_FIELD_SPEC = 1063 +PARSE_ERROR = 1064 +EMPTY_QUERY = 1065 +NONUNIQ_TABLE = 1066 +INVALID_DEFAULT = 1067 +MULTIPLE_PRI_KEY = 1068 +TOO_MANY_KEYS = 1069 +TOO_MANY_KEY_PARTS = 1070 +TOO_LONG_KEY = 1071 +KEY_COLUMN_DOES_NOT_EXITS = 1072 +BLOB_USED_AS_KEY = 1073 +TOO_BIG_FIELDLENGTH = 1074 +WRONG_AUTO_KEY = 1075 +READY = 1076 +NORMAL_SHUTDOWN = 1077 +GOT_SIGNAL = 1078 +SHUTDOWN_COMPLETE = 1079 +FORCING_CLOSE = 1080 +IPSOCK_ERROR = 1081 +NO_SUCH_INDEX = 1082 +WRONG_FIELD_TERMINATORS = 1083 +BLOBS_AND_NO_TERMINATED = 1084 +TEXTFILE_NOT_READABLE = 1085 +FILE_EXISTS_ERROR = 1086 +LOAD_INFO = 1087 +ALTER_INFO = 1088 +WRONG_SUB_KEY = 1089 +CANT_REMOVE_ALL_FIELDS = 1090 +CANT_DROP_FIELD_OR_KEY = 1091 +INSERT_INFO = 1092 +UPDATE_TABLE_USED = 1093 +NO_SUCH_THREAD = 1094 +KILL_DENIED_ERROR = 1095 +NO_TABLES_USED = 1096 +TOO_BIG_SET = 1097 +NO_UNIQUE_LOGFILE = 1098 +TABLE_NOT_LOCKED_FOR_WRITE = 1099 +TABLE_NOT_LOCKED = 1100 +BLOB_CANT_HAVE_DEFAULT = 1101 +WRONG_DB_NAME = 1102 +WRONG_TABLE_NAME = 1103 +TOO_BIG_SELECT = 1104 +UNKNOWN_ERROR = 1105 +UNKNOWN_PROCEDURE = 1106 +WRONG_PARAMCOUNT_TO_PROCEDURE = 1107 +WRONG_PARAMETERS_TO_PROCEDURE = 1108 +UNKNOWN_TABLE = 1109 +FIELD_SPECIFIED_TWICE = 1110 +INVALID_GROUP_FUNC_USE = 1111 +UNSUPPORTED_EXTENSION = 1112 +TABLE_MUST_HAVE_COLUMNS = 1113 +RECORD_FILE_FULL = 1114 +UNKNOWN_CHARACTER_SET = 1115 +TOO_MANY_TABLES = 1116 +TOO_MANY_FIELDS = 1117 +TOO_BIG_ROWSIZE = 1118 +STACK_OVERRUN = 1119 +WRONG_OUTER_JOIN = 1120 +NULL_COLUMN_IN_INDEX = 1121 +CANT_FIND_UDF = 1122 +CANT_INITIALIZE_UDF = 1123 +UDF_NO_PATHS = 1124 +UDF_EXISTS = 1125 +CANT_OPEN_LIBRARY = 1126 +CANT_FIND_DL_ENTRY = 1127 +FUNCTION_NOT_DEFINED = 1128 +HOST_IS_BLOCKED = 1129 +HOST_NOT_PRIVILEGED = 1130 +PASSWORD_ANONYMOUS_USER = 1131 +PASSWORD_NOT_ALLOWED = 1132 +PASSWORD_NO_MATCH = 1133 +UPDATE_INFO = 1134 +CANT_CREATE_THREAD = 1135 +WRONG_VALUE_COUNT_ON_ROW = 1136 +CANT_REOPEN_TABLE = 1137 +INVALID_USE_OF_NULL = 1138 +REGEXP_ERROR = 1139 +MIX_OF_GROUP_FUNC_AND_FIELDS = 1140 +NONEXISTING_GRANT = 1141 +TABLEACCESS_DENIED_ERROR = 1142 +COLUMNACCESS_DENIED_ERROR = 1143 +ILLEGAL_GRANT_FOR_TABLE = 1144 +GRANT_WRONG_HOST_OR_USER = 1145 +NO_SUCH_TABLE = 1146 +NONEXISTING_TABLE_GRANT = 1147 +NOT_ALLOWED_COMMAND = 1148 +SYNTAX_ERROR = 1149 +DELAYED_CANT_CHANGE_LOCK = 1150 +TOO_MANY_DELAYED_THREADS = 1151 +ABORTING_CONNECTION = 1152 +NET_PACKET_TOO_LARGE = 1153 +NET_READ_ERROR_FROM_PIPE = 1154 +NET_FCNTL_ERROR = 1155 +NET_PACKETS_OUT_OF_ORDER = 1156 +NET_UNCOMPRESS_ERROR = 1157 +NET_READ_ERROR = 1158 +NET_READ_INTERRUPTED = 1159 +NET_ERROR_ON_WRITE = 1160 +NET_WRITE_INTERRUPTED = 1161 +TOO_LONG_STRING = 1162 +TABLE_CANT_HANDLE_BLOB = 1163 +TABLE_CANT_HANDLE_AUTO_INCREMENT = 1164 +DELAYED_INSERT_TABLE_LOCKED = 1165 +WRONG_COLUMN_NAME = 1166 +WRONG_KEY_COLUMN = 1167 +WRONG_MRG_TABLE = 1168 +DUP_UNIQUE = 1169 +BLOB_KEY_WITHOUT_LENGTH = 1170 +PRIMARY_CANT_HAVE_NULL = 1171 +TOO_MANY_ROWS = 1172 +REQUIRES_PRIMARY_KEY = 1173 +NO_RAID_COMPILED = 1174 +UPDATE_WITHOUT_KEY_IN_SAFE_MODE = 1175 +KEY_DOES_NOT_EXITS = 1176 +CHECK_NO_SUCH_TABLE = 1177 +CHECK_NOT_IMPLEMENTED = 1178 +CANT_DO_THIS_DURING_AN_TRANSACTION = 1179 +ERROR_DURING_COMMIT = 1180 +ERROR_DURING_ROLLBACK = 1181 +ERROR_DURING_FLUSH_LOGS = 1182 +ERROR_DURING_CHECKPOINT = 1183 +NEW_ABORTING_CONNECTION = 1184 +DUMP_NOT_IMPLEMENTED = 1185 +FLUSH_MASTER_BINLOG_CLOSED = 1186 +INDEX_REBUILD = 1187 +MASTER = 1188 +MASTER_NET_READ = 1189 +MASTER_NET_WRITE = 1190 +FT_MATCHING_KEY_NOT_FOUND = 1191 +LOCK_OR_ACTIVE_TRANSACTION = 1192 +UNKNOWN_SYSTEM_VARIABLE = 1193 +CRASHED_ON_USAGE = 1194 +CRASHED_ON_REPAIR = 1195 +WARNING_NOT_COMPLETE_ROLLBACK = 1196 +TRANS_CACHE_FULL = 1197 +SLAVE_MUST_STOP = 1198 +SLAVE_NOT_RUNNING = 1199 +BAD_SLAVE = 1200 +MASTER_INFO = 1201 +SLAVE_THREAD = 1202 +TOO_MANY_USER_CONNECTIONS = 1203 +SET_CONSTANTS_ONLY = 1204 +LOCK_WAIT_TIMEOUT = 1205 +LOCK_TABLE_FULL = 1206 +READ_ONLY_TRANSACTION = 1207 +DROP_DB_WITH_READ_LOCK = 1208 +CREATE_DB_WITH_READ_LOCK = 1209 +WRONG_ARGUMENTS = 1210 +NO_PERMISSION_TO_CREATE_USER = 1211 +UNION_TABLES_IN_DIFFERENT_DIR = 1212 +LOCK_DEADLOCK = 1213 +TABLE_CANT_HANDLE_FT = 1214 +CANNOT_ADD_FOREIGN = 1215 +NO_REFERENCED_ROW = 1216 +ROW_IS_REFERENCED = 1217 +CONNECT_TO_MASTER = 1218 +QUERY_ON_MASTER = 1219 +ERROR_WHEN_EXECUTING_COMMAND = 1220 +WRONG_USAGE = 1221 +WRONG_NUMBER_OF_COLUMNS_IN_SELECT = 1222 +CANT_UPDATE_WITH_READLOCK = 1223 +MIXING_NOT_ALLOWED = 1224 +DUP_ARGUMENT = 1225 +USER_LIMIT_REACHED = 1226 +SPECIFIC_ACCESS_DENIED_ERROR = 1227 +LOCAL_VARIABLE = 1228 +GLOBAL_VARIABLE = 1229 +NO_DEFAULT = 1230 +WRONG_VALUE_FOR_VAR = 1231 +WRONG_TYPE_FOR_VAR = 1232 +VAR_CANT_BE_READ = 1233 +CANT_USE_OPTION_HERE = 1234 +NOT_SUPPORTED_YET = 1235 +MASTER_FATAL_ERROR_READING_BINLOG = 1236 +SLAVE_IGNORED_TABLE = 1237 +INCORRECT_GLOBAL_LOCAL_VAR = 1238 +WRONG_FK_DEF = 1239 +KEY_REF_DO_NOT_MATCH_TABLE_REF = 1240 +OPERAND_COLUMNS = 1241 +SUBQUERY_NO_1_ROW = 1242 +UNKNOWN_STMT_HANDLER = 1243 +CORRUPT_HELP_DB = 1244 +CYCLIC_REFERENCE = 1245 +AUTO_CONVERT = 1246 +ILLEGAL_REFERENCE = 1247 +DERIVED_MUST_HAVE_ALIAS = 1248 +SELECT_REDUCED = 1249 +TABLENAME_NOT_ALLOWED_HERE = 1250 +NOT_SUPPORTED_AUTH_MODE = 1251 +SPATIAL_CANT_HAVE_NULL = 1252 +COLLATION_CHARSET_MISMATCH = 1253 +SLAVE_WAS_RUNNING = 1254 +SLAVE_WAS_NOT_RUNNING = 1255 +TOO_BIG_FOR_UNCOMPRESS = 1256 +ZLIB_Z_MEM_ERROR = 1257 +ZLIB_Z_BUF_ERROR = 1258 +ZLIB_Z_DATA_ERROR = 1259 +CUT_VALUE_GROUP_CONCAT = 1260 +WARN_TOO_FEW_RECORDS = 1261 +WARN_TOO_MANY_RECORDS = 1262 +WARN_NULL_TO_NOTNULL = 1263 +WARN_DATA_OUT_OF_RANGE = 1264 +WARN_DATA_TRUNCATED = 1265 +WARN_USING_OTHER_HANDLER = 1266 +CANT_AGGREGATE_2COLLATIONS = 1267 +DROP_USER = 1268 +REVOKE_GRANTS = 1269 +CANT_AGGREGATE_3COLLATIONS = 1270 +CANT_AGGREGATE_NCOLLATIONS = 1271 +VARIABLE_IS_NOT_STRUCT = 1272 +UNKNOWN_COLLATION = 1273 +SLAVE_IGNORED_SSL_PARAMS = 1274 +SERVER_IS_IN_SECURE_AUTH_MODE = 1275 +WARN_FIELD_RESOLVED = 1276 +BAD_SLAVE_UNTIL_COND = 1277 +MISSING_SKIP_SLAVE = 1278 +UNTIL_COND_IGNORED = 1279 +WRONG_NAME_FOR_INDEX = 1280 +WRONG_NAME_FOR_CATALOG = 1281 +WARN_QC_RESIZE = 1282 +BAD_FT_COLUMN = 1283 +UNKNOWN_KEY_CACHE = 1284 +WARN_HOSTNAME_WONT_WORK = 1285 +UNKNOWN_STORAGE_ENGINE = 1286 +WARN_DEPRECATED_SYNTAX = 1287 +NON_UPDATABLE_TABLE = 1288 +FEATURE_DISABLED = 1289 +OPTION_PREVENTS_STATEMENT = 1290 +DUPLICATED_VALUE_IN_TYPE = 1291 +TRUNCATED_WRONG_VALUE = 1292 +TOO_MUCH_AUTO_TIMESTAMP_COLS = 1293 +INVALID_ON_UPDATE = 1294 +UNSUPPORTED_PS = 1295 +GET_ERRMSG = 1296 +GET_TEMPORARY_ERRMSG = 1297 +UNKNOWN_TIME_ZONE = 1298 +WARN_INVALID_TIMESTAMP = 1299 +INVALID_CHARACTER_STRING = 1300 +WARN_ALLOWED_PACKET_OVERFLOWED = 1301 +CONFLICTING_DECLARATIONS = 1302 +SP_NO_RECURSIVE_CREATE = 1303 +SP_ALREADY_EXISTS = 1304 +SP_DOES_NOT_EXIST = 1305 +SP_DROP_FAILED = 1306 +SP_STORE_FAILED = 1307 +SP_LILABEL_MISMATCH = 1308 +SP_LABEL_REDEFINE = 1309 +SP_LABEL_MISMATCH = 1310 +SP_UNINIT_VAR = 1311 +SP_BADSELECT = 1312 +SP_BADRETURN = 1313 +SP_BADSTATEMENT = 1314 +UPDATE_LOG_DEPRECATED_IGNORED = 1315 +UPDATE_LOG_DEPRECATED_TRANSLATED = 1316 +QUERY_INTERRUPTED = 1317 +SP_WRONG_NO_OF_ARGS = 1318 +SP_COND_MISMATCH = 1319 +SP_NORETURN = 1320 +SP_NORETURNEND = 1321 +SP_BAD_CURSOR_QUERY = 1322 +SP_BAD_CURSOR_SELECT = 1323 +SP_CURSOR_MISMATCH = 1324 +SP_CURSOR_ALREADY_OPEN = 1325 +SP_CURSOR_NOT_OPEN = 1326 +SP_UNDECLARED_VAR = 1327 +SP_WRONG_NO_OF_FETCH_ARGS = 1328 +SP_FETCH_NO_DATA = 1329 +SP_DUP_PARAM = 1330 +SP_DUP_VAR = 1331 +SP_DUP_COND = 1332 +SP_DUP_CURS = 1333 +SP_CANT_ALTER = 1334 +SP_SUBSELECT_NYI = 1335 +STMT_NOT_ALLOWED_IN_SF_OR_TRG = 1336 +SP_VARCOND_AFTER_CURSHNDLR = 1337 +SP_CURSOR_AFTER_HANDLER = 1338 +SP_CASE_NOT_FOUND = 1339 +FPARSER_TOO_BIG_FILE = 1340 +FPARSER_BAD_HEADER = 1341 +FPARSER_EOF_IN_COMMENT = 1342 +FPARSER_ERROR_IN_PARAMETER = 1343 +FPARSER_EOF_IN_UNKNOWN_PARAMETER = 1344 +VIEW_NO_EXPLAIN = 1345 +FRM_UNKNOWN_TYPE = 1346 +WRONG_OBJECT = 1347 +NONUPDATEABLE_COLUMN = 1348 +VIEW_SELECT_DERIVED = 1349 +VIEW_SELECT_CLAUSE = 1350 +VIEW_SELECT_VARIABLE = 1351 +VIEW_SELECT_TMPTABLE = 1352 +VIEW_WRONG_LIST = 1353 +WARN_VIEW_MERGE = 1354 +WARN_VIEW_WITHOUT_KEY = 1355 +VIEW_INVALID = 1356 +SP_NO_DROP_SP = 1357 +SP_GOTO_IN_HNDLR = 1358 +TRG_ALREADY_EXISTS = 1359 +TRG_DOES_NOT_EXIST = 1360 +TRG_ON_VIEW_OR_TEMP_TABLE = 1361 +TRG_CANT_CHANGE_ROW = 1362 +TRG_NO_SUCH_ROW_IN_TRG = 1363 +NO_DEFAULT_FOR_FIELD = 1364 +DIVISION_BY_ZERO = 1365 +TRUNCATED_WRONG_VALUE_FOR_FIELD = 1366 +ILLEGAL_VALUE_FOR_TYPE = 1367 +VIEW_NONUPD_CHECK = 1368 +VIEW_CHECK_FAILED = 1369 +PROCACCESS_DENIED_ERROR = 1370 +RELAY_LOG_FAIL = 1371 +PASSWD_LENGTH = 1372 +UNKNOWN_TARGET_BINLOG = 1373 +IO_ERR_LOG_INDEX_READ = 1374 +BINLOG_PURGE_PROHIBITED = 1375 +FSEEK_FAIL = 1376 +BINLOG_PURGE_FATAL_ERR = 1377 +LOG_IN_USE = 1378 +LOG_PURGE_UNKNOWN_ERR = 1379 +RELAY_LOG_INIT = 1380 +NO_BINARY_LOGGING = 1381 +RESERVED_SYNTAX = 1382 +WSAS_FAILED = 1383 +DIFF_GROUPS_PROC = 1384 +NO_GROUP_FOR_PROC = 1385 +ORDER_WITH_PROC = 1386 +LOGGING_PROHIBIT_CHANGING_OF = 1387 +NO_FILE_MAPPING = 1388 +WRONG_MAGIC = 1389 +PS_MANY_PARAM = 1390 +KEY_PART_0 = 1391 +VIEW_CHECKSUM = 1392 +VIEW_MULTIUPDATE = 1393 +VIEW_NO_INSERT_FIELD_LIST = 1394 +VIEW_DELETE_MERGE_VIEW = 1395 +CANNOT_USER = 1396 +XAER_NOTA = 1397 +XAER_INVAL = 1398 +XAER_RMFAIL = 1399 +XAER_OUTSIDE = 1400 +XAER_RMERR = 1401 +XA_RBROLLBACK = 1402 +NONEXISTING_PROC_GRANT = 1403 +PROC_AUTO_GRANT_FAIL = 1404 +PROC_AUTO_REVOKE_FAIL = 1405 +DATA_TOO_LONG = 1406 +SP_BAD_SQLSTATE = 1407 +STARTUP = 1408 +LOAD_FROM_FIXED_SIZE_ROWS_TO_VAR = 1409 +CANT_CREATE_USER_WITH_GRANT = 1410 +WRONG_VALUE_FOR_TYPE = 1411 +TABLE_DEF_CHANGED = 1412 +SP_DUP_HANDLER = 1413 +SP_NOT_VAR_ARG = 1414 +SP_NO_RETSET = 1415 +CANT_CREATE_GEOMETRY_OBJECT = 1416 +FAILED_ROUTINE_BREAK_BINLOG = 1417 +BINLOG_UNSAFE_ROUTINE = 1418 +BINLOG_CREATE_ROUTINE_NEED_SUPER = 1419 +EXEC_STMT_WITH_OPEN_CURSOR = 1420 +STMT_HAS_NO_OPEN_CURSOR = 1421 +COMMIT_NOT_ALLOWED_IN_SF_OR_TRG = 1422 +NO_DEFAULT_FOR_VIEW_FIELD = 1423 +SP_NO_RECURSION = 1424 +TOO_BIG_SCALE = 1425 +TOO_BIG_PRECISION = 1426 +M_BIGGER_THAN_D = 1427 +WRONG_LOCK_OF_SYSTEM_TABLE = 1428 +CONNECT_TO_FOREIGN_DATA_SOURCE = 1429 +QUERY_ON_FOREIGN_DATA_SOURCE = 1430 +FOREIGN_DATA_SOURCE_DOESNT_EXIST = 1431 +FOREIGN_DATA_STRING_INVALID_CANT_CREATE = 1432 +FOREIGN_DATA_STRING_INVALID = 1433 +CANT_CREATE_FEDERATED_TABLE = 1434 +TRG_IN_WRONG_SCHEMA = 1435 +STACK_OVERRUN_NEED_MORE = 1436 +TOO_LONG_BODY = 1437 +WARN_CANT_DROP_DEFAULT_KEYCACHE = 1438 +TOO_BIG_DISPLAYWIDTH = 1439 +XAER_DUPID = 1440 +DATETIME_FUNCTION_OVERFLOW = 1441 +CANT_UPDATE_USED_TABLE_IN_SF_OR_TRG = 1442 +VIEW_PREVENT_UPDATE = 1443 +PS_NO_RECURSION = 1444 +SP_CANT_SET_AUTOCOMMIT = 1445 +MALFORMED_DEFINER = 1446 +VIEW_FRM_NO_USER = 1447 +VIEW_OTHER_USER = 1448 +NO_SUCH_USER = 1449 +FORBID_SCHEMA_CHANGE = 1450 +ROW_IS_REFERENCED_2 = 1451 +NO_REFERENCED_ROW_2 = 1452 +SP_BAD_VAR_SHADOW = 1453 +TRG_NO_DEFINER = 1454 +OLD_FILE_FORMAT = 1455 +SP_RECURSION_LIMIT = 1456 +SP_PROC_TABLE_CORRUPT = 1457 +SP_WRONG_NAME = 1458 +TABLE_NEEDS_UPGRADE = 1459 +SP_NO_AGGREGATE = 1460 +MAX_PREPARED_STMT_COUNT_REACHED = 1461 +VIEW_RECURSIVE = 1462 +NON_GROUPING_FIELD_USED = 1463 +TABLE_CANT_HANDLE_SPKEYS = 1464 +NO_TRIGGERS_ON_SYSTEM_SCHEMA = 1465 +USERNAME = 1466 +HOSTNAME = 1467 +WRONG_STRING_LENGTH = 1468 +ERROR_LAST = 1468 + +# MariaDB only +STATEMENT_TIMEOUT = 1969 +QUERY_TIMEOUT = 3024 +# https://github.com/PyMySQL/PyMySQL/issues/607 +CONSTRAINT_FAILED = 4025 diff --git a/plugins/module_utils/pymysql/constants/FIELD_TYPE.py b/plugins/module_utils/pymysql/constants/FIELD_TYPE.py new file mode 100644 index 0000000..b8b4486 --- /dev/null +++ b/plugins/module_utils/pymysql/constants/FIELD_TYPE.py @@ -0,0 +1,31 @@ +DECIMAL = 0 +TINY = 1 +SHORT = 2 +LONG = 3 +FLOAT = 4 +DOUBLE = 5 +NULL = 6 +TIMESTAMP = 7 +LONGLONG = 8 +INT24 = 9 +DATE = 10 +TIME = 11 +DATETIME = 12 +YEAR = 13 +NEWDATE = 14 +VARCHAR = 15 +BIT = 16 +JSON = 245 +NEWDECIMAL = 246 +ENUM = 247 +SET = 248 +TINY_BLOB = 249 +MEDIUM_BLOB = 250 +LONG_BLOB = 251 +BLOB = 252 +VAR_STRING = 253 +STRING = 254 +GEOMETRY = 255 + +CHAR = TINY +INTERVAL = ENUM diff --git a/plugins/module_utils/pymysql/constants/FLAG.py b/plugins/module_utils/pymysql/constants/FLAG.py new file mode 100644 index 0000000..f9ebfad --- /dev/null +++ b/plugins/module_utils/pymysql/constants/FLAG.py @@ -0,0 +1,15 @@ +NOT_NULL = 1 +PRI_KEY = 2 +UNIQUE_KEY = 4 +MULTIPLE_KEY = 8 +BLOB = 16 +UNSIGNED = 32 +ZEROFILL = 64 +BINARY = 128 +ENUM = 256 +AUTO_INCREMENT = 512 +TIMESTAMP = 1024 +SET = 2048 +PART_KEY = 16384 +GROUP = 32767 +UNIQUE = 65536 diff --git a/plugins/module_utils/pymysql/constants/SERVER_STATUS.py b/plugins/module_utils/pymysql/constants/SERVER_STATUS.py new file mode 100644 index 0000000..8f8d776 --- /dev/null +++ b/plugins/module_utils/pymysql/constants/SERVER_STATUS.py @@ -0,0 +1,10 @@ +SERVER_STATUS_IN_TRANS = 1 +SERVER_STATUS_AUTOCOMMIT = 2 +SERVER_MORE_RESULTS_EXISTS = 8 +SERVER_QUERY_NO_GOOD_INDEX_USED = 16 +SERVER_QUERY_NO_INDEX_USED = 32 +SERVER_STATUS_CURSOR_EXISTS = 64 +SERVER_STATUS_LAST_ROW_SENT = 128 +SERVER_STATUS_DB_DROPPED = 256 +SERVER_STATUS_NO_BACKSLASH_ESCAPES = 512 +SERVER_STATUS_METADATA_CHANGED = 1024 diff --git a/plugins/module_utils/pymysql/constants/__init__.py b/plugins/module_utils/pymysql/constants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/module_utils/pymysql/converters.py b/plugins/module_utils/pymysql/converters.py new file mode 100644 index 0000000..dbf97ca --- /dev/null +++ b/plugins/module_utils/pymysql/converters.py @@ -0,0 +1,363 @@ +import datetime +from decimal import Decimal +import re +import time + +from .err import ProgrammingError +from .constants import FIELD_TYPE + + +def escape_item(val, charset, mapping=None): + if mapping is None: + mapping = encoders + encoder = mapping.get(type(val)) + + # Fallback to default when no encoder found + if not encoder: + try: + encoder = mapping[str] + except KeyError: + raise TypeError("no default type converter defined") + + if encoder in (escape_dict, escape_sequence): + val = encoder(val, charset, mapping) + else: + val = encoder(val, mapping) + return val + + +def escape_dict(val, charset, mapping=None): + raise TypeError("dict can not be used as parameter") + + +def escape_sequence(val, charset, mapping=None): + n = [] + for item in val: + quoted = escape_item(item, charset, mapping) + n.append(quoted) + return "(" + ",".join(n) + ")" + + +def escape_set(val, charset, mapping=None): + return ",".join([escape_item(x, charset, mapping) for x in val]) + + +def escape_bool(value, mapping=None): + return str(int(value)) + + +def escape_int(value, mapping=None): + return str(value) + + +def escape_float(value, mapping=None): + s = repr(value) + if s in ("inf", "-inf", "nan"): + raise ProgrammingError("%s can not be used with MySQL" % s) + if "e" not in s: + s += "e0" + return s + + +_escape_table = [chr(x) for x in range(128)] +_escape_table[0] = "\\0" +_escape_table[ord("\\")] = "\\\\" +_escape_table[ord("\n")] = "\\n" +_escape_table[ord("\r")] = "\\r" +_escape_table[ord("\032")] = "\\Z" +_escape_table[ord('"')] = '\\"' +_escape_table[ord("'")] = "\\'" + + +def escape_string(value, mapping=None): + """escapes *value* without adding quote. + + Value should be unicode + """ + return value.translate(_escape_table) + + +def escape_bytes_prefixed(value, mapping=None): + return "_binary'%s'" % value.decode("ascii", "surrogateescape").translate( + _escape_table + ) + + +def escape_bytes(value, mapping=None): + return "'%s'" % value.decode("ascii", "surrogateescape").translate(_escape_table) + + +def escape_str(value, mapping=None): + return "'%s'" % escape_string(str(value), mapping) + + +def escape_None(value, mapping=None): + return "NULL" + + +def escape_timedelta(obj, mapping=None): + seconds = int(obj.seconds) % 60 + minutes = int(obj.seconds // 60) % 60 + hours = int(obj.seconds // 3600) % 24 + int(obj.days) * 24 + if obj.microseconds: + fmt = "'{0:02d}:{1:02d}:{2:02d}.{3:06d}'" + else: + fmt = "'{0:02d}:{1:02d}:{2:02d}'" + return fmt.format(hours, minutes, seconds, obj.microseconds) + + +def escape_time(obj, mapping=None): + if obj.microsecond: + fmt = "'{0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'" + else: + fmt = "'{0.hour:02}:{0.minute:02}:{0.second:02}'" + return fmt.format(obj) + + +def escape_datetime(obj, mapping=None): + if obj.microsecond: + fmt = ( + "'{0.year:04}-{0.month:02}-{0.day:02}" + + " {0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'" + ) + else: + fmt = "'{0.year:04}-{0.month:02}-{0.day:02} {0.hour:02}:{0.minute:02}:{0.second:02}'" + return fmt.format(obj) + + +def escape_date(obj, mapping=None): + fmt = "'{0.year:04}-{0.month:02}-{0.day:02}'" + return fmt.format(obj) + + +def escape_struct_time(obj, mapping=None): + return escape_datetime(datetime.datetime(*obj[:6])) + + +def Decimal2Literal(o, d): + return format(o, "f") + + +def _convert_second_fraction(s): + if not s: + return 0 + # Pad zeros to ensure the fraction length in microseconds + s = s.ljust(6, "0") + return int(s[:6]) + + +DATETIME_RE = re.compile( + r"(\d{1,4})-(\d{1,2})-(\d{1,2})[T ](\d{1,2}):(\d{1,2}):(\d{1,2})(?:.(\d{1,6}))?" +) + + +def convert_datetime(obj): + """Returns a DATETIME or TIMESTAMP column value as a datetime object: + + >>> convert_datetime('2007-02-25 23:06:20') + datetime.datetime(2007, 2, 25, 23, 6, 20) + >>> convert_datetime('2007-02-25T23:06:20') + datetime.datetime(2007, 2, 25, 23, 6, 20) + + Illegal values are returned as str: + + >>> convert_datetime('2007-02-31T23:06:20') + '2007-02-31T23:06:20' + >>> convert_datetime('0000-00-00 00:00:00') + '0000-00-00 00:00:00' + """ + if isinstance(obj, (bytes, bytearray)): + obj = obj.decode("ascii") + + m = DATETIME_RE.match(obj) + if not m: + return convert_date(obj) + + try: + groups = list(m.groups()) + groups[-1] = _convert_second_fraction(groups[-1]) + return datetime.datetime(*[int(x) for x in groups]) + except ValueError: + return convert_date(obj) + + +TIMEDELTA_RE = re.compile(r"(-)?(\d{1,3}):(\d{1,2}):(\d{1,2})(?:.(\d{1,6}))?") + + +def convert_timedelta(obj): + """Returns a TIME column as a timedelta object: + + >>> convert_timedelta('25:06:17') + datetime.timedelta(days=1, seconds=3977) + >>> convert_timedelta('-25:06:17') + datetime.timedelta(days=-2, seconds=82423) + + Illegal values are returned as string: + + >>> convert_timedelta('random crap') + 'random crap' + + Note that MySQL always returns TIME columns as (+|-)HH:MM:SS, but + can accept values as (+|-)DD HH:MM:SS. The latter format will not + be parsed correctly by this function. + """ + if isinstance(obj, (bytes, bytearray)): + obj = obj.decode("ascii") + + m = TIMEDELTA_RE.match(obj) + if not m: + return obj + + try: + groups = list(m.groups()) + groups[-1] = _convert_second_fraction(groups[-1]) + negate = -1 if groups[0] else 1 + hours, minutes, seconds, microseconds = groups[1:] + + tdelta = ( + datetime.timedelta( + hours=int(hours), + minutes=int(minutes), + seconds=int(seconds), + microseconds=int(microseconds), + ) + * negate + ) + return tdelta + except ValueError: + return obj + + +TIME_RE = re.compile(r"(\d{1,2}):(\d{1,2}):(\d{1,2})(?:.(\d{1,6}))?") + + +def convert_time(obj): + """Returns a TIME column as a time object: + + >>> convert_time('15:06:17') + datetime.time(15, 6, 17) + + Illegal values are returned as str: + + >>> convert_time('-25:06:17') + '-25:06:17' + >>> convert_time('random crap') + 'random crap' + + Note that MySQL always returns TIME columns as (+|-)HH:MM:SS, but + can accept values as (+|-)DD HH:MM:SS. The latter format will not + be parsed correctly by this function. + + Also note that MySQL's TIME column corresponds more closely to + Python's timedelta and not time. However if you want TIME columns + to be treated as time-of-day and not a time offset, then you can + use set this function as the converter for FIELD_TYPE.TIME. + """ + if isinstance(obj, (bytes, bytearray)): + obj = obj.decode("ascii") + + m = TIME_RE.match(obj) + if not m: + return obj + + try: + groups = list(m.groups()) + groups[-1] = _convert_second_fraction(groups[-1]) + hours, minutes, seconds, microseconds = groups + return datetime.time( + hour=int(hours), + minute=int(minutes), + second=int(seconds), + microsecond=int(microseconds), + ) + except ValueError: + return obj + + +def convert_date(obj): + """Returns a DATE column as a date object: + + >>> convert_date('2007-02-26') + datetime.date(2007, 2, 26) + + Illegal values are returned as str: + + >>> convert_date('2007-02-31') + '2007-02-31' + >>> convert_date('0000-00-00') + '0000-00-00' + """ + if isinstance(obj, (bytes, bytearray)): + obj = obj.decode("ascii") + try: + return datetime.date(*[int(x) for x in obj.split("-", 2)]) + except ValueError: + return obj + + +def through(x): + return x + + +# def convert_bit(b): +# b = "\x00" * (8 - len(b)) + b # pad w/ zeroes +# return struct.unpack(">Q", b)[0] +# +# the snippet above is right, but MySQLdb doesn't process bits, +# so we shouldn't either +convert_bit = through + + +encoders = { + bool: escape_bool, + int: escape_int, + float: escape_float, + str: escape_str, + bytes: escape_bytes, + tuple: escape_sequence, + list: escape_sequence, + set: escape_sequence, + frozenset: escape_sequence, + dict: escape_dict, + type(None): escape_None, + datetime.date: escape_date, + datetime.datetime: escape_datetime, + datetime.timedelta: escape_timedelta, + datetime.time: escape_time, + time.struct_time: escape_struct_time, + Decimal: Decimal2Literal, +} + + +decoders = { + FIELD_TYPE.BIT: convert_bit, + FIELD_TYPE.TINY: int, + FIELD_TYPE.SHORT: int, + FIELD_TYPE.LONG: int, + FIELD_TYPE.FLOAT: float, + FIELD_TYPE.DOUBLE: float, + FIELD_TYPE.LONGLONG: int, + FIELD_TYPE.INT24: int, + FIELD_TYPE.YEAR: int, + FIELD_TYPE.TIMESTAMP: convert_datetime, + FIELD_TYPE.DATETIME: convert_datetime, + FIELD_TYPE.TIME: convert_timedelta, + FIELD_TYPE.DATE: convert_date, + FIELD_TYPE.BLOB: through, + FIELD_TYPE.TINY_BLOB: through, + FIELD_TYPE.MEDIUM_BLOB: through, + FIELD_TYPE.LONG_BLOB: through, + FIELD_TYPE.STRING: through, + FIELD_TYPE.VAR_STRING: through, + FIELD_TYPE.VARCHAR: through, + FIELD_TYPE.DECIMAL: Decimal, + FIELD_TYPE.NEWDECIMAL: Decimal, +} + + +# for MySQLdb compatibility +conversions = encoders.copy() +conversions.update(decoders) +Thing2Literal = escape_str + +# Run doctests with `pytest --doctest-modules pymysql/converters.py` diff --git a/plugins/module_utils/pymysql/cursors.py b/plugins/module_utils/pymysql/cursors.py new file mode 100644 index 0000000..8be05ca --- /dev/null +++ b/plugins/module_utils/pymysql/cursors.py @@ -0,0 +1,531 @@ +import re +import warnings +from . import err + + +#: Regular expression for :meth:`Cursor.executemany`. +#: executemany only supports simple bulk insert. +#: You can use it to load large dataset. +RE_INSERT_VALUES = re.compile( + r"\s*((?:INSERT|REPLACE)\b.+\bVALUES?\s*)" + + r"(\(\s*(?:%s|%\(.+\)s)\s*(?:,\s*(?:%s|%\(.+\)s)\s*)*\))" + + r"(\s*(?:ON DUPLICATE.*)?);?\s*\Z", + re.IGNORECASE | re.DOTALL, +) + + +class Cursor: + """ + This is the object used to interact with the database. + + Do not create an instance of a Cursor yourself. Call + connections.Connection.cursor(). + + See `Cursor `_ in + the specification. + """ + + #: Max statement size which :meth:`executemany` generates. + #: + #: Max size of allowed statement is max_allowed_packet - packet_header_size. + #: Default value of max_allowed_packet is 1048576. + max_stmt_length = 1024000 + + def __init__(self, connection): + self.connection = connection + self.warning_count = 0 + self.description = None + self.rownumber = 0 + self.rowcount = -1 + self.arraysize = 1 + self._executed = None + self._result = None + self._rows = None + + def close(self): + """ + Closing a cursor just exhausts all remaining data. + """ + conn = self.connection + if conn is None: + return + try: + while self.nextset(): + pass + finally: + self.connection = None + + def __enter__(self): + return self + + def __exit__(self, *exc_info): + del exc_info + self.close() + + def _get_db(self): + if not self.connection: + raise err.ProgrammingError("Cursor closed") + return self.connection + + def _check_executed(self): + if not self._executed: + raise err.ProgrammingError("execute() first") + + def _conv_row(self, row): + return row + + def setinputsizes(self, *args): + """Does nothing, required by DB API.""" + + def setoutputsizes(self, *args): + """Does nothing, required by DB API.""" + + def _nextset(self, unbuffered=False): + """Get the next query set.""" + conn = self._get_db() + current_result = self._result + if current_result is None or current_result is not conn._result: + return None + if not current_result.has_next: + return None + self._result = None + self._clear_result() + conn.next_result(unbuffered=unbuffered) + self._do_get_result() + return True + + def nextset(self): + return self._nextset(False) + + def _escape_args(self, args, conn): + if isinstance(args, (tuple, list)): + return tuple(conn.literal(arg) for arg in args) + elif isinstance(args, dict): + return {key: conn.literal(val) for (key, val) in args.items()} + else: + # If it's not a dictionary let's try escaping it anyways. + # Worst case it will throw a Value error + return conn.escape(args) + + def mogrify(self, query, args=None): + """ + Returns the exact string that would be sent to the database by calling the + execute() method. + + :param query: Query to mogrify. + :type query: str + + :param args: Parameters used with query. (optional) + :type args: tuple, list or dict + + :return: The query with argument binding applied. + :rtype: str + + This method follows the extension to the DB API 2.0 followed by Psycopg. + """ + conn = self._get_db() + + if args is not None: + query = query % self._escape_args(args, conn) + + return query + + def execute(self, query, args=None): + """Execute a query. + + :param query: Query to execute. + :type query: str + + :param args: Parameters used with query. (optional) + :type args: tuple, list or dict + + :return: Number of affected rows. + :rtype: int + + If args is a list or tuple, %s can be used as a placeholder in the query. + If args is a dict, %(name)s can be used as a placeholder in the query. + """ + while self.nextset(): + pass + + query = self.mogrify(query, args) + + result = self._query(query) + self._executed = query + return result + + def executemany(self, query, args): + """Run several data against one query. + + :param query: Query to execute. + :type query: str + + :param args: Sequence of sequences or mappings. It is used as parameter. + :type args: tuple or list + + :return: Number of rows affected, if any. + :rtype: int or None + + This method improves performance on multiple-row INSERT and + REPLACE. Otherwise it is equivalent to looping over args with + execute(). + """ + if not args: + return + + m = RE_INSERT_VALUES.match(query) + if m: + q_prefix = m.group(1) % () + q_values = m.group(2).rstrip() + q_postfix = m.group(3) or "" + assert q_values[0] == "(" and q_values[-1] == ")" + return self._do_execute_many( + q_prefix, + q_values, + q_postfix, + args, + self.max_stmt_length, + self._get_db().encoding, + ) + + self.rowcount = sum(self.execute(query, arg) for arg in args) + return self.rowcount + + def _do_execute_many( + self, prefix, values, postfix, args, max_stmt_length, encoding + ): + conn = self._get_db() + escape = self._escape_args + if isinstance(prefix, str): + prefix = prefix.encode(encoding) + if isinstance(postfix, str): + postfix = postfix.encode(encoding) + sql = bytearray(prefix) + args = iter(args) + v = values % escape(next(args), conn) + if isinstance(v, str): + v = v.encode(encoding, "surrogateescape") + sql += v + rows = 0 + for arg in args: + v = values % escape(arg, conn) + if isinstance(v, str): + v = v.encode(encoding, "surrogateescape") + if len(sql) + len(v) + len(postfix) + 1 > max_stmt_length: + rows += self.execute(sql + postfix) + sql = bytearray(prefix) + else: + sql += b"," + sql += v + rows += self.execute(sql + postfix) + self.rowcount = rows + return rows + + def callproc(self, procname, args=()): + """Execute stored procedure procname with args. + + :param procname: Name of procedure to execute on server. + :type procname: str + + :param args: Sequence of parameters to use with procedure. + :type args: tuple or list + + Returns the original args. + + Compatibility warning: PEP-249 specifies that any modified + parameters must be returned. This is currently impossible + as they are only available by storing them in a server + variable and then retrieved by a query. Since stored + procedures return zero or more result sets, there is no + reliable way to get at OUT or INOUT parameters via callproc. + The server variables are named @_procname_n, where procname + is the parameter above and n is the position of the parameter + (from zero). Once all result sets generated by the procedure + have been fetched, you can issue a SELECT @_procname_0, ... + query using .execute() to get any OUT or INOUT values. + + Compatibility warning: The act of calling a stored procedure + itself creates an empty result set. This appears after any + result sets generated by the procedure. This is non-standard + behavior with respect to the DB-API. Be sure to use nextset() + to advance through all result sets; otherwise you may get + disconnected. + """ + conn = self._get_db() + if args: + fmt = f"@_{procname}_%d=%s" + self._query( + "SET %s" + % ",".join( + fmt % (index, conn.escape(arg)) for index, arg in enumerate(args) + ) + ) + self.nextset() + + q = "CALL {}({})".format( + procname, + ",".join(["@_%s_%d" % (procname, i) for i in range(len(args))]), + ) + self._query(q) + self._executed = q + return args + + def fetchone(self): + """Fetch the next row.""" + self._check_executed() + if self._rows is None or self.rownumber >= len(self._rows): + return None + result = self._rows[self.rownumber] + self.rownumber += 1 + return result + + def fetchmany(self, size=None): + """Fetch several rows.""" + self._check_executed() + if self._rows is None: + # Django expects () for EOF. + # https://github.com/django/django/blob/0c1518ee429b01c145cf5b34eab01b0b92f8c246/django/db/backends/mysql/features.py#L8 + return () + end = self.rownumber + (size or self.arraysize) + result = self._rows[self.rownumber : end] + self.rownumber = min(end, len(self._rows)) + return result + + def fetchall(self): + """Fetch all the rows.""" + self._check_executed() + if self._rows is None: + return [] + if self.rownumber: + result = self._rows[self.rownumber :] + else: + result = self._rows + self.rownumber = len(self._rows) + return result + + def scroll(self, value, mode="relative"): + self._check_executed() + if mode == "relative": + r = self.rownumber + value + elif mode == "absolute": + r = value + else: + raise err.ProgrammingError("unknown scroll mode %s" % mode) + + if not (0 <= r < len(self._rows)): + raise IndexError("out of range") + self.rownumber = r + + def _query(self, q): + conn = self._get_db() + self._clear_result() + conn.query(q) + self._do_get_result() + return self.rowcount + + def _clear_result(self): + self.rownumber = 0 + self._result = None + + self.rowcount = 0 + self.warning_count = 0 + self.description = None + self.lastrowid = None + self._rows = None + + def _do_get_result(self): + conn = self._get_db() + + self._result = result = conn._result + + self.rowcount = result.affected_rows + self.warning_count = result.warning_count + self.description = result.description + self.lastrowid = result.insert_id + self._rows = result.rows + + def __iter__(self): + return self + + def __next__(self): + row = self.fetchone() + if row is None: + raise StopIteration + return row + + def __getattr__(self, name): + # DB-API 2.0 optional extension says these errors can be accessed + # via Connection object. But MySQLdb had defined them on Cursor object. + if name in ( + "Warning", + "Error", + "InterfaceError", + "DatabaseError", + "DataError", + "OperationalError", + "IntegrityError", + "InternalError", + "ProgrammingError", + "NotSupportedError", + ): + # Deprecated since v1.1 + warnings.warn( + "PyMySQL errors hould be accessed from `pymysql` package", + DeprecationWarning, + stacklevel=2, + ) + return getattr(err, name) + raise AttributeError(name) + + +class DictCursorMixin: + # You can override this to use OrderedDict or other dict-like types. + dict_type = dict + + def _do_get_result(self): + super()._do_get_result() + fields = [] + if self.description: + for f in self._result.fields: + name = f.name + if name in fields: + name = f.table_name + "." + name + fields.append(name) + self._fields = fields + + if fields and self._rows: + self._rows = [self._conv_row(r) for r in self._rows] + + def _conv_row(self, row): + if row is None: + return None + return self.dict_type(zip(self._fields, row)) + + +class DictCursor(DictCursorMixin, Cursor): + """A cursor which returns results as a dictionary""" + + +class SSCursor(Cursor): + """ + Unbuffered Cursor, mainly useful for queries that return a lot of data, + or for connections to remote servers over a slow network. + + Instead of copying every row of data into a buffer, this will fetch + rows as needed. The upside of this is the client uses much less memory, + and rows are returned much faster when traveling over a slow network + or if the result set is very big. + + There are limitations, though. The MySQL protocol doesn't support + returning the total number of rows, so the only way to tell how many rows + there are is to iterate over every row returned. Also, it currently isn't + possible to scroll backwards, as only the current row is held in memory. + """ + + def _conv_row(self, row): + return row + + def close(self): + conn = self.connection + if conn is None: + return + + if self._result is not None and self._result is conn._result: + self._result._finish_unbuffered_query() + + try: + while self.nextset(): + pass + finally: + self.connection = None + + __del__ = close + + def _query(self, q): + conn = self._get_db() + self._clear_result() + conn.query(q, unbuffered=True) + self._do_get_result() + return self.rowcount + + def nextset(self): + return self._nextset(unbuffered=True) + + def read_next(self): + """Read next row.""" + return self._conv_row(self._result._read_rowdata_packet_unbuffered()) + + def fetchone(self): + """Fetch next row.""" + self._check_executed() + row = self.read_next() + if row is None: + self.warning_count = self._result.warning_count + return None + self.rownumber += 1 + return row + + def fetchall(self): + """ + Fetch all, as per MySQLdb. Pretty useless for large queries, as + it is buffered. See fetchall_unbuffered(), if you want an unbuffered + generator version of this method. + """ + return list(self.fetchall_unbuffered()) + + def fetchall_unbuffered(self): + """ + Fetch all, implemented as a generator, which isn't to standard, + however, it doesn't make sense to return everything in a list, as that + would use ridiculous memory for large result sets. + """ + return iter(self.fetchone, None) + + def fetchmany(self, size=None): + """Fetch many.""" + self._check_executed() + if size is None: + size = self.arraysize + + rows = [] + for i in range(size): + row = self.read_next() + if row is None: + self.warning_count = self._result.warning_count + break + rows.append(row) + self.rownumber += 1 + if not rows: + # Django expects () for EOF. + # https://github.com/django/django/blob/0c1518ee429b01c145cf5b34eab01b0b92f8c246/django/db/backends/mysql/features.py#L8 + return () + return rows + + def scroll(self, value, mode="relative"): + self._check_executed() + + if mode == "relative": + if value < 0: + raise err.NotSupportedError( + "Backwards scrolling not supported by this cursor" + ) + + for _ in range(value): + self.read_next() + self.rownumber += value + elif mode == "absolute": + if value < self.rownumber: + raise err.NotSupportedError( + "Backwards scrolling not supported by this cursor" + ) + + end = value - self.rownumber + for _ in range(end): + self.read_next() + self.rownumber = value + else: + raise err.ProgrammingError("unknown scroll mode %s" % mode) + + +class SSDictCursor(DictCursorMixin, SSCursor): + """An unbuffered cursor, which returns results as a dictionary""" diff --git a/plugins/module_utils/pymysql/err.py b/plugins/module_utils/pymysql/err.py new file mode 100644 index 0000000..dac65d3 --- /dev/null +++ b/plugins/module_utils/pymysql/err.py @@ -0,0 +1,150 @@ +import struct + +from .constants import ER + + +class MySQLError(Exception): + """Exception related to operation with MySQL.""" + + +class Warning(Warning, MySQLError): + """Exception raised for important warnings like data truncations + while inserting, etc.""" + + +class Error(MySQLError): + """Exception that is the base class of all other error exceptions + (not Warning).""" + + +class InterfaceError(Error): + """Exception raised for errors that are related to the database + interface rather than the database itself.""" + + +class DatabaseError(Error): + """Exception raised for errors that are related to the + database.""" + + +class DataError(DatabaseError): + """Exception raised for errors that are due to problems with the + processed data like division by zero, numeric value out of range, + etc.""" + + +class OperationalError(DatabaseError): + """Exception raised for errors that are related to the database's + operation and not necessarily under the control of the programmer, + e.g. an unexpected disconnect occurs, the data source name is not + found, a transaction could not be processed, a memory allocation + error occurred during processing, etc.""" + + +class IntegrityError(DatabaseError): + """Exception raised when the relational integrity of the database + is affected, e.g. a foreign key check fails, duplicate key, + etc.""" + + +class InternalError(DatabaseError): + """Exception raised when the database encounters an internal + error, e.g. the cursor is not valid anymore, the transaction is + out of sync, etc.""" + + +class ProgrammingError(DatabaseError): + """Exception raised for programming errors, e.g. table not found + or already exists, syntax error in the SQL statement, wrong number + of parameters specified, etc.""" + + +class NotSupportedError(DatabaseError): + """Exception raised in case a method or database API was used + which is not supported by the database, e.g. requesting a + .rollback() on a connection that does not support transaction or + has transactions turned off.""" + + +error_map = {} + + +def _map_error(exc, *errors): + for error in errors: + error_map[error] = exc + + +_map_error( + ProgrammingError, + ER.DB_CREATE_EXISTS, + ER.SYNTAX_ERROR, + ER.PARSE_ERROR, + ER.NO_SUCH_TABLE, + ER.WRONG_DB_NAME, + ER.WRONG_TABLE_NAME, + ER.FIELD_SPECIFIED_TWICE, + ER.INVALID_GROUP_FUNC_USE, + ER.UNSUPPORTED_EXTENSION, + ER.TABLE_MUST_HAVE_COLUMNS, + ER.CANT_DO_THIS_DURING_AN_TRANSACTION, + ER.WRONG_DB_NAME, + ER.WRONG_COLUMN_NAME, +) +_map_error( + DataError, + ER.WARN_DATA_TRUNCATED, + ER.WARN_NULL_TO_NOTNULL, + ER.WARN_DATA_OUT_OF_RANGE, + ER.NO_DEFAULT, + ER.PRIMARY_CANT_HAVE_NULL, + ER.DATA_TOO_LONG, + ER.DATETIME_FUNCTION_OVERFLOW, + ER.TRUNCATED_WRONG_VALUE_FOR_FIELD, + ER.ILLEGAL_VALUE_FOR_TYPE, +) +_map_error( + IntegrityError, + ER.DUP_ENTRY, + ER.NO_REFERENCED_ROW, + ER.NO_REFERENCED_ROW_2, + ER.ROW_IS_REFERENCED, + ER.ROW_IS_REFERENCED_2, + ER.CANNOT_ADD_FOREIGN, + ER.BAD_NULL_ERROR, +) +_map_error( + NotSupportedError, + ER.WARNING_NOT_COMPLETE_ROLLBACK, + ER.NOT_SUPPORTED_YET, + ER.FEATURE_DISABLED, + ER.UNKNOWN_STORAGE_ENGINE, +) +_map_error( + OperationalError, + ER.DBACCESS_DENIED_ERROR, + ER.ACCESS_DENIED_ERROR, + ER.CON_COUNT_ERROR, + ER.TABLEACCESS_DENIED_ERROR, + ER.COLUMNACCESS_DENIED_ERROR, + ER.CONSTRAINT_FAILED, + ER.LOCK_DEADLOCK, +) + + +del _map_error, ER + + +def raise_mysql_exception(data): + errno = struct.unpack("= 2 and value[0] == value[-1] == quote: + return value[1:-1] + return value + + def optionxform(self, key): + return key.lower().replace("_", "-") + + def get(self, section, option): + value = configparser.RawConfigParser.get(self, section, option) + return self.__remove_quotes(value) diff --git a/plugins/module_utils/pymysql/protocol.py b/plugins/module_utils/pymysql/protocol.py new file mode 100644 index 0000000..98fde6d --- /dev/null +++ b/plugins/module_utils/pymysql/protocol.py @@ -0,0 +1,356 @@ +# Python implementation of low level MySQL client-server protocol +# http://dev.mysql.com/doc/internals/en/client-server-protocol.html + +from .charset import MBLENGTH +from .constants import FIELD_TYPE, SERVER_STATUS +from . import err + +import struct +import sys + + +DEBUG = False + +NULL_COLUMN = 251 +UNSIGNED_CHAR_COLUMN = 251 +UNSIGNED_SHORT_COLUMN = 252 +UNSIGNED_INT24_COLUMN = 253 +UNSIGNED_INT64_COLUMN = 254 + + +def dump_packet(data): # pragma: no cover + def printable(data): + if 32 <= data < 127: + return chr(data) + return "." + + try: + print("packet length:", len(data)) + for i in range(1, 7): + f = sys._getframe(i) + print("call[%d]: %s (line %d)" % (i, f.f_code.co_name, f.f_lineno)) + print("-" * 66) + except ValueError: + pass + dump_data = [data[i : i + 16] for i in range(0, min(len(data), 256), 16)] + for d in dump_data: + print( + " ".join(f"{x:02X}" for x in d) + + " " * (16 - len(d)) + + " " * 2 + + "".join(printable(x) for x in d) + ) + print("-" * 66) + print() + + +class MysqlPacket: + """Representation of a MySQL response packet. + + Provides an interface for reading/parsing the packet results. + """ + + __slots__ = ("_position", "_data") + + def __init__(self, data, encoding): + self._position = 0 + self._data = data + + def get_all_data(self): + return self._data + + def read(self, size): + """Read the first 'size' bytes in packet and advance cursor past them.""" + result = self._data[self._position : (self._position + size)] + if len(result) != size: + error = ( + "Result length not requested length:\n" + f"Expected={size}. Actual={len(result)}. Position: {self._position}. Data Length: {len(self._data)}" + ) + if DEBUG: + print(error) + self.dump() + raise AssertionError(error) + self._position += size + return result + + def read_all(self): + """Read all remaining data in the packet. + + (Subsequent read() will return errors.) + """ + result = self._data[self._position :] + self._position = None # ensure no subsequent read() + return result + + def advance(self, length): + """Advance the cursor in data buffer 'length' bytes.""" + new_position = self._position + length + if new_position < 0 or new_position > len(self._data): + raise Exception( + f"Invalid advance amount ({length}) for cursor. Position={new_position}" + ) + self._position = new_position + + def rewind(self, position=0): + """Set the position of the data buffer cursor to 'position'.""" + if position < 0 or position > len(self._data): + raise Exception("Invalid position to rewind cursor to: %s." % position) + self._position = position + + def get_bytes(self, position, length=1): + """Get 'length' bytes starting at 'position'. + + Position is start of payload (first four packet header bytes are not + included) starting at index '0'. + + No error checking is done. If requesting outside end of buffer + an empty string (or string shorter than 'length') may be returned! + """ + return self._data[position : (position + length)] + + def read_uint8(self): + result = self._data[self._position] + self._position += 1 + return result + + def read_uint16(self): + result = struct.unpack_from("= 7 + + def is_eof_packet(self): + # http://dev.mysql.com/doc/internals/en/generic-response-packets.html#packet-EOF_Packet + # Caution: \xFE may be LengthEncodedInteger. + # If \xFE is LengthEncodedInteger header, 8bytes followed. + return self._data[0] == 0xFE and len(self._data) < 9 + + def is_auth_switch_request(self): + # http://dev.mysql.com/doc/internals/en/connection-phase-packets.html#packet-Protocol::AuthSwitchRequest + return self._data[0] == 0xFE + + def is_extra_auth_data(self): + # https://dev.mysql.com/doc/internals/en/successful-authentication.html + return self._data[0] == 1 + + def is_resultset_packet(self): + field_count = self._data[0] + return 1 <= field_count <= 250 + + def is_load_local_packet(self): + return self._data[0] == 0xFB + + def is_error_packet(self): + return self._data[0] == 0xFF + + def check_error(self): + if self.is_error_packet(): + self.raise_for_error() + + def raise_for_error(self): + self.rewind() + self.advance(1) # field_count == error (we already know that) + errno = self.read_uint16() + if DEBUG: + print("errno =", errno) + err.raise_mysql_exception(self._data) + + def dump(self): + dump_packet(self._data) + + +class FieldDescriptorPacket(MysqlPacket): + """A MysqlPacket that represents a specific column's metadata in the result. + + Parsing is automatically done and the results are exported via public + attributes on the class such as: db, table_name, name, length, type_code. + """ + + def __init__(self, data, encoding): + MysqlPacket.__init__(self, data, encoding) + self._parse_field_descriptor(encoding) + + def _parse_field_descriptor(self, encoding): + """Parse the 'Field Descriptor' (Metadata) packet. + + This is compatible with MySQL 4.1+ (not compatible with MySQL 4.0). + """ + self.catalog = self.read_length_coded_string() + self.db = self.read_length_coded_string() + self.table_name = self.read_length_coded_string().decode(encoding) + self.org_table = self.read_length_coded_string().decode(encoding) + self.name = self.read_length_coded_string().decode(encoding) + self.org_name = self.read_length_coded_string().decode(encoding) + ( + self.charsetnr, + self.length, + self.type_code, + self.flags, + self.scale, + ) = self.read_struct(" 1: test_is_excluded = True return test_is_excluded @@ -74,37 +66,29 @@ def main(): for db_engine_name in tests_matrix_yaml.get('db_engine_name'): for db_engine_version in tests_matrix_yaml.get('db_engine_version'): for python in tests_matrix_yaml.get('python'): - for connector_name in tests_matrix_yaml.get('connector_name'): - for connector_version in tests_matrix_yaml.get('connector_version'): - test_suite = { - 'ansible': ansible, - 'db_engine_name': db_engine_name, - 'db_engine_version': db_engine_version, - 'python': python, - 'connector_name': connector_name, - 'connector_version': connector_version - } - if not is_exclude(exclude_list, test_suite): - matrix.append(test_suite) + test_suite = { + 'ansible': ansible, + 'db_engine_name': db_engine_name, + 'db_engine_version': db_engine_version, + 'python': python, + } + if not is_exclude(exclude_list, test_suite): + matrix.append(test_suite) for tests in matrix: a = tests.get('ansible') dn = tests.get('db_engine_name') dv = tests.get('db_engine_version') p = tests.get('python') - cn = tests.get('connector_name') - cv = tests.get('connector_version') make_cmd = ( f'make ' f'ansible="{a}" ' f'db_engine_name="{dn}" ' f'db_engine_version="{dv}" ' f'python="{p}" ' - f'connector_name="{cn}" ' - f'connector_version="{cv}" ' f'test-integration' ) - print(f'Run tests for: Ansible: {a}, DB: {dn} {dv}, Python: {p}, Connector: {cn} {cv}') + print(f'Run tests for: Ansible: {a}, DB: {dn} {dv}, Python: {p}') os.system(make_cmd) # TODO, allow for CTRL+C to break the loop more easily # TODO, store the failures from this iteration diff --git a/tests/integration/targets/setup_controller/tasks/main.yml b/tests/integration/targets/setup_controller/tasks/main.yml index 0d5e36b..27b3ac3 100644 --- a/tests/integration/targets/setup_controller/tasks/main.yml +++ b/tests/integration/targets/setup_controller/tasks/main.yml @@ -4,6 +4,14 @@ # and should not be used as examples of how to write Ansible roles # #################################################################### +- name: "{{ role_name }} | Mains | Install required packages for testing" + ansible.builtin.package: + name: + - bzip2 # To test mysql_db dump compression + - iproute2 # To gather network facts + - mariadb-client # Can't install both mysql_client, had to make a choice + state: present + - name: Prepare the fake root folder ansible.builtin.import_tasks: file: fake_root.yml diff --git a/tests/integration/targets/setup_controller/tasks/setvars.yml b/tests/integration/targets/setup_controller/tasks/setvars.yml index 3e070a9..7e9da89 100644 --- a/tests/integration/targets/setup_controller/tasks/setvars.yml +++ b/tests/integration/targets/setup_controller/tasks/setvars.yml @@ -1,23 +1,11 @@ --- -- name: "{{ role_name }} | Setvars | Extract Podman/Docker Network Gateway" - ansible.builtin.shell: - cmd: ip route|grep default|awk '{print $3}' - register: ip_route_output +- name: "{{ role_name }} | Setvars | Gather facts" + ansible.builtin.setup: - name: "{{ role_name }} | Setvars | Set Fact" ansible.builtin.set_fact: - gateway_addr: "{{ ip_route_output.stdout }}" - connector_name_lookup: >- - {{ lookup( - 'file', - '/root/ansible_collections/community/mysql/tests/integration/connector_name' - ) }} - connector_version_lookup: >- - {{ lookup( - 'file', - '/root/ansible_collections/community/mysql/tests/integration/connector_version' - ) }} + gateway_addr: "{{ ansible_default_ipv4.gateway }}" db_engine_name_lookup: >- {{ lookup( 'file', @@ -41,8 +29,6 @@ - name: "{{ role_name }} | Setvars | Set Fact using above facts" ansible.builtin.set_fact: - connector_name: "{{ connector_name_lookup.strip() }}" - connector_version: "{{ connector_version_lookup.strip() }}" db_engine: "{{ db_engine_name_lookup.strip() }}" db_version: "{{ db_engine_version_lookup.strip() }}" python_version: "{{ python_version_lookup.strip() }}" @@ -69,8 +55,6 @@ - name: "{{ role_name }} | Setvars | Output test informations" vars: msg: |- - connector_name: {{ connector_name }} - connector_version: {{ connector_version }} db_engine: {{ db_engine }} db_version: {{ db_version }} python_version: {{ python_version }} diff --git a/tests/integration/targets/setup_controller/tasks/verify.yml b/tests/integration/targets/setup_controller/tasks/verify.yml index e5b4c94..6ad20d9 100644 --- a/tests/integration/targets/setup_controller/tasks/verify.yml +++ b/tests/integration/targets/setup_controller/tasks/verify.yml @@ -1,63 +1,35 @@ --- -- vars: - mysql_parameters: &mysql_params - login_user: root - login_password: msandbox - login_host: "{{ gateway_addr }}" - login_port: 3307 +- name: Query Primary container over TCP for MySQL/MariaDB version + community.mysql.mysql_info: + login_user: root + login_password: msandbox + login_host: "{{ gateway_addr }}" + login_port: 3307 + filter: + - version + register: primary_info + failed_when: + - registred_db_version != db_version + vars: + registred_db_version: + "{{ primary_info.version.major }}.{{ primary_info.version.minor }}\ + .{{ primary_info.version.release }}" - block: +- name: Assert that expected Python is installed + ansible.builtin.command: + cmd: python{{ python_version }} -V + changed_when: false + register: python_in_use + failed_when: + - python_in_use.stdout is not search(python_version) - - name: Query Primary container over TCP for MySQL/MariaDB version - mysql_info: - <<: *mysql_params - filter: - - version - register: primary_info - - - name: Assert that test container runs the expected MySQL/MariaDB version - assert: - that: - - registred_db_version == db_version - vars: - registred_db_version: - "{{ primary_info.version.major }}.{{ primary_info.version.minor }}\ - .{{ primary_info.version.release }}" - - - name: Assert that mysql_info module used the expected version of pymysql - assert: - that: - - primary_info.connector_name == connector_name - - primary_info.connector_version == connector_version - when: - - connector_name == 'pymysql' - - - name: Assert that mysql_info module used the expected version of mysqlclient - assert: - that: - - primary_info.connector_name == 'MySQLdb' - - primary_info.connector_version == connector_version - when: - - connector_name == 'mysqlclient' - - - name: Display the python version in use - command: - cmd: python{{ python_version }} -V - changed_when: false - register: python_in_use - - - name: Assert that expected Python is installed - assert: - that: - - python_in_use.stdout is search(python_version) - - - name: Assert that we run the expected ansible version - assert: - that: - - ansible_running_version == test_ansible_version - vars: - ansible_running_version: - "{{ ansible_version.major }}.{{ ansible_version.minor }}" - when: - - test_ansible_version != 'devel' # Devel will change overtime +- name: Assert that we run the expected ansible version + ansible.builtin.assert: + that: + - ansible_running_version == test_ansible_version + vars: + ansible_running_version: + "{{ ansible_version.major }}.{{ ansible_version.minor }}" + when: + - test_ansible_version != 'devel' # Devel will change overtime diff --git a/tests/integration/targets/test_mysql_db/tasks/config_overrides_defaults.yml b/tests/integration/targets/test_mysql_db/tasks/config_overrides_defaults.yml index 390c6ae..09fa7d8 100644 --- a/tests/integration/targets/test_mysql_db/tasks/config_overrides_defaults.yml +++ b/tests/integration/targets/test_mysql_db/tasks/config_overrides_defaults.yml @@ -15,39 +15,18 @@ - name: Config overrides | Add blank line shell: 'echo "" >> {{ config_file }}' - when: - - > - connector_name != 'pymysql' - or ( - connector_name == 'pymysql' - and connector_version is version('0.9.3', '>=') - ) - name: Config overrides | Create include_dir file: path: '{{ include_dir }}' state: directory mode: '0777' - when: - - > - connector_name != 'pymysql' - or ( - connector_name == 'pymysql' - and connector_version is version('0.9.3', '>=') - ) - name: Config overrides | Add include_dir lineinfile: path: '{{ config_file }}' line: '!includedir {{ include_dir }}' insertafter: EOF - when: - - > - connector_name != 'pymysql' - or ( - connector_name == 'pymysql' - and connector_version is version('0.9.3', '>=') - ) - name: Config overrides | Create database using fake port to connect to, must fail mysql_db: diff --git a/tests/integration/targets/test_mysql_db/tasks/issue-28.yml b/tests/integration/targets/test_mysql_db/tasks/issue-28.yml index 8cad28e..8677752 100644 --- a/tests/integration/targets/test_mysql_db/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_db/tasks/issue-28.yml @@ -49,19 +49,8 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: yes - - - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + failed_when: + - result is success - name: attempt connection with newly created user ignoring hostname mysql_db: @@ -74,11 +63,6 @@ ca_cert: /tmp/cert.pem check_hostname: no register: result - ignore_errors: yes - - - assert: - that: - - result is succeeded or 'pymysql >= 0.7.11 is required' in result.msg - name: Drop mysql user mysql_user: diff --git a/tests/integration/targets/test_mysql_info/tasks/connector_info.yml b/tests/integration/targets/test_mysql_info/tasks/connector_info.yml deleted file mode 100644 index d525e8e..0000000 --- a/tests/integration/targets/test_mysql_info/tasks/connector_info.yml +++ /dev/null @@ -1,30 +0,0 @@ ---- -# Added in 3.6.0 in -# https://github.com/ansible-collections/community.mysql/pull/497 - -- name: Connector info | Assert connector_name exists and has expected values - ansible.builtin.assert: - that: - - result.connector_name is defined - - result.connector_name is in ['pymysql', 'MySQLdb'] - success_msg: >- - Assertions passed, result.connector_name is {{ result.connector_name }} - fail_msg: >- - Assertion failed, result.connector_name is - {{ result.connector_name | d('Unknown')}} which is different than expected - pymysql or MySQLdb - -- name: Connector info | Assert connector_version exists and has expected values - ansible.builtin.assert: - that: - - result.connector_version is defined - - > - result.connector_version == 'Unknown' - or result.connector_version is version(connector_version, '==') - success_msg: >- - Assertions passed, result.connector_version is - {{ result.connector_version }} - fail_msg: >- - Assertion failed, result.connector_version is - {{ result.connector_version }} which is different than expected - {{ connector_version }} diff --git a/tests/integration/targets/test_mysql_info/tasks/issue-28.yml b/tests/integration/targets/test_mysql_info/tasks/issue-28.yml index 83e6883..22de220 100644 --- a/tests/integration/targets/test_mysql_info/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_info/tasks/issue-28.yml @@ -48,19 +48,8 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: yes - - - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + failed_when: + - result is success - name: attempt connection with newly created user ignoring hostname mysql_info: diff --git a/tests/integration/targets/test_mysql_info/tasks/main.yml b/tests/integration/targets/test_mysql_info/tasks/main.yml index 5d34da9..1eb2184 100644 --- a/tests/integration/targets/test_mysql_info/tasks/main.yml +++ b/tests/integration/targets/test_mysql_info/tasks/main.yml @@ -57,10 +57,6 @@ - result.engines != {} - result.users != {} - - name: mysql_info - Test connector informations display - ansible.builtin.import_tasks: - file: connector_info.yml - # Access by non-default cred file - name: mysql_info - check non-default cred file mysql_info: diff --git a/tests/integration/targets/test_mysql_query/tasks/issue-28.yml b/tests/integration/targets/test_mysql_query/tasks/issue-28.yml index e788fea..a8a52f9 100644 --- a/tests/integration/targets/test_mysql_query/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_query/tasks/issue-28.yml @@ -48,19 +48,9 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: yes - - - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + ignore_errors: true + failed_when: + - result is success - name: attempt connection with newly created user ignoring hostname mysql_query: @@ -70,13 +60,8 @@ login_host: '{{ mysql_host }}' login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem - check_hostname: no + check_hostname: false register: result - ignore_errors: yes - - - assert: - that: - - result is succeeded or 'pymysql >= 0.7.11 is required' in result.msg - name: Drop mysql user mysql_user: diff --git a/tests/integration/targets/test_mysql_query/tasks/mysql_query_initial.yml b/tests/integration/targets/test_mysql_query/tasks/mysql_query_initial.yml index 82665af..d91ee2e 100644 --- a/tests/integration/targets/test_mysql_query/tasks/mysql_query_initial.yml +++ b/tests/integration/targets/test_mysql_query/tasks/mysql_query_initial.yml @@ -360,30 +360,14 @@ register: result # Issue https://github.com/ansible-collections/community.mysql/issues/268 - - name: Assert that create table IF NOT EXISTS is not changed with pymysql + # Though, the best thing would be to debug this and invert the condition... + - name: Assert that create table IF NOT EXISTS is changed assert: that: - # PyMySQL driver throws a warning for version before 0.10.0 - - result is not changed - when: - - connector_name == 'pymysql' - - connector_version is version('0.10.0', '<') - - # Issue https://github.com/ansible-collections/community.mysql/issues/268 - - name: Assert that create table IF NOT EXISTS is changed with mysqlclient - assert: - that: - # Mysqlclient 2.0.1 and pymysql 0.10.0+ drivers throws no warning, + # pymysql 0.10.0+ drivers throws no warning, # so it's impossible to figure out if the state was changed or not. # We assume that it was for DDL queries by default in the code - result is changed - when: - - > - connector_name == 'mysqlclient' - or ( - connector_name == 'pymysql' - and connector_version is version('0.10.0', '>') - ) - name: Drop db {{ test_db }} mysql_query: diff --git a/tests/integration/targets/test_mysql_replication/tasks/issue-28.yml b/tests/integration/targets/test_mysql_replication/tasks/issue-28.yml index 4225a07..71310fb 100644 --- a/tests/integration/targets/test_mysql_replication/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_replication/tasks/issue-28.yml @@ -48,19 +48,8 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: yes - - - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + failed_when: + - result is success - name: attempt connection with newly created user ignoring hostname mysql_replication: @@ -70,13 +59,7 @@ login_host: '{{ mysql_host }}' login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem - check_hostname: no - register: result - ignore_errors: yes - - - assert: - that: - - result is succeeded or 'pymysql >= 0.7.11 is required' in result.msg + check_hostname: false - name: Drop mysql user mysql_user: diff --git a/tests/integration/targets/test_mysql_replication/tasks/mysql_replication_initial.yml b/tests/integration/targets/test_mysql_replication/tasks/mysql_replication_initial.yml index e08954b..89f497c 100644 --- a/tests/integration/targets/test_mysql_replication/tasks/mysql_replication_initial.yml +++ b/tests/integration/targets/test_mysql_replication/tasks/mysql_replication_initial.yml @@ -259,14 +259,11 @@ fail_on_error: true register: result - # mysqlclient 2.0.1 and pymysql 0.10.0+ always return "changed" - - name: Assert that startreplica is not changed - assert: - that: - - result is not changed - when: - - connector_name == 'pymysql' - - connector_version is version('0.10.0', '<') + # # pymysql 0.10.0+ always return "changed" + # - name: Assert that startreplica is not changed + # assert: + # that: + # - result is not changed # Test stopreplica mode: - name: Stop replica @@ -286,23 +283,20 @@ ansible.builtin.wait_for: timeout: 2 - # Test stopreplica mode: - # mysqlclient 2.0.1 and pymysql 0.10.0+ always return "changed" - - name: Stop replica that is no longer running - mysql_replication: - <<: *mysql_params - login_port: '{{ mysql_replica1_port }}' - mode: stopreplica - fail_on_error: true - register: result + # # Test stopreplica mode: + # # pymysql 0.10.0+ always return "changed" + # - name: Stop replica that is no longer running + # mysql_replication: + # <<: *mysql_params + # login_port: '{{ mysql_replica1_port }}' + # mode: stopreplica + # fail_on_error: true + # register: result - - name: Assert that stopreplica is not changed - assert: - that: - - result is not changed - when: - - connector_name == 'pymysql' - - connector_version is version('0.10.0', '<') + # - name: Assert that stopreplica is not changed + # assert: + # that: + # - result is not changed # master / slave related choices were removed in 3.0.0 # https://github.com/ansible-collections/community.mysql/pull/252 diff --git a/tests/integration/targets/test_mysql_user/tasks/issue-28.yml b/tests/integration/targets/test_mysql_user/tasks/issue-28.yml index 51a2091..8900a68 100644 --- a/tests/integration/targets/test_mysql_user/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_user/tasks/issue-28.yml @@ -50,21 +50,8 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: true - - - name: Issue-28 | Assert connection failed - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - name: Issue-28 | Assert connection succeeded - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + failed_when: + - result is success - name: Issue-28 | Attempt connection with newly created user ignoring hostname mysql_user: @@ -77,13 +64,6 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem check_hostname: false - register: result - ignore_errors: true - - - name: Issue-28 | Assert connection succeeded - assert: - that: - - result is succeeded or 'pymysql >= 0.7.11 is required' in result.msg - name: Issue-28 | Drop mysql user mysql_user: diff --git a/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml b/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml index d8ff04d..c2ed310 100644 --- a/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml +++ b/tests/integration/targets/test_mysql_user/tasks/test_user_plugin_auth.yml @@ -403,75 +403,66 @@ # plugins that are loaded by default are sha2*, but these aren't compatible with pymysql < 0.9, so skip these tests # for those versions. # - - name: Plugin auth | Test plugin auth switching which doesn't work on pymysql < 0.9 - when: - - > - connector_name != 'pymysql' - or ( - connector_name == 'pymysql' - and connector_version is version('0.9', '>=') - ) - block: - - name: Plugin auth | Create user with plugin auth (empty auth string) - mysql_user: - <<: *mysql_params - name: '{{ test_user_name }}' - plugin: '{{ test_plugin_type }}' - priv: '{{ test_default_priv }}' - register: result + - name: Plugin auth | Create user with plugin auth (empty auth string) + mysql_user: + <<: *mysql_params + name: '{{ test_user_name }}' + plugin: '{{ test_plugin_type }}' + priv: '{{ test_default_priv }}' + register: result - - name: Plugin auth | Get user information (empty auth string) - command: "{{ mysql_command }} -e \"SHOW CREATE USER '{{ test_user_name }}'@'localhost'\"" - register: show_create_user + - name: Plugin auth | Get user information (empty auth string) + command: "{{ mysql_command }} -e \"SHOW CREATE USER '{{ test_user_name }}'@'localhost'\"" + register: show_create_user - - name: Plugin auth | Check that the module made a change (empty auth string) - assert: - that: - - result is changed + - name: Plugin auth | Check that the module made a change (empty auth string) + assert: + that: + - result is changed - - name: Plugin auth | Check that the expected plugin type is set (empty auth string) - assert: - that: - - test_plugin_type in show_create_user.stdout - when: db_engine == 'mysql' or (db_engine == 'mariadb' and db_version is version('10.3', '>=')) + - name: Plugin auth | Check that the expected plugin type is set (empty auth string) + assert: + that: + - test_plugin_type in show_create_user.stdout + when: db_engine == 'mysql' or (db_engine == 'mariadb' and db_version is version('10.3', '>=')) - - include_tasks: utils/assert_user.yml - vars: - user_name: "{{ test_user_name }}" - user_host: localhost - priv: "{{ test_default_priv_type }}" + - include_tasks: utils/assert_user.yml + vars: + user_name: "{{ test_user_name }}" + user_host: localhost + priv: "{{ test_default_priv_type }}" - - name: Plugin auth | Switch user to sha256_password auth plugin - mysql_user: - <<: *mysql_params - name: '{{ test_user_name }}' - plugin: sha256_password - priv: '{{ test_default_priv }}' - register: result + - name: Plugin auth | Switch user to sha256_password auth plugin + mysql_user: + <<: *mysql_params + name: '{{ test_user_name }}' + plugin: sha256_password + priv: '{{ test_default_priv }}' + register: result - - name: Plugin auth | Get user information (sha256_password) - command: "{{ mysql_command }} -e \"SHOW CREATE USER '{{ test_user_name }}'@'localhost'\"" - register: show_create_user + - name: Plugin auth | Get user information (sha256_password) + command: "{{ mysql_command }} -e \"SHOW CREATE USER '{{ test_user_name }}'@'localhost'\"" + register: show_create_user - - name: Plugin auth | Check that the module made a change (sha256_password) - assert: - that: - - result is changed + - name: Plugin auth | Check that the module made a change (sha256_password) + assert: + that: + - result is changed - - name: Plugin auth | Check that the expected plugin type is set (sha256_password) - assert: - that: - - "'sha256_password' in show_create_user.stdout" - when: db_engine == 'mysql' or (db_engine == 'mariadb' and db_version is version('10.3', '>=')) + - name: Plugin auth | Check that the expected plugin type is set (sha256_password) + assert: + that: + - "'sha256_password' in show_create_user.stdout" + when: db_engine == 'mysql' or (db_engine == 'mariadb' and db_version is version('10.3', '>=')) - - include_tasks: utils/assert_user.yml - vars: - user_name: "{{ test_user_name }}" - user_host: localhost - priv: "{{ test_default_priv_type }}" + - include_tasks: utils/assert_user.yml + vars: + user_name: "{{ test_user_name }}" + user_host: localhost + priv: "{{ test_default_priv_type }}" - # Cleanup - - include_tasks: utils/remove_user.yml - vars: - user_name: "{{ test_user_name }}" + # Cleanup + - include_tasks: utils/remove_user.yml + vars: + user_name: "{{ test_user_name }}" diff --git a/tests/integration/targets/test_mysql_variables/tasks/issue-28.yml b/tests/integration/targets/test_mysql_variables/tasks/issue-28.yml index 2d171aa..c786ab6 100644 --- a/tests/integration/targets/test_mysql_variables/tasks/issue-28.yml +++ b/tests/integration/targets/test_mysql_variables/tasks/issue-28.yml @@ -48,19 +48,8 @@ login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem register: result - ignore_errors: yes - - - assert: - that: - - result is failed - when: - - connector_name == 'pymysql' - - - assert: - that: - - result is succeeded - when: - - connector_name != 'pymysql' + failed_when: + - result is success - name: attempt connection with newly created user ignoring hostname mysql_variables: @@ -70,13 +59,7 @@ login_host: '{{ mysql_host }}' login_port: '{{ mysql_primary_port }}' ca_cert: /tmp/cert.pem - check_hostname: no - register: result - ignore_errors: yes - - - assert: - that: - - result is succeeded or 'pymysql >= 0.7.11 is required' in result.msg + check_hostname: false - name: Drop mysql user mysql_user: diff --git a/tests/integration/targets/test_mysql_variables/tasks/mysql_variables.yml b/tests/integration/targets/test_mysql_variables/tasks/mysql_variables.yml index 2d2318e..9ee4e25 100644 --- a/tests/integration/targets/test_mysql_variables/tasks/mysql_variables.yml +++ b/tests/integration/targets/test_mysql_variables/tasks/mysql_variables.yml @@ -188,16 +188,14 @@ output: "{{ oor_result }}" var_name: max_connect_errors var_value: 1 - when: - - connector_name == 'mysqlclient' - - db_engine == 'mysql' # mysqlclient returns "changed" with MariaDB - - include_tasks: assert_fail_msg.yml - vars: - output: "{{ oor_result }}" - msg: 'Truncated incorrect' - when: - - connector_name == 'pymsql' + # pymysql apply the invalid value without errors: + # msg: "Variable change succeeded prev_value=100" + # query: "SET GLOBAL `max_connect_errors` = -1" + # - include_tasks: assert_fail_msg.yml + # vars: + # output: "{{ oor_result }}" + # msg: 'Truncated incorrect' # ============================================================ # Verify mysql_variable fails when setting an incorrect value (incorrect type)