--- - name: Native MySQL delivery hosts: "{{ target_hosts }}" become: true gather_facts: true any_errors_fatal: true vars: # --- identity --- mysql_instance: "{{ instance_name }}" mysql_version_value: "{{ mysql_version | default('8.0') }}" # 版本包映射:8.0 用 Ubuntu 24.04 自带源(精确锁版);8.4 用 MySQL 官方 APT 源的 LTS 组件 # (noble 自带源无 8.4,官方源组件内即为该系列,不再锁小版本)。 # 5.6/5.7 已官方 EOL 且 Ubuntu 24.04 无可用 apt 包,明确不纳入白名单。 mysql_package_map: "8.0": package: "mysql-server=8.0.46-0ubuntu0.24.04.3" repo_component: "" "8.4": package: "mysql-community-server" repo_component: "mysql-8.4-lts" mysql_package_name: "{{ (mysql_package_map[mysql_version_value] | default({})).package | default('') }}" mysql_repo_component: "{{ (mysql_package_map[mysql_version_value] | default({})).repo_component | default('') }}" # --- platform-allocated inputs (safe fallbacks when not passed in) --- # 端口池 13306–13999:平台从池分配并传入;未传时兜底池首端口,占用探测在目标机执行。 mysql_port_value: "{{ mysql_port | default(13306) | int }}" # GR 组通信端口:仅 mgr_3 使用,平台成对分配;兜底为 SQL 端口 +10000。 mysql_gr_port_value: "{{ gr_port | default((mysql_port | default(13306) | int) + 10000) | int }}" # 数据盘挂载点:平台按白名单选定并传入;兜底 /data。 mysql_data_disk: "{{ data_disk | default('/data') }}" # --- resource quota (Go→AWX 契约单位保持 MB/GB,不擅改) --- mysql_memory_mb_value: "{{ memory_mb | default(4096) | int }}" mysql_storage_gb_value: "{{ storage_gb | default(50) | int }}" # --- mount-point-agnostic layout: {data_disk}/mysql-delivery/{instance_id}/... --- 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_tmp_dir: "{{ mysql_base_dir }}/tmp" # socket/pid 走 /run tmpfs(重启自动清理),由 systemd RuntimeDirectory 创建。 mysql_run_dir: "/run/mysql-delivery-{{ mysql_instance }}" mysql_config_file: "/etc/mysql/mysql-delivery/{{ mysql_instance }}.cnf" # --- database config (user form, with doc default baselines) --- mysql_timezone: "{{ timezone | default('+08:00') }}" mysql_lower_case_table_names: "{{ lower_case_table_names | default(1) | int }}" mysql_character_set: "{{ character_set | default('utf8mb4') }}" mysql_collation: "{{ collation | default('utf8mb4_general_ci') }}" # --- advanced params (overridable; default baseline assumes SSD for io_capacity) --- mysql_flush_log_at_trx_commit: "{{ innodb_flush_log_at_trx_commit | default(1) | int }}" mysql_sync_binlog: "{{ sync_binlog | default(1) | int }}" mysql_io_capacity: "{{ innodb_io_capacity | default(2000) | int }}" mysql_long_query_time: "{{ long_query_time | default(1) }}" mysql_binlog_expire_logs_seconds: "{{ binlog_expire_logs_seconds | default(604800) | int }}" mysql_max_binlog_size: "{{ max_binlog_size | default('256M') }}" mgr_group_name: "{{ group_replication_group_name | default('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') }}" # --- expected node count per topology --- mysql_expected_hosts: "{{ {'standalone': 1, 'primary_replica': 2, 'mgr_3': 3}[topology | default('standalone')] }}" # --- secrets (prefer launch extra_vars; fall back to AWX credential-injected env) --- mysql_root_password_value: "{{ mysql_root_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ROOT_PASSWORD'), true) }}" mysql_admin_password_value: "{{ mysql_admin_password | default(lookup('ansible.builtin.env', 'XINFRA_MYSQL_ADMIN_PASSWORD'), true) }}" # --- platform callback --- 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('') }}" 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 delivery parameters ansible.builtin.assert: that: - topology in ['standalone', 'primary_replica', 'mgr_3'] - mysql_instance is match('^[a-z0-9][a-z0-9-]{0,62}$') - mysql_version_value in mysql_package_map - mysql_data_disk is match('^/.+') - mysql_data_disk != '/' - "'..' not in mysql_data_disk" - "'//' not in mysql_data_disk" - (mysql_port_value | int) >= 13306 - (mysql_port_value | int) <= 13999 - (mysql_memory_mb_value | int) >= 2048 - (mysql_memory_mb_value | int) <= 65536 - (mysql_storage_gb_value | int) >= 20 - (mysql_storage_gb_value | int) <= 2000 - (mysql_lower_case_table_names | int) in [0, 1] - mysql_root_password_value | length >= 16 - mysql_admin_password_value | length >= 16 fail_msg: >- Delivery parameters out of the supported target-state whitelist (topology / version-package-map / port pool 13306-13999 / memory 2-64G / storage 20-2000G). quiet: true no_log: true - name: Validate topology host count ansible.builtin.assert: that: - (ansible_play_hosts_all | length | int) == (mysql_expected_hosts | int) fail_msg: "topology={{ topology }} expects {{ mysql_expected_hosts }} host(s), got {{ ansible_play_hosts_all | length }}" run_once: true - name: Probe target-host port occupancy (SQL + GR) # cmd 字典形式不经过 free-form split_args 解析,避免引号/Jinja 块导致的解析失败。 ansible.builtin.shell: cmd: | set -o pipefail {% for port in mysql_probe_ports %} if ss -lntH "sport = :{{ port }}" | grep -q .; then # already listening: only tolerated when owned by this instance service if ! systemctl is-active --quiet "mysql-delivery@{{ mysql_instance }}.service"; then echo "port {{ port }} already in use on target host" >&2 exit 3 fi fi {% endfor %} executable: /bin/bash vars: mysql_probe_ports: "{{ [mysql_port_value, mysql_gr_port_value] if topology == 'mgr_3' else [mysql_port_value] }}" changed_when: false - name: Read currently available memory (point-in-time guard) ansible.builtin.shell: awk '/^MemAvailable:/ { print int($2 / 1024) }' /proc/meminfo args: executable: /bin/bash register: mysql_available_memory changed_when: false - name: Read available bytes from the backing filesystem ansible.builtin.shell: cmd: | set -euo pipefail candidate="{{ mysql_data_disk }}" while [ ! -e "$candidate" ] && [ "$candidate" != "/" ]; do candidate="$(dirname "$candidate")" done df -P -B1 "$candidate" | awk 'NR == 2 { print $4 }' executable: /bin/bash register: mysql_available_disk_bytes changed_when: false - name: Check available memory and data-disk space ansible.builtin.assert: that: - (mysql_available_memory.stdout | int) >= (mysql_memory_mb_value | int) - (mysql_available_disk_bytes.stdout | int) >= (mysql_storage_gb_value | int) * 1073741824 fail_msg: >- Target host lacks memory or free space on {{ mysql_data_disk }}; host-wide Σ-quota budgeting is the platform's responsibility, this is a last-resort guard. - 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 MySQL server package ansible.builtin.command: dpkg-query -W -f '${Package}=${Version}\n' mysql-server mysql-community-server register: mysql_package_before failed_when: false changed_when: false - name: Resolve the installed MySQL server version ansible.builtin.set_fact: mysql_installed_version: >- {{ (mysql_package_before.stdout_lines | select('search', '=') | list | first | default('')).split('=') | last }} # /usr 下的 mysqld/mysql 二进制全机共享,一台主机只能承载一个 MySQL 版本系列。 - name: Enforce host-level MySQL series consistency ansible.builtin.assert: that: - mysql_installed_version == '' or mysql_installed_version.startswith(mysql_version_value ~ '.') fail_msg: >- Host already runs MySQL {{ mysql_installed_version }} but {{ mysql_version_value }} was requested; native binaries under /usr are shared host-wide, so one host serves exactly one MySQL series. - name: Install the MySQL APT repository signing key ansible.builtin.get_url: url: https://repo.mysql.com/RPM-GPG-KEY-mysql-2023 dest: /etc/apt/keyrings/mysql.asc owner: root group: root mode: '0644' when: mysql_repo_component != '' - name: Configure the MySQL APT repository component ansible.builtin.apt_repository: repo: >- deb [signed-by=/etc/apt/keyrings/mysql.asc] http://repo.mysql.com/apt/ubuntu {{ ansible_distribution_release }} {{ mysql_repo_component }} filename: xinfra-mysql-delivery state: present when: mysql_repo_component != '' - name: Install the MySQL server 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_installed_version == '' - name: Check for the bundled MySQL AppArmor profile ansible.builtin.stat: path: /etc/apparmor.d/usr.sbin.mysqld register: mysql_apparmor_profile - name: Authorize the delivery 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, /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 (mount-point-agnostic layout) 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_disk }}", 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' } - 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 topology == 'standalone' else ('primary' if ansible_play_hosts_all.index(inventory_hostname) == 0 else ('replica' if topology == 'primary_replica' else 'mgr')) }} # host-wide unique: platform may pass explicit mysql_server_id; otherwise derive # port + node index (ports are unique per host in the pool model). mysql_server_id_value: >- {{ mysql_server_id | default((mysql_port_value | int) + (ansible_play_hosts_all.index(inventory_hostname))) | int }} - name: Resolve memory tier (GB) ansible.builtin.set_fact: mysql_memory_gb: "{{ ((mysql_memory_mb_value | int) // 1024) | int }}" - name: Resolve linkage-derived defaults (illustrative tiers, pending hardware calibration) ansible.builtin.set_fact: mysql_max_connections: >- {{ (max_connections | int) if (max_connections is defined and (max_connections | string) != 'auto') else (200 if (mysql_memory_gb | int) <= 2 else 500 if (mysql_memory_gb | int) <= 4 else 1000 if (mysql_memory_gb | int) <= 8 else 2000 if (mysql_memory_gb | int) <= 16 else 4000 if (mysql_memory_gb | int) <= 32 else 8000 if (mysql_memory_gb | int) <= 64 else 16000) }} mysql_redo_capacity: >- {{ innodb_redo_log_capacity if (innodb_redo_log_capacity is defined and (innodb_redo_log_capacity | string) != 'auto') else ('128M' if (mysql_memory_gb | int) <= 4 else '256M' if (mysql_memory_gb | int) <= 16 else '512M' if (mysql_memory_gb | int) <= 32 else '1G') }} - 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" - 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 # cmd 字典形式不经过 free-form split_args 解析,heredoc SQL 中的奇数个单引号才不会报错。 ansible.builtin.shell: cmd: | 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_value | replace("'", "''") }}'; 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_value | replace("'", "''") }}'; ALTER USER 'xinfra_admin'@'%' IDENTIFIED BY '{{ mysql_admin_password_value | replace("'", "''") }}'; GRANT ALL PRIVILEGES ON *.* TO 'xinfra_admin'@'%' WITH GRANT OPTION; FLUSH PRIVILEGES; EOF /usr/bin/mysql --defaults-extra-file="$client_file" <"$sql_file" 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: Note pending HA runtime orchestration ansible.builtin.debug: msg: >- topology={{ topology }} deployed with HA-ready config (GTID/binlog/relay/GR settings in place), but replication wiring (CHANGE REPLICATION SOURCE) and Group Replication bootstrap (START GROUP_REPLICATION) are not yet automated — nodes start config-ready only. when: topology != 'standalone' run_once: 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