Merge remote-tracking branch 'upstream/main' into feat/base-service-delivery

# Conflicts:
#	server/.env.example
#	server/internal/config/config.go
#	server/internal/service/delivery.go
#	server/internal/service/delivery_test.go
This commit is contained in:
mac
2026-07-28 09:34:37 +08:00
11 changed files with 786 additions and 602 deletions
+208 -64
View File
@@ -1,82 +1,186 @@
---
- name: Preflight native MySQL delivery
- 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": "mysql-server=8.0.46-0ubuntu0.24.04.3"
mysql_package_name: "{{ mysql_package_map[mysql_version_value] }}"
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 }}"
"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: "/data/mysql-delivery/{{ mysql_instance }}/data"
mysql_log_dir: "/data/mysql-delivery/{{ mysql_instance }}/log"
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 (injected via environment) ---
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
- name: Validate delivery parameters
ansible.builtin.assert:
that:
- topology == 'standalone'
- 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_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_data_disk is match('^/')
- (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: The first machine prototype only supports safe standalone parameters
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 == 1
fail_msg: The selected topology does not match the target host count
- (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: 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:
- name: Probe target-host port occupancy (SQL + GR)
# cmd 字典形式不经过 free-form split_args 解析,避免引号/Jinja 块导致的解析失败。
ansible.builtin.shell:
cmd: |
set -o pipefail
for p in {{ mysql_probe_ports | join(' ') }}; do
if ss -lntH "sport = :${p}" | 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 ${p} already in use on target host" >&2
exit 3
fi
fi
done
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
- 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: Check available memory and disk
- name: Resolve data-disk mountpoint available space
ansible.builtin.set_fact:
mysql_mount_avail: >-
{{ (ansible_mounts | selectattr('mount', 'equalto', mysql_data_disk)
| map(attribute='size_available') | first)
| default(ansible_mounts | selectattr('mount', 'equalto', '/')
| map(attribute='size_available') | first) }}
- name: Check available memory and data-disk space
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
- (mysql_mount_avail | 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.
tasks:
- name: Detect an existing Ubuntu MySQL package
ansible.builtin.command: dpkg-query -W mysql-server
- 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: Install Ubuntu MySQL package
- 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
@@ -88,14 +192,14 @@
name: mysql.service
state: stopped
enabled: false
when: mysql_package_before.rc != 0
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: Allow the delivery data and run paths in the 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
@@ -103,8 +207,8 @@
mode: '0644'
content: |
# Managed by XINFRA MySQL delivery - grant per-instance native paths
/data/mysql-delivery/ r,
/data/mysql-delivery/** rwk,
{{ 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
@@ -115,7 +219,7 @@
when: mysql_apparmor_profile.stat.exists and mysql_apparmor_local.changed
changed_when: true
- name: Create instance directories
- name: Create instance directories (mount-point-agnostic layout)
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
@@ -125,9 +229,12 @@
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_run_dir }}", owner: mysql, group: mysql, mode: '0755' }
- { path: "{{ mysql_binlog_dir }}", owner: mysql, group: mysql, mode: '0750' }
- { path: "{{ mysql_redo_dir }}", owner: mysql, group: mysql, mode: '0750' }
- { path: "{{ mysql_tmp_dir }}", owner: mysql, group: mysql, mode: '0750' }
- name: Link native binaries into the instance directory
ansible.builtin.file:
@@ -146,6 +253,33 @@
{{ '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)
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:
@@ -193,32 +327,33 @@
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
# 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'
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"
args:
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
@@ -229,6 +364,15 @@
port: "{{ mysql_port_value }}"
timeout: 30
- 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
handlers:
- name: Restart MySQL delivery instance
ansible.builtin.systemd_service:
+58 -11
View File
@@ -1,35 +1,82 @@
# Managed by XINFRA MySQL delivery - generated, do not edit by hand
# instance={{ mysql_instance }} version={{ mysql_version_value }} topology={{ topology }}
[client]
socket={{ mysql_run_dir }}/mysql.sock
port={{ mysql_port_value }}
[mysqld]
user=mysql
basedir=/usr
datadir={{ mysql_data_dir }}
tmpdir={{ mysql_tmp_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 }}
skip-name-resolve=ON
server-id={{ mysql_server_id_value }}
local-infile=OFF
# --- charset / collation / timezone / identifier case ---
character-set-server={{ mysql_character_set }}
collation-server={{ mysql_collation }}
default-time-zone={{ mysql_timezone }}
lower-case-table-names={{ mysql_lower_case_table_names }}
# --- error / slow log ---
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
long-query-time={{ mysql_long_query_time }}
# --- binlog (PITR + replication base) ---
log-bin={{ mysql_binlog_dir }}/binlog
binlog-format=ROW
sync-binlog={{ mysql_sync_binlog }}
max-binlog-size={{ mysql_max_binlog_size }}
binlog-expire-logs-seconds={{ mysql_binlog_expire_logs_seconds }}
{% if topology != 'standalone' %}
gtid-mode=ON
enforce-gtid-consistency=ON
relay-log={{ mysql_log_dir }}/relay-bin
relay-log={{ mysql_binlog_dir }}/relay-bin
{% endif %}
# --- InnoDB core ---
innodb-buffer-pool-size={{ ((mysql_memory_mb_value | int) * 55 / 100) | int }}M
innodb-flush-method=O_DIRECT
innodb-flush-log-at-trx-commit={{ mysql_flush_log_at_trx_commit }}
innodb-io-capacity={{ mysql_io_capacity }}
max-connections={{ mysql_max_connections }}
# --- redo log (version-sensitive) ---
innodb-log-group-home-dir={{ mysql_redo_dir }}
{% if mysql_version_value in ['8.0', '8.4'] %}
innodb-redo-log-capacity={{ mysql_redo_capacity }}
{% else %}
innodb-log-file-size={{ mysql_redo_capacity }}
{% endif %}
# --- X Protocol disabled (version-sensitive) ---
{% if mysql_version_value in ['8.0', '8.4'] %}
mysqlx=0
{% elif mysql_version_value == '5.7' %}
loose-mysqlx=0
{% endif %}
{% if topology == 'mgr_3' %}
# --- Group Replication (config-ready; runtime bootstrap handled out-of-band) ---
plugin-load-add=group_replication.so
{% if mysql_version_value != '8.4' %}
{# deprecated since 8.0.26 and removed in 8.3+; only inject for older series #}
transaction-write-set-extraction=XXHASH64
{% endif %}
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-local-address={{ ansible_host | default(inventory_hostname) }}:{{ mysql_gr_port_value }}
loose-group-replication-group-seeds={% for host in ansible_play_hosts_all %}{{ hostvars[host].ansible_host | default(host) }}:{{ mysql_gr_port_value }}{% 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 %}