From 4637228bae46f2f114d9c4575627dfb7c4fbcaa2 Mon Sep 17 00:00:00 2001 From: mac Date: Mon, 27 Jul 2026 17:25:03 +0800 Subject: [PATCH] feat(delivery): add AWX callback MySQL playbook --- ansible/mysql-deploy-callback.yml | 479 ++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 ansible/mysql-deploy-callback.yml diff --git a/ansible/mysql-deploy-callback.yml b/ansible/mysql-deploy-callback.yml new file mode 100644 index 0000000..ca54337 --- /dev/null +++ b/ansible/mysql-deploy-callback.yml @@ -0,0 +1,479 @@ +--- +- name: Preflight native MySQL delivery + hosts: all + become: true + gather_facts: true + any_errors_fatal: true + vars: + mysql_instance: "{{ instance_name }}" + mysql_version_value: "{{ mysql_version | default('8.0') }}" + mysql_package_map: + "8.0": "mysql-server=8.0.46-0ubuntu0.24.04.3" + mysql_package_name: "{{ mysql_package_map[mysql_version_value] }}" + mysql_port_value: "{{ mysql_port | default(13306) | int }}" + mysql_cpu_cores_value: "{{ cpu_cores | default(1) | int }}" + mysql_memory_gb_value: "{{ memory_gb | default(2) | int }}" + mysql_memory_mb_value: "{{ mysql_memory_gb_value | int * 1024 }}" + mysql_storage_gb_value: "{{ storage_gb | default(20) | int }}" + mysql_data_disk: "{{ data_disk | default('/data') }}" + mysql_base_dir: "{{ mysql_data_disk }}/mysql-delivery/{{ mysql_instance }}" + mysql_install_dir: "/opt/mysql-delivery/{{ mysql_instance }}" + mysql_data_dir: "{{ mysql_base_dir }}/data" + mysql_log_dir: "{{ mysql_base_dir }}/logs" + mysql_binlog_dir: "{{ mysql_base_dir }}/logs/binlog" + mysql_redo_dir: "{{ mysql_base_dir }}/logs/redo" + mysql_run_dir: "{{ mysql_base_dir }}/run" + mysql_tmp_dir: "{{ mysql_base_dir }}/tmp" + mysql_conf_dir: "{{ mysql_base_dir }}/conf" + mysql_config_file: "{{ mysql_conf_dir }}/my.cnf" + mysql_system_config_file: "/etc/mysql/mysql-delivery/{{ mysql_instance }}.cnf" + mysql_param_template_value: "{{ param_template | default('default') }}" + mysql_timezone_value: "{{ timezone | default('+08:00') }}" + mysql_lower_case_table_names_value: "{{ lower_case_table_names | default(1) | int }}" + mysql_character_set_value: "{{ character_set | default('utf8mb4') }}" + mysql_collation_value: "{{ collation | default('utf8mb4_general_ci') }}" + mysql_max_connections_value: "{{ max_connections | default('auto') }}" + mysql_redo_log_capacity_value: "{{ innodb_redo_log_capacity | default('auto') }}" + mysql_flush_log_at_trx_commit_value: "{{ innodb_flush_log_at_trx_commit | default(1) | int }}" + mysql_sync_binlog_value: "{{ sync_binlog | default(1) | int }}" + mysql_io_capacity_value: "{{ innodb_io_capacity | default(2000) | int }}" + mysql_long_query_time_value: "{{ long_query_time | default(1) }}" + mysql_binlog_expire_logs_seconds_value: "{{ binlog_expire_logs_seconds | default(604800) | int }}" + mysql_max_binlog_size_value: "{{ max_binlog_size | default('256M') }}" + delivery_callback_url_value: "{{ delivery_callback_url | default('') }}" + delivery_callback_token_value: "{{ delivery_callback_token | default('') }}" + delivery_callback_enabled: "{{ (delivery_callback_url_value | length > 0) and (delivery_callback_token_value | length > 0) }}" + delivery_awx_job_id: "{{ awx_job_id | default('') }}" + mysql_root_password_value: "{{ lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD') }}" + mysql_admin_password_value: "{{ lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD') }}" + mysql_single_quote: "'" + mysql_double_single_quote: "''" + mysql_root_password_sql: "{{ mysql_root_password_value | replace(mysql_single_quote, mysql_double_single_quote) }}" + mysql_admin_password_sql: "{{ mysql_admin_password_value | replace(mysql_single_quote, mysql_double_single_quote) }}" + mysql_topology_value: "{{ topology | default('standalone') }}" + pre_tasks: + - name: Notify precheck started + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: precheck + status: running + message: resource lock and baseline checks started + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - name: Validate prototype parameters + ansible.builtin.assert: + that: + - mysql_topology_value == 'standalone' + - mysql_instance is match('^[a-z0-9][a-z0-9-]{0,62}$') + - mysql_version_value in mysql_package_map + - (mysql_port_value | int) >= 13306 + - (mysql_port_value | int) <= 13999 + - (mysql_cpu_cores_value | int) in [1, 2, 4, 8, 16] + - (mysql_memory_mb_value | int) >= 1024 + - (mysql_memory_mb_value | int) <= 65536 + - (mysql_storage_gb_value | int) >= 20 + - (mysql_storage_gb_value | int) <= 2000 + - mysql_data_disk in ['/data', '/disk1', '/mnt/vol-1'] + - mysql_param_template_value in ['default', 'high_performance', 'high_safety'] + - mysql_timezone_value in ['SYSTEM', '+08:00', '+00:00', 'Asia/Shanghai'] + - (mysql_lower_case_table_names_value | int) in [0, 1] + - mysql_character_set_value in ['utf8mb4', 'utf8', 'gbk', 'latin1'] + - mysql_collation_value is match('^(utf8mb4_(general_ci|unicode_ci|0900_ai_ci)|utf8_general_ci|gbk_chinese_ci|latin1_swedish_ci)$') + - mysql_max_connections_value == 'auto' or mysql_max_connections_value in ['200', '500', '1000', '2000', '4000', '8000', '16000'] + - mysql_redo_log_capacity_value in ['auto', '128M', '256M', '512M', '1G'] + - (mysql_flush_log_at_trx_commit_value | int) in [0, 1, 2] + - (mysql_sync_binlog_value | int) in [0, 1] + - (mysql_io_capacity_value | int) in [200, 2000, 5000] + - (mysql_long_query_time_value | float) in [0.5, 1.0, 2.0, 5.0, 10.0] + - (mysql_binlog_expire_logs_seconds_value | int) in [86400, 259200, 604800, 1209600] + - mysql_max_binlog_size_value in ['128M', '256M', '512M', '1G'] + fail_msg: The first machine prototype only supports safe standalone parameters + + - name: Validate MySQL delivery secrets + ansible.builtin.assert: + that: + - mysql_root_password_value | length >= 16 + - mysql_admin_password_value | length >= 16 + fail_msg: XINFRA_MYSQL_ROOT_PASSWORD and XINFRA_MYSQL_ADMIN_PASSWORD must be configured and at least 16 characters long + 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', mysql_data_disk) | map(attribute='size_available') | first | default(0) | int) >= (mysql_storage_gb_value | int) * 1073741824 + fail_msg: Target host does not have enough available memory or disk + + - name: Notify precheck completed + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: precheck + status: success + message: resource lock and baseline checks completed + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + tasks: + - name: Notify install started + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: install + status: running + message: MySQL package and instance directories installation started + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - 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_package_name }}" + 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 + {{ mysql_data_disk }}/mysql-delivery/ r, + {{ mysql_data_disk }}/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_base_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_data_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_log_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_binlog_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_redo_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_run_dir }}", owner: mysql, group: mysql, mode: '0755' } + - { path: "{{ mysql_tmp_dir }}", owner: mysql, group: mysql, mode: '0750' } + - { path: "{{ mysql_conf_dir }}", owner: root, group: mysql, mode: '0750' } + + - 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: Notify install completed + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: install + status: success + message: MySQL package and instance directories installation completed + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - name: Notify configure started + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: configure + status: running + message: MySQL configuration and initialization started + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - 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 mysql_topology_value == 'standalone' else + ('primary' if ansible_play_hosts_all.index(inventory_hostname) == 0 else + ('replica' if mysql_topology_value == '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: Link instance configuration into system path + ansible.builtin.file: + src: "{{ mysql_config_file }}" + dest: "{{ mysql_system_config_file }}" + state: link + force: true + + - 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: Create per-instance systemd override directory + ansible.builtin.file: + path: "/etc/systemd/system/mysql-delivery@{{ mysql_instance }}.service.d" + state: directory + owner: root + group: root + mode: '0755' + + - name: Write per-instance systemd resource limits + ansible.builtin.copy: + dest: "/etc/systemd/system/mysql-delivery@{{ mysql_instance }}.service.d/resources.conf" + owner: root + group: root + mode: '0644' + content: | + [Service] + CPUQuota={{ mysql_cpu_cores_value | int * 100 }}% + register: mysql_systemd_resources + notify: Restart MySQL delivery instance + + - name: Reload systemd units + ansible.builtin.systemd_service: + daemon_reload: true + when: mysql_systemd_unit.changed or mysql_systemd_resources.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)" + sql_file="$(mktemp)" + trap 'rm -f "$client_file" "$sql_file"' EXIT + chmod 600 "$client_file" "$sql_file" + cat >"$client_file" <<'EOF' + [client] + user=root + password={{ mysql_root_password_value }} + socket={{ mysql_run_dir }}/mysql.sock + EOF + if ! /usr/bin/mysql --defaults-extra-file="$client_file" -e 'SELECT 1' >/dev/null 2>&1; then + cat >"$sql_file" <<'EOF' + ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ mysql_root_password_sql }}'; + EOF + /usr/bin/mysql --protocol=socket --socket={{ mysql_run_dir }}/mysql.sock -uroot <"$sql_file" + fi + cat >"$sql_file" <<'EOF' + CREATE USER IF NOT EXISTS 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_sql }}'; + ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_sql }}'; + GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; + FLUSH PRIVILEGES; + EOF + /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" + args: + executable: /bin/bash + changed_when: false + no_log: true + + - name: Notify configure completed + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: configure + status: success + message: MySQL configuration and initialization completed + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - name: Notify healthcheck started + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: healthcheck + status: running + message: MySQL TCP health check started + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_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 + + - name: Notify healthcheck completed + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: healthcheck + status: success + message: MySQL TCP health check completed + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + - name: Notify platform registration handoff + ansible.builtin.uri: + url: "{{ delivery_callback_url_value }}" + method: POST + headers: + Authorization: "Bearer {{ delivery_callback_token_value }}" + Content-Type: application/json + body_format: json + body: + stage: register + status: running + message: MySQL deployment finished, waiting for platform asset registration + awx_job_id: "{{ delivery_awx_job_id }}" + status_code: [200, 202] + when: delivery_callback_enabled | bool + failed_when: false + no_log: true + + handlers: + - name: Restart MySQL delivery instance + ansible.builtin.systemd_service: + name: "mysql-delivery@{{ mysql_instance }}.service" + state: restarted