From 5ddb434b74b720c5c364ab72e6775ad3b7805dfd Mon Sep 17 00:00:00 2001 From: mac Date: Thu, 23 Jul 2026 12:05:07 +0800 Subject: [PATCH] feat(delivery): add Ansible playbook for native MySQL provisioning Add mysql-deploy.yml playbook for bare-metal MySQL 8.0 delivery via AWX: parameter validation, resource preflight checks, apt install, AppArmor adaptation, per-instance directory layout, Jinja2 config template (buffer pool sizing, binlog, GTID), systemd template unit (mysql-delivery@.service), admin account setup, and TCP health verification. Includes example inventory. Relates-to: #97 --- ansible/files/mysql-delivery@.service | 20 +++ ansible/inventory.example.yml | 11 ++ ansible/mysql-deploy.yml | 228 ++++++++++++++++++++++++ ansible/templates/mysql-instance.cnf.j2 | 35 ++++ 4 files changed, 294 insertions(+) create mode 100644 ansible/files/mysql-delivery@.service create mode 100644 ansible/inventory.example.yml create mode 100644 ansible/mysql-deploy.yml create mode 100644 ansible/templates/mysql-instance.cnf.j2 diff --git a/ansible/files/mysql-delivery@.service b/ansible/files/mysql-delivery@.service new file mode 100644 index 0000000..431cb4a --- /dev/null +++ b/ansible/files/mysql-delivery@.service @@ -0,0 +1,20 @@ +[Unit] +Description=XINFRA MySQL delivery instance %i +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +User=mysql +Group=mysql +RuntimeDirectory=mysql-delivery-%i +RuntimeDirectoryMode=0755 +ExecStart=/usr/sbin/mysqld --defaults-file=/etc/mysql/mysql-delivery/%i.cnf +Restart=on-failure +RestartSec=5s +TimeoutStartSec=120s +LimitNOFILE=65535 +OOMScoreAdjust=-200 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/inventory.example.yml b/ansible/inventory.example.yml new file mode 100644 index 0000000..c7f5795 --- /dev/null +++ b/ansible/inventory.example.yml @@ -0,0 +1,11 @@ +all: + children: + mysql_hosts: + hosts: + k8s-server-01: { ansible_host: 192.168.1.4 } + k8s-server-02: { ansible_host: 192.168.1.5 } + k8s-server-03: { ansible_host: 192.168.1.2 } + k8s-worker-01: { ansible_host: 192.168.1.3 } + vars: + ansible_user: root + ansible_python_interpreter: /usr/bin/python3 diff --git a/ansible/mysql-deploy.yml b/ansible/mysql-deploy.yml new file mode 100644 index 0000000..96b90e0 --- /dev/null +++ b/ansible/mysql-deploy.yml @@ -0,0 +1,228 @@ +--- +- name: Preflight native MySQL delivery + hosts: "{{ target_hosts }}" + become: true + gather_facts: true + any_errors_fatal: true + vars: + mysql_instance: "{{ instance_name }}" + mysql_port_value: "{{ mysql_port | default(3307) | int }}" + mysql_memory_mb_value: "{{ memory_mb | default(2048) | int }}" + mysql_storage_gb_value: "{{ storage_gb | default(20) | int }}" + mysql_install_dir: "/opt/mysql-delivery/{{ mysql_instance }}" + mysql_data_dir: "/data/mysql-delivery/{{ mysql_instance }}/data" + mysql_log_dir: "/data/mysql-delivery/{{ mysql_instance }}/log" + mysql_run_dir: "/run/mysql-delivery-{{ mysql_instance }}" + mysql_config_file: "/etc/mysql/mysql-delivery/{{ mysql_instance }}.cnf" + mysql_root_password_value: "{{ lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD') }}" + mysql_admin_password_value: "{{ lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD') }}" + pre_tasks: + - name: Validate prototype parameters + ansible.builtin.assert: + that: + - topology == 'standalone' + - mysql_instance is match('^[a-z0-9][a-z0-9-]{0,62}$') + - (mysql_port_value | int) >= 1024 + - (mysql_port_value | int) <= 65535 + - (mysql_memory_mb_value | int) >= 1024 + - (mysql_memory_mb_value | int) <= 4096 + - (mysql_storage_gb_value | int) >= 10 + - (mysql_storage_gb_value | int) <= 100 + - mysql_root_password_value | length >= 16 + - mysql_admin_password_value | length >= 16 + fail_msg: The first machine prototype only supports safe standalone parameters + no_log: true + + - name: Validate topology host count + ansible.builtin.assert: + that: + - ansible_play_hosts_all | length == 1 + fail_msg: The selected topology does not match the target host count + run_once: true + + - name: Check port ownership + ansible.builtin.shell: | + set -o pipefail + if ss -lntH "sport = :{{ mysql_port_value }}" | grep -q .; then + systemctl is-active --quiet "mysql-delivery@{{ mysql_instance }}.service" + fi + args: + executable: /bin/bash + changed_when: false + + - name: Read currently available memory + ansible.builtin.shell: awk '/^MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo + args: + executable: /bin/bash + register: mysql_available_memory + changed_when: false + + - name: Check available memory and disk + ansible.builtin.assert: + that: + - (mysql_available_memory.stdout | int) >= (mysql_memory_mb_value | int) + - (ansible_mounts | selectattr('mount', 'equalto', '/') | map(attribute='size_available') | first | int) >= (mysql_storage_gb_value | int) * 1073741824 + fail_msg: Target host does not have enough available memory or disk + + tasks: + - name: Detect an existing Ubuntu MySQL package + ansible.builtin.command: dpkg-query -W mysql-server + register: mysql_package_before + failed_when: false + changed_when: false + + - name: Install Ubuntu MySQL package + ansible.builtin.apt: + name: mysql-server=8.0.46-0ubuntu0.24.04.3 + state: present + update_cache: true + cache_valid_time: 3600 + + - name: Stop the automatically created default instance on a clean host + ansible.builtin.systemd_service: + name: mysql.service + state: stopped + enabled: false + when: mysql_package_before.rc != 0 + + - name: Check for the bundled MySQL AppArmor profile + ansible.builtin.stat: + path: /etc/apparmor.d/usr.sbin.mysqld + register: mysql_apparmor_profile + + - name: Allow the delivery data and run paths in the MySQL AppArmor profile + ansible.builtin.copy: + dest: /etc/apparmor.d/local/usr.sbin.mysqld + owner: root + group: root + mode: '0644' + content: | + # Managed by XINFRA MySQL delivery - grant per-instance native paths + /data/mysql-delivery/ r, + /data/mysql-delivery/** rwk, + /run/mysql-delivery-*/ rw, + /run/mysql-delivery-*/** rwk, + when: mysql_apparmor_profile.stat.exists + register: mysql_apparmor_local + + - name: Reload the MySQL AppArmor profile + ansible.builtin.command: apparmor_parser -r /etc/apparmor.d/usr.sbin.mysqld + when: mysql_apparmor_profile.stat.exists and mysql_apparmor_local.changed + changed_when: true + + - name: Create instance directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.group }}" + mode: "{{ item.mode }}" + loop: + - { path: /etc/mysql/mysql-delivery, owner: root, group: mysql, mode: '0750' } + - { path: "{{ mysql_install_dir }}", owner: root, group: root, mode: '0755' } + - { path: "{{ mysql_data_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_log_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_run_dir }}", owner: mysql, group: mysql, mode: '0755' } + + - name: Link native binaries into the instance directory + ansible.builtin.file: + src: "{{ item.src }}" + dest: "{{ mysql_install_dir }}/{{ item.dest }}" + state: link + loop: + - { src: /usr/sbin/mysqld, dest: mysqld } + - { src: /usr/bin/mysql, dest: mysql } + - { src: /usr/bin/mysqladmin, dest: mysqladmin } + + - name: Calculate host role and server id + ansible.builtin.set_fact: + mysql_node_index: "{{ ansible_play_hosts_all.index(inventory_hostname) }}" + mysql_node_role: >- + {{ 'standalone' if topology == 'standalone' else + ('primary' if ansible_play_hosts_all.index(inventory_hostname) == 0 else + ('replica' if topology == 'primary_replica' else 'mgr')) }} + + - name: Write instance configuration + ansible.builtin.template: + src: templates/mysql-instance.cnf.j2 + dest: "{{ mysql_config_file }}" + owner: root + group: mysql + mode: '0640' + notify: Restart MySQL delivery instance + + - name: Initialize the data directory once + ansible.builtin.command: + argv: + - /usr/sbin/mysqld + - "--defaults-file={{ mysql_config_file }}" + - --initialize-insecure + - --user=mysql + args: + creates: "{{ mysql_data_dir }}/auto.cnf" + no_log: true + + - name: Install the delivery systemd template + ansible.builtin.copy: + src: files/mysql-delivery@.service + dest: /etc/systemd/system/mysql-delivery@.service + owner: root + group: root + mode: '0644' + register: mysql_systemd_unit + + - name: Reload systemd units + ansible.builtin.systemd_service: + daemon_reload: true + when: mysql_systemd_unit.changed + + - name: Start the MySQL delivery instance + ansible.builtin.systemd_service: + name: "mysql-delivery@{{ mysql_instance }}.service" + state: started + enabled: true + + - name: Wait for the local MySQL socket + ansible.builtin.wait_for: + path: "{{ mysql_run_dir }}/mysql.sock" + timeout: 60 + + - name: Configure local administrative accounts + ansible.builtin.shell: | + set -euo pipefail + client_file="$(mktemp)" + trap 'rm -f "$client_file"' EXIT + chmod 600 "$client_file" + cat >"$client_file" </dev/null 2>&1; then + /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <<'SQL' + ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mysql_root_password_value }}'; + SQL + fi + /usr/bin/mysql --defaults-extra-file="$client_file" <<'SQL' + CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value }}'; + ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value }}'; + GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + FLUSH PRIVILEGES; + SQL + args: + executable: /bin/bash + changed_when: false + no_log: true + + - name: Verify MySQL TCP health + ansible.builtin.wait_for: + host: "{{ ansible_host | default(inventory_hostname) }}" + port: "{{ mysql_port_value }}" + timeout: 30 + + handlers: + - name: Restart MySQL delivery instance + ansible.builtin.systemd_service: + name: "mysql-delivery@{{ mysql_instance }}.service" + state: restarted diff --git a/ansible/templates/mysql-instance.cnf.j2 b/ansible/templates/mysql-instance.cnf.j2 new file mode 100644 index 0000000..a46dc54 --- /dev/null +++ b/ansible/templates/mysql-instance.cnf.j2 @@ -0,0 +1,35 @@ +[mysqld] +user=mysql +basedir=/usr +datadir={{ mysql_data_dir }} +socket={{ mysql_run_dir }}/mysql.sock +pid-file={{ mysql_run_dir }}/mysql.pid +port={{ mysql_port_value }} +bind-address=0.0.0.0 +mysqlx=0 +server-id={{ 101 + mysql_node_index | int }} +log-error={{ mysql_log_dir }}/error.log +slow-query-log=ON +slow-query-log-file={{ mysql_log_dir }}/slow.log +skip-name-resolve=ON +max-connections=100 +innodb-buffer-pool-size={{ ((mysql_memory_mb_value | int) * 55 / 100) | int }}M +innodb-log-file-size=128M +log-bin={{ mysql_log_dir }}/mysql-bin +binlog-format=ROW +gtid-mode=ON +enforce-gtid-consistency=ON +relay-log={{ mysql_log_dir }}/relay-bin + +{% if topology == 'mgr_3' %} +plugin-load-add=group_replication.so +transaction-write-set-extraction=XXHASH64 +loose-group-replication-group-name={{ mgr_group_name }} +loose-group-replication-start-on-boot=OFF +loose-group-replication-local-address={{ ansible_host | default(inventory_hostname) }}:{{ mysql_port_value | int + 10000 }} +loose-group-replication-group-seeds={% for host in ansible_play_hosts_all %}{{ hostvars[host].ansible_host | default(host) }}:{{ mysql_port_value | int + 10000 }}{% if not loop.last %},{% endif %}{% endfor %} +loose-group-replication-ip-allowlist={% for host in ansible_play_hosts_all %}{{ hostvars[host].ansible_host | default(host) }}{% if not loop.last %},{% endif %}{% endfor %} +loose-group-replication-single-primary-mode=ON +loose-group-replication-enforce-update-everywhere-checks=OFF +{% endif %} +